@blogic-cz/agent-tools 0.15.1 → 0.15.2

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/README.md CHANGED
@@ -265,13 +265,14 @@ Pod `exec` is limited to direct `redis-cli PING/INFO` and `ls` diagnostics. Gene
265
265
 
266
266
  `pr view` adds `headSha` and `baseSha`; failed-check evidence adds the same SHA pair. Review summaries, inline comments, and threads add `commitSha` plus `feedbackOrigin`: `current_head` only for an exact `commitSha === headSha`, `pre_existing` for a different known SHA (not an obsolescence verdict), and `unknown` when either SHA is absent. Issue comments always use `commitSha: null` and `feedbackOrigin: unknown`. `review-triage` preserves existing fields and adds `inlineComments` plus per-kind `feedbackOriginCounts`; batch triage returns the same object per PR.
267
267
 
268
- `pr watch --prs 12,34 --until terminal --format jsonl` accepts at most 50 unique, digits-only PR numbers and emits only JSONL state transitions. Identity uses `repo/pr/headSha/runId/attempt/jobId`; `checkId` is nullable and reserved for actual check-run IDs. State/bucket revisions emit even when identity stays stable; `supersedes` appears only when identity changes. Open PRs with no checks become terminal only after three stable empty snapshots, allowing bounded GitHub eventual consistency. `pr checks`, batch checks, triage, and batch triage keep stderr silent with `--format json`; JSONL watch is also informationally silent. Failures still return structured nonzero errors on stderr.
268
+ `pr watch --prs 12,34 --until terminal --format jsonl` accepts at most 50 unique, digits-only PR numbers and emits only JSONL state transitions. Identity uses `repo/pr/headSha/runId/attempt/jobId`; `runId`, `attempt`, and `jobId` are omitted from an event rather than emitted as `null`, and `checkId` is no longer emitted. State/bucket revisions emit even when identity stays stable; `supersedes` appears only when identity changes. Open PRs with no checks become terminal only after three stable empty snapshots, allowing bounded GitHub eventual consistency, and carry `checksObserved: false` — a terminal snapshot with no observed check is never green evidence. `pr checks`, batch checks, triage, and batch triage keep stderr silent with `--format json`; JSONL watch is also informationally silent. Failures still return structured nonzero errors on stderr.
269
269
 
270
270
  Useful flows:
271
271
 
