@blogic-cz/agent-tools 0.14.62 → 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
@@ -178,6 +178,10 @@ bun run agent-tools/example-tool/index.ts ping
178
178
  // auto defaults to true:
179
179
  // darwin -> macos-scutil, linux -> linux-nmcli, win32 -> windows-rasdial
180
180
  name: "ExampleVPN",
181
+ // Reuse package-managed connections for 30 seconds after the last command. Set 0 for immediate cleanup.
182
+ idleDisconnectMs: 30000,
183
+ // Total window for stop plus disconnected-status confirmation.
184
+ disconnectTimeoutMs: 10000,
181
185
  // Optional: pass IPSec shared secret to macOS scutil from env without storing the value in config.
182
186
  secretEnvVar: "EXAMPLE_VPN_IPSEC_SHARED_SECRET",
183
187
  },
@@ -187,8 +191,7 @@ bun run agent-tools/example-tool/index.ts ping
187
191
  clusterId: "your-cluster-id",
188
192
  namespaces: { test: "your-ns-test", prod: "your-ns-prod" },
189
193
  prerequisites: [{ type: "vpn", key: "exampleVpn" }],
190
- // Prerequisites are currently decoded and validated as config metadata;
191
- // automatic VPN connect/disconnect execution is planned for a follow-up release.
194
+ // agent-tools starts disconnected VPNs, shares package-local leases, and disconnects only connections it owns.
192
195
  },
193
196
  },
194
197
  logs: {
@@ -262,13 +265,14 @@ Pod `exec` is limited to direct `redis-cli PING/INFO` and `ls` diagnostics. Gene
262
265
 
263
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.
264
267
 
265
- `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.
266
269
 
267
270
  Useful flows:
268
271
 
269
272
  ```bash
270
273
  bun gh-tool pr checks-failed --pr 123 --with-logs --format json # includes diagnosis
271
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
272
276
  bun gh-tool pr watch --prs 123,124 --format jsonl --timeout 600
273
277
  bun gh-tool pr reply-and-resolve --comment-id 456 --body "Done" # infers PR and thread
274
278
  # Optional --pr/--thread-id retain legacy flow and are validated before either mutation.
@@ -276,6 +280,12 @@ bun gh-tool pr reply-and-resolve --comment-id 456 --body "Done" # infers PR and
276
280
 
277
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.
278
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
+
279
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.
280
290
 
281
291
  ## Audit Logging
@@ -420,7 +430,7 @@ Secrets are **never** stored in the config file. The `db-tool` config references
420
430
  }
421
431
  ```
422
432
 
423
- Database VPN prerequisites can be set at the database profile or environment level. If an environment declares `vpn` or `prerequisites`, that environment config replaces the profile prerequisites; `prerequisites: []` explicitly disables inherited VPN setup. DB commands try the query directly first and only connect VPN prerequisites if direct access fails.
433
+ Database VPN prerequisites can be set at the database profile or environment level. If an environment declares `vpn` or `prerequisites`, that environment config replaces the profile prerequisites; `prerequisites: []` explicitly disables inherited VPN setup. DB commands try the query directly first and only connect VPN prerequisites if direct access fails. Package-managed VPNs remain reusable for `idleDisconnectMs` (default 30000) after the last lease; `0` restores immediate cleanup. Preconnected or `leave-running` connections are treated as external and never stopped automatically. If runtime state is corrupt, unknown, or contains legacy artifacts, first stop all agent-tools processes using the VPN, then remove that VPN state directory under `~/.agent-tools/runtime/vpn-prerequisites`.
424
434
 
425
435
  ```json5
426
436
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogic-cz/agent-tools",
3
- "version": "0.14.62",
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",
@@ -558,16 +558,25 @@
558
558
  "enum": ["leave-running", "stop-if-started"]
559
559
  },
560
560
  "connectTimeoutMs": {
561
- "type": "number"
561
+ "type": "number",
562
+ "minimum": 0,
563
+ "maximum": 2147483647,
564
+ "multipleOf": 1
562
565
  },
563
566
  "disconnectTimeoutMs": {
564
- "type": "number"
565
- },
566
- "cooldownMs": {
567
- "type": "number"
568
- },
569
- "leaseTtlMs": {
570
- "type": "number"
567
+ "type": "number",
568
+ "minimum": 0,
569
+ "maximum": 2147483647,
570
+ "multipleOf": 1,
571
+ "description": "Total bounded stop-and-confirm window in milliseconds."
572
+ },
573
+ "idleDisconnectMs": {
574
+ "type": "number",
575
+ "minimum": 0,
576
+ "maximum": 2147483647,
577
+ "multipleOf": 1,
578
+ "default": 30000,
579
+ "description": "Managed VPN reuse window after the last lease. Set to 0 for immediate cleanup."
571
580
  },
