@levistudio/redline 0.4.0 → 0.5.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/AGENTS.md +8 -4
- package/CHANGELOG.md +16 -1
- package/package.json +1 -1
- package/skills/redline-review/SKILL.md +15 -18
- package/src/agent.ts +1 -1
- package/src/authorHandoff.ts +191 -0
- package/src/cli.ts +81 -5
- package/src/client/cards.ts +4 -4
- package/src/client/lib.ts +12 -2
- package/src/client/render.ts +3 -3
- package/src/client/styles.css +10 -3
- package/src/reviewSummary.ts +26 -14
- package/src/server.ts +3 -1
- package/src/sidecar.ts +7 -3
package/AGENTS.md
CHANGED
|
@@ -35,6 +35,9 @@ redline <file> --context "..." # opens with a reviewer-supplied focus statement
|
|
|
35
35
|
redline <file> --agent codex # use Codex instead of the auto-detected/default provider
|
|
36
36
|
redline <file> --no-agent # manual annotation mode — no agent spawn, no provider CLI required on PATH
|
|
37
37
|
redline resolve <file> # one-shot: read the sidecar, run the revision pass, write back
|
|
38
|
+
redline author-needed <file> # list comments where the inline agent requested author input
|
|
39
|
+
redline author-reply <file> <comment-id> --message "..." # post an author-marked reply into the thread
|
|
40
|
+
redline author-wait <file> # block until author input is needed or the review result is written
|
|
38
41
|
```
|
|
39
42
|
|
|
40
43
|
The bare-arg path also spawns a dedicated agent subprocess alongside the server — see "The dedicated agent process" below.
|
|
@@ -196,12 +199,13 @@ JSON was tried first and abandoned: agent replies frequently contain quotes, cod
|
|
|
196
199
|
|
|
197
200
|
The verdict is **agent-owned**. The human cannot flip it directly. Disagreement flows through a follow-up reply, which gives the agent a chance to re-classify rather than be silently overridden.
|
|
198
201
|
|
|
199
|
-
##
|
|
202
|
+
## Author handoff
|
|
200
203
|
|
|
201
|
-
The inline review agent and the agent that *launched* `redline`
|
|
204
|
+
The inline review agent and the agent that *authored/launched* `redline` are separate processes. The sidecar is the persisted artifact, and `.startup.json` gives the authoring agent a local API bridge while the session is live. Two mechanisms carry feedback back to it:
|
|
202
205
|
|
|
203
|
-
- **`ESCALATE` verdict.**
|
|
204
|
-
- **
|
|
206
|
+
- **`ESCALATE` verdict.** The storage/envelope name is still `ESCALATE` for compatibility, but product language is **author reply needed**. The inline agent sets `ESCALATE: true` when a comment needs author-level input: information, tools, authority, or project context it cannot access from the document and comment thread (an external style guide, a spec to check against, a wider-project decision). It does **not** set it for ordinary requested edits, reframes, emphasis changes, rewrites, or approvals; those are handled by `requires_revision`. It's stored as `escalate?: boolean` on the agent's `ThreadEntry` and rendered as an "↑ Author reply needed" badge on the comment. The author handoff signal is independent of `requires_revision` — the agent judges each on its own.
|
|
207
|
+
- **Live author replies.** The authoring agent can run `redline author-wait <file>` while a session is open; it returns JSON when either a pending author-needed comment appears or the final `.result` is written. For pending comments, it can run `redline author-reply <file> <comment-id> --message "..."` to post back into the same thread, then wait again. When the server is live, the reply command uses `.review/<file>.startup.json` and the local API so the browser soft-refreshes; if the server is gone, it falls back to a locked sidecar write.
|
|
208
|
+
- **Closeout transcript.** On `finished`, the CLI loads the sidecar and prints every comment thread verbatim via [src/reviewSummary.ts](src/reviewSummary.ts) (`formatReviewSummary`), with a dedicated callout listing comments that need author input (`collectEscalations`). The count is also written to `.review/<file>.result` and appended to the `REDLINE_RESULT:` line. This remains the fallback read point for abandoned or unfinished sessions.
|
|
205
209
|
|
|
206
210
|
## Security & resilience as built
|
|
207
211
|
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,19 @@ All notable changes to Redline are documented here. The format follows [Keep a C
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.5.0] - 2026-05-15
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Added author-handoff CLI commands: `redline author-needed <file>` lists pending author-needed comments, `redline author-reply <file> <comment-id> --message "..."` posts an author-marked reply back into the thread, and `redline author-wait <file>` blocks until either author input is needed or the review finishes.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- Reframed M9 author handoff language from "escalated to the launching agent" to "author reply needed" while preserving the existing `ESCALATE`/`escalations` compatibility fields.
|
|
14
|
+
|
|
15
|
+
## [0.4.1] - 2026-05-15
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- `redline-review` now launches Redline with `--open` so agent sessions open the review in the user's real browser instead of relying on clickable localhost links that some agent UIs capture in an embedded preview panel.
|
|
19
|
+
|
|
7
20
|
## [0.4.0] - 2026-05-15
|
|
8
21
|
|
|
9
22
|
### Added
|
|
@@ -60,7 +73,9 @@ Initial public release on npm as `@levistudio/redline`.
|
|
|
60
73
|
- Auto-installs missing dependencies on first CLI run.
|
|
61
74
|
- Initial test suite: server, sidecar, parsing, model-picking, rendering, diff, SSE, integration, happy-dom client.
|
|
62
75
|
|
|
63
|
-
[Unreleased]: https://github.com/alevi/redline/compare/v0.
|
|
76
|
+
[Unreleased]: https://github.com/alevi/redline/compare/v0.5.0...HEAD
|
|
77
|
+
[0.5.0]: https://github.com/alevi/redline/compare/v0.4.1...v0.5.0
|
|
78
|
+
[0.4.1]: https://github.com/alevi/redline/compare/v0.4.0...v0.4.1
|
|
64
79
|
[0.4.0]: https://github.com/alevi/redline/compare/v0.3.0...v0.4.0
|
|
65
80
|
[0.3.0]: https://github.com/alevi/redline/releases/tag/v0.3.0
|
|
66
81
|
[0.2.0]: https://github.com/alevi/redline/releases/tag/v0.2.0
|
package/package.json
CHANGED
|
@@ -20,8 +20,8 @@ STARTUP="$DIR/.review/$BASE.startup.json"
|
|
|
20
20
|
RESULT="$DIR/.review/$BASE.result"
|
|
21
21
|
LOG=/tmp/redline-$BASE.log
|
|
22
22
|
|
|
23
|
-
# Kick off the review in the background.
|
|
24
|
-
__REDLINE_BIN__ "$FILE" > "$LOG" 2>&1 &
|
|
23
|
+
# Kick off the review in the background and open it in the user's real browser.
|
|
24
|
+
__REDLINE_BIN__ "$FILE" --open > "$LOG" 2>&1 &
|
|
25
25
|
|
|
26
26
|
# Step 1: wait for startup, read the URL.
|
|
27
27
|
for i in $(seq 1 60); do [ -f "$STARTUP" ] && break; sleep 0.5; done
|
|
@@ -34,20 +34,19 @@ PID=$(grep -o '"pid": *[0-9]*' "$STARTUP" | grep -o '[0-9]*')
|
|
|
34
34
|
echo "REDLINE_URL: $URL"
|
|
35
35
|
echo "REDLINE_PID: $PID"
|
|
36
36
|
|
|
37
|
-
# Step 2:
|
|
38
|
-
# — see the next section), then wait
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
|
|
42
|
-
cat "$RESULT"
|
|
37
|
+
# Step 2: tell the human the browser opened (you do this after the first shell call returns
|
|
38
|
+
# — see the next section), then wait until either author input is needed or the
|
|
39
|
+
# review exits. If author input is needed, answer it with author-reply and run
|
|
40
|
+
# author-wait again.
|
|
41
|
+
__REDLINE_BIN__ author-wait "$FILE"
|
|
43
42
|
```
|
|
44
43
|
|
|
45
44
|
The startup file at `.review/<basename>.startup.json` is written synchronously when the server begins listening; it contains `url`, `port`, `file`, `result_file`, `started_at`, `pid`. The result file at `.review/<basename>.result` is written when the session ends (approved, abandoned, or error).
|
|
46
45
|
|
|
47
46
|
In practice, run the script above as **two separate shell calls** so you can tell the human the URL between steps:
|
|
48
47
|
1. First call: everything through `echo "REDLINE_PID: $PID"`. Returns in ~1s with the URL and PID on stdout.
|
|
49
|
-
2.
|
|
50
|
-
3. Second call:
|
|
48
|
+
2. Tell the human Redline opened in their browser, and include the URL only as a fallback (see "Surfacing the URL" below).
|
|
49
|
+
3. Second call: `__REDLINE_BIN__ author-wait "$FILE"`. It returns either `{ "kind": "author-needed", ... }` or `{ "kind": "result", ... }`. Long timeout (`timeout: 1800000` = 30 min, or longer). If it returns author-needed JSON, answer with `author-reply`, then run `author-wait` again.
|
|
51
50
|
|
|
52
51
|
If invocation fails (binary missing, startup file never appears, etc.), surface the error verbatim and stop — do not try to recover. The human will re-run `redline install-skill`.
|
|
53
52
|
|
|
@@ -61,11 +60,9 @@ The context string is shown in the reader's header so the human knows what they'
|
|
|
61
60
|
|
|
62
61
|
### Surfacing the URL
|
|
63
62
|
|
|
64
|
-
After the first shell call returns with `REDLINE_URL: http://localhost:NNNN`,
|
|
63
|
+
The `--open` flag launches the review in the user's real browser via the OS opener (`open` on macOS, `xdg-open` on Linux, `start` on Windows). After the first shell call returns with `REDLINE_URL: http://localhost:NNNN`, tell the human the browser opened and include the URL only as a fallback. Do **not** make the URL the primary action; in some agent UIs, clicking localhost opens an embedded preview panel instead of the user's browser.
|
|
65
64
|
|
|
66
|
-
> "
|
|
67
|
-
|
|
68
|
-
(There is an `--open` flag that auto-launches the browser, but prefer leaving it off — the human may not be at the keyboard the moment the session starts, and a stolen-focus browser tab is worse than a URL they click when ready.)
|
|
65
|
+
> "I opened this in Redline in your browser. Fallback URL: http://localhost:NNNN. I'll continue once you click Done."
|
|
69
66
|
|
|
70
67
|
## How to interpret the result
|
|
71
68
|
|
|
@@ -87,13 +84,13 @@ The full loop, when you are the outer agent producing the doc:
|
|
|
87
84
|
|
|
88
85
|
1. Write the markdown file to disk at an absolute path.
|
|
89
86
|
2. Tell the human in one sentence what's about to happen.
|
|
90
|
-
3. First shell call: launch `__REDLINE_BIN__ <abs-path> --context "<one-liner>"` in the background and poll for `.startup.json`. Returns in ~1s with the URL.
|
|
91
|
-
4.
|
|
92
|
-
5. Second shell call:
|
|
87
|
+
3. First shell call: launch `__REDLINE_BIN__ <abs-path> --context "<one-liner>" --open` in the background and poll for `.startup.json`. Returns in ~1s with the URL.
|
|
88
|
+
4. Tell the human Redline opened in their browser and include the URL only as a fallback.
|
|
89
|
+
5. Second shell call: run `__REDLINE_BIN__ author-wait "$FILE"`. If it returns `kind: "author-needed"`, answer with `__REDLINE_BIN__ author-reply "$FILE" <comment-id> --message "..."`, then run `author-wait` again. Do not start unrelated work while the session runs.
|
|
93
90
|
6. On `approved`: re-read the file from disk (it may have been revised) and continue with whatever required sign-off.
|
|
94
91
|
7. On `abandoned` or `error`: stop and ask the human how to proceed; do not retry automatically.
|
|
95
92
|
|
|
96
|
-
You do not need to reply to comments — Redline spawns its own agent subprocess for that. You do not need to invoke `redline resolve` separately — revisions happen inside the session when the human accepts.
|
|
93
|
+
You usually do not need to reply to comments — Redline spawns its own inline agent subprocess for that. The exception is an author-needed handoff: if `author-wait` returns `kind: "author-needed"`, you are the authoring agent and should answer only when you have the project context, tools, or authority the inline agent lacked. You do not need to invoke `redline resolve` separately — revisions happen inside the session when the human accepts.
|
|
97
94
|
|
|
98
95
|
## When *not* to use this
|
|
99
96
|
|
package/src/agent.ts
CHANGED
|
@@ -91,7 +91,7 @@ const REPLY_SYSTEM_PROMPT_BODY =
|
|
|
91
91
|
"Good: message: \"Got it.\" reason: \"Add a third line to the hard line breaks example\"\n" +
|
|
92
92
|
"When requires_revision is false, leave reason empty (or a very short note about why no edit). The reply IS the answer.\n" +
|
|
93
93
|
"\n" +
|
|
94
|
-
"Separately, decide whether this comment needs the agent that
|
|
94
|
+
"Separately, decide whether this comment needs an AUTHOR reply from the agent that launched/wrote this document rather than you. ESCALATE is rare for now: set ESCALATE: true only when the reviewer explicitly asks for information, tools, authority, or project context you cannot access from the document and comment thread — for example an external style guide, a hidden spec, repo-wide code context, or a decision the authoring agent must make. Do NOT route ordinary requested edits, reframes, emphasis changes, priority changes, rewrites, or approvals; those are handled by requires_revision. If the comment can be satisfied by editing this document, set ESCALATE: false. When you do route to the author, briefly tell the reviewer that an author reply is needed. Author handoff does not change requires_revision — judge that on its own.\n" +
|
|
95
95
|
"\n" +
|
|
96
96
|
"Output exactly this format and nothing else (no prose before/after, no code fences). Use these literal markers — do not escape quotes inside the message:\n" +
|
|
97
97
|
"REQUIRES_REVISION: <true|false>\n" +
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "fs";
|
|
2
|
+
import { readFile } from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { loadSidecar, withSidecar, type Comment, type Sidecar, type ThreadEntry } from "./sidecar";
|
|
5
|
+
|
|
6
|
+
export interface AuthorNeededItem {
|
|
7
|
+
round: number;
|
|
8
|
+
commentId: string;
|
|
9
|
+
quote: string;
|
|
10
|
+
request: string;
|
|
11
|
+
note: string;
|
|
12
|
+
resolved: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function flatten(s: string, n: number): string {
|
|
16
|
+
const flat = s.replace(/\s+/g, " ").trim();
|
|
17
|
+
return flat.length > n ? flat.slice(0, n - 1).trimEnd() + "…" : flat;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function latestIndex(thread: ThreadEntry[], predicate: (entry: ThreadEntry) => boolean): number {
|
|
21
|
+
for (let i = thread.length - 1; i >= 0; i--) {
|
|
22
|
+
if (predicate(thread[i]!)) return i;
|
|
23
|
+
}
|
|
24
|
+
return -1;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function collectAuthorNeeded(sidecar: Sidecar): AuthorNeededItem[] {
|
|
28
|
+
const items: AuthorNeededItem[] = [];
|
|
29
|
+
for (const round of sidecar.rounds) {
|
|
30
|
+
for (const comment of round.comments) {
|
|
31
|
+
const escIdx = latestIndex(comment.thread, (entry) => entry.role === "agent" && entry.escalate === true);
|
|
32
|
+
if (escIdx === -1) continue;
|
|
33
|
+
const authorIdx = latestIndex(comment.thread, (entry) => entry.role === "agent" && entry.author === true);
|
|
34
|
+
if (authorIdx > escIdx) continue;
|
|
35
|
+
|
|
36
|
+
const agentEntry = comment.thread[escIdx]!;
|
|
37
|
+
let request = "";
|
|
38
|
+
for (let i = escIdx - 1; i >= 0; i--) {
|
|
39
|
+
if (comment.thread[i]!.role === "human") {
|
|
40
|
+
request = comment.thread[i]!.message;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
items.push({
|
|
46
|
+
round: round.round,
|
|
47
|
+
commentId: comment.id,
|
|
48
|
+
quote: flatten(comment.quote, 120),
|
|
49
|
+
request: flatten(request, 500),
|
|
50
|
+
note: flatten(agentEntry.revision_reason || agentEntry.message, 500),
|
|
51
|
+
resolved: comment.resolved,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return items;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function listAuthorNeeded(filePath: string): Promise<AuthorNeededItem[]> {
|
|
59
|
+
return collectAuthorNeeded(await loadSidecar(filePath));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface AuthorReplyResult {
|
|
63
|
+
via: "server" | "sidecar";
|
|
64
|
+
commentId: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type AuthorWaitResult =
|
|
68
|
+
| { kind: "author-needed"; file: string; author_needed: AuthorNeededItem[] }
|
|
69
|
+
| { kind: "result"; file: string; result: Record<string, unknown> };
|
|
70
|
+
|
|
71
|
+
function startupPath(filePath: string): string {
|
|
72
|
+
return path.join(path.dirname(filePath), ".review", path.basename(filePath) + ".startup.json");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function resultPath(filePath: string): string {
|
|
76
|
+
return path.join(path.dirname(filePath), ".review", path.basename(filePath) + ".result");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readStartup(filePath: string): { url?: string; csrf_token?: string } | null {
|
|
80
|
+
const sp = startupPath(filePath);
|
|
81
|
+
if (!existsSync(sp)) return null;
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(readFileSync(sp, "utf-8")) as { url?: string; csrf_token?: string };
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function postToLiveServer(
|
|
90
|
+
filePath: string,
|
|
91
|
+
commentId: string,
|
|
92
|
+
message: string,
|
|
93
|
+
name: string,
|
|
94
|
+
): Promise<boolean> {
|
|
95
|
+
const startup = readStartup(filePath);
|
|
96
|
+
if (!startup?.url || !startup.csrf_token) return false;
|
|
97
|
+
try {
|
|
98
|
+
const res = await fetch(`${startup.url}/api/comment/${encodeURIComponent(commentId)}/reply`, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: {
|
|
101
|
+
"Content-Type": "application/json",
|
|
102
|
+
"X-Redline-Token": startup.csrf_token,
|
|
103
|
+
},
|
|
104
|
+
body: JSON.stringify({ role: "agent", name, message, author: true }),
|
|
105
|
+
});
|
|
106
|
+
return res.ok;
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function findComment(sidecar: Sidecar, commentId: string): Comment | null {
|
|
113
|
+
for (const round of sidecar.rounds) {
|
|
114
|
+
const found = round.comments.find((comment) => comment.id === commentId);
|
|
115
|
+
if (found) return found;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function postAuthorReply(
|
|
121
|
+
filePath: string,
|
|
122
|
+
commentId: string,
|
|
123
|
+
message: string,
|
|
124
|
+
options: { name?: string } = {},
|
|
125
|
+
): Promise<AuthorReplyResult> {
|
|
126
|
+
const trimmed = message.trim();
|
|
127
|
+
if (!trimmed) throw new Error("message is required");
|
|
128
|
+
const name = options.name?.trim() || "Author";
|
|
129
|
+
|
|
130
|
+
if (await postToLiveServer(filePath, commentId, trimmed, name)) {
|
|
131
|
+
return { via: "server", commentId };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const result = await withSidecar(filePath, (sidecar) => {
|
|
135
|
+
const comment = findComment(sidecar, commentId);
|
|
136
|
+
if (!comment) return { ok: false as const };
|
|
137
|
+
comment.thread.push({
|
|
138
|
+
role: "agent",
|
|
139
|
+
name,
|
|
140
|
+
message: trimmed,
|
|
141
|
+
at: new Date().toISOString(),
|
|
142
|
+
author: true,
|
|
143
|
+
});
|
|
144
|
+
return { ok: true as const };
|
|
145
|
+
});
|
|
146
|
+
if (!result.ok) throw new Error(`Comment not found: ${commentId}`);
|
|
147
|
+
return { via: "sidecar", commentId };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function formatAuthorNeeded(items: AuthorNeededItem[]): string {
|
|
151
|
+
if (items.length === 0) return "No author replies needed.";
|
|
152
|
+
const lines = [
|
|
153
|
+
`${items.length} comment${items.length === 1 ? " needs an author reply" : "s need author replies"}:`,
|
|
154
|
+
];
|
|
155
|
+
for (const item of items) {
|
|
156
|
+
lines.push(`- ${item.commentId} (round ${item.round}${item.resolved ? ", resolved" : ", open"}): "${item.quote}"`);
|
|
157
|
+
if (item.request) lines.push(` Reviewer: ${item.request}`);
|
|
158
|
+
if (item.note) lines.push(` Inline agent: ${item.note}`);
|
|
159
|
+
}
|
|
160
|
+
return lines.join("\n");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function sleep(ms: number): Promise<void> {
|
|
164
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function waitForAuthorEvent(
|
|
168
|
+
filePath: string,
|
|
169
|
+
options: { intervalMs?: number; timeoutMs?: number } = {},
|
|
170
|
+
): Promise<AuthorWaitResult> {
|
|
171
|
+
const intervalMs = Math.max(50, options.intervalMs ?? 500);
|
|
172
|
+
const deadline = options.timeoutMs != null ? Date.now() + options.timeoutMs : null;
|
|
173
|
+
const rp = resultPath(filePath);
|
|
174
|
+
|
|
175
|
+
while (true) {
|
|
176
|
+
const authorNeeded = await listAuthorNeeded(filePath);
|
|
177
|
+
if (authorNeeded.length > 0) {
|
|
178
|
+
return { kind: "author-needed", file: filePath, author_needed: authorNeeded };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (existsSync(rp)) {
|
|
182
|
+
const raw = await readFile(rp, "utf-8");
|
|
183
|
+
return { kind: "result", file: filePath, result: JSON.parse(raw) as Record<string, unknown> };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (deadline != null && Date.now() >= deadline) {
|
|
187
|
+
throw new Error("Timed out waiting for author-needed comments or review result");
|
|
188
|
+
}
|
|
189
|
+
await sleep(intervalMs);
|
|
190
|
+
}
|
|
191
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -45,6 +45,7 @@ preflightDependencies();
|
|
|
45
45
|
// Dynamic imports so preflight runs before module resolution pulls in third-party deps.
|
|
46
46
|
const { createServer } = await import("./server");
|
|
47
47
|
const { resolve } = await import("./resolve");
|
|
48
|
+
const { formatAuthorNeeded, listAuthorNeeded, postAuthorReply, waitForAuthorEvent } = await import("./authorHandoff");
|
|
48
49
|
const {
|
|
49
50
|
getAgentProvider,
|
|
50
51
|
invalidProviderMessage,
|
|
@@ -123,6 +124,81 @@ if (args[0] === "install-skill") {
|
|
|
123
124
|
process.exit(result.status ?? 1);
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
// redline author-needed <file> [--json]
|
|
128
|
+
if (args[0] === "author-needed") {
|
|
129
|
+
const filePath = args[1];
|
|
130
|
+
if (!filePath) {
|
|
131
|
+
console.error("Usage: redline author-needed <file.md> [--json]");
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
const resolved = path.resolve(filePath);
|
|
135
|
+
if (!existsSync(resolved)) {
|
|
136
|
+
console.error(`File not found: ${resolved}`);
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
const items = await listAuthorNeeded(resolved);
|
|
140
|
+
if (args.includes("--json")) {
|
|
141
|
+
console.log(JSON.stringify({ file: resolved, author_needed: items }, null, 2));
|
|
142
|
+
} else {
|
|
143
|
+
console.log(formatAuthorNeeded(items));
|
|
144
|
+
}
|
|
145
|
+
process.exit(0);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// redline author-reply <file> <comment-id> --message "..."
|
|
149
|
+
if (args[0] === "author-reply") {
|
|
150
|
+
const filePath = args[1];
|
|
151
|
+
const commentId = args[2];
|
|
152
|
+
const message = argValue(args, "--message");
|
|
153
|
+
if (!filePath || !commentId || !message) {
|
|
154
|
+
console.error("Usage: redline author-reply <file.md> <comment-id> --message \"...\" [--name \"Author\"]");
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
const resolved = path.resolve(filePath);
|
|
158
|
+
if (!existsSync(resolved)) {
|
|
159
|
+
console.error(`File not found: ${resolved}`);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
const result = await postAuthorReply(resolved, commentId, message, { name: argValue(args, "--name") });
|
|
164
|
+
console.log(`Posted author reply to ${result.commentId} via ${result.via}.`);
|
|
165
|
+
} catch (e) {
|
|
166
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
process.exit(0);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// redline author-wait <file> [--timeout-ms <ms>] [--interval-ms <ms>]
|
|
173
|
+
if (args[0] === "author-wait") {
|
|
174
|
+
const filePath = args[1];
|
|
175
|
+
if (!filePath) {
|
|
176
|
+
console.error("Usage: redline author-wait <file.md> [--timeout-ms <ms>] [--interval-ms <ms>]");
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
const resolved = path.resolve(filePath);
|
|
180
|
+
if (!existsSync(resolved)) {
|
|
181
|
+
console.error(`File not found: ${resolved}`);
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
const timeoutRaw = argValue(args, "--timeout-ms");
|
|
185
|
+
const intervalRaw = argValue(args, "--interval-ms");
|
|
186
|
+
const timeoutMs = timeoutRaw != null ? Number(timeoutRaw) : undefined;
|
|
187
|
+
const intervalMs = intervalRaw != null ? Number(intervalRaw) : undefined;
|
|
188
|
+
if ((timeoutRaw != null && !Number.isFinite(timeoutMs)) || (intervalRaw != null && !Number.isFinite(intervalMs))) {
|
|
189
|
+
console.error("--timeout-ms and --interval-ms must be numbers");
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
const result = await waitForAuthorEvent(resolved, { timeoutMs, intervalMs });
|
|
194
|
+
console.log(JSON.stringify(result, null, 2));
|
|
195
|
+
} catch (e) {
|
|
196
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
process.exit(0);
|
|
200
|
+
}
|
|
201
|
+
|
|
126
202
|
// redline resolve <file> [--model <id>]
|
|
127
203
|
if (args[0] === "resolve") {
|
|
128
204
|
const filePath = args[1];
|
|
@@ -148,7 +224,7 @@ if (args[0] === "resolve") {
|
|
|
148
224
|
// redline <file> — open review reader
|
|
149
225
|
const filePath = args[0];
|
|
150
226
|
if (!filePath) {
|
|
151
|
-
console.error("Usage: redline <file.md>\n redline resolve <file.md> [--model <model-id>] [--agent claude|codex]\n redline install-skill [--agent claude|codex|both]");
|
|
227
|
+
console.error("Usage: redline <file.md>\n redline resolve <file.md> [--model <model-id>] [--agent claude|codex]\n redline author-needed <file.md> [--json]\n redline author-reply <file.md> <comment-id> --message \"...\" [--name \"Author\"]\n redline author-wait <file.md> [--timeout-ms <ms>] [--interval-ms <ms>]\n redline install-skill [--agent claude|codex|both]");
|
|
152
228
|
process.exit(1);
|
|
153
229
|
}
|
|
154
230
|
const resolved = path.resolve(filePath);
|
|
@@ -315,7 +391,7 @@ if (args[0] === "resolve") {
|
|
|
315
391
|
const status = lastRevisionError ? "error" : "abandoned";
|
|
316
392
|
const payload: Record<string, unknown> = { status, file: resolved };
|
|
317
393
|
if (lastRevisionError) payload.reason = lastRevisionError;
|
|
318
|
-
// Carry
|
|
394
|
+
// Carry author handoffs through the error/abandon path too — on an incomplete
|
|
319
395
|
// session they matter more, not less. Read the sidecar synchronously:
|
|
320
396
|
// abandon runs in signal context where async I/O may not complete.
|
|
321
397
|
let escalations: import("./reviewSummary").EscalationItem[] = [];
|
|
@@ -334,7 +410,7 @@ if (args[0] === "resolve") {
|
|
|
334
410
|
} catch { /* best effort */ }
|
|
335
411
|
if (escalations.length) {
|
|
336
412
|
console.log(
|
|
337
|
-
`\n⚠ ${escalations.length} comment${escalations.length !== 1 ? "s" : ""}
|
|
413
|
+
`\n⚠ ${escalations.length} comment${escalations.length !== 1 ? "s need" : " needs"} an author reply — see ${path.basename(resultFile)}`
|
|
338
414
|
);
|
|
339
415
|
}
|
|
340
416
|
const escSuffix = escalations.length ? ` escalations=${escalations.length}` : "";
|
|
@@ -355,9 +431,9 @@ if (args[0] === "resolve") {
|
|
|
355
431
|
console.log(` Revised document: ${resolved}`);
|
|
356
432
|
console.log(`${line}`);
|
|
357
433
|
|
|
358
|
-
// Print the full comment threads so the
|
|
434
|
+
// Print the full comment threads so the authoring agent — which has no
|
|
359
435
|
// live channel to the inline review agent — sees everything the reviewer
|
|
360
|
-
// said, including
|
|
436
|
+
// said, including author-level feedback meant for it.
|
|
361
437
|
let escalations: import("./reviewSummary").EscalationItem[] = [];
|
|
362
438
|
try {
|
|
363
439
|
const { loadSidecar } = await import("./sidecar");
|
package/src/client/cards.ts
CHANGED
|
@@ -44,7 +44,7 @@ export function buildCommentCard(comment: ClientComment): HTMLDivElement {
|
|
|
44
44
|
if (isEscalated(comment)) {
|
|
45
45
|
const ebadge = document.createElement("span");
|
|
46
46
|
ebadge.className = "escalate-badge";
|
|
47
|
-
ebadge.textContent = "↑
|
|
47
|
+
ebadge.textContent = "↑ Author reply needed";
|
|
48
48
|
quote.appendChild(ebadge);
|
|
49
49
|
}
|
|
50
50
|
quote.appendChild(document.createTextNode('"' + comment.quote + '"'));
|
|
@@ -164,16 +164,16 @@ export function buildCommentCard(comment: ClientComment): HTMLDivElement {
|
|
|
164
164
|
|
|
165
165
|
function buildThreadEntry(entry: ThreadEntry, isLatestVerdict: boolean): HTMLDivElement {
|
|
166
166
|
const role = entry.role ?? "agent";
|
|
167
|
-
const label = entry.name ?? (role === "agent" ? "Agent" : "Human");
|
|
167
|
+
const label = entry.name ?? (entry.author ? "Author" : role === "agent" ? "Agent" : "Human");
|
|
168
168
|
const div = document.createElement("div");
|
|
169
|
-
div.className = "thread-entry";
|
|
169
|
+
div.className = "thread-entry" + (entry.author ? " author-entry" : "");
|
|
170
170
|
let verdictHtml = "";
|
|
171
171
|
if (role === "agent" && entry.requires_revision === true && isLatestVerdict) {
|
|
172
172
|
const reason = entry.revision_reason ? escapeHtml(entry.revision_reason) : "edit queued";
|
|
173
173
|
verdictHtml = `<div class="verdict revise"><span class="verdict-icon">\u270E</span><span>${reason}</span></div>`;
|
|
174
174
|
}
|
|
175
175
|
if (role === "agent" && entry.escalate === true) {
|
|
176
|
-
verdictHtml += `<div class="verdict escalate"><span class="verdict-icon">\u2191</span><span>
|
|
176
|
+
verdictHtml += `<div class="verdict escalate"><span class="verdict-icon">\u2191</span><span>Author reply needed</span></div>`;
|
|
177
177
|
}
|
|
178
178
|
const messageHtml = entry.messageHtml ?? escapeHtml(entry.message);
|
|
179
179
|
div.innerHTML = `
|
package/src/client/lib.ts
CHANGED
|
@@ -23,6 +23,7 @@ export type ThreadEntry = {
|
|
|
23
23
|
requires_revision?: boolean;
|
|
24
24
|
revision_reason?: string;
|
|
25
25
|
escalate?: boolean;
|
|
26
|
+
author?: boolean;
|
|
26
27
|
};
|
|
27
28
|
|
|
28
29
|
export function escapeHtml(s: string): string {
|
|
@@ -45,9 +46,18 @@ export function latestVerdict(comment: ClientComment): "revise" | "accept" | nul
|
|
|
45
46
|
return null;
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
// True when an agent reply flagged this comment for
|
|
49
|
+
// True when an agent reply flagged this comment for authoring-agent input.
|
|
49
50
|
export function isEscalated(comment: ClientComment): boolean {
|
|
50
|
-
|
|
51
|
+
const thread = comment.thread || [];
|
|
52
|
+
let escIdx = -1;
|
|
53
|
+
let authorIdx = -1;
|
|
54
|
+
for (let i = thread.length - 1; i >= 0; i--) {
|
|
55
|
+
const entry = thread[i]!;
|
|
56
|
+
if (escIdx === -1 && entry.role === "agent" && entry.escalate === true) escIdx = i;
|
|
57
|
+
if (authorIdx === -1 && entry.role === "agent" && entry.author === true) authorIdx = i;
|
|
58
|
+
if (escIdx !== -1 && authorIdx !== -1) break;
|
|
59
|
+
}
|
|
60
|
+
return escIdx !== -1 && authorIdx < escIdx;
|
|
51
61
|
}
|
|
52
62
|
|
|
53
63
|
export function nearestCell(node: Node): HTMLElement | null {
|
package/src/client/render.ts
CHANGED
|
@@ -346,15 +346,15 @@ export function applyRoundState(): void {
|
|
|
346
346
|
bannerText = `${reviseCount} of ${total} comments imply edits.`;
|
|
347
347
|
}
|
|
348
348
|
banner.textContent = bannerText;
|
|
349
|
-
//
|
|
350
|
-
// can be answered in-thread yet still need
|
|
349
|
+
// Author handoff is orthogonal to the revise/accept verdict — a comment
|
|
350
|
+
// can be answered in-thread yet still need authoring-agent input. Call
|
|
351
351
|
// it out on its own line so it survives all three banner branches.
|
|
352
352
|
const escCount = state.comments.filter(isEscalated).length;
|
|
353
353
|
if (escCount > 0) {
|
|
354
354
|
const escLine = document.createElement("div");
|
|
355
355
|
escLine.className = "banner-escalation";
|
|
356
356
|
escLine.textContent =
|
|
357
|
-
`↑ ${escCount} comment${escCount !== 1 ? "s" : ""}
|
|
357
|
+
`↑ ${escCount} comment${escCount !== 1 ? "s need" : " needs"} an author reply.`;
|
|
358
358
|
banner.appendChild(escLine);
|
|
359
359
|
}
|
|
360
360
|
banner.style.display = "block";
|
package/src/client/styles.css
CHANGED
|
@@ -496,6 +496,13 @@ mark.rl-highlight.rl-img:hover, mark.rl-highlight.rl-img.active {
|
|
|
496
496
|
}
|
|
497
497
|
.thread-role.human { color: var(--accent); }
|
|
498
498
|
.thread-role.agent { color: #3b5bdb; }
|
|
499
|
+
.thread-entry.author-entry {
|
|
500
|
+
border-left: 3px solid #7c3aed;
|
|
501
|
+
padding-left: 10px;
|
|
502
|
+
}
|
|
503
|
+
.thread-entry.author-entry .thread-role {
|
|
504
|
+
color: #5b21b6;
|
|
505
|
+
}
|
|
499
506
|
|
|
500
507
|
.thread-message {
|
|
501
508
|
font-size: 13.5px;
|
|
@@ -597,7 +604,7 @@ mark.rl-highlight.rl-img:hover, mark.rl-highlight.rl-img.active {
|
|
|
597
604
|
.verdict-badge.revise { background: #fef3c7; color: #92400e; }
|
|
598
605
|
.verdict-badge.accept { background: #e5e7eb; color: var(--text-muted); }
|
|
599
606
|
|
|
600
|
-
/*
|
|
607
|
+
/* Author-reply-needed badge on the quote line */
|
|
601
608
|
.escalate-badge {
|
|
602
609
|
display: inline-flex;
|
|
603
610
|
align-items: center;
|
|
@@ -803,8 +810,8 @@ mark.rl-highlight.rl-img:hover, mark.rl-highlight.rl-img.active {
|
|
|
803
810
|
align-items: center;
|
|
804
811
|
gap: 8px;
|
|
805
812
|
}
|
|
806
|
-
/*
|
|
807
|
-
↑
|
|
813
|
+
/* Author-handoff sub-line under the round banner — purple to match the
|
|
814
|
+
↑ Author reply needed badges, distinct from the green verdict copy. */
|
|
808
815
|
.banner-escalation {
|
|
809
816
|
margin-top: 6px;
|
|
810
817
|
padding-top: 6px;
|
package/src/reviewSummary.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// Closeout summary of a finished review.
|
|
2
2
|
//
|
|
3
|
-
// The inline review agent (src/agent.ts) and the agent that *launched* redline
|
|
3
|
+
// The inline review agent (src/agent.ts) and the agent that *authored/launched* redline
|
|
4
4
|
// share no live channel — the sidecar is the only persisted artifact, and the
|
|
5
|
-
//
|
|
5
|
+
// authoring agent only regains control when the session exits. So at closeout
|
|
6
6
|
// the CLI prints every comment thread verbatim. Anything the reviewer said —
|
|
7
|
-
// including feedback meant for the
|
|
7
|
+
// including feedback meant for the authoring agent — lands in front of it.
|
|
8
8
|
//
|
|
9
9
|
// Comments the inline agent explicitly flagged (`escalate: true`) get a
|
|
10
10
|
// dedicated section so they aren't lost in the full transcript.
|
|
@@ -15,7 +15,7 @@ export interface EscalationItem {
|
|
|
15
15
|
round: number;
|
|
16
16
|
quote: string;
|
|
17
17
|
request: string; // the reviewer message the inline agent couldn't act on
|
|
18
|
-
note: string; // the agent's
|
|
18
|
+
note: string; // the agent's author-handoff note
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
function flatten(s: string, n: number): string {
|
|
@@ -24,19 +24,24 @@ function flatten(s: string, n: number): string {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
function isEscalated(c: Comment): boolean {
|
|
27
|
-
|
|
27
|
+
const escIdx = latestIndex(c.thread, (e) => e.role === "agent" && e.escalate === true);
|
|
28
|
+
if (escIdx === -1) return false;
|
|
29
|
+
const authorIdx = latestIndex(c.thread, (e) => e.role === "agent" && e.author === true);
|
|
30
|
+
return authorIdx < escIdx;
|
|
28
31
|
}
|
|
29
32
|
|
|
30
|
-
// Pull out every comment the inline agent routed to the
|
|
33
|
+
// Pull out every comment the inline agent routed to the authoring agent.
|
|
31
34
|
export function collectEscalations(sidecar: Sidecar): EscalationItem[] {
|
|
32
35
|
const items: EscalationItem[] = [];
|
|
33
36
|
for (const round of sidecar.rounds) {
|
|
34
37
|
for (const c of round.comments) {
|
|
35
|
-
const escIdx = c.thread
|
|
38
|
+
const escIdx = latestIndex(c.thread, (e) => e.role === "agent" && e.escalate === true);
|
|
36
39
|
if (escIdx === -1) continue;
|
|
40
|
+
const authorIdx = latestIndex(c.thread, (e) => e.role === "agent" && e.author === true);
|
|
41
|
+
if (authorIdx > escIdx) continue;
|
|
37
42
|
const agentEntry = c.thread[escIdx]!;
|
|
38
|
-
// The reviewer message immediately before the
|
|
39
|
-
// the inline agent couldn't fulfill.
|
|
43
|
+
// The reviewer message immediately before the author handoff is the
|
|
44
|
+
// request the inline agent couldn't fulfill.
|
|
40
45
|
let request = "";
|
|
41
46
|
for (let i = escIdx - 1; i >= 0; i--) {
|
|
42
47
|
if (c.thread[i]!.role === "human") { request = c.thread[i]!.message; break; }
|
|
@@ -52,8 +57,15 @@ export function collectEscalations(sidecar: Sidecar): EscalationItem[] {
|
|
|
52
57
|
return items;
|
|
53
58
|
}
|
|
54
59
|
|
|
55
|
-
|
|
56
|
-
|
|
60
|
+
function latestIndex(thread: Comment["thread"], predicate: (entry: Comment["thread"][number]) => boolean): number {
|
|
61
|
+
for (let i = thread.length - 1; i >= 0; i--) {
|
|
62
|
+
if (predicate(thread[i]!)) return i;
|
|
63
|
+
}
|
|
64
|
+
return -1;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// A readable transcript of every comment thread, plus an author-handoff callout.
|
|
68
|
+
// Printed to stdout on session close so the authoring agent can read it.
|
|
57
69
|
export function formatReviewSummary(sidecar: Sidecar): string {
|
|
58
70
|
const lines: string[] = [`Review threads — ${sidecar.file}`];
|
|
59
71
|
|
|
@@ -62,7 +74,7 @@ export function formatReviewSummary(sidecar: Sidecar): string {
|
|
|
62
74
|
lines.push("", `Round ${round.round}`);
|
|
63
75
|
round.comments.forEach((c, i) => {
|
|
64
76
|
const tags = [c.resolved ? "resolved" : "open"];
|
|
65
|
-
if (isEscalated(c)) tags.push("
|
|
77
|
+
if (isEscalated(c)) tags.push("author reply needed");
|
|
66
78
|
lines.push(` ${i + 1}. "${flatten(c.quote, 80)}" — ${tags.join(" · ")}`);
|
|
67
79
|
for (const e of c.thread) {
|
|
68
80
|
const who = e.role === "human" ? "Reviewer" : (e.name || "Agent");
|
|
@@ -75,7 +87,7 @@ export function formatReviewSummary(sidecar: Sidecar): string {
|
|
|
75
87
|
if (esc.length > 0) {
|
|
76
88
|
lines.push(
|
|
77
89
|
"",
|
|
78
|
-
`⚠ ${esc.length} comment${esc.length !== 1 ? "s" : ""}
|
|
90
|
+
`⚠ ${esc.length} comment${esc.length !== 1 ? "s need" : " needs"} an author reply from you:`,
|
|
79
91
|
);
|
|
80
92
|
for (const e of esc) {
|
|
81
93
|
lines.push(` • "${e.quote}" (round ${e.round})`);
|
|
@@ -84,7 +96,7 @@ export function formatReviewSummary(sidecar: Sidecar): string {
|
|
|
84
96
|
}
|
|
85
97
|
lines.push(
|
|
86
98
|
"",
|
|
87
|
-
"The inline review agent
|
|
99
|
+
"The inline review agent marked these for author-level input. Address them in the",
|
|
88
100
|
"document or with the user before considering the review closed out.",
|
|
89
101
|
);
|
|
90
102
|
}
|
package/src/server.ts
CHANGED
|
@@ -472,6 +472,7 @@ export function createServer(
|
|
|
472
472
|
requires_revision?: boolean;
|
|
473
473
|
revision_reason?: string;
|
|
474
474
|
escalate?: boolean;
|
|
475
|
+
author?: boolean;
|
|
475
476
|
}>();
|
|
476
477
|
if (!body.message?.trim()) return c.json({ ok: false, error: "message is required" }, 400);
|
|
477
478
|
const role = (body.role === "human" ? "human" : "agent") as "human" | "agent";
|
|
@@ -491,12 +492,13 @@ export function createServer(
|
|
|
491
492
|
if (body.revision_reason?.trim()) entry.revision_reason = body.revision_reason.trim();
|
|
492
493
|
}
|
|
493
494
|
if (body.escalate === true) entry.escalate = true;
|
|
495
|
+
if (body.author === true) entry.author = true;
|
|
494
496
|
}
|
|
495
497
|
comment.thread.push(entry);
|
|
496
498
|
return { skip: false as const, roundNumber: round.round, comment };
|
|
497
499
|
});
|
|
498
500
|
if (out.skip) return c.json({ ok: false, error: out.error }, out.status as 400 | 404);
|
|
499
|
-
if (role === "human") {
|
|
501
|
+
if (role === "human" || (role === "agent" && body.author === true)) {
|
|
500
502
|
broadcast("comment-reply", { round: out.roundNumber, commentId: id });
|
|
501
503
|
}
|
|
502
504
|
return c.json({ ok: true, comment: out.comment });
|
package/src/sidecar.ts
CHANGED
|
@@ -14,10 +14,14 @@ export interface ThreadEntry {
|
|
|
14
14
|
requires_revision?: boolean;
|
|
15
15
|
revision_reason?: string;
|
|
16
16
|
// Set true on an agent reply when the comment can't be acted on from inside
|
|
17
|
-
// this review — it needs
|
|
18
|
-
//
|
|
19
|
-
//
|
|
17
|
+
// this review — it needs author-level context, tools, or authority the inline
|
|
18
|
+
// agent lacks. Surfaced in the closeout summary so the authoring agent picks
|
|
19
|
+
// it up. Only set on agent entries.
|
|
20
20
|
escalate?: boolean;
|
|
21
|
+
// Set true on an agent entry posted by the authoring/launching agent rather
|
|
22
|
+
// than the inline review agent. This lets the UI distinguish author replies
|
|
23
|
+
// without changing the legacy role shape.
|
|
24
|
+
author?: boolean;
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
// Latest agent verdict on a comment thread:
|