@blogic-cz/agent-tools 0.14.40 → 0.14.42

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogic-cz/agent-tools",
3
- "version": "0.14.40",
3
+ "version": "0.14.42",
4
4
  "description": "CLI tools for AI coding agent workflows — GitHub, database, Kubernetes, Azure DevOps, logs, sessions, and audit",
5
5
  "keywords": [
6
6
  "agent",
@@ -8,7 +8,7 @@ import type { SchemaMode } from "./types";
8
8
  import { formatOption, formatOutput, renderCauseToStderr, VERSION } from "#shared";
9
9
  import { AuditServiceLayer, withAudit } from "#shared/audit";
10
10
  import { ConfigService, ConfigServiceLayer, getDefaultEnvironment } from "#config";
11
- import { makeDbConfigLayer } from "./config-service";
11
+ import { DbConfigService, makeDbConfigLayer } from "./config-service";
12
12
  import { DbConnectionError } from "./errors";
13
13
  import { DbService } from "./service";
14
14
 
@@ -107,9 +107,35 @@ const schemaCommand = Command.make(
107
107
  }),
108
108
  ).pipe(Command.withDescription("Introspect database schema (tables, columns, relationships)"));
109
109
 
110
+ const envsCommand = Command.make(
111
+ "envs",
112
+ {
113
+ format: formatOption,
114
+ profile: Flag.optional(Flag.string("profile")).pipe(
115
+ Flag.withDescription("Database profile name from agent-tools.json5 (if multiple configured)"),
116
+ ),
117
+ },
118
+ ({ format }) =>
119
+ Effect.gen(function* () {
120
+ const config = yield* ConfigService;
121
+ const dbConfig = yield* DbConfigService;
122
+ const environments = dbConfig ? Object.keys(dbConfig.environments) : [];
123
+ const result = {
124
+ success: true as const,
125
+ environments,
126
+ default: getDefaultEnvironment(config) ?? null,
127
+ message: `${environments.length} environment(s) configured`,
128
+ executionTimeMs: 0,
129
+ };
130
+ yield* Console.log(formatOutput(result, format));
131
+ }),
132
+ ).pipe(
133
+ Command.withDescription("List configured database environments and the default (no network)"),
134
+ );
135
+
110
136
  const mainCommand = Command.make("db-tool", {}).pipe(
111
137
  Command.withDescription("Database Query Tool for Coding Agents"),
112
- Command.withSubcommands([sqlCommand, schemaCommand]),
138
+ Command.withSubcommands([sqlCommand, schemaCommand, envsCommand]),
113
139
  );
114
140
 
115
141
  const cli = Command.run(mainCommand, {
@@ -681,11 +681,17 @@ export class DbService extends Context.Service<
681
681
  );
682
682
  };
683
683
 
684
- const getConfigForEnv = (env: string): DbConfig => {
684
+ const getConfigForEnv = Effect.fn("DbService.getConfigForEnv")(function* (env: string) {
685
685
  const envConfig = dbConfig.environments[env];
686
686
  if (!envConfig) {
687
687
  const available = Object.keys(dbConfig.environments).join(", ");
688
- throw new Error(`Unknown environment "${env}". Available: ${available}`);
688
+ // Tagged failure (not a bare throw) so the hint + nextCommand reach the agent (M3).
689
+ return yield* new DbConnectionError({
690
+ message: `Unknown environment "${env}". Available: ${available}`,
691
+ environment: env,
692
+ hint: "List configured environments with: db-tool envs",
693
+ nextCommand: "db-tool envs",
694
+ });
689
695
  }
690
696
 
691
697
  const accessMode = resolveDbAccessMode(
@@ -707,13 +713,13 @@ export class DbService extends Context.Service<
707
713
  allowMutations: accessMode.allowMutations,
708
714
  allowedMutations: accessMode.allowedMutations,
709
715
  };
710
- };
716
+ });
711
717
 
