@diegopetrucci/pi-extensions 0.1.74 → 0.1.78

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