@diegopetrucci/pi-extensions 0.1.74 → 0.1.76

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.
Files changed (93) hide show
  1. package/LICENSE +2 -0
  2. package/README.md +9 -4
  3. package/extensions/agent-workflow-audit/index.ts +1398 -1009
  4. package/extensions/agent-workflow-audit/package.json +1 -1
  5. package/extensions/annotate-git-diff/README.md +3 -1
  6. package/extensions/annotate-git-diff/clipboard.ts +109 -100
  7. package/extensions/annotate-git-diff/git.ts +853 -767
  8. package/extensions/annotate-git-diff/glimpseui.d.ts +87 -78
  9. package/extensions/annotate-git-diff/index.ts +712 -671
  10. package/extensions/annotate-git-diff/package.json +1 -1
  11. package/extensions/annotate-git-diff/prompt.ts +45 -45
  12. package/extensions/annotate-git-diff/quiet-glimpse.ts +161 -155
  13. package/extensions/annotate-git-diff/review-server.ts +337 -311
  14. package/extensions/annotate-git-diff/types.ts +132 -132
  15. package/extensions/annotate-git-diff/ui.ts +27 -27
  16. package/extensions/annotate-git-diff/watch.ts +74 -74
  17. package/extensions/annotate-git-diff/web/app.js +1947 -1884
  18. package/extensions/annotate-git-diff/web/index.html +5 -6
  19. package/extensions/annotate-last-message/README.md +1 -1
  20. package/extensions/annotate-last-message/glimpseui.d.ts +87 -78
  21. package/extensions/annotate-last-message/index.ts +210 -206
  22. package/extensions/annotate-last-message/package.json +1 -1
  23. package/extensions/annotate-last-message/prompt.ts +87 -66
  24. package/extensions/annotate-last-message/quiet-glimpse.ts +161 -155
  25. package/extensions/annotate-last-message/session.ts +94 -89
  26. package/extensions/annotate-last-message/theme.ts +199 -193
  27. package/extensions/annotate-last-message/types.ts +23 -23
  28. package/extensions/annotate-last-message/ui.ts +27 -25
  29. package/extensions/annotate-last-message/web/app.js +4 -7
  30. package/extensions/annotate-last-message/web/md-renderer.js +1 -1
  31. package/extensions/brrr/README.md +1 -1
  32. package/extensions/brrr/index.ts +213 -191
  33. package/extensions/brrr/package.json +1 -1
  34. package/extensions/claude-fast/README.md +5 -79
  35. package/extensions/claude-fast/index.ts +31 -289
  36. package/extensions/claude-fast/package.json +2 -3
  37. package/extensions/code-reviewer/README.md +1 -1
  38. package/extensions/code-reviewer/index.ts +1598 -1350
  39. package/extensions/code-reviewer/package.json +1 -1
  40. package/extensions/confirm-destructive/README.md +1 -1
  41. package/extensions/confirm-destructive/index.ts +55 -51
  42. package/extensions/confirm-destructive/package.json +1 -1
  43. package/extensions/context-cap/index.ts +312 -304
  44. package/extensions/context-cap/package.json +1 -1
  45. package/extensions/context-inspector/index.ts +1211 -1064
  46. package/extensions/context-inspector/package.json +1 -1
  47. package/extensions/contrarian/index.ts +1854 -1735
  48. package/extensions/contrarian/package.json +1 -1
  49. package/extensions/dirty-repo-guard/index.ts +41 -41
  50. package/extensions/dynamic-context-pruning/README.md +6 -38
  51. package/extensions/dynamic-context-pruning/index.ts +2124 -1980
  52. package/extensions/dynamic-context-pruning/package.json +1 -1
  53. package/extensions/fast/README.md +50 -12
  54. package/extensions/fast/index.ts +513 -317
  55. package/extensions/fast/package.json +1 -1
  56. package/extensions/git-footer/README.md +1 -1
  57. package/extensions/git-footer/index.ts +458 -411
  58. package/extensions/git-footer/package.json +2 -3
  59. package/extensions/gnosis/index.ts +180 -130
  60. package/extensions/gnosis/package.json +1 -1
  61. package/extensions/inline-bash/index.ts +88 -88
  62. package/extensions/inline-bash/package.json +1 -1
  63. package/extensions/librarian/index.ts +1669 -1419
  64. package/extensions/librarian/package.json +1 -1
  65. package/extensions/minimal-footer/index.ts +994 -1012
  66. package/extensions/minimal-footer/openai-usage.ts +195 -190
  67. package/extensions/notify/index.ts +222 -230
  68. package/extensions/notify/package.json +1 -1
  69. package/extensions/openai-fast/README.md +5 -73
  70. package/extensions/openai-fast/index.ts +32 -272
  71. package/extensions/openai-fast/package.json +2 -3
  72. package/extensions/oracle/index.ts +1731 -1625
  73. package/extensions/oracle/package.json +1 -1
  74. package/extensions/permission-gate/index.ts +658 -608
  75. package/extensions/permission-gate/package.json +1 -1
  76. package/extensions/permission-gate/powershell-safety.ts +840 -793
  77. package/extensions/project-mcp-json/.pi-fleet-tested-version +1 -0
  78. package/extensions/project-mcp-json/README.md +199 -0
  79. package/extensions/project-mcp-json/config.ts +564 -0
  80. package/extensions/project-mcp-json/index.ts +119 -0
  81. package/extensions/project-mcp-json/package.json +38 -0
  82. package/extensions/quiet-tools/index.ts +282 -264
  83. package/extensions/review/README.md +2 -2
  84. package/extensions/review/index.ts +1745 -1872
  85. package/extensions/review/package.json +1 -1
  86. package/extensions/todo/index.ts +284 -265
  87. package/extensions/triage-comments/README.md +2 -2
  88. package/extensions/triage-comments/index.ts +2294 -1882
  89. package/extensions/triage-comments/package.json +1 -1
  90. package/package.json +6 -3
  91. package/assets/social-preview.png +0 -0
  92. package/extensions/claude-fast/claude-fast.example.json +0 -4
  93. package/extensions/openai-fast/openai-fast.example.json +0 -4
@@ -1,15 +1,21 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
 
4
- import type { AgentSession, ExecResult, ExtensionAPI, ExtensionCommandContext, ExtensionFactory } from "@earendil-works/pi-coding-agent";
4
+ import type {
5
+ AgentSession,
6
+ ExecResult,
7
+ ExtensionAPI,
8
+ ExtensionCommandContext,
9
+ ExtensionFactory,
10
+ } from "@earendil-works/pi-coding-agent";
5
11
  import type { Usage } from "@earendil-works/pi-ai";
6
12
  import {
7
- DefaultResourceLoader,
8
- SessionManager,
9
- SettingsManager,
10
- createAgentSession,
11
- getAgentDir,
12
- getMarkdownTheme,
13
+ DefaultResourceLoader,
14
+ SessionManager,
15
+ SettingsManager,
16
+ createAgentSession,
17
+ getAgentDir,
18
+ getMarkdownTheme,
13
19
  } from "@earendil-works/pi-coding-agent";
14
20
  import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
15
21
  import { Type } from "typebox";
@@ -21,7 +27,7 @@ const MAX_RUN_MS = 8 * 60 * 1000;
21
27
  const DEFAULT_BASH_TIMEOUT_SECONDS = 30;
22
28
  const COLLAPSED_PREVIEW_LINES = 18;
23
29
  const GH_COMMAND_TIMEOUT_MS = 30_000;
24
- const GH_PR_METADATA_FIELDS = 'number,title,url,headRefName,headRefOid,baseRefName,baseRefOid';
30
+ const GH_PR_METADATA_FIELDS = "number,title,url,headRefName,headRefOid,baseRefName,baseRefOid";
25
31
  const GH_REVIEW_THREADS_GRAPHQL_QUERY = `
26
32
  query TriageCommentsReviewThreads($owner: String!, $name: String!, $number: Int!, $after: String) {
27
33
  repository(owner: $owner, name: $name) {
@@ -48,2225 +54,2631 @@ query TriageCommentsReviewThreads($owner: String!, $name: String!, $number: Int!
48
54
  }`;
49
55
  const COMMENT_DISPLAY_BODY_LIMIT = 1200;
50
56
  const TRIAGE_COMMAND_USAGE =
51
- "Usage: /triage-comments [paste | pr [<PR URL or number>] | <PR URL or number>]\nInteractive UI mode lets you paste feedback or fetch PR comments, optionally hide resolved/outdated inline review comments, then confirm all displayed comments or choose a subset such as 1,3-5. PR mode without a target first tries to detect an open PR for the current non-main branch.";
57
+ "Usage: /triage-comments [paste | pr [<PR URL or number>] | <PR URL or number>]\nInteractive UI mode lets you paste feedback or fetch PR comments, optionally hide resolved/outdated inline review comments, then confirm all displayed comments or choose a subset such as 1,3-5. PR mode without a target first tries to detect an open PR for the current non-main branch.";
52
58
  const IMPLEMENTATION_NOTE =
53
- "Do not implement changes from this triage automatically; ask the parent/user which option to take before implementation.";
59
+ "Do not implement changes from this triage automatically; ask the parent/user which option to take before implementation.";
54
60
 
55
61
  const READ_ONLY_TOOL_NAMES = new Set(["read", "grep", "find", "ls", "bash"]);
56
62
  const READ_ONLY_BASH_COMMANDS = new Set(["gh", "git", "pwd"]);
57
63
  const READ_ONLY_GIT_SUBCOMMANDS = new Set([
58
- "blame",
59
- "cat-file",
60
- "describe",
61
- "diff",
62
- "for-each-ref",
63
- "log",
64
- "ls-files",
65
- "ls-tree",
66
- "merge-base",
67
- "name-rev",
68
- "remote",
69
- "rev-parse",
70
- "shortlog",
71
- "show",
72
- "show-ref",
73
- "status",
74
- "whatchanged",
64
+ "blame",
65
+ "cat-file",
66
+ "describe",
67
+ "diff",
68
+ "for-each-ref",
69
+ "log",
70
+ "ls-files",
71
+ "ls-tree",
72
+ "merge-base",
73
+ "name-rev",
74
+ "remote",
75
+ "rev-parse",
76
+ "shortlog",
77
+ "show",
78
+ "show-ref",
79
+ "status",
80
+ "whatchanged",
75
81
  ]);
76
82
  const SAFE_GIT_BRANCH_FLAGS = new Set([
77
- "-a",
78
- "--all",
79
- "-r",
80
- "--remotes",
81
- "-v",
82
- "-vv",
83
- "--show-current",
84
- "--list",
85
- "--contains",
86
- "--merged",
87
- "--no-merged",
83
+ "-a",
84
+ "--all",
85
+ "-r",
86
+ "--remotes",
87
+ "-v",
88
+ "-vv",
89
+ "--show-current",
90
+ "--list",
91
+ "--contains",
92
+ "--merged",
93
+ "--no-merged",
88
94
  ]);
89
95
  const SAFE_GIT_GLOBAL_FLAGS = new Set(["--no-pager", "--no-optional-locks"]);
90
- const GH_GLOBAL_OPTIONS_WITH_VALUE = new Set(["--repo", "-R", "--hostname", "--jq", "-q", "--template"]);
96
+ const GH_GLOBAL_OPTIONS_WITH_VALUE = new Set([
97
+ "--repo",
98
+ "-R",
99
+ "--hostname",
100
+ "--jq",
101
+ "-q",
102
+ "--template",
103
+ ]);
91
104
  const MUTATING_GH_API_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
92
105
 
93
106
  type TriageStatus = "running" | "done" | "error" | "aborted";
94
107
 
95
108
  type CreateAgentSessionOptions = NonNullable<Parameters<typeof createAgentSession>[0]>;
96
109
 
97
- function getModelRuntimeOption(ctx: { modelRegistry?: unknown }): Pick<CreateAgentSessionOptions, "modelRuntime"> {
98
- const modelRuntime = (ctx.modelRegistry as { runtime?: CreateAgentSessionOptions["modelRuntime"] } | undefined)?.runtime;
99
- return modelRuntime ? { modelRuntime } : {};
110
+ function getModelRuntimeOption(ctx: {
111
+ modelRegistry?: unknown;
112
+ }): Pick<CreateAgentSessionOptions, "modelRuntime"> {
113
+ const modelRuntime = (
114
+ ctx.modelRegistry as { runtime?: CreateAgentSessionOptions["modelRuntime"] } | undefined
115
+ )?.runtime;
116
+ return modelRuntime ? { modelRuntime } : {};
100
117
  }
101
118
 
102
119
  type ToolCall = {
103
- id: string;
104
- name: string;
105
- args: unknown;
106
- startedAt: number;
107
- endedAt?: number;
108
- isError?: boolean;
120
+ id: string;
121
+ name: string;
122
+ args: unknown;
123
+ startedAt: number;
124
+ endedAt?: number;
125
+ isError?: boolean;
109
126
  };
110
127
 
111
128
  type TriageDetails = {
112
- status: TriageStatus;
113
- cwd: string;
114
- commentCount: number;
115
- turns: number;
116
- toolCalls: ToolCall[];
117
- startedAt: number;
118
- endedAt?: number;
119
- error?: string;
129
+ status: TriageStatus;
130
+ cwd: string;
131
+ commentCount: number;
132
+ turns: number;
133
+ toolCalls: ToolCall[];
134
+ startedAt: number;
135
+ endedAt?: number;
136
+ error?: string;
120
137
  };
121
138
 
122
139
  type NormalizedComment = {
123
- index: number;
124
- id?: string;
125
- body: string;
126
- path?: string;
127
- line?: number;
128
- startLine?: number;
129
- side?: string;
130
- diffHunk?: string;
131
- author?: string;
132
- url?: string;
133
- createdAt?: string;
134
- context?: string;
135
- metadata?: Record<string, unknown>;
140
+ index: number;
141
+ id?: string;
142
+ body: string;
143
+ path?: string;
144
+ line?: number;
145
+ startLine?: number;
146
+ side?: string;
147
+ diffHunk?: string;
148
+ author?: string;
149
+ url?: string;
150
+ createdAt?: string;
151
+ context?: string;
152
+ metadata?: Record<string, unknown>;
136
153
  };
137
154
 
138
155
  type NormalizedInput = {
139
- comments: NormalizedComment[];
140
- pr?: Record<string, unknown>;
141
- base?: Record<string, unknown>;
142
- diff?: string;
143
- context?: string;
156
+ comments: NormalizedComment[];
157
+ pr?: Record<string, unknown>;
158
+ base?: Record<string, unknown>;
159
+ diff?: string;
160
+ context?: string;
144
161
  };
145
162
 
146
163
  const CommentSchema = Type.Object({
147
- id: Type.Optional(Type.String({ description: "Stable comment identifier from GitHub or the caller." })),
148
- body: Type.String({ description: "The review comment text to triage." }),
149
- path: Type.Optional(Type.String({ description: "Repository-relative file path the comment refers to, when known." })),
150
- line: Type.Optional(Type.Number({ description: "1-indexed line number the comment refers to, when known." })),
151
- startLine: Type.Optional(Type.Number({ description: "Start line for a multi-line comment, when known." })),
152
- side: Type.Optional(Type.String({ description: "Diff side or thread side, for example RIGHT, LEFT, base, or head." })),
153
- diffHunk: Type.Optional(Type.String({ description: "GitHub diff hunk attached to the comment, when available." })),
154
- author: Type.Optional(Type.String({ description: "Review comment author, when available." })),
155
- url: Type.Optional(Type.String({ description: "Permalink to the review comment, when available." })),
156
- createdAt: Type.Optional(Type.String({ description: "Comment creation timestamp, when available." })),
157
- context: Type.Optional(Type.String({ description: "Any extra per-comment context supplied by the caller." })),
158
- metadata: Type.Optional(
159
- Type.Record(Type.String(), Type.Unknown({ description: "Structured per-comment metadata from the caller." })),
160
- ),
164
+ id: Type.Optional(
165
+ Type.String({ description: "Stable comment identifier from GitHub or the caller." }),
166
+ ),
167
+ body: Type.String({ description: "The review comment text to triage." }),
168
+ path: Type.Optional(
169
+ Type.String({
170
+ description: "Repository-relative file path the comment refers to, when known.",
171
+ }),
172
+ ),
173
+ line: Type.Optional(
174
+ Type.Number({ description: "1-indexed line number the comment refers to, when known." }),
175
+ ),
176
+ startLine: Type.Optional(
177
+ Type.Number({ description: "Start line for a multi-line comment, when known." }),
178
+ ),
179
+ side: Type.Optional(
180
+ Type.String({
181
+ description: "Diff side or thread side, for example RIGHT, LEFT, base, or head.",
182
+ }),
183
+ ),
184
+ diffHunk: Type.Optional(
185
+ Type.String({ description: "GitHub diff hunk attached to the comment, when available." }),
186
+ ),
187
+ author: Type.Optional(Type.String({ description: "Review comment author, when available." })),
188
+ url: Type.Optional(
189
+ Type.String({ description: "Permalink to the review comment, when available." }),
190
+ ),
191
+ createdAt: Type.Optional(
192
+ Type.String({ description: "Comment creation timestamp, when available." }),
193
+ ),
194
+ context: Type.Optional(
195
+ Type.String({ description: "Any extra per-comment context supplied by the caller." }),
196
+ ),
197
+ metadata: Type.Optional(
198
+ Type.Record(
199
+ Type.String(),
200
+ Type.Unknown({ description: "Structured per-comment metadata from the caller." }),
201
+ ),
202
+ ),
161
203
  });
162
204
 
163
205
  const TriageCommentsParams = Type.Object({
164
- comments: Type.Array(Type.Union([Type.String(), CommentSchema]), {
165
- 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.",
166
- minItems: 1,
167
- maxItems: MAX_COMMENTS,
168
- }),
169
- pr: Type.Optional(
170
- Type.Object({
171
- number: Type.Optional(Type.Number({ description: "Pull request number." })),
172
- title: Type.Optional(Type.String({ description: "Pull request title." })),
173
- url: Type.Optional(Type.String({ description: "Pull request URL." })),
174
- repository: Type.Optional(Type.String({ description: "Repository in owner/name form, when known." })),
175
- headRef: Type.Optional(Type.String({ description: "PR head ref name, when known." })),
176
- headSha: Type.Optional(Type.String({ description: "PR head SHA, when known." })),
177
- baseRef: Type.Optional(Type.String({ description: "PR base ref name, when known." })),
178
- baseSha: Type.Optional(Type.String({ description: "PR base SHA, when known." })),
179
- }),
180
- ),
181
- base: Type.Optional(
182
- Type.Object({
183
- branch: Type.Optional(Type.String({ description: "Base branch name, when known." })),
184
- sha: Type.Optional(Type.String({ description: "Base SHA, when known." })),
185
- mergeBase: Type.Optional(Type.String({ description: "Merge-base SHA, when known." })),
186
- }),
187
- ),
188
- diff: Type.Optional(
189
- Type.String({
190
- description: "Optional PR diff, selected hunks, or command output that helps locate comments. The triage agent still verifies against the local checkout when possible.",
191
- maxLength: 200000,
192
- }),
193
- ),
194
- context: Type.Optional(
195
- Type.String({
196
- description: "Optional caller notes, constraints, or already-collected read-only context for this triage run.",
197
- maxLength: 50000,
198
- }),
199
- ),
206
+ comments: Type.Array(Type.Union([Type.String(), CommentSchema]), {
207
+ description:
208
+ "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.",
209
+ minItems: 1,
210
+ maxItems: MAX_COMMENTS,
211
+ }),
212
+ pr: Type.Optional(
213
+ Type.Object({
214
+ number: Type.Optional(Type.Number({ description: "Pull request number." })),
215
+ title: Type.Optional(Type.String({ description: "Pull request title." })),
216
+ url: Type.Optional(Type.String({ description: "Pull request URL." })),
217
+ repository: Type.Optional(
218
+ Type.String({ description: "Repository in owner/name form, when known." }),
219
+ ),
220
+ headRef: Type.Optional(Type.String({ description: "PR head ref name, when known." })),
221
+ headSha: Type.Optional(Type.String({ description: "PR head SHA, when known." })),
222
+ baseRef: Type.Optional(Type.String({ description: "PR base ref name, when known." })),
223
+ baseSha: Type.Optional(Type.String({ description: "PR base SHA, when known." })),
224
+ }),
225
+ ),
226
+ base: Type.Optional(
227
+ Type.Object({
228
+ branch: Type.Optional(Type.String({ description: "Base branch name, when known." })),
229
+ sha: Type.Optional(Type.String({ description: "Base SHA, when known." })),
230
+ mergeBase: Type.Optional(Type.String({ description: "Merge-base SHA, when known." })),
231
+ }),
232
+ ),
233
+ diff: Type.Optional(
234
+ Type.String({
235
+ description:
236
+ "Optional PR diff, selected hunks, or command output that helps locate comments. The triage agent still verifies against the local checkout when possible.",
237
+ maxLength: 200000,
238
+ }),
239
+ ),
240
+ context: Type.Optional(
241
+ Type.String({
242
+ description:
243
+ "Optional caller notes, constraints, or already-collected read-only context for this triage run.",
244
+ maxLength: 50000,
245
+ }),
246
+ ),
200
247
  });
201
248
 
202
249
  function asRecord(value: unknown): Record<string, unknown> | undefined {
203
- return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
250
+ return value && typeof value === "object" && !Array.isArray(value)
251
+ ? (value as Record<string, unknown>)
252
+ : undefined;
204
253
  }
205
254
 
206
255
  function asTrimmedString(value: unknown): string | undefined {
207
- if (typeof value !== "string") return undefined;
208
- const trimmed = value.trim();
209
- return trimmed || undefined;
256
+ if (typeof value !== "string") return undefined;
257
+ const trimmed = value.trim();
258
+ return trimmed || undefined;
210
259
  }
211
260
 
212
261
  function asFiniteNumber(value: unknown): number | undefined {
213
- if (typeof value === "number" && Number.isFinite(value)) return value;
214
- if (typeof value === "string" && value.trim()) {
215
- const parsed = Number(value);
216
- if (Number.isFinite(parsed)) return parsed;
217
- }
218
- return undefined;
262
+ if (typeof value === "number" && Number.isFinite(value)) return value;
263
+ if (typeof value === "string" && value.trim()) {
264
+ const parsed = Number(value);
265
+ if (Number.isFinite(parsed)) return parsed;
266
+ }
267
+ return undefined;
219
268
  }
220
269
 
221
270
  function asBoolean(value: unknown): boolean | undefined {
222
- return typeof value === "boolean" ? value : undefined;
271
+ return typeof value === "boolean" ? value : undefined;
223
272
  }
224
273
 
225
274
  function normalizeComment(raw: unknown, index: number): NormalizedComment | undefined {
226
- if (typeof raw === "string") {
227
- const body = raw.trim();
228
- return body ? { index, body } : undefined;
229
- }
230
-
231
- const record = asRecord(raw);
232
- if (!record) return undefined;
233
- const body = asTrimmedString(record.body ?? record.text ?? record.comment);
234
- if (!body) return undefined;
235
-
236
- return {
237
- index,
238
- id: asStableIdPart(record.id, record.databaseId, record.nodeId),
239
- body,
240
- path: asTrimmedString(record.path ?? record.file ?? record.filePath),
241
- line: asFiniteNumber(record.line ?? record.position),
242
- startLine: asFiniteNumber(record.startLine ?? record.start_line),
243
- side: asTrimmedString(record.side),
244
- diffHunk: asTrimmedString(record.diffHunk ?? record.diff_hunk ?? record.hunk),
245
- author: githubLogin(record.author) ?? githubLogin(record.user) ?? asTrimmedString(record.login),
246
- url: asTrimmedString(record.url ?? record.htmlUrl ?? record.html_url),
247
- createdAt: asTrimmedString(record.createdAt ?? record.created_at),
248
- context: asTrimmedString(record.context ?? record.extraContext),
249
- metadata: asRecord(record.metadata ?? record.meta),
250
- };
275
+ if (typeof raw === "string") {
276
+ const body = raw.trim();
277
+ return body ? { index, body } : undefined;
278
+ }
279
+
280
+ const record = asRecord(raw);
281
+ if (!record) return undefined;
282
+ const body = asTrimmedString(record.body ?? record.text ?? record.comment);
283
+ if (!body) return undefined;
284
+
285
+ return {
286
+ index,
287
+ id: asStableIdPart(record.id, record.databaseId, record.nodeId),
288
+ body,
289
+ path: asTrimmedString(record.path ?? record.file ?? record.filePath),
290
+ line: asFiniteNumber(record.line ?? record.position),
291
+ startLine: asFiniteNumber(record.startLine ?? record.start_line),
292
+ side: asTrimmedString(record.side),
293
+ diffHunk: asTrimmedString(record.diffHunk ?? record.diff_hunk ?? record.hunk),
294
+ author: githubLogin(record.author) ?? githubLogin(record.user) ?? asTrimmedString(record.login),
295
+ url: asTrimmedString(record.url ?? record.htmlUrl ?? record.html_url),
296
+ createdAt: asTrimmedString(record.createdAt ?? record.created_at),
297
+ context: asTrimmedString(record.context ?? record.extraContext),
298
+ metadata: asRecord(record.metadata ?? record.meta),
299
+ };
251
300
  }
252
301
 
253
302
  function normalizeInput(params: unknown): NormalizedInput {
254
- const record = asRecord(params) ?? {};
255
- const rawComments = Array.isArray(record.comments) ? record.comments : [];
256
- const comments = rawComments
257
- .map((comment, index) => normalizeComment(comment, index + 1))
258
- .filter((comment): comment is NormalizedComment => Boolean(comment));
259
-
260
- if (comments.length === 0) {
261
- throw new Error("Invalid parameters: expected comments to include at least one non-empty comment body.");
262
- }
303
+ const record = asRecord(params) ?? {};
304
+ const rawComments = Array.isArray(record.comments) ? record.comments : [];
305
+ const comments = rawComments
306
+ .map((comment, index) => normalizeComment(comment, index + 1))
307
+ .filter((comment): comment is NormalizedComment => Boolean(comment));
308
+
309
+ if (comments.length === 0) {
310
+ throw new Error(
311
+ "Invalid parameters: expected comments to include at least one non-empty comment body.",
312
+ );
313
+ }
263
314
 
264
- return {
265
- comments,
266
- pr: asRecord(record.pr),
267
- base: asRecord(record.base),
268
- diff: asTrimmedString(record.diff ?? record.diffContext),
269
- context: asTrimmedString(record.context ?? record.extraContext),
270
- };
315
+ return {
316
+ comments,
317
+ pr: asRecord(record.pr),
318
+ base: asRecord(record.base),
319
+ diff: asTrimmedString(record.diff ?? record.diffContext),
320
+ context: asTrimmedString(record.context ?? record.extraContext),
321
+ };
271
322
  }
272
323
 
273
324
  function prepareArguments(args: unknown): any {
274
- const record = asRecord(args);
275
- if (!record) return args;
276
- const prepared: Record<string, unknown> = { ...record };
277
- if (!("comments" in prepared) && "selectedComments" in prepared) prepared.comments = prepared.selectedComments;
278
- if (!("comments" in prepared) && "comment" in prepared) prepared.comments = [prepared.comment];
279
- if (!("pr" in prepared) && "prContext" in prepared) prepared.pr = prepared.prContext;
280
- if (!("base" in prepared) && "baseContext" in prepared) prepared.base = prepared.baseContext;
281
- if (!("diff" in prepared) && "diffContext" in prepared) prepared.diff = prepared.diffContext;
282
- return prepared;
325
+ const record = asRecord(args);
326
+ if (!record) return args;
327
+ const prepared: Record<string, unknown> = { ...record };
328
+ if (!("comments" in prepared) && "selectedComments" in prepared)
329
+ prepared.comments = prepared.selectedComments;
330
+ if (!("comments" in prepared) && "comment" in prepared) prepared.comments = [prepared.comment];
331
+ if (!("pr" in prepared) && "prContext" in prepared) prepared.pr = prepared.prContext;
332
+ if (!("base" in prepared) && "baseContext" in prepared) prepared.base = prepared.baseContext;
333
+ if (!("diff" in prepared) && "diffContext" in prepared) prepared.diff = prepared.diffContext;
334
+ return prepared;
283
335
  }
284
336
 
285
337
  function shorten(text: string, max: number): string {
286
- const oneLine = text.replace(/\s+/g, " ").trim();
287
- if (oneLine.length <= max) return oneLine;
288
- return `${oneLine.slice(0, Math.max(1, max - 1))}…`;
338
+ const oneLine = text.replace(/\s+/g, " ").trim();
339
+ if (oneLine.length <= max) return oneLine;
340
+ return `${oneLine.slice(0, Math.max(1, max - 1))}…`;
289
341
  }
290
342
 
291
343
  function formatJsonForPrompt(value: unknown): string {
292
- if (value === undefined) return "(not provided)";
293
- return JSON.stringify(value, null, 2);
344
+ if (value === undefined) return "(not provided)";
345
+ return JSON.stringify(value, null, 2);
294
346
  }
295
347
 
296
348
  function formatCommentForPrompt(comment: NormalizedComment): string {
297
- const fields = [
298
- `index: ${comment.index}`,
299
- comment.id ? `id: ${comment.id}` : undefined,
300
- comment.author ? `author: ${comment.author}` : undefined,
301
- comment.path ? `path: ${comment.path}` : undefined,
302
- comment.startLine ? `startLine: ${comment.startLine}` : undefined,
303
- comment.line ? `line: ${comment.line}` : undefined,
304
- comment.side ? `side: ${comment.side}` : undefined,
305
- comment.url ? `url: ${comment.url}` : undefined,
306
- comment.createdAt ? `createdAt: ${comment.createdAt}` : undefined,
307
- ]
308
- .filter(Boolean)
309
- .join("\n");
310
-
311
- const sections = [`Comment ${comment.index}:`, fields, `body:\n${comment.body}`];
312
- if (comment.diffHunk) sections.push(`diffHunk:\n${comment.diffHunk}`);
313
- if (comment.metadata) sections.push(`metadata:\n${formatJsonForPrompt(comment.metadata)}`);
314
- if (comment.context) sections.push(`extraContext:\n${comment.context}`);
315
- return sections.filter(Boolean).join("\n");
316
- }
317
-
318
- function buildSystemPrompt(options: { cwd: string; maxTurns: number; maxRunSeconds: number }): string {
319
- 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.\"`;
349
+ const fields = [
350
+ `index: ${comment.index}`,
351
+ comment.id ? `id: ${comment.id}` : undefined,
352
+ comment.author ? `author: ${comment.author}` : undefined,
353
+ comment.path ? `path: ${comment.path}` : undefined,
354
+ comment.startLine ? `startLine: ${comment.startLine}` : undefined,
355
+ comment.line ? `line: ${comment.line}` : undefined,
356
+ comment.side ? `side: ${comment.side}` : undefined,
357
+ comment.url ? `url: ${comment.url}` : undefined,
358
+ comment.createdAt ? `createdAt: ${comment.createdAt}` : undefined,
359
+ ]
360
+ .filter(Boolean)
361
+ .join("\n");
362
+
363
+ const sections = [`Comment ${comment.index}:`, fields, `body:\n${comment.body}`];
364
+ if (comment.diffHunk) sections.push(`diffHunk:\n${comment.diffHunk}`);
365
+ if (comment.metadata) sections.push(`metadata:\n${formatJsonForPrompt(comment.metadata)}`);
366
+ if (comment.context) sections.push(`extraContext:\n${comment.context}`);
367
+ return sections.filter(Boolean).join("\n");
368
+ }
369
+
370
+ function buildSystemPrompt(options: {
371
+ cwd: string;
372
+ maxTurns: number;
373
+ maxRunSeconds: number;
374
+ }): string {
375
+ return `You are Triage Comments, an isolated read-only code-review investigation agent running inside the triage-comments package. 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.\"`;
320
376
  }
321
377
 
322
378
  function buildUserPrompt(input: NormalizedInput, cwd: string): string {
323
- 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.`;
379
+ 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.`;
324
380
  }
325
381
 
326
382
  function ensureImplementationNote(text: string): string {
327
- const trimmed = text.trim();
328
- if (!trimmed) return trimmed;
329
- if (trimmed.toLowerCase().includes(IMPLEMENTATION_NOTE.toLowerCase())) return trimmed;
330
- return `${trimmed}\n\n---\n${IMPLEMENTATION_NOTE}`;
383
+ const trimmed = text.trim();
384
+ if (!trimmed) return trimmed;
385
+ if (trimmed.toLowerCase().includes(IMPLEMENTATION_NOTE.toLowerCase())) return trimmed;
386
+ return `${trimmed}\n\n---\n${IMPLEMENTATION_NOTE}`;
331
387
  }
332
388
 
333
389
  function createEmptyUsage(): Usage {
334
- return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } };
390
+ return {
391
+ input: 0,
392
+ output: 0,
393
+ cacheRead: 0,
394
+ cacheWrite: 0,
395
+ totalTokens: 0,
396
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
397
+ };
335
398
  }