272
272
  ```bash
273
273
  bun gh-tool pr checks-failed --pr 123 --with-logs --format json # includes diagnosis
274
274
  bun gh-tool pr rerun-checks --pr 123 --failed-only --watch --timeout 600
275
+ bun gh-tool pr trigger-checks --pr 123 --workflow dotnet-pull-request.yml # only when zero checks reported
275
276
  bun gh-tool pr watch --prs 123,124 --format jsonl --timeout 600
276
277
  bun gh-tool pr reply-and-resolve --comment-id 456 --body "Done" # infers PR and thread
277
278
  # Optional --pr/--thread-id retain legacy flow and are validated before either mutation.
@@ -279,6 +280,12 @@ bun gh-tool pr reply-and-resolve --comment-id 456 --body "Done" # infers PR and
279
280
 
280
281
  Reruns preflight every target before mutation and fail closed with `evidence_unavailable` when attempt jobs or logs cannot be read. Failed jobs use one `gh run rerun RUN --failed` mutation per workflow run. Without `--watch`, output returns current attempt metadata immediately; with `--watch`, discovery and watching share one absolute `--timeout` deadline and report `discovery_timeout` or `watch_timeout` with latest attempt state. Repeated matching pre-test infrastructure failures return `escalation_required` without mutation. See [`skills/gh-tool/SKILL.md`](skills/gh-tool/SKILL.md) for operating guidance; this section is canonical for added output fields.
281
282
 
283
+ Zero reported checks is a state, not an error: `gh pr checks` exits nonzero on an empty result, and every command that reads checks maps that to `[]`. `pr trigger-checks` covers the case where GitHub dropped the `pull_request` event and no run exists at all — it dispatches the named `workflow_dispatch` workflow **on the PR head branch**, then compares the created run's `headSha` back to the PR head and reports `matchesPrHead`. A run dispatched on the wrong ref goes green for another branch and must never be read as PR evidence, so `workflow run` also returns the discovered `runId`/`headSha` instead of a bare `dispatched: true`.
284
+
285
+ `pr feedback` returns the whole inventory by default. Narrow it with `--only visible-open|needs-human-reply|current-head` (narrowed filters drop issue comments), drop bots with `--exclude-authors github-actions,dependabot`, and read `omitted` for what each filter removed. Base64 report payloads in bodies are replaced with `[base64 payload omitted]`; pass `--raw-bodies` to keep them. `review-triage --omit reviews,inlineComments` trims a repeated snapshot to the verdict and check state, and lists what it left out in `omittedSections`.
286
+
287
+ Call the wrappers through `bun run --silent <tool>` in repos that expose them as package scripts; without `--silent` every invocation echoes the resolved command line into the agent's context.
288
+
282
289
  `audit-tool` reads the same SQLite file the wrappers write to. By default that file lives at `~/.agent-tools/audit.sqlite`, and you can override both path and retention per repo with the global `audit` config section.
283
290
 
284
291
  ## Audit Logging
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogic-cz/agent-tools",
3
- "version": "0.15.1",
3
+ "version": "0.15.2",
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",
@@ -40,6 +40,7 @@ import {
40
40
  prChecksCommand,
41
41
  prChecksFailedCommand,
42
42
  prRerunChecksCommand,
43
+ prTriggerChecksCommand,
43
44
  prReplyAndResolveCommand,
44
45
  prReviewTriageBatchCommand,
45
46
  prReviewTriageCommand,
@@ -97,6 +98,7 @@ const prCommand = Command.make("pr", {}).pipe(
97
98
  prChecksFailedCommand,
98
99
  prWatchCommand,
99
100
  prRerunChecksCommand,
101
+ prTriggerChecksCommand,
100
102
  prReplyAndResolveCommand,
101
103
  prReviewTriageCommand,
102
104
  prReviewTriageBatchCommand,
@@ -174,7 +176,7 @@ WORKFLOW FOR AI AGENTS:
174
176
  2. Use 'pr discussion-summary' for overview (counts + latest discussion comment)
175
177
  3. Use 'pr threads' and 'pr issue-comments-latest --author <username> --body-contains "Review"' for review context
176
178
  4. Use 'pr submit-review', 'pr reply', 'pr comment' and 'pr resolve' to handle feedback
177
- 5. Use 'pr checks' to monitor CI status
179
+ 5. Use 'pr checks' to monitor CI status; 'pr trigger-checks --workflow <file.yml>' when zero checks were reported
178
180
  6. Use 'pr merge' to merge (dry-run by default)
179
181
  7. Use 'issue list' to list open/closed issues
180
182
  8. Use 'issue triage --issue N --verbosity full' to inspect one issue in one call
@@ -1,4 +1,4 @@
1
- import { Command, Flag } from "effect/unstable/cli";
1
+ import { Command, Flag, Param } from "effect/unstable/cli";
2
2
  import { Console, Effect, Option } from "effect";
3
3
 
4
4
  import type { CheckResult, PRStatusResult } from "#gh/types";
@@ -39,6 +39,7 @@ import {
39
39
  listPRs,
40
40
  mergePR,
41
41
  rerunChecks,
42
+ triggerChecks,
42
43
  watchPRs,
43
44
  viewPR,
44
45
  waitForMergeable,
@@ -182,6 +183,101 @@ export const fetchCurrentFeedback = (pr: number | null) =>
182
183
  command: "gh-tool pr feedback",
183
184
  }).pipe(Effect.map(({ value }) => value));
184
185
 
186
+ // Bot bodies carry base64 report payloads that no agent reads but every agent pays for.
187
+ const BASE64_PAYLOAD_RE = /data:[a-z0-9.+-]+\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]{40,}/gi;
188
+
189
+ export const trimNoisyBody = (body: string): string =>
190
+ body.replace(BASE64_PAYLOAD_RE, "[base64 payload omitted]");
191
+
192
+ export const FEEDBACK_FILTERS = [
193
+ "all",
194
+ "visible-open",
195
+ "needs-human-reply",
196
+ "current-head",
197
+ ] as const;
198
+
199
+ export type FeedbackFilter = (typeof FEEDBACK_FILTERS)[number];
200
+
201
+ type FeedbackInventory = {
202
+ readonly reviews: ReadonlyArray<{ author: string; body: string; feedbackOrigin: string }>;
203
+ readonly threads: ReadonlyArray<{
204
+ commentId: number;
205
+ body: string;
206
+ feedbackOrigin: string;
207
+ isVisibleOpen: boolean;
208
+ needsHumanReply: boolean;
209
+ }>;
210
+ readonly inlineComments: ReadonlyArray<{
211
+ id: number;
212
+ inReplyToId: number | null;
213
+ author: string;
214
+ body: string;
215
+ feedbackOrigin: string;
216
+ }>;
217
+ readonly issueComments: ReadonlyArray<{ author: string; body: string; feedbackOrigin: string }>;
218
+ };
219
+
220
+ export const filterFeedback = <T extends FeedbackInventory>(
221
+ feedback: T,
222
+ options: {
223
+ only: FeedbackFilter;
224
+ excludeAuthors: ReadonlyArray<string>;
225
+ rawBodies: boolean;
226
+ },
227
+ ) => {
228
+ const excluded = options.excludeAuthors
229
+ .map((author) => author.toLowerCase())
230
+ .filter((a) => a !== "");
231
+ const keepAuthor = (author: string) =>
232
+ !excluded.some((needle) => author.toLowerCase().includes(needle));
233
+
234
+ const threads = feedback.threads.filter((thread) =>
235
+ options.only === "visible-open"
236
+ ? thread.isVisibleOpen
237
+ : options.only === "needs-human-reply"
238
+ ? thread.needsHumanReply
239
+ : options.only === "current-head"
240
+ ? thread.feedbackOrigin === "current_head"
241
+ : true,
242
+ );
243
+ const keptThreadRoots = new Set(threads.map((thread) => thread.commentId));
244
+
245
+ const inlineComments = feedback.inlineComments
246
+ .filter((comment) =>
247
+ options.only === "all"
248
+ ? true
249
+ : options.only === "current-head"
250
+ ? comment.feedbackOrigin === "current_head"
251
+ : keptThreadRoots.has(comment.inReplyToId ?? comment.id),
252
+ )
253
+ .filter((comment) => keepAuthor(comment.author));
254
+
255
+ const reviews = feedback.reviews
256
+ .filter((review) => options.only !== "current-head" || review.feedbackOrigin === "current_head")
257
+ .filter((review) => keepAuthor(review.author));
258
+
259
+ const issueComments = (options.only === "all" ? feedback.issueComments : []).filter((comment) =>
260
+ keepAuthor(comment.author),
261
+ );
262
+
263
+ const body = <I extends { body: string }>(item: I): I =>
264
+ options.rawBodies ? item : { ...item, body: trimNoisyBody(item.body) };
265
+
266
+ return {
267
+ filter: options.only,
268
+ omitted: {
269
+ reviews: feedback.reviews.length - reviews.length,
270
+ threads: feedback.threads.length - threads.length,
271
+ inlineComments: feedback.inlineComments.length - inlineComments.length,
272
+ issueComments: feedback.issueComments.length - issueComments.length,
273
+ },
274
+ reviews: reviews.map(body),
275
+ threads: threads.map(body),
276
+ inlineComments: inlineComments.map(body),
277
+ issueComments: issueComments.map(body),
278
+ };
279
+ };
280
+
185
281
  const countFeedbackOrigins = (items: ReadonlyArray<{ feedbackOrigin: string }>) => ({
186
282
  current_head: items.filter((item) => item.feedbackOrigin === "current_head").length,
187
283
  pre_existing: items.filter((item) => item.feedbackOrigin === "pre_existing").length,
@@ -720,6 +816,39 @@ export const prRerunChecksCommand = Command.make(
720
816
  Command.withDescription("Rerun CI checks for a PR (GitHub Actions only, failed by default)"),
721
817
  );
722
818
 
819
+ export const prTriggerChecksCommand = Command.make(
820
+ "trigger-checks",
821
+ {
822
+ field: Param.variadic(
823
+ Param.string(Param.flagKind, "field").pipe(
824
+ Param.withAlias("f"),
825
+ Param.withDescription("Workflow input as key=value; may be repeated"),
826
+ ),
827
+ ),
828
+ format: formatOption,
829
+ pr: Flag.integer("pr").pipe(
830
+ Flag.withDescription("PR number (default: current branch PR)"),
831
+ Flag.optional,
832
+ ),
833
+ repo: repoOption,
834
+ workflow: Flag.string("workflow").pipe(
835
+ Flag.withDescription("workflow_dispatch workflow file to run against the PR head branch"),
836
+ ),
837
+ },
838
+ ({ field, format, pr, repo, workflow }) =>
839
+ withRepo(
840
+ repo,
841
+ Effect.gen(function* () {
842
+ const result = yield* triggerChecks(Option.getOrNull(pr), workflow, field);
843
+ yield* logFormatted(result, format);
844
+ }),
845
+ ),
846
+ ).pipe(
847
+ Command.withDescription(
848
+ "Dispatch a workflow against the PR head branch when no checks were reported, and verify the created run matches the PR head",
849
+ ),
850
+ );
851
+
723
852
  export const prThreadsCommand = Command.make(
724
853
  "threads",
725
854
  {
@@ -851,24 +980,50 @@ export const prReviewsCommand = Command.make(
851
980
  export const prFeedbackCommand = Command.make(
852
981
  "feedback",
853
982
  {
983
+ excludeAuthors: Flag.string("exclude-authors").pipe(
984
+ Flag.withDescription(
985
+ "Comma-separated author substrings to drop (e.g. github-actions,dependabot)",
986
+ ),
987
+ Flag.optional,
988
+ ),
854
989
  format: formatOption,
990
+ only: Flag.choice("only", FEEDBACK_FILTERS).pipe(
991
+ Flag.withDefault("all" as FeedbackFilter),
992
+ Flag.withDescription(
993
+ "all (default) | visible-open | needs-human-reply | current-head; narrowed filters drop issue comments",
994
+ ),
995
+ ),
855
996
  pr: Flag.integer("pr").pipe(
856
997
  Flag.withDescription("PR number (default: current branch PR)"),
857
998
  Flag.optional,
858
999
  ),
1000
+ rawBodies: Flag.boolean("raw-bodies").pipe(
1001
+ Flag.withDefault(false),
1002
+ Flag.withDescription("Keep base64 report payloads in bodies instead of omitting them"),
1003
+ ),
859
1004
  repo: repoOption,
860
1005
  },
861
- ({ format, pr, repo }) =>
1006
+ ({ excludeAuthors, format, only, pr, rawBodies, repo }) =>
862
1007
  withRepo(
863
1008
  repo,
864
1009
  Effect.gen(function* () {
865
1010
  const feedback = yield* fetchCurrentFeedback(Option.getOrNull(pr));
866
- yield* logFormatted(feedback, format);
1011
+ yield* logFormatted(
1012
+ filterFeedback(feedback, {
1013
+ only,
1014
+ excludeAuthors:
1015
+ Option.getOrNull(excludeAuthors)
1016
+ ?.split(",")
1017
+ .map((a) => a.trim()) ?? [],
1018
+ rawBodies,
1019
+ }),
1020
+ format,
1021
+ );
867
1022
  }),
868
1023
  ),
869
1024
  ).pipe(
870
1025
  Command.withDescription(
871
- "Full review-response inventory in one call: submitted reviews + threads (with state) + inline comments + issue comments",
1026
+ "Full review-response inventory in one call: submitted reviews + threads (with state) + inline comments + issue comments. Narrow with --only, drop bots with --exclude-authors; `omitted` reports what each filter removed",
872
1027
  ),
873
1028
  );
874
1029
 
@@ -1110,28 +1265,60 @@ export const prSubmitReviewCommand = Command.make(
1110
1265
  ),
1111
1266
  );
1112
1267
 
1268
+ export const OMITTABLE_TRIAGE_SECTIONS = [
1269
+ "reviews",
1270
+ "inlineComments",
1271
+ "unresolvedThreads",
1272
+ ] as const;
1273
+
1274
+ export type OmittableTriageSection = (typeof OMITTABLE_TRIAGE_SECTIONS)[number];
1275
+
1276
+ export const omitTriageSections = <T extends Record<string, unknown>>(
1277
+ triage: T,
1278
+ omit: ReadonlyArray<string>,
1279
+ ) => {
1280
+ const requested = omit.map((section) => section.trim()).filter((section) => section !== "");
1281
+ const applied = requested.filter((section): section is OmittableTriageSection =>
1282
+ (OMITTABLE_TRIAGE_SECTIONS as ReadonlyArray<string>).includes(section),
1283
+ );
1284
+ if (applied.length === 0) return triage;
1285
+ const kept = Object.fromEntries(
1286
+ Object.entries(triage).filter(([key]) => !applied.includes(key as OmittableTriageSection)),
1287
+ ) as Partial<T>;
1288
+ return { ...kept, omittedSections: applied };
1289
+ };
1290
+
1113
1291
  export const prReviewTriageCommand = Command.make(
1114
1292
  "review-triage",
1115
1293
  {
1116
1294
  format: formatOption,
1295
+ omit: Flag.string("omit").pipe(
1296
+ Flag.withDescription(
1297
+ `Comma-separated sections to leave out (${OMITTABLE_TRIAGE_SECTIONS.join(", ")}) — use for cheap repeated snapshots; \`pr feedback\` owns the full inventory`,
1298
+ ),
1299
+ Flag.optional,
1300
+ ),
1117
1301
  pr: Flag.integer("pr").pipe(
1118
1302
  Flag.withDescription("PR number (default: current branch PR)"),
1119
1303
  Flag.optional,
1120
1304
  ),
