@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.
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,21 @@
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
+ // Act
15
+ const ids = new Set(Array.from({ length: 10_000 }, () => newAnnotationId()));
16
+
17
+ // Assert
18
+ expect(ids.size).toBe(10_000);
19
+ for (const id of ids) expect(id).toMatch(/^a_[0-9a-z]+_[0-9a-z]+$/);
20
+ });
21
+ });
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;
@@ -60,9 +72,33 @@ export interface Annotation {
60
72
  body: string;
61
73
  /** Set by resolution when the quote can no longer be found. */
62
74
  orphan?: boolean;
75
+ /**
76
+ * SSH key fingerprint of a share collaborator who authored this note. Absent
77
+ * on the planner's own annotations; the sharing gateway stamps it so the
78
+ * planner can tell whose note is whose and never overwrite a collaborator's.
79
+ */
80
+ author?: string;
81
+ /**
82
+ * Set when a revision addressed this annotation: the agent reported the id
83
+ * on resubmit ("agent"), or the quoted text disappeared from the revised
84
+ * plan ("drift"). Addressed annotations leave the default rail view and the
85
+ * next feedback document, but are never deleted.
86
+ */
87
+ resolution?: AnnotationResolution;
63
88
  createdAt: string;
64
89
  }
65
90
 
91
+ export interface AnnotationResolution {
92
+ /** The revision number whose submission addressed this annotation. */
93
+ revision: number;
94
+ source: "agent" | "drift";
95
+ }
96
+
97
+ /** An annotation a revision has addressed; it leaves the default views. */
98
+ export function isAddressed(annotation: Annotation): boolean {
99
+ return annotation.resolution !== undefined;
100
+ }
101
+
66
102
  export type VerdictKind = "comment" | "approve" | "request_changes";
67
103
 