336
399
 
337
400
  function addUsage(total: Usage, usage: Usage): void {
338
- total.input += usage.input || 0;
339
- total.output += usage.output || 0;
340
- total.cacheRead += usage.cacheRead || 0;
341
- total.cacheWrite += usage.cacheWrite || 0;
342
- if (usage.cacheWrite1h !== undefined) total.cacheWrite1h = (total.cacheWrite1h ?? 0) + usage.cacheWrite1h;
343
- if (usage.reasoning !== undefined) total.reasoning = (total.reasoning ?? 0) + usage.reasoning;
344
- total.totalTokens += usage.totalTokens || 0;
345
- total.cost.input += usage.cost?.input || 0;
346
- total.cost.output += usage.cost?.output || 0;
347
- total.cost.cacheRead += usage.cost?.cacheRead || 0;
348
- total.cost.cacheWrite += usage.cost?.cacheWrite || 0;
349
- total.cost.total += usage.cost?.total || 0;
350
- }
351
-
352
- type AssistantLikeMessage = { role?: string; content?: unknown; stopReason?: unknown; errorMessage?: unknown; usage?: unknown };
401
+ total.input += usage.input || 0;
402
+ total.output += usage.output || 0;
403
+ total.cacheRead += usage.cacheRead || 0;
404
+ total.cacheWrite += usage.cacheWrite || 0;
405
+ if (usage.cacheWrite1h !== undefined)
406
+ total.cacheWrite1h = (total.cacheWrite1h ?? 0) + usage.cacheWrite1h;
407
+ if (usage.reasoning !== undefined) total.reasoning = (total.reasoning ?? 0) + usage.reasoning;
408
+ total.totalTokens += usage.totalTokens || 0;
409
+ total.cost.input += usage.cost?.input || 0;
410
+ total.cost.output += usage.cost?.output || 0;
411
+ total.cost.cacheRead += usage.cost?.cacheRead || 0;
412
+ total.cost.cacheWrite += usage.cost?.cacheWrite || 0;
413
+ total.cost.total += usage.cost?.total || 0;
414
+ }
415
+
416
+ type AssistantLikeMessage = {
417
+ role?: string;
418
+ content?: unknown;
419
+ stopReason?: unknown;
420
+ errorMessage?: unknown;
421
+ usage?: unknown;
422
+ };
353
423
 
354
424
  function addAssistantMessageUsage(total: Usage, message: unknown): void {
355
- const assistant = message as AssistantLikeMessage;
356
- if (assistant?.role !== "assistant" || !assistant.usage || typeof assistant.usage !== "object") return;
357
- addUsage(total, assistant.usage as Usage);
425
+ const assistant = message as AssistantLikeMessage;
426
+ if (assistant?.role !== "assistant" || !assistant.usage || typeof assistant.usage !== "object")
427
+ return;
428
+ addUsage(total, assistant.usage as Usage);
358
429
  }
359
430
 
360
431
  function aggregateAssistantUsage(messages: unknown[]): Usage {
361
- const total = createEmptyUsage();
362
- for (const message of messages) addAssistantMessageUsage(total, message);
363
- return total;
432
+ const total = createEmptyUsage();
433
+ for (const message of messages) addAssistantMessageUsage(total, message);
434
+ return total;
364
435
  }
365
436
 
366
437
  function addSessionEventUsage(total: Usage, event: unknown): void {
367
- const sessionEvent = event as { type?: string; message?: unknown; result?: { usage?: Usage } };
368
- if (sessionEvent?.type === "message_end") return void addAssistantMessageUsage(total, sessionEvent.message);
369
- if (sessionEvent?.type === "compaction_end" && sessionEvent.result?.usage) addUsage(total, sessionEvent.result.usage);
370
- }
371
-
372
- function inspectFinalAssistant(messages: unknown[]):
373
- | { ok: true; answer: string; stopReason?: string }
374
- | { ok: false; reason: string; stopReason?: string; errorMessage?: string } {
375
- for (let i = messages.length - 1; i >= 0; i -= 1) {
376
- const message = messages[i] as AssistantLikeMessage;
377
- if (message?.role !== "assistant") continue;
378
- const stopReason = typeof message.stopReason === "string" ? message.stopReason.trim() : "";
379
- const errorMessage = typeof message.errorMessage === "string" ? message.errorMessage.trim() : "";
380
- const answer = Array.isArray(message.content)
381
- ? message.content
382
- .map((part) => (part && typeof part === "object" && (part as { type?: string }).type === "text" && typeof (part as { text?: unknown }).text === "string"
383
- ? (part as { text: string }).text
384
- : ""))
385
- .join("")
386
- .trim()
387
- : "";
388
- if (stopReason === "error") return { ok: false, reason: `triage_comments subagent error: ${errorMessage || "provider/model error"}`, stopReason, errorMessage };
389
- if (stopReason === "aborted") return { ok: false, reason: "triage_comments subagent aborted before producing a usable answer", stopReason };
390
- if (answer) return { ok: true, answer, stopReason: stopReason || undefined };
391
- return { ok: false, reason: stopReason ? `triage_comments subagent produced no final assistant text (stopReason: ${stopReason})` : "triage_comments subagent produced no final assistant text", stopReason: stopReason || undefined, errorMessage: errorMessage || undefined };
392
- }
393
- return { ok: false, reason: "triage_comments subagent produced no assistant message" };
394
- }
395
-
396
- function classifyRunFailure(error: unknown, aborted: boolean): { status: "aborted" | "error"; message: string; error?: string } {
397
- const message = aborted ? "Aborted" : error instanceof Error ? error.message : String(error);
398
- return { status: aborted ? "aborted" : "error", message, error: aborted ? undefined : message };
438
+ const sessionEvent = event as { type?: string; message?: unknown; result?: { usage?: Usage } };
439
+ if (sessionEvent?.type === "message_end")
440
+ return void addAssistantMessageUsage(total, sessionEvent.message);
441
+ if (sessionEvent?.type === "compaction_end" && sessionEvent.result?.usage)
442
+ addUsage(total, sessionEvent.result.usage);
443
+ }
444
+
445
+ function inspectFinalAssistant(
446
+ messages: unknown[],
447
+ ):
448
+ | { ok: true; answer: string; stopReason?: string }
449
+ | { ok: false; reason: string; stopReason?: string; errorMessage?: string } {
450
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
451
+ const message = messages[i] as AssistantLikeMessage;
452
+ if (message?.role !== "assistant") continue;
453
+ const stopReason = typeof message.stopReason === "string" ? message.stopReason.trim() : "";
454
+ const errorMessage =
455
+ typeof message.errorMessage === "string" ? message.errorMessage.trim() : "";
456
+ const answer = Array.isArray(message.content)
457
+ ? message.content
458
+ .map((part) =>
459
+ part &&
460
+ typeof part === "object" &&
461
+ (part as { type?: string }).type === "text" &&
462
+ typeof (part as { text?: unknown }).text === "string"
463
+ ? (part as { text: string }).text
464
+ : "",
465
+ )
466
+ .join("")
467
+ .trim()
468
+ : "";
469
+ if (stopReason === "error")
470
+ return {
471
+ ok: false,
472
+ reason: `triage_comments subagent error: ${errorMessage || "provider/model error"}`,
473
+ stopReason,
474
+ errorMessage,
475
+ };
476
+ if (stopReason === "aborted")
477
+ return {
478
+ ok: false,
479
+ reason: "triage_comments subagent aborted before producing a usable answer",
480
+ stopReason,
481
+ };
482
+ if (answer) return { ok: true, answer, stopReason: stopReason || undefined };
483
+ return {
484
+ ok: false,
485
+ reason: stopReason
486
+ ? `triage_comments subagent produced no final assistant text (stopReason: ${stopReason})`
487
+ : "triage_comments subagent produced no final assistant text",
488
+ stopReason: stopReason || undefined,
489
+ errorMessage: errorMessage || undefined,
490
+ };
491
+ }
492
+ return { ok: false, reason: "triage_comments subagent produced no assistant message" };
493
+ }
494
+
495
+ function classifyRunFailure(
496
+ error: unknown,
497
+ aborted: boolean,
498
+ ): { status: "aborted" | "error"; message: string; error?: string } {
499
+ const message = aborted ? "Aborted" : error instanceof Error ? error.message : String(error);
500
+ return { status: aborted ? "aborted" : "error", message, error: aborted ? undefined : message };
399
501
  }
400
502
 
401
503
  function isInside(parent: string, child: string): boolean {
402
- const parentResolved = path.resolve(parent);
403
- const childResolved = path.resolve(child);
404
- return childResolved === parentResolved || childResolved.startsWith(`${parentResolved}${path.sep}`);
504
+ const parentResolved = path.resolve(parent);
505
+ const childResolved = path.resolve(child);
506
+ return (
507
+ childResolved === parentResolved || childResolved.startsWith(`${parentResolved}${path.sep}`)
508
+ );
405
509
  }
406
510
 
407
511
  function resolveToolPath(cwd: string, rawPath: string | undefined): string {
408
- const input = rawPath?.trim() || ".";
409
- const normalized = input.startsWith("@") ? input.slice(1) : input;
410
- return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(cwd, normalized);
512
+ const input = rawPath?.trim() || ".";
513
+ const normalized = input.startsWith("@") ? input.slice(1) : input;
514
+ return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(cwd, normalized);
411
515
  }
412
516
 