1121
1305
  repo: repoOption,
1122
1306
  },
1123
- ({ format, pr, repo }) =>
1307
+ ({ format, omit, pr, repo }) =>
1124
1308
  withRepo(
1125
1309
  repo,
1126
1310
  Effect.gen(function* () {
1127
1311
  const prNumber = Option.getOrNull(pr);
1128
1312
  const result = yield* fetchReviewTriage(prNumber, format);
1129
- yield* logFormatted(result, format);
1313
+ yield* logFormatted(
1314
+ omitTriageSections(result, Option.getOrNull(omit)?.split(",") ?? []),
1315
+ format,
1316
+ );
1130
1317
  }),
1131
1318
  ),
1132
1319
  ).pipe(
1133
1320
  Command.withDescription(
1134
- "Composite: PR info + unresolved threads + visible-open threads + discussion summary + checks status in one call",
1321
+ "Composite: PR info + merge-readiness verdict + unresolved threads + visible-open threads + discussion summary + checks status in one call",
1135
1322
  ),
1136
1323
  );
1137
1324
 
@@ -22,7 +22,15 @@ import { GitHubService } from "#gh/service";
22
22
 
23
23
  import type { ButStatusJson, PRViewJsonResult } from "./helpers";
24
24
  import { runLocalCommand } from "./helpers";
