@cueloop/schema 0.1.0-alpha.2 → 0.1.0-alpha.20

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/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 { 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";
@@ -22,10 +22,12 @@ export interface FeedbackInput {
22
22
  planPath?: string;
23
23
  }
24
24
 
25
- const quoteLines = (s: string) => "> " + s.replace(/\n/g, "\n> ");
25
+ const quoteLines = (text: string) => "> " + text.replace(/\n/g, "\n> ");
26
26
 
27
27
  export function renderFeedback(input: FeedbackInput): string {
28
28
  const path = input.planPath ?? "plan.md";
29
+ // agent notes are the submitter's own context - never echoed back as feedback
30
+ const annotations = input.annotations.filter((annotation) => !isAgentNote(annotation));
29
31
  const blocks = parseBlocks(input.workingCopy ?? input.artifactContent);
30
32
  const lines: string[] = [];
31
33
  lines.push("# Review: " + input.verdictKind.replace("_", " "));
@@ -49,35 +51,35 @@ export function renderFeedback(input: FeedbackInput): string {
49
51
  lines.push("");
50
52
  }
51
53
 
52
- if (input.annotations.length) {
53
- lines.push(`## Annotations (${input.annotations.length})`);
54
+ if (annotations.length) {
55
+ lines.push(`## Annotations (${annotations.length})`);
54
56
  lines.push("");
55
57
  lines.push(`Address every item. Locate each one in ${path} by its quoted text.`);
56
58
  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}`);
59
+ annotations.forEach((annotation, annotationIndex) => {
60
+ const resolved = resolveAnchor(annotation.anchor, blocks);
61
+ const sectionTitle = resolved ? sectionOf(blocks, resolved.blockIndex) : "";
62
+ const location = sectionTitle ? ` (§ ${sectionTitle})` : "";
63
+ const orphanFlag = resolved === null ? " [orphaned anchor: the quoted text is no longer present]" : "";
64
+ if (annotation.kind === "suggestion") {
65
+ lines.push(`### ${annotationIndex + 1}. Suggested change${location}${orphanFlag}`);
64
66
  lines.push("");
65
67
  lines.push("Replace:");
66
- lines.push(quoteLines(a.anchor.quote));
68
+ lines.push(quoteLines(annotation.anchor.quote));
67
69
  lines.push("With:");
68
- lines.push(quoteLines(a.body));
70
+ lines.push(quoteLines(annotation.body));
69
71
  } else {
70
- lines.push(`### ${i + 1}. ${capitalize(a.kind)}${loc}${flag}`);
72
+ lines.push(`### ${annotationIndex + 1}. ${capitalize(annotation.kind)}${location}${orphanFlag}`);
71
73
  lines.push("");
72
- lines.push(quoteLines(a.anchor.quote));
74
+ lines.push(quoteLines(annotation.anchor.quote));
73
75
  lines.push("");
74
- lines.push(a.body);
76
+ lines.push(annotation.body);
75
77
  }
76
78
  lines.push("");
77
79
  });
78
80
  }
79
81
 
80
- if (!diff && !input.annotations.length) {
82
+ if (!diff && !annotations.length) {
81
83
  lines.push("_No edits or annotations._");
82
84
  lines.push("");
83
85
  }
@@ -95,6 +97,6 @@ export function feedbackForSession(session: ReviewSession, verdictKind: VerdictK
95
97
  });
96
98
  }
97
99
 