413
- async function assertToolPathInsideCwd(cwd: string, rawPath: unknown, toolName: string): Promise<string | undefined> {
414
- if (rawPath !== undefined && typeof rawPath !== "string") return `${toolName} path must be a string.`;
415
- const root = await fs.realpath(cwd).catch(() => path.resolve(cwd));
416
- const resolved = resolveToolPath(cwd, rawPath);
417
- const realPath = await fs.realpath(resolved).catch(() => resolved);
418
- if (!isInside(root, realPath)) return `${toolName} is limited to the local checkout: ${realPath}`;
419
- return undefined;
517
+ async function assertToolPathInsideCwd(
518
+ cwd: string,
519
+ rawPath: unknown,
520
+ toolName: string,
521
+ ): Promise<string | undefined> {
522
+ if (rawPath !== undefined && typeof rawPath !== "string")
523
+ return `${toolName} path must be a string.`;
524
+ const root = await fs.realpath(cwd).catch(() => path.resolve(cwd));
525
+ const resolved = resolveToolPath(cwd, rawPath);
526
+ const realPath = await fs.realpath(resolved).catch(() => resolved);
527
+ if (!isInside(root, realPath)) return `${toolName} is limited to the local checkout: ${realPath}`;
528
+ return undefined;
420
529
  }
421
530
 
422
531
  function getUnsafePathPatternReason(value: unknown, label: string): string | undefined {
423
- if (value === undefined) return undefined;
424
- if (typeof value !== "string") return `${label} must be a string.`;
425
- if (path.isAbsolute(value)) return `${label} must be relative to the local checkout.`;
426
- if (/(^|[/\\])\.\.(?:[/\\]|$)/.test(value)) return `${label} must not traverse outside the local checkout.`;
427
- return undefined;
532
+ if (value === undefined) return undefined;
533
+ if (typeof value !== "string") return `${label} must be a string.`;
534
+ if (path.isAbsolute(value)) return `${label} must be relative to the local checkout.`;
535
+ if (/(^|[/\\])\.\.(?:[/\\]|$)/.test(value))
536
+ return `${label} must not traverse outside the local checkout.`;
537
+ return undefined;
428
538
  }
429
539
 
430
540
  function stripTokenQuotes(token: string): string {
431
- if ((token.startsWith("'") && token.endsWith("'")) || (token.startsWith('"') && token.endsWith('"'))) {
432
- return token.slice(1, -1);
433
- }
434
- return token;
541
+ if (
542
+ (token.startsWith("'") && token.endsWith("'")) ||
543
+ (token.startsWith('"') && token.endsWith('"'))
544
+ ) {
545
+ return token.slice(1, -1);
546
+ }
547
+ return token;
435
548
  }
436
549
 
437
550
  type ShellTokenizeResult = {
438
- tokens: string[];
439
- reason?: string;
551
+ tokens: string[];
552
+ reason?: string;
440
553
  };
441
554
 
442
555
  function tokenizeShellCommand(command: string): ShellTokenizeResult {
443
- const tokens: string[] = [];
444
- let token = "";
445
- let tokenStarted = false;
446
- let inSingleQuote = false;
447
- let inDoubleQuote = false;
448
-
449
- const pushToken = () => {
450
- if (!tokenStarted) return;
451
- tokens.push(token);
452
- token = "";
453
- tokenStarted = false;
454
- };
455
-
456
- for (let index = 0; index < command.length; index += 1) {
457
- const char = command[index];
458
- const next = command[index + 1] ?? "";
459
-
460
- if (inSingleQuote) {
461
- if (char === "'") {
462
- inSingleQuote = false;
463
- continue;
464
- }
465
- token += char;
466
- tokenStarted = true;
467
- continue;
468
- }
469
-
470
- if (char === "\\") {
471
- return {
472
- tokens,
473
- reason: "Triage bash blocks shell escape sequences; pass plain git, gh, or pwd arguments without backslashes.",
474
- };
475
- }
476
-
477
- if (inDoubleQuote) {
478
- if (char === '"') {
479
- inDoubleQuote = false;
480
- continue;
481
- }
482
- if (char === "`" || char === "$") {
483
- return {
484
- tokens,
485
- reason:
486
- char === "`" || next === "("
487
- ? "Triage bash blocks command substitution."
488
- : "Triage bash blocks shell expansion in double quotes.",
489
- };
490
- }
491
- token += char;
492
- tokenStarted = true;
493
- continue;
494
- }
495
-
496
- if (char === "\n" || char === "\r") {
497
- return {
498
- tokens,
499
- reason:
500
- "Triage bash allows one git, gh, or pwd invocation only; pipelines, shell control operators, and multiple commands are blocked.",
501
- };
502
- }
503
- if (/\s/.test(char)) {
504
- pushToken();
505
- continue;
506
- }
507
- if (char === "'") {
508
- inSingleQuote = true;
509
- tokenStarted = true;
510
- continue;
511
- }
512
- if (char === '"') {
513
- inDoubleQuote = true;
514
- tokenStarted = true;
515
- continue;
516
- }
517
- if (char === "`") {
518
- return { tokens, reason: "Triage bash blocks command substitution." };
519
- }
520
- if (char === "$") {
521
- return {
522
- tokens,
523
- reason:
524
- next === "'" || next === '"'
525
- ? "Triage bash blocks ANSI-C and localized shell quotes."
526
- : next === "("
527
- ? "Triage bash blocks command substitution."
528
- : "Triage bash blocks shell expansion.",
529
- };
530
- }
531
- if (/[;&|()]/.test(char)) {
532
- return {
533
- tokens,
534
- reason:
535
- "Triage bash allows one git, gh, or pwd invocation only; pipelines, shell control operators, and multiple commands are blocked.",
536
- };
537
- }
538
- if (char === ">" || char === "<") {
539
- return { tokens, reason: "Triage bash blocks shell redirection to keep inspection read-only." };
540
- }
541
- if (char === "{" || char === "}") {
542
- return { tokens, reason: "Triage bash blocks shell brace expansion." };
543
- }
544
- if (char === "*" || char === "?" || char === "[" || char === "]") {
545
- return { tokens, reason: "Triage bash blocks shell glob expansion." };
546
- }
547
- if (char === "~" && !tokenStarted) {
548
- return { tokens, reason: "Triage bash blocks shell home-directory expansion." };
549
- }
550
-
551
- token += char;
552
- tokenStarted = true;
553
- }
554
-
555
- if (inSingleQuote || inDoubleQuote) return { tokens, reason: "Triage bash blocks unterminated or malformed shell quoting." };
556
- pushToken();
557
- return { tokens };
556
+ const tokens: string[] = [];
557
+ let token = "";
558
+ let tokenStarted = false;
559
+ let inSingleQuote = false;
560
+ let inDoubleQuote = false;
561
+
562
+ const pushToken = () => {
563
+ if (!tokenStarted) return;
564
+ tokens.push(token);
565
+ token = "";
566
+ tokenStarted = false;
567
+ };
568
+
569
+ for (let index = 0; index < command.length; index += 1) {
570
+ const char = command[index];
571
+ const next = command[index + 1] ?? "";
572
+
573
+ if (inSingleQuote) {
574
+ if (char === "'") {
575
+ inSingleQuote = false;
576
+ continue;
577
+ }
578
+ token += char;
579
+ tokenStarted = true;
580
+ continue;
581
+ }
582
+
583
+ if (char === "\\") {
584
+ return {
585
+ tokens,
586
+ reason:
587
+ "Triage bash blocks shell escape sequences; pass plain git, gh, or pwd arguments without backslashes.",
588
+ };
589
+ }
590
+
591
+ if (inDoubleQuote) {
592
+ if (char === '"') {
593
+ inDoubleQuote = false;
594
+ continue;
595
+ }
596
+ if (char === "`" || char === "$") {
597
+ return {
598
+ tokens,
599
+ reason:
600
+ char === "`" || next === "("
601
+ ? "Triage bash blocks command substitution."
602
+ : "Triage bash blocks shell expansion in double quotes.",
603
+ };
604
+ }
605
+ token += char;
606
+ tokenStarted = true;
607
+ continue;
608
+ }
609
+
610
+ if (char === "\n" || char === "\r") {
611
+ return {
612
+ tokens,
613
+ reason:
614
+ "Triage bash allows one git, gh, or pwd invocation only; pipelines, shell control operators, and multiple commands are blocked.",
615
+ };
616
+ }
617
+ if (/\s/.test(char)) {
618
+ pushToken();
619
+ continue;
620
+ }
621
+ if (char === "'") {
622
+ inSingleQuote = true;
623
+ tokenStarted = true;
624
+ continue;
625
+ }
626
+ if (char === '"') {
627
+ inDoubleQuote = true;
628
+ tokenStarted = true;
629
+ continue;
630
+ }
631
+ if (char === "`") {
632
+ return { tokens, reason: "Triage bash blocks command substitution." };
633
+ }
634
+ if (char === "$") {
635
+ return {
636
+ tokens,
637
+ reason:
638
+ next === "'" || next === '"'
639
+ ? "Triage bash blocks ANSI-C and localized shell quotes."
640
+ : next === "("
641
+ ? "Triage bash blocks command substitution."
642
+ : "Triage bash blocks shell expansion.",
643
+ };
644
+ }
645
+ if (/[;&|()]/.test(char)) {
646
+ return {
647
+ tokens,
648
+ reason:
649
+ "Triage bash allows one git, gh, or pwd invocation only; pipelines, shell control operators, and multiple commands are blocked.",
650
+ };
651
+ }
652
+ if (char === ">" || char === "<") {
653
+ return {
654
+ tokens,
655
+ reason: "Triage bash blocks shell redirection to keep inspection read-only.",
656
+ };
657
+ }
658
+ if (char === "{" || char === "}") {
659
+ return { tokens, reason: "Triage bash blocks shell brace expansion." };
660
+ }
661
+ if (char === "*" || char === "?" || char === "[" || char === "]") {
662
+ return { tokens, reason: "Triage bash blocks shell glob expansion." };
663
+ }
664
+ if (char === "~" && !tokenStarted) {
665
+ return { tokens, reason: "Triage bash blocks shell home-directory expansion." };
666
+ }
667
+
668
+ token += char;
669
+ tokenStarted = true;
670
+ }
671
+
672
+ if (inSingleQuote || inDoubleQuote)
673
+ return { tokens, reason: "Triage bash blocks unterminated or malformed shell quoting." };
674
+ pushToken();
675
+ return { tokens };
558
676
  }
559
677
 
560
678
  function tokenizeSegment(segment: string): string[] {
561
- return tokenizeShellCommand(segment).tokens;
679
+ return tokenizeShellCommand(segment).tokens;
562
680
  }
563
681
 
564
682
  function getExecutableName(token: string): string {
565
- return path.basename(token).toLowerCase();
683
+ return path.basename(token).toLowerCase();
566
684
  }
567
685
 
568
686
  function getShellSyntaxReason(command: string): string | undefined {
569
- return tokenizeShellCommand(command).reason;
687
+ return tokenizeShellCommand(command).reason;
570
688
  }
571
689
 
572
690
  function getUnsafeGitTokenPathReason(token: string): string | undefined {
573
- const valueParts = [token];
574
- const equalsIndex = token.indexOf("=");
575
- if (equalsIndex >= 0 && equalsIndex < token.length - 1) valueParts.push(token.slice(equalsIndex + 1));
576
- for (const value of valueParts) {
577
- const normalized = value.startsWith("@") ? value.slice(1) : value;
578
- if (normalized === "~" || normalized.startsWith("~/") || /^~[^/\\]*/.test(normalized)) {
579
- return "Triage bash git arguments must not use home-directory paths; use built-in read/grep/find/ls for local files.";
580
- }
581
- if (path.isAbsolute(normalized)) {
582
- return "Triage bash git arguments must not use absolute filesystem paths; use built-in read/grep/find/ls for local files.";
583
- }
584
- if (/(^|[/\\])\.\.(?:[/\\]|$)/.test(normalized)) {
585
- return "Triage bash git arguments must not traverse outside the local checkout.";
586
- }
587
- }
588
- return undefined;
691
+ const valueParts = [token];
692
+ const equalsIndex = token.indexOf("=");
693
+ if (equalsIndex >= 0 && equalsIndex < token.length - 1)
694
+ valueParts.push(token.slice(equalsIndex + 1));
695
+ for (const value of valueParts) {
696
+ const normalized = value.startsWith("@") ? value.slice(1) : value;
697
+ if (normalized === "~" || normalized.startsWith("~/") || /^~[^/\\]*/.test(normalized)) {
698
+ return "Triage bash git arguments must not use home-directory paths; use built-in read/grep/find/ls for local files.";
699
+ }
700
+ if (path.isAbsolute(normalized)) {
701
+ return "Triage bash git arguments must not use absolute filesystem paths; use built-in read/grep/find/ls for local files.";
702
+ }
703
+ if (/(^|[/\\])\.\.(?:[/\\]|$)/.test(normalized)) {
704
+ return "Triage bash git arguments must not traverse outside the local checkout.";
705
+ }
706
+ }
707
+ return undefined;
589
708
  }
590
709
 
591
710
  function getBlockedPwdReason(tokens: string[]): string | undefined {
592
- const args = tokens.slice(1);
593
- if (args.length === 0) return undefined;
594
- if (args.length === 1 && (args[0] === "-P" || args[0] === "-L")) return undefined;
595
- return "Triage bash allows pwd only with no arguments or -P/-L.";
596
- }
597
-
598
- function getGitSubcommand(tokens: string[]): { subcommand?: string; index: number; reason?: string } {
599
- let index = 1;
600
- while (index < tokens.length) {
601
- const token = tokens[index];
602
- if (!token.startsWith("-")) break;
603
- if (token === "--git-dir" || token.startsWith("--git-dir=")) {
604
- return { index, reason: "Triage bash blocks git --git-dir because it can inspect or mutate outside the checkout." };
605
- }
606
- if (token === "--work-tree" || token.startsWith("--work-tree=")) {
607
- return { index, reason: "Triage bash blocks git --work-tree because it can inspect or mutate outside the checkout." };
608
- }
609
- if (token === "-C") {
610
- const gitCwd = tokens[index + 1];
611
- if (gitCwd !== ".") {
612
- return { index, reason: "Triage bash only allows git -C .; run git from the local checkout." };
613
- }
614
- index += 2;
615
- continue;
616
- }
617
- if (token.startsWith("-C")) {
618
- if (token !== "-C.") {
619
- return { index, reason: "Triage bash only allows git -C .; run git from the local checkout." };
620
- }
621
- index += 1;
622
- continue;
623
- }
624
- if (token === "--paginate" || token === "-p") {
625
- return { index, reason: "Triage bash blocks git pagination because pagers can execute local utilities." };
626
- }
627
- if (SAFE_GIT_GLOBAL_FLAGS.has(token)) {
628
- index += 1;
629
- continue;
630
- }
631
- return { index, reason: `Triage bash blocks git global option ${token}; use direct read-only git inspection commands from the checkout.` };
632
- }
633
- return { subcommand: tokens[index]?.toLowerCase(), index };
711
+ const args = tokens.slice(1);
712
+ if (args.length === 0) return undefined;
713
+ if (args.length === 1 && (args[0] === "-P" || args[0] === "-L")) return undefined;
714
+ return "Triage bash allows pwd only with no arguments or -P/-L.";
715
+ }
716
+
717
+ function getGitSubcommand(tokens: string[]): {
718
+ subcommand?: string;
719
+ index: number;
720
+ reason?: string;
721
+ } {
722
+ let index = 1;
723
+ while (index < tokens.length) {
724
+ const token = tokens[index];
725
+ if (!token.startsWith("-")) break;
726
+ if (token === "--git-dir" || token.startsWith("--git-dir=")) {
727
+ return {
728
+ index,
729
+ reason:
730
+ "Triage bash blocks git --git-dir because it can inspect or mutate outside the checkout.",
731
+ };
732
+ }
733
+ if (token === "--work-tree" || token.startsWith("--work-tree=")) {
734
+ return {
735
+ index,
736
+ reason:
737
+ "Triage bash blocks git --work-tree because it can inspect or mutate outside the checkout.",
738
+ };
739
+ }
740
+ if (token === "-C") {
741
+ const gitCwd = tokens[index + 1];
742
+ if (gitCwd !== ".") {
743
+ return {
744
+ index,
745
+ reason: "Triage bash only allows git -C .; run git from the local checkout.",
746
+ };
747
+ }
748
+ index += 2;
749
+ continue;
750
+ }
751
+ if (token.startsWith("-C")) {
752
+ if (token !== "-C.") {
753
+ return {
754
+ index,
755
+ reason: "Triage bash only allows git -C .; run git from the local checkout.",
756
+ };
757
+ }
758
+ index += 1;
759
+ continue;
760
+ }
761
+ if (token === "--paginate" || token === "-p") {
762
+ return {
763
+ index,
764
+ reason: "Triage bash blocks git pagination because pagers can execute local utilities.",
765
+ };
766
+ }
767
+ if (SAFE_GIT_GLOBAL_FLAGS.has(token)) {
768
+ index += 1;
769
+ continue;
770
+ }
771
+ return {
772
+ index,
773
+ reason: `Triage bash blocks git global option ${token}; use direct read-only git inspection commands from the checkout.`,
774
+ };
775
+ }
776
+ return { subcommand: tokens[index]?.toLowerCase(), index };
634
777
  }
635
778
 
636
779
  function isSafeGitBranchCommand(tokens: string[], subcommandIndex: number): boolean {
637
- const args = tokens.slice(subcommandIndex + 1);
638
- if (args.length === 0) return true;
639
- if (args.some((arg) => /^-(?:d|D|m|M|c|C|f)$/.test(arg) || /^--(?:delete|move|copy|force|set-upstream-to|unset-upstream)$/.test(arg))) {
640
- return false;
641
- }
642
- return args.some((arg) => SAFE_GIT_BRANCH_FLAGS.has(arg) || arg.startsWith("--contains=") || arg.startsWith("--merged=") || arg.startsWith("--no-merged="));
780
+ const args = tokens.slice(subcommandIndex + 1);
781
+ if (args.length === 0) return true;
782
+ if (
783
+ args.some(
784
+ (arg) =>
785
+ /^-(?:d|D|m|M|c|C|f)$/.test(arg) ||
786
+ /^--(?:delete|move|copy|force|set-upstream-to|unset-upstream)$/.test(arg),
787
+ )
788
+ ) {
789
+ return false;
790
+ }
791
+ return args.some(
792
+ (arg) =>
793
+ SAFE_GIT_BRANCH_FLAGS.has(arg) ||
794
+ arg.startsWith("--contains=") ||
795
+ arg.startsWith("--merged=") ||
796
+ arg.startsWith("--no-merged="),
797
+ );
643
798
  }
644
799
 
645
800
  function isSafeGitRemoteCommand(tokens: string[], subcommandIndex: number): boolean {
646
- const args = tokens.slice(subcommandIndex + 1);
647
- if (args.length === 0) return true;
648
- if (args.length === 1 && (args[0] === "-v" || args[0] === "--verbose")) return true;
649
- const remoteAction = args.find((arg) => !arg.startsWith("-"));
650
- return remoteAction === "show" || remoteAction === "get-url";
801
+ const args = tokens.slice(subcommandIndex + 1);
802
+ if (args.length === 0) return true;
803
+ if (args.length === 1 && (args[0] === "-v" || args[0] === "--verbose")) return true;
804
+ const remoteAction = args.find((arg) => !arg.startsWith("-"));
805
+ return remoteAction === "show" || remoteAction === "get-url";
651
806
  }
652
807
 
653
808
  function getUnsafeGitArgumentReason(tokens: string[]): string | undefined {
654
- for (const token of tokens.slice(1)) {
655
- if (token === "--no-index" || token.startsWith("--no-index=")) {
656
- return "Triage bash blocks git --no-index because it can inspect arbitrary filesystem paths outside the checkout.";
657
- }
658
- if (token === "--paginate" || token.startsWith("--paginate=")) {
659
- return "Triage bash blocks git --paginate because pagers can execute local utilities.";
660
- }
661
- if (token === "--open-files-in-pager" || token.startsWith("--open-files-in-pager=")) {
662
- return "Triage bash blocks git --open-files-in-pager because it can execute local utilities.";
663
- }
664
- if (token === "-O" || token.startsWith("-O")) {
665
- return "Triage bash blocks git -O because it can execute local utilities for some subcommands.";
666
- }
667
- if (token === "--ext-diff" || token.startsWith("--ext-diff=")) {
668
- return "Triage bash blocks git --ext-diff because it can execute external diff helpers.";
669
- }
670
- if (token === "--textconv" || token.startsWith("--textconv=")) {
671
- return "Triage bash blocks git --textconv because it can execute external text conversion filters.";
672
- }
673
- if (token === "--output" || token.startsWith("--output=")) {
674
- return "Triage bash blocks git output-writing flags.";
675
- }
676
- const pathReason = getUnsafeGitTokenPathReason(token);
677
- if (pathReason) return pathReason;
678
- }
679
- return undefined;
809
+ for (const token of tokens.slice(1)) {
810
+ if (token === "--no-index" || token.startsWith("--no-index=")) {
811
+ return "Triage bash blocks git --no-index because it can inspect arbitrary filesystem paths outside the checkout.";
812
+ }
813
+ if (token === "--paginate" || token.startsWith("--paginate=")) {
814
+ return "Triage bash blocks git --paginate because pagers can execute local utilities.";
815
+ }
816
+ if (token === "--open-files-in-pager" || token.startsWith("--open-files-in-pager=")) {
817
+ return "Triage bash blocks git --open-files-in-pager because it can execute local utilities.";
818
+ }
819
+ if (token === "-O" || token.startsWith("-O")) {
820
+ return "Triage bash blocks git -O because it can execute local utilities for some subcommands.";
821
+ }
822
+ if (token === "--ext-diff" || token.startsWith("--ext-diff=")) {
823
+ return "Triage bash blocks git --ext-diff because it can execute external diff helpers.";
824
+ }
825
+ if (token === "--textconv" || token.startsWith("--textconv=")) {
826
+ return "Triage bash blocks git --textconv because it can execute external text conversion filters.";
827
+ }
828
+ if (token === "--output" || token.startsWith("--output=")) {
829
+ return "Triage bash blocks git output-writing flags.";
830
+ }
831
+ const pathReason = getUnsafeGitTokenPathReason(token);
832
+ if (pathReason) return pathReason;
833
+ }
834
+ return undefined;
680
835
  }
681
836
 
682
837
  function getBlockedGitReason(command: string): string | undefined {
683
- const tokens = tokenizeSegment(command);
684
- if (getExecutableName(tokens[0] ?? "") !== "git") return undefined;
685
- const parsed = getGitSubcommand(tokens);
686
- if (parsed.reason) return parsed.reason;
687
- const argumentReason = getUnsafeGitArgumentReason(tokens);
688
- if (argumentReason) return argumentReason;
689
- if (!parsed.subcommand) return undefined;
690
- if (parsed.subcommand === "branch") {
691
- if (!isSafeGitBranchCommand(tokens, parsed.index)) {
692
- return "Triage bash allows git branch only for read-only listing/show-current/contains/merged queries.";
693
- }
694
- return undefined;
695
- }
696
- if (parsed.subcommand === "remote") {
697
- if (!isSafeGitRemoteCommand(tokens, parsed.index)) {
698
- return "Triage bash allows git remote only for read-only list/show/get-url queries.";
699
- }
700
- return undefined;
701
- }
702
- if (!READ_ONLY_GIT_SUBCOMMANDS.has(parsed.subcommand)) {
703
- return `Triage bash blocks git ${parsed.subcommand}; only known read-only git subcommands are allowed.`;
704
- }
705
- return undefined;
838
+ const tokens = tokenizeSegment(command);
839
+ if (getExecutableName(tokens[0] ?? "") !== "git") return undefined;
840
+ const parsed = getGitSubcommand(tokens);
841
+ if (parsed.reason) return parsed.reason;
842
+ const argumentReason = getUnsafeGitArgumentReason(tokens);
843
+ if (argumentReason) return argumentReason;
844
+ if (!parsed.subcommand) return undefined;
845
+ if (parsed.subcommand === "branch") {
846
+ if (!isSafeGitBranchCommand(tokens, parsed.index)) {
847
+ return "Triage bash allows git branch only for read-only listing/show-current/contains/merged queries.";
848
+ }
849
+ return undefined;
850
+ }
851
+ if (parsed.subcommand === "remote") {
852
+ if (!isSafeGitRemoteCommand(tokens, parsed.index)) {
853
+ return "Triage bash allows git remote only for read-only list/show/get-url queries.";
854
+ }
855
+ return undefined;
856
+ }
857
+ if (!READ_ONLY_GIT_SUBCOMMANDS.has(parsed.subcommand)) {
858
+ return `Triage bash blocks git ${parsed.subcommand}; only known read-only git subcommands are allowed.`;
859
+ }
860
+ return undefined;
706
861
  }
707
862
 
708
863
  function getGhCommand(tokens: string[]): { command?: string; subcommand?: string } {
709
- let index = 1;
710
- while (index < tokens.length) {
711
- const token = tokens[index];
712
- if (!token.startsWith("-")) break;
713
- if (GH_GLOBAL_OPTIONS_WITH_VALUE.has(token)) {
714
- index += 2;
715
- continue;
716
- }
717
- index += 1;
718
- }
719
- return { command: tokens[index]?.toLowerCase(), subcommand: tokens[index + 1]?.toLowerCase() };
864
+ let index = 1;
865
+ while (index < tokens.length) {
866
+ const token = tokens[index];
867
+ if (!token.startsWith("-")) break;
868
+ if (GH_GLOBAL_OPTIONS_WITH_VALUE.has(token)) {
869
+ index += 2;
870
+ continue;
871
+ }
872
+ index += 1;
873
+ }
874
+ return { command: tokens[index]?.toLowerCase(), subcommand: tokens[index + 1]?.toLowerCase() };
720
875
  }
721
876
 
722
877
  function normalizeFlagValue(value: string | undefined): string | undefined {
723
- return value ? stripTokenQuotes(value).trim() : undefined;
878
+ return value ? stripTokenQuotes(value).trim() : undefined;
724
879
  }
725
880
 
726
881
  function getBlockedGhApiReason(tokens: string[]): string | undefined {
727
- const parsed = getGhCommand(tokens);
728
- if (parsed.command !== "api") return undefined;
729
- if (tokens.find((token) => token.toLowerCase() === "graphql")) {
730
- return "Triage bash blocks gh api graphql because it uses POST/body fields.";
731
- }
732
- for (let index = 1; index < tokens.length; index += 1) {
733
- const token = tokens[index];
734
- const lowerToken = token.toLowerCase();
735
- if (
736
- token === "-f" ||
737
- token === "-F" ||
738
- token === "--field" ||
739
- token === "--raw-field" ||
740
- token === "--input" ||
741
- lowerToken.startsWith("-f") ||
742
- lowerToken.startsWith("--field=") ||
743
- lowerToken.startsWith("--raw-field=") ||
744
- lowerToken.startsWith("--input=")
745
- ) {
746
- return "Triage bash allows read-only gh api calls only; request fields and input files are blocked.";
747
- }
748
-
749
- let method: string | undefined;
750
- if (lowerToken === "-x" || lowerToken === "--method") {
751
- method = normalizeFlagValue(tokens[index + 1]);
752
- } else if (lowerToken.startsWith("-x") && token.length > 2) {
753
- method = normalizeFlagValue(token.slice(2).replace(/^=/, ""));
754
- } else if (lowerToken.startsWith("--method=")) {
755
- method = normalizeFlagValue(token.slice("--method=".length));
756
- }
757
- if (method && MUTATING_GH_API_METHODS.has(method.toUpperCase())) {
758
- return "Triage bash allows read-only gh api calls only; mutating methods are blocked.";
759
- }
760
- }
761
- return undefined;
882
+ const parsed = getGhCommand(tokens);
883
+ if (parsed.command !== "api") return undefined;
884
+ if (tokens.find((token) => token.toLowerCase() === "graphql")) {
885
+ return "Triage bash blocks gh api graphql because it uses POST/body fields.";
886
+ }
887
+ for (let index = 1; index < tokens.length; index += 1) {
888
+ const token = tokens[index];
889
+ const lowerToken = token.toLowerCase();
890
+ if (
891
+ token === "-f" ||
892
+ token === "-F" ||
893
+ token === "--field" ||
894
+ token === "--raw-field" ||
895
+ token === "--input" ||
896
+ lowerToken.startsWith("-f") ||
897
+ lowerToken.startsWith("--field=") ||
898
+ lowerToken.startsWith("--raw-field=") ||
899
+ lowerToken.startsWith("--input=")
900
+ ) {
901
+ return "Triage bash allows read-only gh api calls only; request fields and input files are blocked.";
902
+ }
903
+
904
+ let method: string | undefined;
905
+ if (lowerToken === "-x" || lowerToken === "--method") {
906
+ method = normalizeFlagValue(tokens[index + 1]);
907
+ } else if (lowerToken.startsWith("-x") && token.length > 2) {
908
+ method = normalizeFlagValue(token.slice(2).replace(/^=/, ""));
909
+ } else if (lowerToken.startsWith("--method=")) {
910
+ method = normalizeFlagValue(token.slice("--method=".length));
911
+ }
912
+ if (method && MUTATING_GH_API_METHODS.has(method.toUpperCase())) {
913
+ return "Triage bash allows read-only gh api calls only; mutating methods are blocked.";
914
+ }
915
+ }
916
+ return undefined;
762
917
  }
763
918
 
764
919
  function isReadOnlyGhCommand(command: string | undefined, subcommand: string | undefined): boolean {
765
- if (!command) return true;
766
- if (command === "api") return true;
767
- if (command === "search") return true;
768
- if (command === "repo") return subcommand === "view" || subcommand === "list";
769
- if (command === "pr") return subcommand === "view" || subcommand === "list" || subcommand === "diff" || subcommand === "status" || subcommand === "checks";
770
- if (command === "issue") return subcommand === "view" || subcommand === "list" || subcommand === "status";
771
- if (command === "release") return subcommand === "view" || subcommand === "list";
772
- if (command === "run") return subcommand === "view" || subcommand === "list";
773
- if (command === "workflow") return subcommand === "view" || subcommand === "list";
774
- if (command === "label") return subcommand === "list";
775
- if (command === "milestone") return subcommand === "list";
776
- return false;
920
+ if (!command) return true;
921
+ if (command === "api") return true;
922
+ if (command === "search") return true;
923
+ if (command === "repo") return subcommand === "view" || subcommand === "list";
924
+ if (command === "pr")
925
+ return (
926
+ subcommand === "view" ||
927
+ subcommand === "list" ||
928
+ subcommand === "diff" ||
929
+ subcommand === "status" ||
930
+ subcommand === "checks"
931
+ );
932
+ if (command === "issue")
933
+ return subcommand === "view" || subcommand === "list" || subcommand === "status";
934
+ if (command === "release") return subcommand === "view" || subcommand === "list";
935
+ if (command === "run") return subcommand === "view" || subcommand === "list";
936
+ if (command === "workflow") return subcommand === "view" || subcommand === "list";
937
+ if (command === "label") return subcommand === "list";
938
+ if (command === "milestone") return subcommand === "list";
939
+ return false;
777
940
  }
778
941
 
779
942
  function getBlockedGhReason(command: string): string | undefined {
780
- const tokens = tokenizeSegment(command);
781
- if (getExecutableName(tokens[0] ?? "") !== "gh") return undefined;
782
- if (/\bgh\s+auth\s+(?:login|logout|refresh|setup-git|token)\b/i.test(command)) {
783
- return "Triage bash blocks gh auth commands and token inspection.";
784
- }
785
- const apiReason = getBlockedGhApiReason(tokens);
786
- if (apiReason) return apiReason;
787
- 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)) {
788
- return "Triage bash blocks mutating gh commands.";
789
- }
790
- const parsed = getGhCommand(tokens);
791
- if (!isReadOnlyGhCommand(parsed.command, parsed.subcommand)) {
792
- return `Triage bash blocks gh ${[parsed.command, parsed.subcommand].filter(Boolean).join(" ")}; only known read-only gh commands are allowed.`;
793
- }
794
- return undefined;
943
+ const tokens = tokenizeSegment(command);
944
+ if (getExecutableName(tokens[0] ?? "") !== "gh") return undefined;
945
+ if (/\bgh\s+auth\s+(?:login|logout|refresh|setup-git|token)\b/i.test(command)) {
946
+ return "Triage bash blocks gh auth commands and token inspection.";
947
+ }
948
+ const apiReason = getBlockedGhApiReason(tokens);
949
+ if (apiReason) return apiReason;
950
+ if (
951
+ /\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(
952
+ command,
953
+ )
954
+ ) {
955
+ return "Triage bash blocks mutating gh commands.";
956
+ }
957
+ const parsed = getGhCommand(tokens);
958
+ if (!isReadOnlyGhCommand(parsed.command, parsed.subcommand)) {
959
+ return `Triage bash blocks gh ${[parsed.command, parsed.subcommand].filter(Boolean).join(" ")}; only known read-only gh commands are allowed.`;
960
+ }
961
+ return undefined;
795
962
  }
796
963
 
797
964
  function getBlockedBashReason(command: string): string | undefined {
798
- const trimmed = command.trim();
799
- if (!trimmed) return "Triage bash requires a non-empty command.";
800
- if (/`|\$\(/.test(trimmed)) return "Triage bash blocks command substitution.";
801
- const syntaxReason = getShellSyntaxReason(trimmed);
802
- if (syntaxReason) return syntaxReason;
803
-
804
- const tokens = tokenizeSegment(trimmed);
805
- if (tokens.length === 0) return "Triage bash requires a non-empty command.";
806
- if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) return "Triage bash blocks inline environment assignment.";
807
- if (/[\\/]/.test(tokens[0])) {
808
- return "Triage bash requires direct git, gh, or pwd invocation without path-qualified executables.";
809
- }
810
-
811
- const executable = getExecutableName(tokens[0]);
812
- if (!READ_ONLY_BASH_COMMANDS.has(executable)) {
813
- return `Triage bash blocks ${executable || "this command"}; use built-in read/grep/find/ls for local files or read-only git/gh inspection commands.`;
814
- }
815
- if (executable === "pwd") return getBlockedPwdReason(tokens);
816
- if (executable === "git") return getBlockedGitReason(trimmed);
817
- if (executable === "gh") return getBlockedGhReason(trimmed);
818
- return undefined;
819
- }
820
-
821
- function createTriageRuntimeGuardExtension(options: { cwd: string; maxTurns: number }): ExtensionFactory {
822
- return (pi) => {
823
- let currentTurn = 0;
824
-
825
- pi.on("turn_start", async (event) => {
826
- currentTurn = event.turnIndex;
827
- });
828
-
829
- pi.on("tool_call", async (event) => {
830
- if (!READ_ONLY_TOOL_NAMES.has(event.toolName)) {
831
- return { block: true, reason: `triage_comments exposes read-only tools only; ${event.toolName} is not allowed.` };
832
- }
833
-
834
- if (currentTurn >= options.maxTurns - 1) {
835
- return {
836
- block: true,
837
- reason: `Tool use is disabled on final triage_comments turn ${options.maxTurns}/${options.maxTurns}. Answer now with the evidence already gathered.`,
838
- };
839
- }
840
-
841
- if (event.toolName === "read") {
842
- const reason = await assertToolPathInsideCwd(options.cwd, (event.input as { path?: unknown }).path, "read");
843
- if (reason) return { block: true, reason };
844
- }
845
-
846
- if (event.toolName === "grep" || event.toolName === "find" || event.toolName === "ls") {
847
- const reason = await assertToolPathInsideCwd(options.cwd, (event.input as { path?: unknown }).path, event.toolName);
848
- if (reason) return { block: true, reason };
849
- if (event.toolName === "grep") {
850
- const globReason = getUnsafePathPatternReason((event.input as { glob?: unknown }).glob, "grep glob");
851
- if (globReason) return { block: true, reason: globReason };
852
- }
853
- if (event.toolName === "find") {
854
- const patternReason = getUnsafePathPatternReason((event.input as { pattern?: unknown }).pattern, "find pattern");
855
- if (patternReason) return { block: true, reason: patternReason };
856
- }
857
- }
858
-
859
- if (event.toolName === "bash") {
860
- const input = event.input as { command?: unknown; timeout?: unknown };
861
- if (typeof input.timeout !== "number") input.timeout = DEFAULT_BASH_TIMEOUT_SECONDS;
862
- const command = typeof input.command === "string" ? input.command : "";
863
- const reason = getBlockedBashReason(command);
864
- if (reason) return { block: true, reason };
865
- }
866
-
867
- return undefined;
868
- });
869
-
870
- pi.on("tool_result", async (event) => ({
871
- content: [
872
- ...(event.content ?? []),
873
- {
874
- type: "text",
875
- text: `\n\n[triage_comments turn budget] turn ${Math.min(currentTurn + 1, options.maxTurns)}/${options.maxTurns}`,
876
- },
877
- ],
878
- }));
879
- };
965
+ const trimmed = command.trim();
966
+ if (!trimmed) return "Triage bash requires a non-empty command.";
967
+ if (/`|\$\(/.test(trimmed)) return "Triage bash blocks command substitution.";
968
+ const syntaxReason = getShellSyntaxReason(trimmed);
969
+ if (syntaxReason) return syntaxReason;
970
+
971
+ const tokens = tokenizeSegment(trimmed);
972
+ if (tokens.length === 0) return "Triage bash requires a non-empty command.";
973
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0]))
974
+ return "Triage bash blocks inline environment assignment.";
975
+ if (/[\\/]/.test(tokens[0])) {
976
+ return "Triage bash requires direct git, gh, or pwd invocation without path-qualified executables.";
977
+ }
978
+
979
+ const executable = getExecutableName(tokens[0]);
980
+ if (!READ_ONLY_BASH_COMMANDS.has(executable)) {
981
+ return `Triage bash blocks ${executable || "this command"}; use built-in read/grep/find/ls for local files or read-only git/gh inspection commands.`;
982
+ }
983
+ if (executable === "pwd") return getBlockedPwdReason(tokens);
984
+ if (executable === "git") return getBlockedGitReason(trimmed);
985
+ if (executable === "gh") return getBlockedGhReason(trimmed);
986
+ return undefined;
987
+ }
988
+
989
+ function createTriageRuntimeGuardExtension(options: {
990
+ cwd: string;
991
+ maxTurns: number;
992
+ }): ExtensionFactory {
993
+ return (pi) => {
994
+ let currentTurn = 0;
995
+
996
+ pi.on("turn_start", async (event) => {
997
+ currentTurn = event.turnIndex;
998
+ });
999
+
1000
+ pi.on("tool_call", async (event) => {
1001
+ if (!READ_ONLY_TOOL_NAMES.has(event.toolName)) {
1002
+ return {
1003
+ block: true,
1004
+ reason: `triage_comments exposes read-only tools only; ${event.toolName} is not allowed.`,
1005
+ };
1006
+ }
1007
+
1008
+ if (currentTurn >= options.maxTurns - 1) {
1009
+ return {
1010
+ block: true,
1011
+ reason: `Tool use is disabled on final triage_comments turn ${options.maxTurns}/${options.maxTurns}. Answer now with the evidence already gathered.`,
1012
+ };
1013
+ }
1014
+
1015
+ if (event.toolName === "read") {
1016
+ const reason = await assertToolPathInsideCwd(
1017
+ options.cwd,
1018
+ (event.input as { path?: unknown }).path,
1019
+ "read",
1020
+ );
1021
+ if (reason) return { block: true, reason };
1022
+ }
1023
+
1024
+ if (event.toolName === "grep" || event.toolName === "find" || event.toolName === "ls") {
1025
+ const reason = await assertToolPathInsideCwd(
1026
+ options.cwd,
1027
+ (event.input as { path?: unknown }).path,
1028
+ event.toolName,
1029
+ );
1030
+ if (reason) return { block: true, reason };
1031
+ if (event.toolName === "grep") {
1032
+ const globReason = getUnsafePathPatternReason(
1033
+ (event.input as { glob?: unknown }).glob,
1034
+ "grep glob",
1035
+ );
1036
+ if (globReason) return { block: true, reason: globReason };
1037
+ }
1038
+ if (event.toolName === "find") {
1039
+ const patternReason = getUnsafePathPatternReason(
1040
+ (event.input as { pattern?: unknown }).pattern,
1041
+ "find pattern",
1042
+ );
1043
+ if (patternReason) return { block: true, reason: patternReason };
1044
+ }
1045
+ }
1046
+
1047
+ if (event.toolName === "bash") {
1048
+ const input = event.input as { command?: unknown; timeout?: unknown };
1049
+ if (typeof input.timeout !== "number") input.timeout = DEFAULT_BASH_TIMEOUT_SECONDS;
1050
+ const command = typeof input.command === "string" ? input.command : "";
1051
+ const reason = getBlockedBashReason(command);
1052
+ if (reason) return { block: true, reason };
1053
+ }
1054
+
1055
+ return undefined;
1056
+ });
1057
+
1058
+ pi.on("tool_result", async (event) => ({
1059
+ content: [
1060
+ ...(event.content ?? []),
1061
+ {
1062
+ type: "text",
1063
+ text: `\n\n[triage_comments turn budget] turn ${Math.min(currentTurn + 1, options.maxTurns)}/${options.maxTurns}`,
1064
+ },
1065
+ ],
1066
+ }));
1067
+ };
880
1068
  }
