@diegopetrucci/pi-extensions 0.1.25 → 0.1.26
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.
|
@@ -0,0 +1,1813 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { AgentSession, ExecResult, ExtensionAPI, ExtensionCommandContext, ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import {
|
|
6
|
+
DefaultResourceLoader,
|
|
7
|
+
SessionManager,
|
|
8
|
+
SettingsManager,
|
|
9
|
+
createAgentSession,
|
|
10
|
+
getAgentDir,
|
|
11
|
+
getMarkdownTheme,
|
|
12
|
+
} from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
14
|
+
import { Type } from "typebox";
|
|
15
|
+
|
|
16
|
+
const MAX_COMMENTS = 50;
|
|
17
|
+
const MAX_TOOL_CALLS_TO_KEEP = 80;
|
|
18
|
+
const MAX_TURNS = 8;
|
|
19
|
+
const MAX_RUN_MS = 8 * 60 * 1000;
|
|
20
|
+
const DEFAULT_BASH_TIMEOUT_SECONDS = 30;
|
|
21
|
+
const COLLAPSED_PREVIEW_LINES = 18;
|
|
22
|
+
const GH_COMMAND_TIMEOUT_MS = 30_000;
|
|
23
|
+
const COMMENT_DISPLAY_BODY_LIMIT = 1200;
|
|
24
|
+
const TRIAGE_COMMAND_USAGE =
|
|
25
|
+
"Usage: /triage-comments [paste | pr <PR URL or number>]\nInteractive UI mode lets you paste feedback or fetch PR comments, then confirm all comments or choose a subset such as 1,3-5.";
|
|
26
|
+
const IMPLEMENTATION_NOTE =
|
|
27
|
+
"Do not implement changes from this triage automatically; ask the parent/user which option to take before implementation.";
|
|
28
|
+
|
|
29
|
+
const READ_ONLY_TOOL_NAMES = new Set(["read", "grep", "find", "ls", "bash"]);
|
|
30
|
+
const READ_ONLY_BASH_COMMANDS = new Set(["gh", "git", "pwd"]);
|
|
31
|
+
const READ_ONLY_GIT_SUBCOMMANDS = new Set([
|
|
32
|
+
"blame",
|
|
33
|
+
"cat-file",
|
|
34
|
+
"describe",
|
|
35
|
+
"diff",
|
|
36
|
+
"for-each-ref",
|
|
37
|
+
"log",
|
|
38
|
+
"ls-files",
|
|
39
|
+
"ls-tree",
|
|
40
|
+
"merge-base",
|
|
41
|
+
"name-rev",
|
|
42
|
+
"remote",
|
|
43
|
+
"rev-parse",
|
|
44
|
+
"shortlog",
|
|
45
|
+
"show",
|
|
46
|
+
"show-ref",
|
|
47
|
+
"status",
|
|
48
|
+
"whatchanged",
|
|
49
|
+
]);
|
|
50
|
+
const SAFE_GIT_BRANCH_FLAGS = new Set([
|
|
51
|
+
"-a",
|
|
52
|
+
"--all",
|
|
53
|
+
"-r",
|
|
54
|
+
"--remotes",
|
|
55
|
+
"-v",
|
|
56
|
+
"-vv",
|
|
57
|
+
"--show-current",
|
|
58
|
+
"--list",
|
|
59
|
+
"--contains",
|
|
60
|
+
"--merged",
|
|
61
|
+
"--no-merged",
|
|
62
|
+
]);
|
|
63
|
+
const SAFE_GIT_GLOBAL_FLAGS = new Set(["--no-pager", "--no-optional-locks"]);
|
|
64
|
+
const GH_GLOBAL_OPTIONS_WITH_VALUE = new Set(["--repo", "-R", "--hostname", "--jq", "-q", "--template"]);
|
|
65
|
+
const MUTATING_GH_API_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
66
|
+
|
|
67
|
+
type TriageStatus = "running" | "done" | "error" | "aborted";
|
|
68
|
+
|
|
69
|
+
type ToolCall = {
|
|
70
|
+
id: string;
|
|
71
|
+
name: string;
|
|
72
|
+
args: unknown;
|
|
73
|
+
startedAt: number;
|
|
74
|
+
endedAt?: number;
|
|
75
|
+
isError?: boolean;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
type TriageDetails = {
|
|
79
|
+
status: TriageStatus;
|
|
80
|
+
cwd: string;
|
|
81
|
+
commentCount: number;
|
|
82
|
+
turns: number;
|
|
83
|
+
toolCalls: ToolCall[];
|
|
84
|
+
startedAt: number;
|
|
85
|
+
endedAt?: number;
|
|
86
|
+
error?: string;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
type NormalizedComment = {
|
|
90
|
+
index: number;
|
|
91
|
+
id?: string;
|
|
92
|
+
body: string;
|
|
93
|
+
path?: string;
|
|
94
|
+
line?: number;
|
|
95
|
+
startLine?: number;
|
|
96
|
+
side?: string;
|
|
97
|
+
diffHunk?: string;
|
|
98
|
+
author?: string;
|
|
99
|
+
url?: string;
|
|
100
|
+
createdAt?: string;
|
|
101
|
+
context?: string;
|
|
102
|
+
metadata?: Record<string, unknown>;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
type NormalizedInput = {
|
|
106
|
+
comments: NormalizedComment[];
|
|
107
|
+
pr?: Record<string, unknown>;
|
|
108
|
+
base?: Record<string, unknown>;
|
|
109
|
+
diff?: string;
|
|
110
|
+
context?: string;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const CommentSchema = Type.Object({
|
|
114
|
+
id: Type.Optional(Type.String({ description: "Stable comment identifier from GitHub or the caller." })),
|
|
115
|
+
body: Type.String({ description: "The review comment text to triage." }),
|
|
116
|
+
path: Type.Optional(Type.String({ description: "Repository-relative file path the comment refers to, when known." })),
|
|
117
|
+
line: Type.Optional(Type.Number({ description: "1-indexed line number the comment refers to, when known." })),
|
|
118
|
+
startLine: Type.Optional(Type.Number({ description: "Start line for a multi-line comment, when known." })),
|
|
119
|
+
side: Type.Optional(Type.String({ description: "Diff side or thread side, for example RIGHT, LEFT, base, or head." })),
|
|
120
|
+
diffHunk: Type.Optional(Type.String({ description: "GitHub diff hunk attached to the comment, when available." })),
|
|
121
|
+
author: Type.Optional(Type.String({ description: "Review comment author, when available." })),
|
|
122
|
+
url: Type.Optional(Type.String({ description: "Permalink to the review comment, when available." })),
|
|
123
|
+
createdAt: Type.Optional(Type.String({ description: "Comment creation timestamp, when available." })),
|
|
124
|
+
context: Type.Optional(Type.String({ description: "Any extra per-comment context supplied by the caller." })),
|
|
125
|
+
metadata: Type.Optional(
|
|
126
|
+
Type.Record(Type.String(), Type.Unknown({ description: "Structured per-comment metadata from the caller." })),
|
|
127
|
+
),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const TriageCommentsParams = Type.Object({
|
|
131
|
+
comments: Type.Array(Type.Union([Type.String(), CommentSchema]), {
|
|
132
|
+
description: "Selected PR review comments to classify. Prefer objects with body, path, line, diffHunk, author, and url when available; plain strings are accepted for quick manual calls.",
|
|
133
|
+
minItems: 1,
|
|
134
|
+
maxItems: MAX_COMMENTS,
|
|
135
|
+
}),
|
|
136
|
+
pr: Type.Optional(
|
|
137
|
+
Type.Object({
|
|
138
|
+
number: Type.Optional(Type.Number({ description: "Pull request number." })),
|
|
139
|
+
title: Type.Optional(Type.String({ description: "Pull request title." })),
|
|
140
|
+
url: Type.Optional(Type.String({ description: "Pull request URL." })),
|
|
141
|
+
repository: Type.Optional(Type.String({ description: "Repository in owner/name form, when known." })),
|
|
142
|
+
headRef: Type.Optional(Type.String({ description: "PR head ref name, when known." })),
|
|
143
|
+
headSha: Type.Optional(Type.String({ description: "PR head SHA, when known." })),
|
|
144
|
+
baseRef: Type.Optional(Type.String({ description: "PR base ref name, when known." })),
|
|
145
|
+
baseSha: Type.Optional(Type.String({ description: "PR base SHA, when known." })),
|
|
146
|
+
}),
|
|
147
|
+
),
|
|
148
|
+
base: Type.Optional(
|
|
149
|
+
Type.Object({
|
|
150
|
+
branch: Type.Optional(Type.String({ description: "Base branch name, when known." })),
|
|
151
|
+
sha: Type.Optional(Type.String({ description: "Base SHA, when known." })),
|
|
152
|
+
mergeBase: Type.Optional(Type.String({ description: "Merge-base SHA, when known." })),
|
|
153
|
+
}),
|
|
154
|
+
),
|
|
155
|
+
diff: Type.Optional(
|
|
156
|
+
Type.String({
|
|
157
|
+
description: "Optional PR diff, selected hunks, or command output that helps locate comments. The triage agent still verifies against the local checkout when possible.",
|
|
158
|
+
maxLength: 200000,
|
|
159
|
+
}),
|
|
160
|
+
),
|
|
161
|
+
context: Type.Optional(
|
|
162
|
+
Type.String({
|
|
163
|
+
description: "Optional caller notes, constraints, or already-collected read-only context for this triage run.",
|
|
164
|
+
maxLength: 50000,
|
|
165
|
+
}),
|
|
166
|
+
),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
170
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function asTrimmedString(value: unknown): string | undefined {
|
|
174
|
+
if (typeof value !== "string") return undefined;
|
|
175
|
+
const trimmed = value.trim();
|
|
176
|
+
return trimmed || undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function asFiniteNumber(value: unknown): number | undefined {
|
|
180
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
181
|
+
if (typeof value === "string" && value.trim()) {
|
|
182
|
+
const parsed = Number(value);
|
|
183
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
184
|
+
}
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function normalizeComment(raw: unknown, index: number): NormalizedComment | undefined {
|
|
189
|
+
if (typeof raw === "string") {
|
|
190
|
+
const body = raw.trim();
|
|
191
|
+
return body ? { index, body } : undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const record = asRecord(raw);
|
|
195
|
+
if (!record) return undefined;
|
|
196
|
+
const body = asTrimmedString(record.body ?? record.text ?? record.comment);
|
|
197
|
+
if (!body) return undefined;
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
index,
|
|
201
|
+
id: asTrimmedString(record.id ?? record.databaseId ?? record.nodeId),
|
|
202
|
+
body,
|
|
203
|
+
path: asTrimmedString(record.path ?? record.file ?? record.filePath),
|
|
204
|
+
line: asFiniteNumber(record.line ?? record.position),
|
|
205
|
+
startLine: asFiniteNumber(record.startLine ?? record.start_line),
|
|
206
|
+
side: asTrimmedString(record.side),
|
|
207
|
+
diffHunk: asTrimmedString(record.diffHunk ?? record.diff_hunk ?? record.hunk),
|
|
208
|
+
author: asTrimmedString(record.author ?? record.user ?? record.login),
|
|
209
|
+
url: asTrimmedString(record.url ?? record.htmlUrl ?? record.html_url),
|
|
210
|
+
createdAt: asTrimmedString(record.createdAt ?? record.created_at),
|
|
211
|
+
context: asTrimmedString(record.context ?? record.extraContext),
|
|
212
|
+
metadata: asRecord(record.metadata ?? record.meta),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function normalizeInput(params: unknown): NormalizedInput {
|
|
217
|
+
const record = asRecord(params) ?? {};
|
|
218
|
+
const rawComments = Array.isArray(record.comments) ? record.comments : [];
|
|
219
|
+
const comments = rawComments
|
|
220
|
+
.map((comment, index) => normalizeComment(comment, index + 1))
|
|
221
|
+
.filter((comment): comment is NormalizedComment => Boolean(comment));
|
|
222
|
+
|
|
223
|
+
if (comments.length === 0) {
|
|
224
|
+
throw new Error("Invalid parameters: expected comments to include at least one non-empty comment body.");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
comments,
|
|
229
|
+
pr: asRecord(record.pr),
|
|
230
|
+
base: asRecord(record.base),
|
|
231
|
+
diff: asTrimmedString(record.diff ?? record.diffContext),
|
|
232
|
+
context: asTrimmedString(record.context ?? record.extraContext),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function prepareArguments(args: unknown): any {
|
|
237
|
+
const record = asRecord(args);
|
|
238
|
+
if (!record) return args;
|
|
239
|
+
const prepared: Record<string, unknown> = { ...record };
|
|
240
|
+
if (!("comments" in prepared) && "selectedComments" in prepared) prepared.comments = prepared.selectedComments;
|
|
241
|
+
if (!("comments" in prepared) && "comment" in prepared) prepared.comments = [prepared.comment];
|
|
242
|
+
if (!("pr" in prepared) && "prContext" in prepared) prepared.pr = prepared.prContext;
|
|
243
|
+
if (!("base" in prepared) && "baseContext" in prepared) prepared.base = prepared.baseContext;
|
|
244
|
+
if (!("diff" in prepared) && "diffContext" in prepared) prepared.diff = prepared.diffContext;
|
|
245
|
+
return prepared;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function shorten(text: string, max: number): string {
|
|
249
|
+
const oneLine = text.replace(/\s+/g, " ").trim();
|
|
250
|
+
if (oneLine.length <= max) return oneLine;
|
|
251
|
+
return `${oneLine.slice(0, Math.max(1, max - 1))}…`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function formatJsonForPrompt(value: unknown): string {
|
|
255
|
+
if (value === undefined) return "(not provided)";
|
|
256
|
+
return JSON.stringify(value, null, 2);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function formatCommentForPrompt(comment: NormalizedComment): string {
|
|
260
|
+
const fields = [
|
|
261
|
+
`index: ${comment.index}`,
|
|
262
|
+
comment.id ? `id: ${comment.id}` : undefined,
|
|
263
|
+
comment.author ? `author: ${comment.author}` : undefined,
|
|
264
|
+
comment.path ? `path: ${comment.path}` : undefined,
|
|
265
|
+
comment.startLine ? `startLine: ${comment.startLine}` : undefined,
|
|
266
|
+
comment.line ? `line: ${comment.line}` : undefined,
|
|
267
|
+
comment.side ? `side: ${comment.side}` : undefined,
|
|
268
|
+
comment.url ? `url: ${comment.url}` : undefined,
|
|
269
|
+
comment.createdAt ? `createdAt: ${comment.createdAt}` : undefined,
|
|
270
|
+
]
|
|
271
|
+
.filter(Boolean)
|
|
272
|
+
.join("\n");
|
|
273
|
+
|
|
274
|
+
const sections = [`Comment ${comment.index}:`, fields, `body:\n${comment.body}`];
|
|
275
|
+
if (comment.diffHunk) sections.push(`diffHunk:\n${comment.diffHunk}`);
|
|
276
|
+
if (comment.metadata) sections.push(`metadata:\n${formatJsonForPrompt(comment.metadata)}`);
|
|
277
|
+
if (comment.context) sections.push(`extraContext:\n${comment.context}`);
|
|
278
|
+
return sections.filter(Boolean).join("\n");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function buildSystemPrompt(options: { cwd: string; maxTurns: number; maxRunSeconds: number }): string {
|
|
282
|
+
return `You are Triage Comments, an isolated read-only code-review investigation agent running inside The Last Harness. Your job is to evaluate selected PR review comments against the local checkout and local git context.\n\nWorking directory: ${options.cwd}\nTurn budget: ${options.maxTurns} turns total, including your final answer.\nWall-clock budget: ${options.maxRunSeconds} seconds.\n\nAvailable tools are intended to be read-only: read, grep, find, ls, and bash. Use the built-in read, grep, find, and ls tools for local file inspection. Use bash only for a single read-only git, gh, or pwd invocation, such as git status/diff/show/log/blame, gh pr view/diff/list/status/checks, gh api GET calls, or pwd. A runtime guard blocks write/edit tools, shell pipelines/control operators, local filesystem utility commands, mutating git, and mutating gh/GitHub API calls.\n\nNon-negotiable constraints:\n- Never implement changes. Never edit, write, move, delete, format, commit, checkout, reset, clean, push, publish, or mutate GitHub.\n- Keep all inspection local to the checkout unless a read-only gh call is necessary for supplied PR metadata.\n- Treat the supplied comments as claims to verify, not as facts. Cite file paths, line ranges, git diff/status/log output, or supplied diff hunks as evidence.\n- If evidence is missing, stale, contradictory, or the local checkout does not match the PR context, say so and classify accordingly.\n- Distinguish objective correctness issues from style/preferences.\n- For valid or partially valid comments, propose one or more handling options, but do not choose implementation without parent/user confirmation.\n\nVerdicts must be one of: valid, invalid, partially valid, subjective, needs clarification.\n\nOutput format, exact order:\n## Summary\n1-3 concise sentences summarizing the triage.\n\n## Per-comment triage\nFor each selected comment, use:\n### Comment <index or id> — <verdict>\n- **What the reviewer asked:** concise paraphrase.\n- **Evidence:** path/line or command-output citations. If evidence is insufficient, state exactly what is missing.\n- **Reasoning:** why the evidence supports the verdict.\n- **Suggested response:** a short review-thread reply the parent/user can post or adapt.\n- **Handling options:** one or more options for valid/partially valid comments; for invalid/subjective/unclear comments, give the appropriate response/clarification path.\n\n## Read-only checks performed\nList the files/commands/probes used, or \`- (none)\`.\n\n## Before implementation\nState explicitly: \"Do not implement changes from this triage automatically; ask the parent/user which option to take before implementation.\"`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function buildUserPrompt(input: NormalizedInput, cwd: string): string {
|
|
286
|
+
return `Task: triage the selected PR review comments against the local checkout.\n\nLocal checkout: ${cwd}\n\nSelected comments:\n${input.comments.map(formatCommentForPrompt).join("\n\n---\n\n")}\n\nPR context:\n${formatJsonForPrompt(input.pr)}\n\nBase context:\n${formatJsonForPrompt(input.base)}\n\nOptional diff context:\n${input.diff ?? "(not provided)"}\n\nOptional caller context:\n${input.context ?? "(not provided)"}\n\nInspect only as much as needed to classify each comment with evidence. Do not implement anything. Respond using the required triage format.`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function ensureImplementationNote(text: string): string {
|
|
290
|
+
const trimmed = text.trim();
|
|
291
|
+
if (!trimmed) return trimmed;
|
|
292
|
+
if (trimmed.toLowerCase().includes(IMPLEMENTATION_NOTE.toLowerCase())) return trimmed;
|
|
293
|
+
return `${trimmed}\n\n---\n${IMPLEMENTATION_NOTE}`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function extractLastAssistantText(messages: unknown[]): string {
|
|
297
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
298
|
+
const message = messages[i] as { role?: string; content?: unknown };
|
|
299
|
+
if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
|
|
300
|
+
const parts: string[] = [];
|
|
301
|
+
for (const part of message.content) {
|
|
302
|
+
if (part && typeof part === "object" && (part as { type?: string }).type === "text") {
|
|
303
|
+
const text = (part as { text?: unknown }).text;
|
|
304
|
+
if (typeof text === "string") parts.push(text);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (parts.length) return parts.join("").trim();
|
|
308
|
+
}
|
|
309
|
+
return "";
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function isAbortLikeError(error: unknown): boolean {
|
|
313
|
+
if (error && typeof error === "object" && (error as { name?: unknown }).name === "AbortError") return true;
|
|
314
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
315
|
+
return /aborted|cancelled|canceled/i.test(message);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function isInside(parent: string, child: string): boolean {
|
|
319
|
+
const parentResolved = path.resolve(parent);
|
|
320
|
+
const childResolved = path.resolve(child);
|
|
321
|
+
return childResolved === parentResolved || childResolved.startsWith(`${parentResolved}${path.sep}`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function resolveToolPath(cwd: string, rawPath: string | undefined): string {
|
|
325
|
+
const input = rawPath?.trim() || ".";
|
|
326
|
+
const normalized = input.startsWith("@") ? input.slice(1) : input;
|
|
327
|
+
return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(cwd, normalized);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function assertToolPathInsideCwd(cwd: string, rawPath: unknown, toolName: string): Promise<string | undefined> {
|
|
331
|
+
if (rawPath !== undefined && typeof rawPath !== "string") return `${toolName} path must be a string.`;
|
|
332
|
+
const root = await fs.realpath(cwd).catch(() => path.resolve(cwd));
|
|
333
|
+
const resolved = resolveToolPath(cwd, rawPath);
|
|
334
|
+
const realPath = await fs.realpath(resolved).catch(() => resolved);
|
|
335
|
+
if (!isInside(root, realPath)) return `${toolName} is limited to the local checkout: ${realPath}`;
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function getUnsafePathPatternReason(value: unknown, label: string): string | undefined {
|
|
340
|
+
if (value === undefined) return undefined;
|
|
341
|
+
if (typeof value !== "string") return `${label} must be a string.`;
|
|
342
|
+
if (path.isAbsolute(value)) return `${label} must be relative to the local checkout.`;
|
|
343
|
+
if (/(^|[/\\])\.\.(?:[/\\]|$)/.test(value)) return `${label} must not traverse outside the local checkout.`;
|
|
344
|
+
return undefined;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function stripTokenQuotes(token: string): string {
|
|
348
|
+
if ((token.startsWith("'") && token.endsWith("'")) || (token.startsWith('"') && token.endsWith('"'))) {
|
|
349
|
+
return token.slice(1, -1);
|
|
350
|
+
}
|
|
351
|
+
return token;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
type ShellTokenizeResult = {
|
|
355
|
+
tokens: string[];
|
|
356
|
+
reason?: string;
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
function tokenizeShellCommand(command: string): ShellTokenizeResult {
|
|
360
|
+
const tokens: string[] = [];
|
|
361
|
+
let token = "";
|
|
362
|
+
let tokenStarted = false;
|
|
363
|
+
let inSingleQuote = false;
|
|
364
|
+
let inDoubleQuote = false;
|
|
365
|
+
|
|
366
|
+
const pushToken = () => {
|
|
367
|
+
if (!tokenStarted) return;
|
|
368
|
+
tokens.push(token);
|
|
369
|
+
token = "";
|
|
370
|
+
tokenStarted = false;
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
374
|
+
const char = command[index];
|
|
375
|
+
const next = command[index + 1] ?? "";
|
|
376
|
+
|
|
377
|
+
if (inSingleQuote) {
|
|
378
|
+
if (char === "'") {
|
|
379
|
+
inSingleQuote = false;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
token += char;
|
|
383
|
+
tokenStarted = true;
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (char === "\\") {
|
|
388
|
+
return {
|
|
389
|
+
tokens,
|
|
390
|
+
reason: "Triage bash blocks shell escape sequences; pass plain git, gh, or pwd arguments without backslashes.",
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (inDoubleQuote) {
|
|
395
|
+
if (char === '"') {
|
|
396
|
+
inDoubleQuote = false;
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (char === "`" || char === "$") {
|
|
400
|
+
return {
|
|
401
|
+
tokens,
|
|
402
|
+
reason:
|
|
403
|
+
char === "`" || next === "("
|
|
404
|
+
? "Triage bash blocks command substitution."
|
|
405
|
+
: "Triage bash blocks shell expansion in double quotes.",
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
token += char;
|
|
409
|
+
tokenStarted = true;
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (char === "\n" || char === "\r") {
|
|
414
|
+
return {
|
|
415
|
+
tokens,
|
|
416
|
+
reason:
|
|
417
|
+
"Triage bash allows one git, gh, or pwd invocation only; pipelines, shell control operators, and multiple commands are blocked.",
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
if (/\s/.test(char)) {
|
|
421
|
+
pushToken();
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
if (char === "'") {
|
|
425
|
+
inSingleQuote = true;
|
|
426
|
+
tokenStarted = true;
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (char === '"') {
|
|
430
|
+
inDoubleQuote = true;
|
|
431
|
+
tokenStarted = true;
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (char === "`") {
|
|
435
|
+
return { tokens, reason: "Triage bash blocks command substitution." };
|
|
436
|
+
}
|
|
437
|
+
if (char === "$") {
|
|
438
|
+
return {
|
|
439
|
+
tokens,
|
|
440
|
+
reason:
|
|
441
|
+
next === "'" || next === '"'
|
|
442
|
+
? "Triage bash blocks ANSI-C and localized shell quotes."
|
|
443
|
+
: next === "("
|
|
444
|
+
? "Triage bash blocks command substitution."
|
|
445
|
+
: "Triage bash blocks shell expansion.",
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
if (/[;&|()]/.test(char)) {
|
|
449
|
+
return {
|
|
450
|
+
tokens,
|
|
451
|
+
reason:
|
|
452
|
+
"Triage bash allows one git, gh, or pwd invocation only; pipelines, shell control operators, and multiple commands are blocked.",
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
if (char === ">" || char === "<") {
|
|
456
|
+
return { tokens, reason: "Triage bash blocks shell redirection to keep inspection read-only." };
|
|
457
|
+
}
|
|
458
|
+
if (char === "{" || char === "}") {
|
|
459
|
+
return { tokens, reason: "Triage bash blocks shell brace expansion." };
|
|
460
|
+
}
|
|
461
|
+
if (char === "*" || char === "?" || char === "[" || char === "]") {
|
|
462
|
+
return { tokens, reason: "Triage bash blocks shell glob expansion." };
|
|
463
|
+
}
|
|
464
|
+
if (char === "~" && !tokenStarted) {
|
|
465
|
+
return { tokens, reason: "Triage bash blocks shell home-directory expansion." };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
token += char;
|
|
469
|
+
tokenStarted = true;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (inSingleQuote || inDoubleQuote) return { tokens, reason: "Triage bash blocks unterminated or malformed shell quoting." };
|
|
473
|
+
pushToken();
|
|
474
|
+
return { tokens };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function tokenizeSegment(segment: string): string[] {
|
|
478
|
+
return tokenizeShellCommand(segment).tokens;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function getExecutableName(token: string): string {
|
|
482
|
+
return path.basename(token).toLowerCase();
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function getShellSyntaxReason(command: string): string | undefined {
|
|
486
|
+
return tokenizeShellCommand(command).reason;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function getUnsafeGitTokenPathReason(token: string): string | undefined {
|
|
490
|
+
const valueParts = [token];
|
|
491
|
+
const equalsIndex = token.indexOf("=");
|
|
492
|
+
if (equalsIndex >= 0 && equalsIndex < token.length - 1) valueParts.push(token.slice(equalsIndex + 1));
|
|
493
|
+
for (const value of valueParts) {
|
|
494
|
+
const normalized = value.startsWith("@") ? value.slice(1) : value;
|
|
495
|
+
if (normalized === "~" || normalized.startsWith("~/") || /^~[^/\\]*/.test(normalized)) {
|
|
496
|
+
return "Triage bash git arguments must not use home-directory paths; use built-in read/grep/find/ls for local files.";
|
|
497
|
+
}
|
|
498
|
+
if (path.isAbsolute(normalized)) {
|
|
499
|
+
return "Triage bash git arguments must not use absolute filesystem paths; use built-in read/grep/find/ls for local files.";
|
|
500
|
+
}
|
|
501
|
+
if (/(^|[/\\])\.\.(?:[/\\]|$)/.test(normalized)) {
|
|
502
|
+
return "Triage bash git arguments must not traverse outside the local checkout.";
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return undefined;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function getBlockedPwdReason(tokens: string[]): string | undefined {
|
|
509
|
+
const args = tokens.slice(1);
|
|
510
|
+
if (args.length === 0) return undefined;
|
|
511
|
+
if (args.length === 1 && (args[0] === "-P" || args[0] === "-L")) return undefined;
|
|
512
|
+
return "Triage bash allows pwd only with no arguments or -P/-L.";
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function getGitSubcommand(tokens: string[]): { subcommand?: string; index: number; reason?: string } {
|
|
516
|
+
let index = 1;
|
|
517
|
+
while (index < tokens.length) {
|
|
518
|
+
const token = tokens[index];
|
|
519
|
+
if (!token.startsWith("-")) break;
|
|
520
|
+
if (token === "--git-dir" || token.startsWith("--git-dir=")) {
|
|
521
|
+
return { index, reason: "Triage bash blocks git --git-dir because it can inspect or mutate outside the checkout." };
|
|
522
|
+
}
|
|
523
|
+
if (token === "--work-tree" || token.startsWith("--work-tree=")) {
|
|
524
|
+
return { index, reason: "Triage bash blocks git --work-tree because it can inspect or mutate outside the checkout." };
|
|
525
|
+
}
|
|
526
|
+
if (token === "-C") {
|
|
527
|
+
const gitCwd = tokens[index + 1];
|
|
528
|
+
if (gitCwd !== ".") {
|
|
529
|
+
return { index, reason: "Triage bash only allows git -C .; run git from the local checkout." };
|
|
530
|
+
}
|
|
531
|
+
index += 2;
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
if (token.startsWith("-C")) {
|
|
535
|
+
if (token !== "-C.") {
|
|
536
|
+
return { index, reason: "Triage bash only allows git -C .; run git from the local checkout." };
|
|
537
|
+
}
|
|
538
|
+
index += 1;
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
if (token === "--paginate" || token === "-p") {
|
|
542
|
+
return { index, reason: "Triage bash blocks git pagination because pagers can execute local utilities." };
|
|
543
|
+
}
|
|
544
|
+
if (SAFE_GIT_GLOBAL_FLAGS.has(token)) {
|
|
545
|
+
index += 1;
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
return { index, reason: `Triage bash blocks git global option ${token}; use direct read-only git inspection commands from the checkout.` };
|
|
549
|
+
}
|
|
550
|
+
return { subcommand: tokens[index]?.toLowerCase(), index };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function isSafeGitBranchCommand(tokens: string[], subcommandIndex: number): boolean {
|
|
554
|
+
const args = tokens.slice(subcommandIndex + 1);
|
|
555
|
+
if (args.length === 0) return true;
|
|
556
|
+
if (args.some((arg) => /^-(?:d|D|m|M|c|C|f)$/.test(arg) || /^--(?:delete|move|copy|force|set-upstream-to|unset-upstream)$/.test(arg))) {
|
|
557
|
+
return false;
|
|
558
|
+
}
|
|
559
|
+
return args.some((arg) => SAFE_GIT_BRANCH_FLAGS.has(arg) || arg.startsWith("--contains=") || arg.startsWith("--merged=") || arg.startsWith("--no-merged="));
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function isSafeGitRemoteCommand(tokens: string[], subcommandIndex: number): boolean {
|
|
563
|
+
const args = tokens.slice(subcommandIndex + 1);
|
|
564
|
+
if (args.length === 0) return true;
|
|
565
|
+
if (args.length === 1 && (args[0] === "-v" || args[0] === "--verbose")) return true;
|
|
566
|
+
const remoteAction = args.find((arg) => !arg.startsWith("-"));
|
|
567
|
+
return remoteAction === "show" || remoteAction === "get-url";
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function getUnsafeGitArgumentReason(tokens: string[]): string | undefined {
|
|
571
|
+
for (const token of tokens.slice(1)) {
|
|
572
|
+
if (token === "--no-index" || token.startsWith("--no-index=")) {
|
|
573
|
+
return "Triage bash blocks git --no-index because it can inspect arbitrary filesystem paths outside the checkout.";
|
|
574
|
+
}
|
|
575
|
+
if (token === "--paginate" || token.startsWith("--paginate=")) {
|
|
576
|
+
return "Triage bash blocks git --paginate because pagers can execute local utilities.";
|
|
577
|
+
}
|
|
578
|
+
if (token === "--open-files-in-pager" || token.startsWith("--open-files-in-pager=")) {
|
|
579
|
+
return "Triage bash blocks git --open-files-in-pager because it can execute local utilities.";
|
|
580
|
+
}
|
|
581
|
+
if (token === "-O" || token.startsWith("-O")) {
|
|
582
|
+
return "Triage bash blocks git -O because it can execute local utilities for some subcommands.";
|
|
583
|
+
}
|
|
584
|
+
if (token === "--ext-diff" || token.startsWith("--ext-diff=")) {
|
|
585
|
+
return "Triage bash blocks git --ext-diff because it can execute external diff helpers.";
|
|
586
|
+
}
|
|
587
|
+
if (token === "--textconv" || token.startsWith("--textconv=")) {
|
|
588
|
+
return "Triage bash blocks git --textconv because it can execute external text conversion filters.";
|
|
589
|
+
}
|
|
590
|
+
if (token === "--output" || token.startsWith("--output=")) {
|
|
591
|
+
return "Triage bash blocks git output-writing flags.";
|
|
592
|
+
}
|
|
593
|
+
const pathReason = getUnsafeGitTokenPathReason(token);
|
|
594
|
+
if (pathReason) return pathReason;
|
|
595
|
+
}
|
|
596
|
+
return undefined;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function getBlockedGitReason(command: string): string | undefined {
|
|
600
|
+
const tokens = tokenizeSegment(command);
|
|
601
|
+
if (getExecutableName(tokens[0] ?? "") !== "git") return undefined;
|
|
602
|
+
const parsed = getGitSubcommand(tokens);
|
|
603
|
+
if (parsed.reason) return parsed.reason;
|
|
604
|
+
const argumentReason = getUnsafeGitArgumentReason(tokens);
|
|
605
|
+
if (argumentReason) return argumentReason;
|
|
606
|
+
if (!parsed.subcommand) return undefined;
|
|
607
|
+
if (parsed.subcommand === "branch") {
|
|
608
|
+
if (!isSafeGitBranchCommand(tokens, parsed.index)) {
|
|
609
|
+
return "Triage bash allows git branch only for read-only listing/show-current/contains/merged queries.";
|
|
610
|
+
}
|
|
611
|
+
return undefined;
|
|
612
|
+
}
|
|
613
|
+
if (parsed.subcommand === "remote") {
|
|
614
|
+
if (!isSafeGitRemoteCommand(tokens, parsed.index)) {
|
|
615
|
+
return "Triage bash allows git remote only for read-only list/show/get-url queries.";
|
|
616
|
+
}
|
|
617
|
+
return undefined;
|
|
618
|
+
}
|
|
619
|
+
if (!READ_ONLY_GIT_SUBCOMMANDS.has(parsed.subcommand)) {
|
|
620
|
+
return `Triage bash blocks git ${parsed.subcommand}; only known read-only git subcommands are allowed.`;
|
|
621
|
+
}
|
|
622
|
+
return undefined;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function getGhCommand(tokens: string[]): { command?: string; subcommand?: string } {
|
|
626
|
+
let index = 1;
|
|
627
|
+
while (index < tokens.length) {
|
|
628
|
+
const token = tokens[index];
|
|
629
|
+
if (!token.startsWith("-")) break;
|
|
630
|
+
if (GH_GLOBAL_OPTIONS_WITH_VALUE.has(token)) {
|
|
631
|
+
index += 2;
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
index += 1;
|
|
635
|
+
}
|
|
636
|
+
return { command: tokens[index]?.toLowerCase(), subcommand: tokens[index + 1]?.toLowerCase() };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function normalizeFlagValue(value: string | undefined): string | undefined {
|
|
640
|
+
return value ? stripTokenQuotes(value).trim() : undefined;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function getBlockedGhApiReason(tokens: string[]): string | undefined {
|
|
644
|
+
if (getGhCommand(tokens).command !== "api") return undefined;
|
|
645
|
+
for (let index = 1; index < tokens.length; index += 1) {
|
|
646
|
+
const token = tokens[index];
|
|
647
|
+
const lowerToken = token.toLowerCase();
|
|
648
|
+
if (
|
|
649
|
+
token === "-f" ||
|
|
650
|
+
token === "-F" ||
|
|
651
|
+
token === "--field" ||
|
|
652
|
+
token === "--raw-field" ||
|
|
653
|
+
token === "--input" ||
|
|
654
|
+
lowerToken.startsWith("-f") ||
|
|
655
|
+
lowerToken.startsWith("--field=") ||
|
|
656
|
+
lowerToken.startsWith("--raw-field=") ||
|
|
657
|
+
lowerToken.startsWith("--input=")
|
|
658
|
+
) {
|
|
659
|
+
return "Triage bash allows read-only gh api calls only; request fields and input files are blocked.";
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
let method: string | undefined;
|
|
663
|
+
if (lowerToken === "-x" || lowerToken === "--method") {
|
|
664
|
+
method = normalizeFlagValue(tokens[index + 1]);
|
|
665
|
+
} else if (lowerToken.startsWith("-x") && token.length > 2) {
|
|
666
|
+
method = normalizeFlagValue(token.slice(2).replace(/^=/, ""));
|
|
667
|
+
} else if (lowerToken.startsWith("--method=")) {
|
|
668
|
+
method = normalizeFlagValue(token.slice("--method=".length));
|
|
669
|
+
}
|
|
670
|
+
if (method && MUTATING_GH_API_METHODS.has(method.toUpperCase())) {
|
|
671
|
+
return "Triage bash allows read-only gh api calls only; mutating methods are blocked.";
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return undefined;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function isReadOnlyGhCommand(command: string | undefined, subcommand: string | undefined): boolean {
|
|
678
|
+
if (!command) return true;
|
|
679
|
+
if (command === "api") return true;
|
|
680
|
+
if (command === "search") return true;
|
|
681
|
+
if (command === "repo") return subcommand === "view" || subcommand === "list";
|
|
682
|
+
if (command === "pr") return subcommand === "view" || subcommand === "list" || subcommand === "diff" || subcommand === "status" || subcommand === "checks";
|
|
683
|
+
if (command === "issue") return subcommand === "view" || subcommand === "list" || subcommand === "status";
|
|
684
|
+
if (command === "release") return subcommand === "view" || subcommand === "list";
|
|
685
|
+
if (command === "run") return subcommand === "view" || subcommand === "list";
|
|
686
|
+
if (command === "workflow") return subcommand === "view" || subcommand === "list";
|
|
687
|
+
if (command === "label") return subcommand === "list";
|
|
688
|
+
if (command === "milestone") return subcommand === "list";
|
|
689
|
+
return false;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function getBlockedGhReason(command: string): string | undefined {
|
|
693
|
+
const tokens = tokenizeSegment(command);
|
|
694
|
+
if (getExecutableName(tokens[0] ?? "") !== "gh") return undefined;
|
|
695
|
+
if (/\bgh\s+auth\s+(?:login|logout|refresh|setup-git|token)\b/i.test(command)) {
|
|
696
|
+
return "Triage bash blocks gh auth commands and token inspection.";
|
|
697
|
+
}
|
|
698
|
+
const apiReason = getBlockedGhApiReason(tokens);
|
|
699
|
+
if (apiReason) return apiReason;
|
|
700
|
+
if (/\bgh\s+(?:repo\s+(?:archive|clone|create|delete|edit|fork|rename|sync)|pr\s+(?:checkout|close|comment|create|edit|lock|merge|ready|reopen|review|unlock)|issue\s+(?:close|comment|create|delete|edit|lock|reopen|transfer|unlock)|release\s+(?:create|delete|edit|upload)|workflow\s+run|run\s+(?:cancel|delete|rerun)|gist\s+(?:create|delete|edit))\b/i.test(command)) {
|
|
701
|
+
return "Triage bash blocks mutating gh commands.";
|
|
702
|
+
}
|
|
703
|
+
const parsed = getGhCommand(tokens);
|
|
704
|
+
if (!isReadOnlyGhCommand(parsed.command, parsed.subcommand)) {
|
|
705
|
+
return `Triage bash blocks gh ${[parsed.command, parsed.subcommand].filter(Boolean).join(" ")}; only known read-only gh commands are allowed.`;
|
|
706
|
+
}
|
|
707
|
+
return undefined;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function getBlockedBashReason(command: string): string | undefined {
|
|
711
|
+
const trimmed = command.trim();
|
|
712
|
+
if (!trimmed) return "Triage bash requires a non-empty command.";
|
|
713
|
+
if (/`|\$\(/.test(trimmed)) return "Triage bash blocks command substitution.";
|
|
714
|
+
const syntaxReason = getShellSyntaxReason(trimmed);
|
|
715
|
+
if (syntaxReason) return syntaxReason;
|
|
716
|
+
|
|
717
|
+
const tokens = tokenizeSegment(trimmed);
|
|
718
|
+
if (tokens.length === 0) return "Triage bash requires a non-empty command.";
|
|
719
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) return "Triage bash blocks inline environment assignment.";
|
|
720
|
+
if (/[\\/]/.test(tokens[0])) {
|
|
721
|
+
return "Triage bash requires direct git, gh, or pwd invocation without path-qualified executables.";
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const executable = getExecutableName(tokens[0]);
|
|
725
|
+
if (!READ_ONLY_BASH_COMMANDS.has(executable)) {
|
|
726
|
+
return `Triage bash blocks ${executable || "this command"}; use built-in read/grep/find/ls for local files or read-only git/gh inspection commands.`;
|
|
727
|
+
}
|
|
728
|
+
if (executable === "pwd") return getBlockedPwdReason(tokens);
|
|
729
|
+
if (executable === "git") return getBlockedGitReason(trimmed);
|
|
730
|
+
if (executable === "gh") return getBlockedGhReason(trimmed);
|
|
731
|
+
return undefined;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function createTriageRuntimeGuardExtension(options: { cwd: string; maxTurns: number }): ExtensionFactory {
|
|
735
|
+
return (pi) => {
|
|
736
|
+
let currentTurn = 0;
|
|
737
|
+
|
|
738
|
+
pi.on("turn_start", async (event) => {
|
|
739
|
+
currentTurn = event.turnIndex;
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
pi.on("tool_call", async (event) => {
|
|
743
|
+
if (!READ_ONLY_TOOL_NAMES.has(event.toolName)) {
|
|
744
|
+
return { block: true, reason: `triage_comments exposes read-only tools only; ${event.toolName} is not allowed.` };
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if (currentTurn >= options.maxTurns - 1) {
|
|
748
|
+
return {
|
|
749
|
+
block: true,
|
|
750
|
+
reason: `Tool use is disabled on final triage_comments turn ${options.maxTurns}/${options.maxTurns}. Answer now with the evidence already gathered.`,
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (event.toolName === "read") {
|
|
755
|
+
const reason = await assertToolPathInsideCwd(options.cwd, (event.input as { path?: unknown }).path, "read");
|
|
756
|
+
if (reason) return { block: true, reason };
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (event.toolName === "grep" || event.toolName === "find" || event.toolName === "ls") {
|
|
760
|
+
const reason = await assertToolPathInsideCwd(options.cwd, (event.input as { path?: unknown }).path, event.toolName);
|
|
761
|
+
if (reason) return { block: true, reason };
|
|
762
|
+
if (event.toolName === "grep") {
|
|
763
|
+
const globReason = getUnsafePathPatternReason((event.input as { glob?: unknown }).glob, "grep glob");
|
|
764
|
+
if (globReason) return { block: true, reason: globReason };
|
|
765
|
+
}
|
|
766
|
+
if (event.toolName === "find") {
|
|
767
|
+
const patternReason = getUnsafePathPatternReason((event.input as { pattern?: unknown }).pattern, "find pattern");
|
|
768
|
+
if (patternReason) return { block: true, reason: patternReason };
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if (event.toolName === "bash") {
|
|
773
|
+
const input = event.input as { command?: unknown; timeout?: unknown };
|
|
774
|
+
if (typeof input.timeout !== "number") input.timeout = DEFAULT_BASH_TIMEOUT_SECONDS;
|
|
775
|
+
const command = typeof input.command === "string" ? input.command : "";
|
|
776
|
+
const reason = getBlockedBashReason(command);
|
|
777
|
+
if (reason) return { block: true, reason };
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
return undefined;
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
pi.on("tool_result", async (event) => ({
|
|
784
|
+
content: [
|
|
785
|
+
...(event.content ?? []),
|
|
786
|
+
{
|
|
787
|
+
type: "text",
|
|
788
|
+
text: `\n\n[triage_comments turn budget] turn ${Math.min(currentTurn + 1, options.maxTurns)}/${options.maxTurns}`,
|
|
789
|
+
},
|
|
790
|
+
],
|
|
791
|
+
}));
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function formatToolCall(call: ToolCall): string {
|
|
796
|
+
const args = call.args && typeof call.args === "object" ? (call.args as Record<string, unknown>) : {};
|
|
797
|
+
if (call.name === "read") {
|
|
798
|
+
const readPath = typeof args.path === "string" ? args.path : "";
|
|
799
|
+
const offset = typeof args.offset === "number" ? args.offset : undefined;
|
|
800
|
+
const limit = typeof args.limit === "number" ? args.limit : undefined;
|
|
801
|
+
const range = offset || limit ? `:${offset ?? 1}${limit ? `-${(offset ?? 1) + limit - 1}` : ""}` : "";
|
|
802
|
+
return `read ${readPath}${range}`.trim();
|
|
803
|
+
}
|
|
804
|
+
if (call.name === "grep") {
|
|
805
|
+
const pattern = typeof args.pattern === "string" ? args.pattern : "";
|
|
806
|
+
const grepPath = typeof args.path === "string" ? args.path : ".";
|
|
807
|
+
return `grep ${shorten(pattern, 50)} ${grepPath}`.trim();
|
|
808
|
+
}
|
|
809
|
+
if (call.name === "find") {
|
|
810
|
+
const pattern = typeof args.pattern === "string" ? args.pattern : "";
|
|
811
|
+
const findPath = typeof args.path === "string" ? args.path : ".";
|
|
812
|
+
return `find ${shorten(pattern, 50)} ${findPath}`.trim();
|
|
813
|
+
}
|
|
814
|
+
if (call.name === "ls") {
|
|
815
|
+
return `ls ${typeof args.path === "string" ? args.path : "."}`.trim();
|
|
816
|
+
}
|
|
817
|
+
if (call.name === "bash") {
|
|
818
|
+
const command = typeof args.command === "string" ? args.command : "";
|
|
819
|
+
return `bash ${shorten(command, 120)}`.trim();
|
|
820
|
+
}
|
|
821
|
+
return call.name;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function renderAnswer(details: TriageDetails): string {
|
|
825
|
+
if (details.error) return details.error;
|
|
826
|
+
return details.status === "running" ? "(triaging comments...)" : "(no output)";
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
type TriageCommandMode = 'paste' | 'pr';
|
|
830
|
+
|
|
831
|
+
type ParsedTriageCommandArgs = {
|
|
832
|
+
mode?: TriageCommandMode;
|
|
833
|
+
target?: string;
|
|
834
|
+
pastePrefill?: string;
|
|
835
|
+
help?: boolean;
|
|
836
|
+
error?: string;
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
type PullRequestUrlParts = {
|
|
840
|
+
host: string;
|
|
841
|
+
owner: string;
|
|
842
|
+
repo: string;
|
|
843
|
+
number: number;
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
type PullRequestContext = {
|
|
847
|
+
number: number;
|
|
848
|
+
title?: string;
|
|
849
|
+
url?: string;
|
|
850
|
+
repository: string;
|
|
851
|
+
host?: string;
|
|
852
|
+
headRef?: string;
|
|
853
|
+
headSha?: string;
|
|
854
|
+
baseRef?: string;
|
|
855
|
+
baseSha?: string;
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
type CommandComment = {
|
|
859
|
+
id: string;
|
|
860
|
+
body: string;
|
|
861
|
+
path?: string;
|
|
862
|
+
line?: number;
|
|
863
|
+
startLine?: number;
|
|
864
|
+
side?: string;
|
|
865
|
+
diffHunk?: string;
|
|
866
|
+
author?: string;
|
|
867
|
+
url?: string;
|
|
868
|
+
createdAt?: string;
|
|
869
|
+
metadata: Record<string, unknown>;
|
|
870
|
+
sourceLabel: string;
|
|
871
|
+
displayNumber?: number;
|
|
872
|
+
sortTimestamp?: string;
|
|
873
|
+
sortIndex: number;
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
type SelectionParseResult = { ok: true; indices: number[] } | { ok: false; error: string };
|
|
877
|
+
|
|
878
|
+
type TriageCommandPayload = {
|
|
879
|
+
comments: Record<string, unknown>[];
|
|
880
|
+
pr?: Record<string, unknown>;
|
|
881
|
+
base?: Record<string, unknown>;
|
|
882
|
+
context?: string;
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
function notifyCommand(
|
|
886
|
+
ctx: ExtensionCommandContext,
|
|
887
|
+
message: string,
|
|
888
|
+
type: 'info' | 'warning' | 'error' = 'info',
|
|
889
|
+
): void {
|
|
890
|
+
if (ctx.hasUI) {
|
|
891
|
+
ctx.ui.notify(message, type);
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const writer = type === 'error' ? console.error : console.log;
|
|
896
|
+
writer(message);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function formatErrorMessage(error: unknown): string {
|
|
900
|
+
return error instanceof Error ? error.message : String(error);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function compactRecord(record: Record<string, unknown>): Record<string, unknown> {
|
|
904
|
+
const compacted: Record<string, unknown> = {};
|
|
905
|
+
for (const [key, value] of Object.entries(record)) {
|
|
906
|
+
if (value === undefined || value === null) continue;
|
|
907
|
+
if (typeof value === 'string' && value.trim() === '') continue;
|
|
908
|
+
if (Array.isArray(value) && value.length === 0) continue;
|
|
909
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value as Record<string, unknown>).length === 0) {
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
compacted[key] = value;
|
|
913
|
+
}
|
|
914
|
+
return compacted;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function optionalRecord(record: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
918
|
+
const compacted = compactRecord(record);
|
|
919
|
+
return Object.keys(compacted).length > 0 ? compacted : undefined;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function asStableIdPart(...values: unknown[]): string | undefined {
|
|
923
|
+
for (const value of values) {
|
|
924
|
+
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
|
925
|
+
const trimmed = asTrimmedString(value);
|
|
926
|
+
if (trimmed) return trimmed;
|
|
927
|
+
}
|
|
928
|
+
return undefined;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function parsePullRequestUrl(raw: string): PullRequestUrlParts | undefined {
|
|
932
|
+
const trimmed = raw.trim();
|
|
933
|
+
if (!trimmed) return undefined;
|
|
934
|
+
|
|
935
|
+
try {
|
|
936
|
+
const url = new URL(trimmed);
|
|
937
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined;
|
|
938
|
+
const segments = url.pathname.split('/').filter(Boolean).map((segment) => decodeURIComponent(segment));
|
|
939
|
+
if (segments.length < 4 || segments[2] !== 'pull') return undefined;
|
|
940
|
+
const number = Number(segments[3]);
|
|
941
|
+
if (!Number.isInteger(number) || number <= 0) return undefined;
|
|
942
|
+
return {
|
|
943
|
+
host: url.hostname.replace(/^www\./i, ''),
|
|
944
|
+
owner: segments[0],
|
|
945
|
+
repo: segments[1],
|
|
946
|
+
number,
|
|
947
|
+
};
|
|
948
|
+
} catch {
|
|
949
|
+
return undefined;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function looksLikePrTarget(value: string): boolean {
|
|
954
|
+
const trimmed = value.trim();
|
|
955
|
+
return /^#?\d+$/.test(trimmed) || Boolean(parsePullRequestUrl(trimmed));
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function normalizePrTarget(value: string): string | undefined {
|
|
959
|
+
const trimmed = value.trim();
|
|
960
|
+
if (!trimmed) return undefined;
|
|
961
|
+
if (/^#?\d+$/.test(trimmed)) return trimmed.replace(/^#/, '');
|
|
962
|
+
if (parsePullRequestUrl(trimmed)) return trimmed;
|
|
963
|
+
return undefined;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function parseTriageCommandArgs(args: string): ParsedTriageCommandArgs {
|
|
967
|
+
const trimmed = args.trim();
|
|
968
|
+
if (!trimmed) return {};
|
|
969
|
+
|
|
970
|
+
const [first = ''] = trimmed.split(/\s+/, 1);
|
|
971
|
+
const normalized = first.toLowerCase();
|
|
972
|
+
const rest = trimmed.slice(first.length).trim();
|
|
973
|
+
|
|
974
|
+
if (normalized === '--help' || normalized === '-h' || normalized === 'help') return { help: true };
|
|
975
|
+
if (normalized === 'paste' || normalized === 'manual') return { mode: 'paste', pastePrefill: rest || undefined };
|
|
976
|
+
if (normalized === 'pr' || normalized === 'pull' || normalized === 'pull-request') return { mode: 'pr', target: rest || undefined };
|
|
977
|
+
if (looksLikePrTarget(trimmed)) return { mode: 'pr', target: trimmed };
|
|
978
|
+
|
|
979
|
+
return { error: `Unknown /triage-comments option: ${first}\n${TRIAGE_COMMAND_USAGE}` };
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function formatExecFailure(result: ExecResult): string {
|
|
983
|
+
const details = (result.stderr || result.stdout).trim();
|
|
984
|
+
if (!details) return `exit code ${result.code}`;
|
|
985
|
+
const lines = details.split('\n').filter(Boolean);
|
|
986
|
+
return lines.slice(-6).join('\n');
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
async function execChecked(
|
|
990
|
+
pi: ExtensionAPI,
|
|
991
|
+
ctx: ExtensionCommandContext,
|
|
992
|
+
command: string,
|
|
993
|
+
args: string[],
|
|
994
|
+
label: string,
|
|
995
|
+
): Promise<string> {
|
|
996
|
+
const result = await pi.exec(command, args, { cwd: ctx.cwd, signal: ctx.signal, timeout: GH_COMMAND_TIMEOUT_MS });
|
|
997
|
+
if (result.killed) throw new Error(`${label} timed out or was cancelled.`);
|
|
998
|
+
if (result.code !== 0) throw new Error(`${label} failed: ${formatExecFailure(result)}`);
|
|
999
|
+
return result.stdout;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
async function assertGitRepo(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
|
|
1003
|
+
const result = await pi.exec('git', ['rev-parse', '--show-toplevel'], {
|
|
1004
|
+
cwd: ctx.cwd,
|
|
1005
|
+
signal: ctx.signal,
|
|
1006
|
+
timeout: GH_COMMAND_TIMEOUT_MS,
|
|
1007
|
+
});
|
|
1008
|
+
if (result.killed) throw new Error('Checking the git repository timed out or was cancelled.');
|
|
1009
|
+
if (result.code !== 0) {
|
|
1010
|
+
throw new Error('/triage-comments PR mode must run inside a git repository. Open The Last Harness in a checkout and retry.');
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
async function assertGhReady(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
|
|
1015
|
+
const version = await pi.exec('gh', ['--version'], { cwd: ctx.cwd, signal: ctx.signal, timeout: GH_COMMAND_TIMEOUT_MS });
|
|
1016
|
+
if (version.killed) throw new Error('Checking GitHub CLI availability timed out or was cancelled.');
|
|
1017
|
+
if (version.code !== 0) {
|
|
1018
|
+
throw new Error('GitHub CLI `gh` is required for PR mode but was not found or could not run. Install `gh`, then authenticate with `gh auth login`.');
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
const auth = await pi.exec('gh', ['auth', 'status'], { cwd: ctx.cwd, signal: ctx.signal, timeout: GH_COMMAND_TIMEOUT_MS });
|
|
1022
|
+
if (auth.killed) throw new Error('Checking GitHub CLI authentication timed out or was cancelled.');
|
|
1023
|
+
if (auth.code !== 0) {
|
|
1024
|
+
throw new Error(`GitHub CLI ` + '`gh`' + ` is not authenticated. Run ` + '`gh auth login`' + ` and retry. Details: ${formatExecFailure(auth)}`);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function parseJsonOutput(stdout: string, label: string): unknown {
|
|
1029
|
+
const trimmed = stdout.trim();
|
|
1030
|
+
if (!trimmed) throw new Error(`${label} returned empty output; expected JSON.`);
|
|
1031
|
+
try {
|
|
1032
|
+
return JSON.parse(trimmed);
|
|
1033
|
+
} catch (error) {
|
|
1034
|
+
throw new Error(`${label} returned invalid JSON: ${formatErrorMessage(error)}`);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function flattenGhArray(value: unknown, label: string): unknown[] {
|
|
1039
|
+
if (!Array.isArray(value)) throw new Error(`${label} returned JSON that was not an array.`);
|
|
1040
|
+
if (value.every((item) => Array.isArray(item))) return value.flatMap((item) => item as unknown[]);
|
|
1041
|
+
return value;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function normalizePullRequestContext(raw: unknown, target: string): PullRequestContext {
|
|
1045
|
+
const record = asRecord(raw);
|
|
1046
|
+
if (!record) throw new Error('Could not parse PR metadata from `gh pr view`: expected a JSON object.');
|
|
1047
|
+
|
|
1048
|
+
const number = asFiniteNumber(record.number) ?? parsePullRequestUrl(target)?.number;
|
|
1049
|
+
const url = asTrimmedString(record.url);
|
|
1050
|
+
const parsedUrl = (url ? parsePullRequestUrl(url) : undefined) ?? parsePullRequestUrl(target);
|
|
1051
|
+
if (!number || !Number.isInteger(number) || number <= 0) {
|
|
1052
|
+
throw new Error('Could not parse the PR number from `gh pr view` output.');
|
|
1053
|
+
}
|
|
1054
|
+
if (!parsedUrl) {
|
|
1055
|
+
throw new Error('Could not parse the PR repository from `gh pr view` output. Use a GitHub PR URL or run from a checkout with a GitHub remote.');
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
return {
|
|
1059
|
+
number,
|
|
1060
|
+
title: asTrimmedString(record.title),
|
|
1061
|
+
url,
|
|
1062
|
+
repository: `${parsedUrl.owner}/${parsedUrl.repo}`,
|
|
1063
|
+
host: parsedUrl.host,
|
|
1064
|
+
headRef: asTrimmedString(record.headRefName ?? record.headRef),
|
|
1065
|
+
headSha: asTrimmedString(record.headRefOid ?? record.headSha),
|
|
1066
|
+
baseRef: asTrimmedString(record.baseRefName ?? record.baseRef),
|
|
1067
|
+
baseSha: asTrimmedString(record.baseRefOid ?? record.baseSha),
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
async function ghApiArray(
|
|
1072
|
+
pi: ExtensionAPI,
|
|
1073
|
+
ctx: ExtensionCommandContext,
|
|
1074
|
+
pr: PullRequestContext,
|
|
1075
|
+
endpointSuffix: string,
|
|
1076
|
+
label: string,
|
|
1077
|
+
): Promise<unknown[]> {
|
|
1078
|
+
const [owner, repo] = pr.repository.split('/');
|
|
1079
|
+
if (!owner || !repo) throw new Error(`Could not build GitHub API path from repository ${pr.repository}.`);
|
|
1080
|
+
const endpoint = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${endpointSuffix}`;
|
|
1081
|
+
const args = ['api'];
|
|
1082
|
+
if (pr.host) args.push('--hostname', pr.host);
|
|
1083
|
+
args.push('--paginate', '--slurp', endpoint);
|
|
1084
|
+
const stdout = await execChecked(pi, ctx, 'gh', args, `Fetching ${label} with gh`);
|
|
1085
|
+
return flattenGhArray(parseJsonOutput(stdout, `Fetching ${label} with gh`), label);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function githubLogin(value: unknown): string | undefined {
|
|
1089
|
+
if (typeof value === 'string') return asTrimmedString(value);
|
|
1090
|
+
const record = asRecord(value);
|
|
1091
|
+
return record ? asTrimmedString(record.login ?? record.name) : undefined;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
function reviewHtmlUrl(record: Record<string, unknown>): string | undefined {
|
|
1095
|
+
const links = asRecord(record._links);
|
|
1096
|
+
const html = asRecord(links?.html);
|
|
1097
|
+
return asTrimmedString(record.html_url ?? record.htmlUrl ?? html?.href);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function normalizeReviewComment(raw: unknown, pr: PullRequestContext, sortIndex: number): CommandComment | undefined {
|
|
1101
|
+
const record = asRecord(raw);
|
|
1102
|
+
if (!record) return undefined;
|
|
1103
|
+
const body = asTrimmedString(record.body);
|
|
1104
|
+
if (!body) return undefined;
|
|
1105
|
+
|
|
1106
|
+
const databaseId = asFiniteNumber(record.id);
|
|
1107
|
+
const nodeId = asTrimmedString(record.node_id ?? record.nodeId);
|
|
1108
|
+
const stableId = asStableIdPart(record.id, nodeId) ?? `${pr.repository}#${pr.number}:review-comment:${sortIndex}`;
|
|
1109
|
+
const createdAt = asTrimmedString(record.created_at ?? record.createdAt);
|
|
1110
|
+
const originalLine = asFiniteNumber(record.original_line ?? record.originalLine);
|
|
1111
|
+
const originalStartLine = asFiniteNumber(record.original_start_line ?? record.originalStartLine);
|
|
1112
|
+
const line = asFiniteNumber(record.line) ?? originalLine;
|
|
1113
|
+
const startLine = asFiniteNumber(record.start_line ?? record.startLine) ?? originalStartLine;
|
|
1114
|
+
|
|
1115
|
+
return {
|
|
1116
|
+
id: `github-review-comment:${stableId}`,
|
|
1117
|
+
body,
|
|
1118
|
+
path: asTrimmedString(record.path),
|
|
1119
|
+
line,
|
|
1120
|
+
startLine,
|
|
1121
|
+
side: asTrimmedString(record.side ?? record.start_side ?? record.startSide),
|
|
1122
|
+
diffHunk: asTrimmedString(record.diff_hunk ?? record.diffHunk),
|
|
1123
|
+
author: githubLogin(record.user),
|
|
1124
|
+
url: asTrimmedString(record.html_url ?? record.htmlUrl),
|
|
1125
|
+
createdAt,
|
|
1126
|
+
metadata: compactRecord({
|
|
1127
|
+
source: 'pull_request_review_comment',
|
|
1128
|
+
repository: pr.repository,
|
|
1129
|
+
prNumber: pr.number,
|
|
1130
|
+
host: pr.host,
|
|
1131
|
+
databaseId,
|
|
1132
|
+
nodeId,
|
|
1133
|
+
pullRequestReviewId: asFiniteNumber(record.pull_request_review_id ?? record.pullRequestReviewId),
|
|
1134
|
+
commitId: asTrimmedString(record.commit_id ?? record.commitId),
|
|
1135
|
+
originalCommitId: asTrimmedString(record.original_commit_id ?? record.originalCommitId),
|
|
1136
|
+
originalLine,
|
|
1137
|
+
originalStartLine,
|
|
1138
|
+
position: asFiniteNumber(record.position),
|
|
1139
|
+
originalPosition: asFiniteNumber(record.original_position ?? record.originalPosition),
|
|
1140
|
+
subjectType: asTrimmedString(record.subject_type ?? record.subjectType),
|
|
1141
|
+
authorAssociation: asTrimmedString(record.author_association ?? record.authorAssociation),
|
|
1142
|
+
inReplyToId: asFiniteNumber(record.in_reply_to_id ?? record.inReplyToId),
|
|
1143
|
+
updatedAt: asTrimmedString(record.updated_at ?? record.updatedAt),
|
|
1144
|
+
}),
|
|
1145
|
+
sourceLabel: 'review comment',
|
|
1146
|
+
sortTimestamp: createdAt,
|
|
1147
|
+
sortIndex,
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function normalizeIssueComment(raw: unknown, pr: PullRequestContext, sortIndex: number): CommandComment | undefined {
|
|
1152
|
+
const record = asRecord(raw);
|
|
1153
|
+
if (!record) return undefined;
|
|
1154
|
+
const body = asTrimmedString(record.body);
|
|
1155
|
+
if (!body) return undefined;
|
|
1156
|
+
|
|
1157
|
+
const databaseId = asFiniteNumber(record.id);
|
|
1158
|
+
const nodeId = asTrimmedString(record.node_id ?? record.nodeId);
|
|
1159
|
+
const stableId = asStableIdPart(record.id, nodeId) ?? `${pr.repository}#${pr.number}:issue-comment:${sortIndex}`;
|
|
1160
|
+
const createdAt = asTrimmedString(record.created_at ?? record.createdAt);
|
|
1161
|
+
|
|
1162
|
+
return {
|
|
1163
|
+
id: `github-issue-comment:${stableId}`,
|
|
1164
|
+
body,
|
|
1165
|
+
author: githubLogin(record.user),
|
|
1166
|
+
url: asTrimmedString(record.html_url ?? record.htmlUrl),
|
|
1167
|
+
createdAt,
|
|
1168
|
+
metadata: compactRecord({
|
|
1169
|
+
source: 'pull_request_issue_comment',
|
|
1170
|
+
repository: pr.repository,
|
|
1171
|
+
prNumber: pr.number,
|
|
1172
|
+
host: pr.host,
|
|
1173
|
+
databaseId,
|
|
1174
|
+
nodeId,
|
|
1175
|
+
authorAssociation: asTrimmedString(record.author_association ?? record.authorAssociation),
|
|
1176
|
+
updatedAt: asTrimmedString(record.updated_at ?? record.updatedAt),
|
|
1177
|
+
}),
|
|
1178
|
+
sourceLabel: 'issue comment',
|
|
1179
|
+
sortTimestamp: createdAt,
|
|
1180
|
+
sortIndex,
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function normalizeReviewBody(raw: unknown, pr: PullRequestContext, sortIndex: number): CommandComment | undefined {
|
|
1185
|
+
const record = asRecord(raw);
|
|
1186
|
+
if (!record) return undefined;
|
|
1187
|
+
const body = asTrimmedString(record.body);
|
|
1188
|
+
if (!body) return undefined;
|
|
1189
|
+
|
|
1190
|
+
const databaseId = asFiniteNumber(record.id);
|
|
1191
|
+
const nodeId = asTrimmedString(record.node_id ?? record.nodeId);
|
|
1192
|
+
const stableId = asStableIdPart(record.id, nodeId) ?? `${pr.repository}#${pr.number}:review:${sortIndex}`;
|
|
1193
|
+
const submittedAt = asTrimmedString(record.submitted_at ?? record.submittedAt);
|
|
1194
|
+
const createdAt = submittedAt ?? asTrimmedString(record.created_at ?? record.createdAt);
|
|
1195
|
+
|
|
1196
|
+
return {
|
|
1197
|
+
id: `github-review:${stableId}`,
|
|
1198
|
+
body,
|
|
1199
|
+
author: githubLogin(record.user),
|
|
1200
|
+
url: reviewHtmlUrl(record),
|
|
1201
|
+
createdAt,
|
|
1202
|
+
metadata: compactRecord({
|
|
1203
|
+
source: 'pull_request_review_body',
|
|
1204
|
+
repository: pr.repository,
|
|
1205
|
+
prNumber: pr.number,
|
|
1206
|
+
host: pr.host,
|
|
1207
|
+
databaseId,
|
|
1208
|
+
nodeId,
|
|
1209
|
+
state: asTrimmedString(record.state),
|
|
1210
|
+
commitId: asTrimmedString(record.commit_id ?? record.commitId),
|
|
1211
|
+
submittedAt,
|
|
1212
|
+
authorAssociation: asTrimmedString(record.author_association ?? record.authorAssociation),
|
|
1213
|
+
}),
|
|
1214
|
+
sourceLabel: 'review body',
|
|
1215
|
+
sortTimestamp: createdAt,
|
|
1216
|
+
sortIndex,
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function compareCommandComments(a: CommandComment, b: CommandComment): number {
|
|
1221
|
+
const aTime = a.sortTimestamp ? Date.parse(a.sortTimestamp) : NaN;
|
|
1222
|
+
const bTime = b.sortTimestamp ? Date.parse(b.sortTimestamp) : NaN;
|
|
1223
|
+
if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) return aTime - bTime;
|
|
1224
|
+
if (Number.isFinite(aTime) && !Number.isFinite(bTime)) return -1;
|
|
1225
|
+
if (!Number.isFinite(aTime) && Number.isFinite(bTime)) return 1;
|
|
1226
|
+
return a.sortIndex - b.sortIndex;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
function assignDisplayNumbers(comments: CommandComment[]): CommandComment[] {
|
|
1230
|
+
return comments.map((comment, index) => ({
|
|
1231
|
+
...comment,
|
|
1232
|
+
displayNumber: index + 1,
|
|
1233
|
+
metadata: compactRecord({ ...comment.metadata, displayNumber: index + 1 }),
|
|
1234
|
+
}));
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
async function fetchPrCommentsForTriage(
|
|
1238
|
+
pi: ExtensionAPI,
|
|
1239
|
+
ctx: ExtensionCommandContext,
|
|
1240
|
+
target: string,
|
|
1241
|
+
): Promise<{ pr: PullRequestContext; comments: CommandComment[] }> {
|
|
1242
|
+
const normalizedTarget = normalizePrTarget(target);
|
|
1243
|
+
if (!normalizedTarget) {
|
|
1244
|
+
throw new Error('PR input must be a PR number (for example, 123 or #123) or a GitHub pull request URL.');
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
await assertGitRepo(pi, ctx);
|
|
1248
|
+
await assertGhReady(pi, ctx);
|
|
1249
|
+
|
|
1250
|
+
const prStdout = await execChecked(
|
|
1251
|
+
pi,
|
|
1252
|
+
ctx,
|
|
1253
|
+
'gh',
|
|
1254
|
+
['pr', 'view', normalizedTarget, '--json', 'number,title,url,headRefName,headRefOid,baseRefName,baseRefOid'],
|
|
1255
|
+
'Fetching PR metadata with gh',
|
|
1256
|
+
);
|
|
1257
|
+
const pr = normalizePullRequestContext(parseJsonOutput(prStdout, 'Fetching PR metadata with gh'), normalizedTarget);
|
|
1258
|
+
const reviewComments = await ghApiArray(pi, ctx, pr, `pulls/${pr.number}/comments?per_page=100`, 'review comments');
|
|
1259
|
+
const issueComments = await ghApiArray(pi, ctx, pr, `issues/${pr.number}/comments?per_page=100`, 'issue comments');
|
|
1260
|
+
const reviews = await ghApiArray(pi, ctx, pr, `pulls/${pr.number}/reviews?per_page=100`, 'review bodies');
|
|
1261
|
+
|
|
1262
|
+
let sortIndex = 0;
|
|
1263
|
+
const comments: CommandComment[] = [];
|
|
1264
|
+
for (const raw of reviewComments) {
|
|
1265
|
+
const comment = normalizeReviewComment(raw, pr, ++sortIndex);
|
|
1266
|
+
if (comment) comments.push(comment);
|
|
1267
|
+
}
|
|
1268
|
+
for (const raw of issueComments) {
|
|
1269
|
+
const comment = normalizeIssueComment(raw, pr, ++sortIndex);
|
|
1270
|
+
if (comment) comments.push(comment);
|
|
1271
|
+
}
|
|
1272
|
+
for (const raw of reviews) {
|
|
1273
|
+
const comment = normalizeReviewBody(raw, pr, ++sortIndex);
|
|
1274
|
+
if (comment) comments.push(comment);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
return { pr, comments: assignDisplayNumbers(comments.sort(compareCommandComments)) };
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function truncateForDisplay(text: string, max: number): string {
|
|
1281
|
+
if (text.length <= max) return text;
|
|
1282
|
+
return `${text.slice(0, Math.max(1, max)).trimEnd()}\n… (truncated; full text will be sent to triage_comments)`;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function indentLines(text: string, prefix: string): string {
|
|
1286
|
+
return text.split('\n').map((line) => `${prefix}${line}`).join('\n');
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
function formatCommentLocation(comment: CommandComment): string {
|
|
1290
|
+
if (!comment.path) return 'PR discussion';
|
|
1291
|
+
const lineRange = comment.startLine && comment.line && comment.startLine !== comment.line
|
|
1292
|
+
? `${comment.startLine}-${comment.line}`
|
|
1293
|
+
: comment.line ?? comment.startLine;
|
|
1294
|
+
return lineRange ? `${comment.path}:${lineRange}` : comment.path;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function formatFetchedCommentsForSelection(pr: PullRequestContext, comments: CommandComment[]): string {
|
|
1298
|
+
const title = pr.title ? ` — ${pr.title}` : '';
|
|
1299
|
+
const limitNotice = comments.length > MAX_COMMENTS
|
|
1300
|
+
? `\n\ntriage_comments can investigate at most ${MAX_COMMENTS} comments per run, so choose a subset.`
|
|
1301
|
+
: '\n\nChoose whether to investigate all displayed comments or select a subset.';
|
|
1302
|
+
const header = `Fetched ${comments.length} numbered comment(s) from ${pr.repository}#${pr.number}${title}.${limitNotice}`;
|
|
1303
|
+
const body = comments.map((comment) => {
|
|
1304
|
+
const author = comment.author ? ` by @${comment.author}` : '';
|
|
1305
|
+
const url = comment.url ? `\n url: ${comment.url}` : '';
|
|
1306
|
+
const preview = indentLines(truncateForDisplay(comment.body, COMMENT_DISPLAY_BODY_LIMIT), ' ');
|
|
1307
|
+
return `${comment.displayNumber ?? '?'}. ${comment.sourceLabel}${author} — ${formatCommentLocation(comment)}\n id: ${comment.id}${url}\n${preview}`;
|
|
1308
|
+
});
|
|
1309
|
+
return [header, ...body].join('\n\n');
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
function parseSelectionList(input: string, max: number): SelectionParseResult {
|
|
1313
|
+
const trimmed = input.trim();
|
|
1314
|
+
if (!trimmed) return { ok: false, error: 'Enter comment numbers such as 1,3-5.' };
|
|
1315
|
+
if (trimmed.toLowerCase() === 'all') return { ok: true, indices: Array.from({ length: max }, (_value, index) => index) };
|
|
1316
|
+
|
|
1317
|
+
const selected: number[] = [];
|
|
1318
|
+
const seen = new Set<number>();
|
|
1319
|
+
const addNumber = (value: number): string | undefined => {
|
|
1320
|
+
if (!Number.isInteger(value) || value <= 0) return `Invalid comment number: ${value}`;
|
|
1321
|
+
if (value > max) return `Comment number ${value} is outside the available range 1-${max}.`;
|
|
1322
|
+
const index = value - 1;
|
|
1323
|
+
if (!seen.has(index)) {
|
|
1324
|
+
seen.add(index);
|
|
1325
|
+
selected.push(index);
|
|
1326
|
+
}
|
|
1327
|
+
return undefined;
|
|
1328
|
+
};
|
|
1329
|
+
|
|
1330
|
+
for (const rawPart of trimmed.split(',')) {
|
|
1331
|
+
const part = rawPart.trim();
|
|
1332
|
+
if (!part) return { ok: false, error: 'Selection contains an empty item. Use a format like 1,3-5.' };
|
|
1333
|
+
const range = part.match(/^(\d+)\s*-\s*(\d+)$/);
|
|
1334
|
+
if (range) {
|
|
1335
|
+
const start = Number(range[1]);
|
|
1336
|
+
const end = Number(range[2]);
|
|
1337
|
+
if (start > end) return { ok: false, error: `Range ${part} goes backwards; use ${end}-${start} instead.` };
|
|
1338
|
+
for (let value = start; value <= end; value += 1) {
|
|
1339
|
+
const error = addNumber(value);
|
|
1340
|
+
if (error) return { ok: false, error };
|
|
1341
|
+
}
|
|
1342
|
+
continue;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
if (/^\d+$/.test(part)) {
|
|
1346
|
+
const error = addNumber(Number(part));
|
|
1347
|
+
if (error) return { ok: false, error };
|
|
1348
|
+
continue;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
return { ok: false, error: `Could not parse selection item: ${part}. Use numbers and ranges like 1,3-5.` };
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
return selected.length > 0 ? { ok: true, indices: selected } : { ok: false, error: 'Select at least one comment.' };
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
async function promptForCommentSubset(
|
|
1358
|
+
ctx: ExtensionCommandContext,
|
|
1359
|
+
comments: CommandComment[],
|
|
1360
|
+
selectionPrompt: string,
|
|
1361
|
+
): Promise<CommandComment[] | undefined> {
|
|
1362
|
+
const inputPrompt = `${selectionPrompt}\n\nEnter comment numbers to investigate.`;
|
|
1363
|
+
const inputHint = comments.length <= MAX_COMMENTS
|
|
1364
|
+
? `Examples: 1,3-5 or all (max ${MAX_COMMENTS})`
|
|
1365
|
+
: `Examples: 1,3-5 (choose up to ${MAX_COMMENTS})`;
|
|
1366
|
+
|
|
1367
|
+
while (true) {
|
|
1368
|
+
const input = await ctx.ui.input(inputPrompt, inputHint);
|
|
1369
|
+
if (input === undefined) return undefined;
|
|
1370
|
+
const parsed = parseSelectionList(input, comments.length);
|
|
1371
|
+
if (!parsed.ok) {
|
|
1372
|
+
notifyCommand(ctx, parsed.error, 'error');
|
|
1373
|
+
continue;
|
|
1374
|
+
}
|
|
1375
|
+
if (parsed.indices.length > MAX_COMMENTS) {
|
|
1376
|
+
notifyCommand(ctx, `triage_comments accepts at most ${MAX_COMMENTS} comments per run; choose a smaller subset.`, 'error');
|
|
1377
|
+
continue;
|
|
1378
|
+
}
|
|
1379
|
+
return parsed.indices.map((index) => comments[index]);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
async function choosePrComments(
|
|
1384
|
+
ctx: ExtensionCommandContext,
|
|
1385
|
+
pr: PullRequestContext,
|
|
1386
|
+
comments: CommandComment[],
|
|
1387
|
+
): Promise<CommandComment[] | undefined> {
|
|
1388
|
+
const options = comments.length <= MAX_COMMENTS
|
|
1389
|
+
? ['Investigate all displayed comments', 'Choose a subset', 'Cancel']
|
|
1390
|
+
: ['Choose a subset', 'Cancel'];
|
|
1391
|
+
const selectionPrompt = formatFetchedCommentsForSelection(pr, comments);
|
|
1392
|
+
const decision = await ctx.ui.select(selectionPrompt, options);
|
|
1393
|
+
if (!decision || decision === 'Cancel') return undefined;
|
|
1394
|
+
if (decision === 'Investigate all displayed comments') return comments;
|
|
1395
|
+
return promptForCommentSubset(ctx, comments, selectionPrompt);
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
function createPastedComment(body: string): CommandComment {
|
|
1399
|
+
return {
|
|
1400
|
+
id: 'pasted-feedback:1',
|
|
1401
|
+
body,
|
|
1402
|
+
metadata: {
|
|
1403
|
+
source: 'pasted_feedback',
|
|
1404
|
+
displayNumber: 1,
|
|
1405
|
+
capturedBy: '/triage-comments',
|
|
1406
|
+
},
|
|
1407
|
+
sourceLabel: 'pasted feedback',
|
|
1408
|
+
displayNumber: 1,
|
|
1409
|
+
sortIndex: 1,
|
|
1410
|
+
};
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
function toPayloadComment(comment: CommandComment): Record<string, unknown> {
|
|
1414
|
+
return compactRecord({
|
|
1415
|
+
id: comment.id,
|
|
1416
|
+
body: comment.body,
|
|
1417
|
+
path: comment.path,
|
|
1418
|
+
line: comment.line,
|
|
1419
|
+
startLine: comment.startLine,
|
|
1420
|
+
side: comment.side,
|
|
1421
|
+
diffHunk: comment.diffHunk,
|
|
1422
|
+
author: comment.author,
|
|
1423
|
+
url: comment.url,
|
|
1424
|
+
createdAt: comment.createdAt,
|
|
1425
|
+
metadata: comment.metadata,
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
function buildCommandPayload(
|
|
1430
|
+
comments: CommandComment[],
|
|
1431
|
+
options: { pr?: PullRequestContext; totalDisplayed: number; source: 'paste' | 'pr' },
|
|
1432
|
+
): TriageCommandPayload {
|
|
1433
|
+
const payload: TriageCommandPayload = {
|
|
1434
|
+
comments: comments.map(toPayloadComment),
|
|
1435
|
+
context:
|
|
1436
|
+
options.source === 'pr'
|
|
1437
|
+
? `Selected by /triage-comments after displaying ${options.totalDisplayed} fetched PR comment(s) and receiving explicit user selection. Do not implement changes until the user chooses a handling option.`
|
|
1438
|
+
: 'Pasted feedback captured by /triage-comments. Do not implement changes until the user chooses a handling option.',
|
|
1439
|
+
};
|
|
1440
|
+
|
|
1441
|
+
if (options.pr) {
|
|
1442
|
+
payload.pr = compactRecord({
|
|
1443
|
+
number: options.pr.number,
|
|
1444
|
+
title: options.pr.title,
|
|
1445
|
+
url: options.pr.url,
|
|
1446
|
+
repository: options.pr.repository,
|
|
1447
|
+
headRef: options.pr.headRef,
|
|
1448
|
+
headSha: options.pr.headSha,
|
|
1449
|
+
baseRef: options.pr.baseRef,
|
|
1450
|
+
baseSha: options.pr.baseSha,
|
|
1451
|
+
});
|
|
1452
|
+
const base = optionalRecord({ branch: options.pr.baseRef, sha: options.pr.baseSha });
|
|
1453
|
+
if (base) payload.base = base;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
return payload;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
function buildTriageUserPrompt(payload: TriageCommandPayload, selectedCount: number, totalDisplayed: number): string {
|
|
1460
|
+
const selectionNote = selectedCount === totalDisplayed ? `${selectedCount} selected item(s)` : `${selectedCount} selected item(s) out of ${totalDisplayed} displayed`;
|
|
1461
|
+
return `Task: start read-only review-feedback triage for ${selectionNote}.\n\nCall the triage_comments tool with exactly this JSON payload before doing any analysis:\n\n\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\`\n\nDo not edit files or implement changes as part of this request. After triage_comments returns, summarize its findings and ask which handling option to take before any implementation.`;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
function sendTriageUserMessage(
|
|
1465
|
+
pi: ExtensionAPI,
|
|
1466
|
+
ctx: ExtensionCommandContext,
|
|
1467
|
+
payload: TriageCommandPayload,
|
|
1468
|
+
selectedCount: number,
|
|
1469
|
+
totalDisplayed: number,
|
|
1470
|
+
): void {
|
|
1471
|
+
const prompt = buildTriageUserPrompt(payload, selectedCount, totalDisplayed);
|
|
1472
|
+
if (ctx.isIdle()) pi.sendUserMessage(prompt);
|
|
1473
|
+
else pi.sendUserMessage(prompt, { deliverAs: 'followUp' });
|
|
1474
|
+
notifyCommand(ctx, `Sent ${selectedCount} selected comment(s) to the main agent for triage_comments.`, 'info');
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
async function runPasteMode(pi: ExtensionAPI, ctx: ExtensionCommandContext, prefill?: string): Promise<void> {
|
|
1478
|
+
const feedback = await ctx.ui.editor('Paste feedback for /triage-comments', prefill ?? '');
|
|
1479
|
+
if (feedback === undefined) return;
|
|
1480
|
+
const body = feedback.trim();
|
|
1481
|
+
if (!body) {
|
|
1482
|
+
notifyCommand(ctx, 'No feedback was provided; /triage-comments paste mode was cancelled.', 'warning');
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
const comments = [createPastedComment(body)];
|
|
1487
|
+
const payload = buildCommandPayload(comments, { totalDisplayed: 1, source: 'paste' });
|
|
1488
|
+
sendTriageUserMessage(pi, ctx, payload, comments.length, 1);
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
async function runPrMode(pi: ExtensionAPI, ctx: ExtensionCommandContext, target?: string): Promise<void> {
|
|
1492
|
+
let prTarget = target?.trim();
|
|
1493
|
+
if (!prTarget) {
|
|
1494
|
+
const input = await ctx.ui.input('PR URL or number', 'For example: 123, #123, or https://github.com/owner/repo/pull/123');
|
|
1495
|
+
if (input === undefined) return;
|
|
1496
|
+
prTarget = input.trim();
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
if (!normalizePrTarget(prTarget)) {
|
|
1500
|
+
notifyCommand(ctx, 'PR input must be a PR number (for example, 123 or #123) or a GitHub pull request URL.', 'error');
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
ctx.ui.setStatus('triage-comments', 'triage-comments: fetching PR comments…');
|
|
1505
|
+
try {
|
|
1506
|
+
notifyCommand(ctx, 'Fetching PR comments with gh (read-only)…', 'info');
|
|
1507
|
+
const { pr, comments } = await fetchPrCommentsForTriage(pi, ctx, prTarget);
|
|
1508
|
+
if (comments.length === 0) {
|
|
1509
|
+
notifyCommand(ctx, `No review comments, issue comments, or review bodies were found for ${pr.repository}#${pr.number}.`, 'info');
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
const selected = await choosePrComments(ctx, pr, comments);
|
|
1514
|
+
if (!selected || selected.length === 0) return;
|
|
1515
|
+
const payload = buildCommandPayload(selected, { pr, totalDisplayed: comments.length, source: 'pr' });
|
|
1516
|
+
sendTriageUserMessage(pi, ctx, payload, selected.length, comments.length);
|
|
1517
|
+
} catch (error) {
|
|
1518
|
+
notifyCommand(ctx, formatErrorMessage(error), 'error');
|
|
1519
|
+
} finally {
|
|
1520
|
+
ctx.ui.setStatus('triage-comments', undefined);
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
export default function triageCommentsExtension(pi: ExtensionAPI) {
|
|
1525
|
+
pi.registerCommand("triage-comments", {
|
|
1526
|
+
description: "Collect pasted feedback or PR comments, then start a triage_comments investigation",
|
|
1527
|
+
getArgumentCompletions: (prefix) => {
|
|
1528
|
+
const commands = ["paste", "pr", "help"];
|
|
1529
|
+
const normalized = prefix.trim().toLowerCase();
|
|
1530
|
+
const matches = commands.filter((command) => command.startsWith(normalized));
|
|
1531
|
+
return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null;
|
|
1532
|
+
},
|
|
1533
|
+
handler: async (args, ctx) => {
|
|
1534
|
+
const parsed = parseTriageCommandArgs(args);
|
|
1535
|
+
if (parsed.help) {
|
|
1536
|
+
notifyCommand(ctx, TRIAGE_COMMAND_USAGE, "info");
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
if (parsed.error) {
|
|
1541
|
+
notifyCommand(ctx, parsed.error, "error");
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
if (!ctx.hasUI) {
|
|
1546
|
+
notifyCommand(
|
|
1547
|
+
ctx,
|
|
1548
|
+
`${TRIAGE_COMMAND_USAGE}\n\nThis intake flow requires The Last Harness interactive UI for the editor, PR comment display, and all/subset confirmation.`,
|
|
1549
|
+
"warning",
|
|
1550
|
+
);
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
let mode = parsed.mode;
|
|
1555
|
+
if (!mode) {
|
|
1556
|
+
const selection = await ctx.ui.select("/triage-comments: choose feedback source", [
|
|
1557
|
+
"Paste feedback",
|
|
1558
|
+
"Fetch comments from a PR",
|
|
1559
|
+
"Cancel",
|
|
1560
|
+
]);
|
|
1561
|
+
if (!selection || selection === "Cancel") return;
|
|
1562
|
+
mode = selection === "Paste feedback" ? "paste" : "pr";
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
if (mode === "paste") {
|
|
1566
|
+
await runPasteMode(pi, ctx, parsed.pastePrefill);
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
await runPrMode(pi, ctx, parsed.target);
|
|
1571
|
+
},
|
|
1572
|
+
});
|
|
1573
|
+
|
|
1574
|
+
pi.registerTool({
|
|
1575
|
+
name: "triage_comments",
|
|
1576
|
+
label: "Triage comments",
|
|
1577
|
+
description:
|
|
1578
|
+
"Read-only subagent that triages selected PR review comments against the local checkout and git context. It classifies each comment with evidence, suggests review responses, and proposes handling options without implementing changes.",
|
|
1579
|
+
promptSnippet:
|
|
1580
|
+
"Triage selected PR review comments in a read-only isolated subagent; returns verdicts, evidence, response text, and handling options without editing files.",
|
|
1581
|
+
promptGuidelines: [
|
|
1582
|
+
"Use triage_comments when selected PR review comments need evidence-based classification against the local checkout.",
|
|
1583
|
+
"Do not use triage_comments to implement changes; ask the user which valid handling option to take after triage.",
|
|
1584
|
+
],
|
|
1585
|
+
parameters: TriageCommentsParams,
|
|
1586
|
+
prepareArguments,
|
|
1587
|
+
|
|
1588
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
1589
|
+
if (!ctx.model) throw new Error("triage_comments needs an active model, but ctx.model is unavailable.");
|
|
1590
|
+
const input = normalizeInput(params);
|
|
1591
|
+
const cwd = path.resolve(ctx.cwd);
|
|
1592
|
+
const details: TriageDetails = {
|
|
1593
|
+
status: "running",
|
|
1594
|
+
cwd,
|
|
1595
|
+
commentCount: input.comments.length,
|
|
1596
|
+
turns: 0,
|
|
1597
|
+
toolCalls: [],
|
|
1598
|
+
startedAt: Date.now(),
|
|
1599
|
+
};
|
|
1600
|
+
|
|
1601
|
+
let lastContent = "(triaging comments...)";
|
|
1602
|
+
let session: AgentSession | undefined;
|
|
1603
|
+
let unsubscribe: (() => void) | undefined;
|
|
1604
|
+
let runTimeout: NodeJS.Timeout | undefined;
|
|
1605
|
+
let abortListenerAdded = false;
|
|
1606
|
+
let aborted = Boolean(signal?.aborted);
|
|
1607
|
+
|
|
1608
|
+
const emit = () => {
|
|
1609
|
+
onUpdate?.({ content: [{ type: "text", text: lastContent }], details });
|
|
1610
|
+
};
|
|
1611
|
+
|
|
1612
|
+
const abort = () => {
|
|
1613
|
+
aborted = true;
|
|
1614
|
+
details.status = "aborted";
|
|
1615
|
+
details.endedAt = Date.now();
|
|
1616
|
+
lastContent = "Aborted";
|
|
1617
|
+
emit();
|
|
1618
|
+
void session?.abort();
|
|
1619
|
+
};
|
|
1620
|
+
|
|
1621
|
+
if (signal?.aborted) abort();
|
|
1622
|
+
if (signal && !signal.aborted) {
|
|
1623
|
+
signal.addEventListener("abort", abort);
|
|
1624
|
+
abortListenerAdded = true;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
try {
|
|
1628
|
+
emit();
|
|
1629
|
+
|
|
1630
|
+
const systemPrompt = buildSystemPrompt({
|
|
1631
|
+
cwd,
|
|
1632
|
+
maxTurns: MAX_TURNS,
|
|
1633
|
+
maxRunSeconds: Math.round(MAX_RUN_MS / 1000),
|
|
1634
|
+
});
|
|
1635
|
+
|
|
1636
|
+
// Keep the guarded subagent from inheriting user/project shell prefix or shell path settings.
|
|
1637
|
+
const isolatedSettingsManager = SettingsManager.inMemory({});
|
|
1638
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
1639
|
+
cwd,
|
|
1640
|
+
agentDir: getAgentDir(),
|
|
1641
|
+
settingsManager: isolatedSettingsManager,
|
|
1642
|
+
noExtensions: true,
|
|
1643
|
+
noSkills: true,
|
|
1644
|
+
noPromptTemplates: true,
|
|
1645
|
+
noThemes: true,
|
|
1646
|
+
noContextFiles: true,
|
|
1647
|
+
extensionFactories: [createTriageRuntimeGuardExtension({ cwd, maxTurns: MAX_TURNS })],
|
|
1648
|
+
systemPromptOverride: () => systemPrompt,
|
|
1649
|
+
appendSystemPromptOverride: () => [],
|
|
1650
|
+
skillsOverride: () => ({ skills: [], diagnostics: [] }),
|
|
1651
|
+
promptsOverride: () => ({ prompts: [], diagnostics: [] }),
|
|
1652
|
+
themesOverride: () => ({ themes: [], diagnostics: [] }),
|
|
1653
|
+
agentsFilesOverride: () => ({ agentsFiles: [] }),
|
|
1654
|
+
});
|
|
1655
|
+
|
|
1656
|
+
await resourceLoader.reload();
|
|
1657
|
+
|
|
1658
|
+
const created = await createAgentSession({
|
|
1659
|
+
cwd,
|
|
1660
|
+
modelRegistry: ctx.modelRegistry,
|
|
1661
|
+
resourceLoader,
|
|
1662
|
+
settingsManager: isolatedSettingsManager,
|
|
1663
|
+
sessionManager: SessionManager.inMemory(cwd),
|
|
1664
|
+
model: ctx.model,
|
|
1665
|
+
thinkingLevel: pi.getThinkingLevel(),
|
|
1666
|
+
tools: ["read", "grep", "find", "ls", "bash"],
|
|
1667
|
+
});
|
|
1668
|
+
|
|
1669
|
+
session = created.session;
|
|
1670
|
+
unsubscribe = session.subscribe((event) => {
|
|
1671
|
+
switch (event.type) {
|
|
1672
|
+
case "turn_end":
|
|
1673
|
+
details.turns += 1;
|
|
1674
|
+
emit();
|
|
1675
|
+
break;
|
|
1676
|
+
case "tool_execution_start":
|
|
1677
|
+
details.toolCalls.push({
|
|
1678
|
+
id: event.toolCallId,
|
|
1679
|
+
name: event.toolName,
|
|
1680
|
+
args: event.args,
|
|
1681
|
+
startedAt: Date.now(),
|
|
1682
|
+
});
|
|
1683
|
+
if (details.toolCalls.length > MAX_TOOL_CALLS_TO_KEEP) {
|
|
1684
|
+
details.toolCalls.splice(0, details.toolCalls.length - MAX_TOOL_CALLS_TO_KEEP);
|
|
1685
|
+
}
|
|
1686
|
+
emit();
|
|
1687
|
+
break;
|
|
1688
|
+
case "tool_execution_end": {
|
|
1689
|
+
const call = details.toolCalls.find((item) => item.id === event.toolCallId);
|
|
1690
|
+
if (call) {
|
|
1691
|
+
call.endedAt = Date.now();
|
|
1692
|
+
call.isError = event.isError;
|
|
1693
|
+
}
|
|
1694
|
+
emit();
|
|
1695
|
+
break;
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
});
|
|
1699
|
+
|
|
1700
|
+
if (!aborted) {
|
|
1701
|
+
const promptPromise = session.prompt(buildUserPrompt(input, cwd), { expandPromptTemplates: false });
|
|
1702
|
+
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
|
1703
|
+
runTimeout = setTimeout(() => {
|
|
1704
|
+
abort();
|
|
1705
|
+
reject(new Error(`triage_comments timed out after ${Math.round(MAX_RUN_MS / 1000)} seconds.`));
|
|
1706
|
+
}, MAX_RUN_MS);
|
|
1707
|
+
});
|
|
1708
|
+
await Promise.race([promptPromise, timeoutPromise]);
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
const answer = session ? extractLastAssistantText(session.state.messages) : "";
|
|
1712
|
+
lastContent = answer ? ensureImplementationNote(answer) : aborted ? "Aborted" : "(no output)";
|
|
1713
|
+
details.status = aborted ? "aborted" : "done";
|
|
1714
|
+
details.endedAt = Date.now();
|
|
1715
|
+
emit();
|
|
1716
|
+
|
|
1717
|
+
return {
|
|
1718
|
+
content: [{ type: "text", text: lastContent }],
|
|
1719
|
+
details,
|
|
1720
|
+
};
|
|
1721
|
+
} catch (error) {
|
|
1722
|
+
const wasAbort = aborted || isAbortLikeError(error);
|
|
1723
|
+
const message = wasAbort ? "Aborted" : error instanceof Error ? error.message : String(error);
|
|
1724
|
+
details.status = wasAbort ? "aborted" : "error";
|
|
1725
|
+
details.error = wasAbort ? undefined : message;
|
|
1726
|
+
details.endedAt = Date.now();
|
|
1727
|
+
lastContent = message;
|
|
1728
|
+
emit();
|
|
1729
|
+
|
|
1730
|
+
return {
|
|
1731
|
+
content: [{ type: "text", text: message }],
|
|
1732
|
+
details,
|
|
1733
|
+
};
|
|
1734
|
+
} finally {
|
|
1735
|
+
if (runTimeout) clearTimeout(runTimeout);
|
|
1736
|
+
if (signal && abortListenerAdded) signal.removeEventListener("abort", abort);
|
|
1737
|
+
unsubscribe?.();
|
|
1738
|
+
session?.dispose();
|
|
1739
|
+
}
|
|
1740
|
+
},
|
|
1741
|
+
|
|
1742
|
+
renderCall(args, theme) {
|
|
1743
|
+
const comments = Array.isArray((args as { comments?: unknown })?.comments) ? (args as { comments: unknown[] }).comments : [];
|
|
1744
|
+
const first = comments[0];
|
|
1745
|
+
const body = typeof first === "string" ? first : asTrimmedString(asRecord(first)?.body) ?? "";
|
|
1746
|
+
const pr = asRecord((args as { pr?: unknown })?.pr);
|
|
1747
|
+
const prLabel = pr?.number ? `PR #${pr.number}` : pr?.url ? "PR context" : "no PR context";
|
|
1748
|
+
return new Text(
|
|
1749
|
+
`${theme.fg("muted", `${comments.length} comments • ${prLabel}`)} · ${theme.fg("toolOutput", shorten(body, 90))}`,
|
|
1750
|
+
0,
|
|
1751
|
+
0,
|
|
1752
|
+
);
|
|
1753
|
+
},
|
|
1754
|
+
|
|
1755
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
1756
|
+
const details = result.details as TriageDetails | undefined;
|
|
1757
|
+
if (!details) {
|
|
1758
|
+
const first = result.content[0];
|
|
1759
|
+
return new Text(first?.type === "text" ? first.text : "(no output)", 0, 0);
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
const status = isPartial ? "running" : details.status;
|
|
1763
|
+
const icon =
|
|
1764
|
+
status === "done"
|
|
1765
|
+
? theme.fg("success", "✓")
|
|
1766
|
+
: status === "error"
|
|
1767
|
+
? theme.fg("error", "✗")
|
|
1768
|
+
: status === "aborted"
|
|
1769
|
+
? theme.fg("warning", "◼")
|
|
1770
|
+
: theme.fg("warning", "⏳");
|
|
1771
|
+
const header = `${icon} ${theme.fg("toolTitle", theme.bold("triage_comments "))}${theme.fg(
|
|
1772
|
+
"dim",
|
|
1773
|
+
`${details.commentCount} comments • ${details.turns} turns • ${details.toolCalls.length} tools`,
|
|
1774
|
+
)}`;
|
|
1775
|
+
const cwdLine = `${theme.fg("muted", "checkout: ")}${theme.fg("toolOutput", details.cwd)}`;
|
|
1776
|
+
const answer =
|
|
1777
|
+
(result.content[0]?.type === "text" ? result.content[0].text : renderAnswer(details)).trim() || "(no output)";
|
|
1778
|
+
|
|
1779
|
+
const toolLines = details.toolCalls.slice(expanded ? 0 : -6).map((call) => {
|
|
1780
|
+
const callIcon = call.isError ? theme.fg("error", "✗") : theme.fg("dim", "→");
|
|
1781
|
+
return `${callIcon} ${theme.fg("toolOutput", formatToolCall(call))}`;
|
|
1782
|
+
});
|
|
1783
|
+
if (!expanded && details.toolCalls.length > 6) toolLines.unshift(theme.fg("muted", "…"));
|
|
1784
|
+
|
|
1785
|
+
if (status === "running") {
|
|
1786
|
+
const parts = [header, cwdLine];
|
|
1787
|
+
if (toolLines.length) parts.push("", theme.fg("muted", "Read-only checks:"), ...toolLines);
|
|
1788
|
+
parts.push("", theme.fg("muted", "Triaging comments…"));
|
|
1789
|
+
return new Text(parts.join("\n"), 0, 0);
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
if (!expanded) {
|
|
1793
|
+
const lines = answer.split("\n");
|
|
1794
|
+
const previewLines = lines.slice(0, COLLAPSED_PREVIEW_LINES);
|
|
1795
|
+
const parts = [header, cwdLine, "", theme.fg("toolOutput", previewLines.join("\n"))];
|
|
1796
|
+
if (lines.length > previewLines.length) parts.push(theme.fg("muted", "(Ctrl+O to expand)"));
|
|
1797
|
+
if (toolLines.length) parts.push("", theme.fg("muted", "Read-only checks:"), ...toolLines);
|
|
1798
|
+
return new Text(parts.join("\n"), 0, 0);
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
const container = new Container();
|
|
1802
|
+
container.addChild(new Text(header, 0, 0));
|
|
1803
|
+
container.addChild(new Text(cwdLine, 0, 0));
|
|
1804
|
+
if (toolLines.length) {
|
|
1805
|
+
container.addChild(new Spacer(1));
|
|
1806
|
+
container.addChild(new Text([theme.fg("muted", "Read-only checks:"), ...toolLines].join("\n"), 0, 0));
|
|
1807
|
+
}
|
|
1808
|
+
container.addChild(new Spacer(1));
|
|
1809
|
+
container.addChild(new Markdown(answer, 0, 0, getMarkdownTheme()));
|
|
1810
|
+
return container;
|
|
1811
|
+
},
|
|
1812
|
+
});
|
|
1813
|
+
}
|