@nanobpm/nano-workforce 0.148.2 → 0.150.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/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`).
@@ -0,0 +1,133 @@
1
+ # Configure an agent to drive/debug this workforce over MCP
2
+
3
+ > Adoption of the Urban runtime-served MCP surface ([ADR 0067](https://github.com/nanobpm/nano-ide/blob/main/docs/adr/0067-runtime-served-mcp-surface.md),
4
+ > nano-ide#488) — first consumer, nano-workforce#567. Written against the
5
+ > [Copilot CLI](https://github.com/github/copilot-cli) (the harness nwf's fleet
6
+ > uses). Claude/Cursor equivalents use the same server entries.
7
+
8
+ The Urban runtime serves a Streamable-HTTP MCP endpoint at **`/app/mcp`** for every
9
+ hosted app and projects this app's `openapi.yaml` into tools — **zero MCP server code
10
+ in nwf**. An MCP-capable agent gets nwf's operations (submit work, answer escalations,
11
+ read status, and the operator **guide** itself — `GET /app/api/agent`, projected as the
12
+ `getAgentInstructions` read tool), the framework-owned engine-debug tool family (process
13
+ instances, wait states, variables, incidents), the `urban_*` projection reads, and the
14
+ runtime's derived **system brief** as an MCP resource plus an orientation prompt — all
15
+ namespaced per server entry.
16
+
17
+ This replaces the SKILL.md instance-probing dance for MCP clients: naming the
18
+ instance (`"drive workforce-merlin"`) makes the wrong-instance mistake structurally
19
+ impossible. The named-instance registry (`NANO_WORKFORCE_INSTANCES` /
20
+ `~/.config/nano-workforce/instances.json`) remains the source for the fallback path
21
+ and a handy list of the entries to register here.
22
+
23
+ MCP is a **third door**, not a replacement: `GET /app/api/agent` (the live guide) and
24
+ `GET /app/api/agent/skill` are unchanged for agents without MCP — see
25
+ [§5 Fallback](#5-fallback).
26
+
27
+ ## 1. One MCP server entry per instance
28
+
29
+ In `~/.copilot/mcp-config.json` (user-wide) or `.mcp.json` (repo-scoped):
30
+
31
+ ```json
32
+ {
33
+ "mcpServers": {
34
+ "workforce-local": {
35
+ "type": "http",
36
+ "url": "http://localhost:3000/app/mcp",
37
+ "tools": ["*"]
38
+ },
39
+ "workforce-merlin": {
40
+ "type": "http",
41
+ "url": "http://merlin.local:3000/app/mcp",
42
+ "headers": { "x-hook-secret": "$NANO_PR_WEBHOOK_SECRET" },
43
+ "tools": ["*"]
44
+ },
45
+ "workforce-remote": {
46
+ "type": "http",
47
+ "url": "https://<subdomain>.ngrok.app/app/mcp",
48
+ "headers": { "x-hook-secret": "$NANO_PR_WEBHOOK_SECRET" },
49
+ "tools": ["*"]
50
+ }
51
+ }
52
+ }
53
+ ```
54
+
55
+ Or from the terminal:
56
+
57
+ ```bash
58
+ copilot mcp add --transport http workforce-local http://localhost:3000/app/mcp
59
+ # add --header for a guarded instance:
60
+ copilot mcp add --transport http workforce-merlin http://merlin.local:3000/app/mcp \
61
+ --header "x-hook-secret: $NANO_PR_WEBHOOK_SECRET"
62
+ ```
63
+
64
+ Tool calls are namespaced per server entry, so the instance you name is the instance
65
+ you drive.
66
+
67
+ ### Instance behind Basic Auth? You need *both* headers
68
+
69
+ Two different layers. `x-hook-secret` is the **app's own** guard (checked by nwf in
70
+ the operation handler, only when `NANO_PR_WEBHOOK_SECRET` is set). **Basic Auth** is
71
+ enforced by whatever **fronts** the instance (ngrok edge, console proxy) and 401s
72
+ *before* the request ever reaches nwf:
73
+
74
+ ```json
75
+ "headers": {
76
+ "Authorization": "Basic <base64(user:pass)>",
77
+ "x-hook-secret": "..."
78
+ }
79
+ ```
80
+
81
+ Generate the blob with `printf '%s' 'user:pass' | base64` — `echo | base64` appends a
82
+ newline and yields the wrong value. The proxy must forward custom headers for
83
+ `x-hook-secret` to survive (most do by default). Base64 is encoding, not encryption:
84
+ only use Basic Auth over HTTPS. The fallback curl path needs both too:
85
+ `curl -u user:pass -H "x-hook-secret: …"`.
86
+
87
+ ## 2. Verify discovery
88
+
89
+ New agent session → the `workforce-*` tools appear (app operations + the engine-debug
90
+ family). Ask:
91
+
92
+ > *"Using workforce-local, show what's in flight and any open escalations."*
93
+
94
+ The agent should call the status operation tool, not curl.
95
+
96
+ ## 3. Debug a wedged instance
97
+
98
+ > *"workforce-local: PR nanobpm/nano-workforce#123 looks wedged — find its process
99
+ > instance, compare engine truth against the app's projections, and tell me where
100
+ > it's stuck."*
101
+
102
+ The agent has: instance search, wait states, variables, incidents (engine truth) +
103
+ the `urban_*` projection reads (app belief) + the operator guide (the
104
+ `getAgentInstructions` tool) for the convergence-loop-specific meaning of each wedge
105
+ shape. A wedge is frequently exactly a disagreement between the two planes.
106
+
107
+ ## 4. Guard posture
108
+
109
+ When `NANO_PR_WEBHOOK_SECRET` is **unset**, both reads (status, instances, incidents,
110
+ projections, the operator guide) and mutations (cancel/retry/resolve, `start/*`
111
+ operations, answering escalations) work from loopback with no credential. When it **is
112
+ set**, the guard is not mutation-only: that secret is required as an `x-hook-secret`
113
+ header on **both reads and mutations** — read endpoints like `GET /app/api/agent` and
114
+ `GET /app/api/version` also return `401` without it. Put it in the server entry's
115
+ `headers`, never in chat. For a remote fleet,
116
+ `NANO_WORKFORCE_BASE_URL` reachability rules apply unchanged, and LAN exposure of
117
+ `/app/mcp` follows the same `network.bind` manifest setting as the rest of the app's
118
+ HTTP surface.
119
+
120
+ **Operator-only doors stay operator-only.** The staged delivery-graph lifecycle —
121
+ `stageDeliveryGraph`, `dispatchDeliveryGraph`, `dismissProposal` — is `x-mcp`-excluded
122
+ from the projected tool surface (ADR 0067 §2): the human clicking **Dispatch** in the
123
+ cockpit *is* the approval (ADR 0005 Decision 7), so an agent cannot dispatch a delivery
124
+ graph through MCP. Agents author graphs through the pure `compileDeliveryGraph` /
125
+ `previewDeliveryGraph` doors, which stay exposed.
126
+
127
+ ## 5. Fallback
128
+
129
+ Agents without MCP are unchanged — resolve the instance, then
130
+ `curl -sS $BASE/agent | jq -r .instructions`, or load the
131
+ [`nano-workforce` skill](../skills/nano-workforce/SKILL.md), which fetches the same
132
+ live guide. `GET /app/api/agent` and `GET /app/api/agent/skill` keep working exactly as
133
+ before.
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
@@ -3133,6 +3134,12 @@ paths:
3133
3134
  /actions/delivery-graph/stage:
3134
3135
  post:
3135
3136
  operationId: stageDeliveryGraph
3137
+ # x-mcp exclusion (ADR 0067 §2 / nano-ide#488): operator-only cockpit door, kept OFF the
3138
+ # runtime-projected MCP tool surface. The staged-proposal lifecycle (stage -> dispatch ->
3139
+ # dismiss) is the human approval path (ADR 0005 Decision 7 — the operator's click IS the
3140
+ # approval); agents author graphs through the pure compile/preview doors only, never stage.
3141
+ x-mcp:
3142
+ exclude: true
3136
3143
  summary: UI JSON-paste STAGE — parse a pasted delivery-graph JSON string, compile it and stage it for operator dispatch. (ADR 0005 Decision 7 / #460 / #516)
3137
3144
  description: >-
3138
3145
  The human-facing UI JSON-paste STAGE ingress — the deliberate commit half of the preview/stage
@@ -3166,6 +3173,11 @@ paths:
3166
3173
  /actions/delivery-graph/dispatch:
3167
3174
  post:
3168
3175
  operationId: dispatchDeliveryGraph
3176
+ # x-mcp exclusion (ADR 0067 §2 / nano-ide#488): the canonical operator-only door. Dispatch
3177
+ # approval IS a human clicking Dispatch in the cockpit (ADR 0005 Decision 7) — there is no
3178
+ # replayable token and no agent-facing dispatch handle, so it is never a projected MCP tool.
3179
+ x-mcp:
3180
+ exclude: true
3169
3181
  summary: OPERATOR DISPATCH — launch a staged delivery-graph proposal by its digest (idempotent). (ADR 0005 Decision 7 / #460)
3170
3182
  description: >-
3171
3183
  The OPERATOR-ONLY dispatch door (ADR 0005 Decision 7, issue #460). The cockpit's staged-proposals
@@ -3211,6 +3223,11 @@ paths:
3211
3223
  /actions/delivery-graph/dismiss:
3212
3224
  post:
3213
3225
  operationId: dismissProposal
3226
+ # x-mcp exclusion (ADR 0067 §2 / nano-ide#488): operator-only cockpit door — discarding a
3227
+ # staged proposal as noise is a human decision in the same approval class as dispatch, so it
3228
+ # stays off the projected MCP tool surface.
3229
+ x-mcp:
3230
+ exclude: true
3214
3231
  summary: OPERATOR DISMISS — discard a staged delivery-graph proposal by its digest as noise (idempotent). (#520)
3215
3232
  description: >-
3216
3233
  The OPERATOR-ONLY dismiss door (#520). The cockpit's staged-proposals grid posts the `digest` of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.148.2",
3
+ "version": "0.150.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",
@@ -59,7 +59,7 @@
59
59
  },
60
60
  "dependencies": {
61
61
  "@nanobpm/agentic": "^0.4.0",
62
- "@nanobpm/urban": "^0.83.0",
62
+ "@nanobpm/urban": "^0.86.0",
63
63
  "bpmn-auto-layout": "^2.0.0-alpha.2"
64
64
  },
65
65
  "devDependencies": {
package/skills/README.md CHANGED
@@ -7,9 +7,13 @@ loads on demand when its `description` matches the task.
7
7
  ## `nano-workforce`
8
8
 
9
9
  A **thin bootstrap** that teaches any agent to operate a running Nano Workforce
10
- instance: it resolves the instance base URL and fetches the instance's *live*
11
- operator guide (`GET /app/api/agent`), then follows it. It deliberately holds no
12
- endpoint detail of its own the live, version-matched guide is the source of truth.
10
+ instance. Where the client supports **MCP**, it registers the instance's `/app/mcp`
11
+ server (its tools appear automatically, including the operator guide as the
12
+ `getAgentInstructions` toolADR 0067); where
13
+ it does not, it resolves the instance base URL and fetches the instance's *live*
14
+ operator guide (`GET /app/api/agent`), then follows it. Either way it deliberately holds
15
+ no endpoint detail of its own — the live, version-matched surface is the source of truth.
16
+ See [`docs/mcp-runbook.md`](../docs/mcp-runbook.md) for the MCP server-entry recipes.
13
17
 
14
18
  ### Install
15
19