881
1069
 
882
1070
  function formatToolCall(call: ToolCall): string {
883
- const args = call.args && typeof call.args === "object" ? (call.args as Record<string, unknown>) : {};
884
- if (call.name === "read") {
885
- const readPath = typeof args.path === "string" ? args.path : "";
886
- const offset = typeof args.offset === "number" ? args.offset : undefined;
887
- const limit = typeof args.limit === "number" ? args.limit : undefined;
888
- const range = offset || limit ? `:${offset ?? 1}${limit ? `-${(offset ?? 1) + limit - 1}` : ""}` : "";
889
- return `read ${readPath}${range}`.trim();
890
- }
891
- if (call.name === "grep") {
892
- const pattern = typeof args.pattern === "string" ? args.pattern : "";
893
- const grepPath = typeof args.path === "string" ? args.path : ".";
894
- return `grep ${shorten(pattern, 50)} ${grepPath}`.trim();
895
- }
896
- if (call.name === "find") {
897
- const pattern = typeof args.pattern === "string" ? args.pattern : "";
898
- const findPath = typeof args.path === "string" ? args.path : ".";
899
- return `find ${shorten(pattern, 50)} ${findPath}`.trim();
900
- }
901
- if (call.name === "ls") {
902
- return `ls ${typeof args.path === "string" ? args.path : "."}`.trim();
903
- }
904
- if (call.name === "bash") {
905
- const command = typeof args.command === "string" ? args.command : "";
906
- return `bash ${shorten(command, 120)}`.trim();
907
- }
908
- return call.name;
1071
+ const args =
1072
+ call.args && typeof call.args === "object" ? (call.args as Record<string, unknown>) : {};
1073
+ if (call.name === "read") {
1074
+ const readPath = typeof args.path === "string" ? args.path : "";
1075
+ const offset = typeof args.offset === "number" ? args.offset : undefined;
1076
+ const limit = typeof args.limit === "number" ? args.limit : undefined;
1077
+ const range =
1078
+ offset || limit ? `:${offset ?? 1}${limit ? `-${(offset ?? 1) + limit - 1}` : ""}` : "";
1079
+ return `read ${readPath}${range}`.trim();
1080
+ }
1081
+ if (call.name === "grep") {
1082
+ const pattern = typeof args.pattern === "string" ? args.pattern : "";
1083
+ const grepPath = typeof args.path === "string" ? args.path : ".";
1084
+ return `grep ${shorten(pattern, 50)} ${grepPath}`.trim();
1085
+ }
1086
+ if (call.name === "find") {
1087
+ const pattern = typeof args.pattern === "string" ? args.pattern : "";
1088
+ const findPath = typeof args.path === "string" ? args.path : ".";
1089
+ return `find ${shorten(pattern, 50)} ${findPath}`.trim();
1090
+ }
1091
+ if (call.name === "ls") {
1092
+ return `ls ${typeof args.path === "string" ? args.path : "."}`.trim();
1093
+ }
1094
+ if (call.name === "bash") {
1095
+ const command = typeof args.command === "string" ? args.command : "";
1096
+ return `bash ${shorten(command, 120)}`.trim();
1097
+ }
1098
+ return call.name;
909
1099
  }
910
1100
 
911
1101
  function renderAnswer(details: TriageDetails): string {
912
- if (details.error) return details.error;
913
- return details.status === "running" ? "(triaging comments...)" : "(no output)";
1102
+ if (details.error) return details.error;
1103
+ return details.status === "running" ? "(triaging comments...)" : "(no output)";
914
1104
  }
915
1105
 
916
- type TriageCommandMode = 'paste' | 'pr';
1106
+ type TriageCommandMode = "paste" | "pr";
917
1107
 
918
1108
  type ParsedTriageCommandArgs = {
919
- mode?: TriageCommandMode;
920
- target?: string;
921
- pastePrefill?: string;
922
- help?: boolean;
923
- error?: string;
1109
+ mode?: TriageCommandMode;
1110
+ target?: string;
1111
+ pastePrefill?: string;
1112
+ help?: boolean;
1113
+ error?: string;
924
1114
  };
925
1115
 
926
1116
  type PullRequestUrlParts = {
927
- host: string;
928
- owner: string;
929
- repo: string;
930
- number: number;
1117
+ host: string;
1118
+ owner: string;
1119
+ repo: string;
1120
+ number: number;
931
1121
  };
932
1122
 
933
1123
  type PullRequestContext = {
934
- number: number;
935
- title?: string;
936
- url?: string;
937
- repository: string;
938
- host?: string;
939
- headRef?: string;
940
- headSha?: string;
941
- baseRef?: string;
942
- baseSha?: string;
1124
+ number: number;
1125
+ title?: string;
1126
+ url?: string;
1127
+ repository: string;
1128
+ host?: string;
1129
+ headRef?: string;
1130
+ headSha?: string;
1131
+ baseRef?: string;
1132
+ baseSha?: string;
943
1133
  };
944
1134
 
945
1135
  type ReviewThreadState = {
946
- threadId?: string;
947
- isResolved?: boolean;
948
- isOutdated?: boolean;
949
- metadataAvailable: boolean;
1136
+ threadId?: string;
1137
+ isResolved?: boolean;
1138
+ isOutdated?: boolean;
1139
+ metadataAvailable: boolean;
950
1140
  };
951
1141
 
952
1142
  type ReviewThreadStateLookup = {
953
- byDatabaseId: Map<number, ReviewThreadState>;
954
- byNodeId: Map<string, ReviewThreadState>;
1143
+ byDatabaseId: Map<number, ReviewThreadState>;
1144
+ byNodeId: Map<string, ReviewThreadState>;
955
1145
  };
956
1146
 
957
1147
  type InlineCommentFilter = {
958
- hideResolved: boolean;
959
- hideOutdated: boolean;
960
- label: string;
1148
+ hideResolved: boolean;
1149
+ hideOutdated: boolean;
1150
+ label: string;
961
1151
  };
962
1152
 
963
1153
  type AppliedInlineCommentFilter = {
964
- filter: InlineCommentFilter;
965
- originalCount: number;
966
- displayedCount: number;
967
- hiddenInlineCount: number;
968
- hiddenResolvedInlineCount: number;
969
- hiddenOutdatedInlineCount: number;
970
- keptInlineWithoutThreadMetadataCount: number;
1154
+ filter: InlineCommentFilter;
1155
+ originalCount: number;
1156
+ displayedCount: number;
1157
+ hiddenInlineCount: number;
1158
+ hiddenResolvedInlineCount: number;
1159
+ hiddenOutdatedInlineCount: number;
1160
+ keptInlineWithoutThreadMetadataCount: number;
971
1161
  };
972
1162
 
973
1163
  type CommandComment = {
974
- id: string;
975
- body: string;
976
- path?: string;
977
- line?: number;
978
- startLine?: number;
979
- side?: string;
980
- diffHunk?: string;
981
- author?: string;
982
- url?: string;
983
- createdAt?: string;
984
- reviewThread?: ReviewThreadState;
985
- metadata: Record<string, unknown>;
986
- sourceLabel: string;
987
- displayNumber?: number;
988
- sortTimestamp?: string;
989
- sortIndex: number;
1164
+ id: string;
1165
+ body: string;
1166
+ path?: string;
1167
+ line?: number;
1168
+ startLine?: number;
1169
+ side?: string;
1170
+ diffHunk?: string;
1171
+ author?: string;
1172
+ url?: string;
1173
+ createdAt?: string;
1174
+ reviewThread?: ReviewThreadState;
1175
+ metadata: Record<string, unknown>;
1176
+ sourceLabel: string;
1177
+ displayNumber?: number;
1178
+ sortTimestamp?: string;
1179
+ sortIndex: number;
990
1180
  };
991
1181
 
992
1182
  type SelectionParseResult = { ok: true; indices: number[] } | { ok: false; error: string };
993
1183
 
994
1184
  type TriageCommandPayload = {
995
- comments: Record<string, unknown>[];
996
- pr?: Record<string, unknown>;
997
- base?: Record<string, unknown>;
998
- context?: string;
1185
+ comments: Record<string, unknown>[];
1186
+ pr?: Record<string, unknown>;
1187
+ base?: Record<string, unknown>;
1188
+ context?: string;
999
1189
  };
1000
1190
 
1001
1191
  function notifyCommand(
1002
- ctx: ExtensionCommandContext,
1003
- message: string,
1004
- type: 'info' | 'warning' | 'error' = 'info',
1192
+ ctx: ExtensionCommandContext,
1193
+ message: string,
1194
+ type: "info" | "warning" | "error" = "info",
1005
1195
  ): void {
1006
- if (ctx.hasUI) {
1007
- ctx.ui.notify(message, type);
1008
- return;
1009
- }
1196
+ if (ctx.hasUI) {
1197
+ ctx.ui.notify(message, type);
1198
+ return;
1199
+ }
1010
1200
 
1011
- const writer = type === 'error' ? console.error : console.log;
1012
- writer(message);
1201
+ const writer = type === "error" ? console.error : console.log;
1202
+ writer(message);
1013
1203
  }
1014
1204
 
1015
1205
  function formatErrorMessage(error: unknown): string {
1016
- return error instanceof Error ? error.message : String(error);
1206
+ return error instanceof Error ? error.message : String(error);
1017
1207
  }
1018
1208
 
1019
1209
  function compactRecord(record: Record<string, unknown>): Record<string, unknown> {
1020
- const compacted: Record<string, unknown> = {};
1021
- for (const [key, value] of Object.entries(record)) {
1022
- if (value === undefined || value === null) continue;
1023
- if (typeof value === 'string' && value.trim() === '') continue;
1024
- if (Array.isArray(value) && value.length === 0) continue;
1025
- if (value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value as Record<string, unknown>).length === 0) {
1026
- continue;
1027
- }
1028
- compacted[key] = value;
1029
- }
1030
- return compacted;
1210
+ const compacted: Record<string, unknown> = {};
1211
+ for (const [key, value] of Object.entries(record)) {
1212
+ if (value === undefined || value === null) continue;
1213
+ if (typeof value === "string" && value.trim() === "") continue;
1214
+ if (Array.isArray(value) && value.length === 0) continue;
1215
+ if (
1216
+ value &&
1217
+ typeof value === "object" &&
1218
+ !Array.isArray(value) &&
1219
+ Object.keys(value as Record<string, unknown>).length === 0
1220
+ ) {
1221
+ continue;
1222
+ }
1223
+ compacted[key] = value;
1224
+ }
1225
+ return compacted;
1031
1226
  }
1032
1227
 
1033
1228
  function optionalRecord(record: Record<string, unknown>): Record<string, unknown> | undefined {
1034
- const compacted = compactRecord(record);
1035
- return Object.keys(compacted).length > 0 ? compacted : undefined;
1229
+ const compacted = compactRecord(record);
1230
+ return Object.keys(compacted).length > 0 ? compacted : undefined;
1036
1231
  }
1037
1232
 
1038
1233
  function asStableIdPart(...values: unknown[]): string | undefined {
1039
- for (const value of values) {
1040
- if (typeof value === 'number' && Number.isFinite(value)) return String(value);
1041
- const trimmed = asTrimmedString(value);
1042
- if (trimmed) return trimmed;
1043
- }
1044
- return undefined;
1234
+ for (const value of values) {
1235
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
1236
+ const trimmed = asTrimmedString(value);
1237
+ if (trimmed) return trimmed;
1238
+ }
1239
+ return undefined;
1045
1240
  }
1046
1241
 
1047
1242
  function parsePullRequestUrl(raw: string): PullRequestUrlParts | undefined {
1048
- const trimmed = raw.trim();
1049
- if (!trimmed) return undefined;
1050
-
1051
- try {
1052
- const url = new URL(trimmed);
1053
- if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined;
1054
- const segments = url.pathname.split('/').filter(Boolean).map((segment) => decodeURIComponent(segment));
1055
- if (segments.length < 4 || segments[2] !== 'pull') return undefined;
1056
- const number = Number(segments[3]);
1057
- if (!Number.isInteger(number) || number <= 0) return undefined;
1058
- return {
1059
- host: url.hostname.replace(/^www\./i, ''),
1060
- owner: segments[0],
1061
- repo: segments[1],
1062
- number,
1063
- };
1064
- } catch {
1065
- return undefined;
1066
- }
1243
+ const trimmed = raw.trim();
1244
+ if (!trimmed) return undefined;
1245
+
1246
+ try {
1247
+ const url = new URL(trimmed);
1248
+ if (url.protocol !== "https:" && url.protocol !== "http:") return undefined;
1249
+ const segments = url.pathname
1250
+ .split("/")
1251
+ .filter(Boolean)
1252
+ .map((segment) => decodeURIComponent(segment));
1253
+ if (segments.length < 4 || segments[2] !== "pull") return undefined;
1254
+ const number = Number(segments[3]);
1255
+ if (!Number.isInteger(number) || number <= 0) return undefined;
1256
+ return {
1257
+ host: url.hostname.replace(/^www\./i, ""),
1258
+ owner: segments[0],
1259
+ repo: segments[1],
1260
+ number,
1261
+ };
1262
+ } catch {
1263
+ return undefined;
1264
+ }
1067
1265
  }
1068
1266
 
1069
1267
  function looksLikePrTarget(value: string): boolean {
1070
- const trimmed = value.trim();
1071
- return /^#?\d+$/.test(trimmed) || Boolean(parsePullRequestUrl(trimmed));
1268
+ const trimmed = value.trim();
1269
+ return /^#?\d+$/.test(trimmed) || Boolean(parsePullRequestUrl(trimmed));
1072
1270
  }
1073
1271
 
1074
1272
  function normalizePrTarget(value: string): string | undefined {
1075
- const trimmed = value.trim();
1076
- if (!trimmed) return undefined;
1077
- if (/^#?\d+$/.test(trimmed)) return trimmed.replace(/^#/, '');
1078
- if (parsePullRequestUrl(trimmed)) return trimmed;
1079
- return undefined;
1273
+ const trimmed = value.trim();
1274
+ if (!trimmed) return undefined;
1275
+ if (/^#?\d+$/.test(trimmed)) return trimmed.replace(/^#/, "");
1276
+ if (parsePullRequestUrl(trimmed)) return trimmed;
1277
+ return undefined;
1080
1278
  }
1081
1279
 
1082
1280
  function parseTriageCommandArgs(args: string): ParsedTriageCommandArgs {
1083
- const trimmed = args.trim();
1084
- if (!trimmed) return {};
1281
+ const trimmed = args.trim();
1282
+ if (!trimmed) return {};
1085
1283
 
1086
- const [first = ''] = trimmed.split(/\s+/, 1);
1087
- const normalized = first.toLowerCase();
1088
- const rest = trimmed.slice(first.length).trim();
1284
+ const [first = ""] = trimmed.split(/\s+/, 1);
1285
+ const normalized = first.toLowerCase();
1286
+ const rest = trimmed.slice(first.length).trim();
1089
1287
 
1090
- if (normalized === '--help' || normalized === '-h' || normalized === 'help') return { help: true };
1091
- if (normalized === 'paste' || normalized === 'manual') return { mode: 'paste', pastePrefill: rest || undefined };
1092
- if (normalized === 'pr' || normalized === 'pull' || normalized === 'pull-request') return { mode: 'pr', target: rest || undefined };
1093
- if (looksLikePrTarget(trimmed)) return { mode: 'pr', target: trimmed };
1288
+ if (normalized === "--help" || normalized === "-h" || normalized === "help")
1289
+ return { help: true };
1290
+ if (normalized === "paste" || normalized === "manual")
1291
+ return { mode: "paste", pastePrefill: rest || undefined };
1292
+ if (normalized === "pr" || normalized === "pull" || normalized === "pull-request")
1293
+ return { mode: "pr", target: rest || undefined };
1294
+ if (looksLikePrTarget(trimmed)) return { mode: "pr", target: trimmed };
1094
1295
 
1095
- return { error: `Unknown /triage-comments option: ${first}\n${TRIAGE_COMMAND_USAGE}` };
1296
+ return { error: `Unknown /triage-comments option: ${first}\n${TRIAGE_COMMAND_USAGE}` };
1096
1297
  }
1097
1298
 
1098
1299
  function formatExecFailure(result: ExecResult): string {
1099
- const details = (result.stderr || result.stdout).trim();
1100
- if (!details) return `exit code ${result.code}`;
1101
- const lines = details.split('\n').filter(Boolean);
1102
- return lines.slice(-6).join('\n');
1300
+ const details = (result.stderr || result.stdout).trim();
1301
+ if (!details) return `exit code ${result.code}`;
1302
+ const lines = details.split("\n").filter(Boolean);
1303
+ return lines.slice(-6).join("\n");
1103
1304
  }
1104
1305
 
1105
1306
  async function execChecked(
1106
- pi: ExtensionAPI,
1107
- ctx: ExtensionCommandContext,
1108
- command: string,
1109
- args: string[],
1110
- label: string,
1307
+ pi: ExtensionAPI,
1308
+ ctx: ExtensionCommandContext,
1309
+ command: string,
1310
+ args: string[],
1311
+ label: string,
1111
1312
  ): Promise<string> {
1112
- const result = await pi.exec(command, args, { cwd: ctx.cwd, signal: ctx.signal, timeout: GH_COMMAND_TIMEOUT_MS });
1113
- if (result.killed) throw new Error(`${label} timed out or was cancelled.`);
1114
- if (result.code !== 0) throw new Error(`${label} failed: ${formatExecFailure(result)}`);
1115
- return result.stdout;
1313
+ const result = await pi.exec(command, args, {
1314
+ cwd: ctx.cwd,
1315
+ signal: ctx.signal,
1316
+ timeout: GH_COMMAND_TIMEOUT_MS,
1317
+ });
1318
+ if (result.killed) throw new Error(`${label} timed out or was cancelled.`);
1319
+ if (result.code !== 0) throw new Error(`${label} failed: ${formatExecFailure(result)}`);
1320
+ return result.stdout;
1116
1321
  }
1117
1322
 
1118
1323
  async function assertGitRepo(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
1119
- const result = await pi.exec('git', ['rev-parse', '--show-toplevel'], {
1120
- cwd: ctx.cwd,
1121
- signal: ctx.signal,
1122
- timeout: GH_COMMAND_TIMEOUT_MS,
1123
- });
1124
- if (result.killed) throw new Error('Checking the git repository timed out or was cancelled.');
1125
- if (result.code !== 0) {
1126
- throw new Error('/triage-comments PR mode must run inside a git repository. Open The Last Harness in a checkout and retry.');
1127
- }
1324
+ const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], {
1325
+ cwd: ctx.cwd,
1326
+ signal: ctx.signal,
1327
+ timeout: GH_COMMAND_TIMEOUT_MS,
1328
+ });
1329
+ if (result.killed) throw new Error("Checking the git repository timed out or was cancelled.");
1330
+ if (result.code !== 0) {
1331
+ throw new Error(
1332
+ "/triage-comments PR mode must run inside a git repository. Run /triage-comments in a checkout and retry.",
1333
+ );
1334
+ }
1128
1335
  }
1129
1336
 
