@blogic-cz/agent-tools 1.14.4 → 1.14.6

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 (64) hide show
  1. package/README.md +12 -11
  2. package/dist/api-tool/main.d.ts +6 -1
  3. package/dist/api-tool/main.d.ts.map +1 -1
  4. package/dist/audit-tool/main.d.ts +6 -1
  5. package/dist/audit-tool/main.d.ts.map +1 -1
  6. package/dist/az-tool/main.d.ts +6 -1
  7. package/dist/az-tool/main.d.ts.map +1 -1
  8. package/dist/azdo-tool/main.d.ts +6 -1
  9. package/dist/azdo-tool/main.d.ts.map +1 -1
  10. package/dist/credential-guard/issue-227.test.d.ts +2 -0
  11. package/dist/credential-guard/issue-227.test.d.ts.map +1 -0
  12. package/dist/credential-guard/policy.d.ts.map +1 -1
  13. package/dist/credential-guard/registry.d.ts.map +1 -1
  14. package/dist/db-tool/main.d.ts +8 -1
  15. package/dist/db-tool/main.d.ts.map +1 -1
  16. package/dist/db-tool/service.d.ts +1 -0
  17. package/dist/db-tool/service.d.ts.map +1 -1
  18. package/dist/gh-tool/main.d.ts +4 -1
  19. package/dist/gh-tool/main.d.ts.map +1 -1
  20. package/dist/gh-tool/pr/commands.d.ts +13 -0
  21. package/dist/gh-tool/pr/commands.d.ts.map +1 -1
  22. package/dist/gh-tool/pr/review.d.ts +1 -1
  23. package/dist/gh-tool/pr/review.d.ts.map +1 -1
  24. package/dist/gh-tool/read/store.d.ts +4 -1
  25. package/dist/gh-tool/read/store.d.ts.map +1 -1
  26. package/dist/k8s-tool/main.d.ts +8 -1
  27. package/dist/k8s-tool/main.d.ts.map +1 -1
  28. package/dist/logs-tool/main.d.ts +4 -0
  29. package/dist/logs-tool/main.d.ts.map +1 -1
  30. package/dist/observability-tool/main.d.ts +6 -1
  31. package/dist/observability-tool/main.d.ts.map +1 -1
  32. package/dist/session-tool/main.d.ts +4 -0
  33. package/dist/session-tool/main.d.ts.map +1 -1
  34. package/dist/shared/secret-env.d.ts +9 -0
  35. package/dist/shared/secret-env.d.ts.map +1 -1
  36. package/package.json +10 -11
  37. package/src/api-tool/index.ts +1 -1
  38. package/src/api-tool/main.ts +19 -6
  39. package/src/audit-tool/index.ts +1 -1
  40. package/src/audit-tool/main.ts +13 -4
  41. package/src/az-tool/index.ts +1 -1
  42. package/src/az-tool/main.ts +10 -4
  43. package/src/azdo-tool/index.ts +1 -1
  44. package/src/azdo-tool/main.ts +13 -4
  45. package/src/credential-guard/issue-227.test.ts +69 -0
  46. package/src/credential-guard/policy.ts +23 -20
  47. package/src/credential-guard/registry.ts +99 -11
  48. package/src/db-tool/index.ts +1 -1
  49. package/src/db-tool/main.ts +10 -4
  50. package/src/db-tool/service.ts +6 -1
  51. package/src/gh-tool/index.ts +1 -1
  52. package/src/gh-tool/main.ts +5 -4
  53. package/src/gh-tool/pr/commands.ts +85 -10
  54. package/src/gh-tool/pr/review.ts +2 -1
  55. package/src/gh-tool/read/store.ts +8 -2
  56. package/src/k8s-tool/index.ts +1 -1
  57. package/src/k8s-tool/main.ts +10 -4
  58. package/src/logs-tool/index.ts +1 -1
  59. package/src/logs-tool/main.ts +13 -4
  60. package/src/observability-tool/index.ts +1 -1
  61. package/src/observability-tool/main.ts +10 -2
  62. package/src/session-tool/index.ts +1 -1
  63. package/src/session-tool/main.ts +10 -13
  64. package/src/shared/secret-env.ts +108 -88
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
2
  import { checkEffectVersion } from "#shared/effect-version";
3
3
 
4
- if (await checkEffectVersion()) await import("./main");
4
+ if (await checkEffectVersion()) (await import("./main")).runMain();
5
5
  else process.exitCode = 1;
