@cueloop/schema 0.1.0-alpha.3 → 0.1.0-alpha.30

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.
@@ -13,7 +13,7 @@ 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 ann(partial: Partial<Annotation> & { anchor: Annotation["anchor"] }): Annotation {
16
+ function makeAnnotation(partial: Partial<Annotation> & { anchor: Annotation["anchor"] }): Annotation {
17
17
  return {
18
18
  id: "a1",
19
19
  kind: "comment",
@@ -25,7 +25,8 @@ function ann(partial: Partial<Annotation> & { anchor: Annotation["anchor"] }): A
25
25
 
26
26
  describe("renderFeedback", () => {
27
27
  test("plan edits section carries the one unified diff, directive framing first", () => {
28
- const out = renderFeedback({
28
+ // Act
29
+ const feedback = renderFeedback({
29
30
  verdictKind: "request_changes",
30
31
  summary: "Tighten the storage section.",
31
32
  artifactContent: PLAN,
@@ -33,83 +34,126 @@ describe("renderFeedback", () => {
33
34
  annotations: [],
34
35
  planPath: "docs/plan.md",
35
36
  });
36
- expect(out).toContain("# Review: request changes");
37
- expect(out).toContain("Tighten the storage section.");
38
- expect(out).toContain("## Plan edits");
39
- expect(out).toContain("Apply this exact diff first");
40
- expect(out).toContain("--- a/docs/plan.md");
41
- expect(out).toContain("-Sessions are written as one JSON document per session.");
42
- expect(out).toContain("+Sessions are written as one JSON record per session.");
37
+
38
+ // Assert
39
+ expect(feedback).toContain("# Review: request changes");
40
+ expect(feedback).toContain("Tighten the storage section.");
41
+ expect(feedback).toContain("## Plan edits");
42
+ expect(feedback).toContain("Apply this exact diff first");
43
+ expect(feedback).toContain("--- a/docs/plan.md");
44
+ expect(feedback).toContain("-Sessions are written as one JSON document per session.");
45
+ expect(feedback).toContain("+Sessions are written as one JSON record per session.");
43
46
  });
44
47
 
45
48
  test("annotations are located by quote and section", () => {
46
- const out = renderFeedback({
49
+ // Act
50
+ const feedback = renderFeedback({
47
51
  verdictKind: "comment",
48
52
  summary: "",
49
53
  artifactContent: PLAN,
50
54
  annotations: [
51
- ann({
55
+ makeAnnotation({
52
56
  anchor: { quote: "daemon memory", prefix: "live only in ", suffix: " today." },
53
57
  }),
54
58
  ],
55
59
  });
56
- expect(out).toContain("## Annotations (1)");
57
- expect(out).toContain("### 1. Comment (§ Context)");
58
- expect(out).toContain("> daemon memory");
59
- expect(out).toContain("Please clarify.");
60
+
61
+ // 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.");
60
66
  });
61
67
 
62
68
  test("suggestions render as replace/with pairs", () => {
63
- const out = renderFeedback({
69
+ // Act
70
+ const feedback = renderFeedback({
64
71
  verdictKind: "request_changes",
65
72
  summary: "",
66
73
  artifactContent: PLAN,
67
74
  annotations: [
68
- ann({
75
+ makeAnnotation({
69
76
  kind: "suggestion",
70
77
  body: "one durable JSON record",
71
78
  anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." },
72
79
  }),
73
80
  ],
74
81
  });
75
- expect(out).toContain("### 1. Suggested change (§ Storage)");
76
- expect(out).toContain("Replace:\n> one JSON document");
77
- expect(out).toContain("With:\n> one durable JSON record");
82
+
83
+ // 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");
78
87
  });
79
88
 
80
89
  test("orphaned anchors are flagged, never dropped", () => {
81
- const out = renderFeedback({
90
+ // Act
91
+ const feedback = renderFeedback({
82
92
  verdictKind: "comment",
83
93
  summary: "",
84
94
  artifactContent: PLAN,
85
- annotations: [ann({ anchor: { quote: "text that is gone", prefix: "", suffix: "" } })],
95
+ annotations: [makeAnnotation({ anchor: { quote: "text that is gone", prefix: "", suffix: "" } })],
86
96
  });
87
- expect(out).toContain("[orphaned anchor: the quoted text is no longer present]");
88
- expect(out).toContain("> text that is gone");
97
+
98
+ // Assert
99
+ expect(feedback).toContain("[orphaned anchor: the quoted text is no longer present]");
100
+ expect(feedback).toContain("> text that is gone");
89
101
  });
90
102
 
91
103
  test("empty review says so explicitly", () => {
92
- const out = renderFeedback({
104
+ // Act
105
+ const feedback = renderFeedback({
93
106
  verdictKind: "approve",
94
107
  summary: "",
95
108
  artifactContent: PLAN,
96
109
  annotations: [],
97
110
  });
98
- expect(out).toContain("_No edits or annotations._");
111
+
112
+ // Assert
113
+ expect(feedback).toContain("_No edits or annotations._");
114
+ });
115
+
116
+ test("agent notes never come back as feedback items", () => {
117
+ // Arrange
118
+ const note = makeAnnotation({
119
+ kind: "note",
120
+ anchor: { quote: "src/a.ts", prefix: "", suffix: "" },
121
+ body: "The agent's own explanation of the change.",
122
+ });
123
+
124
+ // Act
125
+ const noteOnly = renderFeedback({ verdictKind: "approve", summary: "", artifactContent: PLAN, annotations: [note] });
126
+
127
+ // Assert
128
+ expect(noteOnly).toContain("_No edits or annotations._");
129
+ expect(noteOnly).not.toContain("The agent's own explanation of the change.");
130
+ const mixed = renderFeedback({
131
+ verdictKind: "request_changes",
132
+ summary: "",
133
+ artifactContent: PLAN,
134
+ annotations: [note, makeAnnotation({ anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." } })],
135
+ });
136
+ expect(mixed).toContain("## Annotations (1)");
137
+ expect(mixed).not.toContain("The agent's own explanation of the change.");
99
138
  });
100
139
 
101
140
  test("anchors resolve against the working copy when present", () => {
141
+ // Arrange
102
142
  // annotation made on text that only exists in the edited copy
103
143
  const working = PLAN.replace("## Storage", "## Persistence");
104
- const out = renderFeedback({
144
+
145
+ // Act
146
+ const feedback = renderFeedback({
105
147
  verdictKind: "request_changes",
106
148
  summary: "",
107
149
  artifactContent: PLAN,
108
150
  workingCopy: working,
109
151
  annotations: [
110
- ann({ anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." } }),
152
+ makeAnnotation({ anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." } }),
111
153
  ],
112
154
  });
113
- expect(out).toContain("(§ Persistence)");
155
+
156
+ // Assert
157
+ expect(feedback).toContain("(§ Persistence)");
114
158
  });
115
159
  });
package/src/feedback.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * feedback.md: the ONE structured markdown document the agent receives on
3
- * resolve (map #2, verified in the edit-mode deep-dive). Directive framing -
4
- * soft phrasing gets ignored by models: the plan diff is applied verbatim
5
- * first, then every annotation is addressed, located by quoted text.
3
+ * resolve. Directive framing - soft phrasing gets ignored by models: the plan
4
+ * diff is applied verbatim first, then every annotation is addressed, located
5
+ * by quoted text.
6
6
  */
7
7
 
8
- import type { Annotation, ReviewSession, VerdictKind } from "./types";
8
+ import { isAddressed, isAgentNote, type Annotation, type ReviewSession, type VerdictKind } from "./types";
9
9
  import { parseBlocks, sectionOf } from "./markdown";
10
10
  import { resolveAnchor } from "./anchor";
11
11
  import { unifiedDiffText } from "./diff";
@@ -20,12 +20,18 @@ export interface FeedbackInput {
20
20
  annotations: Annotation[];
21
21
  /** Path the agent knows the plan by, for direct reference. */
22
22
  planPath?: string;
23
+ /** Session id, so the document can teach the addressed-ids resubmit call. */
24
+ sessionId?: string;
23
25
  }
24
26
 
25
- const quoteLines = (s: string) => "> " + s.replace(/\n/g, "\n> ");
27
+ const quoteLines = (text: string) => "> " + text.replace(/\n/g, "\n> ");
26
28
 
27
29
  export function renderFeedback(input: FeedbackInput): string {
28
30
  const path = input.planPath ?? "plan.md";
31
+ // agent notes are the submitter's own context - never echoed back as
32
+ // feedback - and annotations a previous revision already addressed stay out
33
+ // of the next document, so the agent only ever sees the open items
34
+ const annotations = input.annotations.filter((annotation) => !isAgentNote(annotation) && !isAddressed(annotation));
29
35
  const blocks = parseBlocks(input.workingCopy ?? input.artifactContent);
30
36
  const lines: string[] = [];
31
37
  lines.push("# Review: " + input.verdictKind.replace("_", " "));
@@ -49,35 +55,48 @@ export function renderFeedback(input: FeedbackInput): string {
49
55
  lines.push("");
50
56
  }
51
57
 
52
- if (input.annotations.length) {
53
- lines.push(`## Annotations (${input.annotations.length})`);
58
+ if (annotations.length) {
59
+ lines.push(`## Annotations (${annotations.length})`);
54
60
  lines.push("");
55
61
  lines.push(`Address every item. Locate each one in ${path} by its quoted text.`);
56
62
  lines.push("");
57
- input.annotations.forEach((a, i) => {
58
- const res = resolveAnchor(a.anchor, blocks);
59
- const sec = res ? sectionOf(blocks, res.blockIndex) : "";
60
- const loc = sec ? ` (§ ${sec})` : "";
61
- const flag = res === null ? " [orphaned anchor: the quoted text is no longer present]" : "";
62
- if (a.kind === "suggestion") {
63
- lines.push(`### ${i + 1}. Suggested change${loc}${flag}`);
63
+ annotations.forEach((annotation, annotationIndex) => {
64
+ const resolved = resolveAnchor(annotation.anchor, blocks);
65
+ const sectionTitle = resolved ? sectionOf(blocks, resolved.blockIndex) : "";
66
+ 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}`);
64
70
  lines.push("");
65
71
  lines.push("Replace:");
66
- lines.push(quoteLines(a.anchor.quote));
72
+ lines.push(quoteLines(annotation.anchor.quote));
67
73
  lines.push("With:");
68
- lines.push(quoteLines(a.body));
74
+ lines.push(quoteLines(annotation.body));
69
75
  } else {
70
- lines.push(`### ${i + 1}. ${capitalize(a.kind)}${loc}${flag}`);
76
+ lines.push(`### ${annotationIndex + 1}. ${capitalize(annotation.kind)}${location}${orphanFlag}`);
71
77
  lines.push("");
72
- lines.push(quoteLines(a.anchor.quote));
78
+ lines.push(quoteLines(annotation.anchor.quote));
73
79
  lines.push("");
74
- lines.push(a.body);
80
+ lines.push(annotation.body);
75
81
  }
76
82
  lines.push("");
83
+ lines.push(`annotation id: \`${annotation.id}\``);
84
+ lines.push("");
77
85
  });
86
+ if (input.sessionId) {
87
+ lines.push("## Reporting what you addressed");
88
+ lines.push("");
89
+ lines.push("When you resubmit the revised plan, list the annotation ids you acted on -");
90
+ lines.push("they are marked addressed for the reviewer and leave the open list:");
91
+ lines.push("");
92
+ lines.push("```sh");
93
+ lines.push(`cueloop session submit-revision ${input.sessionId} --content-file ${path} --addressed <id,id,...>`);
94
+ lines.push("```");
95
+ lines.push("");
96
+ }
78
97
  }
79
98
 
80
- if (!diff && !input.annotations.length) {
99
+ if (!diff && !annotations.length) {
81
100
  lines.push("_No edits or annotations._");
82
101
  lines.push("");
83
102
  }
@@ -92,9 +111,10 @@ export function feedbackForSession(session: ReviewSession, verdictKind: VerdictK
92
111
  workingCopy: session.workingCopy,
93
112
  annotations: session.annotations,
94
113
  planPath: session.artifact.meta.planPath,
114
+ sessionId: session.id,
95
115
  });
96
116
  }
97
117
 
98
- function capitalize(s: string): string {
99
- return s.charAt(0).toUpperCase() + s.slice(1);
118
+ function capitalize(word: string): string {
119
+ return word.charAt(0).toUpperCase() + word.slice(1);
100
120
  }
@@ -0,0 +1,40 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { detectHerdr, insideHerdr, returnPaneFor } from "./herdr";
3
+
4
+ describe("detectHerdr", () => {
5
+ test("null outside herdr, context inside", () => {
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({
9
+ paneId: "p1",
10
+ binPath: "/x/herdr",
11
+ });
12
+ });
13
+ });
14
+
15
+ describe("insideHerdr", () => {
16
+ test("true only when HERDR_ENV is exactly 1", () => {
17
+ expect(insideHerdr({})).toBeFalse();
18
+ expect(insideHerdr({ HERDR_ENV: "0" })).toBeFalse();
19
+ expect(insideHerdr({ HERDR_ENV: "1" })).toBeTrue();
20
+ });
21
+ });
22
+
23
+ describe("returnPaneFor", () => {
24
+ test("outside herdr there is never a return target", () => {
25
+ expect(returnPaneFor("w1:p1", {})).toBeUndefined();
26
+ });
27
+
28
+ test("env override wins, session meta is the fallback", () => {
29
+ // Arrange
30
+ const inside = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p9" };
31
+
32
+ // Assert
33
+ expect(returnPaneFor("w1:p1", inside)).toBe("w1:p1");
34
+ expect(returnPaneFor("w1:p1", { ...inside, CUELOOP_RETURN_PANE: "w1:p2" })).toBe("w1:p2");
35
+ });
36
+
37
+ test("returning to our own pane is a no-op", () => {
38
+ expect(returnPaneFor("w1:p1", { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1" })).toBeUndefined();
39
+ });
40
+ });
package/src/herdr.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
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.
8
+ */
9
+
10
+ export interface HerdrContext {
11
+ paneId: string;
12
+ binPath: string;
13
+ }
14
+
15
+ export type HerdrEnv = Record<string, string | undefined>;
16
+
17
+ /**
18
+ * The pane's full contract - pane id and binary path - or null outside a
19
+ * functioning herdr. Required by anything that spawns the herdr CLI.
20
+ */
21
+ export function detectHerdr(env: HerdrEnv = process.env): HerdrContext | null {
22
+ if (env.HERDR_ENV !== "1") return null;
23
+ const paneId = env.HERDR_PANE_ID;
24
+ const binPath = env.HERDR_BIN_PATH;
25
+ if (!paneId || !binPath) return null;
26
+ return { paneId, binPath };
27
+ }
28
+
29
+ /** True whenever this process runs inside a herdr pane. */
30
+ export function insideHerdr(env: HerdrEnv = process.env): boolean {
31
+ return env.HERDR_ENV === "1";
32
+ }
33
+
34
+ /**
35
+ * The pane a closing review should hand focus back to: the opener's
36
+ * CUELOOP_RETURN_PANE, or the session's recorded pane, but never this pane
37
+ * itself. Undefined outside herdr or when the only candidate is our own pane.
38
+ */
39
+ export function returnPaneFor(sessionHerdrPane?: string, env: HerdrEnv = process.env): string | undefined {
40
+ if (!insideHerdr(env)) return undefined;
41
+ const pane = env.CUELOOP_RETURN_PANE ?? sessionHerdrPane;
42
+ return pane && pane !== env.HERDR_PANE_ID ? pane : undefined;
43
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export * from "./types";
2
2
  export * from "./markdown";
3
+ export * from "./working-copy";
3
4
  export * from "./diff";
4
5
  export * from "./anchor";
5
6
  export * from "./feedback";
7
+ export * from "./herdr";
@@ -29,8 +29,11 @@ const y = 2;
29
29
 
30
30
  describe("parseBlocks", () => {
31
31
  test("parses every block kind with source line ranges", () => {
32
+ // Act
32
33
  const blocks = parseBlocks(SAMPLE);
33
- expect(blocks.map((b) => b.kind)).toEqual([
34
+
35
+ // Assert
36
+ expect(blocks.map((block) => block.kind)).toEqual([
34
37
  "h1",
35
38
  "h2",
36
39
  "p",
@@ -43,7 +46,7 @@ describe("parseBlocks", () => {
43
46
  "quote",
44
47
  "hr",
45
48
  ]);
46
- const code = blocks.find((b) => b.kind === "code")!;
49
+ const code = blocks.find((block) => block.kind === "code")!;
47
50
  expect(code.lang).toBe("ts");
48
51
  expect(code.text).toBe("const x = 1;\nconst y = 2;");
49
52
  // fence lines included in the source range
@@ -51,50 +54,69 @@ describe("parseBlocks", () => {
51
54
  });
52
55
 
53
56
  test("multi-line paragraph keeps its text and range", () => {
57
+ // Arrange
54
58
  const blocks = parseBlocks(SAMPLE);
55
- const p = blocks.find((b) => b.kind === "p")!;
56
- expect(p.text).toBe("A paragraph that\nspans two lines.");
57
- expect(p.lineEnd - p.lineStart).toBe(1);
59
+ const paragraph = blocks.find((block) => block.kind === "p")!;
60
+
61
+ // Assert
62
+ expect(paragraph.text).toBe("A paragraph that\nspans two lines.");
63
+ expect(paragraph.lineEnd - paragraph.lineStart).toBe(1);
58
64
  });
59
65
 
60
66
  test("line ranges index into the source", () => {
67
+ // Arrange
61
68
  const lines = SAMPLE.split("\n");
62
- for (const b of parseBlocks(SAMPLE)) {
63
- if (b.kind === "code") {
64
- expect(lines[b.lineStart]!.startsWith("```")).toBe(true);
65
- } else if (b.kind === "h2") {
66
- expect(lines[b.lineStart]!.startsWith("## ")).toBe(true);
69
+
70
+ // Assert
71
+ for (const block of parseBlocks(SAMPLE)) {
72
+ if (block.kind === "code") {
73
+ expect(lines[block.lineStart]!.startsWith("```")).toBe(true);
74
+ } else if (block.kind === "h2") {
75
+ expect(lines[block.lineStart]!.startsWith("## ")).toBe(true);
67
76
  }
68
77
  }
69
78
  });
70
79
 
71
80
  test("round-trips through blockToMd", () => {
81
+ // Arrange
72
82
  const blocks = parseBlocks(SAMPLE);
73
- let oli = 0;
83
+ let orderedItemCount = 0;
74
84
  const rebuilt = blocks
75
- .map((b) => {
76
- oli = b.kind === "oli" ? oli + 1 : 0;
77
- return blockToMd(b, oli || 1);
85
+ .map((block) => {
86
+ orderedItemCount = block.kind === "oli" ? orderedItemCount + 1 : 0;
87
+ return blockToMd(block, orderedItemCount || 1);
78
88
  })
79
89
  .join("\n\n");
90
+
91
+ // Act
80
92
  // re-parsing the rebuild yields the same kinds and texts
81
93
  const again = parseBlocks(rebuilt);
82
- expect(again.map((b) => [b.kind, b.text])).toEqual(blocks.map((b) => [b.kind, b.text]));
94
+
95
+ // Assert
96
+ expect(again.map((block) => [block.kind, block.text])).toEqual(blocks.map((block) => [block.kind, block.text]));
83
97
  });
84
98
 
85
99
  test("unknown constructs degrade to paragraphs, no content lost", () => {
100
+ // Arrange
86
101
  const md = "| a | b |\n|---|---|\n| 1 | 2 |";
102
+
103
+ // Act
87
104
  const blocks = parseBlocks(md);
88
- expect(blocks.map((b) => b.text).join("\n")).toContain("| a | b |");
105
+
106
+ // Assert
107
+ expect(blocks.map((block) => block.text).join("\n")).toContain("| a | b |");
89
108
  });
90
109
  });
91
110
 
92
111
  describe("sectionOf", () => {
93
112
  test("returns the nearest preceding heading", () => {
113
+ // Arrange
94
114
  const blocks = parseBlocks(SAMPLE);
95
- const oliIdx = blocks.findIndex((b) => b.kind === "oli");
96
- expect(sectionOf(blocks, oliIdx)).toBe("Steps");
97
- const pIdx = blocks.findIndex((b) => b.kind === "p");
98
- expect(sectionOf(blocks, pIdx)).toBe("Context");
115
+
116
+ // Assert
117
+ const orderedItemIndex = blocks.findIndex((block) => block.kind === "oli");
118
+ expect(sectionOf(blocks, orderedItemIndex)).toBe("Steps");
119
+ const paragraphIndex = blocks.findIndex((block) => block.kind === "p");
120
+ expect(sectionOf(blocks, paragraphIndex)).toBe("Context");
99
121
  });
100
122
  });