1130
1337
  async function assertGhReady(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
1131
- const version = await pi.exec('gh', ['--version'], { cwd: ctx.cwd, signal: ctx.signal, timeout: GH_COMMAND_TIMEOUT_MS });
1132
- if (version.killed) throw new Error('Checking GitHub CLI availability timed out or was cancelled.');
1133
- if (version.code !== 0) {
1134
- 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`.');
1135
- }
1338
+ const version = await pi.exec("gh", ["--version"], {
1339
+ cwd: ctx.cwd,
1340
+ signal: ctx.signal,
1341
+ timeout: GH_COMMAND_TIMEOUT_MS,
1342
+ });
1343
+ if (version.killed)
1344
+ throw new Error("Checking GitHub CLI availability timed out or was cancelled.");
1345
+ if (version.code !== 0) {
1346
+ throw new Error(
1347
+ "GitHub CLI `gh` is required for PR mode but was not found or could not run. Install `gh`, then authenticate with `gh auth login`.",
1348
+ );
1349
+ }
1136
1350
 
1137
- const auth = await pi.exec('gh', ['auth', 'status'], { cwd: ctx.cwd, signal: ctx.signal, timeout: GH_COMMAND_TIMEOUT_MS });
1138
- if (auth.killed) throw new Error('Checking GitHub CLI authentication timed out or was cancelled.');
1139
- if (auth.code !== 0) {
1140
- throw new Error(`GitHub CLI ` + '`gh`' + ` is not authenticated. Run ` + '`gh auth login`' + ` and retry. Details: ${formatExecFailure(auth)}`);
1141
- }
1351
+ const auth = await pi.exec("gh", ["auth", "status"], {
1352
+ cwd: ctx.cwd,
1353
+ signal: ctx.signal,
1354
+ timeout: GH_COMMAND_TIMEOUT_MS,
1355
+ });
1356
+ if (auth.killed)
1357
+ throw new Error("Checking GitHub CLI authentication timed out or was cancelled.");
1358
+ if (auth.code !== 0) {
1359
+ throw new Error(
1360
+ `GitHub CLI ` +
1361
+ "`gh`" +
1362
+ ` is not authenticated. Run ` +
1363
+ "`gh auth login`" +
1364
+ ` and retry. Details: ${formatExecFailure(auth)}`,
1365
+ );
1366
+ }
1142
1367
  }
1143
1368
 
1144
1369
  function parseJsonOutput(stdout: string, label: string): unknown {
1145
- const trimmed = stdout.trim();
1146
- if (!trimmed) throw new Error(`${label} returned empty output; expected JSON.`);
1147
- try {
1148
- return JSON.parse(trimmed);
1149
- } catch (error) {
1150
- throw new Error(`${label} returned invalid JSON: ${formatErrorMessage(error)}`);
1151
- }
1370
+ const trimmed = stdout.trim();
1371
+ if (!trimmed) throw new Error(`${label} returned empty output; expected JSON.`);
1372
+ try {
1373
+ return JSON.parse(trimmed);
1374
+ } catch (error) {
1375
+ throw new Error(`${label} returned invalid JSON: ${formatErrorMessage(error)}`);
1376
+ }
1152
1377
  }
1153
1378
 
1154
1379
  function flattenGhArray(value: unknown, label: string): unknown[] {
1155
- if (!Array.isArray(value)) throw new Error(`${label} returned JSON that was not an array.`);
1156
- if (value.every((item) => Array.isArray(item))) return value.flatMap((item) => item as unknown[]);
1157
- return value;
1380
+ if (!Array.isArray(value)) throw new Error(`${label} returned JSON that was not an array.`);
1381
+ if (value.every((item) => Array.isArray(item))) return value.flatMap((item) => item as unknown[]);
1382
+ return value;
1158
1383
  }
1159
1384
 
1160
1385
  function normalizePullRequestContext(raw: unknown, target: string): PullRequestContext {
1161
- const record = asRecord(raw);
1162
- if (!record) throw new Error('Could not parse PR metadata from `gh pr view`: expected a JSON object.');
1163
-
1164
- const number = asFiniteNumber(record.number) ?? parsePullRequestUrl(target)?.number;
1165
- const url = asTrimmedString(record.url);
1166
- const parsedUrl = (url ? parsePullRequestUrl(url) : undefined) ?? parsePullRequestUrl(target);
1167
- if (!number || !Number.isInteger(number) || number <= 0) {
1168
- throw new Error('Could not parse the PR number from `gh pr view` output.');
1169
- }
1170
- if (!parsedUrl) {
1171
- 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.');
1172
- }
1173
-
1174
- return {
1175
- number,
1176
- title: asTrimmedString(record.title),
1177
- url,
1178
- repository: `${parsedUrl.owner}/${parsedUrl.repo}`,
1179
- host: parsedUrl.host,
1180
- headRef: asTrimmedString(record.headRefName ?? record.headRef),
1181
- headSha: asTrimmedString(record.headRefOid ?? record.headSha),
1182
- baseRef: asTrimmedString(record.baseRefName ?? record.baseRef),
1183
- baseSha: asTrimmedString(record.baseRefOid ?? record.baseSha),
1184
- };
1386
+ const record = asRecord(raw);
1387
+ if (!record)
1388
+ throw new Error("Could not parse PR metadata from `gh pr view`: expected a JSON object.");
1389
+
1390
+ const number = asFiniteNumber(record.number) ?? parsePullRequestUrl(target)?.number;
1391
+ const url = asTrimmedString(record.url);
1392
+ const parsedUrl = (url ? parsePullRequestUrl(url) : undefined) ?? parsePullRequestUrl(target);
1393
+ if (!number || !Number.isInteger(number) || number <= 0) {
1394
+ throw new Error("Could not parse the PR number from `gh pr view` output.");
1395
+ }
1396
+ if (!parsedUrl) {
1397
+ throw new Error(
1398
+ "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.",
1399
+ );
1400
+ }
1401
+
1402
+ return {
1403
+ number,
1404
+ title: asTrimmedString(record.title),
1405
+ url,
1406
+ repository: `${parsedUrl.owner}/${parsedUrl.repo}`,
1407
+ host: parsedUrl.host,
1408
+ headRef: asTrimmedString(record.headRefName ?? record.headRef),
1409
+ headSha: asTrimmedString(record.headRefOid ?? record.headSha),
1410
+ baseRef: asTrimmedString(record.baseRefName ?? record.baseRef),
1411
+ baseSha: asTrimmedString(record.baseRefOid ?? record.baseSha),
1412
+ };
1185
1413
  }
1186
1414
 
1187
1415
  async function ghApiArray(
1188
- pi: ExtensionAPI,
1189
- ctx: ExtensionCommandContext,
1190
- pr: PullRequestContext,
1191
- endpointSuffix: string,
1192
- label: string,
1416
+ pi: ExtensionAPI,
1417
+ ctx: ExtensionCommandContext,
1418
+ pr: PullRequestContext,
1419
+ endpointSuffix: string,
1420
+ label: string,
1193
1421
  ): Promise<unknown[]> {
1194
- const [owner, repo] = pr.repository.split('/');
1195
- if (!owner || !repo) throw new Error(`Could not build GitHub API path from repository ${pr.repository}.`);
1196
- const endpoint = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${endpointSuffix}`;
1197
- const args = ['api'];
1198
- if (pr.host) args.push('--hostname', pr.host);
1199
- args.push('--paginate', '--slurp', endpoint);
1200
- const stdout = await execChecked(pi, ctx, 'gh', args, `Fetching ${label} with gh`);
1201
- return flattenGhArray(parseJsonOutput(stdout, `Fetching ${label} with gh`), label);
1422
+ const [owner, repo] = pr.repository.split("/");
1423
+ if (!owner || !repo)
1424
+ throw new Error(`Could not build GitHub API path from repository ${pr.repository}.`);
1425
+ const endpoint = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${endpointSuffix}`;
1426
+ const args = ["api"];
1427
+ if (pr.host) args.push("--hostname", pr.host);
1428
+ args.push("--paginate", "--slurp", endpoint);
1429
+ const stdout = await execChecked(pi, ctx, "gh", args, `Fetching ${label} with gh`);
1430
+ return flattenGhArray(parseJsonOutput(stdout, `Fetching ${label} with gh`), label);
1202
1431
  }
1203
1432
 
1204
1433
  function githubLogin(value: unknown): string | undefined {
1205
- if (typeof value === 'string') return asTrimmedString(value);
1206
- const record = asRecord(value);
1207
- return record ? asTrimmedString(record.login ?? record.name) : undefined;
1434
+ if (typeof value === "string") return asTrimmedString(value);
1435
+ const record = asRecord(value);
1436
+ return record ? asTrimmedString(record.login ?? record.name) : undefined;
1208
1437
  }
1209
1438
 
1210
1439
  function reviewHtmlUrl(record: Record<string, unknown>): string | undefined {
1211
- const links = asRecord(record._links);
1212
- const html = asRecord(links?.html);
1213
- return asTrimmedString(record.html_url ?? record.htmlUrl ?? html?.href);
1440
+ const links = asRecord(record._links);
1441
+ const html = asRecord(links?.html);
1442
+ return asTrimmedString(record.html_url ?? record.htmlUrl ?? html?.href);
1214
1443
  }
1215
1444
 
1216
1445
  function createReviewThreadStateLookup(): ReviewThreadStateLookup {
1217
- return { byDatabaseId: new Map(), byNodeId: new Map() };
1446
+ return { byDatabaseId: new Map(), byNodeId: new Map() };
1218
1447
  }
1219
1448
 
1220
- function reviewThreadStateForComment(raw: unknown, lookup: ReviewThreadStateLookup): ReviewThreadState {
1221
- const record = asRecord(raw);
1222
- const databaseId = asFiniteNumber(record?.id);
1223
- const nodeId = asTrimmedString(record?.node_id ?? record?.nodeId);
1224
- return (
1225
- (databaseId !== undefined ? lookup.byDatabaseId.get(databaseId) : undefined) ??
1226
- (nodeId ? lookup.byNodeId.get(nodeId) : undefined) ??
1227
- { metadataAvailable: false }
1228
- );
1449
+ function reviewThreadStateForComment(
1450
+ raw: unknown,
1451
+ lookup: ReviewThreadStateLookup,
1452
+ ): ReviewThreadState {
1453
+ const record = asRecord(raw);
1454
+ const databaseId = asFiniteNumber(record?.id);
1455
+ const nodeId = asTrimmedString(record?.node_id ?? record?.nodeId);
1456
+ return (
1457
+ (databaseId !== undefined ? lookup.byDatabaseId.get(databaseId) : undefined) ??
1458
+ (nodeId ? lookup.byNodeId.get(nodeId) : undefined) ?? { metadataAvailable: false }
1459
+ );
1229
1460
  }
1230
1461
 
1231
1462
  async function fetchReviewThreadStateLookup(
1232
- pi: ExtensionAPI,
1233
- ctx: ExtensionCommandContext,
1234
- pr: PullRequestContext,
1463
+ pi: ExtensionAPI,
1464
+ ctx: ExtensionCommandContext,
1465
+ pr: PullRequestContext,
1235
1466
  ): Promise<ReviewThreadStateLookup> {
1236
- const [owner, repo] = pr.repository.split('/');
1237
- if (!owner || !repo) throw new Error(`Could not build GitHub GraphQL variables from repository ${pr.repository}.`);
1238
-
1239
- const lookup = createReviewThreadStateLookup();
1240
- let after: string | undefined;
1241
-
1242
- while (true) {
1243
- const args = ['api'];
1244
- if (pr.host) args.push('--hostname', pr.host);
1245
- args.push(
1246
- 'graphql',
1247
- '-f',
1248
- `owner=${owner}`,
1249
- '-f',
1250
- `name=${repo}`,
1251
- '-F',
1252
- `number=${pr.number}`,
1253
- '-f',
1254
- `query=${GH_REVIEW_THREADS_GRAPHQL_QUERY}`,
1255
- );
1256
- if (after) args.push('-f', `after=${after}`);
1257
-
1258
- const label = 'Fetching review thread resolved/outdated metadata with gh';
1259
- const stdout = await execChecked(pi, ctx, 'gh', args, label);
1260
- const raw = parseJsonOutput(stdout, label);
1261
- const data = asRecord(raw)?.data;
1262
- const repository = asRecord(asRecord(data)?.repository);
1263
- const pullRequest = asRecord(repository?.pullRequest);
1264
- const reviewThreads = asRecord(pullRequest?.reviewThreads);
1265
- if (!reviewThreads) throw new Error('GitHub GraphQL response did not include reviewThreads.');
1266
-
1267
- const nodes = Array.isArray(reviewThreads.nodes) ? reviewThreads.nodes : [];
1268
- for (const rawThread of nodes) {
1269
- const thread = asRecord(rawThread);
1270
- if (!thread) continue;
1271
- const state: ReviewThreadState = {
1272
- threadId: asTrimmedString(thread.id),
1273
- isResolved: asBoolean(thread.isResolved),
1274
- isOutdated: asBoolean(thread.isOutdated),
1275
- metadataAvailable: true,
1276
- };
1277
- const comments = asRecord(thread.comments);
1278
- const commentNodes = Array.isArray(comments?.nodes) ? comments.nodes : [];
1279
- for (const rawComment of commentNodes) {
1280
- const comment = asRecord(rawComment);
1281
- if (!comment) continue;
1282
- const databaseId = asFiniteNumber(comment.databaseId);
1283
- const nodeId = asTrimmedString(comment.id);
1284
- if (databaseId !== undefined) lookup.byDatabaseId.set(databaseId, state);
1285
- if (nodeId) lookup.byNodeId.set(nodeId, state);
1286
- }
1287
- }
1288
-
1289
- const pageInfo = asRecord(reviewThreads.pageInfo);
1290
- if (!asBoolean(pageInfo?.hasNextPage)) break;
1291
- const endCursor = asTrimmedString(pageInfo?.endCursor);
1292
- if (!endCursor) break;
1293
- after = endCursor;
1294
- }
1295
-
1296
- return lookup;
1467
+ const [owner, repo] = pr.repository.split("/");
1468
+ if (!owner || !repo)
1469
+ throw new Error(`Could not build GitHub GraphQL variables from repository ${pr.repository}.`);
1470
+
1471
+ const lookup = createReviewThreadStateLookup();
1472
+ let after: string | undefined;
1473
+
1474
+ while (true) {
1475
+ const args = ["api"];
1476
+ if (pr.host) args.push("--hostname", pr.host);
1477
+ args.push(
1478
+ "graphql",
1479
+ "-f",
1480
+ `owner=${owner}`,
1481
+ "-f",
1482
+ `name=${repo}`,
1483
+ "-F",
1484
+ `number=${pr.number}`,
1485
+ "-f",
1486
+ `query=${GH_REVIEW_THREADS_GRAPHQL_QUERY}`,
1487
+ );
1488
+ if (after) args.push("-f", `after=${after}`);
1489
+
1490
+ const label = "Fetching review thread resolved/outdated metadata with gh";
1491
+ const stdout = await execChecked(pi, ctx, "gh", args, label);
1492
+ const raw = parseJsonOutput(stdout, label);
1493
+ const data = asRecord(raw)?.data;
1494
+ const repository = asRecord(asRecord(data)?.repository);
1495
+ const pullRequest = asRecord(repository?.pullRequest);
1496
+ const reviewThreads = asRecord(pullRequest?.reviewThreads);
1497
+ if (!reviewThreads) throw new Error("GitHub GraphQL response did not include reviewThreads.");
1498
+
1499
+ const nodes = Array.isArray(reviewThreads.nodes) ? reviewThreads.nodes : [];
1500
+ for (const rawThread of nodes) {
1501
+ const thread = asRecord(rawThread);
1502
+ if (!thread) continue;
1503
+ const state: ReviewThreadState = {
1504
+ threadId: asTrimmedString(thread.id),
1505
+ isResolved: asBoolean(thread.isResolved),
1506
+ isOutdated: asBoolean(thread.isOutdated),
1507
+ metadataAvailable: true,
1508
+ };
1509
+ const comments = asRecord(thread.comments);
1510
+ const commentNodes = Array.isArray(comments?.nodes) ? comments.nodes : [];
1511
+ for (const rawComment of commentNodes) {
1512
+ const comment = asRecord(rawComment);
1513
+ if (!comment) continue;
1514
+ const databaseId = asFiniteNumber(comment.databaseId);
1515
+ const nodeId = asTrimmedString(comment.id);
1516
+ if (databaseId !== undefined) lookup.byDatabaseId.set(databaseId, state);
1517
+ if (nodeId) lookup.byNodeId.set(nodeId, state);
1518
+ }
1519
+ }
1520
+
1521
+ const pageInfo = asRecord(reviewThreads.pageInfo);
1522
+ if (!asBoolean(pageInfo?.hasNextPage)) break;
1523
+ const endCursor = asTrimmedString(pageInfo?.endCursor);
1524
+ if (!endCursor) break;
1525
+ after = endCursor;
1526
+ }
1527
+
1528
+ return lookup;
1297
1529
  }
1298
1530
 
1299
1531
  async function fetchReviewThreadStateLookupBestEffort(
1300
- pi: ExtensionAPI,
1301
- ctx: ExtensionCommandContext,
1302
- pr: PullRequestContext,
1532
+ pi: ExtensionAPI,
1533
+ ctx: ExtensionCommandContext,
1534
+ pr: PullRequestContext,
1303
1535
  ): Promise<ReviewThreadStateLookup> {
1304
- try {
1305
- return await fetchReviewThreadStateLookup(pi, ctx, pr);
1306
- } catch (error) {
1307
- notifyCommand(
1308
- ctx,
1309
- `Could not fetch inline review-thread resolved/outdated metadata; inline review comments without thread metadata will stay visible. ${formatErrorMessage(error)}`,
1310
- 'warning',
1311
- );
1312
- return createReviewThreadStateLookup();
1313
- }
1314
- }
1315
-
1316
- function normalizeReviewComment(raw: unknown, pr: PullRequestContext, sortIndex: number, reviewThread: ReviewThreadState): CommandComment | undefined {
1317
- const record = asRecord(raw);
1318
- if (!record) return undefined;
1319
- const body = asTrimmedString(record.body);
1320
- if (!body) return undefined;
1321
-
1322
- const databaseId = asFiniteNumber(record.id);
1323
- const nodeId = asTrimmedString(record.node_id ?? record.nodeId);
1324
- const stableId = asStableIdPart(record.id, nodeId) ?? `${pr.repository}#${pr.number}:review-comment:${sortIndex}`;
1325
- const createdAt = asTrimmedString(record.created_at ?? record.createdAt);
1326
- const originalLine = asFiniteNumber(record.original_line ?? record.originalLine);
1327
- const originalStartLine = asFiniteNumber(record.original_start_line ?? record.originalStartLine);
1328
- const line = asFiniteNumber(record.line) ?? originalLine;
1329
- const startLine = asFiniteNumber(record.start_line ?? record.startLine) ?? originalStartLine;
1330
-
1331
- return {
1332
- id: `github-review-comment:${stableId}`,
1333
- body,
1334
- path: asTrimmedString(record.path),
1335
- line,
1336
- startLine,
1337
- side: asTrimmedString(record.side ?? record.start_side ?? record.startSide),
1338
- diffHunk: asTrimmedString(record.diff_hunk ?? record.diffHunk),
1339
- author: githubLogin(record.user),
1340
- url: asTrimmedString(record.html_url ?? record.htmlUrl),
1341
- createdAt,
1342
- reviewThread,
1343
- metadata: compactRecord({
1344
- source: 'pull_request_review_comment',
1345
- repository: pr.repository,
1346
- prNumber: pr.number,
1347
- host: pr.host,
1348
- databaseId,
1349
- nodeId,
1350
- reviewThread: compactRecord({
1351
- threadId: reviewThread.threadId,
1352
- isResolved: reviewThread.isResolved,
1353
- isOutdated: reviewThread.isOutdated,
1354
- metadataAvailable: reviewThread.metadataAvailable,
1355
- }),
1356
- pullRequestReviewId: asFiniteNumber(record.pull_request_review_id ?? record.pullRequestReviewId),
1357
- commitId: asTrimmedString(record.commit_id ?? record.commitId),
1358
- originalCommitId: asTrimmedString(record.original_commit_id ?? record.originalCommitId),
1359
- originalLine,
1360
- originalStartLine,
1361
- position: asFiniteNumber(record.position),
1362
- originalPosition: asFiniteNumber(record.original_position ?? record.originalPosition),
1363
- subjectType: asTrimmedString(record.subject_type ?? record.subjectType),
1364
- authorAssociation: asTrimmedString(record.author_association ?? record.authorAssociation),
1365
- inReplyToId: asFiniteNumber(record.in_reply_to_id ?? record.inReplyToId),
1366
- updatedAt: asTrimmedString(record.updated_at ?? record.updatedAt),
1367
- }),
1368
- sourceLabel: 'review comment',
1369
- sortTimestamp: createdAt,
1370
- sortIndex,
1371
- };
1372
- }
1373
-
1374
- function normalizeIssueComment(raw: unknown, pr: PullRequestContext, sortIndex: number): CommandComment | undefined {
1375
- const record = asRecord(raw);
1376
- if (!record) return undefined;
1377
- const body = asTrimmedString(record.body);
1378
- if (!body) return undefined;
1379
-
1380
- const databaseId = asFiniteNumber(record.id);
1381
- const nodeId = asTrimmedString(record.node_id ?? record.nodeId);
1382
- const stableId = asStableIdPart(record.id, nodeId) ?? `${pr.repository}#${pr.number}:issue-comment:${sortIndex}`;
1383
- const createdAt = asTrimmedString(record.created_at ?? record.createdAt);
1384
-
1385
- return {
1386
- id: `github-issue-comment:${stableId}`,
1387
- body,
1388
- author: githubLogin(record.user),
1389
- url: asTrimmedString(record.html_url ?? record.htmlUrl),
1390
- createdAt,
1391
- metadata: compactRecord({
1392
- source: 'pull_request_issue_comment',
1393
- repository: pr.repository,
1394
- prNumber: pr.number,
1395
- host: pr.host,
1396
- databaseId,
1397
- nodeId,
1398
- authorAssociation: asTrimmedString(record.author_association ?? record.authorAssociation),
1399
- updatedAt: asTrimmedString(record.updated_at ?? record.updatedAt),
1400
- }),
1401
- sourceLabel: 'issue comment',
1402
- sortTimestamp: createdAt,
1403
- sortIndex,
1404
- };
1405
- }
1406
-
1407
- function normalizeReviewBody(raw: unknown, pr: PullRequestContext, sortIndex: number): CommandComment | undefined {
1408
- const record = asRecord(raw);
1409
- if (!record) return undefined;
1410
- const body = asTrimmedString(record.body);
1411
- if (!body) return undefined;
1412
-
1413
- const databaseId = asFiniteNumber(record.id);
1414
- const nodeId = asTrimmedString(record.node_id ?? record.nodeId);
1415
- const stableId = asStableIdPart(record.id, nodeId) ?? `${pr.repository}#${pr.number}:review:${sortIndex}`;
1416
- const submittedAt = asTrimmedString(record.submitted_at ?? record.submittedAt);
1417
- const createdAt = submittedAt ?? asTrimmedString(record.created_at ?? record.createdAt);
1418
-
1419
- return {
1420
- id: `github-review:${stableId}`,
1421
- body,
1422
- author: githubLogin(record.user),
1423
- url: reviewHtmlUrl(record),
1424
- createdAt,
1425
- metadata: compactRecord({
1426
- source: 'pull_request_review_body',
1427
- repository: pr.repository,
1428
- prNumber: pr.number,
1429
- host: pr.host,
1430
- databaseId,
1431
- nodeId,
1432
- state: asTrimmedString(record.state),
1433
- commitId: asTrimmedString(record.commit_id ?? record.commitId),
1434
- submittedAt,
1435
- authorAssociation: asTrimmedString(record.author_association ?? record.authorAssociation),
1436
- }),
1437
- sourceLabel: 'review body',
1438
- sortTimestamp: createdAt,
1439
- sortIndex,
1440
- };
1536
+ try {
1537
+ return await fetchReviewThreadStateLookup(pi, ctx, pr);
1538
+ } catch (error) {
1539
+ notifyCommand(
1540
+ ctx,
1541
+ `Could not fetch inline review-thread resolved/outdated metadata; inline review comments without thread metadata will stay visible. ${formatErrorMessage(error)}`,
1542
+ "warning",
1543
+ );
1544
+ return createReviewThreadStateLookup();
1545
+ }
1546
+ }
1547
+
1548
+ function normalizeReviewComment(
1549
+ raw: unknown,
1550
+ pr: PullRequestContext,
1551
+ sortIndex: number,
1552
+ reviewThread: ReviewThreadState,
1553
+ ): CommandComment | undefined {
1554
+ const record = asRecord(raw);
1555
+ if (!record) return undefined;
1556
+ const body = asTrimmedString(record.body);
1557
+ if (!body) return undefined;
1558
+
1559
+ const databaseId = asFiniteNumber(record.id);
1560
+ const nodeId = asTrimmedString(record.node_id ?? record.nodeId);
1561
+ const stableId =
1562
+ asStableIdPart(record.id, nodeId) ??
1563
+ `${pr.repository}#${pr.number}:review-comment:${sortIndex}`;
1564
+ const createdAt = asTrimmedString(record.created_at ?? record.createdAt);
1565
+ const originalLine = asFiniteNumber(record.original_line ?? record.originalLine);
1566
+ const originalStartLine = asFiniteNumber(record.original_start_line ?? record.originalStartLine);
1567
+ const line = asFiniteNumber(record.line) ?? originalLine;
1568
+ const startLine = asFiniteNumber(record.start_line ?? record.startLine) ?? originalStartLine;
1569
+
1570
+ return {
1571
+ id: `github-review-comment:${stableId}`,
1572
+ body,
1573
+ path: asTrimmedString(record.path),
1574
+ line,
1575
+ startLine,
1576
+ side: asTrimmedString(record.side ?? record.start_side ?? record.startSide),
1577
+ diffHunk: asTrimmedString(record.diff_hunk ?? record.diffHunk),
1578
+ author: githubLogin(record.user),
1579
+ url: asTrimmedString(record.html_url ?? record.htmlUrl),
1580
+ createdAt,
1581
+ reviewThread,
1582
+ metadata: compactRecord({
1583
+ source: "pull_request_review_comment",
1584
+ repository: pr.repository,
1585
+ prNumber: pr.number,
1586
+ host: pr.host,
1587
+ databaseId,
1588
+ nodeId,
1589
+ reviewThread: compactRecord({
1590
+ threadId: reviewThread.threadId,
1591
+ isResolved: reviewThread.isResolved,
1592
+ isOutdated: reviewThread.isOutdated,
1593
+ metadataAvailable: reviewThread.metadataAvailable,
1594
+ }),
1595
+ pullRequestReviewId: asFiniteNumber(
1596
+ record.pull_request_review_id ?? record.pullRequestReviewId,
1597
+ ),
1598
+ commitId: asTrimmedString(record.commit_id ?? record.commitId),
1599
+ originalCommitId: asTrimmedString(record.original_commit_id ?? record.originalCommitId),
1600
+ originalLine,
1601
+ originalStartLine,
1602
+ position: asFiniteNumber(record.position),
1603
+ originalPosition: asFiniteNumber(record.original_position ?? record.originalPosition),
1604
+ subjectType: asTrimmedString(record.subject_type ?? record.subjectType),
1605
+ authorAssociation: asTrimmedString(record.author_association ?? record.authorAssociation),
1606
+ inReplyToId: asFiniteNumber(record.in_reply_to_id ?? record.inReplyToId),
1607
+ updatedAt: asTrimmedString(record.updated_at ?? record.updatedAt),
1608
+ }),
1609
+ sourceLabel: "review comment",
1610
+ sortTimestamp: createdAt,
1611
+ sortIndex,
1612
+ };
1613
+ }
1614
+
1615
+ function normalizeIssueComment(
1616
+ raw: unknown,
1617
+ pr: PullRequestContext,
1618
+ sortIndex: number,
1619
+ ): CommandComment | undefined {
1620
+ const record = asRecord(raw);
1621
+ if (!record) return undefined;
1622
+ const body = asTrimmedString(record.body);
1623
+ if (!body) return undefined;
1624
+
1625
+ const databaseId = asFiniteNumber(record.id);
1626
+ const nodeId = asTrimmedString(record.node_id ?? record.nodeId);
1627
+ const stableId =
1628
+ asStableIdPart(record.id, nodeId) ?? `${pr.repository}#${pr.number}:issue-comment:${sortIndex}`;
1629
+ const createdAt = asTrimmedString(record.created_at ?? record.createdAt);
1630
+
1631
+ return {
1632
+ id: `github-issue-comment:${stableId}`,
1633
+ body,
1634
+ author: githubLogin(record.user),
1635
+ url: asTrimmedString(record.html_url ?? record.htmlUrl),
1636
+ createdAt,
1637
+ metadata: compactRecord({
1638
+ source: "pull_request_issue_comment",
1639
+ repository: pr.repository,
1640
+ prNumber: pr.number,
1641
+ host: pr.host,
1642
+ databaseId,
1643
+ nodeId,
1644
+ authorAssociation: asTrimmedString(record.author_association ?? record.authorAssociation),
1645
+ updatedAt: asTrimmedString(record.updated_at ?? record.updatedAt),
1646
+ }),
1647
+ sourceLabel: "issue comment",
1648
+ sortTimestamp: createdAt,
1649
+ sortIndex,
1650
+ };
1651
+ }
1652
+
1653
+ function normalizeReviewBody(
1654
+ raw: unknown,
1655
+ pr: PullRequestContext,
1656
+ sortIndex: number,
1657
+ ): CommandComment | undefined {
1658
+ const record = asRecord(raw);
1659
+ if (!record) return undefined;
1660
+ const body = asTrimmedString(record.body);
1661
+ if (!body) return undefined;
1662
+
1663
+ const databaseId = asFiniteNumber(record.id);
1664
+ const nodeId = asTrimmedString(record.node_id ?? record.nodeId);
1665
+ const stableId =
1666
+ asStableIdPart(record.id, nodeId) ?? `${pr.repository}#${pr.number}:review:${sortIndex}`;
1667
+ const submittedAt = asTrimmedString(record.submitted_at ?? record.submittedAt);
1668
+ const createdAt = submittedAt ?? asTrimmedString(record.created_at ?? record.createdAt);
1669
+
1670
+ return {
1671
+ id: `github-review:${stableId}`,
1672
+ body,
1673
+ author: githubLogin(record.user),
1674
+ url: reviewHtmlUrl(record),
1675
+ createdAt,
1676
+ metadata: compactRecord({
1677
+ source: "pull_request_review_body",
1678
+ repository: pr.repository,
1679
+ prNumber: pr.number,
1680
+ host: pr.host,
1681
+ databaseId,
1682
+ nodeId,
1683
+ state: asTrimmedString(record.state),
1684
+ commitId: asTrimmedString(record.commit_id ?? record.commitId),
1685
+ submittedAt,
1686
+ authorAssociation: asTrimmedString(record.author_association ?? record.authorAssociation),
1687
+ }),
1688
+ sourceLabel: "review body",
1689
+ sortTimestamp: createdAt,
1690
+ sortIndex,
1691
+ };
1441
1692
  }
1442
1693
 
1443
1694
  function compareCommandComments(a: CommandComment, b: CommandComment): number {
1444
- const aTime = a.sortTimestamp ? Date.parse(a.sortTimestamp) : NaN;
1445
- const bTime = b.sortTimestamp ? Date.parse(b.sortTimestamp) : NaN;
1446
- if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) return aTime - bTime;
1447
- if (Number.isFinite(aTime) && !Number.isFinite(bTime)) return -1;
1448
- if (!Number.isFinite(aTime) && Number.isFinite(bTime)) return 1;
1449
- return a.sortIndex - b.sortIndex;
1695
+ const aTime = a.sortTimestamp ? Date.parse(a.sortTimestamp) : NaN;
1696
+ const bTime = b.sortTimestamp ? Date.parse(b.sortTimestamp) : NaN;
1697
+ if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) return aTime - bTime;
1698
+ if (Number.isFinite(aTime) && !Number.isFinite(bTime)) return -1;
1699
+ if (!Number.isFinite(aTime) && Number.isFinite(bTime)) return 1;
1700
+ return a.sortIndex - b.sortIndex;
1450
1701
  }
