@cueloop/schema 0.1.0-alpha.76 → 0.1.0-alpha.78
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 +1 -1
- package/src/anchor.test.ts +72 -0
- package/src/anchor.ts +33 -10
- package/src/fuzzy.ts +24 -7
- package/src/history.ts +1 -1
- package/src/index.ts +1 -0
- package/src/participants.test.ts +48 -1
- package/src/participants.ts +24 -7
- package/src/share-access.test.ts +20 -0
- package/src/share-access.ts +13 -0
- package/src/types.ts +11 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/schema",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.78",
|
|
4
4
|
"description": "The Thread primitive: types, markdown block model, quote-primary anchors, feedback serialization",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
package/src/anchor.test.ts
CHANGED
|
@@ -320,3 +320,75 @@ so a crash can never leave a torn file on disk.
|
|
|
320
320
|
expect(resolved?.endBlockIndex).toBe(secondBullet - 1);
|
|
321
321
|
});
|
|
322
322
|
});
|
|
323
|
+
|
|
324
|
+
describe("fuzzy work budget", () => {
|
|
325
|
+
function largePlan(paragraphs: number): string {
|
|
326
|
+
const lines = ["# Plan", ""];
|
|
327
|
+
|
|
328
|
+
for (let index = 0; index < paragraphs; index++) {
|
|
329
|
+
lines.push(
|
|
330
|
+
`This is paragraph number ${index} describing the migration of the session store and its atomic write path in enough prose to be a realistic block a reviewer might quote.`,
|
|
331
|
+
"",
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return lines.join("\n");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
test("a stale anchor over a large plan stays bounded across all its blocks", () => {
|
|
339
|
+
// Arrange - a 160-block plan whose quoted paragraph was edited so the exact text is gone,
|
|
340
|
+
// forcing the fuzzy tier over every block; a per-block budget would freeze for many seconds
|
|
341
|
+
const blocks = parseBlocks(largePlan(160));
|
|
342
|
+
const quoted = Math.floor(blocks.length / 2);
|
|
343
|
+
const anchor = makeAnchor(blocks, quoted, 0, Math.min(blocks[quoted]!.text.length, 140));
|
|
344
|
+
const edited = blocks.map((block) => ({
|
|
345
|
+
...block,
|
|
346
|
+
text: block.text.replaceAll("migration", "rollout").replaceAll("session", "record"),
|
|
347
|
+
}));
|
|
348
|
+
|
|
349
|
+
// Act
|
|
350
|
+
const started = Date.now();
|
|
351
|
+
const resolved = resolveAnchor(anchor, edited);
|
|
352
|
+
const elapsed = Date.now() - started;
|
|
353
|
+
|
|
354
|
+
// Assert - it rebinds via the fuzzy tier, and one shared budget keeps it far under a freeze
|
|
355
|
+
expect(resolved?.strategy).toBe("fuzzy");
|
|
356
|
+
expect(elapsed).toBeLessThan(2000);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("still binds when a distinctive quote moved down a few blocks and was edited", () => {
|
|
360
|
+
// Arrange - a distinctive quote among unrelated blocks; the agent inserts paragraphs above it
|
|
361
|
+
// (shifting it down) and edits one word, so exact/normalized miss and the outward scan must
|
|
362
|
+
// reach the shifted block before the shared budget runs out
|
|
363
|
+
const original =
|
|
364
|
+
"The canonical migration store commits every session write atomically along one path.";
|
|
365
|
+
const plan = (target: string): string => {
|
|
366
|
+
const lines = ["# Plan", ""];
|
|
367
|
+
|
|
368
|
+
for (let index = 0; index < 40; index++) {
|
|
369
|
+
lines.push(
|
|
370
|
+
index === 20
|
|
371
|
+
? target
|
|
372
|
+
: `Filler paragraph ${index} covers an unrelated topic in its own words.`,
|
|
373
|
+
"",
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return lines.join("\n");
|
|
378
|
+
};
|
|
379
|
+
const before = parseBlocks(plan(original));
|
|
380
|
+
const quoted = before.findIndex((block) => block.text === original);
|
|
381
|
+
const anchor = makeAnchor(before, quoted, 0, original.length);
|
|
382
|
+
const edited = original.replace("atomically", "carefully");
|
|
383
|
+
const shifted = parseBlocks(
|
|
384
|
+
plan(edited).replace("# Plan\n", "# Plan\n\nInserted A.\n\nInserted B.\n\nInserted C.\n"),
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
// Act
|
|
388
|
+
const resolved = resolveAnchor(anchor, shifted);
|
|
389
|
+
|
|
390
|
+
// Assert - rebinds via fuzzy onto the shifted block, not orphaned or bound to a neighbour
|
|
391
|
+
expect(resolved?.strategy).toBe("fuzzy");
|
|
392
|
+
expect(shifted[resolved!.blockIndex]!.text).toBe(edited);
|
|
393
|
+
});
|
|
394
|
+
});
|
package/src/anchor.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import type { Anchor } from "./types";
|
|
20
20
|
import { type Block, stripLeadingBlockMarker } from "./markdown";
|
|
21
|
-
import { fuzzyFindBestMatch } from "./fuzzy";
|
|
21
|
+
import { fuzzyFindBestMatch, newFuzzyBudget } from "./fuzzy";
|
|
22
22
|
|
|
23
23
|
/** Characters of surrounding text captured as prefix/suffix selectors. */
|
|
24
24
|
const ANCHOR_CONTEXT_CHARS = 24;
|
|
@@ -139,7 +139,26 @@ function findExactCandidates(anchor: Anchor, blocks: Block[], quote: string): Ca
|
|
|
139
139
|
*/
|
|
140
140
|
const FUZZY_CONTEXT_TIE_BREAK = 0.01;
|
|
141
141
|
|
|
142
|
-
/**
|
|
142
|
+
/** Block indices to scan, spiralling out from the anchor's hinted block: hint, hint+1, hint-1, ... */
|
|
143
|
+
function blockScanOrder(hint: number | undefined, count: number): number[] {
|
|
144
|
+
if (count <= 0) return [];
|
|
145
|
+
const first = Number.isInteger(hint) ? Math.min(Math.max(hint!, 0), count - 1) : 0;
|
|
146
|
+
const order = [first];
|
|
147
|
+
|
|
148
|
+
for (const step of Array.from({ length: count - 1 }, (_unused, index) => index + 1)) {
|
|
149
|
+
if (first + step < count) order.push(first + step);
|
|
150
|
+
if (first - step >= 0) order.push(first - step);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return order;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The best fuzzy window per block, ranked by similarity with context as a tiebreak. One work budget
|
|
158
|
+
* is shared across every block scan, so a stale anchor costs a single budget total, not one per
|
|
159
|
+
* block. Blocks are scanned outward from the anchor's hint, so a quote nudged a few blocks by an
|
|
160
|
+
* edit is reached before the budget runs out; a passage that moved far or is truly gone orphans.
|
|
161
|
+
*/
|
|
143
162
|
function findFuzzyCandidates(
|
|
144
163
|
anchor: Anchor,
|
|
145
164
|
blocks: Block[],
|
|
@@ -147,17 +166,21 @@ function findFuzzyCandidates(
|
|
|
147
166
|
minimumSimilarity: number,
|
|
148
167
|
): Candidate[] {
|
|
149
168
|
const candidates: Candidate[] = [];
|
|
169
|
+
const budget = newFuzzyBudget();
|
|
150
170
|
|
|
151
|
-
|
|
152
|
-
const
|
|
171
|
+
for (const blockIndex of blockScanOrder(anchor.blockIndex, blocks.length)) {
|
|
172
|
+
const block = blocks[blockIndex]!;
|
|
173
|
+
const match = fuzzyFindBestMatch(needle, block.text, minimumSimilarity, budget);
|
|
153
174
|
|
|
154
|
-
if (match
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
175
|
+
if (match !== null) {
|
|
176
|
+
const score =
|
|
177
|
+
match.similarity +
|
|
178
|
+
contextScore(anchor, blockIndex, block, match.start, match.end) * FUZZY_CONTEXT_TIE_BREAK;
|
|
158
179
|
|
|
159
|
-
|
|
160
|
-
|
|
180
|
+
candidates.push({ blockIndex, start: match.start, end: match.end, score });
|
|
181
|
+
}
|
|
182
|
+
if (budget.remaining <= 0) break;
|
|
183
|
+
}
|
|
161
184
|
|
|
162
185
|
return candidates;
|
|
163
186
|
}
|
package/src/fuzzy.ts
CHANGED
|
@@ -63,25 +63,43 @@ export function similarityRatio(left: string, right: string): number {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
|
-
* Character comparisons
|
|
67
|
-
* best window found so far (or null). The scan is O(haystack * needle^3) - a
|
|
66
|
+
* Character comparisons a fuzzy search may spend before it gives up and returns
|
|
67
|
+
* the best window found so far (or null). The scan is O(haystack * needle^3) - a
|
|
68
68
|
* long quote fuzzed over a long document (a stale multi-block anchor whose text
|
|
69
69
|
* is gone) would otherwise pin the CPU for a minute; a passage that is truly gone
|
|
70
70
|
* is better left orphaned than fuzzed. Small everyday quotes never approach it.
|
|
71
71
|
*/
|
|
72
72
|
const FUZZY_WORK_BUDGET = 150_000_000;
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* A mutable work allowance shared across a group of fuzzy searches. Resolving one
|
|
76
|
+
* anchor scans every block, so a per-call cap would still let a stale multi-block
|
|
77
|
+
* anchor spend budget x blocks; sharing one budget across those scans bounds the
|
|
78
|
+
* whole anchor to a single budget, not one per block.
|
|
79
|
+
*/
|
|
80
|
+
export interface FuzzyBudget {
|
|
81
|
+
remaining: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A fresh work allowance for one anchor's fuzzy search across its blocks. */
|
|
85
|
+
export function newFuzzyBudget(): FuzzyBudget {
|
|
86
|
+
return { remaining: FUZZY_WORK_BUDGET };
|
|
87
|
+
}
|
|
88
|
+
|
|
74
89
|
/**
|
|
75
90
|
* Find the haystack window most similar to the needle, or null when nothing
|
|
76
91
|
* clears `minimumSimilarity`. Windows range around the needle length so the
|
|
77
92
|
* match tolerates a few inserted or deleted characters, not only substitutions.
|
|
78
93
|
* Ties keep the earliest window. Offsets index the haystack directly. The scan
|
|
79
|
-
*
|
|
94
|
+
* spends from `budget` (its own single-budget allowance by default) and stops
|
|
95
|
+
* once the budget is exhausted, returning the best window so far - so a caller
|
|
96
|
+
* scanning many blocks can pass one budget to bound them together.
|
|
80
97
|
*/
|
|
81
98
|
export function fuzzyFindBestMatch(
|
|
82
99
|
needle: string,
|
|
83
100
|
haystack: string,
|
|
84
101
|
minimumSimilarity: number,
|
|
102
|
+
budget: FuzzyBudget = newFuzzyBudget(),
|
|
85
103
|
): FuzzyMatch | null {
|
|
86
104
|
if (needle === "" || haystack === "") return null;
|
|
87
105
|
|
|
@@ -90,7 +108,6 @@ export function fuzzyFindBestMatch(
|
|
|
90
108
|
const maximumWindowLength = needle.length + lengthTolerance;
|
|
91
109
|
|
|
92
110
|
let bestMatch: FuzzyMatch | null = null;
|
|
93
|
-
let remainingWork = FUZZY_WORK_BUDGET;
|
|
94
111
|
|
|
95
112
|
for (let start = 0; start < haystack.length; start++) {
|
|
96
113
|
for (
|
|
@@ -98,9 +115,9 @@ export function fuzzyFindBestMatch(
|
|
|
98
115
|
windowLength <= maximumWindowLength && start + windowLength <= haystack.length;
|
|
99
116
|
windowLength++
|
|
100
117
|
) {
|
|
101
|
-
// each window costs a needle x window edit-distance pass; spend that from the budget
|
|
102
|
-
|
|
103
|
-
if (
|
|
118
|
+
// each window costs a needle x window edit-distance pass; spend that from the shared budget
|
|
119
|
+
budget.remaining -= needle.length * windowLength;
|
|
120
|
+
if (budget.remaining <= 0) return bestMatch;
|
|
104
121
|
const window = haystack.slice(start, start + windowLength);
|
|
105
122
|
const similarity = similarityRatio(needle, window);
|
|
106
123
|
|
package/src/history.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A
|
|
2
|
+
* A thread's history as an append-only tree of entries. Each entry
|
|
3
3
|
* points at its parent; a branch is a named tip; the active path is the chain
|
|
4
4
|
* from the current branch's tip back to the root. Nothing is ever deleted:
|
|
5
5
|
* navigating moves a tip, a comment removal is its own entry, a fork copies a
|
package/src/index.ts
CHANGED
package/src/participants.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { registerParticipant } from "./participants";
|
|
2
|
+
import { registerParticipant, registeredGithubLogin } from "./participants";
|
|
3
3
|
import { SCHEMA_VERSION, type Thread } from "./types";
|
|
4
4
|
|
|
5
5
|
function emptySession(): Thread {
|
|
@@ -55,4 +55,51 @@ describe("registerParticipant", () => {
|
|
|
55
55
|
// Assert
|
|
56
56
|
expect(next.participants).toEqual([{ id: "SHA256:ana", provider: "ssh", name: "Ana" }]);
|
|
57
57
|
});
|
|
58
|
+
|
|
59
|
+
test("records a verified github source with its login handle", () => {
|
|
60
|
+
// Act
|
|
61
|
+
const next = registerParticipant(emptySession(), "SHA256:ana", "Ana", {
|
|
62
|
+
provider: "github",
|
|
63
|
+
handle: "ana",
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Assert
|
|
67
|
+
expect(next.participants).toEqual([
|
|
68
|
+
{ id: "SHA256:ana", provider: "github", name: "Ana", handle: "ana" },
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("a later nameless write preserves the github provider and handle", () => {
|
|
73
|
+
// Arrange
|
|
74
|
+
const verified = registerParticipant(emptySession(), "SHA256:ana", "Ana", {
|
|
75
|
+
provider: "github",
|
|
76
|
+
handle: "ana",
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Act
|
|
80
|
+
const next = registerParticipant(verified, "SHA256:ana");
|
|
81
|
+
|
|
82
|
+
// Assert
|
|
83
|
+
expect(next.participants).toEqual([
|
|
84
|
+
{ id: "SHA256:ana", provider: "github", name: "Ana", handle: "ana" },
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("registeredGithubLogin", () => {
|
|
90
|
+
test("returns the handle a fingerprint verified on a past visit", () => {
|
|
91
|
+
const session = registerParticipant(emptySession(), "SHA256:ana", "Ana", {
|
|
92
|
+
provider: "github",
|
|
93
|
+
handle: "ana-gh",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(registeredGithubLogin(session, "SHA256:ana")).toBe("ana-gh");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("returns undefined for an ssh-only or unknown participant", () => {
|
|
100
|
+
const session = registerParticipant(emptySession(), "SHA256:ana", "Ana");
|
|
101
|
+
|
|
102
|
+
expect(registeredGithubLogin(session, "SHA256:ana")).toBeUndefined();
|
|
103
|
+
expect(registeredGithubLogin(session, "SHA256:unknown")).toBeUndefined();
|
|
104
|
+
});
|
|
58
105
|
});
|
package/src/participants.ts
CHANGED
|
@@ -13,17 +13,34 @@ import type { Identity, Thread } from "./types";
|
|
|
13
13
|
* rail renders anonymous) and never erases a name a past visit set. Immutable:
|
|
14
14
|
* the input session is not mutated.
|
|
15
15
|
*/
|
|
16
|
-
export
|
|
16
|
+
export interface ParticipantSource {
|
|
17
|
+
provider: "ssh" | "github";
|
|
18
|
+
handle?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function registeredGithubLogin(session: Thread, author: string): string | undefined {
|
|
22
|
+
const existing = (session.participants ?? []).find((participant) => participant.id === author);
|
|
23
|
+
|
|
24
|
+
return existing?.provider === "github" ? existing.handle : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function registerParticipant(
|
|
28
|
+
session: Thread,
|
|
29
|
+
author: string,
|
|
30
|
+
name?: string,
|
|
31
|
+
source?: ParticipantSource,
|
|
32
|
+
): Thread {
|
|
17
33
|
const participants = session.participants ?? [];
|
|
18
34
|
const existing = participants.find((participant) => participant.id === author);
|
|
19
35
|
const trimmed = name?.trim();
|
|
20
36
|
|
|
21
|
-
if (existing && !trimmed) return session;
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
37
|
+
if (existing && !trimmed && !source) return session;
|
|
38
|
+
const resolvedName = trimmed ?? existing?.name;
|
|
39
|
+
const resolvedHandle = source?.handle ?? existing?.handle;
|
|
40
|
+
const next: Identity = { id: author, provider: source?.provider ?? existing?.provider ?? "ssh" };
|
|
41
|
+
|
|
42
|
+
if (resolvedName) next.name = resolvedName;
|
|
43
|
+
if (resolvedHandle) next.handle = resolvedHandle;
|
|
27
44
|
|
|
28
45
|
return {
|
|
29
46
|
...session,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { isShareViewerAllowed } from "./share-access";
|
|
3
|
+
|
|
4
|
+
describe("isShareViewerAllowed", () => {
|
|
5
|
+
test("an unauthenticated viewer is refused", () => {
|
|
6
|
+
expect(isShareViewerAllowed({ githubLogins: ["octocat"] }, undefined)).toBe(false);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("a member is admitted, case-insensitively", () => {
|
|
10
|
+
expect(isShareViewerAllowed({ githubLogins: ["OctoCat"] }, "octocat")).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("a non-member is refused", () => {
|
|
14
|
+
expect(isShareViewerAllowed({ githubLogins: ["octocat"] }, "hubot")).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("an empty allowlist admits no one", () => {
|
|
18
|
+
expect(isShareViewerAllowed({ githubLogins: [] }, "octocat")).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Whether a github login may view a private share; membership is case-insensitive and an unauthenticated viewer is never allowed. */
|
|
2
|
+
|
|
3
|
+
import type { ShareAccess } from "./types";
|
|
4
|
+
|
|
5
|
+
export function isShareViewerAllowed(
|
|
6
|
+
access: ShareAccess,
|
|
7
|
+
githubLogin: string | undefined,
|
|
8
|
+
): boolean {
|
|
9
|
+
if (githubLogin === undefined) return false;
|
|
10
|
+
const login = githubLogin.toLowerCase();
|
|
11
|
+
|
|
12
|
+
return access.githubLogins.some((allowed) => allowed.toLowerCase() === login);
|
|
13
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { SessionHistory } from "./history";
|
|
|
8
8
|
|
|
9
9
|
export const SCHEMA_VERSION = "1";
|
|
10
10
|
|
|
11
|
-
/** A workspace is a repo/branch context holding
|
|
11
|
+
/** A workspace is a repo/branch context holding threads. */
|
|
12
12
|
export interface WorkspaceKey {
|
|
13
13
|
repoRoot: string;
|
|
14
14
|
branch: string;
|
|
@@ -19,7 +19,7 @@ export interface WorkspaceKey {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* What kind of artifact a
|
|
22
|
+
* What kind of artifact a thread holds. `plan` and `reply` are both
|
|
23
23
|
* markdown documents (see isMarkdownArtifact) - a plan is a proposal written
|
|
24
24
|
* forward, a reply is the agent's previous message pulled back for review.
|
|
25
25
|
* `diff` is a unified-diff patch; `prototype` is a component design doc (API /
|
|
@@ -224,14 +224,19 @@ export type SessionStatus = "pending" | "resolved";
|
|
|
224
224
|
export interface Identity {
|
|
225
225
|
/** Stable identity key; equals an annotation's `author`. */
|
|
226
226
|
id: string;
|
|
227
|
-
/** Identity source
|
|
228
|
-
provider: "ssh";
|
|
227
|
+
/** Identity source: the SSH key that authored, or a verified GitHub login. */
|
|
228
|
+
provider: "ssh" | "github";
|
|
229
229
|
/** Display name; absent = the collaborator stayed anonymous. */
|
|
230
230
|
name?: string;
|
|
231
231
|
/** Provider handle: a github login, an email, or a short fingerprint. */
|
|
232
232
|
handle?: string;
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
/** Owner-set access control for a private share: only these GitHub logins may open it. Absent = a public share. */
|
|
236
|
+
export interface ShareAccess {
|
|
237
|
+
githubLogins: string[];
|
|
238
|
+
}
|
|
239
|
+
|
|
235
240
|
export interface Thread {
|
|
236
241
|
schemaVersion: string;
|
|
237
242
|
id: string;
|
|
@@ -278,6 +283,8 @@ export interface Thread {
|
|
|
278
283
|
shareBranch?: string;
|
|
279
284
|
/** SSH fingerprint that created the share; the gateway stamps it to gate pulls. */
|
|
280
285
|
owner?: string;
|
|
286
|
+
/** Owner-set allowlist of GitHub logins for a private share; absent = public. */
|
|
287
|
+
access?: ShareAccess;
|
|
281
288
|
/**
|
|
282
289
|
* Identities that authored annotations here, keyed by id (union-by-id, like
|
|
283
290
|
* annotations). The gateway records a collaborator's identity and chosen name;
|
|
@@ -286,9 +293,6 @@ export interface Thread {
|
|
|
286
293
|
participants?: Identity[];
|
|
287
294
|
}
|
|
288
295
|
|
|
289
|
-
/** @deprecated use Thread */
|
|
290
|
-
export type ReviewSession = Thread;
|
|
291
|
-
|
|
292
296
|
/** comment and request_changes both map to deny in agent-native contracts. */
|
|
293
297
|
export function verdictAllows(kind: VerdictKind): boolean {
|
|
294
298
|
return kind === "approve";
|