25
- import { diagnoseLogEntries, fetchJobLogs, formatLogEntries, parseRawJobLogs } from "#gh/workflow";
25
+ import {
26
+ diagnoseLogEntries,
27
+ discoverDispatchedRun,
28
+ dispatchWorkflow,
29
+ fetchJobLogs,
30
+ formatLogEntries,
31
+ listDispatchedRuns,
32
+ parseRawJobLogs,
33
+ } from "#gh/workflow";
26
34
 
27
35
  const CHECK_JSON_FIELDS = "name,state,bucket,link";
28
36
  const LONG_LIVED_BRANCHES = new Set(["main", "master", "develop", "staging", "production"]);
@@ -187,6 +195,10 @@ const fetchWorkflowRunFailureContext = Effect.fn("pr.fetchWorkflowRunFailureCont
187
195
  return context;
188
196
  });
189
197
 
198
+ // `gh pr checks` exits 1 on an *empty* result ("no checks reported on the 'x' branch"). Zero checks
199
+ // is an ordinary state, so map it to [] and keep the zero-check paths downstream reachable.
200
+ export const NO_CHECKS_REPORTED_RE = /no checks reported/i;
201
+
190
202
  const fetchCheckResults = Effect.fn("pr.fetchCheckResults")(function* (pr: number | null) {
191
203
  const gh = yield* GitHubService;
192
204
 
@@ -195,7 +207,15 @@ const fetchCheckResults = Effect.fn("pr.fetchCheckResults")(function* (pr: numbe
195
207
  args.push(String(pr));
196
208
  }
197
209
 
198
- return yield* gh.runGhJson<CheckResult[]>([...args, "--json", CHECK_JSON_FIELDS]);
210
+ return yield* gh
211
+ .runGhJson<CheckResult[]>([...args, "--json", CHECK_JSON_FIELDS])
212
+ .pipe(
213
+ Effect.catchTag("GitHubCommandError", (error) =>
214
+ NO_CHECKS_REPORTED_RE.test(error.stderr) || NO_CHECKS_REPORTED_RE.test(error.message)
215
+ ? Effect.succeed<CheckResult[]>([])
216
+ : Effect.fail(error),
217
+ ),
218
+ );
199
219
  });
200
220
 
201
221
  const buildFailedChecksReport = Effect.fn("pr.buildFailedChecksReport")(function* (
@@ -1049,6 +1069,69 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
1049
1069
  return results;
1050
1070
  });
1051
1071
 
1072
+ // Dispatching on the wrong ref produces a green run for another branch that reads as PR evidence.
1073
+ // The ref is therefore taken from the PR head, and the created run's headSha is compared back to it.
1074
+ export const triggerChecks = Effect.fn("pr.triggerChecks")(function* (
1075
+ pr: number | null,
1076
+ workflow: string,
1077
+ fields: ReadonlyArray<string>,
1078
+ ) {
1079
+ const gh = yield* GitHubService;
1080
+ const info = yield* viewPR(pr);
1081
+ const repoInfo = yield* gh.getRepoInfo();
1082
+ const repo = `${repoInfo.owner}/${repoInfo.name}`;
1083
+
1084
+ const existing = yield* fetchCheckResults(info.number);
1085
+ if (existing.length > 0) {
1086
+ return {
1087
+ triggered: false as const,
1088
+ pr: info.number,
1089
+ prHeadSha: info.headSha,
1090
+ message: `${existing.length} check(s) already reported for ${info.headSha ?? "head"}; nothing to trigger.`,
1091
+ nextCommand: `agent-tools-gh pr watch --prs ${info.number} --until terminal --format jsonl`,
1092
+ };
1093
+ }
1094
+
1095
+ const before = yield* listDispatchedRuns({ workflow, ref: info.headRefName, repo });
1096
+ yield* dispatchWorkflow({ workflow, ref: info.headRefName, fields, repo });
1097
+ const created = yield* discoverDispatchedRun({
1098
+ workflow,
1099
+ ref: info.headRefName,
1100
+ repo,
1101
+ knownRunIds: new Set(before.map((run) => run.databaseId)),
1102
+ });
1103
+
1104
+ const matchesPrHead = created !== null && created.headSha === info.headSha;
1105
+ if (created !== null && !matchesPrHead) {
1106
+ yield* Console.warn(
1107
+ `⚠️ Dispatched run ${created.databaseId} is on ${created.headSha}, not PR head ${info.headSha ?? "unknown"}. ` +
1108
+ `Its result is NOT evidence for PR #${info.number}.`,
1109
+ );
1110
+ }
1111
+
1112
+ return {
1113
+ triggered: true as const,
1114
+ pr: info.number,
1115
+ workflow,
1116
+ ref: info.headRefName,
1117
+ runId: created?.databaseId ?? null,
1118
+ runHeadSha: created?.headSha ?? null,
1119
+ prHeadSha: info.headSha,
1120
+ matchesPrHead,
1121
+ url: created?.url ?? null,
1122
+ message:
1123
+ created === null
1124
+ ? "Dispatched, but no matching run appeared yet. List runs to find it before treating anything as evidence."
1125
+ : matchesPrHead
1126
+ ? `Run ${created.databaseId} is running against PR head ${info.headSha ?? "unknown"}.`
1127
+ : `Run ${created.databaseId} is on ${created.headSha}, not PR head ${info.headSha ?? "unknown"}; not evidence for this PR.`,
1128
+ nextCommand:
1129
+ created === null
1130
+ ? `agent-tools-gh workflow list --workflow ${workflow} --branch ${info.headRefName}`
1131
+ : `agent-tools-gh workflow watch --run ${created.databaseId}`,
1132
+ };
1133
+ });
1134
+
1052
1135
  export const collectWithStableState = <S, A, E1, R1, E2, R2>(
1053
1136
  initial: S,
1054
1137
  collect: (state: S) => Effect.Effect<A, E1, R1>,
@@ -1284,10 +1367,13 @@ export const watchPRs = Effect.fn("pr.watchPRs")(function* (
1284
1367
  repo: `${repo.owner}/${repo.name}`,
1285
1368
  pr: number,
1286
1369
  headSha: state.head,
1287
- runId: run?.databaseId ?? null,
1288
- attempt: run?.attempt ?? null,
1289
- jobId,
1290
- checkId: null,
1370
+ ...(run?.databaseId === undefined || run.databaseId === null
1371
+ ? {}
1372
+ : { runId: run.databaseId }),
1373
+ ...(run?.attempt === undefined || run.attempt === null
1374
+ ? {}
1375
+ : { attempt: run.attempt }),
1376
+ ...(jobId === null ? {} : { jobId }),
1291
1377
  name: check.name,
1292
1378
  state: check.state,
1293
1379
  bucket: check.bucket,
@@ -21,6 +21,7 @@ export {
21
21
  prStatusCommand,
22
22
  prSubmitReviewCommand,
23
23
  prThreadsCommand,
24
+ prTriggerChecksCommand,
24
25
  prWaitMergeableCommand,
25
26
  prReviewTriageCommand,
26
27
  prReviewTriageBatchCommand,
@@ -14,6 +14,41 @@ const NETWORK_ERROR_RE =
14
14
  const AUTH_401_RE = /HTTP 401|Bad credentials/i;
15
15
  const MAX_GH_RETRIES = 2;
16
16
 
17
+ // A bare `gh` stderr tells an agent what broke but never what to do next. `hint`/`nextCommand`
18
+ // already exist on GitHubCommandError; these fill them for the failures agents actually hit.
19
+ const KNOWN_STDERR_HINTS: ReadonlyArray<{
20
+ readonly re: RegExp;
21
+ readonly hint: string;
22
+ readonly nextCommand?: string;
23
+ }> = [
24
+ {
25
+ re: /no checks reported/i,
26
+ hint: "The head commit carries no check runs yet. Wait for CI to register, or trigger the workflow when the push event was dropped.",
27
+ nextCommand: "agent-tools-gh pr trigger-checks --pr <number> --workflow <file.yml>",
28
+ },
29
+ {
30
+ re: /no commits between/i,
31
+ hint: "Head and base point at the same commit. Push a commit before opening or updating the PR.",
32
+ },
33
+ {
34
+ re: /already exists/i,
35
+ hint: "The resource already exists. Fetch the existing one and update it instead of creating another.",
36
+ },
37
+ {
38
+ re: /pending review/i,
39
+ hint: "A pending (unsubmitted) review blocks this mutation. Inspect its contents and submit or discard it before retrying.",
40
+ nextCommand: "agent-tools-gh pr reviews --pr <number>",
41
+ },
42
+ {
43
+ re: /workflow does not have 'workflow_dispatch'/i,
44
+ hint: "This workflow cannot be dispatched manually. Re-run an existing run instead.",
45
+ nextCommand: "agent-tools-gh workflow rerun --run <run-id>",
46
+ },
47
+ ];
48
+
49
+ const resolveStderrHint = (stderr: string) =>
50
+ KNOWN_STDERR_HINTS.find((entry) => entry.re.test(stderr));
51
+
17
52
  // Only retry verbs that are unambiguously idempotent reads — never replay a mutation on a timeout.
18
53
  const READ_VERBS = new Set(["view", "list", "checks", "status", "diff"]);
19
54
  const MUTATION_TOKENS =
@@ -204,11 +239,14 @@ export class GitHubService extends Context.Service<
204
239
  });
205
240
  }
206
241
 
242
+ const known = resolveStderrHint(result.stderr);
207
243
  return yield* new GitHubCommandError({
208
244
  message: result.stderr,
209
245
  command: `gh ${args.join(" ")}`,
210
246
  exitCode: result.exitCode,
211
247
  stderr: result.stderr,
248
+ ...(known === undefined ? {} : { hint: known.hint }),
249
+ ...(known?.nextCommand === undefined ? {} : { nextCommand: known.nextCommand }),
212
250
  });
213
251
  }
214
252
 
@@ -1,5 +1,5 @@
1
1
  import { Command, Flag, Param } from "effect/unstable/cli";
2
- import { Console, Effect, Option } from "effect";
2
+ import { Console, Duration, Effect, Option } from "effect";
3
3
 
4
4
  import { formatOption, logFormatted } from "#shared";
5
5
  import { CI_CHECK_WATCH_TIMEOUT_MS } from "#gh/config";
@@ -241,6 +241,65 @@ export const dispatchWorkflow = Effect.fn("workflow.dispatchWorkflow")(function*
241
241
  };
242
242
  });
