@nanobpm/nano-workforce 0.148.2 → 0.149.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.149.0](https://github.com/nanobpm/nano-workforce/compare/v0.148.2...v0.149.0) (2026-08-27)
2
+
3
+ ### Features
4
+
5
+ * **delivery-graph:** epic 'fully merged' wait kind + dispatch fact-bound wait[pr] targets ([#572](https://github.com/nanobpm/nano-workforce/issues/572)) ([91e9ad8](https://github.com/nanobpm/nano-workforce/commit/91e9ad8f5d1e7079859937e245c01ceb0497be84)), closes [#570](https://github.com/nanobpm/nano-workforce/issues/570) [#548](https://github.com/nanobpm/nano-workforce/issues/548) [#568](https://github.com/nanobpm/nano-workforce/issues/568) [#570](https://github.com/nanobpm/nano-workforce/issues/570) [#568](https://github.com/nanobpm/nano-workforce/issues/568) [#548](https://github.com/nanobpm/nano-workforce/issues/548)
6
+
1
7
  ## [0.148.2](https://github.com/nanobpm/nano-workforce/compare/v0.148.1...v0.148.2) (2026-08-27)
2
8
 
3
9
  ### Bug Fixes
@@ -152,6 +152,57 @@ test("late-binding: a fact-qualified edge threads a boundFacts input into the co
152
152
  assert(boundInput, `boundFacts is a single-quoted FEEL list literal, got: ${r.bpmn.match(/source='[^']*' target="boundFacts"/)?.[0] ?? r.bpmn.match(/source="[^"]*" target="boundFacts"/)?.[0]}`);
153
153
  });
154
154
 
155
+ test("#568 epic wait kind: an `epic` wait node compiles to a readiness gate and seeds its probe verbatim", async () => {
156
+ // A new `wait` kind `epic` (issue #568) gates a graph on an nwf plan-fanout epic reaching "fully
157
+ // merged", keyed by its planKey. It reuses the SAME readiness-gate machinery as `pr` (Decision 3 —
158
+ // never a second wait loop), so it compiles through with no BPMN branch: the node delegates to
159
+ // `pr.readiness-probe` and seeds `nodeInputs.<el>.probe` (the whole descriptor) verbatim.
160
+ const graph = {
161
+ name: "epic gate",
162
+ nodes: [
163
+ { id: "gate-epic", kind: "wait", wait: { kind: "epic", target: "nanobpm/nano-ide#488", match: { epicState: "merged" }, onTimeout: "escalate" } },
164
+ { id: "start-b", kind: "agent", agent: { jobType: "senior:feature", prompt: "implement #567" } },
165
+ ],
166
+ edges: [{ from: "gate-epic", to: "start-b" }],
167
+ };
168
+ const r = await compileOk(graph);
169
+ const types = new Set([...r.bpmn.matchAll(/<zeebe:taskDefinition type="([^"]+)"/g)].map((m) => m[1]));
170
+ assert(types.has("pr.readiness-probe"), "the epic wait delegates to the shared readiness-probe gate");
171
+ // The epic wait's element (n0 — sorted node id `gate-epic` precedes `start-b`) seeds its probe.
172
+ assert(r.bpmn.includes('source="=nodeInputs.n0.probe" target="probe"'), "the epic wait seeds its probe descriptor verbatim");
173
+ });
174
+
175
+ test("#572 epic prCount emit: a wait[epic] node's `prCount` fact publishes from the epic-match `prCount` bind, not the probe detail", async () => {
176
+ // matchEpic binds `{ prCount }` on a fully-merged match, but the probe-loop only lifted
177
+ // ready/detail/resolvedArtifact/mergedSha/observed out of the gate scope, and factSourceVar mapped
178
+ // a non-mergedSha/non-artifact fact to `detail` — so a documented `emits: [{name:"prCount"}]` wait
179
+ // node published the detail STRING instead of the count. Thread prCount end-to-end (Copilot #572).
180
+ const graph = {
181
+ name: "epic count gate",
182
+ nodes: [
183
+ {
184
+ id: "gate-epic",
185
+ kind: "wait",
186
+ wait: { kind: "epic", target: "nanobpm/nano-ide#488", match: { epicState: "merged" }, onTimeout: "escalate" },
187
+ emits: [{ name: "prCount", type: "number" }],
188
+ },
189
+ { id: "start-b", kind: "agent", agent: { jobType: "senior:feature", prompt: "implement #567" } },
190
+ ],
191
+ edges: [{ from: "gate-epic.prCount", to: "start-b" }],
192
+ };
193
+ const r = await compileOk(graph);
194
+ // The probe-loop subProcess must lift `prCount` out of the gate scope (alongside mergedSha).
195
+ assert(
196
+ r.bpmn.includes('source="=if (is defined(prCount)) then prCount else null" target="prCount"'),
197
+ "the probe loop lifts the prCount bind out of the gate scope",
198
+ );
199
+ // The wait node's emit output must SOURCE prCount from the `prCount` variable, not `detail`.
200
+ assert(
201
+ r.bpmn.includes('source="=if (is defined(prCount)) then prCount else null" target="n0_prCount"'),
202
+ "the prCount emit publishes from the prCount bind variable, not the probe detail",
203
+ );
204
+ });
205
+
155
206
  test("#499 human context: the human user-task seeds prompt/nodeId/emit context so its generic form is not contextless", async () => {
156
207
  const r = await compileOk(RELEASE_RUNBOOK);
157
208
  // The human node's subProcess ioMapping must thread the authored prompt + node identity + emit
@@ -151,9 +151,10 @@ function byCodeUnit(a: string, b: string): number {
151
151
 
152
152
  /** The engine variable a producer node's OUTPUT mapping reads to publish a declared emitted `fact`
153
153
  * (S4 late-binding). Each node kind's real body exposes the observed value under a canonical name:
154
- * • `wait` (readiness-gate) — a `mergedSha` fact reads the merge oid; an `artifact` fact reads the
155
- * `resolvedArtifact` bind (mirroring the `capability`/`pr` probe binds); anything else reads the
156
- * probe's `detail`.
154
+ * • `wait` (readiness-gate) — a `mergedSha` fact reads the merge oid; a `prCount` fact reads the
155
+ * epic-match `prCount` bind (how many slice PRs a `wait[epic]` landed); an `artifact` fact reads
156
+ * the `resolvedArtifact` bind (mirroring the `capability`/`pr` probe binds); anything else reads
157
+ * the probe's `detail`.
157
158
  * • `human` (delivery-human) — an `artifact` fact reads `humanEmitArtifact`; anything else reads
158
159
  * `humanEmitValue` (the generic typed-emit form's captured value).
159
160
  * • `agent`/`connector` — the body's job worker returns the value under the fact's own name.
@@ -161,7 +162,13 @@ function byCodeUnit(a: string, b: string): number {
161
162
  function factSourceVar(kind: DeliveryNode["kind"], fact: DeliveryFact): string {
162
163
  switch (kind) {
163
164
  case "wait":
164
- return fact.name === "mergedSha" ? "mergedSha" : fact.type === "artifact" ? "resolvedArtifact" : "detail";
165
+ return fact.name === "mergedSha"
166
+ ? "mergedSha"
167
+ : fact.name === "prCount"
168
+ ? "prCount"
169
+ : fact.type === "artifact"
170
+ ? "resolvedArtifact"
171
+ : "detail";
165
172
  case "human":
166
173
  return fact.type === "artifact" ? "humanEmitArtifact" : "humanEmitValue";
167
174
  case "agent":
@@ -1013,6 +1020,7 @@ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>
1013
1020
  ' <zeebe:output source="=if (is defined(detail)) then detail else null" target="detail" />',
1014
1021
  ' <zeebe:output source="=if (is defined(resolvedArtifact)) then resolvedArtifact else null" target="resolvedArtifact" />',
1015
1022
  ' <zeebe:output source="=if (is defined(mergedSha)) then mergedSha else null" target="mergedSha" />',
1023
+ ' <zeebe:output source="=if (is defined(prCount)) then prCount else null" target="prCount" />',
1016
1024
  ' <zeebe:output source="=if (is defined(observed)) then observed else null" target="observed" />',
1017
1025
  " </zeebe:ioMapping>",
1018
1026
  " </bpmn:extensionElements>",
@@ -362,3 +362,33 @@ test("runDeliveryGraph coerces a numeric engine processInstanceKey to a string h
362
362
  assertEquals(r.handle.processInstanceKey, "987654321");
363
363
  assertEquals(typeof r.handle.processInstanceKey, "string");
364
364
  });
365
+
366
+ test("the canonical `agent → converge-merge → wait[pr merged]` graph DISPATCHES with a fact-bound wait target (#570)", async () => {
367
+ // Regression for #570: a `wait[pr]` node whose `target` is a fact reference (`open.pr`, the #548
368
+ // late-binding shape the guide documents as canonical) COMPILED+staged but threw at dispatch —
369
+ // `buildNodeInput`'s wait case eagerly `parseProbe`'d the fact-ref target as a literal `owner/repo#N`
370
+ // and aborted the whole launch. It must now LAUNCH (the target is resolved at runtime by the
371
+ // readiness-probe worker), while a genuinely malformed literal still fails loudly (see the
372
+ // deliveryRunner sibling assertion + readiness.test.ts).
373
+ const graph: DeliveryGraph = {
374
+ name: "canonical land shape",
375
+ nodes: [
376
+ { id: "open", kind: "agent", agent: { jobType: "senior:feature", prompt: "open a PR" }, emits: [{ name: "pr", type: "pr" }] },
377
+ { id: "converge-merge", kind: "connector", connector: { target: "converge-merge", payload: { pr: "open.pr" } } },
378
+ { id: "merged", kind: "wait", wait: { kind: "pr", target: "open.pr", match: { prState: "merged" }, onTimeout: "escalate" } },
379
+ ],
380
+ edges: [
381
+ { from: "open.pr", to: "converge-merge" },
382
+ { from: "open.pr", to: "merged" },
383
+ ],
384
+ };
385
+ // Dispatch through the full launch path (prepare → deploy → createInstance). Before the fix this
386
+ // threw synchronously inside buildNodeInput; now it launches.
387
+ const engine = {
388
+ deployResources: async () => [],
389
+ createInstance: async () => ({ processInstanceKey: "555" }),
390
+ };
391
+ const r = await runDeliveryGraph(engine, graph);
392
+ assert(r.ok, `expected the canonical fact-bound wait[pr] graph to launch, got ${JSON.stringify(r)}`);
393
+ assertEquals(r.handle.processInstanceKey, "555");
394
+ });
@@ -276,7 +276,7 @@ function buildNodeInput(
276
276
  return { jobType: node.agent.jobType, appendPrompt: renderIdempotencyPreamble() + basePrompt + renderEmitContract(emits), timeout: isoDuration(node.agent.timeout, ctx.nodeTimeout) };
277
277
  }
278
278
  case "wait": {
279
- const probe = parseProbe(node.wait);
279
+ const probe = parseProbe(node.wait, { allowLateBoundTarget: true });
280
280
  // Only a VALID, positive per-node budget overrides the run level. Match the `>= 1` predicate
281
281
  // `readinessTimeout`/`readinessPollEvery` apply internally, rather than a bare JS-truthiness
282
282
  // check on `poll.timeoutMs`/`everyMs`: a negative (`-1`) value is truthy, so a truthiness gate
@@ -18,6 +18,7 @@ import {
18
18
  MAX_EVERY_MS,
19
19
  matchCapability,
20
20
  matchCommand,
21
+ matchEpic,
21
22
  matchGithubCheck,
22
23
  matchHttp,
23
24
  matchNpm,
@@ -28,6 +29,10 @@ import {
28
29
  normalizePoll,
29
30
  parseProbe,
30
31
  parsePrTarget,
32
+ epicLineageUrl,
33
+ type EpicObservation,
34
+ isFactRefTarget,
35
+ parseEpicLineage,
31
36
  summariseCapabilityCandidates,
32
37
  parsePrView,
33
38
  parseReleases,
@@ -468,6 +473,141 @@ test("parseProbe: a valid pr probe round-trips its prState", () => {
468
473
  assertEquals(p.match?.prState, "mergeable");
469
474
  });
470
475
 
476
+ // ── #570: a fact-bound pr/epic target DISPATCHES (late-binding), a malformed literal still fails ──
477
+ test("parseProbe: a FACT-BOUND pr target (`<node>.<fact>`) parses (resolved at dispatch, not a literal here) — #570", () => {
478
+ // The documented canonical `agent → converge-merge → wait[pr merged]` shape wires the wait's
479
+ // `target` to the upstream node's emitted `pr` fact (`open.pr`). It is NOT an `owner/repo#N`
480
+ // literal at parse/dispatch time — the compiler rewrites it to the OBSERVED PR — so parseProbe must
481
+ // accept it (WITH the delivery-graph opt-in) rather than throw and abort the whole dispatch.
482
+ const p = parseProbe({ kind: "pr", target: "open.pr", match: { prState: "merged" } }, { allowLateBoundTarget: true });
483
+ assertEquals(p.kind, "pr");
484
+ assertEquals(p.target, "open.pr");
485
+ });
486
+
487
+ test("parseProbe: a fact-bound pr target is REJECTED without the delivery-graph opt-in (non-delivery-graph surfaces have no resolver, must fail loudly at submit)", () => {
488
+ // `app/featureReadiness.ts` parses probes with NO late-binding compiler rewrite, so a fact-ref
489
+ // target there could never resolve. Default-off keeps the loud submit-time failure (Copilot #572).
490
+ assertThrows(() => parseProbe({ kind: "pr", target: "open.pr", match: { prState: "merged" } }), Error, "owner/repo#<number>");
491
+ });
492
+
493
+ test("parseProbe: a genuinely malformed (dot-free) pr literal still throws — #570 keeps the loud failure", () => {
494
+ assertThrows(() => parseProbe({ kind: "pr", target: "foo" }), Error, "owner/repo#<number>");
495
+ });
496
+
497
+ test("isFactRefTarget: distinguishes a `<node>.<fact>` reference from a literal handle", () => {
498
+ assert(isFactRefTarget("open.pr"), "a dotted, hash-free ref is a fact reference");
499
+ assert(isFactRefTarget("converge-merge.mergedSha"), "a hyphenated node id with a fact");
500
+ assert(!isFactRefTarget("nanobpm/nano-workforce#377"), "a literal PR handle carries a #number");
501
+ assert(!isFactRefTarget("foo"), "a dot-free bare word is not a fact reference");
502
+ assert(!isFactRefTarget("open."), "a trailing dot is not a fact reference");
503
+ assert(!isFactRefTarget(".pr"), "a leading dot is not a fact reference");
504
+ });
505
+
506
+ // ── parseProbe: epic kind (#568 — planKey target + validated epicState) ───────────────────────────
507
+ test("parseProbe: accepts an owner/repo#NN epic probe (keyed by planKey) and defaults onTimeout to escalate", () => {
508
+ const p = parseProbe({ kind: "epic", target: "nanobpm/nano-ide#488", match: { epicState: "merged" } });
509
+ assertEquals(p.kind, "epic");
510
+ assertEquals(p.onTimeout, "escalate");
511
+ assertEquals(p.match?.epicState, "merged");
512
+ });
513
+
514
+ test("parseProbe: an epic probe whose target is no planKey throws (never resolvable)", () => {
515
+ assertThrows(() => parseProbe({ kind: "epic", target: "nanobpm/nano-ide" }), Error, "planKey");
516
+ });
517
+
518
+ test("parseProbe: an epic probe with an unknown match.epicState throws (mistyped state fails loudly)", () => {
519
+ assertThrows(
520
+ () => parseProbe({ kind: "epic", target: "o/r#1", match: { epicState: "landed" } }),
521
+ Error,
522
+ "invalid match.epicState",
523
+ );
524
+ });
525
+
526
+ test("parseProbe: a FACT-BOUND epic target parses WITH the delivery-graph opt-in (late-binding, same as pr)", () => {
527
+ const p = parseProbe({ kind: "epic", target: "plan.epic" }, { allowLateBoundTarget: true });
528
+ assertEquals(p.target, "plan.epic");
529
+ });
530
+
531
+ test("parseProbe: a fact-bound epic target is REJECTED without the delivery-graph opt-in (fails loudly at submit)", () => {
532
+ assertThrows(() => parseProbe({ kind: "epic", target: "plan.epic" }), Error, "planKey");
533
+ });
534
+
535
+ // ── matchEpic (pure — operates on an already-fetched epic observation) ────────────────────────────
536
+ function epicObs(over: Partial<EpicObservation> = {}): EpicObservation {
537
+ return { present: true, stage: "converging", active: true, prCount: 3, ...over };
538
+ }
539
+
540
+ test("matchEpic: green once the epic settles on a fully-merged aggregate, binding prCount", () => {
541
+ const r = matchEpic({ epicState: "merged" }, epicObs({ stage: "merged", active: false, prCount: 5 }));
542
+ assert(r.ready, "a fully-merged epic is ready");
543
+ assertEquals(r.bind?.prCount, "5");
544
+ });
545
+
546
+ test("matchEpic: not-ready while the epic is still in flight", () => {
547
+ const r = matchEpic({ epicState: "merged" }, epicObs({ stage: "converging", active: true }));
548
+ assert(!r.ready, "an in-flight epic is not ready");
549
+ });
550
+
551
+ test("matchEpic: a failed/abandoned epic is NOT ready (routes via onTimeout, never a false-green)", () => {
552
+ // A settled-but-not-merged epic (abandoned/resolved) never reports ready, so the bounded wait routes
553
+ // to onTimeout rather than hanging OR falsely proceeding — issue #568 acceptance.
554
+ for (const stage of ["abandoned", "resolved", "converged"]) {
555
+ const r = matchEpic({ epicState: "merged" }, epicObs({ stage, active: false }));
556
+ assert(!r.ready, `a '${stage}' epic must not be ready`);
557
+ assertStringIncludes(r.detail, "onTimeout");
558
+ }
559
+ });
560
+
561
+ test("matchEpic: an as-yet-unobserved planKey is not ready (keep waiting)", () => {
562
+ const r = matchEpic(undefined, { present: false, stage: "", active: false, prCount: 0 });
563
+ assert(!r.ready);
564
+ });
565
+
566
+ test("parseEpicLineage: reads the matching thread from a `/lineage?root=` response", () => {
567
+ const payload = { count: 1, threads: [{ rootRequestKey: "o/r#7", stage: "merged", active: false, prCount: 4 }] };
568
+ const obs = parseEpicLineage(payload, "o/r#7");
569
+ assertEquals(obs, { present: true, stage: "merged", active: false, prCount: 4 });
570
+ });
571
+
572
+ test("parseEpicLineage: an unknown/empty root yields an absent observation (not ready, no throw)", () => {
573
+ assertEquals(parseEpicLineage({ count: 0, threads: [] }, "o/r#9"), { present: false, stage: "", active: false, prCount: 0 });
574
+ assertEquals(parseEpicLineage("garbage", "o/r#9"), { present: false, stage: "", active: false, prCount: 0 });
575
+ });
576
+
577
+ test("epicLineageUrl: builds the app lineage read-model URL from the base + planKey", () => {
578
+ assertEquals(epicLineageUrl("o/r#12", "http://host:3000/"), "http://host:3000/app/api/lineage?root=o%2Fr%2312");
579
+ });
580
+
581
+ test("probeOnce: an epic probe reads the app lineage endpoint and goes green when fully merged", async () => {
582
+ const capture: { url?: string } = {};
583
+ const exec: ProbeExec = {
584
+ async httpGet(url) {
585
+ capture.url = url;
586
+ return { status: 200, body: JSON.stringify({ count: 1, threads: [{ rootRequestKey: "o/r#7", stage: "merged", active: false, prCount: 2 }] }) };
587
+ },
588
+ async run() {
589
+ return { code: 0, stdout: "", stderr: "" };
590
+ },
591
+ };
592
+ const probe = parseProbe({ kind: "epic", target: "o/r#7" });
593
+ const r = await probeOnce(probe, exec, { NANO_WORKFORCE_BASE_URL: "http://host:3000" });
594
+ assert(r.ready, "a fully-merged epic goes green");
595
+ assertStringIncludes(capture.url ?? "", "/app/api/lineage?root=o%2Fr%237");
596
+ });
597
+
598
+ test("probeOnce: an epic probe stays not-ready on a non-2xx lineage read (transient → keep waiting)", async () => {
599
+ const exec: ProbeExec = {
600
+ async httpGet() {
601
+ return { status: 503, body: "" };
602
+ },
603
+ async run() {
604
+ return { code: 0, stdout: "", stderr: "" };
605
+ },
606
+ };
607
+ const r = await probeOnce(parseProbe({ kind: "epic", target: "o/r#7" }), exec, {});
608
+ assert(!r.ready, "a failed read is not ready, never a throw");
609
+ });
610
+
471
611
  // ── matchPr (pure — operates on an already-fetched PR observation) ────────────────────────────────
472
612
  function prObs(over: Partial<PrObservation> = {}): PrObservation {
473
613
  return {
package/app/readiness.ts CHANGED
@@ -30,7 +30,7 @@ import { isoDuration, isoDurationToMs } from "./reviewWait.ts";
30
30
  * merge loop (`app/mergeProtocol.ts` / `app/github.ts`) into a first-class probe so "watch an
31
31
  * in-flight PR reach a declared state" is a graph edge, not logic buried in the merge-loop node
32
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";
33
+ export type ProbeKind = "http" | "command" | "npm" | "github-check" | "capability" | "pr" | "epic";
34
34
 
35
35
  /** The declared PR state a `pr` probe waits for (ADR 0005 §2). Each is a discovered fact about an
36
36
  * in-flight PR, read from its live GitHub state and evaluated by {@link matchPr}:
@@ -41,16 +41,26 @@ export type ProbeKind = "http" | "command" | "npm" | "github-check" | "capabilit
41
41
  * • `checks-green` — every head check run is complete with none failing (required checks green). */
42
42
  export type PrCondition = "ready" | "merged" | "mergeable" | "checks-green";
43
43
 
44
+ /** The declared epic (plan-fanout) state an `epic` probe waits for (issue #568). An nwf epic fans out
45
+ * many slice PRs across waves whose numbers are unknown at compose time, so this kind gates on the
46
+ * app's AGGREGATE ("all slices merged"), not a single PR. Both values mean the same terminal —
47
+ * "fully merged" — and are read from the app's lineage read-model (`stage === "merged"`, i.e. every
48
+ * opened slice landed): `merged` is the canonical name; `done` is accepted as a synonym for the plan
49
+ * aggregate reaching `done`. A failed/abandoned/mixed epic never reports this stage, so it never goes
50
+ * ready and the bounded wait routes to `onTimeout` rather than hanging. */
51
+ export type EpicCondition = "merged" | "done";
52
+
44
53
  /** What the gate does when the bounded wait times out (the engine timer arm fires). */
45
54
  export type OnTimeout = "escalate" | "fail" | "continue";
46
55
 
47
56
  /** Backoff policy between poll attempts. */
48
57
  export type Backoff = "fixed" | "exponential";
49
58
 
50
- const PROBE_KINDS: readonly ProbeKind[] = ["http", "command", "npm", "github-check", "capability", "pr"];
59
+ const PROBE_KINDS: readonly ProbeKind[] = ["http", "command", "npm", "github-check", "capability", "pr", "epic"];
51
60
  const ON_TIMEOUTS: readonly OnTimeout[] = ["escalate", "fail", "continue"];
52
61
  const BACKOFFS: readonly Backoff[] = ["fixed", "exponential"];
53
62
  const PR_CONDITIONS: readonly PrCondition[] = ["ready", "merged", "mergeable", "checks-green"];
63
+ const EPIC_CONDITIONS: readonly EpicCondition[] = ["merged", "done"];
54
64
 
55
65
  /** The per-kind readiness predicate. Every field is optional; each kind reads only the ones it
56
66
  * understands and applies a sensible default when a field is absent (see the matchers below). */
@@ -84,6 +94,10 @@ export interface ProbeMatch {
84
94
  /** pr: the declared PR state the probe waits for (default `merged`). One of {@link PrCondition} —
85
95
  * `ready` (out of draft), `merged`, `mergeable`, or `checks-green`. */
86
96
  readonly prState?: PrCondition;
97
+ /** epic: the declared epic (plan-fanout) aggregate state the probe waits for (default `merged`).
98
+ * One of {@link EpicCondition} — `merged`/`done` both mean "fully merged" (every opened slice
99
+ * landed). Issue #568. */
100
+ readonly epicState?: EpicCondition;
87
101
  }
88
102
 
89
103
  /** The poll cadence: how often to re-probe, how long to keep trying, and the backoff shape. */
@@ -195,8 +209,17 @@ function isRecord(v: unknown): v is Record<string, unknown> {
195
209
  * Throws a descriptive error on an unknown/missing `kind`, a blank `target`, an invalid
196
210
  * `onTimeout`/`backoff`, or a `credentialEnv` on a non-`http` kind — a malformed probe must fail
197
211
  * loudly at the worker, never silently wait forever (nor let a caller believe a subprocess probe is
198
- * authenticated when its credential is silently ignored). */
199
- export function parseProbe(raw: unknown): ReadinessProbe {
212
+ * authenticated when its credential is silently ignored).
213
+ *
214
+ * `opts.allowLateBoundTarget` opts a caller into accepting a fact-bound `<nodeId>.<fact>` target for
215
+ * the `pr`/`epic` kinds — the #548/#570 late-binding reference the delivery-graph compiler rewrites
216
+ * to the observed handle at dispatch. It is OFF by default: only the delivery-graph dispatch path
217
+ * (`app/deliveryRunner.ts`) sets it. Every other surface (e.g. feature-intake readiness in
218
+ * `app/featureReadiness.ts`) has no such compiler rewrite, so a fact-ref target there could never
219
+ * resolve — keeping it off means a mis-declared gate fails loudly at submit rather than degrading
220
+ * into a runtime timeout/escalation. */
221
+ export function parseProbe(raw: unknown, opts?: { allowLateBoundTarget?: boolean }): ReadinessProbe {
222
+ const allowLateBoundTarget = opts?.allowLateBoundTarget === true;
200
223
  if (!isRecord(raw)) throw new Error("readiness probe: descriptor must be an object");
201
224
  const kind = str(raw.kind).trim();
202
225
  if (!isProbeKind(kind)) {
@@ -233,12 +256,28 @@ export function parseProbe(raw: unknown): ReadinessProbe {
233
256
  }
234
257
  }
235
258
  // A pr edge whose target names no numeric PR id can never resolve — fail loudly at parse (mirroring
236
- // the capability ref guard) rather than surface it as a timeout much later. `owner/repo#123`.
237
- if (kind === "pr" && !parsePrTarget(target)) {
259
+ // the capability ref guard) rather than surface it as a timeout much later. `owner/repo#123`. A
260
+ // FACT-BOUND target (`<nodeId>.<fact>`, e.g. `open.pr`) is exempt: it is a #548 late-binding
261
+ // reference the compiler rewrites to the OBSERVED PR at dispatch and the readiness-probe worker
262
+ // resolves at runtime — it is legitimately not a literal here, so validating it as one would reject
263
+ // the documented canonical `agent → converge-merge → wait[pr merged]` shape (issue #570). A
264
+ // genuinely malformed literal (dot-free, e.g. `foo`) is not fact-ref-shaped, so it still fails. The
265
+ // exemption is gated on `allowLateBoundTarget`: a non-delivery-graph caller (default OFF) has no
266
+ // compiler rewrite, so a fact-ref target there can never resolve — it must fail loudly at submit.
267
+ if (kind === "pr" && !(allowLateBoundTarget && isFactRefTarget(target)) && !parsePrTarget(target)) {
238
268
  throw new Error(
239
269
  `readiness probe (pr): 'target' ('${target}') must be an 'owner/repo#<number>' PR reference (e.g. 'nanobpm/nano-workforce#377')`,
240
270
  );
241
271
  }
272
+ // An epic edge is keyed by the durable `planKey` (`owner/repo#NN`, the epic issue) — the stable
273
+ // business id, so a resubmit/replay still resolves (issue #568). Validate it as a literal planKey,
274
+ // exempting a fact-bound reference for the same #548 late-binding reason as `pr` above (and gated
275
+ // on the same `allowLateBoundTarget` opt-in, so a non-delivery-graph caller still fails loudly).
276
+ if (kind === "epic" && !(allowLateBoundTarget && isFactRefTarget(target)) && !parsePrTarget(target)) {
277
+ throw new Error(
278
+ `readiness probe (epic): 'target' ('${target}') must be an 'owner/repo#<number>' planKey (the epic issue, e.g. 'nanobpm/nano-workforce#374')`,
279
+ );
280
+ }
242
281
  const poll = isRecord(raw.poll) ? parsePoll(raw.poll) : undefined;
243
282
  const credentialEnv = str(raw.credentialEnv).trim() || undefined;
244
283
  if (credentialEnv !== undefined && !isEnvKey(credentialEnv)) {
@@ -292,9 +331,47 @@ function parseMatch(raw: Record<string, unknown>): ProbeMatch {
292
331
  package: str(raw.package).trim() || undefined,
293
332
  verifyCommand: str(raw.verifyCommand).trim() || undefined,
294
333
  prState: parsePrCondition(raw.prState),
334
+ epicState: parseEpicCondition(raw.epicState),
295
335
  };
296
336
  }
297
337
 
338
+ /** Narrow a raw `match.epicState` to an {@link EpicCondition}, throwing on a non-empty unknown value so
339
+ * a mistyped state fails loudly at parse rather than waiting forever. An absent/blank value yields
340
+ * undefined — {@link matchEpic} then applies the `merged` default. */
341
+ function parseEpicCondition(raw: unknown): EpicCondition | undefined {
342
+ const s = str(raw).trim();
343
+ if (s === "") return undefined;
344
+ if (!isEpicCondition(s)) {
345
+ throw new Error(`readiness probe (epic): invalid match.epicState '${s}' (expected one of ${EPIC_CONDITIONS.join(", ")})`);
346
+ }
347
+ return s;
348
+ }
349
+
350
+ function isEpicCondition(v: string): v is EpicCondition {
351
+ for (const c of EPIC_CONDITIONS) if (c === v) return true;
352
+ return false;
353
+ }
354
+
355
+ // A late-binding probe `target` is a `<nodeId>.<fact>` reference to an upstream node's emitted fact
356
+ // (#548) — the compiler rewrites it to the OBSERVED value at dispatch via a FEEL `context put`, and
357
+ // the readiness-probe worker resolves it at runtime, so it is legitimately NOT a literal
358
+ // `owner/repo#N` at parse time (issue #570). A literal PR/epic handle always carries a `#<number>` and
359
+ // never this dotted, hash-free shape, so the two are unambiguous. Splits on the LAST dot, mirroring
360
+ // the graph's `resolveFrom` (a node id MAY contain dots; a fact name — matched by FACT_REF_FACT — may
361
+ // not), so a dot-free malformed literal is not fact-ref-shaped and still fails its kind's validation.
362
+ const FACT_REF_NODE_ID = /^[A-Za-z_][A-Za-z0-9_.-]*$/;
363
+ const FACT_REF_FACT = /^[A-Za-z_][A-Za-z0-9_]*$/;
364
+
365
+ /** Whether `target` is a `<nodeId>.<fact>` late-binding fact reference (issue #548/#570) rather than a
366
+ * literal `owner/repo#<number>` handle. See the note above. */
367
+ export function isFactRefTarget(target: string): boolean {
368
+ const t = target.trim();
369
+ if (t === "" || t.includes("#")) return false;
370
+ const dot = t.lastIndexOf(".");
371
+ if (dot <= 0 || dot === t.length - 1) return false;
372
+ return FACT_REF_NODE_ID.test(t.slice(0, dot)) && FACT_REF_FACT.test(t.slice(dot + 1));
373
+ }
374
+
298
375
  /** Narrow a raw `match.prState` to a {@link PrCondition}, throwing on a non-empty unknown value so a
299
376
  * mistyped state (`"landed"` for `"merged"`) fails loudly at parse rather than waiting forever. An
300
377
  * absent/blank value yields undefined — `matchPr` then applies the `merged` default. */
@@ -693,6 +770,83 @@ export function prViewCommand(repo: string, number: string): string {
693
770
  return `gh pr view ${shellQuote(number)} --repo ${shellQuote(repo)} --json ${shellQuote("state,mergedAt,mergeStateStatus,statusCheckRollup,isDraft,headRefOid,mergeCommit")}`;
694
771
  }
695
772
 
773
+ // ── Epic / plan-fanout probe (issue #568 — gate a graph on an epic reaching "fully merged") ──────
774
+
775
+ /** A live epic (plan-fanout) observation, reduced to the fields {@link matchEpic} reads. An nwf epic
776
+ * fans out many slice PRs across waves, so "fully merged" is an AGGREGATE, read from the app's own
777
+ * lineage read-model (`/lineage?root=<planKey>`, {@link parseEpicLineage}) rather than any single PR.
778
+ * `stage`/`active` are the lineage thread's already-derived frontier: an epic reaches `stage:"merged"`
779
+ * (and `active:false`) EXACTLY when every opened slice landed (`app/lineage.ts`), while a
780
+ * failed/abandoned/mixed epic settles on `abandoned`/`resolved` — never `merged` — so it never goes
781
+ * ready and the bounded wait routes to `onTimeout`. Kept separate from I/O so the matcher stays
782
+ * pure/unit-testable, exactly like {@link PrObservation}. */
783
+ export interface EpicObservation {
784
+ /** Whether the planKey resolved to a known lineage thread at all (an as-yet-unknown/never-started
785
+ * epic yields `present:false` → not ready, keep waiting). */
786
+ readonly present: boolean;
787
+ /** The lineage thread's derived frontier stage (e.g. `converging`, `merged`, `resolved`). */
788
+ readonly stage: string;
789
+ /** Whether the arc still has an active frontier (false once every stage has settled). */
790
+ readonly active: boolean;
791
+ /** Count of slice PRs on the epic — bound downstream as a fact (parity with the `pr` kind's
792
+ * `mergedSha`), so a consumer can pin how many PRs the epic landed. */
793
+ readonly prCount: number;
794
+ }
795
+
796
+ /** Parse a raw `/lineage?root=<planKey>` response (already JSON-decoded) into an {@link EpicObservation}
797
+ * for `planKey`. The endpoint returns `{ count, threads: [thread] }` (or `threads: []` for an unknown
798
+ * root); this reads the thread whose `rootRequestKey` matches `planKey`. Tolerant: a malformed/empty
799
+ * payload yields an absent observation, so a transient read degrades to "not ready" (keep waiting),
800
+ * never a throw. */
801
+ export function parseEpicLineage(payload: unknown, planKey: string): EpicObservation {
802
+ const j = isRecord(payload) ? payload : {};
803
+ const threads = Array.isArray(j.threads) ? j.threads : [];
804
+ const key = planKey.trim();
805
+ const thread = threads.find((t) => isRecord(t) && str(t.rootRequestKey).trim() === key);
806
+ if (!isRecord(thread)) return { present: false, stage: "", active: false, prCount: 0 };
807
+ const prCount = num(thread.prCount);
808
+ return {
809
+ present: true,
810
+ stage: str(thread.stage).trim().toLowerCase(),
811
+ active: thread.active === true,
812
+ prCount: typeof prCount === "number" ? prCount : 0,
813
+ };
814
+ }
815
+
816
+ /** epic readiness (issue #568): does the observed epic satisfy the declared `match.epicState`
817
+ * (default `merged`)? PURE — it operates on an already-fetched {@link EpicObservation} and NEVER
818
+ * throws, so a transient/garbled read is simply "not ready yet". "Fully merged" means the lineage
819
+ * thread settled on `stage:"merged"` — every opened slice landed. A failed/abandoned/mixed epic
820
+ * settles on another terminal (`abandoned`/`resolved`/`converged`), so it stays not-ready and the
821
+ * bounded wait routes to `onTimeout` rather than hanging. A `merged` match binds `{ prCount }` so a
822
+ * downstream edge can pin how many slice PRs the epic landed (parity with the `pr` kind's
823
+ * `mergedSha`). Both `merged` and `done` map to the same "fully merged" terminal. */
824
+ export function matchEpic(match: ProbeMatch | undefined, epic: EpicObservation): ProbeResult {
825
+ const want: EpicCondition = match?.epicState ?? "merged";
826
+ if (!epic.present) return { ready: false, detail: `epic (${want}): planKey not observed yet (not ready)` };
827
+ if (epic.stage === "merged" && !epic.active) {
828
+ return { ready: true, detail: `epic fully merged (${epic.prCount} slices)`, bind: { prCount: String(epic.prCount) } };
829
+ }
830
+ const settled = !epic.active;
831
+ return {
832
+ ready: false,
833
+ detail: settled
834
+ ? `epic settled on '${epic.stage}' (not fully merged) — routing via onTimeout`
835
+ : `epic in flight (stage '${epic.stage || "unknown"}', not merged yet)`,
836
+ observed: `stage=${epic.stage || "unknown"} active=${epic.active} prCount=${epic.prCount}`,
837
+ };
838
+ }
839
+
840
+ /** Build the app's lineage read-model URL for an epic's `planKey`. The app mounts its OpenAPI paths
841
+ * under `/app/api` (mirroring `abandonUrl`/`blackboardUrl` in `app/blackboard.ts`), and `?root=`
842
+ * accepts a `plan_key`. `base` is the ONE `NANO_WORKFORCE_BASE_URL` env contract (never a second
843
+ * name), read through the typed schema — so an epic gate is observed over the SAME reachable base a
844
+ * remote fleet already uses for the abandon/blackboard hooks. */
845
+ export function epicLineageUrl(planKey: string, base: string): string {
846
+ const b = base.trim().replace(/\/+$/, "") || readEnvOr("NANO_WORKFORCE_BASE_URL");
847
+ return `${b}/app/api/lineage?root=${encodeURIComponent(planKey.trim())}`;
848
+ }
849
+
696
850
 
697
851
  // ── Single probe attempt (does I/O via the injected {@link ProbeExec}) ──────────────────────────
698
852
 
@@ -735,6 +889,17 @@ export async function probeOnce(
735
889
  if (out.code !== 0) return { ready: false, detail: "pr: gh pr view failed (not ready)" };
736
890
  return matchPr(probe.match, parsePrView(parseJson(out.stdout)));
737
891
  }
892
+ case "epic": {
893
+ // "Fully merged" is the app's AGGREGATE, not a GitHub read — observe it over the app's own
894
+ // lineage read-model (level-triggered, same poll machinery as `pr`). A fact-bound target that is
895
+ // still unresolved (`<node>.<fact>`) is not a literal planKey, so treat it as "not ready" and
896
+ // keep waiting rather than issue a malformed request.
897
+ if (!parsePrTarget(probe.target)) return { ready: false, detail: "epic: unresolved/unparseable planKey (not ready)" };
898
+ const url = epicLineageUrl(probe.target, readEnvOr("NANO_WORKFORCE_BASE_URL", "", env));
899
+ const resp = await exec.httpGet(url, { accept: "application/json" });
900
+ if (resp.status < 200 || resp.status >= 300) return { ready: false, detail: `epic: lineage read HTTP ${resp.status} (not ready)` };
901
+ return matchEpic(probe.match, parseEpicLineage(parseJson(resp.body), probe.target));
902
+ }
738
903
  }
739
904
  }
740
905
 
@@ -433,18 +433,29 @@ layer schedules, it does not re-implement execution):
433
433
  | kind | config | what it does | may `emits`? |
434
434
  |---|---|---|---|
435
435
  | `agent` | `agent: { jobType, prompt? }` | a worker runs an agent job type (the fan-out body). **Side-effecting.** | yes |
436
- | `wait` | `wait: <ReadinessProbe>` | a durable, bounded readiness probe — kind ∈ `http`, `command`, `npm`, `github-check`, `capability`, `pr`. Read-only. | yes (binds observed facts) |
436
+ | `wait` | `wait: <ReadinessProbe>` | a durable, bounded readiness probe — kind ∈ `http`, `command`, `npm`, `github-check`, `capability`, `pr`, `epic`. Read-only. | yes (binds observed facts) |
437
437
  | `human` | `human?: { formKey?, prompt? }` | a scheduled user task + form (the Tasks inbox, §3). Blocks dependents, SLA-bounded, answerable by a human **or** an agent. | yes |
438
438
  | `connector` | `connector: { target, dedupeKey?, payload? }` | an automated, side-effecting outbound action. Carries a `dedupeKey` (at-least-once safe). Two **real targets** ship today — **`converge`** and **`converge-merge`** (§9.4); other targets are a forward-declared stub. | yes |
439
439
 
440
440
  A **`wait` node's `wait` is a `ReadinessProbe` verbatim** (the same shape feature-run
441
441
  intake uses): `{ kind, target, onTimeout?, match?, poll? }`. The **`pr` kind** watches an
442
442
  in-flight PR — `target: "owner/repo#123"`, `match.prState ∈ ready|merged|mergeable|checks-green`
443
- (default `merged`) — and on a merged match binds `mergedSha` as an output fact.
443
+ (default `merged`) — and on a merged match binds `mergedSha` as an output fact. The **`epic`
444
+ kind** (issue #568) gates on an **nwf plan-fanout epic reaching "fully merged"** — `target:
445
+ "owner/repo#NN"` is the epic's durable **`planKey`** (the epic issue, *not* the engine
446
+ `processInstanceKey`, so a resubmit/replay still resolves), `match.epicState ∈ merged|done`
447
+ (default `merged`, both mean "every opened slice landed"). It observes the app's own aggregate
448
+ (the lineage read-model), so a **failed/abandoned/mixed** epic never reports merged and the
449
+ bounded wait routes to **`onTimeout`** rather than hanging; on a fully-merged match it binds
450
+ `prCount` (how many slice PRs landed) as an output fact. Both `pr` and `epic` targets may also
451
+ be a **`<nodeId>.<fact>` late-binding reference** the compiler resolves at dispatch (§9.4),
452
+ rather than a literal handle.
444
453
 
445
454
  A **typed fact** (`emits[]` entry) is `{ name, type, description? }` where
446
- `type ∈ string|number|boolean|artifact|version|url` (`artifact` = a `pkg@version` handle,
447
- `version` = a bare version). `name` matches `^[A-Za-z_][A-Za-z0-9_]*$` and is referenced
455
+ `type ∈ string|number|boolean|artifact|version|url|pr` (`artifact` = a `pkg@version` handle,
456
+ `version` = a bare version, `pr` = a PR reference `owner/repo#N` an `agent` node emits for the
457
+ PR it opened, late-bound by a downstream `wait[pr]`/`connector[converge*]` target — issue #548).
458
+ `name` matches `^[A-Za-z_][A-Za-z0-9_]*$` and is referenced
448
459
  downstream as `<nodeId>.<name>`. A "click done" human node or a pass-through node declares
449
460
  no facts.
450
461
 
@@ -635,3 +646,39 @@ ways to name the target PR:
635
646
  `senior:feature` already returns the PR it opened, so declaring `emits: [{ "name": "pr", "type": "pr" }]`
636
647
  on the agent node is all it takes to publish it (issue #548).
637
648
 
649
+ ### 9.5 Gate a graph on an epic reaching "fully merged" (`wait[epic]`)
650
+
651
+ Sometimes the thing you must wait for is not one PR but a **whole epic** — an nwf
652
+ `plan-fanout` that fans many slice PRs across waves whose numbers are unknown at compose
653
+ time. The **`wait` kind `epic`** (issue #568) gates on that epic reaching **"fully merged"**
654
+ (every opened slice landed), keyed by its durable **`planKey`** (`owner/repo#NN` — the epic
655
+ issue), so *"start feature B once epic A has fully landed"* is an automated edge, not a human
656
+ babysitting a `confirm` gate.
657
+
658
+ ```json
659
+ {
660
+ "name": "start #567 once epic #488 has fully merged",
661
+ "nodes": [
662
+ { "id": "gate-epic", "kind": "wait",
663
+ "wait": { "kind": "epic", "target": "nanobpm/nano-ide#488",
664
+ "match": { "epicState": "merged" }, "onTimeout": "escalate" },
665
+ "emits": [ { "name": "prCount", "type": "number" } ] },
666
+ { "id": "start-b", "kind": "agent",
667
+ "agent": { "jobType": "senior:feature", "prompt": "Implement nanobpm/nano-workforce#567 and open a PR." } }
668
+ ],
669
+ "edges": [ { "from": "gate-epic", "to": "start-b" } ]
670
+ }
671
+ ```
672
+
673
+ Semantics:
674
+
675
+ - **`target` is the `planKey`** (`owner/repo#NN`, the epic issue) — the *stable business id*,
676
+ not the engine `processInstanceKey` (`64200`), so a resubmit/replay still resolves.
677
+ - **`match.epicState`** is `merged` (default) or its synonym `done` — both mean "every opened
678
+ slice landed". The gate reads the app's own **aggregate** (the lineage read-model over
679
+ `NANO_WORKFORCE_BASE_URL`), so it is level-triggered like `pr` (no missed edge).
680
+ - **A failed/abandoned/mixed epic never reports merged**, so it never falsely releases the
681
+ gate; the **bounded** wait elapses and routes via **`onTimeout`** (`escalate`/`continue`) —
682
+ it does **not** hang.
683
+ - On a fully-merged match it binds **`prCount`** (how many slice PRs the epic landed) as an
684
+ output fact, so a downstream node can consume it (parity with the `pr` kind's `mergedSha`).
package/openapi.yaml CHANGED
@@ -1245,12 +1245,12 @@ components:
1245
1245
  properties:
1246
1246
  kind:
1247
1247
  type: string
1248
- enum: [http, command, npm, github-check, capability, pr]
1249
- 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).
1248
+ enum: [http, command, npm, github-check, capability, pr, epic]
1249
+ 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); `epic` gates on an nwf plan-fanout epic reaching "fully merged", keyed by its `planKey` (issue #568).
1250
1250
  target:
1251
1251
  type: string
1252
1252
  minLength: 1
1253
- 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).
1253
+ description: The kind-specific target (a URL, a shell command, a `pkg@version`, an `owner/repo@ref`, `github-releases:owner/repo`, an `owner/repo#123` PR reference for the `pr` kind, or an `owner/repo#NN` planKey — the epic issue — for the `epic` kind). For a `pr`/`epic` target used in a **delivery-graph `wait` node** it may instead be a `<nodeId>.<fact>` late-binding reference the delivery-graph compiler resolves at dispatch (issue #548/#570); this rewrite exists ONLY on the delivery-graph dispatch path — other surfaces (e.g. feature-intake readiness) have no such resolver, so they must supply a literal handle.
1254
1254
  onTimeout:
1255
1255
  type: string
1256
1256
  enum: [escalate, fail, continue]
@@ -1274,6 +1274,7 @@ components:
1274
1274
  package: { type: string, description: "capability: the package whose releases are scanned for provenance." }
1275
1275
  verifyCommand: { type: string, description: "capability: optional empirical verifier run once at the gate boundary." }
1276
1276
  prState: { type: string, enum: [ready, merged, mergeable, checks-green], description: "pr: the declared PR state to wait for (default merged)." }
1277
+ epicState: { type: string, enum: [merged, done], description: "epic: the declared plan-fanout aggregate state to wait for (default merged) — both mean 'fully merged' (issue #568)." }
1277
1278
  poll:
1278
1279
  type: object
1279
1280
  additionalProperties: false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.148.2",
3
+ "version": "0.149.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",