712
718
  const executeQuery = Effect.fn("DbService.executeQuery")(function* (
713
719
  env: string,
714
720
  sql: string,
715
721
  ) {
716
- const config = getConfigForEnv(env);
722
+ const config = yield* getConfigForEnv(env);
717
723
  const startTimeMs = yield* Clock.currentTimeMillis;
718
724
  const resolvedConfig = yield* resolveDbConfig(config, env);
719
725
  const password = yield* resolvePassword(resolvedConfig, env);
@@ -753,7 +759,7 @@ export class DbService extends Context.Service<
753
759
  mode: SchemaMode,
754
760
  table?: string,
755
761
  ) {
756
- const config = getConfigForEnv(env);
762
+ const config = yield* getConfigForEnv(env);
757
763
  const startTimeMs = yield* Clock.currentTimeMillis;
758
764
  const resolvedConfig = yield* resolveDbConfig(config, env);
759
765
  const password = yield* resolvePassword(resolvedConfig, env);
@@ -20,6 +20,7 @@ import {
20
20
  import {
21
21
  prViewCommand,
22
22
  prStatusCommand,
23
+ prListCommand,
23
24
  prCreateCommand,
24
25
  prCloseCommand,
25
26
  prEditCommand,
@@ -39,6 +40,7 @@ import {
39
40
  prReplyAndResolveCommand,
40
41
  prReviewTriageBatchCommand,
41
42
  prReviewTriageCommand,
43
+ prWaitMergeableCommand,
42
44
  } from "./pr/index";
43
45
  import { branchRenameCommand } from "./branch";
44
46
  import {
@@ -68,10 +70,12 @@ const prCommand = Command.make("pr", {}).pipe(
68
70
  Command.withSubcommands([
69
71
  prViewCommand,
70
72
  prStatusCommand,
73
+ prListCommand,
71
74
  prCreateCommand,
72
75
  prCloseCommand,
73
76
  prEditCommand,
74
77
  prMergeCommand,
78
+ prWaitMergeableCommand,
75
79
  prThreadsCommand,
76
80
  prCommentsCommand,
77
81
  prIssueCommentsCommand,
@@ -73,7 +73,11 @@ export const issueViewCommand = Command.make(
73
73
  yield* logFormatted(info, format);
74
74
  }),
75
75
  ),
76
- ).pipe(Command.withDescription("View issue details"));
76
+ ).pipe(
77
+ Command.withDescription(
78
+ "View one issue's details. For multiple issues use `gh issue snapshot-batch --issues 1,2,3` (one call, not N).",
79
+ ),
80
+ );
77
81
 
78
82
  export const issueCommentsCommand = Command.make(
79
83
  "comments",
@@ -1,10 +1,20 @@
1
1
  import { Command, Flag } from "effect/unstable/cli";
2
- import { Effect, Option } from "effect";
2
+ import { Console, Effect, Option } from "effect";
3
3
 
4
4
  import type { CheckResult, PRStatusResult } from "#gh/types";
5
5
 
6
6
  import { formatOption, logFormatted } from "#shared";
7
7
  import { GitHubService } from "#gh/service";
8
+ import { GitHubCommandError } from "#gh/errors";
9
+
10
+ const emptyBatchError = (batch: string) =>
11
+ new GitHubCommandError({
12
+ message: `--prs received no valid PR numbers: ${JSON.stringify(batch)}`,
13
+ command: "gh pr --prs",
14
+ exitCode: 1,
15
+ stderr: "",
16
+ hint: "Pass comma-separated positive integers, e.g. --prs 1,2,3.",
17
+ });
8
18
  import {
9
19
  resolveDefaultTextInput,
10
20
  resolveOptionalTextInput,
@@ -25,9 +35,11 @@ import {
25
35
  fetchChecks,
26
36
  fetchChecksForCommand,
27
37
  fetchFailedChecks,
38
+ listPRs,
28
39
  mergePR,
29
40
  rerunChecks,
30
41
  viewPR,
42
+ waitForMergeable,
31
43
  } from "./core";
32
44
  import {
33
45
  fetchComments,
@@ -97,7 +109,25 @@ export const fetchReviewTriage = Effect.fn("pr.fetchReviewTriage")(function* (
97
109
  fetchChecks(prNumber, false, false, 0),
98
110
  ]);
99
111
  const classification = classifyReviewTriage(summary, checks);
100
- return { classification, info, unresolvedThreads, visibleOpenThreads, summary, checks };
112
+
113
+ // Single merge-readiness verdict so agents stop re-stitching mergeable + checks + threads +
114
+ // review state across separate calls (F2). `blocking` names exactly what's left to do.
115
+ const blocking: string[] = [];
116
+ if (info.mergeable !== "MERGEABLE") blocking.push(`mergeable=${info.mergeable || "UNKNOWN"}`);
117
+ if (checks.some((check) => check.bucket === "fail")) blocking.push("failing_checks");
118
+ if (checks.some((check) => check.bucket === "pending")) blocking.push("pending_checks");
119
+ if (summary.unresolvedReviewThreadsCount > 0) blocking.push("unresolved_threads");
120
+ if (info.reviewDecision !== "" && info.reviewDecision !== "APPROVED") {
121
+ blocking.push(`review=${info.reviewDecision}`);
122
+ }
123
+ const ready = {
124
+ ready: blocking.length === 0,
125
+ mergeable: info.mergeable,
126
+ reviewDecision: info.reviewDecision || null,
127
+ blocking,
128
+ };
129
+
130
+ return { ready, classification, info, unresolvedThreads, visibleOpenThreads, summary, checks };
101
131
  });
102
132
 
103
133
  export const prViewCommand = Command.make(
@@ -108,18 +138,34 @@ export const prViewCommand = Command.make(
108
138
  Flag.withDescription("PR number (default: current branch PR)"),
109
139
  Flag.optional,
110
140
  ),
141
+ prs: Flag.string("prs").pipe(
142
+ Flag.withDescription("Comma-separated PR numbers to view in one call (overrides --pr)"),
143
+ Flag.optional,
144
+ ),
111
145
  repo: repoOption,
112
146
  },
113
- ({ format, pr, repo }) =>
147
+ ({ format, pr, prs, repo }) =>
114
148
  withRepo(
115
149
  repo,
116
150
  Effect.gen(function* () {
117
- const prNumber = Option.getOrNull(pr);
118
- const info = yield* viewPR(prNumber);
151
+ const batch = Option.getOrNull(prs);
152
+ if (batch !== null) {
153
+ const numbers = parsePrNumbers(batch);
154
+ if (numbers.length === 0) return yield* emptyBatchError(batch);
155
+ const results = yield* Effect.all(
156
+ numbers.map((n) => viewPR(n).pipe(Effect.map((info) => ({ pr: n, info })))),
157
+ { concurrency: 5 },
158
+ );
159
+ yield* logFormatted({ count: results.length, prs: results }, format);
160
+ return;
161
+ }
162
+ const info = yield* viewPR(Option.getOrNull(pr));
119
163
  yield* logFormatted(info, format);
120
164
  }),
121
165
  ),
122
- ).pipe(Command.withDescription("View PR information"));
166
+ ).pipe(
167
+ Command.withDescription("View PR information (use --prs 1,2,3 to view several in one call)"),
168
+ );
123
169
 
124
170
  export const prStatusCommand = Command.make(
125
171
  "status",
@@ -136,12 +182,87 @@ export const prStatusCommand = Command.make(
136
182
  Command.withDescription("Auto-detect PR for current branch or GitButler workspace branches"),
137
183
  );
138
184
 
185
+ export const prListCommand = Command.make(
186
+ "list",
187
+ {
188
+ format: formatOption,
189
+ state: Flag.choice("state", ["open", "closed", "merged", "all"]).pipe(
190
+ Flag.withDescription("Filter by state: open, closed, merged, all"),
191
+ Flag.withDefault("open"),
192
+ ),
193
+ author: Flag.string("author").pipe(
194
+ Flag.withDescription("Filter by author login (use @me for yourself)"),
195
+ Flag.optional,
196
+ ),
197
+ base: Flag.string("base").pipe(Flag.withDescription("Filter by base branch"), Flag.optional),
198
+ head: Flag.string("head").pipe(Flag.withDescription("Filter by head branch"), Flag.optional),
199
+ search: Flag.string("search").pipe(
200
+ Flag.withDescription("GitHub search query (e.g. 'review:required')"),
201
+ Flag.optional,
202
+ ),
203
+ limit: Flag.integer("limit").pipe(
204
+ Flag.withDescription("Maximum number of PRs to return"),
205
+ Flag.withDefault(30),
206
+ ),
207
+ repo: repoOption,
208
+ },
209
+ ({ format, state, author, base, head, search, limit, repo }) =>
210
+ withRepo(
211
+ repo,
212
+ Effect.gen(function* () {
213
+ const prs = yield* listPRs({
214
+ state,
215
+ limit,
216
+ author: Option.getOrNull(author),
217
+ base: Option.getOrNull(base),
218
+ head: Option.getOrNull(head),
219
+ search: Option.getOrNull(search),
220
+ });
221
+ yield* logFormatted(prs, format);
222
+ }),
223
+ ),
224
+ ).pipe(
225
+ Command.withDescription(
226
+ "List PRs (default: open; filter with --state/--author/--base/--head/--search)",
227
+ ),
228
+ );
229
+
230
+ export const prWaitMergeableCommand = Command.make(
231
+ "wait-mergeable",
232
+ {
233
+ format: formatOption,
234
+ pr: Flag.integer("pr").pipe(
235
+ Flag.withDescription("PR number (default: current branch PR)"),
236
+ Flag.optional,
237
+ ),
238
+ timeout: Flag.integer("timeout").pipe(
239
+ Flag.withDescription(
240
+ "Max seconds to wait for a definitive mergeable verdict (capped at 180)",
241
+ ),
242
+ Flag.withDefault(60),
243
+ ),
244
+ repo: repoOption,
245
+ },
246
+ ({ format, pr, timeout, repo }) =>
247
+ withRepo(
248
+ repo,
249
+ Effect.gen(function* () {
250
+ const info = yield* waitForMergeable(Option.getOrNull(pr), timeout);
251
+ yield* logFormatted(info, format);
252
+ }),
253
+ ),
254
+ ).pipe(
255
+ Command.withDescription(
256
+ "Poll until GitHub reports a definitive mergeable verdict (MERGEABLE/CONFLICTING) or timeout",
257
+ ),
258
+ );
259
+
139
260
  export const prCreateCommand = Command.make(
140
261
  "create",
141
262
  {
142
263
  base: Flag.string("base").pipe(
143
- Flag.withDescription("Base branch for the PR"),
144
- Flag.withDefault("test"),
264
+ Flag.withDescription("Base branch for the PR (default: repository default branch)"),
265
+ Flag.optional,
145
266
  ),
146
267
  body: Flag.string("body").pipe(Flag.withDescription("PR body/description"), Flag.optional),
147
268
  bodyFile: Flag.string("body-file").pipe(
@@ -181,7 +302,7 @@ export const prCreateCommand = Command.make(
181
302
  });
182
303
 
183
304
  const info = yield* createPR({
184
- base,
305
+ base: Option.getOrNull(base),
185
306
  body: resolvedBody,
186
307
  draft,
187
308
  head: Option.getOrNull(head),
@@ -324,6 +445,10 @@ export const prChecksCommand = Command.make(
324
445
  Flag.withDescription("PR number (default: current branch PR)"),
325
446
  Flag.optional,
326
447
  ),
448
+ prs: Flag.string("prs").pipe(
449
+ Flag.withDescription("Comma-separated PR numbers for a one-shot batch snapshot (no --watch)"),
450
+ Flag.optional,
451
+ ),
327
452
  repo: repoOption,
328
453
  timeout: Flag.integer("timeout").pipe(
329
454
  Flag.withDefault(CI_CHECK_WATCH_TIMEOUT_MS / 1000),
@@ -334,16 +459,39 @@ export const prChecksCommand = Command.make(
334
459
  Flag.withDescription("Watch until checks complete or timeout"),
335
460
  ),
336
461
  },
337
- ({ failFast, format, pr, repo, timeout, watch }) =>
462
+ ({ failFast, format, pr, prs, repo, timeout, watch }) =>
338
463
  withRepo(
339
464
  repo,
340
465
  Effect.gen(function* () {
341
- const prNumber = Option.getOrNull(pr);
342
- const checks = yield* fetchChecksForCommand(prNumber, watch, failFast, timeout);
466
+ const batch = Option.getOrNull(prs);
467
+ if (batch !== null) {
468
+ if (watch) {
469
+ yield* Console.warn(
470
+ "ℹ️ --watch is ignored with --prs; batch mode returns a one-shot snapshot per PR.",
471
+ );
472
+ }
473
+ const numbers = parsePrNumbers(batch);
474
+ if (numbers.length === 0) return yield* emptyBatchError(batch);
475
+ const results = yield* Effect.all(
476
+ numbers.map((n) =>
477
+ fetchChecks(n, false, failFast, timeout).pipe(
478
+ Effect.map((checks) => ({ pr: n, checks })),
479
+ ),
480
+ ),
481
+ { concurrency: 5 },
482
+ );
483
+ yield* logFormatted({ count: results.length, prs: results }, format);
484
+ return;
485
+ }
486
+ const checks = yield* fetchChecksForCommand(Option.getOrNull(pr), watch, failFast, timeout);
343
487
  yield* logFormatted(checks, format);
344
488
  }),
345
489
  ),
346
- ).pipe(Command.withDescription("Fetch CI check status for a PR (optionally watch with timeout)"));
490
+ ).pipe(
491
+ Command.withDescription(
492
+ "Fetch CI check status for a PR (--watch to block; --prs 1,2,3 for a batch snapshot)",
493
+ ),
494
+ );
347
495
 
348
496
  export const prChecksFailedCommand = Command.make(
349
497
  "checks-failed",
@@ -1,4 +1,4 @@
1
- import { Console, Effect, Option, Result } from "effect";
1
+ import { Clock, Console, Duration, Effect, Option, Result } from "effect";
2
2
 
3
3
  import type {
4
4
  BranchPRDetail,
@@ -12,7 +12,7 @@ import type {
12
12
  WorkflowRunDetail,
13
13
  } from "#gh/types";
14
14
 
15
- import { GitHubCommandError, GitHubMergeError, GitHubTimeoutError } from "#gh/errors";
15
+ import { GitHubCommandError, GitHubMergeError } from "#gh/errors";
16
16
  import { GitHubService } from "#gh/service";
17
17
 
18
18
  import type { ButStatusJson, PRViewJsonResult } from "./helpers";
@@ -20,6 +20,9 @@ import { runLocalCommand } from "./helpers";
20
20
 
21
21
  const CHECK_JSON_FIELDS = "name,state,bucket,link";
22
22
  const GITHUB_ACTIONS_RUN_ID_RE = /github\.com\/[^/]+\/[^/]+\/actions\/runs\/(\d+)/;
23
+ // A single blocking `--watch` is capped here so an agent never loses a whole turn to a 30-min
24
+ // foreground wait. On hitting the cap we return the partial snapshot, not a failure (H1).
25
+ const MAX_WATCH_SECONDS = 120;
23
26
 
24
27
  const validatePRTitle = Effect.fn("pr.validatePRTitle")(function* (title: string) {
25
28
  const gh = yield* GitHubService;
@@ -254,7 +257,7 @@ const buildFailedChecksReport = Effect.fn("pr.buildFailedChecksReport")(function
254
257
  const hint =
255
258
  failedChecks.length === 0
256
259
  ? pendingChecks.length > 0
257
- ? "Wait for the remaining checks to finish, or use --watch to block until CI settles."
260
+ ? "Some checks are still running. Re-run this command to refresh the snapshot."
258
261
  : "All current checks are green."
259
262
  : pendingChecks.length > 0
260
263
  ? "Inspect the failed workflow run first. Other checks are still running and may change overall merge readiness."
@@ -453,8 +456,69 @@ export const detectPRStatus = Effect.fn("pr.detectPRStatus")(function* () {
453
456
  };
454
457
  });
455
458
 
459
+ export const listPRs = Effect.fn("pr.listPRs")(function* (opts: {
460
+ state: string;
461
+ limit: number;
462
+ author: string | null;
463
+ base: string | null;
464
+ head: string | null;
465
+ search: string | null;
466
+ }) {
467
+ const gh = yield* GitHubService;
468
+ const args = [
469
+ "pr",
470
+ "list",
471
+ "--state",
472
+ opts.state,
473
+ "--limit",
474
+ String(opts.limit),
475
+ "--json",
476
+ "number,url,title,headRefName,baseRefName,state,isDraft,author,createdAt,reviewDecision",
477
+ ];
478
+ if (opts.author !== null) args.push("--author", opts.author);
479
+ if (opts.base !== null) args.push("--base", opts.base);
480
+ if (opts.head !== null) args.push("--head", opts.head);
481
+ if (opts.search !== null) args.push("--search", opts.search);
482
+ return yield* gh.runGhJson<PRInfo[]>(args);
483
+ });
484
+
485
+ // GitHub recomputes mergeability asynchronously after CI; poll until it settles out of "UNKNOWN".
486
+ const MAX_MERGEABLE_WAIT_SECONDS = 180;
487
+ const MERGEABLE_POLL_INTERVAL_MS = 3000;
488
+
489
+ export const waitForMergeable = Effect.fn("pr.waitForMergeable")(function* (
490
+ pr: number | null,
491
+ timeoutSeconds: number,
492
+ ) {
493
+ const cappedSeconds = Math.min(timeoutSeconds, MAX_MERGEABLE_WAIT_SECONDS);
494
+ const start = yield* Clock.currentTimeMillis;
495
+ const deadlineMs = Number(start) + cappedSeconds * 1000;
496
+
497
+ // Effect.whileLoop (not recursion) so TestClock.adjust can advance Effect.sleep without real waits.
498
+ let latest = yield* viewPR(pr);
499
+ let timedOut = false;
500
+ yield* Effect.whileLoop({
501
+ while: () => latest.mergeable === "UNKNOWN" && !timedOut,
502
+ body: () =>
503
+ Effect.gen(function* () {
504
+ const now = yield* Clock.currentTimeMillis;
505
+ if (Number(now) >= deadlineMs) {
506
+ timedOut = true;
507
+ return;
508
+ }
509
+ // Cap the sleep to the remaining budget so the total wait doesn't overshoot the deadline.
510
+ const remaining = deadlineMs - Number(now);
511
+ yield* Effect.sleep(Duration.millis(Math.min(MERGEABLE_POLL_INTERVAL_MS, remaining)));
512
+ latest = yield* viewPR(pr);
513
+ }),
514
+ step: () => undefined,
515
+ });
516
+
517
+ return latest;
518
+ });
519
+
456
520
  export const createPR = Effect.fn("pr.createPR")(function* (opts: {
457
- base: string;
521
+ base: string | null;
458
522
  title: string;
459
523
  body: string;
460
524
  draft: boolean;
@@ -463,6 +527,10 @@ export const createPR = Effect.fn("pr.createPR")(function* (opts: {
463
527
  const gh = yield* GitHubService;
464
528
  yield* validatePRTitle(opts.title);
465
529
 
530
+ // Default to the repo's real default branch instead of a hardcoded "test" — an omitted --base
531
+ // must never silently open a PR against the wrong trunk (L1).
532
+ const baseBranch = opts.base ?? (yield* gh.getRepoInfo()).defaultBranch;
533
+
466
534
  // When --head is provided (e.g. GitButler workspace), use `gh pr list --head`
467
535
  // to find existing PR since `gh pr view` relies on the current git branch.
468
536
  const existing = yield* opts.head !== null
@@ -500,7 +568,7 @@ export const createPR = Effect.fn("pr.createPR")(function* (opts: {
500
568
  "pr",
501
569
  "create",
502
570
  "--base",
503
- opts.base,
571
+ baseBranch,
504
572
  "--title",
505
573
  opts.title,
506
574
  "--body",
@@ -806,31 +874,31 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
806
874
  watchArgs.push("--fail-fast");
807
875
  }
808
876
 
809
- const timeoutMs = timeoutSeconds * 1000;
810
- yield* gh.runGh(watchArgs).pipe(
877
+ // Cap the blocking wait; on timeout fall through to a snapshot instead of failing with no state.
878
+ const cappedSeconds = Math.min(timeoutSeconds, MAX_WATCH_SECONDS);
879
+ const watchOutcome = yield* gh.runGh(watchArgs).pipe(
811
880
  Effect.timeoutOrElse({
812
- duration: timeoutMs,
813
- orElse: () =>
814
- Effect.fail(
815
- new GitHubTimeoutError({
816
- message: `CI check monitoring timed out after ${timeoutSeconds}s`,
817
- timeoutMs,
818
- hint: "CI checks are still running. Retry with a longer --timeout or check status manually.",
819
- nextCommand: `agent-tools-gh pr checks${pr !== null ? ` --pr ${pr}` : ""}`,
820
- retryable: true,
821
- }),
822
- ),
881
+ duration: cappedSeconds * 1000,
882
+ orElse: () => Effect.succeed(null),
823
883
  }),
824
884
  );
825
885
 
826
- return yield* fetchCheckResults(pr);
886
+ const results = yield* fetchCheckResults(pr);
887
+ if (watchOutcome === null && results.some((c) => c.bucket === "pending")) {
888
+ const pending = results.filter((c) => c.bucket === "pending").length;
889
+ yield* Console.warn(
890
+ `ℹ️ Watch capped at ${cappedSeconds}s; ${pending} check(s) still pending (snapshot returned). ` +
891
+ `Re-run to keep watching:\n ${buildChecksCommand(pr, true)}`,
892
+ );
893
+ }
894
+ return results;
827
895
  }
828
896
 
829
897
  const results = yield* fetchCheckResults(pr);
830
898
  if (results.some((c) => c.bucket === "pending")) {
831
899
  yield* Console.warn(
832
- `ℹ️ Some checks are still running. Prefer --watch to block until completion instead of polling:\n` +
833
- ` ${buildChecksCommand(pr, true)}`,
900
+ `ℹ️ Some checks are still running. Re-run to refresh each call returns the latest snapshot:\n` +
901
+ ` ${buildChecksCommand(pr, false)}`,
834
902
  );
835
903
  }
836
904
  return results;
@@ -9,6 +9,7 @@ export {
9
9
  prEditCommand,
10
10
  prIssueCommentsCommand,
11
11
  prIssueCommentsLatestCommand,
12
+ prListCommand,
12
13
  prMergeCommand,
13
14
  prReplyCommand,
14
15
  prRerunChecksCommand,
@@ -17,6 +18,7 @@ export {
17
18
  prStatusCommand,
18
19
  prSubmitReviewCommand,
19
20
  prThreadsCommand,
21
+ prWaitMergeableCommand,
20
22
  prReviewTriageCommand,
21
23
  prReviewTriageBatchCommand,
22
24
  prViewCommand,
@@ -1,5 +1,5 @@
1
1
  import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2
- import { Context, Effect, Layer, Stream } from "effect";
2
+ import { Context, Duration, Effect, Layer, Stream } from "effect";
3
3
 
4
4
  import type { GitHubRepoConfig } from "#config";
5
5
  import type { RepoInfo } from "./types";
@@ -8,6 +8,23 @@ import { GH_BINARY } from "./config";
8
8
  import { GitHubAuthError, GitHubCommandError, GitHubNotFoundError } from "./errors";
9
9
  import { ConfigService, getGitHubConfig, resolveGitHubRepoTarget } from "#config";
10
10
 
11
+ // Transient GitHub-side failures worth a silent retry (vs. a hard error the agent must act on).
12
+ const NETWORK_ERROR_RE =
13
+ /i\/o timeout|dial tcp|operation timed out|connection reset|\bEOF\b|HTTP 50[0-9]|50[0-9] (?:Bad Gateway|Service Unavailable|Gateway Timeout)|timeout awaiting/i;
14
+ const AUTH_401_RE = /HTTP 401|Bad credentials/i;
15
+ const MAX_GH_RETRIES = 2;
16
+
17
+ // Only retry verbs that are unambiguously idempotent reads — never replay a mutation on a timeout.
18
+ const READ_VERBS = new Set(["view", "list", "checks", "status", "diff"]);
19
+ const MUTATION_TOKENS =
20
+ /\b(create|edit|merge|comment|close|reopen|delete|review|ready|sync|rerun|cancel|mutation)\b|(?:-X|--method)\s+(?:POST|PATCH|PUT|DELETE)/i;
21
+ const isSafeRetryRead = (args: readonly string[]): boolean => {
22
+ const joined = args.join(" ");
23
+ if (MUTATION_TOKENS.test(joined)) return false;
24
+ if (args[0] === "api") return true; // GET by default; mutating methods already excluded above
25
+ return args.some((a) => READ_VERBS.has(a));
26
+ };
27
+
11
28
  type GhResult = {
12
29
  stdout: string;
13
30
  stderr: string;
@@ -134,7 +151,7 @@ export class GitHubService extends Context.Service<
134
151
  ),
135
152
  );
136
153
 
137
- const runGh = Effect.fn("GitHubService.runGh")(function* (args: string[]) {
154
+ const runGhAttempt = Effect.fn("GitHubService.runGhAttempt")(function* (args: string[]) {
138
155
  const result = yield* executeGh(args);
139
156
 
140
157
  if (result.exitCode !== 0) {
@@ -149,15 +166,41 @@ export class GitHubService extends Context.Service<
149
166
  });
150
167
  }
151
168
 
169
+ // Expired/invalid token (401) is distinct from "never logged in" — the fix is refresh.
170
+ if (AUTH_401_RE.test(result.stderr)) {
171
+ return yield* new GitHubAuthError({
172
+ message: "GitHub credentials rejected (HTTP 401). Token is missing or expired.",
173
+ hint: "Refresh the GitHub CLI token, then retry.",
174
+ nextCommand: "gh auth refresh -h github.com",
175
+ });
176
+ }
177
+
178
+ // Transient network/5xx — flag retryable so the wrapper below can replay safe reads.
179
+ if (NETWORK_ERROR_RE.test(result.stderr)) {
180
+ return yield* new GitHubCommandError({
181
+ message: `Transient GitHub network error: ${result.stderr.trim()}`,
182
+ command: `gh ${args.join(" ")}`,
183
+ exitCode: result.exitCode,
184
+ stderr: result.stderr,
185
+ retryable: true,
186
+ hint: "Transient GitHub/network failure. Read commands auto-retry; if it persists, check VPN/connectivity.",
187
+ });
188
+ }
189
+
152
190
  if (
153
191
  result.stderr.includes("not found") ||
154
192
  result.stderr.includes("Could not resolve")
155
193
  ) {
194
+ const ghRepo = yield* RepoTarget;
156
195
  return yield* new GitHubNotFoundError({
157
- message: result.stderr,
158
- resource: "unknown",
196
+ message: ghRepo
197
+ ? `${result.stderr.trim()} (queried repo: ${ghRepo})`
198
+ : result.stderr,
199
+ resource: ghRepo ?? "unknown",
159
200
  identifier: "unknown",
160
- hint: "Verify the resource exists and you have access. Check repository owner/name spelling.",
201
+ hint: ghRepo
202
+ ? `Queried ${ghRepo}. If the resource lives in another repo, pass --repo (e.g. --repo fe).`
203
+ : "Verify the resource exists and you have access. Check repository owner/name spelling.",
161
204
  });
162
205
  }
163
206
 
@@ -172,6 +215,25 @@ export class GitHubService extends Context.Service<
172
215
  return result;
173
216
  });
174
217
 
218
+ // Auto-retry transient failures, but only for idempotent reads (never replay a mutation).
219
+ const runGh = (args: string[]): Effect.Effect<GhResult, GhError> => {
220
+ const canRetry = isSafeRetryRead(args);
221
+ const loop = (attempt: number): Effect.Effect<GhResult, GhError> =>
222
+ runGhAttempt(args).pipe(
223
+ Effect.catch((err) => {
224
+ const retryable =
225
+ err instanceof GitHubCommandError && err.retryable === true && canRetry;
226
+ if (retryable && attempt < MAX_GH_RETRIES) {
227
+ return Effect.sleep(Duration.millis(500 * 2 ** attempt)).pipe(
228
+ Effect.flatMap(() => loop(attempt + 1)),
229
+ );
230
+ }
231
+ return Effect.fail(err);
232
+ }),
233
+ );
234
+ return loop(0);
235
+ };
236
+
175
237
  const runGhJson = <T>(args: string[]) =>
176
238
  Effect.gen(function* () {
177
239
  const result = yield* runGh(args);
@@ -212,6 +212,10 @@ const cancelRun = Effect.fn("workflow.cancelRun")(function* (runId: number, repo
212
212
  };
213
213
  });
214
214
 
215
+ // `gh run watch` has no native timeout and was observed hanging a turn for 36 min. Cap it and
216
+ // fall back to a one-shot snapshot, mirroring the `pr checks --watch` cap (M1).
217
+ const WATCH_RUN_TIMEOUT_SECONDS = 120;
218
+
215
219
  const watchRun = Effect.fn("workflow.watchRun")(function* (runId: number, repo: string | null) {
216
220
  const gh = yield* GitHubService;
217
221
 
@@ -232,6 +236,10 @@ const watchRun = Effect.fn("workflow.watchRun")(function* (runId: number, repo:
232
236
  }
233
237
  return Effect.fail(error);
234
238
  }),
239
+ Effect.timeoutOrElse({
240
+ duration: WATCH_RUN_TIMEOUT_SECONDS * 1000,
241
+ orElse: () => Effect.succeed(null),
242
+ }),
235
243
  );
236
244
 
237
245
  const finalState = yield* viewRun(runId, repo);
@@ -245,7 +253,10 @@ const watchRun = Effect.fn("workflow.watchRun")(function* (runId: number, repo:
245
253
  status: job.status,
246
254
  conclusion: job.conclusion,
247
255
  })),
248
- watchOutput: result.stdout,
256
+ watchOutput:
257
+ result === null
258
+ ? `(watch capped at ${WATCH_RUN_TIMEOUT_SECONDS}s; status taken from snapshot — re-run to keep watching)`
259
+ : result.stdout,
249
260
  };
250
261
  });
251
262
 
@@ -116,9 +116,34 @@ const formatUnknownError = (error: unknown): string => {
116
116
  return String(error);
117
117
  };
118
118
 
119
+ // effect-cli `ShowHelp` is a control-flow error: empty `errors[]` means a plain `--help` (exit 0),
120
+ // a non-empty `errors[]` means a real parse failure (unrecognized option, missing arg, …) that was
121
+ // downgraded to a help render. Distinguish them so we neither mislabel help as a failure (H4) nor
122
+ // hide the actual syntax error behind the opaque "Help requested" string (M4).
123
+ const getShowHelpErrors = (error: unknown): readonly unknown[] | undefined => {
124
+ if (typeof error === "object" && error !== null && Reflect.get(error, "_tag") === "ShowHelp") {
125
+ const errors = Reflect.get(error, "errors");
126
+ return Array.isArray(errors) ? errors : [];
127
+ }
128
+ return undefined;
129
+ };
130
+
131
+ const isPureHelpCause = (cause: Cause.Cause<unknown>): boolean => {
132
+ const firstFailure = cause.reasons.find(Cause.isFailReason);
133
+ const showHelpErrors =
134
+ firstFailure === undefined ? undefined : getShowHelpErrors(firstFailure.error);
135
+ return showHelpErrors !== undefined && showHelpErrors.length === 0;
136
+ };
137
+
119
138
  const formatCause = (cause: Cause.Cause<unknown>): string => {
120
139
  const firstFailure = cause.reasons.find(Cause.isFailReason);
121
140
  if (firstFailure !== undefined) {
141
+ const showHelpErrors = getShowHelpErrors(firstFailure.error);
142
+ if (showHelpErrors !== undefined) {
143
+ return showHelpErrors.length > 0
144
+ ? showHelpErrors.map((e) => formatUnknownError(e)).join("; ")
145
+ : "Help requested";
146
+ }
122
147
  return formatUnknownError(firstFailure.error);
123
148
  }
124
149
 
@@ -296,19 +321,23 @@ export const withAudit = <A, E, R>(
296
321
  const tool = safeToolName(toolName || deriveToolNameFromArgv());
297
322
 
298
323
  return Effect.matchCauseEffect(program, {
299
- onFailure: (cause) =>
300
- Effect.flatMap(
324
+ onFailure: (cause) => {
325
+ // A bare `--help` exits 0 and is not a tool failure — recording it as one inflated the
326
+ // suite-wide fail rate by ~21% (H4). A ShowHelp carrying parse errors is still a failure.
327
+ const pureHelp = isPureHelpCause(cause);
328
+ return Effect.flatMap(
301
329
  safelyRecord({
302
330
  tool,
303
331
  project,
304
332
  args,
305
333
  duration: Date.now() - startedAt,
306
- success: false,
307
- error: formatCause(cause),
308
- exitCode: extractExitCode(cause),
334
+ success: pureHelp,
335
+ error: pureHelp ? undefined : formatCause(cause),
336
+ exitCode: pureHelp ? 0 : extractExitCode(cause),
309
337
  }),
310
338
  () => Effect.failCause(cause),
311
- ),
339
+ );
340
+ },
312
341
  onSuccess: (value) =>
313
342
  Effect.flatMap(
314
343
  safelyRecord({
@@ -12,12 +12,16 @@ const formatError = (error: unknown): string => {
12
12
  const tag = (error as Record<string, unknown>)._tag as string;
13
13
  const message = (error as Record<string, unknown>).message;
14
14
  const hint = (error as Record<string, unknown>).hint;
15
+ const nextCommand = (error as Record<string, unknown>).nextCommand;
15
16
  let result = "";
16
17
  if (typeof message === "string") {
17
18
  result = `${tag}: ${message}`;
18
19
  } else {
19
20
  const details = Object.entries(error as Record<string, unknown>)
20
- .filter(([key, val]) => typeof val === "string" && key !== "_tag" && key !== "hint")
21
+ .filter(
22
+ ([key, val]) =>
23
+ typeof val === "string" && key !== "_tag" && key !== "hint" && key !== "nextCommand",
24
+ )
21
25
  .map(([key, val]) => `${key}=${String(val)}`)
22
26
  .join(", ");
23
27
  result = details ? `${tag}: ${details}` : tag;
@@ -25,6 +29,11 @@ const formatError = (error: unknown): string => {
25
29
  if (typeof hint === "string") {
26
30
  result += `\n Hint: ${hint}`;
27
31
  }
32
+ // Surface the exact corrective invocation that tagged errors already carry — without this
33
+ // the affordance is computed everywhere and shown nowhere.
34
+ if (typeof nextCommand === "string" && nextCommand.length > 0) {
35
+ result += `\n Try: ${nextCommand}`;
36
+ }
28
37
  return result;
29
38
  }
30
39
  if (error instanceof Error) return error.message;