@nanobpm/nano-workforce 0.135.0 → 0.136.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,13 @@
1
+ ## [0.136.0](https://github.com/nanobpm/nano-workforce/compare/v0.135.0...v0.136.0) (2026-08-24)
2
+
3
+ ### Features
4
+
5
+ * **delivery:** agent-node classifier-emit contract so S7 guarded branches fire ([#506](https://github.com/nanobpm/nano-workforce/issues/506)) ([#509](https://github.com/nanobpm/nano-workforce/issues/509)) ([f3acd5b](https://github.com/nanobpm/nano-workforce/commit/f3acd5bd5977fe3b12797f7d1e3acb7d5e67eb8c))
6
+
7
+ ### Bug Fixes
8
+
9
+ * **deps:** update dependency @nanobpm/urban to ^0.82.0 ([#482](https://github.com/nanobpm/nano-workforce/issues/482)) ([81decc1](https://github.com/nanobpm/nano-workforce/commit/81decc1b8ebd53b2835aaecbb369c5347371ceb3))
10
+
1
11
  ## [0.135.0](https://github.com/nanobpm/nano-workforce/compare/v0.134.0...v0.135.0) (2026-08-24)
2
12
 
3
13
  ### Features
@@ -277,6 +277,110 @@ test("S7 deploy+route: the green default branch SKIPS `migrate` and rides the el
277
277
  assert(r.releaseRan, "the green outcome still reaches `release` via the else-flow (proof the exclusive merge fires on one token)");
278
278
  });
279
279
 
280
+ // ── #506: the REAL agentic-worker classifier-emit contract drives a guarded split ──────────────────
281
+ // The S7 stubs above (`() => ({ result: outcome })`) prove the ENGINE routes on a published fact, but a
282
+ // bare `{ result }` is NOT what a real `senior:*` fleet agent returns — it completes with the whole
283
+ // Output-contract envelope (`{ status, summary, pr, … }`) and never a bare fact. So the gap #506 closes
284
+ // is: (a) the node's declared `emits` must be threaded into the agent's `appendPrompt` so a real agent
285
+ // is TOLD to surface the fact, and (b) the fact rides that SAME envelope as an extra top-level field.
286
+ // This graph proves both against the real engine: the `adopt` node declares `emits: [result]` and is
287
+ // serviced by a worker that (1) ASSERTS the emit contract reached it via `appendPrompt` — proving the
288
+ // runner actually delivers the instruction, not a test stub — and (2) returns the full envelope with the
289
+ // fact folded in, exactly as a contract-following agent would. Both branches are driven end to end.
290
+ const GUARDED_ADOPT_REAL: DeliveryGraph = {
291
+ name: "adopt runbook (real agent)",
292
+ nodes: [
293
+ {
294
+ id: "adopt",
295
+ kind: "agent",
296
+ agent: { jobType: "senior:feature", prompt: "Adopt the published package into this consumer and open a PR." },
297
+ emits: [{ name: "result", type: "string", description: "breaking | compatible" }],
298
+ },
299
+ { id: "migrate", kind: "agent", agent: { jobType: "senior:migrate" } },
300
+ { id: "release", kind: "connector", connector: { target: "npm:publish", dedupeKey: "rel-real-1" } },
301
+ ],
302
+ edges: [
303
+ { from: "adopt", to: "migrate", when: "adopt.result", equals: "breaking" },
304
+ { from: "adopt", to: "release", default: true },
305
+ { from: "migrate", to: "release" },
306
+ ],
307
+ };
308
+
309
+ /** Drive `GUARDED_ADOPT_REAL` with a worker that behaves like a REAL contract-following `senior:feature`
310
+ * agent: it reads the emit contract the runner threaded into its `appendPrompt`, then completes with the
311
+ * full Output-contract envelope carrying the classifier fact as a top-level field. Returns whether the
312
+ * contract actually reached the agent, plus which branches ran. */
313
+ async function driveGuardedRealAgent(outcome: "breaking" | "compatible"): Promise<{
314
+ state: string;
315
+ contractDelivered: boolean;
316
+ factSurfaced: boolean;
317
+ migrateRan: boolean;
318
+ releaseRan: boolean;
319
+ }> {
320
+ const engine = await createWasmEngineClient();
321
+ try {
322
+ let contractDelivered = false;
323
+ let factSurfaced = false;
324
+ let migrateRan = false;
325
+ let releaseRan = false;
326
+
327
+ await engine.registerWorker("senior:feature", async (job) => {
328
+ const appendPrompt = String((job.variables as Record<string, unknown> | undefined)?.appendPrompt ?? "");
329
+ // (a) The classifier emit contract MUST have reached the agent via its steering channel — this is
330
+ // the #506 fix (a plain `senior:feature` seed would carry no such instruction).
331
+ contractDelivered =
332
+ appendPrompt.includes("Classifier emit contract") &&
333
+ appendPrompt.includes("`result`") &&
334
+ appendPrompt.includes("AGENT_RESULT_FILE");
335
+ factSurfaced = appendPrompt.includes("`result`");
336
+ // (b) A real agent completes with the WHOLE Output-contract envelope, folding the declared fact in
337
+ // as an extra top-level field — NOT a bare `{ result }` stub.
338
+ return { status: "opened", summary: `adopt done (${outcome})`, pr: "owner/repo#900", result: outcome };
339
+ });
340
+ await engine.registerWorker("senior:migrate", async () => {
341
+ migrateRan = true;
342
+ return { status: "opened", summary: "migrated", pr: "owner/repo#901" };
343
+ });
344
+ await engine.registerWorker(DELIVERY_CONNECTOR_TASK_TYPE, async () => {
345
+ releaseRan = true;
346
+ return {};
347
+ });
348
+
349
+ const run = await runDeliveryGraph(engine, GUARDED_ADOPT_REAL);
350
+ assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
351
+ const key = run.handle.processInstanceKey;
352
+
353
+ let state = "?";
354
+ for (let round = 0; round < MAX_ROUNDS; round++) {
355
+ await engine.drain();
356
+ const [pi] = await engine.searchProcessInstances({ processInstanceKeys: [key] });
357
+ assert(pi, `no process instance snapshot for ${key}`);
358
+ state = pi.state ?? "?";
359
+ if (state === "COMPLETED" || state === "TERMINATED") break;
360
+ }
361
+ return { state, contractDelivered, factSurfaced, migrateRan, releaseRan };
362
+ } finally {
363
+ await engine.close();
364
+ }
365
+ }
366
+
367
+ test("#506 deploy+route: a REAL contract-following agent's envelope carries the classifier fact and routes the BREAKING branch through `migrate`", async () => {
368
+ const r = await driveGuardedRealAgent("breaking");
369
+ assert(r.contractDelivered, "the emit contract must reach the agent via its threaded appendPrompt (the #506 fix)");
370
+ assert(r.factSurfaced, "the declared fact must be named to the agent");
371
+ assertEquals(r.state, "COMPLETED", "the breaking branch must run to a COMPLETED instance");
372
+ assert(r.migrateRan, "the breaking outcome (returned inside the real Output-contract envelope) must route through `migrate`");
373
+ assert(r.releaseRan, "both branches must re-converge on `release`");
374
+ });
375
+
376
+ test("#506 deploy+route: the SAME real agent returning `compatible` in its envelope rides the default flow, SKIPPING `migrate`", async () => {
377
+ const r = await driveGuardedRealAgent("compatible");
378
+ assert(r.contractDelivered, "the emit contract must reach the agent via its threaded appendPrompt (the #506 fix)");
379
+ assertEquals(r.state, "COMPLETED", "the compatible branch must run to a COMPLETED instance");
380
+ assert(!r.migrateRan, "the compatible outcome must NOT route through `migrate` — the envelope's `result` rides the default flow");
381
+ assert(r.releaseRan, "the compatible outcome still reaches `release` via the else-flow");
382
+ });
383
+
280
384
  test("S7 deploy+route: mutually-exclusive leaves join End on an exclusive merge — the untaken leaf never blocks completion", async () => {
281
385
  // Mode D: `adopt` routes a missing surface to an escalate (human) leaf, else to a `done` connector
282
386
  // leaf. On the default path the escalate leaf never fires; an exclusive End merge must still let the
@@ -119,6 +119,39 @@ test("the human node seeds prompt/nodeId/emits; a click-done (no-emit, no-prompt
119
119
  assertEquals(ack?.nodeId, "ack");
120
120
  });
121
121
 
122
+ test("agent node classifier-emit contract (#506): a declared `emits` threads the emit instruction into appendPrompt; a no-emit node leaves it untouched", async () => {
123
+ // #506: a guarded split (S7) routes on a producer's emitted scalar, published from the engine
124
+ // variable named exactly after the fact. A real `senior:*` agent completes with the Output-contract
125
+ // envelope and would never return that fact unless TOLD — so an agent node that declares `emits`
126
+ // must carry the emit contract in its `appendPrompt` (its only steering channel), while a plain
127
+ // implementation node (no emits) must be byte-for-byte unchanged.
128
+ const graph: DeliveryGraph = {
129
+ name: "classifier",
130
+ nodes: [
131
+ { id: "adopt", kind: "agent", agent: { jobType: "senior:feature", prompt: "adopt the package" }, emits: [{ name: "result", type: "string", description: "breaking | compatible" }] },
132
+ { id: "plain", kind: "agent", agent: { jobType: "senior:feature", prompt: "just implement it" } },
133
+ ],
134
+ edges: [{ from: "adopt.result", to: "plain", when: "adopt.result", equals: "breaking" }, { from: "adopt", to: "plain", default: true }],
135
+ };
136
+ const p = await prepareOk(graph);
137
+ const agents = Object.values(p.nodeInputs).filter((v) => "jobType" in v) as Array<Record<string, unknown>>;
138
+ const adopt = agents.find((v) => String(v.appendPrompt).startsWith("adopt the package"));
139
+ const plain = agents.find((v) => String(v.appendPrompt).startsWith("just implement it"));
140
+
141
+ // The emit-declaring node keeps its authored prompt AND gains the emit contract naming its fact.
142
+ assert(adopt, "the emit-declaring agent node must be seeded");
143
+ const adoptPrompt = String(adopt?.appendPrompt);
144
+ assert(adoptPrompt.startsWith("adopt the package"), "the authored prompt is preserved as the prefix");
145
+ assert(adoptPrompt.includes("Classifier emit contract"), `the emit contract must be threaded in, got: ${adoptPrompt}`);
146
+ assert(adoptPrompt.includes("`result`") && adoptPrompt.includes("(string)"), "the declared fact name + type must be surfaced to the agent");
147
+ assert(adoptPrompt.includes("breaking | compatible"), "the fact's optional description rides the contract");
148
+ assert(adoptPrompt.includes("AGENT_RESULT_FILE"), "the contract names the completion channel the fact rides");
149
+
150
+ // A node that declares NO facts is untouched — appendPrompt is exactly the authored prompt.
151
+ assertEquals(plain?.appendPrompt, "just implement it");
152
+ });
153
+
154
+
122
155
  test("wait gateKeys default to a fresh per-run token so concurrent runs of one graph never cross-correlate", async () => {
123
156
  const gateKeyOf = (p: Awaited<ReturnType<typeof prepareOk>>) =>
124
157
  (Object.values(p.nodeInputs).find((v) => "gateKey" in v) as { gateKey?: string } | undefined)?.gateKey;
@@ -170,6 +170,39 @@ function rewriteProcessId(bpmn: string, processDefinitionId: string): string {
170
170
  .replace(`bpmnElement="${DELIVERY_GRAPH_PROCESS_ID}"`, `bpmnElement="${processDefinitionId}"`);
171
171
  }
172
172
 
173
+ /** Render the classifier-emit contract appended to an `agent` node's `appendPrompt` (issue #506) — the
174
+ * instruction that turns a declared `emits[]` into completion variables a downstream guarded split (S7)
175
+ * can route on. A `senior:*` fleet agent completes with the Output-contract envelope (`status`,
176
+ * `summary`, `pr`, …); the delivery output ioMapping instead publishes the engine variable named exactly
177
+ * after each fact (`factSourceVar` → `fact.name`), so the agent must ALSO return each declared fact as a
178
+ * TOP-LEVEL field of that same result JSON. This block tells it so, deriving entirely from the node's
179
+ * declared `emits` (no second source of truth). Empty for a no-emit node → the prompt is unchanged, so a
180
+ * plain implementation node behaves exactly as before. Deterministic: fixed wording, facts in declared
181
+ * order, so identical graphs still compile+seed byte-identically. */
182
+ export function renderEmitContract(emits: readonly DeliveryFact[]): string {
183
+ if (emits.length === 0) return "";
184
+ const facts = emits.map((f) => `- \`${f.name}\` (${f.type})${f.description ? ` — ${f.description}` : ""}`);
185
+ return [
186
+ "",
187
+ "",
188
+ "---",
189
+ "",
190
+ "## Classifier emit contract (delivery graph)",
191
+ "",
192
+ "This node is a PRODUCER in a delivery graph: a downstream **guarded split** routes on the typed",
193
+ "fact(s) below. In ADDITION to your normal result fields (`status`, `summary`, `pr`, …), the",
194
+ "structured result you write to `AGENT_RESULT_FILE` MUST include these TOP-LEVEL fields, each a",
195
+ "bare scalar of the declared type:",
196
+ "",
197
+ ...facts,
198
+ "",
199
+ "The value you return for each fact IS the routing decision — a downstream edge fires only when the",
200
+ "fact equals a specific literal, otherwise the graph takes the `default` (else) branch. If you",
201
+ "genuinely cannot determine a fact, OMIT it (the default branch is taken) rather than guessing.",
202
+ ].join("\n");
203
+ }
204
+
205
+
173
206
  /** Build the `nodeInputs.<element>` seed for one node, per its kind — the exact fields the compiled
174
207
  * subProcess ioMapping pulls. Total over the closed kind set. */
175
208
  function buildNodeInput(
@@ -177,8 +210,21 @@ function buildNodeInput(
177
210
  ctx: { runKey: string; element: string; nodeTimeout: string; probeTimeout: string; probePollEvery: string; escalationSlaTimeout: string; escalationAssignee: string | null },
178
211
  ): NodeInput {
179
212
  switch (node.kind) {
180
- case "agent":
181
- return { jobType: node.agent.jobType, appendPrompt: node.agent.prompt ?? "", timeout: isoDuration(node.agent.timeout, ctx.nodeTimeout) };
213
+ case "agent": {
214
+ // Classifier-emit contract (issue #506). A `senior:*` fleet agent's real completion is the
215
+ // Output-contract envelope (`{ status, summary, pr, … }`) — it does NOT return a bare fact, so a
216
+ // node's declared `emits` would never appear and a downstream GUARDED split (S7) could only ever
217
+ // take its `default` branch. Close the gap the same way `factSourceVar` already reads it: the
218
+ // output ioMapping publishes the engine variable named exactly after each fact, so the agent must
219
+ // return `{ <fact>: <value> }` AS A TOP-LEVEL field of its result JSON (the same channel that
220
+ // carries `status`/`summary`/`pr`). The agent only knows to do this if it is TOLD — so the
221
+ // declared emits are rendered into the node's `appendPrompt` (its sole steering channel; the
222
+ // delivery agent node carries no base-prompt resource), keeping `emits` the single source of
223
+ // truth. A no-emit node appends nothing, so a plain implementation node is unchanged.
224
+ const basePrompt = node.agent.prompt ?? "";
225
+ const emits = Array.isArray(node.emits) ? node.emits.map((f) => ({ ...f })) : [];
226
+ return { jobType: node.agent.jobType, appendPrompt: basePrompt + renderEmitContract(emits), timeout: isoDuration(node.agent.timeout, ctx.nodeTimeout) };
227
+ }
182
228
  case "wait": {
183
229
  const probe = parseProbe(node.wait);
184
230
  return {
@@ -268,6 +268,32 @@ resume cannot double-fire.
268
268
  > Determinism is preserved: gateway ids are positional over id-sorted nodes, so a graph with no guards
269
269
  > compiles byte-for-byte as before.
270
270
 
271
+ > **Amendment (issue #506): the agent-node classifier-emit contract.** S7 (above) routes a guarded
272
+ > split on a producer's emitted scalar, published to `<producerElement>_<fact>` by the node's output
273
+ > ioMapping — which, for an `agent`/`connector` node, reads the engine variable named exactly after the
274
+ > fact (`factSourceVar` → `fact.name`). A REAL `senior:*` fleet agent, though, completes with its
275
+ > **Output-contract envelope** (`{ status, summary, pr, question, delta }`) and never a bare
276
+ > `{ <fact>: <value> }`, so a guarded split authored on a `senior:feature` node compiled + was
277
+ > deadlock-safe (the `default` else-flow fires when the fact is unset) but its non-default branch was
278
+ > **inert** — the migrate/escalate arm never fired. The contract that closes this:
279
+ > - An `agent` node's declared `emits[]` is threaded into the node's `appendPrompt` (its sole steering
280
+ > channel — the delivery agent node carries no base-prompt resource) as a **classifier emit contract**
281
+ > block (`deliveryRunner.renderEmitContract`), instructing the servicing agent to ALSO return each
282
+ > declared fact as a **top-level field** of its result JSON — the very same `AGENT_RESULT_FILE`
283
+ > channel that already carries `status`/`summary`/`pr`. The fleet harness merges that JSON into the
284
+ > job completion variables, so `<fact.name>` lands in scope exactly where the output ioMapping reads
285
+ > it. `emits` stays the single source of truth: the contract text is derived from it, never a parallel
286
+ > declaration.
287
+ > - The convention a graph author relies on: declare `emits: [{ name, type }]` on the agent node and
288
+ > guard the downstream edge with `when: "<node>.<name>"` + `equals: <literal>`; a contract-following
289
+ > agent returns `{ …, <name>: <value> }` and the split routes on it. Omitting the fact (the agent
290
+ > could not decide) takes the `default` branch — the deadlock-safe fallback S7 already guarantees.
291
+ > - A no-emit agent node appends nothing, so a plain implementation node is byte-for-byte unchanged.
292
+ > The `deploy+route` coverage now drives BOTH branches with a **real contract-following worker** (it
293
+ > asserts the emit contract reached it via `appendPrompt`, then completes with the full envelope
294
+ > carrying the fact), not a bare-`{ result }` stub — proving the instruction is actually delivered and
295
+ > the real completion shape routes.
296
+
271
297
  ## Open questions
272
298
 
273
299
  - **Compiler target for the first cut** — confirm compile-to-native (diagram + native scheduling) vs a
@@ -448,6 +448,23 @@ A **typed fact** (`emits[]` entry) is `{ name, type, description? }` where
448
448
  downstream as `<nodeId>.<name>`. A "click done" human node or a pass-through node declares
449
449
  no facts.
450
450
 
451
+ **Guarded routing + the agent classifier-emit contract.** An edge may carry a **guard** —
452
+ `when: "<nodeId>.<fact>"` + `equals: <scalar>` — or be the split's single `default: true`
453
+ else-branch (S7). A node whose out-edges are guarded is a **data-based exclusive split**: at
454
+ runtime exactly one branch fires, chosen by the producer's emitted fact. For an **`agent`**
455
+ node the fact is late-bound from the servicing job's completion: the delivery output-mapping
456
+ publishes the engine variable named **exactly after the fact** (e.g. a fact `result` reads the
457
+ completion variable `result`). A real `senior:*` fleet agent completes with its **Output
458
+ contract** envelope (`{ status, summary, pr, question, delta }`), so — to make a guarded split
459
+ fire — an agent node that declares `emits` has a **classifier emit contract** automatically
460
+ appended to its prompt at dispatch: the agent MUST return each declared fact as an **extra
461
+ top-level field of the same result JSON** (the `AGENT_RESULT_FILE` it already writes
462
+ `status`/`summary`/`pr` to). Author side, this means: declare `emits: [{ name, type }]` on the
463
+ agent node and guard the downstream edge on `<node>.<name>`; a contract-following agent returns
464
+ `{ …, <name>: <value> }` and the split routes on it. If the agent cannot decide the fact it
465
+ **omits** it, and the split takes its `default` (else) branch — the deadlock-safe fallback. A
466
+ node that declares no `emits` gets no contract text and behaves exactly as before.
467
+
451
468
  ### 9.2 The agent loop: draft → compile → stage → ask an operator to dispatch
452
469
 
453
470
  ```
package/openapi.yaml CHANGED
@@ -1392,7 +1392,17 @@ components:
1392
1392
  properties:
1393
1393
  id: { type: string }
1394
1394
  kind: { type: string, enum: [agent] }
1395
- emits: { type: array, items: { $ref: "#/components/schemas/DeliveryFact" } }
1395
+ emits:
1396
+ type: array
1397
+ items: { $ref: "#/components/schemas/DeliveryFact" }
1398
+ description: >-
1399
+ The typed facts this agent node hands forward (issue #506 — the classifier-emit
1400
+ contract). Each declared fact is appended to the node's dispatch prompt as an
1401
+ instruction the servicing `senior:*` agent MUST honour: return the fact as an extra
1402
+ TOP-LEVEL field of its result JSON (the same `AGENT_RESULT_FILE` envelope that carries
1403
+ `status`/`summary`/`pr`). The delivery output-mapping publishes that completion variable
1404
+ (named exactly after the fact) so a downstream guarded edge (`when: "<node>.<fact>"` +
1405
+ `equals`) routes on it; an omitted fact takes the split's `default` branch.
1396
1406
  agent:
1397
1407
  type: object
1398
1408
  additionalProperties: false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.135.0",
3
+ "version": "0.136.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.81.0",
62
+ "@nanobpm/urban": "^0.82.0",
63
63
  "bpmn-auto-layout": "^2.0.0-alpha.2"
64
64
  },
65
65
  "devDependencies": {