@nanobpm/nano-workforce 0.141.0 → 0.142.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,9 @@
1
+ ## [0.142.0](https://github.com/nanobpm/nano-workforce/compare/v0.141.0...v0.142.0) (2026-08-25)
2
+
3
+ ### Features
4
+
5
+ * **delivery-graph:** late-bind converge/wait PR from an emitted `pr` fact ([#548](https://github.com/nanobpm/nano-workforce/issues/548)) ([#550](https://github.com/nanobpm/nano-workforce/issues/550)) ([dbb3aab](https://github.com/nanobpm/nano-workforce/commit/dbb3aabfe8aeaa9951d2ce9f5a2b732eaa99748b))
6
+
1
7
  ## [0.141.0](https://github.com/nanobpm/nano-workforce/compare/v0.140.0...v0.141.0) (2026-08-25)
2
8
 
3
9
  ### Features
@@ -0,0 +1,30 @@
1
+ // The converge-enrollment connector-target vocabulary (ADR 0005, issue #500) — the SINGLE, dependency-
2
+ // free source of truth for the `converge` / `converge-merge` literals and their derived semantics.
3
+ // Extracted from `deliveryConnector.ts` so the pure, import-free semantic validator (`deliveryGraph.ts`)
4
+ // can share the exact same predicate WITHOUT pulling in the connector module's urban/data-layer deps —
5
+ // a converge/wait node's late-binding validation (issue #548) must agree with the worker's dispatch
6
+ // branch on what "a converge target" is, and this module is what keeps them from drifting.
7
+
8
+ /** The review-only converge target: enrolls a PR into the shared convergence loop and STOPS at
9
+ * `converged` (never hands it to the merge loop). */
10
+ export const CONVERGE_TARGET = "converge";
11
+
12
+ /** The converge-AND-merge target: enrolls a PR into the shared convergence loop and drives the merge
13
+ * loop too (the canonical `agent → connector[converge-merge] → wait[pr, merged]` land shape). */
14
+ export const CONVERGE_MERGE_TARGET = "converge-merge";
15
+
16
+ /** Is `target` one of the converge-enrollment targets (`converge` / `converge-merge`)? The single
17
+ * predicate the connector worker branches on to route a dispatch into `submitPr`, and the validator
18
+ * branches on to require a bound/literal PR (issue #548). */
19
+ export function isConvergeTarget(target: string): boolean {
20
+ return target === CONVERGE_TARGET || target === CONVERGE_MERGE_TARGET;
21
+ }
22
+
23
+ /** The DEFAULT `convergeOnly` for a converge target: `converge` is review-only (`true` — stop at
24
+ * `converged`), `converge-merge` drives the merge loop too (`false`). Maps directly onto `submitPr`'s
25
+ * `convergeOnly` argument. An author may still override it per-dispatch via the connector payload's
26
+ * `convergeOnly`. Only ever consulted behind `isConvergeTarget`, so a non-converge target's `false`
27
+ * is unreachable. */
28
+ export function convergeOnlyForTarget(target: string): boolean {
29
+ return target === CONVERGE_TARGET;
30
+ }
@@ -39,26 +39,14 @@ export const OUTCOME_DELIVERED = "delivered";
39
39
  * convergence AND the merge loop; `converge` stops at `converged` (converge-only). This is the "real
40
40
  * target dispatch" ADR 0005 deferred as a later slice for the connector I/O surface: a `converge`/
41
41
  * `converge-merge` connector IS the "automated, side-effecting outbound action" a connector is
42
- * defined to be. Named constants so the worker's dispatch branch and the docs/preview can never drift
43
- * on the literal. */
44
- export const CONVERGE_TARGET = "converge";
45
- export const CONVERGE_MERGE_TARGET = "converge-merge";
46
-
47
- /** Is `target` one of the converge-enrollment targets (`converge` / `converge-merge`)? The single
48
- * predicate the worker branches on to route a dispatch into `submitPr` instead of the forward-declared
49
- * stub. */
50
- export function isConvergeTarget(target: string): boolean {
51
- return target === CONVERGE_TARGET || target === CONVERGE_MERGE_TARGET;
52
- }
53
-
54
- /** The DEFAULT `convergeOnly` for a converge target: `converge` is review-only (`true` — stop at
55
- * `converged`), `converge-merge` drives the merge loop too (`false`). Maps directly onto `submitPr`'s
56
- * `convergeOnly` argument (mirroring how `converge-feature` inverts `autoMerge`). An author may still
57
- * override it per-dispatch via the connector payload's `convergeOnly`. Only ever consulted behind
58
- * `isConvergeTarget`, so a non-converge target's `false` is unreachable. */
59
- export function convergeOnlyForTarget(target: string): boolean {
60
- return target === CONVERGE_TARGET;
61
- }
42
+ * defined to be. The converge-target vocabulary lives in the dependency-free {@link ./convergeTargets.ts}
43
+ * so the pure validator can share it; re-exported here for the worker's existing import surface. */
44
+ export {
45
+ CONVERGE_MERGE_TARGET,
46
+ CONVERGE_TARGET,
47
+ convergeOnlyForTarget,
48
+ isConvergeTarget,
49
+ } from "./convergeTargets.ts";
62
50
 
63
51
  /** One durable dispatch-claim row — the at-most-once ledger entry a connector writes before it acts. */
64
52
  export interface DeliveryConnectorDispatchRow extends Record<string, unknown> {
@@ -82,6 +70,29 @@ export interface BoundFact {
82
70
  value: unknown;
83
71
  }
84
72
 
73
+ /** Resolve a converge connector's effective target PR (issue #548), late-binding it from an upstream
74
+ * `agent` node's emitted `pr` fact so the canonical `agent → connector[converge-merge] → wait` shape
75
+ * needs NO hardcoded literal. Precedence, given the author-supplied `payload.pr` and the threaded
76
+ * `boundFacts`:
77
+ * • an explicit fact REFERENCE — a `payload.pr` string that exactly matches a threaded fact's
78
+ * `<from>.<name>` — resolves to that fact's value (an `owner/repo#N` PR ref emitted upstream);
79
+ * • an explicit LITERAL — any other `payload.pr` string — is returned as-is (a real `owner/repo#N`
80
+ * is never `<node>.<fact>`-shaped, so it can't collide with a reference), and `parsePr` validates it;
81
+ * • OMITTED (`payload.pr` absent) — binds the single incoming fact named `pr` (the canonical emit),
82
+ * else the single incoming bound fact when there is exactly one, else stays undefined so
83
+ * {@link readConvergeInput} raises its "requires payload.pr" error (fail closed).
84
+ * Pure + total (no PR parsing here — the caller validates), so it is unit-testable without the engine. */
85
+ export function resolveConvergePr(payloadPr: unknown, boundFacts: readonly BoundFact[]): unknown {
86
+ if (typeof payloadPr === "string" && payloadPr.trim() !== "") {
87
+ const ref = boundFacts.find((b) => `${b.from}.${b.name}` === payloadPr.trim());
88
+ return ref ? ref.value : payloadPr;
89
+ }
90
+ const named = boundFacts.filter((b) => b.name === "pr");
91
+ if (named.length === 1) return named[0].value;
92
+ if (named.length === 0 && boundFacts.length === 1) return boundFacts[0].value;
93
+ return payloadPr;
94
+ }
95
+
85
96
  /** The effective dedupe key for a connector dispatch: the author-supplied `connector.dedupeKey` when
86
97
  * present, else a graph-derived `<processInstanceKey>:<elementId>` — both STABLE across a re-activation
87
98
  * of the same node instance (the engine re-delivers the same job with the same identity), so an
@@ -731,3 +731,114 @@ test("a wait node's onTimeout: fail is rejected (unsupported-on-timeout) while c
731
731
  assertEquals(validateDeliveryGraph(waitWith("continue")), []);
732
732
  assertEquals(validateDeliveryGraph(waitWith("escalate")), []);
733
733
  });
734
+
735
+ // ── Pass 4 (#548): converge/wait PR late-binding ──────────────────────────────────────────────────
736
+
737
+ /** The canonical no-literal converge shape: `open` emits a `pr` fact, both the converge connector and
738
+ * the pr-wait reference it (`open.pr`) on incoming fact edges. */
739
+ function noLiteralConverge(overrides?: {
740
+ connectorPr?: unknown;
741
+ waitTarget?: string;
742
+ openEmitsPr?: boolean;
743
+ edges?: { from: string; to: string }[];
744
+ }) {
745
+ const emits = overrides?.openEmitsPr === false ? [{ name: "pr", type: "url" }] : [{ name: "pr", type: "pr" }];
746
+ const connector =
747
+ "connectorPr" in (overrides ?? {})
748
+ ? { target: "converge-merge", payload: { pr: overrides?.connectorPr } }
749
+ : { target: "converge-merge", payload: { pr: "open.pr" } };
750
+ return {
751
+ name: "no-literal converge",
752
+ nodes: [
753
+ { id: "open", kind: "agent", agent: { jobType: "senior:feature", prompt: "open a PR" }, emits },
754
+ { id: "land", kind: "connector", connector },
755
+ { id: "merged", kind: "wait", wait: { kind: "pr", target: overrides?.waitTarget ?? "open.pr", match: { prState: "merged" } } },
756
+ ],
757
+ edges: overrides?.edges ?? [
758
+ { from: "open.pr", to: "land" },
759
+ { from: "open.pr", to: "merged" },
760
+ ],
761
+ };
762
+ }
763
+
764
+ test("#548 a converge connector + pr-wait that reference a threaded `pr` fact validate", () => {
765
+ assertEquals(validateDeliveryGraph(noLiteralConverge()), []);
766
+ });
767
+
768
+ test("#548 a LITERAL owner/repo#N PR on both the connector and the wait validates (no binding needed)", () => {
769
+ const g = noLiteralConverge({ connectorPr: "acme/repo#12", waitTarget: "acme/repo#12", edges: [{ from: "open", to: "land" }, { from: "land", to: "merged" }] });
770
+ assertEquals(validateDeliveryGraph(g), []);
771
+ });
772
+
773
+ test("#548 a PR reference that is NOT threaded by a fact edge is rejected (unbound-pr)", () => {
774
+ // The connector/wait name `open.pr`, but the edges carry only a plain completion dependency — the
775
+ // `pr` fact never flows in, so it can never late-bind.
776
+ const g = noLiteralConverge({ edges: [{ from: "open", to: "land" }, { from: "land", to: "merged" }] });
777
+ const errs = validateDeliveryGraph(g);
778
+ const unbound = errs.filter((e) => e.code === "unbound-pr");
779
+ assertEquals(unbound.length, 2, `both consumers are unbound: ${JSON.stringify(errs)}`);
780
+ assert(unbound.some((e) => e.path === "nodes[1].connector.payload.pr"));
781
+ assert(unbound.some((e) => e.path === "nodes[2].wait.target"));
782
+ });
783
+
784
+ test("#548 a PR reference to a non-`pr`-typed fact is rejected (unbound-pr)", () => {
785
+ const g = noLiteralConverge({ openEmitsPr: false }); // `open` emits `pr` as a `url`, not `pr`
786
+ const err = hasCode(validateDeliveryGraph(g), "unbound-pr");
787
+ assert(err.message.includes("must reference a `pr`-typed fact") || err.message.includes('"url"'), err.message);
788
+ });
789
+
790
+ test("#548 a PR reference to an undeclared fact is rejected (unbound-pr)", () => {
791
+ // `open` emits no facts, but the connector references `open.pr`.
792
+ const g = {
793
+ name: "undeclared ref",
794
+ nodes: [
795
+ { id: "open", kind: "agent", agent: { jobType: "j", prompt: "p" } },
796
+ { id: "land", kind: "connector", connector: { target: "converge", payload: { pr: "open.pr" } } },
797
+ ],
798
+ edges: [{ from: "open", to: "land" }],
799
+ };
800
+ const err = hasCode(validateDeliveryGraph(g), "unbound-pr");
801
+ assert(err.message.includes("does not declare"), err.message);
802
+ });
803
+
804
+ test("#548 a converge connector that OMITS payload.pr auto-binds a single threaded `pr` fact", () => {
805
+ const g = {
806
+ name: "omitted auto-bind",
807
+ nodes: [
808
+ { id: "open", kind: "agent", agent: { jobType: "senior:feature", prompt: "open" }, emits: [{ name: "pr", type: "pr" }] },
809
+ { id: "land", kind: "connector", connector: { target: "converge-merge" } },
810
+ ],
811
+ edges: [{ from: "open.pr", to: "land" }],
812
+ };
813
+ assertEquals(validateDeliveryGraph(g), []);
814
+ });
815
+
816
+ test("#548 a converge connector with NO PR at all is rejected (unbound-pr)", () => {
817
+ const g = {
818
+ name: "no pr",
819
+ nodes: [
820
+ { id: "open", kind: "agent", agent: { jobType: "j", prompt: "p" } },
821
+ { id: "land", kind: "connector", connector: { target: "converge-merge" } },
822
+ ],
823
+ edges: [{ from: "open", to: "land" }],
824
+ };
825
+ const err = hasCode(validateDeliveryGraph(g), "unbound-pr");
826
+ assert(err.message.includes("has no target PR"), err.message);
827
+ });
828
+
829
+ test("#548 a converge connector with MULTIPLE incoming `pr` facts is rejected as ambiguous (unbound-pr)", () => {
830
+ const g = {
831
+ name: "ambiguous pr",
832
+ nodes: [
833
+ { id: "a", kind: "agent", agent: { jobType: "j", prompt: "p" }, emits: [{ name: "pr", type: "pr" }] },
834
+ { id: "b", kind: "agent", agent: { jobType: "j", prompt: "p" }, emits: [{ name: "pr", type: "pr" }] },
835
+ { id: "land", kind: "connector", connector: { target: "converge-merge" } },
836
+ ],
837
+ edges: [
838
+ { from: "a.pr", to: "land" },
839
+ { from: "b.pr", to: "land" },
840
+ ],
841
+ };
842
+ const err = hasCode(validateDeliveryGraph(g), "unbound-pr");
843
+ assert(err.message.includes("disambiguate"), err.message);
844
+ });
@@ -19,6 +19,8 @@
19
19
  // Every error carries a JSON-path-qualified `path` (`nodes[2].kind`, `edges[1].from`, …) so the
20
20
  // caller can point the author straight at the offending input.
21
21
 
22
+ import { isConvergeTarget } from "./convergeTargets.ts";
23
+
22
24
  /** The CLOSED node-kind allowlist (ADR 0005 Decision 2) — the trust boundary. Extensible only by a
23
25
  * deliberate ADR/PR (add the openapi variant + a case here), never by a graph author. Kept as the
24
26
  * single source of truth for "which kinds are legal" so the validator and any future compiler agree. */
@@ -30,8 +32,11 @@ export type DeliveryNodeKind = (typeof DELIVERY_NODE_KINDS)[number];
30
32
  /** The CLOSED emitted-fact type allowlist (ADR 0005 Decision 3/4) — mirrors the `DeliveryFact.type`
31
33
  * enum in `openapi.yaml`. Kept as the single source of truth so the semantic validator rejects an
32
34
  * untyped/unknown fact type even when the OpenAPI shape validator is bypassed (a directly-invoked
33
- * delegate), since later compilation/execution steps rely on this allowlist. */
34
- export const DELIVERY_FACT_TYPES = ["string", "number", "boolean", "artifact", "version", "url"] as const;
35
+ * delegate), since later compilation/execution steps rely on this allowlist. `pr` (issue #548) is the
36
+ * PR-reference type an `agent` node emits for the PR it opened (`owner/repo#N`), so a downstream
37
+ * `connector[converge*]` / `wait[pr]` node LATE-BINDS its target PR from that fact instead of a
38
+ * hardcoded literal (the canonical `agent → connector[converge-merge] → wait[pr, merged]` shape). */
39
+ export const DELIVERY_FACT_TYPES = ["string", "number", "boolean", "artifact", "version", "url", "pr"] as const;
35
40
 
36
41
  /** An emitted fact's declared `type`, narrowed to the closed allowlist. */
37
42
  export type DeliveryFactType = (typeof DELIVERY_FACT_TYPES)[number];
@@ -84,7 +89,8 @@ export type DeliveryGraphErrorCode =
84
89
  | "multiple-defaults"
85
90
  | "non-exhaustive-split"
86
91
  | "exclusive-merge-parity"
87
- | "unsupported-on-timeout";
92
+ | "unsupported-on-timeout"
93
+ | "unbound-pr";
88
94
 
89
95
  /** A single semantic validation failure. `path` is a JSON-path-qualified pointer at the offending
90
96
  * input (`nodes[2].kind`, `edges[1].from`, `nodes[0].emits[1].name`), `message` is human-actionable,
@@ -280,6 +286,19 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
280
286
  // validation (pass 3) reads to enforce that a `when` references a SCALAR fact.
281
287
  const nodeFacts = new Map<string, Set<string>>();
282
288
  const nodeFactTypes = new Map<string, Map<string, DeliveryFactType>>();
289
+ // #548 PR late-binding: the converge-connector / pr-wait nodes whose target PR must resolve to a
290
+ // literal `owner/repo#N` OR a threaded upstream `pr` fact. Collected in pass 1, validated in pass 4
291
+ // once the incoming fact edges are known — so an author who references a `pr` fact that isn't
292
+ // actually threaded (or isn't `pr`-typed) is rejected at COMPILE, not left to fail closed at runtime.
293
+ const prBindConsumers: {
294
+ path: string;
295
+ field: "connector.payload.pr" | "wait.target";
296
+ id: string;
297
+ // The authored PR ref: the connector's `payload.pr` or the wait's `target`. `undefined` when
298
+ // absent (a connector may omit it and auto-bind the single incoming `pr` fact; a wait's `target`
299
+ // is a required field reported missing elsewhere).
300
+ authored: string | undefined;
301
+ }[] = [];
283
302
  nodes.forEach((rawNode, i) => {
284
303
  const path = `nodes[${i}]`;
285
304
  if (!isRecord(rawNode)) {
@@ -358,6 +377,26 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
358
377
  code: "unsupported-on-timeout",
359
378
  });
360
379
  }
380
+ // #548: register a converge-connector / pr-wait as a PR-binding consumer (pass 4 validates the
381
+ // binding once edges are resolved). Only when the id is usable so pass 4 can key by node id.
382
+ if (typeof id === "string" && id.length > 0) {
383
+ if (kind === "connector" && typeof config.target === "string" && isConvergeTarget(config.target)) {
384
+ const payload = isRecord(config.payload) ? config.payload : undefined;
385
+ prBindConsumers.push({
386
+ path,
387
+ field: "connector.payload.pr",
388
+ id,
389
+ authored: payload !== undefined && typeof payload.pr === "string" ? payload.pr : undefined,
390
+ });
391
+ } else if (kind === "wait" && config.kind === "pr") {
392
+ prBindConsumers.push({
393
+ path,
394
+ field: "wait.target",
395
+ id,
396
+ authored: typeof config.target === "string" ? config.target : undefined,
397
+ });
398
+ }
399
+ }
361
400
  }
362
401
  } else if (rawNode.human !== undefined && !isRecord(rawNode.human)) {
363
402
  // `human` config is OPTIONAL (formKey/prompt both resolve to a generic fallback in S3), but
@@ -472,6 +511,9 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
472
511
  }
473
512
  // consumer (`to`) → set of upstream node ids (`from`'s node) — the dependency direction.
474
513
  const adjacency = new Map<string, Set<string>>();
514
+ // #548: consumer (`to`) → the resolved fact edges threaded INTO it (`<node>.<fact>` refs + declared
515
+ // types), so pass 4 can confirm a converge/wait PR reference is actually threaded and `pr`-typed.
516
+ const incomingFactRefs = new Map<string, { ref: string; factName: string; factType: DeliveryFactType | undefined }[]>();
475
517
  // Resolved, well-formed edges captured for the guard/topology pass (pass 3). Only edges whose BOTH
476
518
  // endpoints resolve are kept — a dangling/self edge is already reported and must not reach pass 3.
477
519
  const guardEdges: {
@@ -556,6 +598,13 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
556
598
  const ups = adjacency.get(to) ?? new Set<string>();
557
599
  ups.add(nodeId);
558
600
  adjacency.set(to, ups);
601
+ // #548: record a resolved, DECLARED fact edge so pass 4 can confirm a converge/wait PR reference
602
+ // is actually threaded into its consumer (and is `pr`-typed).
603
+ if (fact !== undefined && upstreamFacts.has(fact)) {
604
+ const refs = incomingFactRefs.get(to) ?? [];
605
+ refs.push({ ref: `${nodeId}.${fact}`, factName: fact, factType: nodeFactTypes.get(nodeId)?.get(fact) });
606
+ incomingFactRefs.set(to, refs);
607
+ }
559
608
  guardEdges.push({
560
609
  index: i,
561
610
  fromNode: nodeId,
@@ -570,14 +619,119 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
570
619
  });
571
620
 
572
621
  collectCycle(adjacency, errors);
573
- // Pass 3: guard (S7) semantics only when the graph is otherwise structurally sound (every edge
574
- // resolved, no cycle). A malformed base graph is reported first; guard analysis assumes a DAG.
622
+ // Passes 3 & 4 assume an otherwise structurally-sound graph (every edge resolved, no cycle) — a
623
+ // malformed base graph is reported first, and guard/binding analysis assumes a DAG with resolved
624
+ // fact edges. Both run under the SAME soundness snapshot so a pass-3 guard error can't suppress a
625
+ // pass-4 binding error (they are independent classes and should surface together).
575
626
  if (errors.length === 0) {
627
+ // Pass 3: guard (S7) semantics.
576
628
  validateGuardedEdges(guardEdges, nodeFactTypes, errors);
629
+ // Pass 4 (#548): converge/wait PR late-binding — a referenced PR fact must be threaded + `pr`-typed.
630
+ validatePrBindings(prBindConsumers, incomingFactRefs, nodeFacts, nodeFactTypes, errors);
577
631
  }
578
632
  return errors;
579
633
  }
580
634
 
635
+ /** Pass 4 (#548) — validate that every converge-connector / pr-wait node can resolve its target PR.
636
+ * A converge/wait node needs a PR to enroll or poll; it may name it three ways (mirroring the runtime
637
+ * `resolveConvergePr` / the wait compiler `context put` late-bind):
638
+ * • a LITERAL `owner/repo#N` — never `<node>.<fact>`-shaped, so it resolves to no graph node and is
639
+ * accepted here (the worker/probe validates the literal at runtime);
640
+ * • a fact REFERENCE `<node>.pr` — REQUIRES that the referenced node declares a `pr`-typed fact of
641
+ * that name AND that a fact edge actually threads it into this consumer (else it can never bind);
642
+ * • OMITTED (connectors only) — auto-binds the single incoming `pr` fact, else the single incoming
643
+ * fact; rejected when there is no PR to bind or the choice is ambiguous.
644
+ * Rejecting these at compile (fail closed, path-qualified) turns a silent runtime "requires payload.pr"
645
+ * / unparseable-target failure into an actionable authoring error. */
646
+ function validatePrBindings(
647
+ consumers: readonly {
648
+ path: string;
649
+ field: "connector.payload.pr" | "wait.target";
650
+ id: string;
651
+ authored: string | undefined;
652
+ }[],
653
+ incomingFactRefs: ReadonlyMap<string, { ref: string; factName: string; factType: DeliveryFactType | undefined }[]>,
654
+ nodeFacts: ReadonlyMap<string, ReadonlySet<string>>,
655
+ nodeFactTypes: ReadonlyMap<string, ReadonlyMap<string, DeliveryFactType>>,
656
+ errors: DeliveryGraphError[],
657
+ ): void {
658
+ for (const c of consumers) {
659
+ const incoming = incomingFactRefs.get(c.id) ?? [];
660
+ const errPath = `${c.path}.${c.field}`;
661
+ if (c.authored !== undefined) {
662
+ // `resolveFrom` returns a `fact` iff the reference's prefix is an existing node — so a real PR
663
+ // literal (`owner/repo#N`, no node-qualified dot) resolves to no fact and passes through.
664
+ const resolved = resolveFrom(c.authored, nodeFacts);
665
+ if (resolved.fact === undefined) continue; // a literal — validated at runtime.
666
+ const ref = `${resolved.nodeId}.${resolved.fact}`;
667
+ const declaredType = nodeFactTypes.get(resolved.nodeId)?.get(resolved.fact);
668
+ if (declaredType === undefined) {
669
+ errors.push({
670
+ path: errPath,
671
+ message:
672
+ `${c.id}'s PR reference "${c.authored}" points at fact "${resolved.fact}" that node ` +
673
+ `"${resolved.nodeId}" does not declare in its \`emits[]\``,
674
+ code: "unbound-pr",
675
+ });
676
+ } else if (declaredType !== "pr") {
677
+ errors.push({
678
+ path: errPath,
679
+ message:
680
+ `${c.id}'s PR reference "${c.authored}" resolves to a "${declaredType}" fact — a converge/` +
681
+ "wait PR binding must reference a `pr`-typed fact",
682
+ code: "unbound-pr",
683
+ });
684
+ } else if (!incoming.some((f) => f.ref === ref)) {
685
+ errors.push({
686
+ path: errPath,
687
+ message:
688
+ `${c.id}'s PR reference "${ref}" is not threaded into it — add a fact edge ` +
689
+ `{ from: "${ref}", to: "${c.id}" } so the emitted PR late-binds`,
690
+ code: "unbound-pr",
691
+ });
692
+ }
693
+ continue;
694
+ }
695
+ // OMITTED. Only a connector may omit its PR (auto-bind); a wait's `target` is a required field
696
+ // already reported missing elsewhere, so skip it here to avoid a duplicate error.
697
+ if (c.field !== "connector.payload.pr") continue;
698
+ const prNamed = incoming.filter((f) => f.factName === "pr");
699
+ if (prNamed.length === 1) {
700
+ if (prNamed[0].factType !== "pr") {
701
+ errors.push({
702
+ path: errPath,
703
+ message:
704
+ `converge connector "${c.id}" auto-binds its single incoming \`pr\` fact, but that fact is ` +
705
+ `typed "${prNamed[0].factType}" — declare it as \`pr\` or set connector.payload.pr explicitly`,
706
+ code: "unbound-pr",
707
+ });
708
+ }
709
+ } else if (prNamed.length === 0 && incoming.length === 1) {
710
+ if (incoming[0].factType !== "pr") {
711
+ errors.push({
712
+ path: errPath,
713
+ message:
714
+ `converge connector "${c.id}" would auto-bind its single incoming fact "${incoming[0].ref}", ` +
715
+ `but it is typed "${incoming[0].factType}", not \`pr\` — thread a \`pr\` fact or set ` +
716
+ "connector.payload.pr to a literal or `<node>.pr` reference",
717
+ code: "unbound-pr",
718
+ });
719
+ }
720
+ } else {
721
+ errors.push({
722
+ path: errPath,
723
+ message:
724
+ prNamed.length === 0
725
+ ? `converge connector "${c.id}" has no target PR — set connector.payload.pr to a literal ` +
726
+ '"owner/repo#N" or a "<node>.pr" reference, or thread a single `pr` fact edge into it'
727
+ : `converge connector "${c.id}" has ${prNamed.length} incoming \`pr\` facts — disambiguate ` +
728
+ 'by setting connector.payload.pr to the specific "<node>.pr" reference',
729
+ code: "unbound-pr",
730
+ });
731
+ }
732
+ }
733
+ }
734
+
581
735
  /** True when a guard `equals` literal's JSON type matches the referenced fact's declared scalar type. */
582
736
  function equalsMatchesFactType(equals: unknown, factType: DeliveryGuardScalarType): boolean {
583
737
  switch (factType) {
@@ -252,7 +252,7 @@ test("#514 Defect A: a capability wait-gate escalation surfaces the probe's last
252
252
  // Discrete diagnostic task variables the form/agent can bind directly.
253
253
  assert(esc.includes('target="probeDetail"'), "the escalation surfaces the probe's last detail as a discrete variable");
254
254
  assert(esc.includes('target="observedReleases"'), "the escalation surfaces the observed candidate releases");
255
- assert(/source="=nodeInputs\.[^"]+\.probe\.target" target="probeTarget"/.test(esc), "the escalation surfaces the resolved probe target");
255
+ assert(/source="=if \(is defined\(probe\.target\)\) then probe\.target else nodeInputs\.[^"]+\.probe\.target" target="probeTarget"/.test(esc), "the escalation surfaces the resolved (late-bound) probe target");
256
256
  assert(/source="=nodeInputs\.[^"]+\.probe\.match" target="probeMatch"/.test(esc), "the escalation surfaces the resolved probe match");
257
257
  });
258
258
 
@@ -423,6 +423,56 @@ test("converge-merge worked graph: agent → connector[converge-merge] → wait[
423
423
  assert(!r.sideEffects.some((s) => s.nodeId === "merged"), "the wait gate is not a side effect");
424
424
  });
425
425
 
426
+ test("#548 no-literal converge shape: an emitted `pr` fact late-binds the connector (boundFacts) AND the wait target (context put)", async () => {
427
+ // The canonical `agent → connector[converge-merge] → wait[pr, merged]` shape carrying NO hardcoded PR
428
+ // number: `open` emits the PR it opened as a typed `pr` fact, and both downstream consumers reference
429
+ // it (`open.pr`) on incoming fact edges. The compiler must (a) publish the fact as `<open>_pr`, (b)
430
+ // thread it into the connector's `boundFacts` input, and (c) rewrite the wait's probe target via
431
+ // `context put` to poll the late-bound PR.
432
+ const graph = {
433
+ name: "no-literal converge",
434
+ nodes: [
435
+ {
436
+ id: "open",
437
+ kind: "agent",
438
+ agent: { jobType: "senior:feature", prompt: "Implement and open a PR." },
439
+ emits: [{ name: "pr", type: "pr" }],
440
+ },
441
+ { id: "land", kind: "connector", connector: { target: "converge-merge", payload: { pr: "open.pr" } } },
442
+ { id: "merged", kind: "wait", wait: { kind: "pr", target: "open.pr", match: { prState: "merged" } } },
443
+ ],
444
+ edges: [
445
+ { from: "open.pr", to: "land" },
446
+ { from: "open.pr", to: "merged" },
447
+ ],
448
+ };
449
+ const r = await compileOk(graph);
450
+ const openEl = r.resolved.edges.find((e) => e.from === "open.pr")?.fromNode;
451
+ assertEquals(openEl, "open", "the fact edge resolves to the `open` producer node");
452
+ // (a) the producer publishes its declared `pr` emit into a flat `<element>_pr` parent variable.
453
+ assert(/target="[^"]*_pr"/.test(r.bpmn), "the agent's `pr` emit is published as `<element>_pr`");
454
+ // (b) the connector receives the fact list — its `boundFacts` input names the `pr` fact + producer.
455
+ assert(/target="boundFacts"/.test(r.bpmn), "the connector is threaded a boundFacts input");
456
+ assert(/name: "pr"/.test(r.bpmn), "the boundFacts entry names the `pr` fact");
457
+ // (c) the wait probe target is late-bound via `context put`, not the raw `open.pr` reference literal.
458
+ assert(/context put\([^)]*\.probe, "target",/.test(r.bpmn), "the wait probe target is rewritten via context put");
459
+ assert(!/target="owner\/repo#/.test(r.bpmn), "no hardcoded PR literal is compiled into the graph");
460
+ });
461
+
462
+ test("a wait node with a LITERAL pr target compiles the probe unchanged (no spurious context put)", async () => {
463
+ const graph = {
464
+ name: "literal target",
465
+ nodes: [
466
+ { id: "open", kind: "agent", agent: { jobType: "senior:feature", prompt: "open a PR" } },
467
+ { id: "merged", kind: "wait", wait: { kind: "pr", target: "acme/repo#7", match: { prState: "merged" } } },
468
+ ],
469
+ edges: [{ from: "open", to: "merged" }],
470
+ };
471
+ const r = await compileOk(graph);
472
+ assert(!/context put/.test(r.bpmn), "a literal target is not wrapped in a context put rewrite");
473
+ assert(/source="=nodeInputs\.[^"]+\.probe" target="probe"/.test(r.bpmn), "the probe is seeded directly from nodeInputs");
474
+ });
475
+
426
476
  test("resolved edges carry the resolved fromNode and the referenced fact", async () => {
427
477
  const r = await compileOk(RELEASE_RUNBOOK);
428
478
  const factEdge = r.resolved.edges.find((e) => e.from === "watch-b.mergedSha");
@@ -822,12 +822,30 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
822
822
  // threads null rather than raising a FEEL error.
823
823
  inputs.push({ source: guarded(TRANSCRIPT_URL_BASE_VAR), target: TRANSCRIPT_URL_BASE_VAR });
824
824
  break;
825
- case "wait":
825
+ case "wait": {
826
826
  inputs.push({ source: cfg("gateKey"), target: "gateKey" });
827
- inputs.push({ source: cfg("probe"), target: "probe" });
827
+ // #548 late-binding: when the authored probe `target` is a `<node>.<fact>` reference to an
828
+ // upstream emitted fact threaded on an incoming edge, rewrite the seeded probe's `target` to the
829
+ // OBSERVED value via FEEL `context put`, so the canonical `agent → connector[converge-merge] →
830
+ // wait[pr, merged]` shape polls the PR the agent opened with NO hardcoded literal. A plain literal
831
+ // target (a real `owner/repo#N` is never `<node>.<fact>`-shaped) can't match a bound ref, so it
832
+ // passes through unchanged. Guarded (`is defined`) so an as-yet-unobserved fact keeps the
833
+ // authored value rather than raising a FEEL error.
834
+ const boundTarget = boundInputs.find((b) => `${b.fromNode}.${b.fact}` === node.wait.target);
835
+ if (boundTarget) {
836
+ const varName = `${boundTarget.producerElement}_${boundTarget.fact}`;
837
+ const probeRef = cfg("probe").slice(1);
838
+ inputs.push({
839
+ source: `=context put(${probeRef}, "target", if (is defined(${varName})) then ${varName} else ${probeRef}.target)`,
840
+ target: "probe",
841
+ });
842
+ } else {
843
+ inputs.push({ source: cfg("probe"), target: "probe" });
844
+ }
828
845
  inputs.push({ source: cfg("probeTimeout"), target: "probeTimeout" });
829
846
  inputs.push({ source: cfg("probePollEvery"), target: "probePollEvery" });
830
847
  break;
848
+ }
831
849
  case "human":
832
850
  inputs.push({ source: cfg("escalationSlaTimeout"), target: "escalationSlaTimeout" });
833
851
  inputs.push({ source: cfg("escalationAssignee"), target: "escalationAssignee" });
@@ -983,7 +1001,7 @@ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>
983
1001
  const diagnosticInputs = [
984
1002
  { source: "=if (is defined(detail)) then detail else null", target: "probeDetail" },
985
1003
  { source: "=if (is defined(observed)) then observed else null", target: "observedReleases" },
986
- { source: `=nodeInputs.${el}.probe.target`, target: "probeTarget" },
1004
+ { source: `=if (is defined(probe.target)) then probe.target else nodeInputs.${el}.probe.target`, target: "probeTarget" },
987
1005
  { source: `=nodeInputs.${el}.probe.match`, target: "probeMatch" },
988
1006
  ];
989
1007
  return [
@@ -113,9 +113,11 @@ The closed set (extensible only by a deliberate ADR/PR, never by graph authors):
113
113
  > PR back to `converging`. `submitPr`'s own `prKey` idempotency additionally makes a resumed re-perform
114
114
  > double-safe on a still-live row. This retires the manual `land-*` human gate whose only job was "go run convergence
115
115
  > yourself" — the canonical shape is now `agent (opens PR) → connector[converge-merge] →
116
- > wait[pr, merged]` with no human node. The payload is `{ pr, convergeOnly?, dependsOn? }`; the MVP
117
- > sources `pr` as a literal (auto-emitting it from the `agent` node as a typed `pr` fact is a deferred
118
- > follow-up). Other connector targets remain the forward-declared stub.
116
+ > wait[pr, merged]` with no human node. The payload is `{ pr, convergeOnly?, dependsOn? }`; the `pr`
117
+ > may be a literal `owner/repo#N`, a `<node>.pr` fact reference late-bound from an upstream `agent`
118
+ > node's emitted `pr` fact, or omitted to auto-bind the single incoming `pr` fact (issue #548 —
119
+ > shipped: the connector resolves it via `resolveConvergePr`, and the `wait[pr]` target is late-bound
120
+ > in the compiler via FEEL `context put`). Other connector targets remain the forward-declared stub.
119
121
 
120
122
  Crucially, **execution stays engine-native**: each node kind is a real, already-deployed
121
123
  sub-process / call activity (`readiness-gate`, a user task, the implementation task, a connector
@@ -597,28 +597,41 @@ into the PR's merge-stage dependency set. The enrollment is idempotent (the conn
597
597
  at-least-once dedupe fence **plus** `submitPr`'s own `prKey` idempotency), so a graph resume /
598
598
  redelivery never double-enrolls.
599
599
 
600
- **Canonical shape** — the agent opens the PR, the connector enrolls it, and a `wait[pr, merged]`
601
- gate binds `mergedSha` when it lands, with **no human node**:
600
+ **Canonical shape** — the agent opens the PR, emits it as a typed **`pr` fact**, and the connector
601
+ and `wait[pr, merged]` gate **late-bind** that fact (no hardcoded PR number, no human node). The
602
+ author never knows the PR number at compose time, so reference it by fact:
602
603
 
603
604
  ```json
604
605
  {
605
606
  "name": "open → converge+merge → wait merged",
606
607
  "nodes": [
607
608
  { "id": "open", "kind": "agent",
608
- "agent": { "jobType": "senior:feature", "prompt": "Implement the change in acme/repo and open a PR." } },
609
+ "agent": { "jobType": "senior:feature", "prompt": "Implement the change in acme/repo and open a PR." },
610
+ "emits": [ { "name": "pr", "type": "pr" } ] },
609
611
  { "id": "land", "kind": "connector",
610
- "connector": { "target": "converge-merge", "payload": { "pr": "acme/repo#123" } } },
612
+ "connector": { "target": "converge-merge", "payload": { "pr": "open.pr" } } },
611
613
  { "id": "merged", "kind": "wait",
612
- "wait": { "kind": "pr", "target": "acme/repo#123", "match": { "prState": "merged" }, "onTimeout": "escalate" } }
614
+ "wait": { "kind": "pr", "target": "open.pr", "match": { "prState": "merged" }, "onTimeout": "escalate" } }
613
615
  ],
614
616
  "edges": [
615
- { "from": "open", "to": "land" },
616
- { "from": "land", "to": "merged" }
617
+ { "from": "open.pr", "to": "land" },
618
+ { "from": "open.pr", "to": "merged" }
617
619
  ]
618
620
  }
619
621
  ```
620
622
 
621
- > **Follow-up (not shipped):** the MVP sources the connector's `pr` as a **literal**. Auto-emitting
622
- > the opened PR from the `agent` node as a typed `pr` fact (so the connector/`wait` bind it instead of
623
- > a literal) is a later slice not required for the graph above.
623
+ The `pr` fact is threaded along the **fact-qualified edges** (`open.pr land`, `open.pr merged`)
624
+ those edges are what carry the observed PR into each consumer, so they are **required** when you
625
+ reference `open.pr` (the validator rejects a reference with no threading edge, `unbound-pr`). Three
626
+ ways to name the target PR:
627
+
628
+ - **fact reference** `"<node>.pr"` — late-bound from the upstream `agent`'s emitted `pr` fact (the
629
+ shape above). The referenced fact must be declared **`pr`-typed** and threaded on an incoming edge.
630
+ - **omitted** (connector only) — `payload` without a `pr` auto-binds the **single** incoming `pr`
631
+ fact, so `"connector": { "target": "converge-merge" }` works when exactly one `pr` fact flows in.
632
+ - **literal** `"owner/repo#N"` — still accepted for a PR you already know (a real ref is never
633
+ `<node>.pr`-shaped, so it never collides with a reference).
634
+
635
+ `senior:feature` already returns the PR it opened, so declaring `emits: [{ "name": "pr", "type": "pr" }]`
636
+ on the agent node is all it takes to publish it (issue #548).
624
637
 
@@ -155,6 +155,36 @@ describe("delivery-graph runner — engine-native execution (S4)", () => {
155
155
  assert.ok(takenFlows(app).some((f) => f.endsWith("->End")), "the graph reached its End event");
156
156
  });
157
157
 
158
+ test("#548 wait target LATE-BINDS from an upstream fact via FEEL `context put` (engine-native)", async () => {
159
+ const app = track(await boot(freshDir()));
160
+
161
+ // The agent emits a `cmd` fact whose OBSERVED value is the deterministic green probe command
162
+ // ("true"). The downstream wait references that fact as its probe `target` (`a.cmd`) — the compiler
163
+ // rewrites the seeded probe's target via FEEL `context put`, so the probe polls the LATE-BOUND
164
+ // value, not a hardcoded literal. This is the exact mechanism the canonical `agent →
165
+ // connector[converge-merge] → wait[pr, merged]` shape uses to poll the PR the agent opened (#548).
166
+ await app.engine.registerWorker("senior:demo", async () => ({ cmd: "true" }));
167
+
168
+ const graph: DeliveryGraph = {
169
+ name: "e2e wait late-bind",
170
+ nodes: [
171
+ { id: "a", kind: "agent", agent: { jobType: "senior:demo" }, emits: [{ name: "cmd", type: "string" }] },
172
+ { id: "w", kind: "wait", wait: { kind: "command", target: "a.cmd", poll: { everyMs: 5, backoff: "fixed" } } },
173
+ ],
174
+ edges: [{ from: "a.cmd", to: "w" }],
175
+ };
176
+
177
+ const run = await runDeliveryGraph(app.engine, graph, { probeTimeout: "PT2S" });
178
+ assert.ok(run.ok, `graph should deploy + run, got ${JSON.stringify(run)}`);
179
+ await app.settle();
180
+
181
+ // The wait's probe ran the LATE-BOUND command "true" (green) — had `context put` not resolved the
182
+ // `a.cmd` reference to "true", the probe would have run the literal "a.cmd" (a probe escape the
183
+ // hermetic seam records + fails on), and the gate would never have resolved. The graph reaching End
184
+ // proves the wait released its ready branch off the late-bound target.
185
+ assert.ok(takenFlows(app).some((f) => f.endsWith("->End")), "the late-bound wait resolved and the graph reached End");
186
+ });
187
+
158
188
  test("resume never double-fires: an at-least-once redelivery of the connector dedupes", async () => {
159
189
  const app = track(await boot(freshDir()));
160
190
  // The connector fired once above's-style; here prove the idempotency directly against the ledger a
package/openapi.yaml CHANGED
@@ -1361,10 +1361,13 @@ components:
1361
1361
  description: The fact's identifier, referenced downstream as `<nodeId>.<name>`. Must be unique within the node.
1362
1362
  type:
1363
1363
  type: string
1364
- enum: [string, number, boolean, artifact, version, url]
1364
+ enum: [string, number, boolean, artifact, version, url, pr]
1365
1365
  description: >-
1366
1366
  The fact's declared type. `artifact` is a `pkg@version` handle, `version` a bare version
1367
- string, `url` a location — mirrors the values `capability`/`pr` probes late-bind.
1367
+ string, `url` a location — mirrors the values `capability`/`pr` probes late-bind. `pr`
1368
+ (issue #548) is a PR reference (`owner/repo#N`) an `agent` node emits for the PR it opened,
1369
+ so a downstream `connector[converge*]`/`wait[pr]` node LATE-BINDS its target PR from the
1370
+ fact instead of a hardcoded literal.
1368
1371
  description:
1369
1372
  type: string
1370
1373
  maxLength: 512
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.141.0",
3
+ "version": "0.142.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",
@@ -123,34 +123,52 @@ test("readConnectorInput: an array payload is rejected (arrays are not plain obj
123
123
 
124
124
  test("readConvergeInput: parses pr; convergeOnly defaults from the target; dependsOn is optional", () => {
125
125
  // `converge-merge` drives the merge loop → convergeOnly defaults false.
126
- const merge = readConvergeInput("converge-merge", { pr: "owner/repo#7" });
126
+ const merge = readConvergeInput("converge-merge", { pr: "owner/repo#7" }, null);
127
127
  assertEquals(merge.parsed.prKey, "owner/repo#7");
128
128
  assertEquals(merge.convergeOnly, false);
129
129
  assertEquals(merge.dependsOn, []);
130
130
  // `converge` is review-only → convergeOnly defaults true.
131
- const conv = readConvergeInput("converge", { pr: "owner/repo#7" });
131
+ const conv = readConvergeInput("converge", { pr: "owner/repo#7" }, null);
132
132
  assertEquals(conv.convergeOnly, true);
133
133
  });
134
134
 
135
135
  test("readConvergeInput: an explicit payload.convergeOnly overrides the target default; dependsOn threads through", () => {
136
- const r = readConvergeInput("converge-merge", { pr: "owner/repo#7", convergeOnly: true, dependsOn: ["owner/repo#5", 42] as unknown as string[] });
136
+ const r = readConvergeInput("converge-merge", { pr: "owner/repo#7", convergeOnly: true, dependsOn: ["owner/repo#5", 42] as unknown as string[] }, null);
137
137
  assertEquals(r.convergeOnly, true, "the explicit boolean wins over the target default");
138
138
  assertEquals(r.dependsOn, ["owner/repo#5"], "non-string dependsOn entries are dropped");
139
139
  });
140
140
 
141
141
  test("readConvergeInput: a missing / unparseable pr fails CLOSED (a converge connector with no target PR is meaningless)", () => {
142
- assertThrows(() => readConvergeInput("converge-merge", null), Error, "payload.pr");
143
- assertThrows(() => readConvergeInput("converge-merge", {}), Error, "payload.pr");
144
- assertThrows(() => readConvergeInput("converge", { pr: "not-a-pr" }), Error, "payload.pr");
142
+ assertThrows(() => readConvergeInput("converge-merge", null, null), Error, "payload.pr");
143
+ assertThrows(() => readConvergeInput("converge-merge", {}, null), Error, "payload.pr");
144
+ assertThrows(() => readConvergeInput("converge", { pr: "not-a-pr" }, null), Error, "payload.pr");
145
145
  });
146
146
 
147
147
  test("readConvergeInput: an unparseable pr whose value is not JSON-serializable still fails CLOSED with the intended error (not a serializer TypeError)", () => {
148
148
  // `p.pr` is user-controlled payload data; a BigInt (or a circular object) makes JSON.stringify
149
149
  // throw, which must NOT mask the intended "requires payload.pr" error.
150
- assertThrows(() => readConvergeInput("converge", { pr: 10n as unknown as string }), Error, "payload.pr");
150
+ assertThrows(() => readConvergeInput("converge", { pr: 10n as unknown as string }, null), Error, "payload.pr");
151
151
  const circular: Record<string, unknown> = {};
152
152
  circular.self = circular;
153
- assertThrows(() => readConvergeInput("converge", { pr: circular as unknown as string }), Error, "payload.pr");
153
+ assertThrows(() => readConvergeInput("converge", { pr: circular as unknown as string }, null), Error, "payload.pr");
154
+ });
155
+
156
+ test("readConvergeInput: #548 late-binds the PR from an upstream agent's emitted `pr` fact (no literal needed)", () => {
157
+ const bound = [{ from: "open", name: "pr", value: "owner/repo#42" }];
158
+ // OMITTED payload.pr → binds the single incoming `pr` fact.
159
+ assertEquals(readConvergeInput("converge-merge", null, bound).parsed.prKey, "owner/repo#42");
160
+ assertEquals(readConvergeInput("converge-merge", {}, bound).parsed.prKey, "owner/repo#42");
161
+ // EXPLICIT reference form → resolves that specific threaded fact.
162
+ assertEquals(readConvergeInput("converge", { pr: "open.pr" }, bound).parsed.prKey, "owner/repo#42");
163
+ // A LITERAL still wins as-is even when a bound fact is present (never `<node>.<fact>`-shaped).
164
+ assertEquals(readConvergeInput("converge", { pr: "owner/repo#9" }, bound).parsed.prKey, "owner/repo#9");
165
+ });
166
+
167
+ test("readConvergeInput: #548 a dangling reference (no matching bound fact) fails CLOSED", () => {
168
+ // "open.pr" is reference-shaped but nothing threaded it → falls through to a literal parse, which
169
+ // rejects (it is not an `owner/repo#N`), so a mis-wired graph never silently enrolls a junk PR.
170
+ assertThrows(() => readConvergeInput("converge-merge", { pr: "open.pr" }, []), Error, "payload.pr");
171
+ assertThrows(() => readConvergeInput("converge-merge", { pr: "open.pr" }, [{ from: "other", name: "pr", value: "o/r#1" }]), Error, "payload.pr");
154
172
  });
155
173
 
156
174
  test("safeStringify: always returns a string, even for values JSON.stringify serializes to undefined (Symbol/undefined/function)", () => {
@@ -15,6 +15,7 @@ import {
15
15
  convergeOnlyForTarget,
16
16
  dispatchConnector,
17
17
  isConvergeTarget,
18
+ resolveConvergePr,
18
19
  } from "../../app/deliveryConnector.ts";
19
20
  import { isPrSettled, MAX_ROUNDS, type ParsedPr, parsePr, submitPr } from "../../app/service.ts";
20
21
 
@@ -81,19 +82,26 @@ export function safeStringify(value: unknown): string {
81
82
  * `convergeOnly` DEFAULTS from the target (`converge` → review-only `true`; `converge-merge` → drive
82
83
  * the merge loop `false`) and may be overridden per-dispatch by an explicit boolean. `dependsOn` is an
83
84
  * optional list of PR refs unioned into the enrolled PR's merge-stage dependency set (only non-string
84
- * entries are dropped; `submitPr` itself ignores unparseable refs). Exported for unit coverage — the
85
- * MVP sources `pr` as a literal (identical to how the `wait: pr` node targets a known PR), so no new
86
- * fact plumbing is needed to ship. */
85
+ * entries are dropped; `submitPr` itself ignores unparseable refs). Exported for unit coverage.
86
+ *
87
+ * `pr` may be sourced three ways (issue #548), resolved by {@link resolveConvergePr} against the
88
+ * threaded `boundFacts` BEFORE parsing: a LITERAL `owner/repo#N`, an explicit fact REFERENCE
89
+ * (`payload.pr: "<upstreamNode>.pr"`), or OMITTED — late-bound from the single incoming `pr` fact an
90
+ * upstream `agent` node emitted for the PR it opened. This is what lets the canonical
91
+ * `agent → connector[converge-merge] → wait` shape carry no hardcoded PR number. */
87
92
  export function readConvergeInput(
88
93
  target: string,
89
94
  payload: Record<string, unknown> | null,
95
+ boundFacts: readonly BoundFact[] | null,
90
96
  ): { parsed: ParsedPr; convergeOnly: boolean; dependsOn: string[] } {
91
97
  const p = payload ?? {};
92
- const parsed = parsePr(p.pr);
98
+ const prValue = resolveConvergePr(p.pr, boundFacts ?? []);
99
+ const parsed = parsePr(prValue);
93
100
  if (!parsed) {
94
101
  throw new Error(
95
- `delivery-connector: '${target}' target requires payload.pr as a parseable "owner/repo#N" ` +
96
- `(got ${safeStringify(p.pr ?? null)})`,
102
+ `delivery-connector: '${target}' target requires a target PR — a literal "owner/repo#N" in ` +
103
+ `payload.pr, an upstream-fact reference (payload.pr: "<node>.pr"), or a threaded \`pr\` fact ` +
104
+ `bound from an upstream \`agent\` node (got ${safeStringify(prValue ?? null)})`,
97
105
  );
98
106
  }
99
107
  const convergeOnly = typeof p.convergeOnly === "boolean" ? p.convergeOnly : convergeOnlyForTarget(target);
@@ -106,7 +114,7 @@ const handler: AppJobHandler<In, ConnectorDispatchResult> = async (job, app) =>
106
114
  // A `converge`/`converge-merge` target enrolls a PR into the shared convergence (+ merge) loop.
107
115
  // Parse + validate its payload BEFORE claiming a ledger row, so a misconfigured converge node (no
108
116
  // parseable `pr`) fails CLOSED without writing a junk dispatch row it could never act on.
109
- const converge = isConvergeTarget(target) ? readConvergeInput(target, payload) : null;
117
+ const converge = isConvergeTarget(target) ? readConvergeInput(target, payload, boundFacts) : null;
110
118
  const dedupeKey = connectorDedupeKey({
111
119
  dedupeKey: job.variables.dedupeKey ?? null,
112
120
  processInstanceKey: job.processInstanceKey ?? null,