1451
1702
 
1452
1703
  function assignDisplayNumbers(comments: CommandComment[]): CommandComment[] {
1453
- return comments.map((comment, index) => ({
1454
- ...comment,
1455
- displayNumber: index + 1,
1456
- metadata: compactRecord({ ...comment.metadata, displayNumber: index + 1 }),
1457
- }));
1704
+ return comments.map((comment, index) => ({
1705
+ ...comment,
1706
+ displayNumber: index + 1,
1707
+ metadata: compactRecord({ ...comment.metadata, displayNumber: index + 1 }),
1708
+ }));
1458
1709
  }
1459
1710
 
1460
1711
  function prContextFallbackTarget(raw: unknown, explicitTarget?: string): string {
1461
- const record = asRecord(raw);
1462
- const number = asFiniteNumber(record?.number);
1463
- return explicitTarget ?? asTrimmedString(record?.url) ?? (number ? String(number) : '');
1712
+ const record = asRecord(raw);
1713
+ const number = asFiniteNumber(record?.number);
1714
+ return explicitTarget ?? asTrimmedString(record?.url) ?? (number ? String(number) : "");
1464
1715
  }
1465
1716
 
1466
1717
  async function fetchPullRequestMetadata(
1467
- pi: ExtensionAPI,
1468
- ctx: ExtensionCommandContext,
1469
- label: string,
1470
- target?: string,
1718
+ pi: ExtensionAPI,
1719
+ ctx: ExtensionCommandContext,
1720
+ label: string,
1721
+ target?: string,
1471
1722
  ): Promise<PullRequestContext> {
1472
- const args = ['pr', 'view'];
1473
- if (target) args.push(target);
1474
- args.push('--json', GH_PR_METADATA_FIELDS);
1723
+ const args = ["pr", "view"];
1724
+ if (target) args.push(target);
1725
+ args.push("--json", GH_PR_METADATA_FIELDS);
1475
1726
 
1476
- const stdout = await execChecked(pi, ctx, 'gh', args, label);
1477
- const raw = parseJsonOutput(stdout, label);
1478
- return normalizePullRequestContext(raw, prContextFallbackTarget(raw, target));
1727
+ const stdout = await execChecked(pi, ctx, "gh", args, label);
1728
+ const raw = parseJsonOutput(stdout, label);
1729
+ return normalizePullRequestContext(raw, prContextFallbackTarget(raw, target));
1479
1730
  }
1480
1731
 
1481
1732
  async function detectCurrentBranchPullRequest(
1482
- pi: ExtensionAPI,
1483
- ctx: ExtensionCommandContext,
1733
+ pi: ExtensionAPI,
1734
+ ctx: ExtensionCommandContext,
1484
1735
  ): Promise<PullRequestContext | undefined> {
1485
- try {
1486
- const branch = await pi.exec('git', ['branch', '--show-current'], {
1487
- cwd: ctx.cwd,
1488
- signal: ctx.signal,
1489
- timeout: GH_COMMAND_TIMEOUT_MS,
1490
- });
1491
- if (branch.killed || branch.code !== 0) return undefined;
1492
-
1493
- const branchName = branch.stdout.trim();
1494
- if (!branchName || branchName === 'main') return undefined;
1495
-
1496
- return await fetchPullRequestMetadata(pi, ctx, 'Detecting current branch PR with gh');
1497
- } catch {
1498
- return undefined;
1499
- }
1736
+ try {
1737
+ const branch = await pi.exec("git", ["branch", "--show-current"], {
1738
+ cwd: ctx.cwd,
1739
+ signal: ctx.signal,
1740
+ timeout: GH_COMMAND_TIMEOUT_MS,
1741
+ });
1742
+ if (branch.killed || branch.code !== 0) return undefined;
1743
+
1744
+ const branchName = branch.stdout.trim();
1745
+ if (!branchName || branchName === "main") return undefined;
1746
+
1747
+ return await fetchPullRequestMetadata(pi, ctx, "Detecting current branch PR with gh");
1748
+ } catch {
1749
+ return undefined;
1750
+ }
1500
1751
  }
1501
1752
 
1502
1753
  async function fetchCommentsForPullRequest(
1503
- pi: ExtensionAPI,
1504
- ctx: ExtensionCommandContext,
1505
- pr: PullRequestContext,
1754
+ pi: ExtensionAPI,
1755
+ ctx: ExtensionCommandContext,
1756
+ pr: PullRequestContext,
1506
1757
  ): Promise<CommandComment[]> {
1507
- const reviewComments = await ghApiArray(pi, ctx, pr, `pulls/${pr.number}/comments?per_page=100`, 'review comments');
1508
- const reviewThreadLookup = reviewComments.length > 0
1509
- ? await fetchReviewThreadStateLookupBestEffort(pi, ctx, pr)
1510
- : createReviewThreadStateLookup();
1511
- const issueComments = await ghApiArray(pi, ctx, pr, `issues/${pr.number}/comments?per_page=100`, 'issue comments');
1512
- const reviews = await ghApiArray(pi, ctx, pr, `pulls/${pr.number}/reviews?per_page=100`, 'review bodies');
1513
-
1514
- let sortIndex = 0;
1515
- const comments: CommandComment[] = [];
1516
- for (const raw of reviewComments) {
1517
- const comment = normalizeReviewComment(raw, pr, ++sortIndex, reviewThreadStateForComment(raw, reviewThreadLookup));
1518
- if (comment) comments.push(comment);
1519
- }
1520
- for (const raw of issueComments) {
1521
- const comment = normalizeIssueComment(raw, pr, ++sortIndex);
1522
- if (comment) comments.push(comment);
1523
- }
1524
- for (const raw of reviews) {
1525
- const comment = normalizeReviewBody(raw, pr, ++sortIndex);
1526
- if (comment) comments.push(comment);
1527
- }
1528
-
1529
- return assignDisplayNumbers(comments.sort(compareCommandComments));
1758
+ const reviewComments = await ghApiArray(
1759
+ pi,
1760
+ ctx,
1761
+ pr,
1762
+ `pulls/${pr.number}/comments?per_page=100`,
1763
+ "review comments",
1764
+ );
1765
+ const reviewThreadLookup =
1766
+ reviewComments.length > 0
1767
+ ? await fetchReviewThreadStateLookupBestEffort(pi, ctx, pr)
1768
+ : createReviewThreadStateLookup();
1769
+ const issueComments = await ghApiArray(
1770
+ pi,
1771
+ ctx,
1772
+ pr,
1773
+ `issues/${pr.number}/comments?per_page=100`,
1774
+ "issue comments",
1775
+ );
1776
+ const reviews = await ghApiArray(
1777
+ pi,
1778
+ ctx,
1779
+ pr,
1780
+ `pulls/${pr.number}/reviews?per_page=100`,
1781
+ "review bodies",
1782
+ );
1783
+
1784
+ let sortIndex = 0;
1785
+ const comments: CommandComment[] = [];
1786
+ for (const raw of reviewComments) {
1787
+ const comment = normalizeReviewComment(
1788
+ raw,
1789
+ pr,
1790
+ ++sortIndex,
1791
+ reviewThreadStateForComment(raw, reviewThreadLookup),
1792
+ );
1793
+ if (comment) comments.push(comment);
1794
+ }
1795
+ for (const raw of issueComments) {
1796
+ const comment = normalizeIssueComment(raw, pr, ++sortIndex);
1797
+ if (comment) comments.push(comment);
1798
+ }
1799
+ for (const raw of reviews) {
1800
+ const comment = normalizeReviewBody(raw, pr, ++sortIndex);
1801
+ if (comment) comments.push(comment);
1802
+ }
1803
+
1804
+ return assignDisplayNumbers(comments.sort(compareCommandComments));
1530
1805
  }
1531
1806
 
1532
1807
  async function fetchPrCommentsForTriage(
1533
- pi: ExtensionAPI,
1534
- ctx: ExtensionCommandContext,
1535
- target: string,
1808
+ pi: ExtensionAPI,
1809
+ ctx: ExtensionCommandContext,
1810
+ target: string,
1536
1811
  ): Promise<{ pr: PullRequestContext; comments: CommandComment[] }> {
1537
- const normalizedTarget = normalizePrTarget(target);
1538
- if (!normalizedTarget) {
1539
- throw new Error('PR input must be a PR number (for example, 123 or #123) or a GitHub pull request URL.');
1540
- }
1812
+ const normalizedTarget = normalizePrTarget(target);
1813
+ if (!normalizedTarget) {
1814
+ throw new Error(
1815
+ "PR input must be a PR number (for example, 123 or #123) or a GitHub pull request URL.",
1816
+ );
1817
+ }
1541
1818
 
1542
- await assertGitRepo(pi, ctx);
1543
- await assertGhReady(pi, ctx);
1819
+ await assertGitRepo(pi, ctx);
1820
+ await assertGhReady(pi, ctx);
1544
1821
 
1545
- const pr = await fetchPullRequestMetadata(pi, ctx, 'Fetching PR metadata with gh', normalizedTarget);
1546
- const comments = await fetchCommentsForPullRequest(pi, ctx, pr);
1547
- return { pr, comments };
1822
+ const pr = await fetchPullRequestMetadata(
1823
+ pi,
1824
+ ctx,
1825
+ "Fetching PR metadata with gh",
1826
+ normalizedTarget,
1827
+ );
1828
+ const comments = await fetchCommentsForPullRequest(pi, ctx, pr);
1829
+ return { pr, comments };
1548
1830
  }
1549
1831
 
1550
1832
  function truncateForDisplay(text: string, max: number): string {
1551
- if (text.length <= max) return text;
1552
- return `${text.slice(0, Math.max(1, max)).trimEnd()}\n… (truncated; full text will be sent to triage_comments)`;
1833
+ if (text.length <= max) return text;
1834
+ return `${text.slice(0, Math.max(1, max)).trimEnd()}\n… (truncated; full text will be sent to triage_comments)`;
1553
1835
  }
1554
1836
 
1555
1837
  function indentLines(text: string, prefix: string): string {
1556
- return text.split('\n').map((line) => `${prefix}${line}`).join('\n');
1838
+ return text
1839
+ .split("\n")
1840
+ .map((line) => `${prefix}${line}`)
1841
+ .join("\n");
1557
1842
  }
1558
1843
 
1559
1844
  function formatCommentLocation(comment: CommandComment): string {
1560
- if (!comment.path) return 'PR discussion';
1561
- const lineRange = comment.startLine && comment.line && comment.startLine !== comment.line
1562
- ? `${comment.startLine}-${comment.line}`
1563
- : comment.line ?? comment.startLine;
1564
- return lineRange ? `${comment.path}:${lineRange}` : comment.path;
1845
+ if (!comment.path) return "PR discussion";
1846
+ const lineRange =
1847
+ comment.startLine && comment.line && comment.startLine !== comment.line
1848
+ ? `${comment.startLine}-${comment.line}`
1849
+ : (comment.line ?? comment.startLine);
1850
+ return lineRange ? `${comment.path}:${lineRange}` : comment.path;
1565
1851
  }
1566
1852
 
1567
1853
  function isInlineReviewComment(comment: CommandComment): boolean {
1568
- return asTrimmedString(comment.metadata.source) === 'pull_request_review_comment';
1854
+ return asTrimmedString(comment.metadata.source) === "pull_request_review_comment";
1569
1855
  }
1570
1856
 
1571
1857
  function formatReviewThreadState(comment: CommandComment): string | undefined {
1572
- if (!isInlineReviewComment(comment)) return undefined;
1573
- const reviewThread = comment.reviewThread;
1574
- if (!reviewThread?.metadataAvailable) return 'thread: resolved/outdated state unavailable (kept visible)';
1575
- const resolved = reviewThread.isResolved === true
1576
- ? 'resolved'
1577
- : reviewThread.isResolved === false
1578
- ? 'unresolved'
1579
- : 'resolved state unavailable';
1580
- const outdated = reviewThread.isOutdated === true
1581
- ? 'outdated'
1582
- : reviewThread.isOutdated === false
1583
- ? 'current'
1584
- : 'outdated state unavailable';
1585
- return `thread: ${resolved}, ${outdated}`;
1858
+ if (!isInlineReviewComment(comment)) return undefined;
1859
+ const reviewThread = comment.reviewThread;
1860
+ if (!reviewThread?.metadataAvailable)
1861
+ return "thread: resolved/outdated state unavailable (kept visible)";
1862
+ const resolved =
1863
+ reviewThread.isResolved === true
1864
+ ? "resolved"
1865
+ : reviewThread.isResolved === false
1866
+ ? "unresolved"
1867
+ : "resolved state unavailable";
1868
+ const outdated =
1869
+ reviewThread.isOutdated === true
1870
+ ? "outdated"
1871
+ : reviewThread.isOutdated === false
1872
+ ? "current"
1873
+ : "outdated state unavailable";
1874
+ return `thread: ${resolved}, ${outdated}`;
1586
1875
  }
1587
1876
 
1588
1877
  function formatInlineFilterNotice(summary: AppliedInlineCommentFilter): string {
1589
- const scope = 'Only inline review comments can be filtered; PR issue comments, review bodies, and inline comments without thread metadata remain visible.';
1590
- if (!summary.filter.hideResolved && !summary.filter.hideOutdated) {
1591
- return `Filter: showing all fetched comments. ${scope}`;
1592
- }
1593
- const hiddenParts = [
1594
- summary.hiddenResolvedInlineCount > 0 ? `${summary.hiddenResolvedInlineCount} resolved` : undefined,
1595
- summary.hiddenOutdatedInlineCount > 0 ? `${summary.hiddenOutdatedInlineCount} outdated` : undefined,
1596
- ]
1597
- .filter(Boolean)
1598
- .join(', ');
1599
- const hiddenDetail = hiddenParts ? ` (${hiddenParts})` : '';
1600
- return `Filter: ${summary.filter.label}. Hidden ${summary.hiddenInlineCount} inline review comment(s)${hiddenDetail}; displaying ${summary.displayedCount} of ${summary.originalCount} fetched comment(s). ${scope}`;
1878
+ const scope =
1879
+ "Only inline review comments can be filtered; PR issue comments, review bodies, and inline comments without thread metadata remain visible.";
1880
+ if (!summary.filter.hideResolved && !summary.filter.hideOutdated) {
1881
+ return `Filter: showing all fetched comments. ${scope}`;
1882
+ }
1883
+ const hiddenParts = [
1884
+ summary.hiddenResolvedInlineCount > 0
1885
+ ? `${summary.hiddenResolvedInlineCount} resolved`
1886
+ : undefined,
1887
+ summary.hiddenOutdatedInlineCount > 0
1888
+ ? `${summary.hiddenOutdatedInlineCount} outdated`
1889
+ : undefined,
1890
+ ]
1891
+ .filter(Boolean)
1892
+ .join(", ");
1893
+ const hiddenDetail = hiddenParts ? ` (${hiddenParts})` : "";
1894
+ return `Filter: ${summary.filter.label}. Hidden ${summary.hiddenInlineCount} inline review comment(s)${hiddenDetail}; displaying ${summary.displayedCount} of ${summary.originalCount} fetched comment(s). ${scope}`;
1601
1895
  }
1602
1896
 
1603
1897
  function formatInlineFilterContext(summary: AppliedInlineCommentFilter): string {
1604
- return `${formatInlineFilterNotice(summary)} Inline comments kept without thread metadata: ${summary.keptInlineWithoutThreadMetadataCount}.`;
1605
- }
1606
-
1607
- function formatFetchedCommentsForSelection(pr: PullRequestContext, comments: CommandComment[], filterSummary?: AppliedInlineCommentFilter): string {
1608
- const title = pr.title ? ` — ${pr.title}` : '';
1609
- const limitNotice = comments.length > MAX_COMMENTS
1610
- ? `\n\ntriage_comments can investigate at most ${MAX_COMMENTS} comments per run, so choose a subset.`
1611
- : '\n\nChoose whether to investigate all displayed comments or select a subset.';
1612
- const fetchedCount = filterSummary?.originalCount ?? comments.length;
1613
- const displayedCount = comments.length;
1614
- const countSummary = filterSummary
1615
- ? `Fetched ${fetchedCount} comment(s) from ${pr.repository}#${pr.number}${title}; displaying ${displayedCount} numbered comment(s) after filtering.`
1616
- : `Fetched ${displayedCount} numbered comment(s) from ${pr.repository}#${pr.number}${title}.`;
1617
- const filterNotice = filterSummary ? `\n\n${formatInlineFilterNotice(filterSummary)}` : '';
1618
- const header = `${countSummary}${filterNotice}${limitNotice}`;
1619
- const body = comments.map((comment) => {
1620
- const author = comment.author ? ` by @${comment.author}` : '';
1621
- const url = comment.url ? `\n url: ${comment.url}` : '';
1622
- const threadState = formatReviewThreadState(comment);
1623
- const threadLine = threadState ? `\n ${threadState}` : '';
1624
- const preview = indentLines(truncateForDisplay(comment.body, COMMENT_DISPLAY_BODY_LIMIT), ' ');
1625
- return `${comment.displayNumber ?? '?'}. ${comment.sourceLabel}${author} — ${formatCommentLocation(comment)}\n id: ${comment.id}${url}${threadLine}\n${preview}`;
1626
- });
1627
- return [header, ...body].join('\n\n');
1628
- }
1629
-
1630
- async function promptForInlineCommentFilter(ctx: ExtensionCommandContext): Promise<InlineCommentFilter | undefined> {
1631
- const prompt = [
1632
- '/triage-comments: filter inline review comments before display',
1633
- 'GitHub exposes resolved/outdated state only for inline review threads. PR issue comments and review bodies will stay visible, and inline comments without thread metadata will stay visible.',
1634
- ].join('\n\n');
1635
- const showAll = 'Show all fetched comments';
1636
- const hideResolved = 'Hide resolved inline review comments';
1637
- const hideOutdated = 'Hide outdated inline review comments';
1638
- const hideBoth = 'Hide resolved and outdated inline review comments';
1639
- const cancel = 'Cancel';
1640
- const decision = await ctx.ui.select(prompt, [showAll, hideResolved, hideOutdated, hideBoth, cancel]);
1641
- if (!decision || decision === cancel) return undefined;
1642
- if (decision === hideResolved) return { hideResolved: true, hideOutdated: false, label: 'hiding resolved inline review comments' };
1643
- if (decision === hideOutdated) return { hideResolved: false, hideOutdated: true, label: 'hiding outdated inline review comments' };
1644
- if (decision === hideBoth) return { hideResolved: true, hideOutdated: true, label: 'hiding resolved or outdated inline review comments' };
1645
- return { hideResolved: false, hideOutdated: false, label: 'showing all fetched comments' };
1898
+ return `${formatInlineFilterNotice(summary)} Inline comments kept without thread metadata: ${summary.keptInlineWithoutThreadMetadataCount}.`;
1899
+ }
1900
+
1901
+ function formatFetchedCommentsForSelection(
1902
+ pr: PullRequestContext,
1903
+ comments: CommandComment[],
1904
+ filterSummary?: AppliedInlineCommentFilter,
1905
+ ): string {
1906
+ const title = pr.title ? ` — ${pr.title}` : "";
1907
+ const limitNotice =
1908
+ comments.length > MAX_COMMENTS
1909
+ ? `\n\ntriage_comments can investigate at most ${MAX_COMMENTS} comments per run, so choose a subset.`
1910
+ : "\n\nChoose whether to investigate all displayed comments or select a subset.";
1911
+ const fetchedCount = filterSummary?.originalCount ?? comments.length;
1912
+ const displayedCount = comments.length;
1913
+ const countSummary = filterSummary
1914
+ ? `Fetched ${fetchedCount} comment(s) from ${pr.repository}#${pr.number}${title}; displaying ${displayedCount} numbered comment(s) after filtering.`
1915
+ : `Fetched ${displayedCount} numbered comment(s) from ${pr.repository}#${pr.number}${title}.`;
1916
+ const filterNotice = filterSummary ? `\n\n${formatInlineFilterNotice(filterSummary)}` : "";
1917
+ const header = `${countSummary}${filterNotice}${limitNotice}`;
1918
+ const body = comments.map((comment) => {
1919
+ const author = comment.author ? ` by @${comment.author}` : "";
1920
+ const url = comment.url ? `\n url: ${comment.url}` : "";
1921
+ const threadState = formatReviewThreadState(comment);
1922
+ const threadLine = threadState ? `\n ${threadState}` : "";
1923
+ const preview = indentLines(
1924
+ truncateForDisplay(comment.body, COMMENT_DISPLAY_BODY_LIMIT),
1925
+ " ",
1926
+ );
1927
+ return `${comment.displayNumber ?? "?"}. ${comment.sourceLabel}${author} — ${formatCommentLocation(comment)}\n id: ${comment.id}${url}${threadLine}\n${preview}`;
1928
+ });
1929
+ return [header, ...body].join("\n\n");
1930
+ }
1931
+
1932
+ async function promptForInlineCommentFilter(
1933
+ ctx: ExtensionCommandContext,
1934
+ ): Promise<InlineCommentFilter | undefined> {
1935
+ const prompt = [
1936
+ "/triage-comments: filter inline review comments before display",
1937
+ "GitHub exposes resolved/outdated state only for inline review threads. PR issue comments and review bodies will stay visible, and inline comments without thread metadata will stay visible.",
1938
+ ].join("\n\n");
1939
+ const showAll = "Show all fetched comments";
1940
+ const hideResolved = "Hide resolved inline review comments";
1941
+ const hideOutdated = "Hide outdated inline review comments";
1942
+ const hideBoth = "Hide resolved and outdated inline review comments";
1943
+ const cancel = "Cancel";
1944
+ const decision = await ctx.ui.select(prompt, [
1945
+ showAll,
1946
+ hideResolved,
1947
+ hideOutdated,
1948
+ hideBoth,
1949
+ cancel,
1950
+ ]);
1951
+ if (!decision || decision === cancel) return undefined;
1952
+ if (decision === hideResolved)
1953
+ return {
1954
+ hideResolved: true,
1955
+ hideOutdated: false,
1956
+ label: "hiding resolved inline review comments",
1957
+ };
1958
+ if (decision === hideOutdated)
1959
+ return {
1960
+ hideResolved: false,
1961
+ hideOutdated: true,
1962
+ label: "hiding outdated inline review comments",
1963
+ };
1964
+ if (decision === hideBoth)
1965
+ return {
1966
+ hideResolved: true,
1967
+ hideOutdated: true,
1968
+ label: "hiding resolved or outdated inline review comments",
1969
+ };
1970
+ return { hideResolved: false, hideOutdated: false, label: "showing all fetched comments" };
1646
1971
  }
1647
1972
 
1648
1973
  function applyInlineCommentFilter(
1649
- comments: CommandComment[],
1650
- filter: InlineCommentFilter,
1974
+ comments: CommandComment[],
1975
+ filter: InlineCommentFilter,
1651
1976
  ): { comments: CommandComment[]; summary: AppliedInlineCommentFilter } {
1652
- const kept: CommandComment[] = [];
1653
- let hiddenInlineCount = 0;
1654
- let hiddenResolvedInlineCount = 0;
1655
- let hiddenOutdatedInlineCount = 0;
1656
- let keptInlineWithoutThreadMetadataCount = 0;
1657
-
1658
- for (const comment of comments) {
1659
- if (!isInlineReviewComment(comment)) {
1660
- kept.push(comment);
1661
- continue;
1662
- }
1663
-
1664
- const reviewThread = comment.reviewThread;
1665
- const hasThreadMetadata = Boolean(reviewThread?.metadataAvailable);
1666
- const hideForResolved = filter.hideResolved && reviewThread?.isResolved === true;
1667
- const hideForOutdated = filter.hideOutdated && reviewThread?.isOutdated === true;
1668
- if (hideForResolved || hideForOutdated) {
1669
- hiddenInlineCount += 1;
1670
- if (hideForResolved) hiddenResolvedInlineCount += 1;
1671
- if (hideForOutdated) hiddenOutdatedInlineCount += 1;
1672
- continue;
1673
- }
1674
-
1675
- if (!hasThreadMetadata) keptInlineWithoutThreadMetadataCount += 1;
1676
- kept.push(comment);
1677
- }
1678
-
1679
- const commentsWithFilterMetadata = kept.map((comment) => ({
1680
- ...comment,
1681
- metadata: compactRecord({
1682
- ...comment.metadata,
1683
- preFilterDisplayNumber: comment.displayNumber,
1684
- }),
1685
- }));
1686
-
1687
- const summary: AppliedInlineCommentFilter = {
1688
- filter,
1689
- originalCount: comments.length,
1690
- displayedCount: kept.length,
1691
- hiddenInlineCount,
1692
- hiddenResolvedInlineCount,
1693
- hiddenOutdatedInlineCount,
1694
- keptInlineWithoutThreadMetadataCount,
1695
- };
1696
-
1697
- return { comments: assignDisplayNumbers(commentsWithFilterMetadata), summary };
1977
+ const kept: CommandComment[] = [];
1978
+ let hiddenInlineCount = 0;
1979
+ let hiddenResolvedInlineCount = 0;
1980
+ let hiddenOutdatedInlineCount = 0;
1981
+ let keptInlineWithoutThreadMetadataCount = 0;
1982
+
1983
+ for (const comment of comments) {
1984
+ if (!isInlineReviewComment(comment)) {
1985
+ kept.push(comment);
1986
+ continue;
1987
+ }
1988
+
1989
+ const reviewThread = comment.reviewThread;
1990
+ const hasThreadMetadata = Boolean(reviewThread?.metadataAvailable);
1991
+ const hideForResolved = filter.hideResolved && reviewThread?.isResolved === true;
1992
+ const hideForOutdated = filter.hideOutdated && reviewThread?.isOutdated === true;
1993
+ if (hideForResolved || hideForOutdated) {
1994
+ hiddenInlineCount += 1;
1995
+ if (hideForResolved) hiddenResolvedInlineCount += 1;
1996
+ if (hideForOutdated) hiddenOutdatedInlineCount += 1;
1997
+ continue;
1998
+ }
1999
+
2000
+ if (!hasThreadMetadata) keptInlineWithoutThreadMetadataCount += 1;
2001
+ kept.push(comment);
2002
+ }
2003
+
2004
+ const commentsWithFilterMetadata = kept.map((comment) => ({
2005
+ ...comment,
2006
+ metadata: compactRecord({
2007
+ ...comment.metadata,
2008
+ preFilterDisplayNumber: comment.displayNumber,
2009
+ }),
2010
+ }));
2011
+
2012
+ const summary: AppliedInlineCommentFilter = {
2013
+ filter,
2014
+ originalCount: comments.length,
2015
+ displayedCount: kept.length,
2016
+ hiddenInlineCount,
2017
+ hiddenResolvedInlineCount,
2018
+ hiddenOutdatedInlineCount,
2019
+ keptInlineWithoutThreadMetadataCount,
2020
+ };
2021
+
2022
+ return { comments: assignDisplayNumbers(commentsWithFilterMetadata), summary };
1698
2023
  }