243
243
 
244
+ export type DispatchedRun = {
245
+ databaseId: number;
246
+ headSha: string;
247
+ status: string;
248
+ conclusion: string | null;
249
+ url: string;
250
+ };
251
+
252
+ const DISPATCH_DISCOVERY_ATTEMPTS = 8;
253
+ const DISPATCH_DISCOVERY_INTERVAL_MS = 2000;
254
+
255
+ export const listDispatchedRuns = Effect.fn("workflow.listDispatchedRuns")(function* (opts: {
256
+ workflow: string;
257
+ ref: string;
258
+ repo: string | null;
259
+ }) {
260
+ const gh = yield* GitHubService;
261
+ const args = [
262
+ "run",
263
+ "list",
264
+ "--json",
265
+ "databaseId,headSha,status,conclusion,url",
266
+ "--limit",
267
+ "20",
268
+ "--workflow",
269
+ opts.workflow,
270
+ "--branch",
271
+ opts.ref,
272
+ "--event",
273
+ "workflow_dispatch",
274
+ ];
275
+
276
+ if (opts.repo !== null) {
277
+ args.push("--repo", opts.repo);
278
+ }
279
+
280
+ return yield* gh
281
+ .runGhJson<DispatchedRun[]>(args)
282
+ .pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed<DispatchedRun[]>([])));
283
+ });
284
+
285
+ // `gh workflow run` and the REST dispatch endpoint both return an empty body — there is no
286
+ // dispatch-to-run mapping. Polling the run list for an id absent before the dispatch is the only
287
+ // way to name the run we just created, and an unnamed run cannot be watched or verified.
288
+ export const discoverDispatchedRun = Effect.fn("workflow.discoverDispatchedRun")(function* (opts: {
289
+ workflow: string;
290
+ ref: string;
291
+ repo: string | null;
292
+ knownRunIds: ReadonlySet<number>;
293
+ }) {
294
+ for (let attempt = 0; attempt < DISPATCH_DISCOVERY_ATTEMPTS; attempt += 1) {
295
+ const runs = yield* listDispatchedRuns(opts);
296
+ const created = runs.find((run) => !opts.knownRunIds.has(run.databaseId));
297
+ if (created !== undefined) return created;
298
+ yield* Effect.sleep(Duration.millis(DISPATCH_DISCOVERY_INTERVAL_MS));
299
+ }
300
+ return null;
301
+ });
302
+
244
303
  // `gh run watch` has no native timeout (observed hanging 36 min). Block for the caller's --timeout,
