tau-coding-agent 0.1.3 → 0.1.5

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 (60) hide show
  1. package/README.md +1 -1
  2. package/extensions/answer.ts +49 -43
  3. package/extensions/branch-term.ts +11 -16
  4. package/extensions/btw.ts +8 -8
  5. package/extensions/ghostty.ts +65 -12
  6. package/extensions/git-diff-stats.ts +1 -1
  7. package/extensions/git-pr-status.ts +1 -1
  8. package/extensions/insights.ts +6 -5
  9. package/extensions/loop.ts +39 -11
  10. package/extensions/memory.ts +6 -5
  11. package/extensions/notify.ts +25 -6
  12. package/extensions/openai-fast.ts +6 -4
  13. package/extensions/openai-verbosity.ts +6 -4
  14. package/extensions/review/fix.ts +239 -0
  15. package/extensions/review/git.ts +517 -0
  16. package/extensions/review/index.ts +232 -0
  17. package/extensions/review/message-queue.ts +249 -0
  18. package/extensions/review/models.ts +589 -0
  19. package/extensions/review/prompts.ts +337 -0
  20. package/extensions/review/renderers/inline.ts +126 -0
  21. package/extensions/review/request.ts +504 -0
  22. package/extensions/review/review.ts +1305 -0
  23. package/extensions/review/runner.ts +522 -0
  24. package/extensions/review/runtime.ts +275 -0
  25. package/extensions/review/schema.ts +160 -0
  26. package/extensions/review/submit-review-tool.ts +66 -0
  27. package/extensions/review/submit-triage-tool.ts +59 -0
  28. package/extensions/review/triage.ts +746 -0
  29. package/extensions/sandbox/index.ts +428 -103
  30. package/extensions/{interlude.ts → stash.ts} +13 -11
  31. package/extensions/tool-display-mode.ts +173 -17
  32. package/extensions/usage/anthropic.ts +1 -1
  33. package/extensions/usage/github-copilot.ts +1 -1
  34. package/extensions/usage/index.ts +9 -5
  35. package/extensions/usage/minimax.ts +1 -1
  36. package/extensions/usage/openai-codex.ts +1 -1
  37. package/extensions/usage/openrouter.ts +1 -1
  38. package/extensions/usage/providers.ts +0 -2
  39. package/extensions/usage/types.ts +1 -2
  40. package/extensions/usage/zai.ts +1 -1
  41. package/extensions/websearch/README.md +1 -1
  42. package/extensions/websearch/browser/chromium.ts +42 -31
  43. package/extensions/websearch/browser/discovery.ts +11 -9
  44. package/extensions/websearch/browser/firefox.ts +10 -7
  45. package/extensions/websearch/browser/fs.ts +10 -0
  46. package/extensions/websearch/browser/sqlite.ts +22 -15
  47. package/extensions/websearch/config.ts +2 -2
  48. package/extensions/websearch/index.ts +9 -9
  49. package/extensions/websearch/providers/anthropic.pi.ts +1 -1
  50. package/extensions/websearch/providers/gemini.pi.ts +1 -1
  51. package/extensions/websearch/providers/openai-codex.pi.ts +1 -1
  52. package/extensions/websearch/providers/pi-model.shared.ts +2 -2
  53. package/extensions/worktree.ts +36 -36
  54. package/package.json +7 -7
  55. package/skills/git-clean-history/SKILL.md +1 -0
  56. package/skills/git-commit/SKILL.md +2 -2
  57. package/skills/oracle/scripts/oracle +28 -12
  58. package/themes/tau-dark.json +2 -1
  59. package/extensions/review.ts +0 -4347
  60. package/extensions/usage/google-gemini-cli.ts +0 -232
