@nanobpm/nano-workforce 0.110.0 → 0.111.0

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/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [0.111.0](https://github.com/nanobpm/nano-workforce/compare/v0.110.0...v0.111.0) (2026-08-20)
2
+
3
+
4
+ ### Features
5
+
6
+ * **readiness:** add pr/merge-state ReadinessProbe kind (ADR 0005 S2) ([#385](https://github.com/nanobpm/nano-workforce/issues/385)) ([860e031](https://github.com/nanobpm/nano-workforce/commit/860e03134b428c73426621acb14ccd9a3ab94149)), closes [#258](https://github.com/nanobpm/nano-workforce/issues/258) [owner/repo#N](https://github.com/owner/repo/issues/N) [#377](https://github.com/nanobpm/nano-workforce/issues/377) [owner/repo#N](https://github.com/owner/repo/issues/N)
7
+
1
8
  # [0.110.0](https://github.com/nanobpm/nano-workforce/compare/v0.109.0...v0.110.0) (2026-08-20)
2
9
 
3
10
 
package/app/github.ts CHANGED
@@ -597,6 +597,28 @@ export function failingCheckNames(rollup: RollupEntry[]): string[] {
597
597
  return names;
598
598
  }
599
599
 
600
+ /** Names of checks that are still in flight — queued or in progress, i.e. NOT yet complete and not a
601
+ * hard failure. Covers the CheckRun shape (`status` QUEUED/IN_PROGRESS/PENDING/WAITING/… anything but
602
+ * COMPLETED) and the legacy StatusContext shape (`state` PENDING/EXPECTED). Derived over the newest
603
+ * run per check (`latestRunPerCheck`) like {@link failingCheckNames}, so a superseded run doesn't
604
+ * linger as pending. A `checks-green` gate MUST count these so it never reports green while a run has
605
+ * not yet concluded (a pending run has no failing conclusion, so it would otherwise slip through). */
606
+ export function pendingCheckNames(rollup: RollupEntry[]): string[] {
607
+ const names: string[] = [];
608
+ for (const c of latestRunPerCheck(rollup)) {
609
+ const status = (c.status || "").toUpperCase();
610
+ if (status !== "") {
611
+ // CheckRun: anything other than COMPLETED is still running/queued.
612
+ if (status !== "COMPLETED") names.push(checkKey(c));
613
+ } else {
614
+ // Legacy StatusContext: PENDING/EXPECTED are not-yet-concluded.
615
+ const state = (c.state || "").toUpperCase();
616
+ if (state === "PENDING" || state === "EXPECTED") names.push(checkKey(c));
617
+ }
618
+ }
619
+ return names;
620
+ }
621
+
600
622
  /** Names of every head check present, regardless of state. Covers both the CheckRun shape
601
623
  * (`name`/`workflowName`) and the legacy StatusContext shape (`context`). Used to test whether a
602
624
  * repo's *required* checks are present on the head — so an unrelated always-on check (e.g.
@@ -21,17 +21,22 @@ import {
21
21
  matchGithubCheck,
22
22
  matchHttp,
23
23
  matchNpm,
24
+ matchPr,
24
25
  msToIsoDuration,
25
26
  newestPublishedVersion,
26
27
  nextDelay,
27
28
  normalizePoll,
28
29
  parseProbe,
30
+ parsePrTarget,
31
+ parsePrView,
29
32
  parseReleases,
30
33
  parseReleasesTarget,
31
34
  parseRepoRef,
32
35
  probeBudgetMs,
33
36
  probeOnce,
34
37
  type ProbeExec,
38
+ type PrObservation,
39
+ prViewCommand,
35
40
  readinessTimeout,
36
41
  readinessTimeoutMs,
37
42
  redactString,
@@ -375,6 +380,161 @@ test("probeOnce github-check: a failed gh api call is not-ready (never throws)",
375
380
  assert(!res.ready);
376
381
  });
377
382
 
383
+ // ── parseProbe: pr kind (ADR 0005 §2 — owner/repo#N target + validated prState) ──────────────────
384
+ test("parseProbe: accepts an owner/repo#N pr probe and defaults onTimeout to escalate (timeout → escalate)", () => {
385
+ const p = parseProbe({ kind: "pr", target: "nanobpm/nano-workforce#377" });
386
+ assertEquals(p.kind, "pr");
387
+ assertEquals(p.onTimeout, "escalate");
388
+ });
389
+
390
+ test("parseProbe: a pr probe whose target carries no numeric PR id throws (never resolvable)", () => {
391
+ assertThrows(() => parseProbe({ kind: "pr", target: "nanobpm/nano-workforce" }), Error, "owner/repo#<number>");
392
+ });
393
+
394
+ test("parseProbe: a pr probe with an unknown match.prState throws (mistyped state fails loudly)", () => {
395
+ assertThrows(
396
+ () => parseProbe({ kind: "pr", target: "o/r#1", match: { prState: "landed" } }),
397
+ Error,
398
+ "invalid match.prState",
399
+ );
400
+ });
401
+
402
+ test("parseProbe: a valid pr probe round-trips its prState", () => {
403
+ const p = parseProbe({ kind: "pr", target: "o/r#12", match: { prState: "mergeable" } });
404
+ assertEquals(p.match?.prState, "mergeable");
405
+ });
406
+
407
+ // ── matchPr (pure — operates on an already-fetched PR observation) ────────────────────────────────
408
+ function prObs(over: Partial<PrObservation> = {}): PrObservation {
409
+ return {
410
+ merged: false,
411
+ state: "open",
412
+ mergeStateStatus: "UNKNOWN",
413
+ failingChecks: 0,
414
+ failingCheckNames: [],
415
+ totalChecks: 0,
416
+ presentCheckNames: [],
417
+ isDraft: false,
418
+ headRefOid: "abc123",
419
+ mergedSha: null,
420
+ pendingChecks: 0,
421
+ ...over,
422
+ };
423
+ }
424
+
425
+ test("matchPr: prState 'ready' is the draft→ready transition (a non-draft PR is ready)", () => {
426
+ assert(!matchPr({ prState: "ready" }, prObs({ isDraft: true })).ready);
427
+ assert(matchPr({ prState: "ready" }, prObs({ isDraft: false })).ready);
428
+ });
429
+
430
+ test("matchPr: prState 'merged' waits for the merge and binds mergedSha (mirrors resolvedArtifact)", () => {
431
+ assert(!matchPr({ prState: "merged" }, prObs({ merged: false })).ready);
432
+ const res = matchPr({ prState: "merged" }, prObs({ merged: true, state: "merged", mergedSha: "deadbeef" }));
433
+ assert(res.ready);
434
+ assertEquals(res.bind?.mergedSha, "deadbeef");
435
+ });
436
+
437
+ test("matchPr: 'merged' is the default when no prState is declared", () => {
438
+ assert(!matchPr(undefined, prObs({ merged: false })).ready);
439
+ assert(matchPr(undefined, prObs({ merged: true, state: "merged" })).ready);
440
+ });
441
+
442
+ test("matchPr: prState 'mergeable' reuses classifyMergeability (CLEAN is ready, BLOCKED is not)", () => {
443
+ assert(matchPr({ prState: "mergeable" }, prObs({ mergeStateStatus: "CLEAN" })).ready);
444
+ assert(!matchPr({ prState: "mergeable" }, prObs({ mergeStateStatus: "BLOCKED", failingChecks: 1 })).ready);
445
+ });
446
+
447
+ test("matchPr: prState 'checks-green' needs a present, non-failing, non-pending head run", () => {
448
+ assert(matchPr({ prState: "checks-green" }, prObs({ totalChecks: 2, failingChecks: 0 })).ready);
449
+ assert(!matchPr({ prState: "checks-green" }, prObs({ totalChecks: 2, failingChecks: 1 })).ready);
450
+ assert(!matchPr({ prState: "checks-green" }, prObs({ totalChecks: 0, failingChecks: 0 })).ready);
451
+ // A run still queued/in-progress (no failing conclusion yet) must NOT read as green.
452
+ assert(!matchPr({ prState: "checks-green" }, prObs({ totalChecks: 2, failingChecks: 0, pendingChecks: 1 })).ready);
453
+ // token mode (checks unenumerable, totalChecks < 0) stays conservative — never falsely green.
454
+ assert(!matchPr({ prState: "checks-green" }, prObs({ totalChecks: -1, failingChecks: -1 })).ready);
455
+ });
456
+
457
+ test("matchPr: a not-yet-satisfied state is not-ready — the bounded gate keeps waiting → timeout escalates", () => {
458
+ // Every un-reached state resolves to ready:false, which is exactly what the engine timer arm bounds
459
+ // (onTimeout defaults to 'escalate'): a PR that never lands is never falsely resolved.
460
+ assert(!matchPr({ prState: "merged" }, prObs({ merged: false })).ready);
461
+ assert(!matchPr({ prState: "ready" }, prObs({ isDraft: true })).ready);
462
+ assert(!matchPr({ prState: "checks-green" }, prObs({ totalChecks: 1, failingChecks: 1 })).ready);
463
+ });
464
+
465
+ // ── parsePrView + probeOnce pr dispatch (injected exec — no I/O) ─────────────────────────────────
466
+ test("parsePrView: reduces a gh pr view payload and collapses the check rollup", () => {
467
+ const obs = parsePrView({
468
+ state: "OPEN",
469
+ mergeStateStatus: "clean",
470
+ isDraft: false,
471
+ headRefOid: "sha1",
472
+ statusCheckRollup: [
473
+ { name: "build", status: "COMPLETED", conclusion: "SUCCESS" },
474
+ { name: "lint", status: "COMPLETED", conclusion: "FAILURE" },
475
+ ],
476
+ });
477
+ assertEquals(obs.merged, false);
478
+ assertEquals(obs.mergeStateStatus, "CLEAN");
479
+ assertEquals(obs.totalChecks, 2);
480
+ assertEquals(obs.failingCheckNames, ["lint"]);
481
+ });
482
+
483
+ test("parsePrView: an in-flight run is counted as pending (so checks-green stays not-green)", () => {
484
+ const obs = parsePrView({
485
+ state: "OPEN",
486
+ statusCheckRollup: [
487
+ { name: "build", status: "COMPLETED", conclusion: "SUCCESS" },
488
+ { name: "e2e", status: "IN_PROGRESS" },
489
+ ],
490
+ });
491
+ assertEquals(obs.failingChecks, 0);
492
+ assertEquals(obs.pendingChecks, 1);
493
+ assert(!matchPr({ prState: "checks-green" }, obs).ready);
494
+ });
495
+
496
+ test("parsePrView: a merged PR carries its merge commit oid", () => {
497
+ const obs = parsePrView({ state: "MERGED", mergedAt: "2026-08-20T00:00:00Z", mergeCommit: { oid: "cafe" } });
498
+ assertEquals(obs.merged, true);
499
+ assertEquals(obs.state, "merged");
500
+ assertEquals(obs.mergedSha, "cafe");
501
+ });
502
+
503
+ test("parsePrView: a garbled payload degrades to an all-open, no-checks observation (never throws)", () => {
504
+ const obs = parsePrView(null);
505
+ assertEquals(obs.merged, false);
506
+ assertEquals(obs.totalChecks, 0);
507
+ });
508
+
509
+ test("probeOnce pr: builds a quoted `gh pr view` command and matches merged, binding mergedSha", async () => {
510
+ const cap: { cmd?: string } = {};
511
+ const exec = stubExec({
512
+ command: { code: 0, stdout: JSON.stringify({ state: "MERGED", mergeCommit: { oid: "abc" } }), stderr: "" },
513
+ capture: cap,
514
+ });
515
+ const res = await probeOnce(parseProbe({ kind: "pr", target: "nanobpm/nano-workforce#377" }), exec, {});
516
+ assert(res.ready);
517
+ assertEquals(res.bind?.mergedSha, "abc");
518
+ assertStringIncludes(cap.cmd ?? "", "gh pr view '377' --repo 'nanobpm/nano-workforce'");
519
+ });
520
+
521
+ test("probeOnce pr: a failed gh pr view call is not-ready (never throws)", async () => {
522
+ const exec = stubExec({ command: { code: 1, stdout: "", stderr: "no pr" } });
523
+ const res = await probeOnce(parseProbe({ kind: "pr", target: "o/r#1" }), exec, {});
524
+ assert(!res.ready);
525
+ });
526
+
527
+ test("parsePrTarget: parses owner/repo#N, rejects @N and a bare repo", () => {
528
+ assertEquals(parsePrTarget("o/r#12"), { repo: "o/r", number: "12" });
529
+ // `@N` is deliberately NOT a PR handle — it's the repo-ref syntax, so it must not parse as a PR.
530
+ assertEquals(parsePrTarget("o/r@34"), null);
531
+ assertEquals(parsePrTarget("o/r"), null);
532
+ });
533
+
534
+ test("prViewCommand: single-quote-escapes its args", () => {
535
+ assertStringIncludes(prViewCommand("o/r", "9"), "gh pr view '9' --repo 'o/r'");
536
+ });
537
+
378
538
  // ── backoff + poll normalisation ──────────────────────────────────────────────────────────────
379
539
  test("normalizePoll: fills defaults and clamps everyMs to the ceiling", () => {
380
540
  const d = normalizePoll(undefined);
package/app/readiness.ts CHANGED
@@ -18,14 +18,28 @@
18
18
  // any credential is read at execution time from the typed env-contract (`credentialEnv` names a
19
19
  // declared {@link EnvKey}; ADR 0004 pinned decision 2) and is redacted from every log line.
20
20
  import { isEnvKey, readEnv, readEnvOr } from "./contracts.ts";
21
+ import { allCheckNames, classifyMergeability, failingCheckNames, type PrState, pendingCheckNames } from "./github.ts";
21
22
  import { isoDuration, isoDurationToMs } from "./reviewWait.ts";
22
23
 
23
24
  /** The built-in readiness sources. `command` is the escape hatch that subsumes the long tail
24
25
  * (`gh`, `curl`, `docker manifest inspect`, a custom probe) — adding a first-class kind later is
25
26
  * an additive matcher, not a schema change. `capability` is the first such additive kind (#274):
26
27
  * it resolves "which published version first carries capability C?" from the publish-provenance
27
- * substrate and binds the discovered `pkg@version` back through the gate (see {@link matchCapability}). */
28
- export type ProbeKind = "http" | "command" | "npm" | "github-check" | "capability";
28
+ * substrate and binds the discovered `pkg@version` back through the gate (see {@link matchCapability}).
29
+ * `pr` (ADR 0005 §2) is the merge-state kind: it lifts the PR-liveness/mergeability READ out of the
30
+ * merge loop (`app/mergeProtocol.ts` / `app/github.ts`) into a first-class probe so "watch an
31
+ * in-flight PR reach a declared state" is a graph edge, not logic buried in the merge-loop node
32
+ * body — the ACTION (landing the PR) stays in that node body; this kind only OBSERVES. */
33
+ export type ProbeKind = "http" | "command" | "npm" | "github-check" | "capability" | "pr";
34
+
35
+ /** The declared PR state a `pr` probe waits for (ADR 0005 §2). Each is a discovered fact about an
36
+ * in-flight PR, read from its live GitHub state and evaluated by {@link matchPr}:
37
+ * • `ready` — the PR is out of draft (the draft→ready transition is observable).
38
+ * • `merged` — the PR has landed; binds `mergedSha` (the merge commit) as an output.
39
+ * • `mergeable` — GitHub reports the PR as landable now ({@link classifyMergeability} `ready`:
40
+ * CLEAN/HAS_HOOKS/UNSTABLE/BEHIND — required review + checks satisfied).
41
+ * • `checks-green` — every head check run is complete with none failing (required checks green). */
42
+ export type PrCondition = "ready" | "merged" | "mergeable" | "checks-green";
29
43
 
30
44
  /** What the gate does when the bounded wait times out (the engine timer arm fires). */
31
45
  export type OnTimeout = "escalate" | "fail" | "continue";
@@ -33,9 +47,10 @@ export type OnTimeout = "escalate" | "fail" | "continue";
33
47
  /** Backoff policy between poll attempts. */
34
48
  export type Backoff = "fixed" | "exponential";
35
49
 
36
- const PROBE_KINDS: readonly ProbeKind[] = ["http", "command", "npm", "github-check", "capability"];
50
+ const PROBE_KINDS: readonly ProbeKind[] = ["http", "command", "npm", "github-check", "capability", "pr"];
37
51
  const ON_TIMEOUTS: readonly OnTimeout[] = ["escalate", "fail", "continue"];
38
52
  const BACKOFFS: readonly Backoff[] = ["fixed", "exponential"];
53
+ const PR_CONDITIONS: readonly PrCondition[] = ["ready", "merged", "mergeable", "checks-green"];
39
54
 
40
55
  /** The per-kind readiness predicate. Every field is optional; each kind reads only the ones it
41
56
  * understands and applies a sensible default when a field is absent (see the matchers below). */
@@ -66,6 +81,9 @@ export interface ProbeMatch {
66
81
  * unset, the capability edge is deterministic-only. The resolved `pkg@version` and bare version
67
82
  * are exposed to the command as `RESOLVED_ARTIFACT` / `RESOLVED_VERSION`. */
68
83
  readonly verifyCommand?: string;
84
+ /** pr: the declared PR state the probe waits for (default `merged`). One of {@link PrCondition} —
85
+ * `ready` (out of draft), `merged`, `mergeable`, or `checks-green`. */
86
+ readonly prState?: PrCondition;
69
87
  }
70
88
 
71
89
  /** The poll cadence: how often to re-probe, how long to keep trying, and the backoff shape. */
@@ -207,6 +225,13 @@ export function parseProbe(raw: unknown): ReadinessProbe {
207
225
  throw new Error("readiness probe (capability): 'match.package' is required (provenance is per-package scoped)");
208
226
  }
209
227
  }
228
+ // A pr edge whose target names no numeric PR id can never resolve — fail loudly at parse (mirroring
229
+ // the capability ref guard) rather than surface it as a timeout much later. `owner/repo#123`.
230
+ if (kind === "pr" && !parsePrTarget(target)) {
231
+ throw new Error(
232
+ `readiness probe (pr): 'target' ('${target}') must be an 'owner/repo#<number>' PR reference (e.g. 'nanobpm/nano-workforce#377')`,
233
+ );
234
+ }
210
235
  const poll = isRecord(raw.poll) ? parsePoll(raw.poll) : undefined;
211
236
  const credentialEnv = str(raw.credentialEnv).trim() || undefined;
212
237
  if (credentialEnv !== undefined && !isEnvKey(credentialEnv)) {
@@ -259,9 +284,27 @@ function parseMatch(raw: Record<string, unknown>): ProbeMatch {
259
284
  capabilityRef: str(raw.capabilityRef).trim() || undefined,
260
285
  package: str(raw.package).trim() || undefined,
261
286
  verifyCommand: str(raw.verifyCommand).trim() || undefined,
287
+ prState: parsePrCondition(raw.prState),
262
288
  };
263
289
  }
264
290
 
291
+ /** Narrow a raw `match.prState` to a {@link PrCondition}, throwing on a non-empty unknown value so a
292
+ * mistyped state (`"landed"` for `"merged"`) fails loudly at parse rather than waiting forever. An
293
+ * absent/blank value yields undefined — `matchPr` then applies the `merged` default. */
294
+ function parsePrCondition(raw: unknown): PrCondition | undefined {
295
+ const s = str(raw).trim();
296
+ if (s === "") return undefined;
297
+ if (!isPrCondition(s)) {
298
+ throw new Error(`readiness probe (pr): invalid match.prState '${s}' (expected one of ${PR_CONDITIONS.join(", ")})`);
299
+ }
300
+ return s;
301
+ }
302
+
303
+ function isPrCondition(v: string): v is PrCondition {
304
+ for (const c of PR_CONDITIONS) if (c === v) return true;
305
+ return false;
306
+ }
307
+
265
308
  function parsePoll(raw: Record<string, unknown>): ProbePoll {
266
309
  const backoffRaw = str(raw.backoff).trim();
267
310
  if (backoffRaw !== "" && !isBackoff(backoffRaw)) {
@@ -485,6 +528,116 @@ export function githubReleasesCommand(repo: string): string {
485
528
  return `gh api --paginate --slurp ${shellQuote(`repos/${repo}/releases?per_page=100`)} -H ${shellQuote("Accept: application/vnd.github+json")}`;
486
529
  }
487
530
 
531
+ // ── PR / merge-state probe (ADR 0005 §2 — lift the merge-loop READ into a first-class probe) ─────
532
+
533
+ /** A live PR observation, reduced to the fields {@link matchPr} reads. It extends the shared
534
+ * {@link PrState} (so `classifyMergeability` and the merge loop's liveness vocabulary are reused
535
+ * verbatim, never re-derived) and adds `mergedSha`, the merge commit oid a `merged` match binds
536
+ * downstream. Kept separate from I/O — {@link parsePrView} builds it from an already-fetched
537
+ * `gh pr view --json …` payload — so the matcher stays pure/unit-testable, exactly like
538
+ * {@link GithubRelease}. */
539
+ export interface PrObservation extends PrState {
540
+ /** The merge commit oid once landed (`gh pr view --json mergeCommit`), else null. */
541
+ readonly mergedSha: string | null;
542
+ /** Count of head checks still in flight (queued/in progress), so a `checks-green` gate never
543
+ * reports green while a run hasn't concluded. Derived via `pendingCheckNames`. */
544
+ readonly pendingChecks: number;
545
+ }
546
+
547
+ /** Parse a raw `gh pr view --json state,mergedAt,mergeStateStatus,statusCheckRollup,isDraft,headRefOid,mergeCommit`
548
+ * payload (already JSON-decoded) into a {@link PrObservation}. Reuses the SAME check-rollup readers
549
+ * as `fetchPrState` (`failingCheckNames`/`allCheckNames`) so a superseded/cancelled run is collapsed
550
+ * identically. Tolerant: a malformed/empty payload yields an all-open, no-checks observation, so a
551
+ * transient read degrades to "not ready" (keep waiting), never a throw. */
552
+ export function parsePrView(payload: unknown): PrObservation {
553
+ const j = isRecord(payload) ? payload : {};
554
+ const rollup = Array.isArray(j.statusCheckRollup) ? j.statusCheckRollup : [];
555
+ const names = failingCheckNames(rollup);
556
+ const pending = pendingCheckNames(rollup);
557
+ const merged = str(j.state).toUpperCase() === "MERGED" || str(j.mergedAt).trim() !== "";
558
+ const mergeCommit = isRecord(j.mergeCommit) ? j.mergeCommit : undefined;
559
+ const mergedSha = mergeCommit && str(mergeCommit.oid).trim() !== "" ? str(mergeCommit.oid).trim() : null;
560
+ return {
561
+ merged,
562
+ state: merged ? "merged" : str(j.state).toUpperCase() === "CLOSED" ? "closed" : "open",
563
+ mergeStateStatus: (str(j.mergeStateStatus) || "UNKNOWN").toUpperCase(),
564
+ failingChecks: names.length,
565
+ failingCheckNames: names,
566
+ totalChecks: rollup.length,
567
+ presentCheckNames: allCheckNames(rollup),
568
+ isDraft: j.isDraft === true,
569
+ headRefOid: str(j.headRefOid).trim() || null,
570
+ mergedSha,
571
+ pendingChecks: pending.length,
572
+ };
573
+ }
574
+
575
+ /** pr readiness (ADR 0005 §2): does the observed PR satisfy the declared `match.prState`
576
+ * (default `merged`)? PURE — it operates on an already-fetched {@link PrObservation} and NEVER
577
+ * throws, so a transient/garbled read is simply "not ready yet". A `merged` match binds the merge
578
+ * commit as `{ mergedSha }` (mirroring the `capability` kind's `resolvedArtifact` bind) so a
579
+ * downstream edge can pin the exact landed commit. The merge ACTION stays in the merge-loop node
580
+ * body — this kind only OBSERVES. */
581
+ export function matchPr(match: ProbeMatch | undefined, pr: PrObservation): ProbeResult {
582
+ const want: PrCondition = match?.prState ?? "merged";
583
+ switch (want) {
584
+ case "ready": {
585
+ // draft→ready: a non-draft PR (already-merged PRs are non-draft too, so they also satisfy it).
586
+ const ready = !pr.isDraft;
587
+ return { ready, detail: `pr ${ready ? "ready (not draft)" : "still draft"}` };
588
+ }
589
+ case "merged": {
590
+ if (!pr.merged) return { ready: false, detail: "pr not merged yet" };
591
+ const bind = pr.mergedSha ? { mergedSha: pr.mergedSha } : undefined;
592
+ return { ready: true, detail: `pr merged${pr.mergedSha ? ` (${pr.mergedSha})` : ""}`, bind };
593
+ }
594
+ case "mergeable": {
595
+ const m = classifyMergeability(pr);
596
+ const ready = m === "ready";
597
+ return { ready, detail: `pr mergeability ${m}` };
598
+ }
599
+ case "checks-green": {
600
+ // Required checks green: at least one head run exists, none failing, AND none still in flight.
601
+ // A queued/in-progress run has no failing conclusion, so counting only `failingChecks` would
602
+ // report green while checks are still running — `pendingChecks` closes that gap. `failingChecks
603
+ // < 0` is token mode (checks unenumerable) — stay conservative (not ready), never falsely green.
604
+ const ready = pr.failingChecks === 0 && pr.pendingChecks === 0 && pr.totalChecks > 0;
605
+ const detail =
606
+ pr.totalChecks < 0
607
+ ? "pr checks unenumerable (not ready)"
608
+ : pr.totalChecks === 0
609
+ ? "pr no checks yet"
610
+ : pr.failingChecks > 0
611
+ ? `pr checks ${pr.failingChecks} failing`
612
+ : pr.pendingChecks > 0
613
+ ? `pr checks ${pr.pendingChecks} pending`
614
+ : "pr checks green";
615
+ return { ready, detail };
616
+ }
617
+ }
618
+ }
619
+
620
+ /** Split an `owner/repo#123` PR reference into its repo + numeric PR number, or `null` when it
621
+ * carries no numeric id (so `parseProbe` can reject a never-resolvable target loudly). The `#`
622
+ * separator is the canonical — and only — PR handle: an `@N` form is deliberately NOT accepted, as
623
+ * `owner/repo@<ref>` is the repo-ref syntax used elsewhere (`parseRepoRef`), so a numeric `@N` there
624
+ * would ambiguously mis-parse a git ref as a PR number. Matches the OpenAPI contract + `parseProbe`
625
+ * error, both of which document `owner/repo#N` only. */
626
+ export function parsePrTarget(target: string): { repo: string; number: string } | null {
627
+ const t = target.trim();
628
+ const m = t.match(/^(.+?)#(\d+)$/);
629
+ if (!m) return null;
630
+ const repo = m[1].trim();
631
+ if (repo === "") return null;
632
+ return { repo, number: m[2] };
633
+ }
634
+
635
+ /** Build the `gh pr view` command that reads a PR's merge-state fields. `gh` reads its token from the
636
+ * ambient env (like `github-check`/`capability`) — no `credentialEnv`. */
637
+ export function prViewCommand(repo: string, number: string): string {
638
+ return `gh pr view ${shellQuote(number)} --repo ${shellQuote(repo)} --json ${shellQuote("state,mergedAt,mergeStateStatus,statusCheckRollup,isDraft,headRefOid,mergeCommit")}`;
639
+ }
640
+
488
641
 
489
642
  // ── Single probe attempt (does I/O via the injected {@link ProbeExec}) ──────────────────────────
490
643
 
@@ -520,6 +673,13 @@ export async function probeOnce(
520
673
  if (out.code !== 0) return { ready: false, detail: "capability: gh api failed (not ready)" };
521
674
  return matchCapability(probe.match, parseReleases(parseJson(out.stdout)));
522
675
  }
676
+ case "pr": {
677
+ const ref = parsePrTarget(probe.target);
678
+ if (!ref) return { ready: false, detail: "pr: unparseable target (not ready)" };
679
+ const out = await exec.run(prViewCommand(ref.repo, ref.number), env);
680
+ if (out.code !== 0) return { ready: false, detail: "pr: gh pr view failed (not ready)" };
681
+ return matchPr(probe.match, parsePrView(parseJson(out.stdout)));
682
+ }
523
683
  }
524
684
  }
525
685
 
package/openapi.yaml CHANGED
@@ -1217,12 +1217,12 @@ components:
1217
1217
  properties:
1218
1218
  kind:
1219
1219
  type: string
1220
- enum: [http, command, npm, github-check, capability]
1221
- description: The readiness source. `command` is the escape hatch; `capability` resolves a cross-repo published-artifact edge.
1220
+ enum: [http, command, npm, github-check, capability, pr]
1221
+ description: The readiness source. `command` is the escape hatch; `capability` resolves a cross-repo published-artifact edge; `pr` watches an in-flight PR's merge state (ADR 0005 §2).
1222
1222
  target:
1223
1223
  type: string
1224
1224
  minLength: 1
1225
- description: The kind-specific target (a URL, a shell command, a `pkg@version`, an `owner/repo@ref`, or `github-releases:owner/repo`).
1225
+ description: The kind-specific target (a URL, a shell command, a `pkg@version`, an `owner/repo@ref`, `github-releases:owner/repo`, or an `owner/repo#123` PR reference for the `pr` kind).
1226
1226
  onTimeout:
1227
1227
  type: string
1228
1228
  enum: [escalate, fail, continue]
@@ -1245,6 +1245,7 @@ components:
1245
1245
  capabilityRef: { type: string, description: "capability: the upstream issue/PR handle the resolved version must carry." }
1246
1246
  package: { type: string, description: "capability: the package whose releases are scanned for provenance." }
1247
1247
  verifyCommand: { type: string, description: "capability: optional empirical verifier run once at the gate boundary." }
1248
+ prState: { type: string, enum: [ready, merged, mergeable, checks-green], description: "pr: the declared PR state to wait for (default merged)." }
1248
1249
  poll:
1249
1250
  type: object
1250
1251
  additionalProperties: false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.110.0",
3
+ "version": "0.111.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -22,6 +22,7 @@
22
22
  <nano:extend name="capabilityRef" type="string" optional="true" />
23
23
  <nano:extend name="package" type="string" optional="true" />
24
24
  <nano:extend name="verifyCommand" type="string" optional="true" />
25
+ <nano:extend name="prState" type="string" optional="true" />
25
26
  </nano:shape>
26
27
  <nano:shape id="ReadinessProbePoll" name="Readiness probe — poll policy">
27
28
  <nano:extend name="everyMs" type="integer" optional="true" />
@@ -46,11 +47,13 @@
46
47
  <nano:extend name="ready" type="boolean" />
47
48
  <nano:extend name="detail" type="string" optional="true" />
48
49
  <nano:extend name="resolvedArtifact" type="string" optional="true" />
50
+ <nano:extend name="mergedSha" type="string" optional="true" />
49
51
  </nano:shape>
50
52
  <nano:shape id="ReadinessReady" name="readiness-ready message payload">
51
53
  <nano:extend name="ready" type="boolean" />
52
54
  <nano:extend name="detail" type="string" optional="true" />
53
55
  <nano:extend name="resolvedArtifact" type="string" optional="true" />
56
+ <nano:extend name="mergedSha" type="string" optional="true" />
54
57
  </nano:shape>
55
58
  </nano:shapes>
56
59
  </bpmn:extensionElements>