245
304
  // then fall back to a one-shot snapshot so a timeout never returns nothing.
246
305
  const DEFAULT_WATCH_RUN_TIMEOUT_SECONDS = CI_CHECK_WATCH_TIMEOUT_MS / 1000;
@@ -781,13 +840,32 @@ export const workflowRunCommand = Command.make(
781
840
  ({ field, format, ref, repo, workflow }) =>
782
841
  Effect.gen(function* () {
783
842
  const resolvedRepo = yield* resolveRepoArg(repo);
843
+ const before = yield* listDispatchedRuns({ workflow, ref, repo: resolvedRepo });
784
844
  const result = yield* dispatchWorkflow({
785
845
  workflow,
786
846
  ref,
787
847
  fields: field,
788
848
  repo: resolvedRepo,
789
849
  });
790
- yield* logFormatted(result, format);
850
+ const created = yield* discoverDispatchedRun({
851
+ workflow,
852
+ ref,
853
+ repo: resolvedRepo,
854
+ knownRunIds: new Set(before.map((run) => run.databaseId)),
855
+ });
856
+ yield* logFormatted(
857
+ {
858
+ ...result,
859
+ runId: created?.databaseId ?? null,
860
+ headSha: created?.headSha ?? null,
861
+ url: created?.url ?? null,
862
+ nextCommand:
863
+ created === null
864
+ ? `agent-tools-gh workflow list --workflow ${workflow} --branch ${ref}`
865
+ : `agent-tools-gh workflow watch --run ${created.databaseId}`,
866
+ },
867
+ format,
868
+ );
791
869
  }),
792
870
  ).pipe(Command.withDescription("Dispatch a workflow_dispatch workflow run"));
793
871