@@ -0,0 +1,504 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ import {
4
+ REVIEW_FOCUS_NAMES,
5
+ type ReviewFocus,
6
+ type ParsedRequest,
7
+ type RequestSignaturePayload,
8
+ type ReviewMessageDetails,
9
+ type ReviewMessageKind,
10
+ type ReviewRequestMode,
11
+ type ReviewTarget,
12
+ } from "./schema.js";
13
+
14
+ const REVIEW_MODE_HINTS = [
15
+ "help",
16
+ "auto",
17
+ "uncommitted",
18
+ "branch",
19
+ "commit",
20
+ "pr",
21
+ "folder",
22
+ "custom",
23
+ ] as const;
24
+
25
+ const REVIEW_FOCUS_ALL_VALUE = "all";
26
+ const REVIEW_FOCUS_OPTION_HINTS = [
27
+ "focus=testing",
28
+ `focus=${REVIEW_FOCUS_NAMES.join(",")}`,
29
+ "focus=all",
30
+ ] as const;
31
+ const VALID_REVIEW_FOCUS_VALUES = [REVIEW_FOCUS_ALL_VALUE, ...REVIEW_FOCUS_NAMES] as const;
32
+
33
+ function isHelpRequest(args: string | undefined): boolean {
34
+ const tokens = tokenizeArgs(args?.trim() ?? "");
35
+ if (tokens.length === 0) return false;
36
+ const first = unquoteToken(tokens[0]).toLowerCase();
37
+ return first === "help";
38
+ }
39
+
40
+ export function getReviewArgumentCompletions(
41
+ prefix: string,
42
+ extraHints: readonly string[] = [],
43
+ ): Array<{ value: string; label: string }> | null {
44
+ const completion = getCompletionToken(prefix);
45
+ const hints = completion.isFirstToken
46
+ ? [...extraHints, ...REVIEW_MODE_HINTS, ...REVIEW_FOCUS_OPTION_HINTS]
47
+ : REVIEW_FOCUS_OPTION_HINTS;
48
+ const matches = Array.from(new Set(hints)).filter((value) => value.startsWith(completion.token));
49
+ if (matches.length === 0) return null;
50
+ return matches.map((value) => ({ value, label: value }));
51
+ }
52
+
53
+ export function getFixArgumentCompletions(
54
+ prefix: string,
55
+ ): Array<{ value: string; label: string }> | null {
56
+ return getReviewArgumentCompletions(prefix, ["loop"]);
57
+ }
58
+
59
+ function getCompletionToken(prefix: string): { token: string; isFirstToken: boolean } {
60
+ const tokens = tokenizeArgs(prefix);
61
+ if (tokens.length === 0) return { token: "", isFirstToken: true };
62
+ if (/\s$/.test(prefix)) return { token: "", isFirstToken: false };
63
+ return {
64
+ token: unquoteToken(tokens[tokens.length - 1] ?? "").toLowerCase(),
65
+ isFirstToken: tokens.length === 1,
66
+ };
67
+ }
68
+
69
+ export function showReviewHelp(pi: ExtensionAPI) {
70
+ pi.sendMessage({
71
+ customType: "review",
72
+ display: true,
73
+ details: { kind: "help" satisfies ReviewMessageKind },
74
+ content: `## /review help
75
+
76
+ Run findings-only code review across up to 6 parallel focuses (general, security, reuse, quality, testing, efficiency). Defaults to all focuses.
77
+
78
+ ### Syntax
79
+ - \`/review [mode] [models=<a,b>] [focus=<focuses>] [context=<text>]\`
80
+ - \`/fix [loop] [mode] [models=<a,b>] [focus=<focuses>] [context=<text>]\`
81
+ - \`/triage <number|url>\`
82
+
83
+ ### Modes
84
+ - \`auto\` (default): working tree first, then branch diff vs base branch.
85
+ - \`uncommitted\`: review tracked + untracked local changes.
86
+ - \`branch <name>\`: review diff from merge-base(name)..HEAD.
87
+ - \`commit <sha>\`: review one commit.
88
+ - \`pr <number|url>\`: checkout PR branch and review against PR base.
89
+ - \`folder <paths...>\`: snapshot review of files/folders (no git diff).
90
+ - \`custom "<instructions>"\`: custom scoped review instructions.
91
+
92
+ ### Options
93
+ - \`models=<a,b>\`: run requested review focuses for each listed model.
94
+ - \`focus=<focuses>\`: comma-separated focuses to run: \`general\`, \`security\`, \`reuse\`, \`quality\`, \`testing\`, \`efficiency\`. Use \`focus=all\` for all focuses.
95
+ - \`context=<text>\`: add extra guidance.
96
+ - For \`/review\`, guides every requested review focus.
97
+ - For \`/fix\`, guides the fix pass; if \`/fix\` must run a fresh review, it guides review too.
98
+ - For spaces, quote the value, e.g. \`context="security and backpressure"\`.
99
+
100
+ ### Examples
101
+ - \`/review\`
102
+ - \`/review help\`
103
+ - \`/review branch main\`
104
+ - \`/review pr 123 models=sonnet,gpt-5\`
105
+ - \`/review folder src/features/foo focus=testing\`
106
+ - \`/review uncommitted focus=testing,quality\`
107
+ - \`/review uncommitted context="security and error handling"\`
108
+ - \`/triage 123\`
109
+ - \`/triage https://github.com/owner/repo/pull/123\`
110
+ - \`/fix\`
111
+ - \`/fix help\`
112
+ - \`/fix context="do not test mocks"\`
113
+ - \`/fix folder src/features/foo focus=testing context="testing-only cleanup"\`
114
+ - \`/fix branch main models=sonnet\`
115
+ - \`/fix loop focus=testing\`
116
+ - \`/fix loop models=sonnet,gpt-5\`
117
+
118
+ ### /fix behavior
119
+ - Uses the last message only when it is a matching \`customType: "review"\` report payload.
120
+ - Otherwise, runs a fresh \`/review\` first.
121
+ - If the last review is stale, \`/fix\` warns and continues.
122
+ - If a review started by \`/fix\` goes stale mid-run, it shows the findings and applies no fixes.
123
+ - Skips execution when there are zero findings.
124
+ - \`loop\` repeats review + fix until a review produces zero findings, review fails/stales, the fix pass is aborted, or a fix pass makes no repository changes.
125
+
126
+ ### /triage behavior
127
+ - Fetches PR feedback from GitHub for the given PR number or URL.
128
+ - Checks out the PR branch locally, then inspects the diff and repository state.
129
+ - Produces one triage row per feedback item with decision \`address\`, \`push_back\`, \`research\`, or \`ignore\`.`,
130
+ });
131
+ }
132
+
133
+ function tokenizeArgs(input: string): string[] {
134
+ return input.match(/[^\s"'=]+=(?:"[^"]*"|'[^']*')|"[^"]*"|'[^']*'|\S+/g) ?? [];
135
+ }
136
+
137
+ function unquoteToken(token: string): string {
138
+ const quoted = token.match(/^(['"])([\s\S]*)\1$/);
139
+ if (!quoted) return token;
140
+ return quoted[2] ?? "";
141
+ }
142
+
143
+ function parseKeyValueOption(
144
+ token: string,
145
+ key: "models" | "model" | "context" | "focus" | "focuses",
146
+ ): string | undefined {
147
+ const pattern = new RegExp(`^${key}=(?:"([\\s\\S]*)"|'([\\s\\S]*)'|(\\S*))$`);
148
+ const match = token.match(pattern);
149
+ if (!match) return undefined;
150
+ const value = (match[1] ?? match[2] ?? match[3] ?? "").trim();
151
+ return value;
152
+ }
153
+
154
+ function isReviewFocus(value: string): value is ReviewFocus {
155
+ return (REVIEW_FOCUS_NAMES as readonly string[]).includes(value);
156
+ }
157
+
158
+ function formatValidReviewFocusValues(): string {
159
+ return VALID_REVIEW_FOCUS_VALUES.join(", ");
160
+ }
161
+
162
+ function parseFocusList(rawFocuses: string[]): { focuses: ReviewFocus[]; explicit: boolean } {
163
+ if (rawFocuses.length === 0) {
164
+ return { focuses: [...REVIEW_FOCUS_NAMES], explicit: false };
165
+ }
166
+
167
+ const selected: ReviewFocus[] = [];
168
+ const seen = new Set<ReviewFocus>();
169
+ let includeAll = false;
170
+
171
+ for (const rawFocus of rawFocuses) {
172
+ if (!rawFocus) {
173
+ throw new Error(
174
+ `focus= requires at least one value. Valid values: ${formatValidReviewFocusValues()}.`,
175
+ );
176
+ }
177
+
178
+ for (const part of rawFocus.split(",")) {
179
+ const focus = part.trim().toLowerCase();
180
+ if (!focus) {
181
+ throw new Error(
182
+ `focus= contains an empty focus name. Valid values: ${formatValidReviewFocusValues()}.`,
183
+ );
184
+ }
185
+ if (focus === REVIEW_FOCUS_ALL_VALUE) {
186
+ includeAll = true;
187
+ continue;
188
+ }
189
+ if (!isReviewFocus(focus)) {
190
+ throw new Error(
191
+ `Invalid focus "${part.trim()}". Valid values: ${formatValidReviewFocusValues()}.`,
192
+ );
193
+ }
194
+ if (!seen.has(focus)) {
195
+ selected.push(focus);
196
+ seen.add(focus);
197
+ }
198
+ }
199
+ }
200
+
201
+ return {
202
+ focuses: includeAll ? [...REVIEW_FOCUS_NAMES] : selected,
203
+ explicit: true,
204
+ };
205
+ }
206
+
207
+ export function parseRequestArgs(args: string | undefined): ParsedRequest {
208
+ const raw = args?.trim() ?? "";
209
+ if (!raw) {
210
+ return {
211
+ target: { type: "auto" },
212
+ mode: "auto",
213
+ models: [],
214
+ focuses: [...REVIEW_FOCUS_NAMES],
215
+ targetExplicit: false,
216
+ focusExplicit: false,
217
+ };
218
+ }
219
+
220
+ const tokens = tokenizeArgs(raw);
221
+ const rawModels: string[] = [];
222
+ const rawFocuses: string[] = [];
223
+ const rawContext: string[] = [];
224
+ const modeTokens: string[] = [];
225
+
226
+ for (const token of tokens) {
227
+ const modelsValue = parseKeyValueOption(token, "models") ?? parseKeyValueOption(token, "model");
228
+ if (modelsValue !== undefined) {
229
+ if (!modelsValue) continue;
230
+ for (const model of modelsValue.split(",")) {
231
+ const trimmed = model.trim();
232
+ if (trimmed) rawModels.push(trimmed);
233
+ }
234
+ continue;
235
+ }
236
+
237
+ const focusValue = parseKeyValueOption(token, "focus") ?? parseKeyValueOption(token, "focuses");
238
+ if (focusValue !== undefined) {
239
+ rawFocuses.push(focusValue);
240
+ continue;
241
+ }
242
+
243
+ const contextValue = parseKeyValueOption(token, "context");
244
+ if (contextValue !== undefined) {
245
+ if (contextValue) rawContext.push(contextValue);
246
+ continue;
247
+ }
248
+
249
+ modeTokens.push(unquoteToken(token));
250
+ }
251
+
252
+ const models = Array.from(new Set(rawModels));
253
+ const { focuses, explicit: focusExplicit } = parseFocusList(rawFocuses);
254
+ const additionalContextJoined = rawContext
255
+ .map((c) => c.trim())
256
+ .filter(Boolean)
257
+ .join("\n\n");
258
+ const additionalContext =
259
+ additionalContextJoined.length > 0 ? additionalContextJoined : undefined;
260
+ const toMode = (target: ReviewTarget): ReviewRequestMode => {
261
+ switch (target.type) {
262
+ case "auto":
263
+ return "auto";
264
+ case "uncommitted":
265
+ return "uncommitted";
266
+ case "branch":
267
+ return `branch:${target.branch}`;
268
+ case "commit":
269
+ return `commit:${target.sha}`;
270
+ case "pr":
271
+ return `pr:${target.ref}`;
272
+ case "folder":
273
+ return `folder:${target.paths.join(",")}`;
274
+ case "custom":
275
+ return "custom";
276
+ }
277
+ };
278
+ const withMeta = (target: ReviewTarget, targetExplicit: boolean): ParsedRequest => ({
279
+ target,
280
+ mode: toMode(target),
281
+ models,
282
+ focuses,
283
+ targetExplicit,
284
+ focusExplicit,
285
+ additionalContext,
286
+ });
287
+
288
+ if (modeTokens.length === 0) {
289
+ return withMeta({ type: "auto" }, false);
290
+ }
291
+
292
+ const mode = modeTokens[0].toLowerCase();
293
+ const rest = modeTokens.slice(1);
294
+
295
+ if (mode === "auto" || mode === "uncommitted") {
296
+ if (rest.length > 0)
297
+ throw new Error(
298
+ `${mode} mode does not accept positional args. Use models=..., focus=..., and/or context=...`,
299
+ );
300
+ return withMeta({ type: mode }, true);
301
+ }
302
+
303
+ if (mode === "branch" || mode === "commit" || mode === "pr") {
304
+ if (!rest[0]) {
305
+ if (mode === "branch")
306
+ throw new Error("branch mode requires a branch name (e.g. /review branch main)");
307
+ if (mode === "commit")
308
+ throw new Error("commit mode requires a commit SHA (e.g. /review commit abc1234)");
309
+ throw new Error("pr mode requires a PR number or URL (e.g. /review pr 123)");
310
+ }
311
+ if (rest.length > 1) {
312
+ const valueLabel =
313
+ mode === "branch" ? "branch name" : mode === "commit" ? "SHA" : "reference";
314
+ throw new Error(
315
+ `${mode} mode accepts one ${valueLabel}. Use models=..., focus=..., and/or context=... for options.`,
316
+ );
317
+ }
318
+
319
+ if (mode === "branch") return withMeta({ type: "branch", branch: rest[0] }, true);
320
+ if (mode === "commit") return withMeta({ type: "commit", sha: rest[0] }, true);
321
+ return withMeta({ type: "pr", ref: rest[0] }, true);
322
+ }
323
+ if (mode === "folder") {
324
+ if (rest.length === 0)
325
+ throw new Error("folder mode requires at least one path (e.g. /review folder src docs)");
326
+ return withMeta({ type: "folder", paths: rest.map((p) => p.trim()).filter(Boolean) }, true);
327
+ }
328
+ if (mode === "custom") {
329
+ const instructions = rest.join(" ").trim();
330
+ if (!instructions)
331
+ throw new Error('custom mode requires instructions (e.g. /review custom "focus on auth")');
332
+ return withMeta({ type: "custom", instructions }, true);
333
+ }
334
+
335
+ throw new Error(
336
+ `Unknown mode "${mode}". Supported modes: auto, uncommitted, branch, commit, pr, folder, custom.`,
337
+ );
338
+ }
339
+
340
+ function normalizeRequestModels(models: string[]): string[] {
341
+ return Array.from(new Set(models)).sort((a, b) => a.localeCompare(b));
342
+ }
343
+
344
+ function normalizeRequestFocuses(focuses: readonly ReviewFocus[]): ReviewFocus[] {
345
+ return Array.from(new Set(focuses)).sort((a, b) => a.localeCompare(b));
346
+ }
347
+
348
+ function normalizeStoredRequestFocuses(focuses: unknown): ReviewFocus[] | null {
349
+ if (focuses === undefined) return normalizeRequestFocuses(REVIEW_FOCUS_NAMES);
350
+ if (!Array.isArray(focuses)) return null;
351
+
352
+ const parsedFocuses: ReviewFocus[] = [];
353
+ for (const focus of focuses) {
354
+ if (typeof focus !== "string" || !isReviewFocus(focus)) return null;
355
+ parsedFocuses.push(focus);
356
+ }
357
+ if (parsedFocuses.length === 0) return null;
358
+ return normalizeRequestFocuses(parsedFocuses);
359
+ }
360
+
361
+ export function buildRequestSignaturePayload(
362
+ request: ParsedRequest,
363
+ options: { includeAdditionalContext?: boolean } = {},
364
+ ): RequestSignaturePayload {
365
+ const additionalContext = request.additionalContext?.trim();
366
+ const target =
367
+ request.target.type === "folder"
368
+ ? {
369
+ type: "folder",
370
+ paths: Array.from(new Set(request.target.paths)).sort((a, b) => a.localeCompare(b)),
371
+ }
372
+ : request.target.type === "custom"
373
+ ? { type: "custom", instructions: request.target.instructions.trim() }
374
+ : request.target.type === "pr"
375
+ ? { type: "pr", ref: parsePrReference(request.target.ref) ?? request.target.ref.trim() }
376
+ : request.target.type === "branch"
377
+ ? { type: "branch", branch: request.target.branch }
378
+ : request.target.type === "commit"
379
+ ? { type: "commit", sha: request.target.sha }
380
+ : { type: request.target.type };
381
+
382
+ const payload: RequestSignaturePayload = {
383
+ target,
384
+ models: normalizeRequestModels(request.models),
385
+ focuses: normalizeRequestFocuses(request.focuses),
386
+ };
387
+ if (options.includeAdditionalContext ?? true) {
388
+ payload.additionalContext =
389
+ additionalContext && additionalContext.length > 0 ? additionalContext : null;
390
+ }
391
+ return payload;
392
+ }
393
+
394
+ export function buildRequestSignature(request: ParsedRequest): string {
395
+ return JSON.stringify(buildRequestSignaturePayload(request));
396
+ }
397
+
398
+ function buildContextlessRequestSignature(request: ParsedRequest): string {
399
+ return JSON.stringify(buildRequestSignaturePayload(request, { includeAdditionalContext: false }));
400
+ }
401
+
402
+ function parseRequestSignaturePayload(signature: string): RequestSignaturePayload | null {
403
+ try {
404
+ const payload: unknown = JSON.parse(signature);
405
+ if (!payload || typeof payload !== "object") return null;
406
+ const { target, models, focuses } = payload as {
407
+ target?: unknown;
408
+ models?: unknown;
409
+ focuses?: unknown;
410
+ };
411
+ if (target === undefined || !Array.isArray(models)) return null;
412
+ const parsedModels = models.filter((model): model is string => typeof model === "string");
413
+ if (parsedModels.length !== models.length) return null;
414
+ const parsedFocuses = normalizeStoredRequestFocuses(focuses);
415
+ if (!parsedFocuses) return null;
416
+ return {
417
+ target,
418
+ models: normalizeRequestModels(parsedModels),
419
+ focuses: parsedFocuses,
420
+ };
421
+ } catch {
422
+ return null;
423
+ }
424
+ }
425
+
426
+ function buildContextlessStoredRequestSignature(signature: string): string | null {
427
+ const payload = parseRequestSignaturePayload(signature);
428
+ if (!payload) return null;
429
+ return JSON.stringify({
430
+ target: payload.target,
431
+ models: payload.models,
432
+ focuses: payload.focuses,
433
+ });
434
+ }
435
+
436
+ function isOpenEndedFixRequest(request: ParsedRequest): boolean {
437
+ return !request.targetExplicit && request.models.length === 0 && !request.focusExplicit;
438
+ }
439
+
440
+ export function reviewMatchesFixRequest(
441
+ details: ReviewMessageDetails,
442
+ request: ParsedRequest,
443
+ ): boolean {
444
+ if (isOpenEndedFixRequest(request)) return true;
445
+ return (
446
+ buildContextlessStoredRequestSignature(details.request.signature) ===
447
+ buildContextlessRequestSignature(request)
448
+ );
449
+ }
450
+
451
+ export function parsePrReference(ref: string): number | null {
452
+ const trimmed = ref.trim();
453
+ if (/^\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10);
454
+
455
+ const urlMatch = trimmed.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/i);
456
+ if (!urlMatch?.[1]) return null;
457
+ return Number.parseInt(urlMatch[1], 10);
458
+ }
459
+
460
+ export type ParseFailure = { ok: false; help?: true; error?: string };
461
+ export type ParseResult<T> = { ok: true; value: T } | ParseFailure;
462
+
463
+ export function parseCommandRequest(args: string | undefined): ParseResult<ParsedRequest> {
464
+ if (isHelpRequest(args)) {
465
+ return { ok: false, help: true };
466
+ }
467
+
468
+ try {
469
+ return { ok: true, value: parseRequestArgs(args) };
470
+ } catch (error) {
471
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
472
+ }
473
+ }
474
+
475
+ export function parseFixCommandRequest(
476
+ args: string | undefined,
477
+ ): ParseResult<{ loop: boolean; request: ParsedRequest }> {
478
+ const raw = args?.trim() ?? "";
479
+ const first = tokenizeArgs(raw)[0];
480
+ const loop = Boolean(first && unquoteToken(first).toLowerCase() === "loop");
481
+ const requestArgs = loop ? raw.slice(first!.length).trim() || undefined : args;
482
+ const parsed = parseCommandRequest(requestArgs);
483
+ if (!parsed.ok) return parsed;
484
+ return { ok: true, value: { loop, request: parsed.value } };
485
+ }
486
+
487
+ export function parseTriagePrRef(args: string | undefined): ParseResult<string> {
488
+ const raw = args?.trim() ?? "";
489
+ if (!raw) {
490
+ return { ok: false, error: "Usage: /triage <pr-number|url>" };
491
+ }
492
+
493
+ const tokens = tokenizeArgs(raw).map(unquoteToken);
494
+ if (tokens.length === 1 && tokens[0]?.toLowerCase() === "help") {
495
+ return { ok: false, help: true };
496
+ }
497
+ if (tokens.length !== 1 || !tokens[0]) {
498
+ return { ok: false, error: "Usage: /triage <pr-number|url>" };
499
+ }
500
+ if (!parsePrReference(tokens[0])) {
501
+ return { ok: false, error: `Invalid PR reference: ${tokens[0]}` };
502
+ }
503
+ return { ok: true, value: tokens[0] };
504
+ }