@cueloop/schema 0.1.0-alpha.59 → 0.1.0-alpha.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/schema",
3
- "version": "0.1.0-alpha.59",
3
+ "version": "0.1.0-alpha.60",
4
4
  "description": "The ReviewSession primitive: types, markdown block model, quote-primary anchors, feedback serialization",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -63,6 +63,7 @@ describe("anchor cascade", () => {
63
63
 
64
64
  // Assert
65
65
  const editedBlockIndex = edited.findIndex((block) => block.text.includes("Review sessions"));
66
+
66
67
  expect(resolved.blockIndex).toBe(editedBlockIndex);
67
68
  expect(edited[resolved.blockIndex]!.text.slice(resolved.start, resolved.end)).toBe(
68
69
  "Review sessions",
package/src/anchor.ts CHANGED
@@ -42,6 +42,7 @@ export function makeAnchor(
42
42
  end: number,
43
43
  ): Anchor {
44
44
  const blockText = blocks[blockIndex]?.text ?? "";
45
+
45
46
  return {
46
47
  quote: blockText.slice(start, end),
47
48
  prefix: blockText.slice(Math.max(0, start - ANCHOR_CONTEXT_CHARS), start),
@@ -70,24 +71,29 @@ function contextScore(
70
71
  const prefix = block.text.slice(Math.max(0, start - ANCHOR_CONTEXT_CHARS), start);
71
72
  const suffix = block.text.slice(end, end + ANCHOR_CONTEXT_CHARS);
72
73
  let score = 0;
74
+
73
75
  if (prefix === anchor.prefix) score += 2;
74
76
  if (suffix === anchor.suffix) score += 2;
75
77
  if (anchor.blockIndex === blockIndex) score += 1;
76
78
  if (anchor.blockIndex === blockIndex && anchor.start === start) score += 1;
79
+
77
80
  return score;
78
81
  }
79
82
 
80
83
  /** Every verbatim occurrence of `quote` across the blocks, scored by context. */
81
84
  function findExactCandidates(anchor: Anchor, blocks: Block[], quote: string): Candidate[] {
82
85
  const candidates: Candidate[] = [];
86
+
83
87
  blocks.forEach((block, blockIndex) => {
84
88
  const text = block.text;
89
+
85
90
  for (
86
91
  let matchStart = text.indexOf(quote);
87
92
  matchStart !== -1;
88
93
  matchStart = text.indexOf(quote, matchStart + 1)
89
94
  ) {
90
95
  const matchEnd = matchStart + quote.length;
96
+
91
97
  candidates.push({
92
98
  blockIndex,
93
99
  start: matchStart,
@@ -96,6 +102,7 @@ function findExactCandidates(anchor: Anchor, blocks: Block[], quote: string): Ca
96
102
  });
97
103
  }
98
104
  });
105
+
99
106
  return candidates;
100
107
  }
101
108
 
@@ -115,14 +122,18 @@ function findFuzzyCandidates(
115
122
  minimumSimilarity: number,
116
123
  ): Candidate[] {
117
124
  const candidates: Candidate[] = [];
125
+
118
126
  blocks.forEach((block, blockIndex) => {
119
127
  const match = fuzzyFindBestMatch(needle, block.text, minimumSimilarity);
128
+
120
129
  if (match === null) return;
121
130
  const score =
122
131
  match.similarity +
123
132
  contextScore(anchor, blockIndex, block, match.start, match.end) * FUZZY_CONTEXT_TIE_BREAK;
133
+
124
134
  candidates.push({ blockIndex, start: match.start, end: match.end, score });
125
135
  });
136
+
126
137
  return candidates;
127
138
  }
128
139
 
@@ -130,6 +141,7 @@ function findFuzzyCandidates(
130
141
  function pickBest(candidates: Candidate[]): Candidate | null {
131
142
  if (candidates.length === 0) return null;
132
143
  candidates.sort((left, right) => right.score - left.score);
144
+
133
145
  return candidates[0]!;
134
146
  }
135
147
 
@@ -148,25 +160,32 @@ export function resolveAnchor(anchor: Anchor, blocks: Block[]): ResolvedAnchor |
148
160
  if (anchor.quote === "") return null;
149
161
 
150
162
  const exact = pickBest(findExactCandidates(anchor, blocks, anchor.quote));
163
+
151
164
  if (exact) return resolved(exact, "exact");
152
165
 
153
166
  const trimmedQuote = anchor.quote.trim();
167
+
154
168
  if (trimmedQuote !== "" && trimmedQuote !== anchor.quote) {
155
169
  const trimmed = pickBest(findExactCandidates(anchor, blocks, trimmedQuote));
170
+
156
171
  if (trimmed) return resolved(trimmed, "trimmed");
157
172
  }
158
173
 
159
174
  const normalizedQuote = stripLeadingBlockMarker(anchor.quote).trim();
175
+
160
176
  if (normalizedQuote !== "" && normalizedQuote !== trimmedQuote) {
161
177
  const normalized = pickBest(findExactCandidates(anchor, blocks, normalizedQuote));
178
+
162
179
  if (normalized) return resolved(normalized, "normalized");
163
180
  }
164
181
 
165
182
  const fuzzyNeedle = normalizedQuote !== "" ? normalizedQuote : trimmedQuote;
183
+
166
184
  if (fuzzyNeedle !== "") {
167
185
  const fuzzy = pickBest(
168
186
  findFuzzyCandidates(anchor, blocks, fuzzyNeedle, FUZZY_MINIMUM_SIMILARITY),
169
187
  );
188
+
170
189
  if (fuzzy) return resolved(fuzzy, "fuzzy");
171
190
  }
172
191
 
package/src/diff.test.ts CHANGED
@@ -30,6 +30,7 @@ describe("unifiedDiff", () => {
30
30
  // Arrange
31
31
  const oldLines = Array.from({ length: 30 }, (_, i) => `line ${i + 1}`);
32
32
  const newLines = [...oldLines];
33
+
33
34
  newLines[1] = "first change";
34
35
  newLines[27] = "second change";
35
36
 
@@ -50,6 +51,7 @@ describe("unifiedDiff", () => {
50
51
 
51
52
  // Assert
52
53
  const rebuilt: string[] = [];
54
+
53
55
  for (const line of diffLines) {
54
56
  if (line.kind === "hunk") continue;
55
57
  if (line.kind === "ctx" || line.kind === "add") rebuilt.push(line.text.slice(1));
package/src/diff.ts CHANGED
@@ -25,6 +25,7 @@ export function lcsDiff<T>(
25
25
  const longestCommon: number[][] = Array.from({ length: oldCount + 1 }, () =>
26
26
  Array.from({ length: newCount + 1 }, () => 0),
27
27
  );
28
+
28
29
  for (let oldIndex = oldCount - 1; oldIndex >= 0; oldIndex--) {
29
30
  for (let newIndex = newCount - 1; newIndex >= 0; newIndex--) {
30
31
  longestCommon[oldIndex]![newIndex] = equals(oldItems[oldIndex]!, newItems[newIndex]!)
@@ -38,6 +39,7 @@ export function lcsDiff<T>(
38
39
  const ops: DiffOp<T>[] = [];
39
40
  let oldIndex = 0;
40
41
  let newIndex = 0;
42
+
41
43
  while (oldIndex < oldCount && newIndex < newCount) {
42
44
  if (equals(oldItems[oldIndex]!, newItems[newIndex]!)) {
43
45
  ops.push({ kind: "ctx", oldValue: oldItems[oldIndex], newValue: newItems[newIndex] });
@@ -53,6 +55,7 @@ export function lcsDiff<T>(
53
55
  }
54
56
  while (oldIndex < oldCount) ops.push({ kind: "del", oldValue: oldItems[oldIndex++] });
55
57
  while (newIndex < newCount) ops.push({ kind: "add", newValue: newItems[newIndex++] });
58
+
56
59
  return ops;
57
60
  }
58
61
 
@@ -67,6 +70,7 @@ export interface UnifiedLine {
67
70
  */
68
71
  export function unifiedDiff(oldText: string, newText: string, context = 3): UnifiedLine[] | null {
69
72
  const ops = lcsDiff(oldText.split("\n"), newText.split("\n"));
73
+
70
74
  if (!ops.some((op) => op.kind !== "ctx")) return null;
71
75
  let oldLineNumber = 1;
72
76
  let newLineNumber = 1;
@@ -77,11 +81,14 @@ export function unifiedDiff(oldText: string, newText: string, context = 3): Unif
77
81
  oldLine: op.kind !== "add" ? oldLineNumber : null,
78
82
  newLine: op.kind !== "del" ? newLineNumber : null,
79
83
  };
84
+
80
85
  if (op.kind !== "add") oldLineNumber++;
81
86
  if (op.kind !== "del") newLineNumber++;
87
+
82
88
  return row;
83
89
  });
84
90
  const keep = Array.from({ length: rows.length }, () => false);
91
+
85
92
  rows.forEach((row, rowIndex) => {
86
93
  if (row.kind !== "ctx") {
87
94
  for (
@@ -95,9 +102,11 @@ export function unifiedDiff(oldText: string, newText: string, context = 3): Unif
95
102
  });
96
103
  const hunks: { rows: (typeof rows)[number][] }[] = [];
97
104
  let openHunk: { rows: (typeof rows)[number][] } | null = null;
105
+
98
106
  rows.forEach((row, rowIndex) => {
99
107
  if (!keep[rowIndex]) {
100
108
  openHunk = null;
109
+
101
110
  return;
102
111
  }
103
112
  if (!openHunk) {
@@ -107,11 +116,13 @@ export function unifiedDiff(oldText: string, newText: string, context = 3): Unif
107
116
  openHunk.rows.push(row);
108
117
  });
109
118
  const lines: UnifiedLine[] = [];
119
+
110
120
  for (const hunk of hunks) {
111
121
  const oldStart = hunk.rows.find((row) => row.oldLine !== null)?.oldLine ?? 1;
112
122
  const newStart = hunk.rows.find((row) => row.newLine !== null)?.newLine ?? 1;
113
123
  const oldCount = hunk.rows.filter((row) => row.kind !== "add").length;
114
124
  const newCount = hunk.rows.filter((row) => row.kind !== "del").length;
125
+
115
126
  lines.push({ kind: "hunk", text: `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@` });
116
127
  for (const row of hunk.rows) {
117
128
  lines.push({
@@ -120,6 +131,7 @@ export function unifiedDiff(oldText: string, newText: string, context = 3): Unif
120
131
  });
121
132
  }
122
133
  }
134
+
123
135
  return lines;
124
136
  }
125
137
 
@@ -131,7 +143,9 @@ export function unifiedDiffText(
131
143
  context = 3,
132
144
  ): string | null {
133
145
  const lines = unifiedDiff(oldText, newText, context);
146
+
134
147
  if (!lines) return null;
148
+
135
149
  return [`--- a/${path}`, `+++ b/${path}`, ...lines.map((line) => line.text)].join("\n");
136
150
  }
137
151
 
@@ -142,7 +156,9 @@ export interface EditStats {
142
156
 
143
157
  export function editStats(oldText: string, newText: string): EditStats {
144
158
  const lines = unifiedDiff(oldText, newText, 0);
159
+
145
160
  if (!lines) return { added: 0, removed: 0 };
161
+
146
162
  return {
147
163
  added: lines.filter((line) => line.kind === "add").length,
148
164
  removed: lines.filter((line) => line.kind === "del").length,
@@ -47,6 +47,24 @@ describe("renderFeedback", () => {
47
47
  expect(feedback).toContain("+Sessions are written as one JSON record per session.");
48
48
  });
49
49
 
50
+ test("reply edits section is labelled for the reply artifact, not a plan", () => {
51
+ // Act
52
+ const feedback = renderFeedback({
53
+ verdictKind: "request_changes",
54
+ summary: "",
55
+ artifactContent: PLAN,
56
+ workingCopy: PLAN.replace("one JSON document", "one JSON record"),
57
+ artifactType: "reply",
58
+ annotations: [],
59
+ artifactPath: "reply.md",
60
+ });
61
+
62
+ // Assert
63
+ expect(feedback).toContain("## Reply edits");
64
+ expect(feedback).not.toContain("## Plan edits");
65
+ expect(feedback).toContain("Apply this exact diff first");
66
+ });
67
+
50
68
  test("a diff working copy is handed back as the curated patch, not a diff of diffs", () => {
51
69
  // Arrange
52
70
  const submittedPatch = `--- a/src/x.ts
@@ -175,6 +193,7 @@ describe("renderFeedback", () => {
175
193
  }),
176
194
  ],
177
195
  });
196
+
178
197
  expect(mixed).toContain("## Annotations (1)");
179
198
  expect(mixed).not.toContain("The agent's own explanation of the change.");
180
199
  });
package/src/feedback.ts CHANGED
@@ -52,6 +52,7 @@ export function renderFeedback(input: FeedbackInput): string {
52
52
  isDiff ? input.artifactContent : (input.workingCopy ?? input.artifactContent),
53
53
  );
54
54
  const lines: string[] = [];
55
+
55
56
  lines.push("# Review: " + input.verdictKind.replace("_", " "));
56
57
  lines.push("");
57
58
  if (input.summary.trim()) {
@@ -60,6 +61,7 @@ export function renderFeedback(input: FeedbackInput): string {
60
61
  }
61
62
 
62
63
  let hasEdits = false;
64
+
63
65
  if (isDiff && input.workingCopy !== undefined) {
64
66
  // The diff working copy is already the curated patch - the accepted subset
65
67
  // of the submitted changes - so hand it back verbatim, not a diff of diffs.
@@ -82,9 +84,12 @@ export function renderFeedback(input: FeedbackInput): string {
82
84
  input.workingCopy !== undefined
83
85
  ? unifiedDiffText(input.artifactContent, input.workingCopy, path)
84
86
  : null;
87
+
85
88
  if (diff) {
86
89
  hasEdits = true;
87
- lines.push("## Plan edits");
90
+ // The edits section names the markdown artifact it edited (plan or reply)
91
+ // so the directive is not mislabelled when the artifact is not a plan.
92
+ lines.push(input.artifactType === "reply" ? "## Reply edits" : "## Plan edits");
88
93
  lines.push("");
89
94
  lines.push(`The reviewer edited ${path} directly. Apply this exact diff first;`);
90
95
  lines.push("it is a unified diff against the version you submitted.");
@@ -115,6 +120,7 @@ export function renderFeedback(input: FeedbackInput): string {
115
120
  !selector && resolved === null
116
121
  ? " [orphaned anchor: the quoted text is no longer present]"
117
122
  : "";
123
+
118
124
  lines.push(
119
125
  `### ${annotationIndex + 1}. ${capitalize(annotation.kind)}${location}${orphanFlag}`,
120
126
  );
@@ -145,6 +151,7 @@ export function renderFeedback(input: FeedbackInput): string {
145
151
  lines.push("_No edits or annotations._");
146
152
  lines.push("");
147
153
  }
154
+
148
155
  return lines.join("\n");
149
156
  }
150
157
 
package/src/fuzzy.ts CHANGED
@@ -33,6 +33,7 @@ export function levenshteinDistance(left: string, right: string): number {
33
33
  currentRow[0] = leftIndex;
34
34
  for (let rightIndex = 1; rightIndex <= right.length; rightIndex++) {
35
35
  const substitutionCost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1;
36
+
36
37
  currentRow[rightIndex] = Math.min(
37
38
  currentRow[rightIndex - 1]! + 1, // insertion
38
39
  previousRow[rightIndex]! + 1, // deletion
@@ -40,6 +41,7 @@ export function levenshteinDistance(left: string, right: string): number {
40
41
  );
41
42
  }
42
43
  const swap = previousRow;
44
+
43
45
  previousRow = currentRow;
44
46
  currentRow = swap;
45
47
  }
@@ -54,7 +56,9 @@ export function levenshteinDistance(left: string, right: string): number {
54
56
  */
55
57
  export function similarityRatio(left: string, right: string): number {
56
58
  const longestLength = Math.max(left.length, right.length);
59
+
57
60
  if (longestLength === 0) return 1;
61
+
58
62
  return 1 - levenshteinDistance(left, right) / longestLength;
59
63
  }
60
64
 
@@ -76,6 +80,7 @@ export function fuzzyFindBestMatch(
76
80
  const maximumWindowLength = needle.length + lengthTolerance;
77
81
 
78
82
  let bestMatch: FuzzyMatch | null = null;
83
+
79
84
  for (let start = 0; start < haystack.length; start++) {
80
85
  for (
81
86
  let windowLength = minimumWindowLength;
@@ -84,11 +89,13 @@ export function fuzzyFindBestMatch(
84
89
  ) {
85
90
  const window = haystack.slice(start, start + windowLength);
86
91
  const similarity = similarityRatio(needle, window);
92
+
87
93
  if (similarity < minimumSimilarity) continue;
88
94
  if (bestMatch === null || similarity > bestMatch.similarity) {
89
95
  bestMatch = { start, end: start + windowLength, similarity };
90
96
  }
91
97
  }
92
98
  }
99
+
93
100
  return bestMatch;
94
101
  }
package/src/herdr.ts CHANGED
@@ -23,11 +23,13 @@ export type HerdrEnv = Record<string, string | undefined>;
23
23
  export function detectHerdr(env: HerdrEnv = process.env): HerdrContext | null {
24
24
  if (env.HERDR_ENV !== "1") return null;
25
25
  const paneId = env.HERDR_PANE_ID;
26
+
26
27
  if (!paneId) return null;
27
28
  // herdr 0.8.0 exposes no HERDR_BIN_PATH; the CLI is `herdr` on PATH and finds
28
29
  // the app over HERDR_SOCKET_PATH from the inherited env. Tests still override
29
30
  // HERDR_BIN_PATH to point the spawn at a stub script.
30
31
  const binPath = env.HERDR_BIN_PATH ?? "herdr";
32
+
31
33
  return { paneId, binPath };
32
34
  }
33
35
 
@@ -47,5 +49,6 @@ export function returnPaneFor(
47
49
  ): string | undefined {
48
50
  if (!insideHerdr(env)) return undefined;
49
51
  const pane = env.CUELOOP_RETURN_PANE ?? sessionHerdrPane;
52
+
50
53
  return pane && pane !== env.HERDR_PANE_ID ? pane : undefined;
51
54
  }
@@ -59,6 +59,7 @@ describe("inlineRuns", () => {
59
59
  ] as const) {
60
60
  const runs = inlineRuns(source);
61
61
  const content = runs.find((run) => run.role === role)!;
62
+
62
63
  expect(content.text).toBe(inner);
63
64
  expect(source.slice(content.start!, content.start! + inner.length)).toBe(inner);
64
65
  expect(reconstruct(runs)).toBe(source);
@@ -74,6 +75,7 @@ describe("inlineRuns", () => {
74
75
 
75
76
  // Assert - the backtick content is one literal code run, no strong inside
76
77
  const code = runs.find((run) => run.role === "code")!;
78
+
77
79
  expect(code.text).toBe("a**b**c");
78
80
  expect(runs.some((run) => run.role === "strong")).toBe(false);
79
81
  });
@@ -150,6 +152,7 @@ describe("inlineRuns", () => {
150
152
  // Assert
151
153
  for (const source of ["**not closed", "a * b", "`open code", "[label](noclose"]) {
152
154
  const runs = inlineRuns(source);
155
+
153
156
  expect(reconstruct(runs)).toBe(source);
154
157
  expect(runs.every((run) => run.role === "text" || run.role === "marker")).toBe(true);
155
158
  expect(offsetsAreExact(source, runs)).toBe(true);
@@ -174,6 +177,7 @@ describe("inlineRuns", () => {
174
177
  expect(reconstruct(runs)).toBe("not \\*emph\\* here");
175
178
  expect(offsetsAreExact("not \\*emph\\* here", runs)).toBe(true);
176
179
  const star = runs.find((run) => run.text === "*" && run.start !== null)!;
180
+
177
181
  expect("not \\*emph\\* here"[star.start!]).toBe("*");
178
182
  });
179
183
 
package/src/inline.ts CHANGED
@@ -44,7 +44,9 @@ const PUNCTUATION = new Set(["*", "_", "`", "~", "[", "]", "(", ")", "\\"]);
44
44
  /** Tokenize one block's text into visible + concealed-marker runs, in order. */
45
45
  export function inlineRuns(text: string): InlineRun[] {
46
46
  const runs: InlineRun[] = [];
47
+
47
48
  tokenize(text, 0, "text", runs);
49
+
48
50
  return runs;
49
51
  }
50
52
 
@@ -76,6 +78,7 @@ function tokenize(source: string, base: number, role: InlineRole, out: InlineRun
76
78
  }
77
79
 
78
80
  const link = matchLink(source, cursor);
81
+
79
82
  if (link) {
80
83
  flushPlain(cursor);
81
84
  out.push({ text: source.slice(cursor, link.labelStart), role: "marker", start: null });
@@ -92,6 +95,7 @@ function tokenize(source: string, base: number, role: InlineRole, out: InlineRun
92
95
  }
93
96
 
94
97
  const code = matchCode(source, cursor);
98
+
95
99
  if (code) {
96
100
  flushPlain(cursor);
97
101
  out.push({ text: source.slice(cursor, code.contentStart), role: "marker", start: null });
@@ -107,6 +111,7 @@ function tokenize(source: string, base: number, role: InlineRole, out: InlineRun
107
111
  }
108
112
 
109
113
  const span = matchEmphasis(source, cursor);
114
+
110
115
  if (span) {
111
116
  flushPlain(cursor);
112
117
  out.push({
@@ -115,6 +120,7 @@ function tokenize(source: string, base: number, role: InlineRole, out: InlineRun
115
120
  start: null,
116
121
  });
117
122
  const contentStart = cursor + span.openLength;
123
+
118
124
  tokenize(source.slice(contentStart, span.closeIndex), base + contentStart, span.role, out);
119
125
  out.push({
120
126
  text: source.slice(span.closeIndex, span.closeIndex + span.closeLength),
@@ -145,14 +151,17 @@ function matchLink(
145
151
  if (text[index] !== "[") return null;
146
152
  const labelStart = index + 1;
147
153
  const labelEnd = text.indexOf("]", labelStart);
154
+
148
155
  if (labelEnd < 0 || labelEnd === labelStart) return null;
149
156
  if (text[labelEnd + 1] !== "(") return null;
150
157
  const hrefStart = labelEnd + 2;
151
158
  // the destination may carry balanced parentheses (wiki URLs); the span ends
152
159
  // at the first unmatched closer
153
160
  let depth = 0;
161
+
154
162
  for (let scan = hrefStart; scan < text.length; scan++) {
155
163
  const character = text[scan];
164
+
156
165
  if (character === "(") depth++;
157
166
  else if (character === ")") {
158
167
  if (depth > 0) {
@@ -160,9 +169,11 @@ function matchLink(
160
169
  continue;
161
170
  }
162
171
  if (scan === hrefStart) return null;
172
+
163
173
  return { labelStart, labelEnd, hrefStart, hrefEnd: scan, end: scan + 1 };
164
174
  }
165
175
  }
176
+
166
177
  return null;
167
178
  }
168
179
 
@@ -173,20 +184,25 @@ function matchCode(
173
184
  ): { contentStart: number; contentEnd: number; end: number } | null {
174
185
  if (text[index] !== "`") return null;
175
186
  let fence = 0;
187
+
176
188
  while (text[index + fence] === "`") fence++;
177
189
  const contentStart = index + fence;
178
190
  const closer = "`".repeat(fence);
179
191
  const contentEnd = text.indexOf(closer, contentStart);
192
+
180
193
  if (contentEnd < 0 || contentEnd === contentStart) return null;
194
+
181
195
  return { contentStart, contentEnd, end: contentEnd + fence };
182
196
  }
183
197
 
184
198
  /** A `**strong**`, `*em*`, or `~~strike~~` span at `index`, or null. */
185
199
  function matchEmphasis(text: string, index: number): Span | null {
186
200
  const two = text.slice(index, index + 2);
201
+
187
202
  if (two === "**") return closingSpan(text, index, 2, "strong");
188
203
  if (two === "~~") return closingSpan(text, index, 2, "strike");
189
204
  if (text[index] === "*") return closingSpan(text, index, 1, "em");
205
+
190
206
  return null;
191
207
  }
192
208
 
@@ -201,13 +217,16 @@ function closingSpan(
201
217
  const contentStart = index + length;
202
218
  const closeIndex =
203
219
  length === 1 ? findLoneStar(text, contentStart) : text.indexOf(marker, contentStart);
220
+
204
221
  if (closeIndex < 0 || closeIndex === contentStart) return null; // unbalanced or empty
222
+
205
223
  return { openLength: length, closeLength: length, closeIndex, role };
206
224
  }
207
225
 
208
226
  /** Index of the next standalone `*` (not part of a `**` run), or -1. */
209
227
  function findLoneStar(text: string, from: number): number {
210
228
  let index = from;
229
+
211
230
  while (index < text.length) {
212
231
  if (text[index] === "*") {
213
232
  if (text[index + 1] !== "*") return index;
@@ -216,5 +235,6 @@ function findLoneStar(text: string, from: number): number {
216
235
  }
217
236
  index++;
218
237
  }
238
+
219
239
  return -1;
220
240
  }
@@ -47,6 +47,7 @@ describe("parseBlocks", () => {
47
47
  "hr",
48
48
  ]);
49
49
  const code = blocks.find((block) => block.kind === "code")!;
50
+
50
51
  expect(code.lang).toBe("ts");
51
52
  expect(code.text).toBe("const x = 1;\nconst y = 2;");
52
53
  // fence lines included in the source range
@@ -84,6 +85,7 @@ describe("parseBlocks", () => {
84
85
  const rebuilt = blocks
85
86
  .map((block) => {
86
87
  orderedItemCount = block.kind === "oli" ? orderedItemCount + 1 : 0;
88
+
87
89
  return blockToMd(block, orderedItemCount || 1);
88
90
  })
89
91
  .join("\n\n");
@@ -117,8 +119,10 @@ describe("sectionOf", () => {
117
119
 
118
120
  // Assert
119
121
  const orderedItemIndex = blocks.findIndex((block) => block.kind === "oli");
122
+
120
123
  expect(sectionOf(blocks, orderedItemIndex)).toBe("Steps");
121
124
  const paragraphIndex = blocks.findIndex((block) => block.kind === "p");
125
+
122
126
  expect(sectionOf(blocks, paragraphIndex)).toBe("Context");
123
127
  });
124
128
  });
package/src/markdown.ts CHANGED
@@ -41,8 +41,10 @@ export function parseBlocks(markdown: string): Block[] {
41
41
  const lines = markdown.split("\n");
42
42
  const blocks: Block[] = [];
43
43
  let lineIndex = 0;
44
+
44
45
  while (lineIndex < lines.length) {
45
46
  const line = lines[lineIndex]!;
47
+
46
48
  if (line.trim() === "") {
47
49
  lineIndex++;
48
50
  continue;
@@ -50,13 +52,16 @@ export function parseBlocks(markdown: string): Block[] {
50
52
  if (line.startsWith("```")) {
51
53
  const start = lineIndex;
52
54
  const lang = line.slice(3).trim() || undefined;
55
+
53
56
  lineIndex++;
54
57
  const body: string[] = [];
58
+
55
59
  while (lineIndex < lines.length && !lines[lineIndex]!.startsWith("```")) {
56
60
  body.push(lines[lineIndex]!);
57
61
  lineIndex++;
58
62
  }
59
63
  const end = Math.min(lineIndex, lines.length - 1);
64
+
60
65
  lineIndex++;
61
66
  blocks.push({ kind: "code", text: body.join("\n"), lang, lineStart: start, lineEnd: end });
62
67
  } else if (line.startsWith("### ")) {
@@ -89,6 +94,7 @@ export function parseBlocks(markdown: string): Block[] {
89
94
  } else if (line.startsWith("> ")) {
90
95
  const start = lineIndex;
91
96
  const body: string[] = [];
97
+
92
98
  while (lineIndex < lines.length && lines[lineIndex]!.startsWith("> ")) {
93
99
  body.push(stripLeadingBlockMarker(lines[lineIndex]!));
94
100
  lineIndex++;
@@ -118,6 +124,7 @@ export function parseBlocks(markdown: string): Block[] {
118
124
  } else {
119
125
  const start = lineIndex;
120
126
  const body: string[] = [];
127
+
121
128
  while (
122
129
  lineIndex < lines.length &&
123
130
  lines[lineIndex]!.trim() !== "" &&
@@ -129,6 +136,7 @@ export function parseBlocks(markdown: string): Block[] {
129
136
  blocks.push({ kind: "p", text: body.join("\n"), lineStart: start, lineEnd: lineIndex - 1 });
130
137
  }
131
138
  }
139
+
132
140
  return blocks;
133
141
  }
134
142
 
@@ -162,10 +170,13 @@ export function blockToMd(block: Block, ordinal = 1): string {
162
170
  /** The section (nearest preceding heading) a block belongs to. */
163
171
  export function sectionOf(blocks: Block[], index: number): string {
164
172
  let sectionTitle = "";
173
+
165
174
  for (let blockIndex = 0; blockIndex <= index && blockIndex < blocks.length; blockIndex++) {
166
175
  const block = blocks[blockIndex]!;
176
+
167
177
  if (block.kind === "h1" || block.kind === "h2" || block.kind === "h3")
168
178
  sectionTitle = block.text;
169
179
  }
180
+
170
181
  return sectionTitle;
171
182
  }
@@ -21,12 +21,14 @@ export function registerParticipant(
21
21
  const participants = session.participants ?? [];
22
22
  const existing = participants.find((participant) => participant.id === author);
23
23
  const trimmed = name?.trim();
24
+
24
25
  if (existing && !trimmed) return session;
25
26
  const next: Identity = {
26
27
  id: author,
27
28
  provider: "ssh",
28
29
  ...(trimmed ? { name: trimmed } : existing?.name ? { name: existing.name } : {}),
29
30
  };
31
+
30
32
  return {
31
33
  ...session,
32
34
  participants: existing
package/src/types.test.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { newAnnotationId, verdictAllows } from "./types";
2
+ import { isMarkdownArtifact, newAnnotationId, verdictAllows } from "./types";
3
3
 
4
4
  describe("verdictAllows", () => {
5
5
  test("only approve maps to allow", () => {
@@ -9,6 +9,15 @@ describe("verdictAllows", () => {
9
9
  });
10
10
  });
11
11
 
12
+ describe("isMarkdownArtifact", () => {
13
+ test("plan and reply are markdown; diff and prototype are not", () => {
14
+ expect(isMarkdownArtifact("plan")).toBe(true);
15
+ expect(isMarkdownArtifact("reply")).toBe(true);
16
+ expect(isMarkdownArtifact("diff")).toBe(false);
17
+ expect(isMarkdownArtifact("prototype")).toBe(false);
18
+ });
19
+ });
20
+
12
21
  describe("newAnnotationId", () => {
13
22
  test("unique by construction within a process, even on one millisecond", () => {
14
23
  // Act
package/src/types.ts CHANGED
@@ -12,14 +12,30 @@ export interface WorkspaceKey {
12
12
  branch: string;
13
13
  }
14
14
 
15
- export type ArtifactType = "plan" | "diff" | "prototype";
15
+ /**
16
+ * What kind of artifact a review session holds. `plan` and `reply` are both
17
+ * markdown documents (see isMarkdownArtifact) - a plan is a proposal written
18
+ * forward, a reply is the agent's previous message pulled back for review.
19
+ * `diff` is a unified-diff patch; `prototype` is a rendered HTML page.
20
+ */
21
+ export type ArtifactType = "plan" | "diff" | "prototype" | "reply";
22
+
23
+ /**
24
+ * Markdown artifacts (plan, reply) are block-parsed and quote-anchored, so they
25
+ * share the plan render path, first-heading title derivation, and revision
26
+ * drift-assist. A diff (a patch) and a prototype (HTML/DOM) do not - keep this
27
+ * the one place that names the set, so a new markdown primitive joins here once.
28
+ */
29
+ export function isMarkdownArtifact(type: ArtifactType): boolean {
30
+ return type === "plan" || type === "reply";
31
+ }
16
32
 
17
33
  export interface ArtifactMeta {
18
34
  cwd?: string;
19
35
  agent?: string;
20
36
  /** Agent-native session id, for resume/fork context. */
21
37
  agentSessionId?: string;
22
- /** Path to the plan file on disk, so feedback can reference it. */
38
+ /** Path to the plan or reply markdown file on disk, so feedback can reference it. */
23
39
  planPath?: string;
24
40
  /** Path to the prototype's entry HTML file on disk. */
25
41
  prototypePath?: string;
@@ -45,7 +61,7 @@ export interface DiffFileContents {
45
61
 
46
62
  export interface Artifact {
47
63
  type: ArtifactType;
48
- /** Markdown source for plans; unified-diff text for diffs. */
64
+ /** Markdown source for plans and replies; unified-diff text for diffs. */
49
65
  content: string;
50
66
  meta: ArtifactMeta;
51
67
  /**
@@ -201,6 +217,7 @@ export function verdictAllows(kind: VerdictKind): boolean {
201
217
  * annotators in different processes on the same millisecond.
202
218
  */
203
219
  let annotationSeq = 0;
220
+
204
221
  export function newAnnotationId(): string {
205
222
  return `a_${Date.now().toString(36)}${(annotationSeq++).toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
206
223
  }
@@ -139,6 +139,7 @@ describe("restoreBlock", () => {
139
139
  ).toBeUndefined();
140
140
  // a real text change keeps the working copy alive
141
141
  const changed = restoreBlock(BASE, cut.replace("first", "1st"), listItem, line);
142
+
142
143
  expect(changed).toContain("1st item");
143
144
  });
144
145
  });
@@ -10,6 +10,7 @@ import { parseBlocks, type Block } from "./markdown";
10
10
  /** Chunk of the base source a block occupies (for restore and display). */
11
11
  export function sourceChunk(base: string, block: Block): string {
12
12
  const lines = base.split("\n");
13
+
13
14
  return lines.slice(block.lineStart, block.lineEnd + 1).join("\n");
14
15
  }
15
16
 
@@ -18,7 +19,9 @@ export function cutBlock(working: string, block: Block): string {
18
19
  const lines = working.split("\n");
19
20
  const before = lines.slice(0, block.lineStart);
20
21
  const after = lines.slice(block.lineEnd + 1);
22
+
21
23
  while (before.length && before[before.length - 1]!.trim() === "") before.pop();
24
+
22
25
  return [...before, ...after].join("\n");
23
26
  }
24
27
 
@@ -54,8 +57,10 @@ export function restoreBlock(
54
57
  const before = lines.slice(0, beforeLine);
55
58
  const after = lines.slice(beforeLine);
56
59
  const insertedLines = sourceChunk(base, block).split("\n");
60
+
57
61
  if (before.length && before[before.length - 1]!.trim() !== "") insertedLines.unshift("");
58
62
  if (after.length && after[0]!.trim() !== "") insertedLines.push("");
59
63
  const restored = [...before, ...insertedLines, ...after].join("\n");
64
+
60
65
  return signature(restored) === signature(base) ? undefined : restored;
61
66
  }