1699
2024
 
1700
2025
  function parseSelectionList(input: string, max: number): SelectionParseResult {
1701
- const trimmed = input.trim();
1702
- if (!trimmed) return { ok: false, error: 'Enter comment numbers such as 1,3-5.' };
1703
- if (trimmed.toLowerCase() === 'all') return { ok: true, indices: Array.from({ length: max }, (_value, index) => index) };
1704
-
1705
- const selected: number[] = [];
1706
- const seen = new Set<number>();
1707
- const addNumber = (value: number): string | undefined => {
1708
- if (!Number.isInteger(value) || value <= 0) return `Invalid comment number: ${value}`;
1709
- if (value > max) return `Comment number ${value} is outside the available range 1-${max}.`;
1710
- const index = value - 1;
1711
- if (!seen.has(index)) {
1712
- seen.add(index);
1713
- selected.push(index);
1714
- }
1715
- return undefined;
1716
- };
1717
-
1718
- for (const rawPart of trimmed.split(',')) {
1719
- const part = rawPart.trim();
1720
- if (!part) return { ok: false, error: 'Selection contains an empty item. Use a format like 1,3-5.' };
1721
- const range = part.match(/^(\d+)\s*-\s*(\d+)$/);
1722
- if (range) {
1723
- const start = Number(range[1]);
1724
- const end = Number(range[2]);
1725
- if (start > end) return { ok: false, error: `Range ${part} goes backwards; use ${end}-${start} instead.` };
1726
- for (let value = start; value <= end; value += 1) {
1727
- const error = addNumber(value);
1728
- if (error) return { ok: false, error };
1729
- }
1730
- continue;
1731
- }
1732
-
1733
- if (/^\d+$/.test(part)) {
1734
- const error = addNumber(Number(part));
1735
- if (error) return { ok: false, error };
1736
- continue;
1737
- }
1738
-
1739
- return { ok: false, error: `Could not parse selection item: ${part}. Use numbers and ranges like 1,3-5.` };
1740
- }
1741
-
1742
- return selected.length > 0 ? { ok: true, indices: selected } : { ok: false, error: 'Select at least one comment.' };
2026
+ const trimmed = input.trim();
2027
+ if (!trimmed) return { ok: false, error: "Enter comment numbers such as 1,3-5." };
2028
+ if (trimmed.toLowerCase() === "all")
2029
+ return { ok: true, indices: Array.from({ length: max }, (_value, index) => index) };
2030
+
2031
+ const selected: number[] = [];
2032
+ const seen = new Set<number>();
2033
+ const addNumber = (value: number): string | undefined => {
2034
+ if (!Number.isInteger(value) || value <= 0) return `Invalid comment number: ${value}`;
2035
+ if (value > max) return `Comment number ${value} is outside the available range 1-${max}.`;
2036
+ const index = value - 1;
2037
+ if (!seen.has(index)) {
2038
+ seen.add(index);
2039
+ selected.push(index);
2040
+ }
2041
+ return undefined;
2042
+ };
2043
+
2044
+ for (const rawPart of trimmed.split(",")) {
2045
+ const part = rawPart.trim();
2046
+ if (!part)
2047
+ return { ok: false, error: "Selection contains an empty item. Use a format like 1,3-5." };
2048
+ const range = part.match(/^(\d+)\s*-\s*(\d+)$/);
2049
+ if (range) {
2050
+ const start = Number(range[1]);
2051
+ const end = Number(range[2]);
2052
+ if (start > end)
2053
+ return { ok: false, error: `Range ${part} goes backwards; use ${end}-${start} instead.` };
2054
+ for (let value = start; value <= end; value += 1) {
2055
+ const error = addNumber(value);
2056
+ if (error) return { ok: false, error };
2057
+ }
2058
+ continue;
2059
+ }
2060
+
2061
+ if (/^\d+$/.test(part)) {
2062
+ const error = addNumber(Number(part));
2063
+ if (error) return { ok: false, error };
2064
+ continue;
2065
+ }
2066
+
2067
+ return {
2068
+ ok: false,
2069
+ error: `Could not parse selection item: ${part}. Use numbers and ranges like 1,3-5.`,
2070
+ };
2071
+ }
2072
+
2073
+ return selected.length > 0
2074
+ ? { ok: true, indices: selected }
2075
+ : { ok: false, error: "Select at least one comment." };
1743
2076
  }
1744
2077
 
1745
2078
  async function promptForCommentSubset(
1746
- ctx: ExtensionCommandContext,
1747
- comments: CommandComment[],
1748
- selectionPrompt: string,
2079
+ ctx: ExtensionCommandContext,
2080
+ comments: CommandComment[],
2081
+ selectionPrompt: string,
1749
2082
  ): Promise<CommandComment[] | undefined> {
1750
- const inputPrompt = `${selectionPrompt}\n\nEnter comment numbers to investigate.`;
1751
- const inputHint = comments.length <= MAX_COMMENTS
1752
- ? `Examples: 1,3-5 or all (max ${MAX_COMMENTS})`
1753
- : `Examples: 1,3-5 (choose up to ${MAX_COMMENTS})`;
1754
-
1755
- while (true) {
1756
- const input = await ctx.ui.input(inputPrompt, inputHint);
1757
- if (input === undefined) return undefined;
1758
- const parsed = parseSelectionList(input, comments.length);
1759
- if (!parsed.ok) {
1760
- notifyCommand(ctx, parsed.error, 'error');
1761
- continue;
1762
- }
1763
- if (parsed.indices.length > MAX_COMMENTS) {
1764
- notifyCommand(ctx, `triage_comments accepts at most ${MAX_COMMENTS} comments per run; choose a smaller subset.`, 'error');
1765
- continue;
1766
- }
1767
- return parsed.indices.map((index) => comments[index]);
1768
- }
2083
+ const inputPrompt = `${selectionPrompt}\n\nEnter comment numbers to investigate.`;
2084
+ const inputHint =
2085
+ comments.length <= MAX_COMMENTS
2086
+ ? `Examples: 1,3-5 or all (max ${MAX_COMMENTS})`
2087
+ : `Examples: 1,3-5 (choose up to ${MAX_COMMENTS})`;
2088
+
2089
+ while (true) {
2090
+ const input = await ctx.ui.input(inputPrompt, inputHint);
2091
+ if (input === undefined) return undefined;
2092
+ const parsed = parseSelectionList(input, comments.length);
2093
+ if (!parsed.ok) {
2094
+ notifyCommand(ctx, parsed.error, "error");
2095
+ continue;
2096
+ }
2097
+ if (parsed.indices.length > MAX_COMMENTS) {
2098
+ notifyCommand(
2099
+ ctx,
2100
+ `triage_comments accepts at most ${MAX_COMMENTS} comments per run; choose a smaller subset.`,
2101
+ "error",
2102
+ );
2103
+ continue;
2104
+ }
2105
+ return parsed.indices.map((index) => comments[index]);
2106
+ }
1769
2107
  }
1770
2108
 
1771
2109
  async function choosePrComments(
1772
- ctx: ExtensionCommandContext,
1773
- pr: PullRequestContext,
1774
- comments: CommandComment[],
1775
- filterSummary?: AppliedInlineCommentFilter,
2110
+ ctx: ExtensionCommandContext,
2111
+ pr: PullRequestContext,
2112
+ comments: CommandComment[],
2113
+ filterSummary?: AppliedInlineCommentFilter,
1776
2114
  ): Promise<CommandComment[] | undefined> {
1777
- const options = comments.length <= MAX_COMMENTS
1778
- ? ['Investigate all displayed comments', 'Choose a subset', 'Cancel']
1779
- : ['Choose a subset', 'Cancel'];
1780
- const selectionPrompt = formatFetchedCommentsForSelection(pr, comments, filterSummary);
1781
- const decision = await ctx.ui.select(selectionPrompt, options);
1782
- if (!decision || decision === 'Cancel') return undefined;
1783
- if (decision === 'Investigate all displayed comments') return comments;
1784
- return promptForCommentSubset(ctx, comments, selectionPrompt);
2115
+ const options =
2116
+ comments.length <= MAX_COMMENTS
2117
+ ? ["Investigate all displayed comments", "Choose a subset", "Cancel"]
2118
+ : ["Choose a subset", "Cancel"];
2119
+ const selectionPrompt = formatFetchedCommentsForSelection(pr, comments, filterSummary);
2120
+ const decision = await ctx.ui.select(selectionPrompt, options);
2121
+ if (!decision || decision === "Cancel") return undefined;
2122
+ if (decision === "Investigate all displayed comments") return comments;
2123
+ return promptForCommentSubset(ctx, comments, selectionPrompt);
1785
2124
  }
1786
2125
 
1787
2126
  function createPastedComment(body: string): CommandComment {
1788
- return {
1789
- id: 'pasted-feedback:1',
1790
- body,
1791
- metadata: {
1792
- source: 'pasted_feedback',
1793
- displayNumber: 1,
1794
- capturedBy: '/triage-comments',
1795
- },
1796
- sourceLabel: 'pasted feedback',
1797
- displayNumber: 1,
1798
- sortIndex: 1,
1799
- };
2127
+ return {
2128
+ id: "pasted-feedback:1",
2129
+ body,
2130
+ metadata: {
2131
+ source: "pasted_feedback",
2132
+ displayNumber: 1,
2133
+ capturedBy: "/triage-comments",
2134
+ },
2135
+ sourceLabel: "pasted feedback",
2136
+ displayNumber: 1,
2137
+ sortIndex: 1,
2138
+ };
1800
2139
  }
1801
2140
 
1802
2141
  function toPayloadComment(comment: CommandComment): Record<string, unknown> {
1803
- return compactRecord({
1804
- id: comment.id,
1805
- body: comment.body,
1806
- path: comment.path,
1807
- line: comment.line,
1808
- startLine: comment.startLine,
1809
- side: comment.side,
1810
- diffHunk: comment.diffHunk,
1811
- author: comment.author,
1812
- url: comment.url,
1813
- createdAt: comment.createdAt,
1814
- metadata: comment.metadata,
1815
- });
2142
+ return compactRecord({
2143
+ id: comment.id,
2144
+ body: comment.body,
2145
+ path: comment.path,
2146
+ line: comment.line,
2147
+ startLine: comment.startLine,
2148
+ side: comment.side,
2149
+ diffHunk: comment.diffHunk,
2150
+ author: comment.author,
2151
+ url: comment.url,
2152
+ createdAt: comment.createdAt,
2153
+ metadata: comment.metadata,
2154
+ });
1816
2155
  }
1817
2156
 
1818
2157
  function buildCommandPayload(
1819
- comments: CommandComment[],
1820
- options: { pr?: PullRequestContext; totalDisplayed: number; source: 'paste' | 'pr'; filterSummary?: AppliedInlineCommentFilter },
2158
+ comments: CommandComment[],
2159
+ options: {
2160
+ pr?: PullRequestContext;
2161
+ totalDisplayed: number;
2162
+ source: "paste" | "pr";
2163
+ filterSummary?: AppliedInlineCommentFilter;
2164
+ },
1821
2165
  ): TriageCommandPayload {
1822
- const prSelectionContext = options.filterSummary
1823
- ? `Selected by /triage-comments after fetching ${options.filterSummary.originalCount} PR comment(s), applying the inline review-comment filter, displaying ${options.totalDisplayed} comment(s), and receiving explicit user selection. ${formatInlineFilterContext(options.filterSummary)}`
1824
- : `Selected by /triage-comments after displaying ${options.totalDisplayed} fetched PR comment(s) and receiving explicit user selection.`;
1825
- const payload: TriageCommandPayload = {
1826
- comments: comments.map(toPayloadComment),
1827
- context:
1828
- options.source === 'pr'
1829
- ? `${prSelectionContext} Do not implement changes until the user chooses a handling option.`
1830
- : 'Pasted feedback captured by /triage-comments. Do not implement changes until the user chooses a handling option.',
1831
- };
1832
-
1833
- if (options.pr) {
1834
- payload.pr = compactRecord({
1835
- number: options.pr.number,
1836
- title: options.pr.title,
1837
- url: options.pr.url,
1838
- repository: options.pr.repository,
1839
- headRef: options.pr.headRef,
1840
- headSha: options.pr.headSha,
1841
- baseRef: options.pr.baseRef,
1842
- baseSha: options.pr.baseSha,
1843
- });
1844
- const base = optionalRecord({ branch: options.pr.baseRef, sha: options.pr.baseSha });
1845
- if (base) payload.base = base;
1846
- }
1847
-
1848
- return payload;
1849
- }
1850
-
1851
- function buildTriageUserPrompt(payload: TriageCommandPayload, selectedCount: number, totalDisplayed: number): string {
1852
- const selectionNote = selectedCount === totalDisplayed ? `${selectedCount} selected item(s)` : `${selectedCount} selected item(s) out of ${totalDisplayed} displayed`;
1853
- 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.`;
2166
+ const prSelectionContext = options.filterSummary
2167
+ ? `Selected by /triage-comments after fetching ${options.filterSummary.originalCount} PR comment(s), applying the inline review-comment filter, displaying ${options.totalDisplayed} comment(s), and receiving explicit user selection. ${formatInlineFilterContext(options.filterSummary)}`
2168
+ : `Selected by /triage-comments after displaying ${options.totalDisplayed} fetched PR comment(s) and receiving explicit user selection.`;
2169
+ const payload: TriageCommandPayload = {
2170
+ comments: comments.map(toPayloadComment),
2171
+ context:
2172
+ options.source === "pr"
2173
+ ? `${prSelectionContext} Do not implement changes until the user chooses a handling option.`
2174
+ : "Pasted feedback captured by /triage-comments. Do not implement changes until the user chooses a handling option.",
2175
+ };
2176
+
2177
+ if (options.pr) {
2178
+ payload.pr = compactRecord({
2179
+ number: options.pr.number,
2180
+ title: options.pr.title,
2181
+ url: options.pr.url,
2182
+ repository: options.pr.repository,
2183
+ headRef: options.pr.headRef,
2184
+ headSha: options.pr.headSha,
2185
+ baseRef: options.pr.baseRef,
2186
+ baseSha: options.pr.baseSha,
2187
+ });
2188
+ const base = optionalRecord({ branch: options.pr.baseRef, sha: options.pr.baseSha });
2189
+ if (base) payload.base = base;
2190
+ }
2191
+
2192
+ return payload;
2193
+ }
2194
+
2195
+ function buildTriageUserPrompt(
2196
+ payload: TriageCommandPayload,
2197
+ selectedCount: number,
2198
+ totalDisplayed: number,
2199
+ ): string {
2200
+ const selectionNote =
2201
+ selectedCount === totalDisplayed
2202
+ ? `${selectedCount} selected item(s)`
2203
+ : `${selectedCount} selected item(s) out of ${totalDisplayed} displayed`;
2204
+ 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.`;
1854
2205
  }
1855
2206
 
1856
2207
  function sendTriageUserMessage(
1857
- pi: ExtensionAPI,
1858
- ctx: ExtensionCommandContext,
1859
- payload: TriageCommandPayload,
1860
- selectedCount: number,
1861
- totalDisplayed: number,
2208
+ pi: ExtensionAPI,
2209
+ ctx: ExtensionCommandContext,
2210
+ payload: TriageCommandPayload,
2211
+ selectedCount: number,
2212
+ totalDisplayed: number,
1862
2213
  ): void {
1863
- const prompt = buildTriageUserPrompt(payload, selectedCount, totalDisplayed);
1864
- if (ctx.isIdle()) pi.sendUserMessage(prompt);
1865
- else pi.sendUserMessage(prompt, { deliverAs: 'followUp' });
1866
- notifyCommand(ctx, `Sent ${selectedCount} selected comment(s) to the main agent for triage_comments.`, 'info');
1867
- }
1868
-
1869
- async function runPasteMode(pi: ExtensionAPI, ctx: ExtensionCommandContext, prefill?: string): Promise<void> {
1870
- const feedback = await ctx.ui.editor('Paste feedback for /triage-comments', prefill ?? '');
1871
- if (feedback === undefined) return;
1872
- const body = feedback.trim();
1873
- if (!body) {
1874
- notifyCommand(ctx, 'No feedback was provided; /triage-comments paste mode was cancelled.', 'warning');
1875
- return;
1876
- }
1877
-
1878
- const comments = [createPastedComment(body)];
1879
- const payload = buildCommandPayload(comments, { totalDisplayed: 1, source: 'paste' });
1880
- sendTriageUserMessage(pi, ctx, payload, comments.length, 1);
1881
- }
1882
-
1883
- async function runPrMode(pi: ExtensionAPI, ctx: ExtensionCommandContext, target?: string): Promise<void> {
1884
- let prTarget = target?.trim();
1885
- let detectedPr: PullRequestContext | undefined;
1886
-
1887
- if (!prTarget) {
1888
- ctx.ui.setStatus('triage-comments', 'triage-comments: checking current branch PR…');
1889
- detectedPr = await detectCurrentBranchPullRequest(pi, ctx);
1890
- ctx.ui.setStatus('triage-comments', undefined);
1891
- if (detectedPr) {
1892
- prTarget = detectedPr.url ?? String(detectedPr.number);
1893
- notifyCommand(ctx, `Detected PR ${detectedPr.repository}#${detectedPr.number} from the current branch.`, 'info');
1894
- }
1895
- }
1896
-
1897
- if (!prTarget) {
1898
- const input = await ctx.ui.input('PR URL or number', 'For example: 123, #123, or https://github.com/owner/repo/pull/123');
1899
- if (input === undefined) return;
1900
- prTarget = input.trim();
1901
- }
1902
-
1903
- if (!normalizePrTarget(prTarget)) {
1904
- notifyCommand(ctx, 'PR input must be a PR number (for example, 123 or #123) or a GitHub pull request URL.', 'error');
1905
- return;
1906
- }
1907
-
1908
- ctx.ui.setStatus('triage-comments', 'triage-comments: fetching PR comments…');
1909
- try {
1910
- notifyCommand(ctx, 'Fetching PR comments with gh (read-only)…', 'info');
1911
- const { pr, comments } = detectedPr
1912
- ? { pr: detectedPr, comments: await fetchCommentsForPullRequest(pi, ctx, detectedPr) }
1913
- : await fetchPrCommentsForTriage(pi, ctx, prTarget);
1914
- if (comments.length === 0) {
1915
- notifyCommand(ctx, `No review comments, issue comments, or review bodies were found for ${pr.repository}#${pr.number}.`, 'info');
1916
- return;
1917
- }
1918
-
1919
- const filter = await promptForInlineCommentFilter(ctx);
1920
- if (!filter) return;
1921
- const filtered = applyInlineCommentFilter(comments, filter);
1922
- if (filtered.comments.length === 0) {
1923
- notifyCommand(ctx, `${formatInlineFilterNotice(filtered.summary)} No comments remain to send to triage_comments.`, 'info');
1924
- return;
1925
- }
1926
-
1927
- const selected = await choosePrComments(ctx, pr, filtered.comments, filtered.summary);
1928
- if (!selected || selected.length === 0) return;
1929
- const payload = buildCommandPayload(selected, { pr, totalDisplayed: filtered.comments.length, source: 'pr', filterSummary: filtered.summary });
1930
- sendTriageUserMessage(pi, ctx, payload, selected.length, filtered.comments.length);
1931
- } catch (error) {
1932
- notifyCommand(ctx, formatErrorMessage(error), 'error');
1933
- } finally {
1934
- ctx.ui.setStatus('triage-comments', undefined);
1935
- }
2214
+ const prompt = buildTriageUserPrompt(payload, selectedCount, totalDisplayed);
2215
+ if (ctx.isIdle()) pi.sendUserMessage(prompt);
2216
+ else pi.sendUserMessage(prompt, { deliverAs: "followUp" });
2217
+ notifyCommand(
2218
+ ctx,
2219
+ `Sent ${selectedCount} selected comment(s) to the main agent for triage_comments.`,
2220
+ "info",
2221
+ );
2222
+ }
2223
+
2224
+ async function runPasteMode(
2225
+ pi: ExtensionAPI,
2226
+ ctx: ExtensionCommandContext,
2227
+ prefill?: string,
2228
+ ): Promise<void> {
2229
+ const feedback = await ctx.ui.editor("Paste feedback for /triage-comments", prefill ?? "");
2230
+ if (feedback === undefined) return;
2231
+ const body = feedback.trim();
2232
+ if (!body) {
2233
+ notifyCommand(
2234
+ ctx,
2235
+ "No feedback was provided; /triage-comments paste mode was cancelled.",
2236
+ "warning",
2237
+ );
2238
+ return;
2239
+ }
2240
+
2241
+ const comments = [createPastedComment(body)];
2242
+ const payload = buildCommandPayload(comments, { totalDisplayed: 1, source: "paste" });
2243
+ sendTriageUserMessage(pi, ctx, payload, comments.length, 1);
2244
+ }
2245
+
2246
+ async function runPrMode(
2247
+ pi: ExtensionAPI,
2248
+ ctx: ExtensionCommandContext,
2249
+ target?: string,
2250
+ ): Promise<void> {
2251
+ let prTarget = target?.trim();
2252
+ let detectedPr: PullRequestContext | undefined;
2253
+
2254
+ if (!prTarget) {
2255
+ ctx.ui.setStatus("triage-comments", "triage-comments: checking current branch PR…");
2256
+ detectedPr = await detectCurrentBranchPullRequest(pi, ctx);
2257
+ ctx.ui.setStatus("triage-comments", undefined);
2258
+ if (detectedPr) {
2259
+ prTarget = detectedPr.url ?? String(detectedPr.number);
2260
+ notifyCommand(
2261
+ ctx,
2262
+ `Detected PR ${detectedPr.repository}#${detectedPr.number} from the current branch.`,
2263
+ "info",
2264
+ );
2265
+ }
2266
+ }
2267
+
2268
+ if (!prTarget) {
2269
+ const input = await ctx.ui.input(
2270
+ "PR URL or number",
2271
+ "For example: 123, #123, or https://github.com/owner/repo/pull/123",
2272
+ );
2273
+ if (input === undefined) return;
2274
+ prTarget = input.trim();
2275
+ }
2276
+
2277
+ if (!normalizePrTarget(prTarget)) {
2278
+ notifyCommand(
2279
+ ctx,
2280
+ "PR input must be a PR number (for example, 123 or #123) or a GitHub pull request URL.",
2281
+ "error",
2282
+ );
2283
+ return;
2284
+ }
2285
+
2286
+ ctx.ui.setStatus("triage-comments", "triage-comments: fetching PR comments…");
2287
+ try {
2288
+ notifyCommand(ctx, "Fetching PR comments with gh (read-only)…", "info");
2289
+ const { pr, comments } = detectedPr
2290
+ ? { pr: detectedPr, comments: await fetchCommentsForPullRequest(pi, ctx, detectedPr) }
2291
+ : await fetchPrCommentsForTriage(pi, ctx, prTarget);
2292
+ if (comments.length === 0) {
2293
+ notifyCommand(
2294
+ ctx,
2295
+ `No review comments, issue comments, or review bodies were found for ${pr.repository}#${pr.number}.`,
2296
+ "info",
2297
+ );
2298
+ return;
2299
+ }
2300
+
2301
+ const filter = await promptForInlineCommentFilter(ctx);
2302
+ if (!filter) return;
2303
+ const filtered = applyInlineCommentFilter(comments, filter);
2304
+ if (filtered.comments.length === 0) {
2305
+ notifyCommand(
2306
+ ctx,
2307
+ `${formatInlineFilterNotice(filtered.summary)} No comments remain to send to triage_comments.`,
2308
+ "info",
2309
+ );
2310
+ return;
2311
+ }
2312
+
2313
+ const selected = await choosePrComments(ctx, pr, filtered.comments, filtered.summary);
2314
+ if (!selected || selected.length === 0) return;
2315
+ const payload = buildCommandPayload(selected, {
2316
+ pr,
2317
+ totalDisplayed: filtered.comments.length,
2318
+ source: "pr",
2319
+ filterSummary: filtered.summary,
2320
+ });
2321
+ sendTriageUserMessage(pi, ctx, payload, selected.length, filtered.comments.length);
2322
+ } catch (error) {
2323
+ notifyCommand(ctx, formatErrorMessage(error), "error");
2324
+ } finally {
2325
+ ctx.ui.setStatus("triage-comments", undefined);
2326
+ }
1936
2327
  }
1937
2328
 
1938
2329
  export const __test__ = {
1939
- assertToolPathInsideCwd,
1940
- buildCommandPayload,
1941
- buildSystemPrompt,
1942
- buildUserPrompt,
1943
- createTriageRuntimeGuardExtension,
1944
- ensureImplementationNote,
1945
- formatFetchedCommentsForSelection,
1946
- formatInlineFilterContext,
1947
- formatToolCall,
1948
- getBlockedBashReason,
1949
- normalizeComment,
1950
- normalizeInput,
1951
- parseSelectionList,
1952
- parseTriageCommandArgs,
1953
- prepareArguments,
1954
- aggregateAssistantUsage,
1955
- addAssistantMessageUsage,
1956
- addSessionEventUsage,
1957
- inspectFinalAssistant,
1958
- classifyRunFailure,
2330
+ assertToolPathInsideCwd,
2331
+ buildCommandPayload,
2332
+ buildSystemPrompt,
2333
+ buildUserPrompt,
2334
+ createTriageRuntimeGuardExtension,
2335
+ ensureImplementationNote,
2336
+ formatFetchedCommentsForSelection,
2337
+ formatInlineFilterContext,
2338
+ formatToolCall,
2339
+ getBlockedBashReason,
2340
+ normalizeComment,
2341
+ normalizeInput,
2342
+ parseSelectionList,
2343
+ parseTriageCommandArgs,
2344
+ prepareArguments,
2345
+ aggregateAssistantUsage,
2346
+ addAssistantMessageUsage,
2347
+ addSessionEventUsage,
2348
+ inspectFinalAssistant,
2349
+ classifyRunFailure,
1959
2350
  };
1960
2351
 