572
581
  "secretEnvVar": {
573
582
  "type": "string",
@@ -18,6 +18,20 @@ const CredentialGuardConfigSchema = Schema.Struct({
18
18
  });
19
19
 
20
20
  const CleanupPolicySchema = Schema.Literals(["leave-running", "stop-if-started"]);
21
+ const VPN_TIMER_FIELDS = ["connectTimeoutMs", "disconnectTimeoutMs", "idleDisconnectMs"] as const;
22
+ const MAX_TIMER_MS = 2_147_483_647;
23
+
24
+ function validateVpnTimer(key: string, field: (typeof VPN_TIMER_FIELDS)[number], value: unknown) {
25
+ if (
26
+ typeof value !== "number" ||
27
+ !Number.isFinite(value) ||
28
+ !Number.isInteger(value) ||
29
+ value < 0 ||
30
+ value > MAX_TIMER_MS
31
+ ) {
32
+ throw new Error(`VPN "${key}" ${field} must be an integer from 0 to ${MAX_TIMER_MS}.`);
33
+ }
34
+ }
21
35
 
22
36
  const VpnPrerequisiteSchema = Schema.Struct({
23
37
  type: Schema.Literal("vpn"),
@@ -56,8 +70,7 @@ const VpnConfigSchema = Schema.Struct({
56
70
  defaultCleanup: Schema.optionalKey(CleanupPolicySchema),
57
71
  connectTimeoutMs: Schema.optionalKey(Schema.Number),
58
72
  disconnectTimeoutMs: Schema.optionalKey(Schema.Number),
59
- cooldownMs: Schema.optionalKey(Schema.Number),
60
- leaseTtlMs: Schema.optionalKey(Schema.Number),
73
+ idleDisconnectMs: Schema.optionalKey(Schema.Number),
61
74
  secretEnvVar: Schema.optionalKey(Schema.String),
62
75
  drivers: Schema.optionalKey(
63
76
  Schema.Struct({
@@ -215,8 +228,29 @@ export function decodeConfig(
215
228
  const sanitized = stripUnknownTopLevelKeys(parsed);
216
229
 
217
230
  try {
218
- const decoded = Schema.decodeUnknownSync(AgentToolsConfigSchema)(sanitized);
219
- return decoded as AgentToolsConfig;
231
+ if (isRecord(sanitized) && isRecord(sanitized.vpns)) {
232
+ for (const [key, value] of Object.entries(sanitized.vpns)) {
233
+ if (!isRecord(value)) continue;
234
+ if ("cooldownMs" in value || "leaseTtlMs" in value) {
235
+ throw new Error(`VPN "${key}" uses removed cooldownMs or leaseTtlMs configuration.`);
236
+ }
237
+ for (const field of VPN_TIMER_FIELDS) {
238
+ if (value[field] !== undefined) validateVpnTimer(key, field, value[field]);
239
+ }
240
+ }
241
+ }
242
+ const decoded = Schema.decodeUnknownSync(AgentToolsConfigSchema)(sanitized) as AgentToolsConfig;
243
+ return decoded.vpns
244
+ ? {
245
+ ...decoded,
246
+ vpns: Object.fromEntries(
247
+ Object.entries(decoded.vpns).map(([key, vpn]) => [
248
+ key,
249
+ { idleDisconnectMs: 30_000, ...vpn },
250
+ ]),
251
+ ),
252
+ }
253
+ : decoded;
220
254
  } catch (error) {
221
255
  throw new Error(
222
256
  `Invalid agent-tools config at ${configPath}: ${
@@ -43,9 +43,10 @@ export type VpnConfig = {
43
43
  auto?: boolean;
44
44
  defaultCleanup?: CleanupPolicy;
45
45
  connectTimeoutMs?: number;
46
+ /** Total bounded stop-and-confirm window in milliseconds. */
46
47
  disconnectTimeoutMs?: number;
47
- cooldownMs?: number;
48
- leaseTtlMs?: number;
48
+ /** Managed VPN reuse window after the last lease. Defaults to 30000; 0 disconnects immediately. */
49
+ idleDisconnectMs?: number;
49
50
  /** Name of environment variable holding the VPN shared secret for supported drivers. */
50
51
  secretEnvVar?: string;
51
52
  drivers?: {
@@ -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,