@blogic-cz/agent-tools 0.14.39 → 0.14.41

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.39",
3
+ "version": "0.14.41",
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",
@@ -112,6 +112,10 @@
112
112
  "./audit": {
113
113
  "types": "./dist/shared/audit.d.ts",
114
114
  "default": "./src/shared/audit.ts"
115
+ },
116
+ "./k8s-probe": {
117
+ "types": "./dist/shared/k8s-probe.d.ts",
118
+ "default": "./src/shared/k8s-probe.ts"
115
119
  }
116
120
  },
117
121
  "publishConfig": {
@@ -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,
@@ -68,6 +69,7 @@ const prCommand = Command.make("pr", {}).pipe(
68
69
  Command.withSubcommands([
69
70
  prViewCommand,
70
71
  prStatusCommand,
72
+ prListCommand,
71
73
  prCreateCommand,
72
74
  prCloseCommand,
73
75
  prEditCommand,
@@ -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",
@@ -25,6 +25,7 @@ import {
25
25
  fetchChecks,
26
26
  fetchChecksForCommand,
27
27
  fetchFailedChecks,
28
+ listPRs,
28
29
  mergePR,
29
30
  rerunChecks,
30
31
  viewPR,
@@ -97,7 +98,25 @@ export const fetchReviewTriage = Effect.fn("pr.fetchReviewTriage")(function* (
97
98
  fetchChecks(prNumber, false, false, 0),
98
99
  ]);
99
100
  const classification = classifyReviewTriage(summary, checks);
100
- return { classification, info, unresolvedThreads, visibleOpenThreads, summary, checks };
101
+
102
+ // Single merge-readiness verdict so agents stop re-stitching mergeable + checks + threads +
103
+ // review state across separate calls (F2). `blocking` names exactly what's left to do.
104
+ const blocking: string[] = [];
105
+ if (info.mergeable !== "MERGEABLE") blocking.push(`mergeable=${info.mergeable || "UNKNOWN"}`);
106
+ if (checks.some((check) => check.bucket === "fail")) blocking.push("failing_checks");
107
+ if (checks.some((check) => check.bucket === "pending")) blocking.push("pending_checks");
108
+ if (summary.unresolvedReviewThreadsCount > 0) blocking.push("unresolved_threads");
109
+ if (info.reviewDecision !== "" && info.reviewDecision !== "APPROVED") {
110
+ blocking.push(`review=${info.reviewDecision}`);
111
+ }
112
+ const ready = {
113
+ ready: blocking.length === 0,
114
+ mergeable: info.mergeable,
115
+ reviewDecision: info.reviewDecision || null,
116
+ blocking,
117
+ };
118
+
119
+ return { ready, classification, info, unresolvedThreads, visibleOpenThreads, summary, checks };
101
120
  });
102
121
 
103
122
  export const prViewCommand = Command.make(
@@ -136,12 +155,57 @@ export const prStatusCommand = Command.make(
136
155
  Command.withDescription("Auto-detect PR for current branch or GitButler workspace branches"),
137
156
  );
138
157
 
158
+ export const prListCommand = Command.make(
159
+ "list",
160
+ {
161
+ format: formatOption,
162
+ state: Flag.choice("state", ["open", "closed", "merged", "all"]).pipe(
163
+ Flag.withDescription("Filter by state: open, closed, merged, all"),
164
+ Flag.withDefault("open"),
165
+ ),
166
+ author: Flag.string("author").pipe(
167
+ Flag.withDescription("Filter by author login (use @me for yourself)"),
168
+ Flag.optional,
169
+ ),
170
+ base: Flag.string("base").pipe(Flag.withDescription("Filter by base branch"), Flag.optional),
171
+ head: Flag.string("head").pipe(Flag.withDescription("Filter by head branch"), Flag.optional),
172
+ search: Flag.string("search").pipe(
173
+ Flag.withDescription("GitHub search query (e.g. 'review:required')"),
174
+ Flag.optional,
175
+ ),
176
+ limit: Flag.integer("limit").pipe(
177
+ Flag.withDescription("Maximum number of PRs to return"),
178
+ Flag.withDefault(30),
179
+ ),
180
+ repo: repoOption,
181
+ },
182
+ ({ format, state, author, base, head, search, limit, repo }) =>
183
+ withRepo(
184
+ repo,
185
+ Effect.gen(function* () {
186
+ const prs = yield* listPRs({
187
+ state,
188
+ limit,
189
+ author: Option.getOrNull(author),
190
+ base: Option.getOrNull(base),
191
+ head: Option.getOrNull(head),
192
+ search: Option.getOrNull(search),
193
+ });
194
+ yield* logFormatted(prs, format);
195
+ }),
196
+ ),
197
+ ).pipe(
198
+ Command.withDescription(
199
+ "List PRs (default: open; filter with --state/--author/--base/--head/--search)",
200
+ ),
201
+ );
202
+
139
203
  export const prCreateCommand = Command.make(
140
204
  "create",
141
205
  {
142
206
  base: Flag.string("base").pipe(
143
- Flag.withDescription("Base branch for the PR"),
144
- Flag.withDefault("test"),
207
+ Flag.withDescription("Base branch for the PR (default: repository default branch)"),
208
+ Flag.optional,
145
209
  ),
146
210
  body: Flag.string("body").pipe(Flag.withDescription("PR body/description"), Flag.optional),
147
211
  bodyFile: Flag.string("body-file").pipe(
@@ -181,7 +245,7 @@ export const prCreateCommand = Command.make(
181
245
  });
182
246
 
183
247
  const info = yield* createPR({
184
- base,
248
+ base: Option.getOrNull(base),
185
249
  body: resolvedBody,
186
250
  draft,
187
251
  head: Option.getOrNull(head),
@@ -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,34 @@ 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
+
456
485
  export const createPR = Effect.fn("pr.createPR")(function* (opts: {
457
- base: string;
486
+ base: string | null;
458
487
  title: string;
459
488
  body: string;
460
489
  draft: boolean;
@@ -463,6 +492,10 @@ export const createPR = Effect.fn("pr.createPR")(function* (opts: {
463
492
  const gh = yield* GitHubService;
464
493
  yield* validatePRTitle(opts.title);
465
494
 
495
+ // Default to the repo's real default branch instead of a hardcoded "test" — an omitted --base
496
+ // must never silently open a PR against the wrong trunk (L1).
497
+ const baseBranch = opts.base ?? (yield* gh.getRepoInfo()).defaultBranch;
498
+
466
499
  // When --head is provided (e.g. GitButler workspace), use `gh pr list --head`
467
500
  // to find existing PR since `gh pr view` relies on the current git branch.
468
501
  const existing = yield* opts.head !== null
@@ -500,7 +533,7 @@ export const createPR = Effect.fn("pr.createPR")(function* (opts: {
500
533
  "pr",
501
534
  "create",
502
535
  "--base",
503
- opts.base,
536
+ baseBranch,
504
537
  "--title",
505
538
  opts.title,
506
539
  "--body",
@@ -806,31 +839,31 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
806
839
  watchArgs.push("--fail-fast");
807
840
  }
808
841
 
809
- const timeoutMs = timeoutSeconds * 1000;
810
- yield* gh.runGh(watchArgs).pipe(
842
+ // Cap the blocking wait; on timeout fall through to a snapshot instead of failing with no state.
843
+ const cappedSeconds = Math.min(timeoutSeconds, MAX_WATCH_SECONDS);
844
+ const watchOutcome = yield* gh.runGh(watchArgs).pipe(
811
845
  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
- ),
846
+ duration: cappedSeconds * 1000,
847
+ orElse: () => Effect.succeed(null),
823
848
  }),
824
849
  );
825
850
 
826
- return yield* fetchCheckResults(pr);
851
+ const results = yield* fetchCheckResults(pr);
852
+ if (watchOutcome === null && results.some((c) => c.bucket === "pending")) {
853
+ const pending = results.filter((c) => c.bucket === "pending").length;
854
+ yield* Console.warn(
855
+ `ℹ️ Watch capped at ${cappedSeconds}s; ${pending} check(s) still pending (snapshot returned). ` +
856
+ `Re-run to keep watching:\n ${buildChecksCommand(pr, true)}`,
857
+ );
858
+ }
859
+ return results;
827
860
  }
828
861
 
829
862
  const results = yield* fetchCheckResults(pr);
830
863
  if (results.some((c) => c.bucket === "pending")) {
831
864
  yield* Console.warn(
832
- `ℹ️ Some checks are still running. Prefer --watch to block until completion instead of polling:\n` +
833
- ` ${buildChecksCommand(pr, true)}`,
865
+ `ℹ️ Some checks are still running. Re-run to refresh each call returns the latest snapshot:\n` +
866
+ ` ${buildChecksCommand(pr, false)}`,
834
867
  );
835
868
  }
836
869
  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,
@@ -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;