@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/CHANGELOG.md +12 -0
- package/README.md +36 -3
- package/SPEC.md +1 -1
- package/app/deliveryGraphCompiler.test.ts +51 -0
- package/app/deliveryGraphCompiler.ts +12 -4
- package/app/deliveryRunner.test.ts +30 -0
- package/app/deliveryRunner.ts +1 -1
- package/app/mcpExclusions.test.ts +83 -0
- package/app/readiness.test.ts +140 -0
- package/app/readiness.ts +171 -6
- package/docs/agent-guide.md +51 -4
- package/docs/mcp-runbook.md +133 -0
- package/openapi.yaml +20 -3
- package/package.json +2 -2
- package/skills/README.md +7 -3
- package/skills/nano-workforce/SKILL.md +122 -75
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
|
-
|
|
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
|
-
|
|
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
|
|
package/docs/agent-guide.md
CHANGED
|
@@ -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
|
|
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`,
|
|
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.
|
|
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.
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
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` tool — ADR 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
|
|