@@ -184,7 +184,7 @@ EXAMPLES:
184
184
 
185
185
  const commandsCommand = makeSchemaCommand(() => mainCommand);
186
186
 
187
- const mainCommand = Command.make("azdo-tool", {}).pipe(
187
+ export const mainCommand = Command.make("azdo-tool", {}).pipe(
188
188
  Command.withDescription(
189
189
  `Azure DevOps Tool for Coding Agents (READ-ONLY)
190
190
 
@@ -219,6 +219,15 @@ const program = withRedactedOutput(withAudit("azdo", cli)).pipe(
219
219
  Effect.tapCause(renderCauseToStderr),
220
220
  );
221
221
 
222
- BunRuntime.runMain(program, {
223
- disableErrorReporting: true,
224
- });
222
+ export const runMain = () =>
223
+ BunRuntime.runMain(program, {
224
+ disableErrorReporting: true,
225
+ });
226
+
227
+ export const runProgram = (args: readonly string[]) =>
228
+ withRedactedOutput(
229
+ withAudit(
230
+ "azdo",
231
+ Command.runWith(mainCommand, { version: VERSION, renderErrors: false })(args),
232
+ ),
233
+ ).pipe(Effect.provide(MainLayer), Effect.tapCause(renderCauseToStderr));
@@ -0,0 +1,69 @@
1
+ import { expect, it } from "vitest";
2
+ import { createCredentialGuard } from "./index";
3
+ import { parseBash } from "./bash";
4
+ import { judge } from "./policy";
5
+
6
+ const bash = (command: string) => judge(parseBash(command), { cwd: "/repo", home: "/users/test" });
7
+
8
+ it.each([
9
+ `sed -i '' 's/"x": "\\^1\\.14\\.[0-9]*"/"x": "^1.14.4"/' package.json`,
10
+ "sed -i '' 's/a*/b/' package.json",
11
+ "sed -i 's/[0-9]/x/' notes.txt",
12
+ "sed -n '/[0-9]*/p' notes.txt",
13
+ ])("keeps sed scripts out of write targets: %s", (command) => {
14
+ expect(bash(command)).toEqual({ kind: "pass" });
15
+ expect(() =>
16
+ createCredentialGuard().handleToolExecuteBefore({ tool: "Bash" }, { args: { command } }),
17
+ ).not.toThrow();
18
+ });
19
+
20
+ it.each([
21
+ "cd ~/.claude && printf x > comm?nds/x.md",
22
+ "cd ~/.claude && printf x | tee comm?nds/x.md",
23
+ "cd ~/.claude && printf x > sk?lls/x/SKILL.md",
24
+ "cd ~/.claude && cp /tmp/x sk?lls/foo/SKILL.md",
25
+ "cd ~/.claude && cp /tmp/x hoo?s/x.sh",
26
+ "cd ~/.claude && tee ag?nts/x.md < /tmp/x",
27
+ "cd ~/.claude && touch comm?nds/x.md",
28
+ "cd ~/.claude && truncate -s 0 comm?nds/x.md",
29
+ "cd ~/.claude && cat /tmp/x >> comm*/x.md",
30
+ "cd ~/.agents && printf x > sk?lls/x/SKILL.md",
31
+ "cd ~/.agents && cp /tmp/x sk*/x/SKILL.md",
32
+ "cd /repo/.claude && printf x > comm*/x.md",
33
+ "cd ~/.claude/ && printf x > ./comm?nds/x.md",
34
+ "cd ~/.claude/commands && printf x > ../sk?lls/x.md",
35
+ "cd .claude && cp f c*/x.md",
36
+ "cd .claude && echo x > c*/x.md",
37
+ "cd /repo/.claude && cp f ./c*/x.md",
38
+ "cd /repo/.claude && cp f s*/x/SKILL.md",
39
+ "cd /repo/.agents && cp f s*/x/SKILL.md",
40
+ "sed -i 's/x/y/' AGENT?.md",
41
+ "sed -i 's/x/y/' AGENT[]S].md",
42
+ "sed -i 's/x/y/' AGENT[S].md",
43
+ "sed -i 's/x/y/' .cl?ude/agents/task.md",
44
+ "sed -i 's/x/y/' .claude/a*/task.md",
45
+ "sed -i 's/x/y/' .claude//a*/task.md",
46
+ "sed -i 's/x/y/' .claude/./a*/task.md",
47
+ "sed -i 's/x/y/' .claude/notes/../a*/task.md",
48
+ "sed -i 's/x/y/' .claude/c*/task.md",
49
+ "sed -i 's/x/y/' .cod?x/prompts/task.md",
50
+ "sed -i 's/x/y/' .agents/s*/task.md",
51
+ "tar -vW create -f AGENTS.md src",
52
+ "tar -vW append -f AGENTS.md src",
53
+ "tar -kW create -f AGENTS.md src",
54
+ "tar -vWappend -f AGENTS.md src",
55
+ "tar -A -f AGENTS.md x.tar",
56
+ "tar --catenate -f AGENTS.md x.tar",
57
+ "tar --delete -f AGENTS.md member",
58
+ ])("refuses protected write targets: %s", (command) => {
59
+ expect(bash(command)).toMatchObject({
60
+ findings: expect.arrayContaining([expect.objectContaining({ rule: "agent-config-write" })]),
61
+ });
62
+ });
63
+
64
+ it.each(["tar --delete -f /tmp/x.tar member", "tar -xf x.tar AGENTS.md"])(
65
+ "keeps documented archive limits: %s",
66
+ (command) => {
67
+ expect(bash(command)).toEqual({ kind: "pass" });
68
+ },
69
+ );
@@ -1,4 +1,5 @@
1
1
  import { formatLiteralPrintf } from "./literal-output";
2
+ import { posix } from "node:path";
2
3
  import { splitXargsInput } from "./xargs";
3
4
  import { parseBash } from "./bash";
4
5
  import { parsePowerShell } from "./powershell";
@@ -98,12 +99,12 @@ function mentions(value: Value): readonly string[] {
98
99
  }
99
100
  function agentConfigGlob(value: Value): boolean {
100
101
  return mentions(value).some((text) =>
101
- text
102
- .replace(/\\/g, "/")
102
+ posix
103
+ .normalize(text.replace(/\\/g, "/"))
103
104
  .split("/")
104
- .some((part) => {
105
+ .some((part, index, parts) => {
105
106
  if (!/[*?[]/.test(part)) return false;
106
- if (part.replace(/\[[A-Za-z0-9._]\]/g, "").includes("[")) return true;
107
+ if (part.replace(/\[(?:[A-Za-z0-9._]|0-9)\]/g, "").includes("[")) return true;
107
108
  const pattern = new RegExp(
108
109
  `^${part
109
110
  .replace(/[.+^${}()|\\]/g, "\\$&")
@@ -112,22 +113,24 @@ function agentConfigGlob(value: Value): boolean {
112
113
  .replace(/\?/g, ".")}$`,
113
114
  "i",
114
115
  );
115
- return [
116
- "AGENTS.md",
117
- "CLAUDE.md",
118
- ".mcp.json",
119
- ".claude",
120
- ".claude.json",
121
- ".codex",
122
- ".agents",
123
- "config.toml",
124
- "settings.json",
125
- "settings.local.json",
126
- "commands",
127
- "agents",
128
- "skills",
129
- "hooks",
130
- ].some((name) => pattern.test(name));
116
+ const names = [".claude", ".codex", ".agents"];
117
+ if (index === parts.length - 1 || (index === parts.length - 2 && parts.at(-1) === ""))
118
+ names.push(
119
+ "AGENTS.md",
120
+ "CLAUDE.md",
121
+ ".mcp.json",
122
+ ".claude.json",
123
+ "config.toml",
124
+ "settings.json",
125
+ "settings.local.json",
126
+ );
127
+ if (
128
+ /^\.claude$/i.test(parts[index - 1] ?? "") ||
129
+ (!posix.isAbsolute(text) && (index === 0 || parts[index - 1] === ".."))
130
+ )
131
+ names.push("commands", "agents", "skills", "hooks");
132
+ if (/^\.agents$/i.test(parts[index - 1] ?? "")) names.push("skills");
133
+ return names.some((name) => pattern.test(name));
131
134
  }),
132
135
  );
133
136
  }
@@ -1164,6 +1164,78 @@ export const EXECUTABLES: readonly ExecutableEntry[] = [
1164
1164
  operands: [path],
1165
1165
  startup: ["JAVA_TOOL_OPTIONS", "JDK_JAVA_OPTIONS", "CLASSPATH", "_JAVA_OPTIONS", "JAVA_HOME"],
1166
1166
  },
1167
+ {
1168
+ name: "hc",
1169
+ rejectsAbbreviations: true,
1170
+ options: {
1171
+ ...flags("--json", "--help", "-h", "--version", "-V", "--skill", "--licenses"),
1172
+ },
1173
+ operands: [data],
1174
+ subcommands: {
1175
+ ...Object.fromEntries(
1176
+ [
1177
+ "get",
1178
+ "keys",
1179
+ "interrupt",
1180
+ "attach",
1181
+ "kill",
1182
+ "rm",
1183
+ "label",
1184
+ "keep",
1185
+ "ls",
1186
+ "read",
1187
+ "wait",
1188
+ "templates",
1189
+ "events",
1190
+ "schema",
1191
+ "connect",
1192
+ "server",
1193
+ ].map((name) => [
1194
+ name,
1195
+ {
1196
+ operands: [data],
1197
+ options: {
1198
+ ...flags("--all", "--off", "--wait", "--force"),
1199
+ "--lines": arg(data),
1200
+ "--output": arg(data),
1201
+ "--children": arg(data),
1202
+ "--timeout": arg(data),
1203
+ },
1204
+ },
1205
+ ]),
1206
+ ),
1207
+ send: {
1208
+ operands: [data, path],
1209
+ options: {
1210
+ ...flags("--force", "--json", "--wait"),
1211
+ "--if": arg(data),
1212
+ "--timeout": arg(data),
1213
+ },
1214
+ },
1215
+ run: {
1216
+ operands: [data],
1217
+ options: {
1218
+ ...flags("--bg", "--outlive-parent", "--no-parent", "--no-notify", "--rm", "--keep"),
1219
+ "--name": arg(data),
1220
+ "--cwd": arg(path),
1221
+ "--on": arg(data),
1222
+ "--label": arg(data),
1223
+ "--parent": arg(data),
1224
+ "--timeout": arg(data),
1225
+ },
1226
+ },
1227
+ exec: {
1228
+ operands: [data],
1229
+ options: {
1230
+ ...flags("-i", "--stdin"),
1231
+ "--cwd": arg(path),
1232
+ "--on": arg(data),
1233
+ "--env": { roles: [data], issue: unknown("hc environment override") },
1234
+ "--timeout": arg(data),
1235
+ },
1236
+ },
1237
+ },
1238
+ },
1167
1239
  {
1168
1240
  name: "herdr",
1169
1241
  options: {
@@ -3119,6 +3191,7 @@ export const EXECUTABLES: readonly ExecutableEntry[] = [
3119
3191
  "-A",
3120
3192
  "--catenate",
3121
3193
  "--concatenate",
3194
+ "--delete",
3122
3195
  "-t",
3123
3196
  "--list",
3124
3197
  "-v",
@@ -4205,21 +4278,18 @@ function classifyInvocation(
4205
4278
  entry.name === "tar" &&
4206
4279
  argv.slice(1).some((word, index) => {
4207
4280
  if (word === undefined) return false;
4208
- const longOption =
4209
- word === "-W"
4210
- ? argv[index + 2]?.split("=", 1)[0]
4211
- : word.startsWith("-W")
4212
- ? word.slice(2).split("=", 1)[0]
4213
- : undefined;
4281
+ const longOption = /^-[^-]*W/.test(word)
4282
+ ? (word.slice(word.indexOf("W") + 1) || argv[index + 2])?.split("=", 1)[0]
4283
+ : undefined;
4214
4284
  if (
4215
4285
  longOption &&
4216
- ["create", "append", "update", "catenate", "concatenate"].some((option) =>
4286
+ ["create", "append", "update", "catenate", "concatenate", "delete"].some((option) =>
4217
4287
  option.startsWith(longOption),
4218
4288
  )
4219
4289
  )
4220
4290
  return true;
4221
4291
  if (word.startsWith("--"))
4222
- return ["--create", "--append", "--update", "--catenate", "--concatenate"].some(
4292
+ return ["--create", "--append", "--update", "--catenate", "--concatenate", "--delete"].some(
4223
4293
  (option) => word.length > 2 && option.startsWith(word.split("=")[0] ?? word),
4224
4294
  );
4225
4295
  const cluster =
@@ -4233,7 +4303,11 @@ function classifyInvocation(
4233
4303
  }
4234
4304
  const a = argv[position];
4235
4305
  if (a === undefined) {
4236
- if (["env", "forward", "timeout", "xargs"].includes(entry.semantics ?? ""))
4306
+ if (
4307
+ ["env", "forward", "timeout", "xargs"].includes(entry.semantics ?? "") ||
4308
+ (entry.name === "hc" &&
4309
+ (mode === entry.subcommands?.run || mode === entry.subcommands?.exec))
4310
+ )
4237
4311
  issues.push({
4238
4312
  kind: "missing-knowledge",
4239
4313
  rule: "opaque-executable",
@@ -4459,8 +4533,8 @@ function classifyInvocation(
4459
4533
  (attached ?? argv[position + 1]) === undefined
4460
4534
  )
4461
4535
  issues.push(unknown("Bun option operand is not literal"));
4462
- if (entry.name === "bun" && optionName === "--cwd") {
4463
- if (cwdOperands.length) issues.push(unknown("repeated Bun working directory"));
4536
+ if (["bun", "hc"].includes(entry.name) && optionName === "--cwd") {
4537
+ if (cwdOperands.length) issues.push(unknown(`repeated ${entry.name} working directory`));
4464
4538
  cwdOperands.push({
4465
4539
  index: attached === undefined ? position + 1 : position,
4466
4540
  ...(attached !== undefined ? { literal: attached } : {}),
@@ -4583,6 +4657,18 @@ function classifyInvocation(
4583
4657
  position++;
4584
4658
  continue;
4585
4659
  }
4660
+ if (
4661
+ entry.name === "hc" &&
4662
+ (mode === entry.subcommands?.run || mode === entry.subcommands?.exec)
4663
+ ) {
4664
+ if (mode === entry.subcommands?.run && position === argv.length - 1) {
4665
+ roles.push({ index: position, role: code("bash") });
4666
+ roles.push({ index: position, role: path });
4667
+ issues.push(...unknownOperandIssues([a]));
4668
+ codeSeen = true;
4669
+ } else child(position);
4670
+ break;
4671
+ }
4586
4672
  if (entry.name === "git" && mode === entry.subcommands?.bisect?.subcommands?.run) {
4587
4673
  child(position);
4588
4674
  break;
@@ -4671,6 +4757,8 @@ function classifyInvocation(
4671
4757
  if (effect && effect !== "none") issues.push(effect);
4672
4758
  if (!positional.length && !children.length && !codeSeen && entry.bare) issues.push(entry.bare);
4673
4759
  roles.push(...optionRoles);
4760
+ if (entry.name === "hc" && mode === entry.subcommands?.send)
4761
+ issues.push(...unknownOperandIssues(positional.slice(1).map((index) => argv[index])));
4674
4762
  const operands =
4675
4763
  (filterOption && ["sed", "awk"].includes(entry.semantics ?? "")) ||
4676
4764
  (filterOption &&
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
2
  import { checkEffectVersion } from "#shared/effect-version";
3
3
 
4
- if (await checkEffectVersion()) await import("./main");
4
+ if (await checkEffectVersion()) (await import("./main")).runMain();
5
5
  else process.exitCode = 1;
@@ -225,7 +225,7 @@ const grantsCommand = makeDiagnosticCommand(
225
225
 
226
226
  const commandsCommand = makeSchemaCommand(() => mainCommand);
227
227
 
228
- const mainCommand = Command.make("db-tool", {}).pipe(
228
+ export const mainCommand = Command.make("db-tool", {}).pipe(
229
229
  Command.withDescription("Database Query Tool for Coding Agents"),
230
230
  Command.withSubcommands([
231
231
  sqlCommand,
@@ -261,6 +261,12 @@ const program = withRedactedOutput(withAudit("db", cli)).pipe(
261
261
  Effect.tapCause(renderCauseToStderr),
262
262
  );
263
263
 
264
- BunRuntime.runMain(program, {
265
- disableErrorReporting: true,
266
- });
264
+ export const runMain = () =>
265
+ BunRuntime.runMain(program, {
266
+ disableErrorReporting: true,
267
+ });
268
+
269
+ export const runProgram = (args: readonly string[]) =>
270
+ withRedactedOutput(
271
+ withAudit("db", Command.runWith(mainCommand, { version: VERSION, renderErrors: false })(args)),
272
+ ).pipe(Effect.provide(MainLayer), Effect.tapCause(renderCauseToStderr));
@@ -133,6 +133,10 @@ export function isFullyReadOnly(
133
133
  // Re-exported from the shared probe so existing `#db/service` consumers keep working.
134
134
  export { buildApiProbeArgs } from "#shared/k8s-probe";
135
135
 
136
+ export const DbGuardianInProcess = Context.Reference<boolean>("@agent-tools/DbGuardianInProcess", {
137
+ defaultValue: () => false,
138
+ });
139
+
136
140
  export class DbService extends Context.Service<
137
141
  DbService,
138
142
  {
@@ -156,6 +160,7 @@ export class DbService extends Context.Service<
156
160
  const sqlClient = yield* DbSqlClient;
157
161
  const secretEnvResolver = yield* SecretEnvResolver;
158
162
  const agentToolsConfig = yield* ConfigService;
163
+ const runGuardianInProcess = yield* DbGuardianInProcess;
159
164
  const dbConfig = yield* DbConfigService;
160
165
 
161
166
  if (!dbConfig) {
@@ -364,7 +369,7 @@ export class DbService extends Context.Service<
364
369
  prerequisiteConfig,
365
370
  (command, _label) => executeShellCommand(command),
366
371
  effect,
367
- { tryWithoutPrerequisites: true },
372
+ { tryWithoutPrerequisites: true, runGuardianInProcess },
368
373
  ).pipe(
369
374
  Effect.mapError((error) =>
370
375
  isPrerequisiteRunError(error)
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
2
  import { checkEffectVersion } from "#shared/effect-version";
3
3
 
4
- if (await checkEffectVersion()) await import("./main");
4
+ if (await checkEffectVersion()) (await import("./main")).runMain();
5
5
  else process.exitCode = 1;
@@ -222,7 +222,7 @@ const gistCommand = Command.make("gist", {}).pipe(
222
222
 
223
223
  const commandsCommand = makeSchemaCommand(() => mainCommand);
224
224
 
225
- const mainCommand = Command.make("gh-tool", {}).pipe(
225
+ export const mainCommand = Command.make("gh-tool", {}).pipe(
226
226
  Command.withDescription(
227
227
  `GitHub CLI Tool for Coding Agents
228
228
 
@@ -307,6 +307,7 @@ const observed = Effect.gen(function* () {
307
307
  });
308
308
  const program = observed.pipe(Effect.provide(MainLayer), Effect.tapCause(renderCauseToStderr));
309
309
 
310
- BunRuntime.runMain(program, {
311
- disableErrorReporting: true,
312
- });
310
+ export const runMain = () =>
311
+ BunRuntime.runMain(program, {
312
+ disableErrorReporting: true,
313
+ });
@@ -405,16 +405,19 @@ export const fetchReviewTriage = Effect.fn("pr.fetchReviewTriage")(function* (
405
405
  const { info, value } = yield* withStableHead(
406
406
  prNumber,
407
407
  (number, headSha) =>
408
- Effect.all([
409
- fetchThreads(number, false, false, headSha),
410
- fetchDiscussionSummary(number),
411
- fetchCheckReport(number),
412
- fetchReviews(number, null, null, null, headSha),
413
- fetchComments(number, null, headSha),
414
- ]),
408
+ Effect.gen(function* () {
409
+ const allThreads = yield* fetchThreads(number, false, false, headSha);
410
+ const [summary, checkReport, reviews, inlineComments] = yield* Effect.all([
411
+ fetchDiscussionSummary(number, allThreads),
412
+ fetchCheckReport(number),
413
+ fetchReviews(number, null, null, null, headSha),
414
+ fetchComments(number, null, headSha),
415
+ ]);
416
+ return { allThreads, summary, checkReport, reviews, inlineComments };
417
+ }),
415
418
  { operation: "review triage", command: "gh-tool pr review-triage" },
416
419
  );
417
- const [allThreads, summary, checkReport, reviews, inlineComments] = value;
420
+ const { allThreads, summary, checkReport, reviews, inlineComments } = value;
418
421
  const checks = checkReport.checks ?? [];
419
422
  const unresolvedThreads = allThreads.filter((thread) => !thread.isResolved);
420
423
  const visibleOpenThreads = allThreads.filter((thread) => thread.isVisibleOpen);
@@ -483,6 +486,60 @@ export const fetchReviewTriage = Effect.fn("pr.fetchReviewTriage")(function* (
483
486
  };
484
487
  });
485
488
 
489
+ type CompactReviewTriage = {
490
+ readonly pr: number;
491
+ readonly classification: ReviewTriageClassification;
492
+ readonly visibleOpenReviewThreadsCount: number;
493
+ readonly unrepliedReviewThreadsCount: number;
494
+ readonly unresolvedReviewThreadsCount: number;
495
+ readonly visibleOpenThreadIds: readonly string[];
496
+ };
497
+
498
+ const compactReviewTriage = (
499
+ pr: number,
500
+ result: {
501
+ readonly classification: ReviewTriageClassification;
502
+ readonly summary: ReviewTriageSummary;
503
+ readonly visibleOpenThreads: ReadonlyArray<{ readonly threadId: string }>;
504
+ },
505
+ ): CompactReviewTriage => ({
506
+ pr,
507
+ classification: result.classification,
508
+ visibleOpenReviewThreadsCount: result.summary.visibleOpenReviewThreadsCount,
509
+ unrepliedReviewThreadsCount: result.summary.unrepliedReviewThreadsCount,
510
+ unresolvedReviewThreadsCount: result.summary.unresolvedReviewThreadsCount,
511
+ visibleOpenThreadIds: result.visibleOpenThreads.map((thread) => thread.threadId),
512
+ });
513
+
514
+ export const fetchReviewTriageBatch = Effect.fn("pr.fetchReviewTriageBatch")(function* (
515
+ prs: readonly number[],
516
+ ) {
517
+ return yield* Effect.forEach(
518
+ prs,
519
+ (pr) =>
520
+ fetchReviewTriage(pr).pipe(
521
+ Effect.map((result) => compactReviewTriage(pr, result)),
522
+ Effect.catch((error) =>
523
+ Effect.succeed({
524
+ pr,
525
+ error: error instanceof Error ? error.message : String(error),
526
+ }),
527
+ ),
528
+ ),
529
+ { concurrency: 5 },
530
+ );
531
+ });
532
+
533
+ const reviewTriageBatchError = (
534
+ errors: readonly { readonly pr: number; readonly error: string }[],
535
+ ) =>
536
+ new GitHubCommandError({
537
+ message: `Failed to fetch review triage for PRs: ${errors.map(({ pr, error }) => `#${pr}: ${error}`).join("; ")}`,
538
+ command: "gh-tool pr review-triage --prs",
539
+ exitCode: 1,
540
+ stderr: "",
541
+ });
542
+
486
543
  export const prViewCommand = Command.make(
487
544
  "view",
488
545
  {
@@ -1715,12 +1772,30 @@ export const prReviewTriageCommand = Command.make(
1715
1772
  Flag.withDescription("PR number (default: current branch PR)"),
1716
1773
  Flag.optional,
1717
1774
  ),
1775
+ prs: Flag.String("prs").pipe(
1776
+ Flag.withDescription(
1777
+ "Comma-separated PR numbers for a compact batch summary (overrides --pr)",
1778
+ ),
1779
+ Flag.optional,
1780
+ ),
1718
1781
  repo: repoOption,
1719
1782
  },
1720
- ({ format, omit, pr, repo }) =>
1783
+ ({ format, omit, pr, prs, repo }) =>
1721
1784
  withRepo(
1722
1785
  repo,
1723
1786
  Effect.gen(function* () {
1787
+ const batch = Option.getOrNull(prs);
1788
+ if (batch !== null) {
1789
+ const numbers = yield* parsePrNumbers(batch);
1790
+ const results = yield* fetchReviewTriageBatch(numbers);
1791
+ yield* logFormatted({ count: results.length, prs: results }, format);
1792
+ const errors = results.filter(
1793
+ (result): result is Extract<(typeof results)[number], { readonly error: string }> =>
1794
+ "error" in result,
1795
+ );
1796
+ if (errors.length > 0) return yield* reviewTriageBatchError(errors);
1797
+ return;
1798
+ }
1724
1799
  const prNumber = Option.getOrNull(pr);
1725
1800
  const result = yield* fetchReviewTriage(prNumber);
1726
1801
  yield* logFormatted(
@@ -1731,7 +1806,7 @@ export const prReviewTriageCommand = Command.make(
1731
1806
  ),
1732
1807
  ).pipe(
1733
1808
  Command.withDescription(
1734
- "Composite: PR info + merge-readiness verdict + unresolved threads + visible-open threads + discussion summary + checks status in one call",
1809
+ "Composite: PR info + merge-readiness verdict + unresolved and visible-open threads + discussion summary + checks status; use --prs for a compact batch summary",
1735
1810
  ),
1736
1811
  );
1737
1812
 
@@ -430,11 +430,12 @@ export const postIssueComment = Effect.fn("pr.postIssueComment")(function* (
430
430
 
431
431
  export const fetchDiscussionSummary = Effect.fn("pr.fetchDiscussionSummary")(function* (
432
432
  pr: number | null,
433
+ collectedThreads?: readonly ReviewThread[],
433
434
  ) {
434
435
  const [issueComments, reviewComments, threads] = yield* Effect.all([
435
436
  fetchIssueComments(pr, null, null, null),
436
437
  fetchComments(pr, null),
437
- fetchThreads(pr, false),
438
+ collectedThreads === undefined ? fetchThreads(pr, false) : Effect.succeed(collectedThreads),
438
439
  ]);
439
440
 
440
441
  const latestIssueComment =
@@ -87,11 +87,13 @@ export class FileStore {
87
87
  private constructor(
88
88
  readonly directory: string,
89
89
  readonly identity: string,
90
+ private readonly contentionTimeoutMs: number,
90
91
  ) {}
91
92
 
92
93
  static async open(
93
94
  identity: string,
94
95
  directory = process.env["GH_TOOL_CACHE_DIR"] ?? defaultCacheDirectory(),
96
+ options: { contentionTimeoutMs?: number } = {},
95
97
  ): Promise<FileStore> {
96
98
  const uid = process.getuid?.();
97
99
  if (uid === undefined) throw new Error("Cache ownership cannot be verified");
@@ -116,7 +118,11 @@ export class FileStore {
116
118
  await checkDirectory(path);
117
119
  }),
118
120
  );
119
- const store = new FileStore(join(directory, "gh-read-v1"), identity);
121
+ const store = new FileStore(
122
+ join(directory, "gh-read-v1"),
123
+ identity,
124
+ options.contentionTimeoutMs ?? 2000,
125
+ );
120
126
  await mkdir(store.ghCacheDirectory, { recursive: true, mode: 0o700 });
121
127
  await checkDirectory(store.ghCacheDirectory);
122
128
  await store.readClock();
@@ -226,7 +232,7 @@ export class FileStore {
226
232
  }
227
233
  },
228
234
  25,
229
- 2000,
235
+ this.contentionTimeoutMs,
230
236
  );
231
237
  try {
232
238
  return await body();
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
2
  import { checkEffectVersion } from "#shared/effect-version";
3
3
 
4
- if (await checkEffectVersion()) await import("./main");
4
+ if (await checkEffectVersion()) (await import("./main")).runMain();
5
5
  else process.exitCode = 1;
@@ -457,7 +457,7 @@ const topCommand = Command.make(
457
457
 
458
458
  const commandsCommand = makeSchemaCommand(() => mainCommand);
459
459
 
460
- const mainCommand = Command.make("k8s-tool", {}).pipe(
460
+ export const mainCommand = Command.make("k8s-tool", {}).pipe(
461
461
  Command.withDescription("Kubernetes CLI Tool for Coding Agents"),
462
462
  Command.withSubcommands([
463
463
  kubectlCommand,
@@ -486,6 +486,12 @@ const program = withRedactedOutput(withAudit("k8s", cli)).pipe(
486
486
  Effect.tapCause(renderCauseToStderr),
487
487
  );
488
488
 
489
- BunRuntime.runMain(program, {
490
- disableErrorReporting: true,
491
- });
489
+ export const runMain = () =>
490
+ BunRuntime.runMain(program, {
491
+ disableErrorReporting: true,
492
+ });
493
+
494
+ export const runProgram = (args: readonly string[]) =>
495
+ withRedactedOutput(
496
+ withAudit("k8s", Command.runWith(mainCommand, { version: VERSION, renderErrors: false })(args)),
497
+ ).pipe(Effect.provide(MainLayer), Effect.tapCause(renderCauseToStderr));
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
2
  import { checkEffectVersion } from "#shared/effect-version";
3
3
 
4
- if (await checkEffectVersion()) await import("./main");
4
+ if (await checkEffectVersion()) (await import("./main")).runMain();
5
5
  else process.exitCode = 1;