98
- function capitalize(s: string): string {
99
- return s.charAt(0).toUpperCase() + s.slice(1);
100
+ function capitalize(word: string): string {
101
+ return word.charAt(0).toUpperCase() + word.slice(1);
100
102
  }
@@ -0,0 +1,37 @@
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
+ const inside = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p9" };
30
+ expect(returnPaneFor("w1:p1", inside)).toBe("w1:p1");
31
+ expect(returnPaneFor("w1:p1", { ...inside, CUELOOP_RETURN_PANE: "w1:p2" })).toBe("w1:p2");
32
+ });
33
+
34
+ test("returning to our own pane is a no-op", () => {
35
+ expect(returnPaneFor("w1:p1", { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1" })).toBeUndefined();
36
+ });
37
+ });
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";
@@ -30,7 +30,7 @@ const y = 2;
30
30
  describe("parseBlocks", () => {
31
31
  test("parses every block kind with source line ranges", () => {
32
32
  const blocks = parseBlocks(SAMPLE);
33
- expect(blocks.map((b) => b.kind)).toEqual([
33
+ expect(blocks.map((block) => block.kind)).toEqual([
34
34
  "h1",
35
35
  "h2",
36
36
  "p",
@@ -43,7 +43,7 @@ describe("parseBlocks", () => {
43
43
  "quote",
44
44
  "hr",
45
45
  ]);
46
- const code = blocks.find((b) => b.kind === "code")!;
46
+ const code = blocks.find((block) => block.kind === "code")!;
47
47
  expect(code.lang).toBe("ts");
48
48
  expect(code.text).toBe("const x = 1;\nconst y = 2;");
49
49
  // fence lines included in the source range
@@ -52,49 +52,49 @@ describe("parseBlocks", () => {
52
52
 
53
53
  test("multi-line paragraph keeps its text and range", () => {
54
54
  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);
55
+ const paragraph = blocks.find((block) => block.kind === "p")!;
56
+ expect(paragraph.text).toBe("A paragraph that\nspans two lines.");
57
+ expect(paragraph.lineEnd - paragraph.lineStart).toBe(1);
58
58
  });
59
59
 
60
60
  test("line ranges index into the source", () => {
61
61
  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);
62
+ for (const block of parseBlocks(SAMPLE)) {
63
+ if (block.kind === "code") {
64
+ expect(lines[block.lineStart]!.startsWith("```")).toBe(true);
65
+ } else if (block.kind === "h2") {
66
+ expect(lines[block.lineStart]!.startsWith("## ")).toBe(true);
67
67
  }
68
68
  }
69
69
  });
70
70
 
71
71
  test("round-trips through blockToMd", () => {
72
72
  const blocks = parseBlocks(SAMPLE);
73
- let oli = 0;
73
+ let orderedItemCount = 0;
74
74
  const rebuilt = blocks
75
- .map((b) => {
76
- oli = b.kind === "oli" ? oli + 1 : 0;
77
- return blockToMd(b, oli || 1);
75
+ .map((block) => {
76
+ orderedItemCount = block.kind === "oli" ? orderedItemCount + 1 : 0;
77
+ return blockToMd(block, orderedItemCount || 1);
78
78
  })
79
79
  .join("\n\n");
80
80
  // re-parsing the rebuild yields the same kinds and texts
81
81
  const again = parseBlocks(rebuilt);
82
- expect(again.map((b) => [b.kind, b.text])).toEqual(blocks.map((b) => [b.kind, b.text]));
82
+ expect(again.map((block) => [block.kind, block.text])).toEqual(blocks.map((block) => [block.kind, block.text]));
83
83
  });
84
84
 
85
85
  test("unknown constructs degrade to paragraphs, no content lost", () => {
86
86
  const md = "| a | b |\n|---|---|\n| 1 | 2 |";
87
87
  const blocks = parseBlocks(md);
88
- expect(blocks.map((b) => b.text).join("\n")).toContain("| a | b |");
88
+ expect(blocks.map((block) => block.text).join("\n")).toContain("| a | b |");
89
89
  });
90
90
  });
91
91
 
92
92
  describe("sectionOf", () => {
93
93
  test("returns the nearest preceding heading", () => {
94
94
  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");
95
+ const orderedItemIndex = blocks.findIndex((block) => block.kind === "oli");
96
+ expect(sectionOf(blocks, orderedItemIndex)).toBe("Steps");
97
+ const paragraphIndex = blocks.findIndex((block) => block.kind === "p");
98
+ expect(sectionOf(blocks, paragraphIndex)).toBe("Context");
99
99
  });
100
100
  });
package/src/markdown.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Markdown block model (map #22): parse source into addressable blocks that
3
- * record their source line ranges. Rendered→source mapping is block+line
2
+ * Markdown block model: parse source into addressable blocks that record
3
+ * their source line ranges. Rendered-to-source mapping is block+line
4
4
  * granular; the quote carries sub-block precision. Round-trip safe for the
5
5
  * subset cueloop plans use; unknown constructs fall back to paragraph blocks
6
6
  * so no content is ever lost.
@@ -19,113 +19,113 @@ export interface Block {
19
19
  lineEnd: number;
20
20
  }
21
21
 
22
- function isMarkerLine(l: string): boolean {
22
+ function isMarkerLine(line: string): boolean {
23
23
  return (
24
- l.startsWith("```") ||
25
- l.startsWith("# ") ||
26
- l.startsWith("## ") ||
27
- l.startsWith("### ") ||
28
- l.startsWith("> ") ||
29
- /^- /.test(l) ||
30
- /^\d+\. /.test(l) ||
31
- /^(---|\*\*\*|___)\s*$/.test(l)
24
+ line.startsWith("```") ||
25
+ line.startsWith("# ") ||
26
+ line.startsWith("## ") ||
27
+ line.startsWith("### ") ||
28
+ line.startsWith("> ") ||
29
+ /^- /.test(line) ||
30
+ /^\d+\. /.test(line) ||
31
+ /^(---|\*\*\*|___)\s*$/.test(line)
32
32
  );
33
33
  }
34
34
 
35
- export function parseBlocks(md: string): Block[] {
36
- const lines = md.split("\n");
35
+ export function parseBlocks(markdown: string): Block[] {
36
+ const lines = markdown.split("\n");
37
37
  const blocks: Block[] = [];
38
- let i = 0;
39
- while (i < lines.length) {
40
- const l = lines[i]!;
41
- if (l.trim() === "") {
42
- i++;
38
+ let lineIndex = 0;
39
+ while (lineIndex < lines.length) {
40
+ const line = lines[lineIndex]!;
41
+ if (line.trim() === "") {
42
+ lineIndex++;
43
43
  continue;
44
44
  }
45
- if (l.startsWith("```")) {
46
- const start = i;
47
- const lang = l.slice(3).trim() || undefined;
48
- i++;
45
+ if (line.startsWith("```")) {
46
+ const start = lineIndex;
47
+ const lang = line.slice(3).trim() || undefined;
48
+ lineIndex++;
49
49
  const body: string[] = [];
50
- while (i < lines.length && !lines[i]!.startsWith("```")) {
51
- body.push(lines[i]!);
52
- i++;
50
+ while (lineIndex < lines.length && !lines[lineIndex]!.startsWith("```")) {
51
+ body.push(lines[lineIndex]!);
52
+ lineIndex++;
53
53
  }
54
- const end = Math.min(i, lines.length - 1);
55
- i++;
54
+ const end = Math.min(lineIndex, lines.length - 1);
55
+ lineIndex++;
56
56
  blocks.push({ kind: "code", text: body.join("\n"), lang, lineStart: start, lineEnd: end });
57
- } else if (l.startsWith("### ")) {
58
- blocks.push({ kind: "h3", text: l.slice(4), lineStart: i, lineEnd: i });
59
- i++;
60
- } else if (l.startsWith("## ")) {
61
- blocks.push({ kind: "h2", text: l.slice(3), lineStart: i, lineEnd: i });
62
- i++;
63
- } else if (l.startsWith("# ")) {
64
- blocks.push({ kind: "h1", text: l.slice(2), lineStart: i, lineEnd: i });
65
- i++;
66
- } else if (/^(---|\*\*\*|___)\s*$/.test(l)) {
67
- blocks.push({ kind: "hr", text: "", lineStart: i, lineEnd: i });
68
- i++;
69
- } else if (l.startsWith("> ")) {
70
- const start = i;
57
+ } else if (line.startsWith("### ")) {
58
+ blocks.push({ kind: "h3", text: line.slice(4), lineStart: lineIndex, lineEnd: lineIndex });
59
+ lineIndex++;
60
+ } else if (line.startsWith("## ")) {
61
+ blocks.push({ kind: "h2", text: line.slice(3), lineStart: lineIndex, lineEnd: lineIndex });
62
+ lineIndex++;
63
+ } else if (line.startsWith("# ")) {
64
+ blocks.push({ kind: "h1", text: line.slice(2), lineStart: lineIndex, lineEnd: lineIndex });
65
+ lineIndex++;
66
+ } else if (/^(---|\*\*\*|___)\s*$/.test(line)) {
67
+ blocks.push({ kind: "hr", text: "", lineStart: lineIndex, lineEnd: lineIndex });
68
+ lineIndex++;
69
+ } else if (line.startsWith("> ")) {
70
+ const start = lineIndex;
71
71
  const body: string[] = [];
72
- while (i < lines.length && lines[i]!.startsWith("> ")) {
73
- body.push(lines[i]!.slice(2));
74
- i++;
72
+ while (lineIndex < lines.length && lines[lineIndex]!.startsWith("> ")) {
73
+ body.push(lines[lineIndex]!.slice(2));
74
+ lineIndex++;
75
75
  }
76
- blocks.push({ kind: "quote", text: body.join("\n"), lineStart: start, lineEnd: i - 1 });
77
- } else if (/^- /.test(l)) {
78
- blocks.push({ kind: "li", text: l.slice(2), lineStart: i, lineEnd: i });
79
- i++;
80
- } else if (/^\d+\. /.test(l)) {
81
- blocks.push({ kind: "oli", text: l.replace(/^\d+\. /, ""), lineStart: i, lineEnd: i });
82
- i++;
76
+ blocks.push({ kind: "quote", text: body.join("\n"), lineStart: start, lineEnd: lineIndex - 1 });
77
+ } else if (/^- /.test(line)) {
78
+ blocks.push({ kind: "li", text: line.slice(2), lineStart: lineIndex, lineEnd: lineIndex });
79
+ lineIndex++;
80
+ } else if (/^\d+\. /.test(line)) {
81
+ blocks.push({ kind: "oli", text: line.replace(/^\d+\. /, ""), lineStart: lineIndex, lineEnd: lineIndex });
82
+ lineIndex++;
83
83
  } else {
84
- const start = i;
84
+ const start = lineIndex;
85
85
  const body: string[] = [];
86
- while (i < lines.length && lines[i]!.trim() !== "" && !isMarkerLine(lines[i]!)) {
87
- body.push(lines[i]!);
88
- i++;
86
+ while (lineIndex < lines.length && lines[lineIndex]!.trim() !== "" && !isMarkerLine(lines[lineIndex]!)) {
87
+ body.push(lines[lineIndex]!);
88
+ lineIndex++;
89
89
  }
90
- blocks.push({ kind: "p", text: body.join("\n"), lineStart: start, lineEnd: i - 1 });
90
+ blocks.push({ kind: "p", text: body.join("\n"), lineStart: start, lineEnd: lineIndex - 1 });
91
91
  }
92
92
  }
93
93
  return blocks;
94
94
  }
95
95
 
96
96
  /** Serialize one block back to its markdown chunk. */
97
- export function blockToMd(b: Block, ordinal = 1): string {
98
- switch (b.kind) {
97
+ export function blockToMd(block: Block, ordinal = 1): string {
98
+ switch (block.kind) {
99
99
  case "h1":
100
- return "# " + b.text;
100
+ return "# " + block.text;
101
101
  case "h2":
102
- return "## " + b.text;
102
+ return "## " + block.text;
103
103
  case "h3":
104
- return "### " + b.text;
104
+ return "### " + block.text;
105
105
  case "li":
106
- return "- " + b.text;
106
+ return "- " + block.text;
107
107
  case "oli":
108
- return `${ordinal}. ` + b.text;
108
+ return `${ordinal}. ` + block.text;
109
109
  case "quote":
110
- return b.text
110
+ return block.text
111
111
  .split("\n")
112
- .map((l) => "> " + l)
112
+ .map((line) => "> " + line)
113
113
  .join("\n");
114
114
  case "code":
115
- return "```" + (b.lang ?? "") + "\n" + b.text + "\n```";
115
+ return "```" + (block.lang ?? "") + "\n" + block.text + "\n```";
116
116
  case "hr":
117
117
  return "---";
118
118
  default:
119
- return b.text;
119
+ return block.text;
120
120
  }
121
121
  }
122
122
 
123
123
  /** The section (nearest preceding heading) a block belongs to. */
124
124
  export function sectionOf(blocks: Block[], index: number): string {
125
- let sec = "";
126
- for (let i = 0; i <= index && i < blocks.length; i++) {
127
- const b = blocks[i]!;
128
- if (b.kind === "h1" || b.kind === "h2" || b.kind === "h3") sec = b.text;
125
+ let sectionTitle = "";
126
+ for (let blockIndex = 0; blockIndex <= index && blockIndex < blocks.length; blockIndex++) {
127
+ const block = blocks[blockIndex]!;
128
+ if (block.kind === "h1" || block.kind === "h2" || block.kind === "h3") sectionTitle = block.text;
129
129
  }
130
- return sec;
130
+ return sectionTitle;
131
131
  }
@@ -0,0 +1,18 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { newAnnotationId, verdictAllows } from "./types";
3
+
4
+ describe("verdictAllows", () => {
5
+ test("only approve maps to allow", () => {
6
+ expect(verdictAllows("approve")).toBe(true);
7
+ expect(verdictAllows("comment")).toBe(false);
8
+ expect(verdictAllows("request_changes")).toBe(false);
9
+ });
10
+ });
11
+
12
+ describe("newAnnotationId", () => {
13
+ test("unique by construction within a process, even on one millisecond", () => {
14
+ const ids = new Set(Array.from({ length: 10_000 }, () => newAnnotationId()));
15
+ expect(ids.size).toBe(10_000);
16
+ for (const id of ids) expect(id).toMatch(/^a_[0-9a-z]+_[0-9a-z]+$/);
17
+ });
18
+ });
package/src/types.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
- * The ReviewSession primitive (map #2, #9). Everything in cueloop renders,
3
- * annotates, or resolves this one noun. This module is pure data shapes -
4
- * no IO, no dependencies.
2
+ * The ReviewSession primitive. Everything in cueloop renders, annotates, or
3
+ * resolves this one noun. This module is pure data shapes - no IO, no
4
+ * dependencies.
5
5
  */
6
6
 
7
7
  export const SCHEMA_VERSION = "1";
@@ -23,6 +23,8 @@ export interface ArtifactMeta {
23
23
  planPath?: string;
24
24
  /** Pull request reference the diff came from, so the verdict can be posted back. */
25
25
  pr?: string;
26
+ /** herdr pane the submitting agent runs in - the review returns focus there. */
27
+ herdrPane?: string;
26
28
  title?: string;
27
29
  }
28
30
 
@@ -49,9 +51,19 @@ export interface Anchor {
49
51
  end?: number;
50
52
  }
51
53
 
52
- /** The annotation kind set is open (map #2); these are the built-ins. */
54
+ /** The annotation kind set is open; these are the built-ins. */
53
55
  export type AnnotationKind = "comment" | "suggestion" | (string & {});
54
56
 
57
+ /**
58
+ * Agent-authored context, not reviewer feedback: the guided walk's per-file
59
+ * notes (kind "note", anchored by the file path). Excluded from the feedback
60
+ * document and the reviewer's pending counts - an agent must never receive
61
+ * its own notes back as items to address.
62
+ */
63
+ export function isAgentNote(annotation: Pick<Annotation, "kind">): boolean {
64
+ return annotation.kind === "note";
65
+ }
66
+
55
67
  export interface Annotation {
56
68
  id: string;
57
69
  kind: AnnotationKind;
@@ -95,6 +107,12 @@ export interface ReviewSession {
95
107
  * Undefined = no direct edits.
96
108
  */
97
109
  workingCopy?: string;
110
+ /**
111
+ * File paths the reviewer marked viewed during the guided walk (diff
112
+ * sessions). Persisting with the session means a resumed review keeps its
113
+ * progress. Undefined = the walk never started.
114
+ */
115
+ viewedPaths?: string[];
98
116
  verdict: Verdict | null;
99
117
  status: SessionStatus;
100
118
  createdAt: string;
@@ -104,3 +122,13 @@ export interface ReviewSession {
104
122
  export function verdictAllows(kind: VerdictKind): boolean {
105
123
  return kind === "approve";
106
124
  }
125
+
126
+ /**
127
+ * Annotation ids are minted client-side. The counter makes ids unique within
128
+ * a process by construction; the random suffix separates concurrent
129
+ * annotators in different processes on the same millisecond.
130
+ */
131
+ let annotationSeq = 0;
132
+ export function newAnnotationId(): string {
133
+ return `a_${Date.now().toString(36)}${(annotationSeq++).toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
134
+ }
@@ -0,0 +1,109 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { cutBlock, restoreBlock, restoreLine, sourceChunk } from "./working-copy";
3
+ import { parseBlocks, type Block } from "./markdown";
4
+
5
+ const BASE = `# Plan
6
+
7
+ ## Context
8
+
9
+ A paragraph that
10
+ spans two lines.
11
+
12
+ - first item
13
+ - second item
14
+
15
+ \`\`\`ts
16
+ const x = 1;
17
+ const y = 2;
18
+ \`\`\`
19
+ `;
20
+
21
+ function block(markdown: string, text: string): Block {
22
+ return parseBlocks(markdown).find((block) => block.text.includes(text))!;
23
+ }
24
+
25
+ describe("sourceChunk", () => {
26
+ test("returns the exact source lines a block occupies", () => {
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.");
29
+ });
30
+
31
+ 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```");
33
+ });
34
+ });
35
+
36
+ describe("cutBlock", () => {
37
+ test("removes the block lines and the blank line before it", () => {
38
+ const cut = cutBlock(BASE, block(BASE, "spans two lines"));
39
+ expect(cut).not.toContain("spans two lines");
40
+ expect(cut).toContain("## Context\n\n- first item");
41
+ });
42
+
43
+ test("cut at the start of the document leaves the leading blank line", () => {
44
+ const cut = cutBlock(BASE, block(BASE, "Plan"));
45
+ expect(cut).not.toContain("# Plan");
46
+ expect(cut.trimStart().startsWith("## Context")).toBe(true);
47
+ });
48
+
49
+ test("cut at the end of the document keeps the rest intact", () => {
50
+ const cut = cutBlock(BASE, block(BASE, "const x"));
51
+ expect(cut).not.toContain("const x");
52
+ expect(cut).toContain("- second item");
53
+ });
54
+ });
55
+
56
+ describe("restoreBlock", () => {
57
+ test("restoring the only cut returns undefined - back to pristine", () => {
58
+ const listItem = block(BASE, "second item");
59
+ const cut = cutBlock(BASE, listItem);
60
+ // the cut block re-enters before the code fence, the next surviving block
61
+ const next = parseBlocks(cut).find((candidate) => candidate.kind === "code");
62
+ const restored = restoreBlock(BASE, cut, listItem, restoreLine(next, cut.split("\n").length));
63
+ expect(restored).toBeUndefined();
64
+ });
65
+
66
+ test("restore after another edit returns the merged working copy", () => {
67
+ const listItem = block(BASE, "second item");
68
+ const edited = cutBlock(BASE, listItem).replace("first item", "first item reworded");
69
+ const next = parseBlocks(edited).find((candidate) => candidate.kind === "code");
70
+ const restored = restoreBlock(BASE, edited, listItem, restoreLine(next, edited.split("\n").length));
71
+ expect(restored).toContain("- second item");
72
+ expect(restored).toContain("first item reworded");
73
+ });
74
+
75
+ test("restore at the end of the document (no next block)", () => {
76
+ const code = block(BASE, "const x");
77
+ const cut = cutBlock(BASE, code);
78
+ const restored = restoreBlock(BASE, cut, code, restoreLine(undefined, cut.split("\n").length));
79
+ expect(restored).toBeUndefined();
80
+ });
81
+
82
+ test("restore a multi-line block round-trips to pristine", () => {
83
+ const paragraph = block(BASE, "spans two lines");
84
+ const cut = cutBlock(BASE, paragraph);
85
+ const next = parseBlocks(cut).find((block) => block.text === "first item");
86
+ const restored = restoreBlock(BASE, cut, paragraph, restoreLine(next, cut.split("\n").length));
87
+ expect(restored).toBeUndefined();
88
+ });
89
+
90
+ test("signature rule ignores blank-line layout but not text changes", () => {
91
+ const listItem = block(BASE, "second item");
92
+ const cut = cutBlock(BASE, listItem);
93
+ const next = parseBlocks(cut).find((candidate) => candidate.kind === "code");
94
+ const line = restoreLine(next, cut.split("\n").length);
95
+ // extra blank lines elsewhere do not block pristine detection
96
+ expect(restoreBlock(BASE, cut.replace("## Context", "## Context\n"), listItem, line)).toBeUndefined();
97
+ // a real text change keeps the working copy alive
98
+ const changed = restoreBlock(BASE, cut.replace("first", "1st"), listItem, line);
99
+ expect(changed).toContain("1st item");
100
+ });
101
+ });
102
+
103
+ describe("restoreLine", () => {
104
+ test("next surviving block's start, or the line count at the end", () => {
105
+ const listItem = block(BASE, "first item");
106
+ expect(restoreLine(listItem, 99)).toBe(listItem.lineStart);
107
+ expect(restoreLine(undefined, 99)).toBe(99);
108
+ });
109
+ });