@cueloop/schema 0.1.0-alpha.0

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 ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@cueloop/schema",
3
+ "version": "0.1.0-alpha.0",
4
+ "license": "Apache-2.0",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "README.md"
15
+ ],
16
+ "engines": {
17
+ "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
+ }
@@ -0,0 +1,94 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { parseBlocks } from "./markdown";
3
+ import { makeAnchor, resolveAnchor } from "./anchor";
4
+
5
+ const DOC = `# Plan
6
+
7
+ ## Context
8
+
9
+ Review sessions currently live only in daemon memory. If the daemon crashes,
10
+ every pending annotation is lost.
11
+
12
+ ## Storage
13
+
14
+ Sessions are written as one JSON document per session. Sessions are written
15
+ atomically.
16
+ `;
17
+
18
+ function blocksOf(md: string) {
19
+ return parseBlocks(md);
20
+ }
21
+
22
+ describe("anchor cascade", () => {
23
+ test("makeAnchor captures quote + context selectors", () => {
24
+ const blocks = blocksOf(DOC);
25
+ const ctxIdx = blocks.findIndex((b) => b.text.startsWith("Review sessions"));
26
+ const a = makeAnchor(blocks, ctxIdx, 0, 15); // "Review sessions"
27
+ expect(a.quote).toBe("Review sessions");
28
+ expect(a.prefix).toBe("");
29
+ expect(a.suffix.startsWith(" currently")).toBe(true);
30
+ });
31
+
32
+ test("resolves at the recorded position when unchanged", () => {
33
+ const blocks = blocksOf(DOC);
34
+ const ctxIdx = blocks.findIndex((b) => b.text.startsWith("Review sessions"));
35
+ const a = makeAnchor(blocks, ctxIdx, 7, 15); // "sessions"
36
+ const r = resolveAnchor(a, blocks)!;
37
+ expect(r.blockIndex).toBe(ctxIdx);
38
+ expect(r.start).toBe(7);
39
+ expect(r.approximate).toBe(false);
40
+ });
41
+
42
+ test("survives text changes before the quote (quote search)", () => {
43
+ const blocks = blocksOf(DOC);
44
+ const ctxIdx = blocks.findIndex((b) => b.text.startsWith("Review sessions"));
45
+ const a = makeAnchor(blocks, ctxIdx, 0, 15);
46
+ const edited = blocksOf(DOC.replace("Review sessions currently", "PREFIX. Review sessions now"));
47
+ const r = resolveAnchor(a, edited)!;
48
+ const idx = edited.findIndex((b) => b.text.includes("Review sessions"));
49
+ expect(r.blockIndex).toBe(idx);
50
+ expect(edited[r.blockIndex]!.text.slice(r.start, r.end)).toBe("Review sessions");
51
+ });
52
+
53
+ test("prefix/suffix selectors disambiguate repeated quotes", () => {
54
+ const blocks = blocksOf(DOC);
55
+ const storIdx = blocks.findIndex((b) => b.text.includes("atomically"));
56
+ // anchor on the SECOND "Sessions are written" occurrence
57
+ const text = blocks[storIdx]!.text;
58
+ const second = text.indexOf("Sessions are written", text.indexOf("Sessions are written") + 1);
59
+ const a = makeAnchor(blocks, storIdx, second, second + "Sessions are written".length);
60
+ const r = resolveAnchor(a, blocks)!;
61
+ expect(r.start).toBe(second);
62
+ });
63
+
64
+ test("position hint breaks ties when context also repeats", () => {
65
+ const md = "word word word";
66
+ const blocks = blocksOf(md);
67
+ const a = makeAnchor(blocks, 0, 5, 9); // middle "word"
68
+ const r = resolveAnchor(a, blocks)!;
69
+ expect(r.start).toBe(5);
70
+ });
71
+
72
+ test("trimmed-quote fallback marks the resolution approximate", () => {
73
+ const blocks = blocksOf("some text here");
74
+ const a = { quote: " text ", prefix: "some", suffix: "here", blockIndex: 0, start: 4, end: 10 };
75
+ const edited = blocksOf("some\ntext\nhere");
76
+ // " text " (with spaces) is gone, "text" survives
77
+ const r = resolveAnchor(a, edited);
78
+ expect(r).not.toBeNull();
79
+ expect(r!.approximate).toBe(true);
80
+ });
81
+
82
+ test("orphans when the quote is gone entirely", () => {
83
+ const blocks = blocksOf(DOC);
84
+ const ctxIdx = blocks.findIndex((b) => b.text.startsWith("Review sessions"));
85
+ const a = makeAnchor(blocks, ctxIdx, 0, 15);
86
+ const edited = blocksOf(DOC.replace(/Review sessions[^\n]*\n[^\n]*/, "Completely different text."));
87
+ expect(resolveAnchor(a, edited)).toBeNull();
88
+ });
89
+
90
+ test("empty quote never binds", () => {
91
+ const blocks = blocksOf(DOC);
92
+ expect(resolveAnchor({ quote: "", prefix: "", suffix: "" }, blocks)).toBeNull();
93
+ });
94
+ });
package/src/anchor.ts ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Anchor resolution: the quote-primary cascade (map #2, #22, verified in the
3
+ * edit-mode deep-dive). Anchors resolve against the current block list:
4
+ * 1. exact quote in a block (prefix/suffix selectors break ties,
5
+ * position hint breaks remaining ties)
6
+ * 2. trimmed quote
7
+ * 3. orphan - keep the quote for display, drop the highlight.
8
+ * Never silently bind to the wrong text.
9
+ */
10
+
11
+ import type { Anchor } from "./types";
12
+ import type { Block } from "./markdown";
13
+
14
+ const CONTEXT = 24;
15
+
16
+ export interface ResolvedAnchor {
17
+ blockIndex: number;
18
+ start: number;
19
+ end: number;
20
+ /** True when the quote had to be trimmed to bind. */
21
+ approximate: boolean;
22
+ }
23
+
24
+ export function makeAnchor(blocks: Block[], blockIndex: number, start: number, end: number): Anchor {
25
+ const t = blocks[blockIndex]?.text ?? "";
26
+ return {
27
+ quote: t.slice(start, end),
28
+ prefix: t.slice(Math.max(0, start - CONTEXT), start),
29
+ suffix: t.slice(end, end + CONTEXT),
30
+ blockIndex,
31
+ start,
32
+ end,
33
+ };
34
+ }
35
+
36
+ interface Candidate extends ResolvedAnchor {
37
+ score: number;
38
+ }
39
+
40
+ function findCandidates(anchor: Anchor, blocks: Block[], quote: string): Candidate[] {
41
+ const out: Candidate[] = [];
42
+ blocks.forEach((b, blockIndex) => {
43
+ const text = b.text;
44
+ for (let i = text.indexOf(quote); i !== -1; i = text.indexOf(quote, i + 1)) {
45
+ const pre = text.slice(Math.max(0, i - CONTEXT), i);
46
+ const suf = text.slice(i + quote.length, i + quote.length + CONTEXT);
47
+ let score = 0;
48
+ if (pre === anchor.prefix) score += 2;
49
+ if (suf === anchor.suffix) score += 2;
50
+ if (anchor.blockIndex === blockIndex) score += 1;
51
+ if (anchor.blockIndex === blockIndex && anchor.start === i) score += 1;
52
+ out.push({ blockIndex, start: i, end: i + quote.length, approximate: false, score });
53
+ }
54
+ });
55
+ return out;
56
+ }
57
+
58
+ /** Resolve an anchor against the current blocks, or null when orphaned. */
59
+ export function resolveAnchor(anchor: Anchor, blocks: Block[]): ResolvedAnchor | null {
60
+ if (anchor.quote === "") return null;
61
+ let candidates = findCandidates(anchor, blocks, anchor.quote);
62
+ let approximate = false;
63
+ if (candidates.length === 0) {
64
+ const trimmed = anchor.quote.trim();
65
+ if (trimmed !== "" && trimmed !== anchor.quote) {
66
+ candidates = findCandidates(anchor, blocks, trimmed);
67
+ approximate = true;
68
+ }
69
+ }
70
+ if (candidates.length === 0) return null;
71
+ candidates.sort((a, b) => b.score - a.score);
72
+ const best = candidates[0]!;
73
+ return { ...best, approximate };
74
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { lcsDiff, unifiedDiff, unifiedDiffText, editStats, wordDiff } from "./diff";
3
+
4
+ describe("unifiedDiff", () => {
5
+ test("equal inputs return null, not an empty diff", () => {
6
+ expect(unifiedDiff("a\nb", "a\nb")).toBeNull();
7
+ });
8
+
9
+ test("produces hunk headers with correct line numbers", () => {
10
+ const oldText = ["l1", "l2", "l3", "l4", "l5", "l6", "l7", "l8"].join("\n");
11
+ const newText = ["l1", "l2", "l3", "CHANGED", "l5", "l6", "l7", "l8"].join("\n");
12
+ const d = unifiedDiff(oldText, newText, 1)!;
13
+ expect(d[0]!.t).toBe("hunk");
14
+ expect(d[0]!.text).toBe("@@ -3,3 +3,3 @@");
15
+ expect(d.map((l) => l.text)).toEqual([
16
+ "@@ -3,3 +3,3 @@",
17
+ " l3",
18
+ "-l4",
19
+ "+CHANGED",
20
+ " l5",
21
+ ]);
22
+ });
23
+
24
+ test("distant changes split into separate hunks", () => {
25
+ const oldLines = Array.from({ length: 30 }, (_, i) => `line ${i + 1}`);
26
+ const newLines = [...oldLines];
27
+ newLines[1] = "first change";
28
+ newLines[27] = "second change";
29
+ const d = unifiedDiff(oldLines.join("\n"), newLines.join("\n"), 2)!;
30
+ expect(d.filter((l) => l.t === "hunk").length).toBe(2);
31
+ });
32
+
33
+ test("applies cleanly: reconstructing new text from old + diff", () => {
34
+ const oldText = "keep\ndrop\nkeep2\nkeep3";
35
+ const newText = "keep\nadded\nkeep2\nkeep3\ntail";
36
+ const d = unifiedDiff(oldText, newText, 50)!;
37
+ const rebuilt: string[] = [];
38
+ for (const l of d) {
39
+ if (l.t === "hunk") continue;
40
+ if (l.t === "ctx" || l.t === "add") rebuilt.push(l.text.slice(1));
41
+ }
42
+ expect(rebuilt.join("\n")).toBe(newText);
43
+ });
44
+
45
+ test("unifiedDiffText carries file headers and path", () => {
46
+ const t = unifiedDiffText("a", "b", "docs/plan.md")!;
47
+ expect(t.startsWith("--- a/docs/plan.md\n+++ b/docs/plan.md\n@@")).toBe(true);
48
+ });
49
+ });
50
+
51
+ describe("editStats", () => {
52
+ test("counts added and removed lines", () => {
53
+ expect(editStats("a\nb\nc", "a\nx\nc\nd")).toEqual({ added: 2, removed: 1 });
54
+ });
55
+ });
56
+
57
+ describe("wordDiff", () => {
58
+ test("preserves whitespace tokens and reconstructs both sides", () => {
59
+ const ops = wordDiff("the quick brown fox", "the slow brown foxes");
60
+ const oldSide = ops.filter((o) => o.t !== "add").map((o) => o.a).join("");
61
+ const newSide = ops.filter((o) => o.t !== "del").map((o) => o.b).join("");
62
+ expect(oldSide).toBe("the quick brown fox");
63
+ expect(newSide).toBe("the slow brown foxes");
64
+ });
65
+ });
66
+
67
+ describe("lcsDiff", () => {
68
+ test("custom equality", () => {
69
+ const ops = lcsDiff([{ v: 1 }, { v: 2 }], [{ v: 2 }, { v: 3 }], (x, y) => x.v === y.v);
70
+ expect(ops.map((o) => o.t)).toEqual(["del", "ctx", "add"]);
71
+ });
72
+ });
package/src/diff.ts ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Line/token diffing and unified-diff serialization. The working copy
3
+ * serializes as ONE unified diff against the submitted revision (map #2).
4
+ * Pure algorithms, no IO.
5
+ */
6
+
7
+ export type DiffOpType = "ctx" | "del" | "add";
8
+
9
+ export interface DiffOp<T = string> {
10
+ t: DiffOpType;
11
+ /** Present for ctx and del. */
12
+ a?: T;
13
+ /** Present for ctx and add. */
14
+ b?: T;
15
+ }
16
+
17
+ /** LCS diff over arrays. O(n*m) - fine for plan-sized documents. */
18
+ export function lcsDiff<T>(a: T[], b: T[], eq: (x: T, y: T) => boolean = (x, y) => x === y): DiffOp<T>[] {
19
+ const n = a.length;
20
+ const m = b.length;
21
+ const dp: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
22
+ for (let i = n - 1; i >= 0; i--) {
23
+ for (let j = m - 1; j >= 0; j--) {
24
+ dp[i]![j] = eq(a[i]!, b[j]!) ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!);
25
+ }
26
+ }
27
+ const out: DiffOp<T>[] = [];
28
+ let i = 0;
29
+ let j = 0;
30
+ while (i < n && j < m) {
31
+ if (eq(a[i]!, b[j]!)) {
32
+ out.push({ t: "ctx", a: a[i], b: b[j] });
33
+ i++;
34
+ j++;
35
+ } else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) {
36
+ out.push({ t: "del", a: a[i] });
37
+ i++;
38
+ } else {
39
+ out.push({ t: "add", b: b[j] });
40
+ j++;
41
+ }
42
+ }
43
+ while (i < n) out.push({ t: "del", a: a[i++] });
44
+ while (j < m) out.push({ t: "add", b: b[j++] });
45
+ return out;
46
+ }
47
+
48
+ export interface UnifiedLine {
49
+ t: DiffOpType | "hunk";
50
+ text: string;
51
+ }
52
+
53
+ /**
54
+ * Unified diff with hunk headers. Returns null when the inputs are equal -
55
+ * "no diff" is a distinct state from "empty diff".
56
+ */
57
+ export function unifiedDiff(oldText: string, newText: string, context = 3): UnifiedLine[] | null {
58
+ const ops = lcsDiff(oldText.split("\n"), newText.split("\n"));
59
+ if (!ops.some((o) => o.t !== "ctx")) return null;
60
+ let ol = 1;
61
+ let nl = 1;
62
+ const rows = ops.map((o) => {
63
+ const r = {
64
+ t: o.t,
65
+ text: (o.t === "add" ? o.b : o.a) ?? "",
66
+ ol: o.t !== "add" ? ol : null,
67
+ nl: o.t !== "del" ? nl : null,
68
+ };
69
+ if (o.t !== "add") ol++;
70
+ if (o.t !== "del") nl++;
71
+ return r;
72
+ });
73
+ const keep = new Array<boolean>(rows.length).fill(false);
74
+ rows.forEach((r, idx) => {
75
+ if (r.t !== "ctx") {
76
+ for (let k = Math.max(0, idx - context); k <= Math.min(rows.length - 1, idx + context); k++) keep[k] = true;
77
+ }
78
+ });
79
+ const hunks: { rows: (typeof rows)[number][] }[] = [];
80
+ let cur: { rows: (typeof rows)[number][] } | null = null;
81
+ rows.forEach((r, idx) => {
82
+ if (!keep[idx]) {
83
+ cur = null;
84
+ return;
85
+ }
86
+ if (!cur) {
87
+ cur = { rows: [] };
88
+ hunks.push(cur);
89
+ }
90
+ cur.rows.push(r);
91
+ });
92
+ const out: UnifiedLine[] = [];
93
+ for (const h of hunks) {
94
+ const oStart = h.rows.find((r) => r.ol !== null)?.ol ?? 1;
95
+ const nStart = h.rows.find((r) => r.nl !== null)?.nl ?? 1;
96
+ const oCount = h.rows.filter((r) => r.t !== "add").length;
97
+ const nCount = h.rows.filter((r) => r.t !== "del").length;
98
+ out.push({ t: "hunk", text: `@@ -${oStart},${oCount} +${nStart},${nCount} @@` });
99
+ for (const r of h.rows) {
100
+ out.push({ t: r.t, text: (r.t === "add" ? "+" : r.t === "del" ? "-" : " ") + r.text });
101
+ }
102
+ }
103
+ return out;
104
+ }
105
+
106
+ /** Render a unified diff as text with file headers. */
107
+ export function unifiedDiffText(oldText: string, newText: string, path = "plan.md", context = 3): string | null {
108
+ const d = unifiedDiff(oldText, newText, context);
109
+ if (!d) return null;
110
+ return [`--- a/${path}`, `+++ b/${path}`, ...d.map((l) => l.text)].join("\n");
111
+ }
112
+
113
+ export interface EditStats {
114
+ added: number;
115
+ removed: number;
116
+ }
117
+
118
+ export function editStats(oldText: string, newText: string): EditStats {
119
+ const d = unifiedDiff(oldText, newText, 0);
120
+ if (!d) return { added: 0, removed: 0 };
121
+ return {
122
+ added: d.filter((r) => r.t === "add").length,
123
+ removed: d.filter((r) => r.t === "del").length,
124
+ };
125
+ }
126
+
127
+ /** Word-level diff ops for tracked-changes rendering. Whitespace-preserving. */
128
+ export function wordDiff(oldText: string, newText: string): DiffOp[] {
129
+ const tok = (s: string) => s.split(/(\s+)/).filter((x) => x !== "");
130
+ return lcsDiff(tok(oldText), tok(newText));
131
+ }
@@ -0,0 +1,115 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { renderFeedback } from "./feedback";
3
+ import type { Annotation } from "./types";
4
+
5
+ const PLAN = `# Plan
6
+
7
+ ## Context
8
+
9
+ Review sessions live only in daemon memory today.
10
+
11
+ ## Storage
12
+
13
+ Sessions are written as one JSON document per session.
14
+ `;
15
+
16
+ function ann(partial: Partial<Annotation> & { anchor: Annotation["anchor"] }): Annotation {
17
+ return {
18
+ id: "a1",
19
+ kind: "comment",
20
+ body: "Please clarify.",
21
+ createdAt: "2026-08-07T00:00:00.000Z",
22
+ ...partial,
23
+ };
24
+ }
25
+
26
+ describe("renderFeedback", () => {
27
+ test("plan edits section carries the one unified diff, directive framing first", () => {
28
+ const out = renderFeedback({
29
+ verdictKind: "request_changes",
30
+ summary: "Tighten the storage section.",
31
+ artifactContent: PLAN,
32
+ workingCopy: PLAN.replace("one JSON document", "one JSON record"),
33
+ annotations: [],
34
+ planPath: "docs/plan.md",
35
+ });
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.");
43
+ });
44
+
45
+ test("annotations are located by quote and section", () => {
46
+ const out = renderFeedback({
47
+ verdictKind: "comment",
48
+ summary: "",
49
+ artifactContent: PLAN,
50
+ annotations: [
51
+ ann({
52
+ anchor: { quote: "daemon memory", prefix: "live only in ", suffix: " today." },
53
+ }),
54
+ ],
55
+ });
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
+
62
+ test("suggestions render as replace/with pairs", () => {
63
+ const out = renderFeedback({
64
+ verdictKind: "request_changes",
65
+ summary: "",
66
+ artifactContent: PLAN,
67
+ annotations: [
68
+ ann({
69
+ kind: "suggestion",
70
+ body: "one durable JSON record",
71
+ anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." },
72
+ }),
73
+ ],
74
+ });
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");
78
+ });
79
+
80
+ test("orphaned anchors are flagged, never dropped", () => {
81
+ const out = renderFeedback({
82
+ verdictKind: "comment",
83
+ summary: "",
84
+ artifactContent: PLAN,
85
+ annotations: [ann({ anchor: { quote: "text that is gone", prefix: "", suffix: "" } })],
86
+ });
87
+ expect(out).toContain("[orphaned anchor: the quoted text is no longer present]");
88
+ expect(out).toContain("> text that is gone");
89
+ });
90
+
91
+ test("empty review says so explicitly", () => {
92
+ const out = renderFeedback({
93
+ verdictKind: "approve",
94
+ summary: "",
95
+ artifactContent: PLAN,
96
+ annotations: [],
97
+ });
98
+ expect(out).toContain("_No edits or annotations._");
99
+ });
100
+
101
+ test("anchors resolve against the working copy when present", () => {
102
+ // annotation made on text that only exists in the edited copy
103
+ const working = PLAN.replace("## Storage", "## Persistence");
104
+ const out = renderFeedback({
105
+ verdictKind: "request_changes",
106
+ summary: "",
107
+ artifactContent: PLAN,
108
+ workingCopy: working,
109
+ annotations: [
110
+ ann({ anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." } }),
111
+ ],
112
+ });
113
+ expect(out).toContain("(§ Persistence)");
114
+ });
115
+ });
@@ -0,0 +1,100 @@
1
+ /**
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.
6
+ */
7
+
8
+ import type { Annotation, ReviewSession, VerdictKind } from "./types";
9
+ import { parseBlocks, sectionOf } from "./markdown";
10
+ import { resolveAnchor } from "./anchor";
11
+ import { unifiedDiffText } from "./diff";
12
+
13
+ export interface FeedbackInput {
14
+ verdictKind: VerdictKind;
15
+ summary: string;
16
+ /** The submitted artifact content (latest revision). */
17
+ artifactContent: string;
18
+ /** The reviewer's working copy; undefined = no direct edits. */
19
+ workingCopy?: string;
20
+ annotations: Annotation[];
21
+ /** Path the agent knows the plan by, for direct reference. */
22
+ planPath?: string;
23
+ }
24
+
25
+ const quoteLines = (s: string) => "> " + s.replace(/\n/g, "\n> ");
26
+
27
+ export function renderFeedback(input: FeedbackInput): string {
28
+ const path = input.planPath ?? "plan.md";
29
+ const blocks = parseBlocks(input.workingCopy ?? input.artifactContent);
30
+ const lines: string[] = [];
31
+ lines.push("# Review: " + input.verdictKind.replace("_", " "));
32
+ lines.push("");
33
+ if (input.summary.trim()) {
34
+ lines.push(input.summary.trim());
35
+ lines.push("");
36
+ }
37
+
38
+ const diff =
39
+ input.workingCopy !== undefined ? unifiedDiffText(input.artifactContent, input.workingCopy, path) : null;
40
+ if (diff) {
41
+ lines.push("## Plan edits");
42
+ lines.push("");
43
+ lines.push(`The reviewer edited ${path} directly. Apply this exact diff first;`);
44
+ lines.push("it is a unified diff against the version you submitted.");
45
+ lines.push("");
46
+ lines.push("```diff");
47
+ lines.push(diff);
48
+ lines.push("```");
49
+ lines.push("");
50
+ }
51
+
52
+ if (input.annotations.length) {
53
+ lines.push(`## Annotations (${input.annotations.length})`);
54
+ lines.push("");
55
+ lines.push(`Address every item. Locate each one in ${path} by its quoted text.`);
56
+ 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}`);
64
+ lines.push("");
65
+ lines.push("Replace:");
66
+ lines.push(quoteLines(a.anchor.quote));
67
+ lines.push("With:");
68
+ lines.push(quoteLines(a.body));
69
+ } else {
70
+ lines.push(`### ${i + 1}. ${capitalize(a.kind)}${loc}${flag}`);
71
+ lines.push("");
72
+ lines.push(quoteLines(a.anchor.quote));
73
+ lines.push("");
74
+ lines.push(a.body);
75
+ }
76
+ lines.push("");
77
+ });
78
+ }
79
+
80
+ if (!diff && !input.annotations.length) {
81
+ lines.push("_No edits or annotations._");
82
+ lines.push("");
83
+ }
84
+ return lines.join("\n");
85
+ }
86
+
87
+ export function feedbackForSession(session: ReviewSession, verdictKind: VerdictKind, summary: string): string {
88
+ return renderFeedback({
89
+ verdictKind,
90
+ summary,
91
+ artifactContent: session.artifact.content,
92
+ workingCopy: session.workingCopy,
93
+ annotations: session.annotations,
94
+ planPath: session.artifact.meta.planPath,
95
+ });
96
+ }
97
+
98
+ function capitalize(s: string): string {
99
+ return s.charAt(0).toUpperCase() + s.slice(1);
100
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from "./types";
2
+ export * from "./markdown";
3
+ export * from "./diff";
4
+ export * from "./anchor";
5
+ export * from "./feedback";
@@ -0,0 +1,100 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { parseBlocks, blockToMd, sectionOf } from "./markdown";
3
+
4
+ const SAMPLE = `# Title
5
+
6
+ ## Context
7
+
8
+ A paragraph that
9
+ spans two lines.
10
+
11
+ - first item
12
+ - second item
13
+
14
+ \`\`\`ts
15
+ const x = 1;
16
+ const y = 2;
17
+ \`\`\`
18
+
19
+ ## Steps
20
+
21
+ 1. do one
22
+ 2. do two
23
+
24
+ > a quoted
25
+ > aside
26
+
27
+ ---
28
+ `;
29
+
30
+ describe("parseBlocks", () => {
31
+ test("parses every block kind with source line ranges", () => {
32
+ const blocks = parseBlocks(SAMPLE);
33
+ expect(blocks.map((b) => b.kind)).toEqual([
34
+ "h1",
35
+ "h2",
36
+ "p",
37
+ "li",
38
+ "li",
39
+ "code",
40
+ "h2",
41
+ "oli",
42
+ "oli",
43
+ "quote",
44
+ "hr",
45
+ ]);
46
+ const code = blocks.find((b) => b.kind === "code")!;
47
+ expect(code.lang).toBe("ts");
48
+ expect(code.text).toBe("const x = 1;\nconst y = 2;");
49
+ // fence lines included in the source range
50
+ expect(code.lineEnd - code.lineStart).toBe(3);
51
+ });
52
+
53
+ test("multi-line paragraph keeps its text and range", () => {
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);
58
+ });
59
+
60
+ test("line ranges index into the source", () => {
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);
67
+ }
68
+ }
69
+ });
70
+
71
+ test("round-trips through blockToMd", () => {
72
+ const blocks = parseBlocks(SAMPLE);
73
+ let oli = 0;
74
+ const rebuilt = blocks
75
+ .map((b) => {
76
+ oli = b.kind === "oli" ? oli + 1 : 0;
77
+ return blockToMd(b, oli || 1);
78
+ })
79
+ .join("\n\n");
80
+ // re-parsing the rebuild yields the same kinds and texts
81
+ const again = parseBlocks(rebuilt);
82
+ expect(again.map((b) => [b.kind, b.text])).toEqual(blocks.map((b) => [b.kind, b.text]));
83
+ });
84
+
85
+ test("unknown constructs degrade to paragraphs, no content lost", () => {
86
+ const md = "| a | b |\n|---|---|\n| 1 | 2 |";
87
+ const blocks = parseBlocks(md);
88
+ expect(blocks.map((b) => b.text).join("\n")).toContain("| a | b |");
89
+ });
90
+ });
91
+
92
+ describe("sectionOf", () => {
93
+ test("returns the nearest preceding heading", () => {
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");
99
+ });
100
+ });
@@ -0,0 +1,131 @@
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
4
+ * granular; the quote carries sub-block precision. Round-trip safe for the
5
+ * subset cueloop plans use; unknown constructs fall back to paragraph blocks
6
+ * so no content is ever lost.
7
+ */
8
+
9
+ export type BlockKind = "h1" | "h2" | "h3" | "p" | "li" | "oli" | "quote" | "code" | "hr";
10
+
11
+ export interface Block {
12
+ kind: BlockKind;
13
+ /** Content with markers stripped; code blocks keep inner lines verbatim. */
14
+ text: string;
15
+ /** For code blocks: the fence info string ("ts", "diff", ...). */
16
+ lang?: string;
17
+ /** 0-based inclusive line range in the source this block occupies. */
18
+ lineStart: number;
19
+ lineEnd: number;
20
+ }
21
+
22
+ function isMarkerLine(l: string): boolean {
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)
32
+ );
33
+ }
34
+
35
+ export function parseBlocks(md: string): Block[] {
36
+ const lines = md.split("\n");
37
+ const blocks: Block[] = [];
38
+ let i = 0;
39
+ while (i < lines.length) {
40
+ const l = lines[i]!;
41
+ if (l.trim() === "") {
42
+ i++;
43
+ continue;
44
+ }
45
+ if (l.startsWith("```")) {
46
+ const start = i;
47
+ const lang = l.slice(3).trim() || undefined;
48
+ i++;
49
+ const body: string[] = [];
50
+ while (i < lines.length && !lines[i]!.startsWith("```")) {
51
+ body.push(lines[i]!);
52
+ i++;
53
+ }
54
+ const end = Math.min(i, lines.length - 1);
55
+ i++;
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;
71
+ const body: string[] = [];
72
+ while (i < lines.length && lines[i]!.startsWith("> ")) {
73
+ body.push(lines[i]!.slice(2));
74
+ i++;
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++;
83
+ } else {
84
+ const start = i;
85
+ const body: string[] = [];
86
+ while (i < lines.length && lines[i]!.trim() !== "" && !isMarkerLine(lines[i]!)) {
87
+ body.push(lines[i]!);
88
+ i++;
89
+ }
90
+ blocks.push({ kind: "p", text: body.join("\n"), lineStart: start, lineEnd: i - 1 });
91
+ }
92
+ }
93
+ return blocks;
94
+ }
95
+
96
+ /** Serialize one block back to its markdown chunk. */
97
+ export function blockToMd(b: Block, ordinal = 1): string {
98
+ switch (b.kind) {
99
+ case "h1":
100
+ return "# " + b.text;
101
+ case "h2":
102
+ return "## " + b.text;
103
+ case "h3":
104
+ return "### " + b.text;
105
+ case "li":
106
+ return "- " + b.text;
107
+ case "oli":
108
+ return `${ordinal}. ` + b.text;
109
+ case "quote":
110
+ return b.text
111
+ .split("\n")
112
+ .map((l) => "> " + l)
113
+ .join("\n");
114
+ case "code":
115
+ return "```" + (b.lang ?? "") + "\n" + b.text + "\n```";
116
+ case "hr":
117
+ return "---";
118
+ default:
119
+ return b.text;
120
+ }
121
+ }
122
+
123
+ /** The section (nearest preceding heading) a block belongs to. */
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;
129
+ }
130
+ return sec;
131
+ }
package/src/types.ts ADDED
@@ -0,0 +1,106 @@
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.
5
+ */
6
+
7
+ export const SCHEMA_VERSION = "1";
8
+
9
+ /** A workspace is a repo/branch context holding review sessions. */
10
+ export interface WorkspaceKey {
11
+ repoRoot: string;
12
+ branch: string;
13
+ }
14
+
15
+ export type ArtifactType = "plan" | "diff";
16
+
17
+ export interface ArtifactMeta {
18
+ cwd?: string;
19
+ agent?: string;
20
+ /** Agent-native session id, for resume/fork context. */
21
+ agentSessionId?: string;
22
+ /** Path to the plan file on disk, so feedback can reference it. */
23
+ planPath?: string;
24
+ /** Pull request reference the diff came from, so the verdict can be posted back. */
25
+ pr?: string;
26
+ title?: string;
27
+ }
28
+
29
+ export interface Artifact {
30
+ type: ArtifactType;
31
+ /** Markdown source for plans; unified-diff text for diffs. */
32
+ content: string;
33
+ meta: ArtifactMeta;
34
+ }
35
+
36
+ /**
37
+ * Anchors are quote-primary selectors (Hypothesis-style), resolved against
38
+ * the artifact's current text by the cascade in anchor.ts. Position fields
39
+ * are hints, never authority.
40
+ */
41
+ export interface Anchor {
42
+ quote: string;
43
+ prefix: string;
44
+ suffix: string;
45
+ /** Index of the block the anchor was made in (hint). */
46
+ blockIndex?: number;
47
+ /** Character offsets within that block's text (hint). */
48
+ start?: number;
49
+ end?: number;
50
+ }
51
+
52
+ /** The annotation kind set is open (map #2); these are the built-ins. */
53
+ export type AnnotationKind = "comment" | "suggestion" | (string & {});
54
+
55
+ export interface Annotation {
56
+ id: string;
57
+ kind: AnnotationKind;
58
+ anchor: Anchor;
59
+ /** Comment body, or the replacement text for suggestions. */
60
+ body: string;
61
+ /** Set by resolution when the quote can no longer be found. */
62
+ orphan?: boolean;
63
+ createdAt: string;
64
+ }
65
+
66
+ export type VerdictKind = "comment" | "approve" | "request_changes";
67
+
68
+ export interface Verdict {
69
+ kind: VerdictKind;
70
+ summary: string;
71
+ /** The one structured feedback document sent to the agent. */
72
+ feedback: string;
73
+ resolvedAt: string;
74
+ }
75
+
76
+ export interface Revision {
77
+ revision: number;
78
+ content: string;
79
+ submittedAt: string;
80
+ }
81
+
82
+ export type SessionStatus = "pending" | "resolved";
83
+
84
+ export interface ReviewSession {
85
+ schemaVersion: string;
86
+ id: string;
87
+ workspace: WorkspaceKey;
88
+ artifact: Artifact;
89
+ /** Revision history; artifact.content always equals the latest revision. */
90
+ revisions: Revision[];
91
+ annotations: Annotation[];
92
+ /**
93
+ * The reviewer's working copy of the artifact source (plan edits).
94
+ * Serializes as ONE unified diff against the submitted revision.
95
+ * Undefined = no direct edits.
96
+ */
97
+ workingCopy?: string;
98
+ verdict: Verdict | null;
99
+ status: SessionStatus;
100
+ createdAt: string;
101
+ }
102
+
103
+ /** comment and request_changes both map to deny in agent-native contracts. */
104
+ export function verdictAllows(kind: VerdictKind): boolean {
105
+ return kind === "approve";
106
+ }