68
104
  export interface Verdict {
@@ -81,6 +117,22 @@ export interface Revision {
81
117
 
82
118
  export type SessionStatus = "pending" | "resolved";
83
119
 
120
+ /**
121
+ * A person who authored annotations on a session. `id` is the stable key an
122
+ * annotation's `author` points at - the SSH fingerprint today, an OAuth id
123
+ * ("github:…") later. Provider-agnostic so those identities slot in unchanged.
124
+ */
125
+ export interface Identity {
126
+ /** Stable identity key; equals an annotation's `author`. */
127
+ id: string;
128
+ /** Identity source. One value today; widen the union when OAuth lands. */
129
+ provider: "ssh";
130
+ /** Display name; absent = the collaborator stayed anonymous. */
131
+ name?: string;
132
+ /** Provider handle: a github login, an email, or a short fingerprint. */
133
+ handle?: string;
134
+ }
135
+
84
136
  export interface ReviewSession {
85
137
  schemaVersion: string;
86
138
  id: string;
@@ -95,12 +147,38 @@ export interface ReviewSession {
95
147
  * Undefined = no direct edits.
96
148
  */
97
149
  workingCopy?: string;
150
+ /**
151
+ * File paths the reviewer marked viewed during the guided walk (diff
152
+ * sessions). Persisting with the session means a resumed review keeps its
153
+ * progress. Undefined = the walk never started.
154
+ */
155
+ viewedPaths?: string[];
98
156
  verdict: Verdict | null;
99
157
  status: SessionStatus;
100
158
  createdAt: string;
159
+ /** Share id once published; lets the planner pull collaborator notes back. */
160
+ shareId?: string;
161
+ /** SSH fingerprint that created the share; the gateway stamps it to gate pulls. */
162
+ owner?: string;
163
+ /**
164
+ * Identities that authored annotations here, keyed by id (union-by-id, like
165
+ * annotations). The gateway records a collaborator's identity and chosen name;
166
+ * the rail resolves an annotation's `author` against this registry.
167
+ */
168
+ participants?: Identity[];
101
169
  }
102
170
 
103
171
  /** comment and request_changes both map to deny in agent-native contracts. */
104
172
  export function verdictAllows(kind: VerdictKind): boolean {
105
173
  return kind === "approve";
106
174
  }
175
+
176
+ /**
177
+ * Annotation ids are minted client-side. The counter makes ids unique within
178
+ * a process by construction; the random suffix separates concurrent
179
+ * annotators in different processes on the same millisecond.
180
+ */
181
+ let annotationSeq = 0;
182
+ export function newAnnotationId(): string {
183
+ return `a_${Date.now().toString(36)}${(annotationSeq++).toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
184
+ }
@@ -0,0 +1,144 @@
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
+ // Act
39
+ const cut = cutBlock(BASE, block(BASE, "spans two lines"));
40
+
41
+ // Assert
42
+ expect(cut).not.toContain("spans two lines");
43
+ expect(cut).toContain("## Context\n\n- first item");
44
+ });
45
+
46
+ test("cut at the start of the document leaves the leading blank line", () => {
47
+ // Act
48
+ const cut = cutBlock(BASE, block(BASE, "Plan"));
49
+
50
+ // Assert
51
+ expect(cut).not.toContain("# Plan");
52
+ expect(cut.trimStart().startsWith("## Context")).toBe(true);
53
+ });
54
+
55
+ test("cut at the end of the document keeps the rest intact", () => {
56
+ // Act
57
+ const cut = cutBlock(BASE, block(BASE, "const x"));
58
+
59
+ // Assert
60
+ expect(cut).not.toContain("const x");
61
+ expect(cut).toContain("- second item");
62
+ });
63
+ });
64
+
65
+ describe("restoreBlock", () => {
66
+ test("restoring the only cut returns undefined - back to pristine", () => {
67
+ // Arrange
68
+ const listItem = block(BASE, "second item");
69
+ const cut = cutBlock(BASE, listItem);
70
+ // the cut block re-enters before the code fence, the next surviving block
71
+ const next = parseBlocks(cut).find((candidate) => candidate.kind === "code");
72
+
73
+ // Act
74
+ const restored = restoreBlock(BASE, cut, listItem, restoreLine(next, cut.split("\n").length));
75
+
76
+ // Assert
77
+ expect(restored).toBeUndefined();
78
+ });
79
+
80
+ test("restore after another edit returns the merged working copy", () => {
81
+ // Arrange
82
+ const listItem = block(BASE, "second item");
83
+ const edited = cutBlock(BASE, listItem).replace("first item", "first item reworded");
84
+ const next = parseBlocks(edited).find((candidate) => candidate.kind === "code");
85
+
86
+ // Act
87
+ const restored = restoreBlock(BASE, edited, listItem, restoreLine(next, edited.split("\n").length));
88
+
89
+ // Assert
90
+ expect(restored).toContain("- second item");
91
+ expect(restored).toContain("first item reworded");
92
+ });
93
+
94
+ test("restore at the end of the document (no next block)", () => {
95
+ // Arrange
96
+ const code = block(BASE, "const x");
97
+ const cut = cutBlock(BASE, code);
98
+
99
+ // Act
100
+ const restored = restoreBlock(BASE, cut, code, restoreLine(undefined, cut.split("\n").length));
101
+
102
+ // Assert
103
+ expect(restored).toBeUndefined();
104
+ });
105
+
106
+ test("restore a multi-line block round-trips to pristine", () => {
107
+ // Arrange
108
+ const paragraph = block(BASE, "spans two lines");
109
+ const cut = cutBlock(BASE, paragraph);
110
+ const next = parseBlocks(cut).find((block) => block.text === "first item");
111
+
112
+ // Act
113
+ const restored = restoreBlock(BASE, cut, paragraph, restoreLine(next, cut.split("\n").length));
114
+
115
+ // Assert
116
+ expect(restored).toBeUndefined();
117
+ });
118
+
119
+ test("signature rule ignores blank-line layout but not text changes", () => {
120
+ // Arrange
121
+ const listItem = block(BASE, "second item");
122
+ const cut = cutBlock(BASE, listItem);
123
+ const next = parseBlocks(cut).find((candidate) => candidate.kind === "code");
124
+ const line = restoreLine(next, cut.split("\n").length);
125
+
126
+ // Assert
127
+ // extra blank lines elsewhere do not block pristine detection
128
+ expect(restoreBlock(BASE, cut.replace("## Context", "## Context\n"), listItem, line)).toBeUndefined();
129
+ // a real text change keeps the working copy alive
130
+ const changed = restoreBlock(BASE, cut.replace("first", "1st"), listItem, line);
131
+ expect(changed).toContain("1st item");
132
+ });
133
+ });
134
+
135
+ describe("restoreLine", () => {
136
+ test("next surviving block's start, or the line count at the end", () => {
137
+ // Arrange
138
+ const listItem = block(BASE, "first item");
139
+
140
+ // Assert
141
+ expect(restoreLine(listItem, 99)).toBe(listItem.lineStart);
142
+ expect(restoreLine(undefined, 99)).toBe(99);
143
+ });
144
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Working-copy block surgery: the only module that slices raw source text by
3
+ * Block line ranges. Cut removes a block's lines; restore re-inserts a base
4
+ * block's chunk and reports when the working copy round-trips back to the
5
+ * submitted revision (pristine). Callers never touch lineStart/lineEnd.
6
+ */
7
+
8
+ import { parseBlocks, type Block } from "./markdown";
9
+
10
+ /** Chunk of the base source a block occupies (for restore and display). */
11
+ export function sourceChunk(base: string, block: Block): string {
12
+ const lines = base.split("\n");
13
+ return lines.slice(block.lineStart, block.lineEnd + 1).join("\n");
14
+ }
15
+
16
+ /** Remove a work block's lines from the working source (Cut). */
17
+ export function cutBlock(working: string, block: Block): string {
18
+ const lines = working.split("\n");
19
+ const before = lines.slice(0, block.lineStart);
20
+ const after = lines.slice(block.lineEnd + 1);
21
+ while (before.length && before[before.length - 1]!.trim() === "") before.pop();
22
+ return [...before, ...after].join("\n");
23
+ }
24
+
25
+ /** The line a restored block re-enters at: the next surviving block's start. */
26
+ export function restoreLine(next: Block | undefined, workingLineCount: number): number {
27
+ return next ? next.lineStart : workingLineCount;
28
+ }
29
+
30
+ /**
31
+ * Block-structure signature: kind + text of every block, insensitive to
32
+ * blank-line layout between blocks. NUL separates blocks so no block text
33
+ * can fake a boundary.
34
+ */
35
+ function signature(text: string): string {
36
+ return parseBlocks(text)
37
+ .map((block) => block.kind + " " + block.text)
38
+ .join("\0");
39
+ }
40
+
41
+ /**
42
+ * Re-insert a cut base block into the working source before `beforeLine`.
43
+ * Returns undefined when the block structure round-trips to the base content
44
+ * (restore may differ in blank-line placement only) - the working copy is
45
+ * back to pristine and should be dropped.
46
+ */
47
+ export function restoreBlock(
48
+ base: string,
49
+ working: string,
50
+ block: Block,
51
+ beforeLine: number,
52
+ ): string | undefined {
53
+ const lines = working.split("\n");
54
+ const before = lines.slice(0, beforeLine);
55
+ const after = lines.slice(beforeLine);
56
+ const insertedLines = sourceChunk(base, block).split("\n");
57
+ if (before.length && before[before.length - 1]!.trim() !== "") insertedLines.unshift("");
58
+ if (after.length && after[0]!.trim() !== "") insertedLines.push("");
59
+ const restored = [...before, ...insertedLines, ...after].join("\n");
60
+ return signature(restored) === signature(base) ? undefined : restored;
61
+ }