1961
2352
  export default function triageCommentsExtension(pi: ExtensionAPI) {
1962
- pi.on("tool_result", async (event) => {
1963
- if (event.toolName !== "triage_comments") return undefined;
1964
- const details = event.details as { status?: unknown } | undefined;
1965
- if (details?.status !== "error") return undefined;
1966
- return { isError: true };
1967
- });
1968
-
1969
- pi.registerCommand("triage-comments", {
1970
- description: "Collect pasted feedback or PR comments, then start a triage_comments investigation",
1971
- getArgumentCompletions: (prefix) => {
1972
- const commands = ["paste", "pr", "help"];
1973
- const normalized = prefix.trim().toLowerCase();
1974
- const matches = commands.filter((command) => command.startsWith(normalized));
1975
- return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null;
1976
- },
1977
- handler: async (args, ctx) => {
1978
- const parsed = parseTriageCommandArgs(args);
1979
- if (parsed.help) {
1980
- notifyCommand(ctx, TRIAGE_COMMAND_USAGE, "info");
1981
- return;
1982
- }
1983
-
1984
- if (parsed.error) {
1985
- notifyCommand(ctx, parsed.error, "error");
1986
- return;
1987
- }
1988
-
1989
- if (!ctx.hasUI) {
1990
- notifyCommand(
1991
- ctx,
1992
- `${TRIAGE_COMMAND_USAGE}\n\nThis intake flow requires The Last Harness interactive UI for the editor, PR comment display, and all/subset confirmation.`,
1993
- "warning",
1994
- );
1995
- return;
1996
- }
1997
-
1998
- let mode = parsed.mode;
1999
- if (!mode) {
2000
- const selection = await ctx.ui.select("/triage-comments: choose feedback source", [
2001
- "Paste feedback",
2002
- "Fetch comments from a PR",
2003
- "Cancel",
2004
- ]);
2005
- if (!selection || selection === "Cancel") return;
2006
- mode = selection === "Paste feedback" ? "paste" : "pr";
2007
- }
2008
-
2009
- if (mode === "paste") {
2010
- await runPasteMode(pi, ctx, parsed.pastePrefill);
2011
- return;
2012
- }
2013
-
2014
- await runPrMode(pi, ctx, parsed.target);
2015
- },
2016
- });
2017
-
2018
- pi.registerTool({
2019
- name: "triage_comments",
2020
- label: "Triage comments",
2021
- description:
2022
- "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.",
2023
- promptSnippet:
2024
- "Triage selected PR review comments in a read-only isolated subagent; returns verdicts, evidence, response text, and handling options without editing files.",
2025
- promptGuidelines: [
2026
- "Use triage_comments when selected PR review comments need evidence-based classification against the local checkout.",
2027
- "Do not use triage_comments to implement changes; ask the user which valid handling option to take after triage.",
2028
- ],
2029
- parameters: TriageCommentsParams,
2030
- prepareArguments,
2031
-
2032
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
2033
- if (!ctx.model) throw new Error("triage_comments needs an active model, but ctx.model is unavailable.");
2034
- const input = normalizeInput(params);
2035
- const cwd = path.resolve(ctx.cwd);
2036
- const details: TriageDetails = {
2037
- status: "running",
2038
- cwd,
2039
- commentCount: input.comments.length,
2040
- turns: 0,
2041
- toolCalls: [],
2042
- startedAt: Date.now(),
2043
- };
2044
-
2045
- let lastContent = "(triaging comments...)";
2046
- let session: AgentSession | undefined;
2047
- let unsubscribe: (() => void) | undefined;
2048
- let runTimeout: NodeJS.Timeout | undefined;
2049
- let abortListenerAdded = false;
2050
- let callerAborted = Boolean(signal?.aborted);
2051
- const usage = createEmptyUsage();
2052
-
2053
- const emit = () => {
2054
- onUpdate?.({ content: [{ type: "text", text: lastContent }], details });
2055
- };
2056
-
2057
- const abortForCleanup = () => {
2058
- void session?.abort();
2059
- };
2060
-
2061
- const abortFromCaller = () => {
2062
- callerAborted = true;
2063
- details.status = "aborted";
2064
- details.endedAt = Date.now();
2065
- lastContent = "Aborted";
2066
- emit();
2067
- abortForCleanup();
2068
- };
2069
-
2070
- if (signal?.aborted) abortFromCaller();
2071
- if (signal && !signal.aborted) {
2072
- signal.addEventListener("abort", abortFromCaller);
2073
- abortListenerAdded = true;
2074
- }
2075
-
2076
- try {
2077
- emit();
2078
-
2079
- const systemPrompt = buildSystemPrompt({
2080
- cwd,
2081
- maxTurns: MAX_TURNS,
2082
- maxRunSeconds: Math.round(MAX_RUN_MS / 1000),
2083
- });
2084
-
2085
- // Keep the guarded subagent from inheriting user/project shell prefix or shell path settings.
2086
- const isolatedSettingsManager = SettingsManager.inMemory({});
2087
- const resourceLoader = new DefaultResourceLoader({
2088
- cwd,
2089
- agentDir: getAgentDir(),
2090
- settingsManager: isolatedSettingsManager,
2091
- noExtensions: true,
2092
- noSkills: true,
2093
- noPromptTemplates: true,
2094
- noThemes: true,
2095
- noContextFiles: true,
2096
- extensionFactories: [createTriageRuntimeGuardExtension({ cwd, maxTurns: MAX_TURNS })],
2097
- systemPromptOverride: () => systemPrompt,
2098
- appendSystemPromptOverride: () => [],
2099
- skillsOverride: () => ({ skills: [], diagnostics: [] }),
2100
- promptsOverride: () => ({ prompts: [], diagnostics: [] }),
2101
- themesOverride: () => ({ themes: [], diagnostics: [] }),
2102
- agentsFilesOverride: () => ({ agentsFiles: [] }),
2103
- });
2104
-
2105
- await resourceLoader.reload();
2106
-
2107
- const created = await createAgentSession({
2108
- cwd,
2109
- ...getModelRuntimeOption(ctx),
2110
- resourceLoader,
2111
- settingsManager: isolatedSettingsManager,
2112
- sessionManager: SessionManager.inMemory(cwd),
2113
- model: ctx.model,
2114
- thinkingLevel: pi.getThinkingLevel(),
2115
- tools: ["read", "grep", "find", "ls", "bash"],
2116
- });
2117
-
2118
- session = created.session;
2119
- unsubscribe = session.subscribe((event) => {
2120
- addSessionEventUsage(usage, event);
2121
- switch (event.type) {
2122
- case "turn_end":
2123
- details.turns += 1;
2124
- emit();
2125
- break;
2126
- case "tool_execution_start":
2127
- details.toolCalls.push({
2128
- id: event.toolCallId,
2129
- name: event.toolName,
2130
- args: event.args,
2131
- startedAt: Date.now(),
2132
- });
2133
- if (details.toolCalls.length > MAX_TOOL_CALLS_TO_KEEP) {
2134
- details.toolCalls.splice(0, details.toolCalls.length - MAX_TOOL_CALLS_TO_KEEP);
2135
- }
2136
- emit();
2137
- break;
2138
- case "tool_execution_end": {
2139
- const call = details.toolCalls.find((item) => item.id === event.toolCallId);
2140
- if (call) {
2141
- call.endedAt = Date.now();
2142
- call.isError = event.isError;
2143
- }
2144
- emit();
2145
- break;
2146
- }
2147
- }
2148
- });
2149
-
2150
- if (!callerAborted) {
2151
- const promptPromise = session.prompt(buildUserPrompt(input, cwd), { expandPromptTemplates: false });
2152
- const timeoutPromise = new Promise<never>((_resolve, reject) => {
2153
- runTimeout = setTimeout(() => {
2154
- abortForCleanup();
2155
- reject(new Error(`triage_comments timed out after ${Math.round(MAX_RUN_MS / 1000)} seconds.`));
2156
- }, MAX_RUN_MS);
2157
- });
2158
- await Promise.race([promptPromise, timeoutPromise]);
2159
- }
2160
-
2161
- const outcome = session ? inspectFinalAssistant(session.state.messages) : { ok: false as const, reason: "the isolated triage_comments session produced no assistant message" };
2162
- if (outcome.ok) {
2163
- lastContent = ensureImplementationNote(outcome.answer);
2164
- details.status = "done";
2165
- } else if (callerAborted) {
2166
- lastContent = "Aborted";
2167
- details.status = "aborted";
2168
- } else {
2169
- throw new Error(outcome.reason);
2170
- }
2171
- details.endedAt = Date.now();
2172
- emit();
2173
-
2174
- return {
2175
- content: [{ type: "text", text: lastContent }],
2176
- details,
2177
- usage,
2178
- };
2179
- } catch (error) {
2180
- const failure = classifyRunFailure(error, callerAborted);
2181
- const { message } = failure;
2182
- details.status = failure.status;
2183
- details.error = failure.error;
2184
- details.endedAt = Date.now();
2185
- lastContent = message;
2186
- emit();
2187
-
2188
- return {
2189
- content: [{ type: "text", text: message }],
2190
- details,
2191
- usage,
2192
- };
2193
- } finally {
2194
- if (runTimeout) clearTimeout(runTimeout);
2195
- if (signal && abortListenerAdded) signal.removeEventListener("abort", abortFromCaller);
2196
- unsubscribe?.();
2197
- session?.dispose();
2198
- }
2199
- },
2200
-
2201
- renderCall(args, theme) {
2202
- const comments = Array.isArray((args as { comments?: unknown })?.comments) ? (args as { comments: unknown[] }).comments : [];
2203
- const first = comments[0];
2204
- const body = typeof first === "string" ? first : asTrimmedString(asRecord(first)?.body) ?? "";
2205
- const pr = asRecord((args as { pr?: unknown })?.pr);
2206
- const prLabel = pr?.number ? `PR #${pr.number}` : pr?.url ? "PR context" : "no PR context";
2207
- return new Text(
2208
- `${theme.fg("muted", `${comments.length} comments • ${prLabel}`)} · ${theme.fg("toolOutput", shorten(body, 90))}`,
2209
- 0,
2210
- 0,
2211
- );
2212
- },
2213
-
2214
- renderResult(result, { expanded, isPartial }, theme) {
2215
- const details = result.details as TriageDetails | undefined;
2216
- if (!details) {
2217
- const first = result.content[0];
2218
- return new Text(first?.type === "text" ? first.text : "(no output)", 0, 0);
2219
- }
2220
-
2221
- const status = isPartial ? "running" : details.status;
2222
- const icon =
2223
- status === "done"
2224
- ? theme.fg("success", "✓")
2225
- : status === "error"
2226
- ? theme.fg("error", "✗")
2227
- : status === "aborted"
2228
- ? theme.fg("warning", "◼")
2229
- : theme.fg("warning", "⏳");
2230
- const header = `${icon} ${theme.fg("toolTitle", theme.bold("triage_comments "))}${theme.fg(
2231
- "dim",
2232
- `${details.commentCount} comments • ${details.turns} turns • ${details.toolCalls.length} tools`,
2233
- )}`;
2234
- const cwdLine = `${theme.fg("muted", "checkout: ")}${theme.fg("toolOutput", details.cwd)}`;
2235
- const answer =
2236
- (result.content[0]?.type === "text" ? result.content[0].text : renderAnswer(details)).trim() || "(no output)";
2237
-
2238
- const toolLines = details.toolCalls.slice(expanded ? 0 : -6).map((call) => {
2239
- const callIcon = call.isError ? theme.fg("error", "✗") : theme.fg("dim", "→");
2240
- return `${callIcon} ${theme.fg("toolOutput", formatToolCall(call))}`;
2241
- });
2242
- if (!expanded && details.toolCalls.length > 6) toolLines.unshift(theme.fg("muted", "…"));
2243
-
2244
- if (status === "running") {
2245
- const parts = [header, cwdLine];
2246
- if (toolLines.length) parts.push("", theme.fg("muted", "Read-only checks:"), ...toolLines);
2247
- parts.push("", theme.fg("muted", "Triaging comments…"));
2248
- return new Text(parts.join("\n"), 0, 0);
2249
- }
2250
-
2251
- if (!expanded) {
2252
- const lines = answer.split("\n");
2253
- const previewLines = lines.slice(0, COLLAPSED_PREVIEW_LINES);
2254
- const parts = [header, cwdLine, "", theme.fg("toolOutput", previewLines.join("\n"))];
2255
- if (lines.length > previewLines.length) parts.push(theme.fg("muted", "(Ctrl+O to expand)"));
2256
- if (toolLines.length) parts.push("", theme.fg("muted", "Read-only checks:"), ...toolLines);
2257
- return new Text(parts.join("\n"), 0, 0);
2258
- }
2259
-
2260
- const container = new Container();
2261
- container.addChild(new Text(header, 0, 0));
2262
- container.addChild(new Text(cwdLine, 0, 0));
2263
- if (toolLines.length) {
2264
- container.addChild(new Spacer(1));
2265
- container.addChild(new Text([theme.fg("muted", "Read-only checks:"), ...toolLines].join("\n"), 0, 0));
2266
- }
2267
- container.addChild(new Spacer(1));
2268
- container.addChild(new Markdown(answer, 0, 0, getMarkdownTheme()));
2269
- return container;
2270
- },
2271
- });
2353
+ pi.on("tool_result", async (event) => {
2354
+ if (event.toolName !== "triage_comments") return undefined;
2355
+ const details = event.details as { status?: unknown } | undefined;
2356
+ if (details?.status !== "error") return undefined;
2357
+ return { isError: true };
2358
+ });
2359
+
2360
+ pi.registerCommand("triage-comments", {
2361
+ description:
2362
+ "Collect pasted feedback or PR comments, then start a triage_comments investigation",
2363
+ getArgumentCompletions: (prefix) => {
2364
+ const commands = ["paste", "pr", "help"];
2365
+ const normalized = prefix.trim().toLowerCase();
2366
+ const matches = commands.filter((command) => command.startsWith(normalized));
2367
+ return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null;
2368
+ },
2369
+ handler: async (args, ctx) => {
2370
+ const parsed = parseTriageCommandArgs(args);
2371
+ if (parsed.help) {
2372
+ notifyCommand(ctx, TRIAGE_COMMAND_USAGE, "info");
2373
+ return;
2374
+ }
2375
+
2376
+ if (parsed.error) {
2377
+ notifyCommand(ctx, parsed.error, "error");
2378
+ return;
2379
+ }
2380
+
2381
+ if (!ctx.hasUI) {
2382
+ notifyCommand(
2383
+ ctx,
2384
+ `${TRIAGE_COMMAND_USAGE}\n\nThis intake flow requires the /triage-comments interactive UI for the editor, PR comment display, and all/subset confirmation.`,
2385
+ "warning",
2386
+ );
2387
+ return;
2388
+ }
2389
+
2390
+ let mode = parsed.mode;
2391
+ if (!mode) {
2392
+ const selection = await ctx.ui.select("/triage-comments: choose feedback source", [
2393
+ "Paste feedback",
2394
+ "Fetch comments from a PR",
2395
+ "Cancel",
2396
+ ]);
2397
+ if (!selection || selection === "Cancel") return;
2398
+ mode = selection === "Paste feedback" ? "paste" : "pr";
2399
+ }
2400
+
2401
+ if (mode === "paste") {
2402
+ await runPasteMode(pi, ctx, parsed.pastePrefill);
2403
+ return;
2404
+ }
2405
+
2406
+ await runPrMode(pi, ctx, parsed.target);
2407
+ },
2408
+ });
2409
+
2410
+ pi.registerTool({
2411
+ name: "triage_comments",
2412
+ label: "Triage comments",
2413
+ description:
2414
+ "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.",
2415
+ promptSnippet:
2416
+ "Triage selected PR review comments in a read-only isolated subagent; returns verdicts, evidence, response text, and handling options without editing files.",
2417
+ promptGuidelines: [
2418
+ "Use triage_comments when selected PR review comments need evidence-based classification against the local checkout.",
2419
+ "Do not use triage_comments to implement changes; ask the user which valid handling option to take after triage.",
2420
+ ],
2421
+ parameters: TriageCommentsParams,
2422
+ prepareArguments,
2423
+
2424
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
2425
+ if (!ctx.model)
2426
+ throw new Error("triage_comments needs an active model, but ctx.model is unavailable.");
2427
+ const input = normalizeInput(params);
2428
+ const cwd = path.resolve(ctx.cwd);
2429
+ const details: TriageDetails = {
2430
+ status: "running",
2431
+ cwd,
2432
+ commentCount: input.comments.length,
2433
+ turns: 0,
2434
+ toolCalls: [],
2435
+ startedAt: Date.now(),
2436
+ };
2437
+
2438
+ let lastContent = "(triaging comments...)";
2439
+ let session: AgentSession | undefined;
2440
+ let unsubscribe: (() => void) | undefined;
2441
+ let runTimeout: NodeJS.Timeout | undefined;
2442
+ let abortListenerAdded = false;
2443
+ let callerAborted = Boolean(signal?.aborted);
2444
+ const usage = createEmptyUsage();
2445
+
2446
+ const emit = () => {
2447
+ onUpdate?.({ content: [{ type: "text", text: lastContent }], details });
2448
+ };
2449
+
2450
+ const abortForCleanup = () => {
2451
+ void session?.abort();
2452
+ };
2453
+
2454
+ const abortFromCaller = () => {
2455
+ callerAborted = true;
2456
+ details.status = "aborted";
2457
+ details.endedAt = Date.now();
2458
+ lastContent = "Aborted";
2459
+ emit();
2460
+ abortForCleanup();
2461
+ };
2462
+
2463
+ if (signal?.aborted) abortFromCaller();
2464
+ if (signal && !signal.aborted) {
2465
+ signal.addEventListener("abort", abortFromCaller);
2466
+ abortListenerAdded = true;
2467
+ }
2468
+
2469
+ try {
2470
+ emit();
2471
+
2472
+ const systemPrompt = buildSystemPrompt({
2473
+ cwd,
2474
+ maxTurns: MAX_TURNS,
2475
+ maxRunSeconds: Math.round(MAX_RUN_MS / 1000),
2476
+ });
2477
+
2478
+ // Keep the guarded subagent from inheriting user/project shell prefix or shell path settings.
2479
+ const isolatedSettingsManager = SettingsManager.inMemory({});
2480
+ const resourceLoader = new DefaultResourceLoader({
2481
+ cwd,
2482
+ agentDir: getAgentDir(),
2483
+ settingsManager: isolatedSettingsManager,
2484
+ noExtensions: true,
2485
+ noSkills: true,
2486
+ noPromptTemplates: true,
2487
+ noThemes: true,
2488
+ noContextFiles: true,
2489
+ extensionFactories: [createTriageRuntimeGuardExtension({ cwd, maxTurns: MAX_TURNS })],
2490
+ systemPromptOverride: () => systemPrompt,
2491
+ appendSystemPromptOverride: () => [],
2492
+ skillsOverride: () => ({ skills: [], diagnostics: [] }),
2493
+ promptsOverride: () => ({ prompts: [], diagnostics: [] }),
2494
+ themesOverride: () => ({ themes: [], diagnostics: [] }),
2495
+ agentsFilesOverride: () => ({ agentsFiles: [] }),
2496
+ });
2497
+
2498
+ await resourceLoader.reload();
2499
+
2500
+ const created = await createAgentSession({
2501
+ cwd,
2502
+ ...getModelRuntimeOption(ctx),
2503
+ resourceLoader,
2504
+ settingsManager: isolatedSettingsManager,
2505
+ sessionManager: SessionManager.inMemory(cwd),
2506
+ model: ctx.model,
2507
+ thinkingLevel: pi.getThinkingLevel(),
2508
+ tools: ["read", "grep", "find", "ls", "bash"],
2509
+ });
2510
+
2511
+ session = created.session;
2512
+ unsubscribe = session.subscribe((event) => {
2513
+ addSessionEventUsage(usage, event);
2514
+ switch (event.type) {
2515
+ case "turn_end":
2516
+ details.turns += 1;
2517
+ emit();
2518
+ break;
2519
+ case "tool_execution_start":
2520
+ details.toolCalls.push({
2521
+ id: event.toolCallId,
2522
+ name: event.toolName,
2523
+ args: event.args,
2524
+ startedAt: Date.now(),
2525
+ });
2526
+ if (details.toolCalls.length > MAX_TOOL_CALLS_TO_KEEP) {
2527
+ details.toolCalls.splice(0, details.toolCalls.length - MAX_TOOL_CALLS_TO_KEEP);
2528
+ }
2529
+ emit();
2530
+ break;
2531
+ case "tool_execution_end": {
2532
+ const call = details.toolCalls.find((item) => item.id === event.toolCallId);
2533
+ if (call) {
2534
+ call.endedAt = Date.now();
2535
+ call.isError = event.isError;
2536
+ }
2537
+ emit();
2538
+ break;
2539
+ }
2540
+ }
2541
+ });
2542
+
2543
+ if (!callerAborted) {
2544
+ const promptPromise = session.prompt(buildUserPrompt(input, cwd), {
2545
+ expandPromptTemplates: false,
2546
+ });
2547
+ const timeoutPromise = new Promise<never>((_resolve, reject) => {
2548
+ runTimeout = setTimeout(() => {
2549
+ abortForCleanup();
2550
+ reject(
2551
+ new Error(
2552
+ `triage_comments timed out after ${Math.round(MAX_RUN_MS / 1000)} seconds.`,
2553
+ ),
2554
+ );
2555
+ }, MAX_RUN_MS);
2556
+ });
2557
+ await Promise.race([promptPromise, timeoutPromise]);
2558
+ }
2559
+
2560
+ const outcome = session
2561
+ ? inspectFinalAssistant(session.state.messages)
2562
+ : {
2563
+ ok: false as const,
2564
+ reason: "the isolated triage_comments session produced no assistant message",
2565
+ };
2566
+ if (outcome.ok) {
2567
+ lastContent = ensureImplementationNote(outcome.answer);
2568
+ details.status = "done";
2569
+ } else if (callerAborted) {
2570
+ lastContent = "Aborted";
2571
+ details.status = "aborted";
2572
+ } else {
2573
+ throw new Error(outcome.reason);
2574
+ }
2575
+ details.endedAt = Date.now();
2576
+ emit();
2577
+
2578
+ return {
2579
+ content: [{ type: "text", text: lastContent }],
2580
+ details,
2581
+ usage,
2582
+ };
2583
+ } catch (error) {
2584
+ const failure = classifyRunFailure(error, callerAborted);
2585
+ const { message } = failure;
2586
+ details.status = failure.status;
2587
+ details.error = failure.error;
2588
+ details.endedAt = Date.now();
2589
+ lastContent = message;
2590
+ emit();
2591
+
2592
+ return {
2593
+ content: [{ type: "text", text: message }],
2594
+ details,
2595
+ usage,
2596
+ };
2597
+ } finally {
2598
+ if (runTimeout) clearTimeout(runTimeout);
2599
+ if (signal && abortListenerAdded) signal.removeEventListener("abort", abortFromCaller);
2600
+ unsubscribe?.();
2601
+ session?.dispose();
2602
+ }
2603
+ },
2604
+
2605
+ renderCall(args, theme) {
2606
+ const comments = Array.isArray((args as { comments?: unknown })?.comments)
2607
+ ? (args as { comments: unknown[] }).comments
2608
+ : [];
2609
+ const first = comments[0];
2610
+ const body =
2611
+ typeof first === "string" ? first : (asTrimmedString(asRecord(first)?.body) ?? "");
2612
+ const pr = asRecord((args as { pr?: unknown })?.pr);
2613
+ const prLabel = pr?.number ? `PR #${pr.number}` : pr?.url ? "PR context" : "no PR context";
2614
+ return new Text(
2615
+ `${theme.fg("muted", `${comments.length} comments • ${prLabel}`)} · ${theme.fg("toolOutput", shorten(body, 90))}`,
2616
+ 0,
2617
+ 0,
2618
+ );
2619
+ },
2620
+
2621
+ renderResult(result, { expanded, isPartial }, theme) {
2622
+ const details = result.details as TriageDetails | undefined;
2623
+ if (!details) {
2624
+ const first = result.content[0];
2625
+ return new Text(first?.type === "text" ? first.text : "(no output)", 0, 0);
2626
+ }
2627
+
2628
+ const status = isPartial ? "running" : details.status;
2629
+ const icon =
2630
+ status === "done"
2631
+ ? theme.fg("success", "✓")
2632
+ : status === "error"
2633
+ ? theme.fg("error", "✗")
2634
+ : status === "aborted"
2635
+ ? theme.fg("warning", "◼")
2636
+ : theme.fg("warning", "⏳");
2637
+ const header = `${icon} ${theme.fg("toolTitle", theme.bold("triage_comments "))}${theme.fg(
2638
+ "dim",
2639
+ `${details.commentCount} comments • ${details.turns} turns • ${details.toolCalls.length} tools`,
2640
+ )}`;
2641
+ const cwdLine = `${theme.fg("muted", "checkout: ")}${theme.fg("toolOutput", details.cwd)}`;
2642
+ const answer =
2643
+ (result.content[0]?.type === "text"
2644
+ ? result.content[0].text
2645
+ : renderAnswer(details)
2646
+ ).trim() || "(no output)";
2647
+
2648
+ const toolLines = details.toolCalls.slice(expanded ? 0 : -6).map((call) => {
2649
+ const callIcon = call.isError ? theme.fg("error", "✗") : theme.fg("dim", "→");
2650
+ return `${callIcon} ${theme.fg("toolOutput", formatToolCall(call))}`;
2651
+ });
2652
+ if (!expanded && details.toolCalls.length > 6) toolLines.unshift(theme.fg("muted", "…"));
2653
+
2654
+ if (status === "running") {
2655
+ const parts = [header, cwdLine];
2656
+ if (toolLines.length) parts.push("", theme.fg("muted", "Read-only checks:"), ...toolLines);
2657
+ parts.push("", theme.fg("muted", "Triaging comments…"));
2658
+ return new Text(parts.join("\n"), 0, 0);
2659
+ }
2660
+
2661
+ if (!expanded) {
2662
+ const lines = answer.split("\n");
2663
+ const previewLines = lines.slice(0, COLLAPSED_PREVIEW_LINES);
2664
+ const parts = [header, cwdLine, "", theme.fg("toolOutput", previewLines.join("\n"))];
2665
+ if (lines.length > previewLines.length) parts.push(theme.fg("muted", "(Ctrl+O to expand)"));
2666
+ if (toolLines.length) parts.push("", theme.fg("muted", "Read-only checks:"), ...toolLines);
2667
+ return new Text(parts.join("\n"), 0, 0);
2668
+ }
2669
+
2670
+ const container = new Container();
2671
+ container.addChild(new Text(header, 0, 0));
2672
+ container.addChild(new Text(cwdLine, 0, 0));
2673
+ if (toolLines.length) {
2674
+ container.addChild(new Spacer(1));
2675
+ container.addChild(
2676
+ new Text([theme.fg("muted", "Read-only checks:"), ...toolLines].join("\n"), 0, 0),
2677
+ );
2678
+ }
2679
+ container.addChild(new Spacer(1));
2680
+ container.addChild(new Markdown(answer, 0, 0, getMarkdownTheme()));
2681
+ return container;
2682
+ },
2683
+ });
2272
2684
  }