@nanobpm/nano-workforce 0.112.0 → 0.113.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,10 @@
1
+ # [0.113.0](https://github.com/nanobpm/nano-workforce/compare/v0.112.0...v0.113.0) (2026-08-20)
2
+
3
+
4
+ ### Features
5
+
6
+ * **delivery-graph:** `human` node as a scheduled user task that emits a typed fact (ADR 0005 S3) ([#389](https://github.com/nanobpm/nano-workforce/issues/389)) ([2266f9c](https://github.com/nanobpm/nano-workforce/commit/2266f9c36f194a7b1bfa995925968353131040e4)), closes [#378](https://github.com/nanobpm/nano-workforce/issues/378)
7
+
1
8
  # [0.112.0](https://github.com/nanobpm/nano-workforce/compare/v0.111.1...v0.112.0) (2026-08-20)
2
9
 
3
10
 
@@ -23,6 +23,7 @@
23
23
  import { readFileSync } from "node:fs";
24
24
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
25
25
  import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
26
+ import { DELIVERY_HUMAN_ELEMENT } from "./deliveryHuman.ts";
26
27
 
27
28
  const now = () => new Date().toISOString();
28
29
 
@@ -70,6 +71,7 @@ export const ESCALATION_TASK_ELEMENTS: ReadonlySet<string> = new Set([
70
71
  "trial-merge-decision",
71
72
  "wait-answer", // PR review-loop escalation (convergence-loop.bpmn, U3)
72
73
  "wait-merge-answer", // PR merge-loop escalation (merge-loop.bpmn) — same native user-task path (#256)
74
+ DELIVERY_HUMAN_ELEMENT, // delivery-graph `human` node (ADR 0005 S3) — a scheduled user task, answerable by a human OR an agent (ADR 0046)
73
75
  ]);
74
76
 
75
77
  /** The `feature-blocked` operator user-task element id (feature.bpmn) — the native wait a run parks on
@@ -110,6 +112,14 @@ const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
110
112
  "wait-merge-answer": "pr-escalation",
111
113
  "feature-blocked": "feature-blocked",
112
114
  [CONFORMANCE_ESCALATION_TASK_ELEMENT]: "conformance-escalation",
115
+ // NOTE: the delivery-graph `human` node (`DELIVERY_HUMAN_ELEMENT`, ADR 0005 S3) is intentionally
116
+ // ABSENT here. Unlike the fixed-form escalations above, ONE `delivery-human-task` element is DESIGNED
117
+ // to render DIFFERENT forms per node (explicit → category → generic → agent-router, `app/deliveryHuman.ts`
118
+ // `resolveHumanForm`) — the S4 runner selects one at activation; this S3 slice deploys the generic
119
+ // form as the static default. Either way a single static required-field contract cannot fit it. Its typed-emit
120
+ // contract is enforced instead by `bindHumanEmits` against the node's declared `emits[]` (binds are
121
+ // validated, not stringly — Decision 3/4), so `validateEscalationVariables` leaves it unenforced
122
+ // (returns `null`) and the completer accepts the captured form variables for the emit binder to type.
113
123
  };
114
124
 
115
125
  /** A field's `conditional.hide` rule, parsed from the FEEL subset the `.form` files use
@@ -91,8 +91,8 @@ function isDeliveryFactType(type: unknown): type is DeliveryFactType {
91
91
  * a test, a future internal use), a dotted fact name could otherwise make `<nodeId>.<fact>` resolution
92
92
  * ambiguous and quietly build the wrong DAG — undermining the trust boundary this validator exists to
93
93
  * hold. */
94
- const FACT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
95
- const FACT_NAME_MAX_LENGTH = 128;
94
+ export const FACT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
95
+ export const FACT_NAME_MAX_LENGTH = 128;
96
96
 
97
97
  /** A node `id` must match openapi's `DeliveryNodeCommon.id` `^[A-Za-z_][A-Za-z0-9_.-]*$` and stay
98
98
  * within its 128-char cap. Re-enforced here INDEPENDENTLY of the OpenAPI shape gate because later
@@ -0,0 +1,306 @@
1
+ // Tests for the `human` delivery-graph node (ADR 0005, slice S3): form resolution (specific-else-
2
+ // generic + gated agent-router), typed-emit binding, and the #289 late-binding surfaces. Pure logic
3
+ // (no engine), plus a structural + cross-layer guard tying the committed BPMN body / `.form` files to
4
+ // the completer allowlist and the inbox read-model so the two can't silently diverge.
5
+
6
+ import { test } from "node:test";
7
+ import { readFileSync } from "node:fs";
8
+ import { assert, assertEquals, assertStringIncludes } from "#test-assert";
9
+ import type { DeliveryFact } from "../nano-generated/api-io.d.ts";
10
+ import {
11
+ bindHumanEmits,
12
+ DELIVERY_HUMAN_ELEMENT,
13
+ deriveHumanCategory,
14
+ GENERIC_HUMAN_FORM,
15
+ HUMAN_ACK_FORM,
16
+ HUMAN_PUBLISH_FORM,
17
+ humanEmitBind,
18
+ needsAgentFormRouter,
19
+ normalizeEmits,
20
+ renderHumanEmitBrief,
21
+ resolveHumanForm,
22
+ } from "./deliveryHuman.ts";
23
+ import { ESCALATION_TASK_ELEMENTS } from "./agentCompletion.ts";
24
+ import { USER_TASK_KIND_LABELS } from "./userTasks.ts";
25
+
26
+ const artifact = (name = "resolvedArtifact"): DeliveryFact => ({ name, type: "artifact" });
27
+ const version = (name = "publishedVersion"): DeliveryFact => ({ name, type: "version" });
28
+ const str = (name: string): DeliveryFact => ({ name, type: "string" });
29
+
30
+ // ── Form resolution: explicit → category → generic → agent-router ──────────────────────────────
31
+
32
+ test("resolveHumanForm: an explicit formKey always wins, even over a category match", () => {
33
+ const r = resolveHumanForm({ emits: [artifact()], human: { formKey: "my-bespoke-form" } });
34
+ assertEquals(r.source, "explicit");
35
+ assertEquals(r.formKey, "my-bespoke-form");
36
+ assertEquals(r.category, null);
37
+ });
38
+
39
+ test("resolveHumanForm: a blank/whitespace explicit formKey does not count as explicit", () => {
40
+ const r = resolveHumanForm({ emits: [], human: { formKey: " " } });
41
+ assertEquals(r.source, "category");
42
+ assertEquals(r.formKey, HUMAN_ACK_FORM);
43
+ });
44
+
45
+ test("resolveHumanForm: no emits selects the ack (click-done) category form", () => {
46
+ const r = resolveHumanForm({ emits: [], human: {} });
47
+ assertEquals(r.source, "category");
48
+ assertEquals(r.category, "ack");
49
+ assertEquals(r.formKey, HUMAN_ACK_FORM);
50
+ });
51
+
52
+ test("resolveHumanForm: a single artifact emit selects the publish category form", () => {
53
+ const r = resolveHumanForm({ emits: [artifact()], human: {} });
54
+ assertEquals(r.source, "category");
55
+ assertEquals(r.category, "publish");
56
+ assertEquals(r.formKey, HUMAN_PUBLISH_FORM);
57
+ });
58
+
59
+ test("resolveHumanForm: a single bare-version emit falls through to the generic form", () => {
60
+ // The publish form captures a pkg@version `resolvedArtifact`, which fails `version` validation in
61
+ // bindHumanEmits — so a lone `version` must use the generic single-value form, not publish.
62
+ const r = resolveHumanForm({ emits: [version()], human: {} });
63
+ assertEquals(r.source, "generic");
64
+ assertEquals(r.formKey, GENERIC_HUMAN_FORM);
65
+ assertEquals(r.category, null);
66
+ });
67
+
68
+ test("resolveHumanForm: a single non-artifact scalar emit falls through to the generic form", () => {
69
+ const r = resolveHumanForm({ emits: [str("approvalNote")], human: {} });
70
+ assertEquals(r.source, "generic");
71
+ assertEquals(r.formKey, GENERIC_HUMAN_FORM);
72
+ assertEquals(r.category, null);
73
+ });
74
+
75
+ test("regression: a single version resolves to a form whose capture key binds against `version`", () => {
76
+ // Guards the class: the resolved form's canonical capture key must produce a value that coerces
77
+ // against the emitted fact's type. The publish form captures `resolvedArtifact` (pkg@version),
78
+ // which FAILS `version` coercion — so a lone version must route to the generic `value` form.
79
+ const emit = version("publishedVersion");
80
+ const r = resolveHumanForm({ emits: [emit], human: {} });
81
+ assertEquals(r.formKey, GENERIC_HUMAN_FORM);
82
+ // Generic form's `value` capture binds cleanly against the version fact.
83
+ assertEquals(bindHumanEmits([emit], { value: "1.4.0" }).errors, []);
84
+ // Whereas the publish form's `resolvedArtifact` (a pkg@version) would NOT — the mismatch avoided.
85
+ assert(
86
+ bindHumanEmits([emit], { resolvedArtifact: "@nanobpm/urban@0.54.0" }).errors.length > 0,
87
+ "a pkg@version resolvedArtifact must not satisfy a bare-version emit",
88
+ );
89
+ });
90
+
91
+ test("resolveHumanForm: two-plus heterogeneous emits with no form gate the agent-router (null form)", () => {
92
+ const node = { emits: [artifact(), str("changelog")], human: {} };
93
+ const r = resolveHumanForm(node);
94
+ assertEquals(r.source, "agent-router");
95
+ assertEquals(r.formKey, null);
96
+ assert(needsAgentFormRouter(node), "needsAgentFormRouter must agree the router fires");
97
+ });
98
+
99
+ test("resolveHumanForm: an explicit form suppresses the agent-router even with many emits", () => {
100
+ const node = { emits: [artifact(), str("changelog"), version("v")], human: { formKey: "multi" } };
101
+ assertEquals(resolveHumanForm(node).source, "explicit");
102
+ assert(!needsAgentFormRouter(node), "an explicit form must pre-empt the router");
103
+ });
104
+
105
+ test("deriveHumanCategory: ack for empty, publish for one artifact, null for a lone version/scalar", () => {
106
+ assertEquals(deriveHumanCategory([]), "ack");
107
+ assertEquals(deriveHumanCategory([artifact()]), "publish");
108
+ // A lone bare `version` is NOT publish — the publish form captures a pkg@version resolvedArtifact,
109
+ // which fails `version` coercion; it falls through to the generic single-value form.
110
+ assertEquals(deriveHumanCategory([version()]), null);
111
+ assertEquals(deriveHumanCategory([str("x")]), null);
112
+ assertEquals(deriveHumanCategory([artifact(), version()]), null);
113
+ });
114
+
115
+ test("normalizeEmits: drops malformed declarations (non-record, blank name, unknown type)", () => {
116
+ const emits = [
117
+ artifact("good"),
118
+ { name: "", type: "string" },
119
+ { name: "bad", type: "nope" },
120
+ "junk",
121
+ { type: "string" },
122
+ ] as unknown as DeliveryFact[];
123
+ const out = normalizeEmits({ emits });
124
+ assertEquals(out.length, 1);
125
+ assertEquals(out[0].name, "good");
126
+ });
127
+
128
+ test("normalizeEmits: drops names the S0 validator would reject (dotted/non-identifier, over-long, duplicate)", () => {
129
+ const emits = [
130
+ str("good"),
131
+ { name: "dotted.name", type: "string" }, // fails FACT_NAME_PATTERN (would make <nodeId>.<fact> ambiguous)
132
+ { name: "1leading", type: "string" }, // leading digit — not a bare identifier
133
+ { name: "has space", type: "string" }, // whitespace — not an identifier
134
+ { name: `${"x".repeat(129)}`, type: "string" }, // exceeds FACT_NAME_MAX_LENGTH (128)
135
+ { name: "good", type: "version" }, // duplicate name — first (string) wins, this is dropped
136
+ ] as unknown as DeliveryFact[];
137
+ const out = normalizeEmits({ emits });
138
+ assertEquals(out.length, 1);
139
+ assertEquals(out[0].name, "good");
140
+ assertEquals(out[0].type, "string");
141
+ });
142
+
143
+ test("normalizeEmits: a name at exactly the 128-char cap is kept", () => {
144
+ const emits = [{ name: "x".repeat(128), type: "string" }] as unknown as DeliveryFact[];
145
+ const out = normalizeEmits({ emits });
146
+ assertEquals(out.length, 1);
147
+ });
148
+
149
+ // ── Typed emit binding ─────────────────────────────────────────────────────────────────────────
150
+
151
+ test("bindHumanEmits: a no-emit node yields no facts and no errors regardless of captured form", () => {
152
+ const r = bindHumanEmits([], { note: "did it", value: "ignored" });
153
+ assertEquals(r.facts.length, 0);
154
+ assertEquals(r.errors.length, 0);
155
+ });
156
+
157
+ test("bindHumanEmits: a single artifact binds from the canonical resolvedArtifact capture key", () => {
158
+ const r = bindHumanEmits([artifact()], { resolvedArtifact: "@nanobpm/urban@0.54.0", note: "n" });
159
+ assertEquals(r.errors, []);
160
+ assertEquals(r.facts.length, 1);
161
+ assertEquals(r.facts[0].value, "@nanobpm/urban@0.54.0");
162
+ assertEquals(r.facts[0].type, "artifact");
163
+ });
164
+
165
+ test("bindHumanEmits: a single scalar binds from the generic form's `value` capture key", () => {
166
+ const r = bindHumanEmits([str("approvalNote")], { value: "ship it" });
167
+ assertEquals(r.errors, []);
168
+ assertEquals(r.facts[0].value, "ship it");
169
+ });
170
+
171
+ test("bindHumanEmits: a fact keyed by its own name wins over the canonical fallback", () => {
172
+ const r = bindHumanEmits([version("publishedVersion")], { publishedVersion: "1.4.0", value: "9.9.9" });
173
+ assertEquals(r.facts[0].value, "1.4.0");
174
+ });
175
+
176
+ test("bindHumanEmits: multi-emit binds each fact by its own name (no canonical fallback)", () => {
177
+ const emits = [artifact("art"), str("changelog")];
178
+ const r = bindHumanEmits(emits, { art: "@a/b@1.0.0", changelog: "notes" });
179
+ assertEquals(r.errors, []);
180
+ assertEquals(r.facts.length, 2);
181
+ });
182
+
183
+ test("bindHumanEmits: a missing declared fact is a path-qualified error", () => {
184
+ const r = bindHumanEmits([artifact("art")], { note: "n" });
185
+ assertEquals(r.facts.length, 0);
186
+ assertEquals(r.errors.length, 1);
187
+ assertStringIncludes(r.errors[0], "emits.art");
188
+ });
189
+
190
+ test("bindHumanEmits: an ill-typed artifact/version/url/number/boolean each errors", () => {
191
+ assertStringIncludes(bindHumanEmits([artifact("a")], { a: "no-at-sign" }).errors[0], "pkg@version");
192
+ assertStringIncludes(bindHumanEmits([version("v")], { v: "not a version" }).errors[0], "version");
193
+ assertStringIncludes(bindHumanEmits([{ name: "u", type: "url" }], { u: "not a url" }).errors[0], "URL");
194
+ assertStringIncludes(bindHumanEmits([{ name: "n", type: "number" }], { n: "abc" }).errors[0], "number");
195
+ assertStringIncludes(bindHumanEmits([{ name: "b", type: "boolean" }], { b: "maybe" }).errors[0], "boolean");
196
+ });
197
+
198
+ test("bindHumanEmits: an artifact with an ill-formed version segment errors", () => {
199
+ // A well-formed `pkg@version` shape but a version segment that is not a valid version must reject —
200
+ // the version segment validates the same way a bare `version` fact does (#263, shared VERSION_PATTERN).
201
+ assertStringIncludes(
202
+ bindHumanEmits([artifact("a")], { a: "@nanobpm/urban@not-a-version" }).errors[0],
203
+ "pkg@version",
204
+ );
205
+ assertEquals(bindHumanEmits([artifact("a")], { a: "@nanobpm/urban@not-a-version" }).facts.length, 0);
206
+ // A valid digit-led (optionally `v`-prefixed) version segment still passes.
207
+ assertEquals(bindHumanEmits([artifact("a")], { a: "@nanobpm/urban@0.54.0" }).facts[0].value, "@nanobpm/urban@0.54.0");
208
+ assertEquals(bindHumanEmits([artifact("a")], { a: "pkg@v2.0.0-rc.1" }).facts[0].value, "pkg@v2.0.0-rc.1");
209
+ });
210
+
211
+ test("bindHumanEmits: number/boolean/url coerce to canonical string serialisations", () => {
212
+ assertEquals(bindHumanEmits([{ name: "n", type: "number" }], { n: "42" }).facts[0].value, "42");
213
+ assertEquals(bindHumanEmits([{ name: "b", type: "boolean" }], { b: true }).facts[0].value, "true");
214
+ // A string boolean is trimmed before validating (like version/artifact/url), so a generic textfield
215
+ // capture with surrounding whitespace still binds to the canonical serialisation.
216
+ assertEquals(bindHumanEmits([{ name: "b", type: "boolean" }], { b: " true " }).facts[0].value, "true");
217
+ assertEquals(bindHumanEmits([{ name: "b", type: "boolean" }], { b: "false " }).facts[0].value, "false");
218
+ assertEquals(
219
+ bindHumanEmits([{ name: "u", type: "url" }], { u: "https://x.test/p" }).facts[0].value,
220
+ "https://x.test/p",
221
+ );
222
+ // A string fact is trimmed before returning (like version/artifact/url), so the canonical
223
+ // serialisation does not depend on incidental surrounding whitespace.
224
+ assertEquals(bindHumanEmits([{ name: "s", type: "string" }], { s: " foo " }).facts[0].value, "foo");
225
+ });
226
+
227
+ // ── Late-binding surfaces (#289) ─────────────────────────────────────────────────────────────────
228
+
229
+ test("humanEmitBind: builds a `<nodeId>.<fact>` qualified bind map (empty for a no-emit node)", () => {
230
+ const { facts } = bindHumanEmits([artifact("resolvedArtifact")], { resolvedArtifact: "@a/b@1.0.0" });
231
+ assertEquals(humanEmitBind("manual-publish", facts), { "manual-publish.resolvedArtifact": "@a/b@1.0.0" });
232
+ assertEquals(humanEmitBind("x", []), {});
233
+ });
234
+
235
+ test("renderHumanEmitBrief: empty for no facts; pins each name→value otherwise", () => {
236
+ assertEquals(renderHumanEmitBrief([]), "");
237
+ const { facts } = bindHumanEmits([artifact("resolvedArtifact")], { resolvedArtifact: "@nanobpm/urban@0.54.0" });
238
+ const brief = renderHumanEmitBrief(facts);
239
+ assertStringIncludes(brief, "Human-emitted facts");
240
+ assertStringIncludes(brief, "`resolvedArtifact` (artifact) → `@nanobpm/urban@0.54.0`");
241
+ });
242
+
243
+ test("renderHumanEmitBrief: neutralises backticks/newlines in a value so the inline-code span can't break or inject", () => {
244
+ const { facts } = bindHumanEmits([str("note")], { note: "a`b\nc" });
245
+ const brief = renderHumanEmitBrief(facts);
246
+ assertStringIncludes(brief, "`note` (string) → `a'b c`");
247
+ assert(!brief.includes("a`b"), "raw backtick must not survive into the inline-code span");
248
+ });
249
+
250
+ // ── Cross-layer / structural guards ──────────────────────────────────────────────────────────────
251
+
252
+ const bpmn = readFileSync("resources/processes/delivery-human.bpmn", "utf8");
253
+ const flat = bpmn.replace(/\s+/g, " ");
254
+
255
+ test("BPMN: the human node is a native SLA-bounded userTask backed by the generic form", () => {
256
+ const task = flat.match(
257
+ new RegExp(`<bpmn:userTask\\b[^>]*\\bid="${DELIVERY_HUMAN_ELEMENT}"[\\s\\S]*?</bpmn:userTask>`),
258
+ );
259
+ assert(task, `${DELIVERY_HUMAN_ELEMENT} must be a <bpmn:userTask>`);
260
+ assertStringIncludes(task![0], "<zeebe:userTask", "must be a native (Zeebe) user task");
261
+ assertStringIncludes(task![0], `formId="${GENERIC_HUMAN_FORM}"`, "must attach the generic typed-emit form");
262
+ assertStringIncludes(task![0], 'candidateGroups="operators"', "must surface to operators");
263
+ // The interrupting SLA boundary timer bounds the node — it nags/times out, never silently wedges.
264
+ const boundary = flat.match(
265
+ new RegExp(`<bpmn:boundaryEvent\\b[^>]*\\battachedToRef="${DELIVERY_HUMAN_ELEMENT}"[\\s\\S]*?</bpmn:boundaryEvent>`),
266
+ );
267
+ assert(boundary, "an SLA boundary timer must be attached to the human task");
268
+ assertStringIncludes(boundary![0], "=escalationSlaTimeout", "the SLA reuses the escalation timeout var");
269
+ assertStringIncludes(boundary![0], "<bpmn:timerEventDefinition", "the SLA arm must be a timer");
270
+ });
271
+
272
+ test("BPMN: humanOutcome is set on BOTH the completion and the SLA-timeout path", () => {
273
+ // Symmetry with readiness-gate.bpmn's `gateOutcome`: a caller reading process variables must be
274
+ // able to distinguish a completed human step from one the SLA timer escalated. The userTask sets
275
+ // "completed" on the normal path; the timeout end event must set "escalated" — otherwise the
276
+ // escalation path ends with humanOutcome unset and the two outcomes are indistinguishable.
277
+ assertStringIncludes(
278
+ flat,
279
+ '<zeebe:output source="=&#34;completed&#34;" target="humanOutcome" />',
280
+ "the completion path must set humanOutcome=completed",
281
+ );
282
+ const escalated = flat.match(
283
+ /<bpmn:endEvent\b[^>]*\bid="human-escalated"[\s\S]*?<\/bpmn:endEvent>/,
284
+ );
285
+ assert(escalated, "the SLA-timeout end event human-escalated must exist");
286
+ assertStringIncludes(
287
+ escalated![0],
288
+ '<zeebe:input source="=&#34;escalated&#34;" target="humanOutcome" />',
289
+ "the SLA-timeout path must set humanOutcome=escalated",
290
+ );
291
+ });
292
+
293
+ test("drift guard: the human element is completer-answerable and surfaces on the inbox", () => {
294
+ // The canonical completer refuses any user task outside ESCALATION_TASK_ELEMENTS, so a model that
295
+ // parks on delivery-human-task while the code doesn't accept it would deploy but be unanswerable
296
+ // (by human OR agent, ADR 0046) — the silent-drift failure this guard closes.
297
+ assert(
298
+ ESCALATION_TASK_ELEMENTS.has(DELIVERY_HUMAN_ELEMENT),
299
+ "ESCALATION_TASK_ELEMENTS must accept the delivery human node",
300
+ );
301
+ // And it must carry an inbox label, or the parked task is invisible on the Tasks cockpit.
302
+ assert(
303
+ typeof USER_TASK_KIND_LABELS[DELIVERY_HUMAN_ELEMENT] === "string",
304
+ "USER_TASK_KIND_LABELS must label the delivery human node",
305
+ );
306
+ });
@@ -0,0 +1,371 @@
1
+ // nano-workforce — the `human` delivery-graph node (ADR 0005, slice S3). A `human` node promotes
2
+ // ADR 0002's user-task+form machinery from an *exception* (something broke) to a *scheduled node* (a
3
+ // planned "now do X" stop that BLOCKS its dependents, is answerable by a human OR an agent — ADR 0046
4
+ // — is SLA-bounded so it nags and cannot silently wedge the graph, and can EMIT a typed fact its form
5
+ // captures which late-binds downstream). It is the emit-side of #263's emit-vs-poll dual: a *human*
6
+ // emitter is the same shape as an automated one (the `capability`/`pr` probe's `resolvedArtifact`
7
+ // bind), which is what unifies human and automated steps in one graph.
8
+ //
9
+ // This module owns the PURE, side-effect-free machinery the compiler (S1, author-time) and the
10
+ // runner (S4, runtime) reuse — it never itself creates a user task or completes one. Execution stays
11
+ // engine-native (Decision 2): the human node's body is the deployed `delivery-human` `bpmn:userTask`
12
+ // (`resources/processes/delivery-human.bpmn`, an SLA-bounded scheduled user task), completion routes
13
+ // through the ONE canonical completer (`completeEscalationAsAgent`/`completeEscalationAsHuman`,
14
+ // `app/agentCompletion.ts`) because `DELIVERY_HUMAN_ELEMENT` is registered in
15
+ // `ESCALATION_TASK_ELEMENTS`, and the emitted fact threads downstream via the #289 brief-appender /
16
+ // bound-output pattern (`renderResolvedDepsBrief` / the `caps-resolved` → `appendPrompt` recipe).
17
+ //
18
+ // The three pure concerns:
19
+ // 1. FORM RESOLUTION — specific-else-generic (Decision 4): an explicit `human.formKey` on the node,
20
+ // else a form SELECTED by node category (derived from the node's typed emits), else a GENERIC
21
+ // fallback form that STILL captures a typed emitted fact — so every human node can emit even with
22
+ // no bespoke form. A runtime agent-form-router is a GATED exception: it fires ONLY when a node
23
+ // activates with no statically resolvable form (multiple heterogeneous emits, no explicit/bespoke
24
+ // form), never in the common path — the same "deterministic default, agent judgment as the escape
25
+ // hatch" grain as the capability probe's empirical verifier.
26
+ // 2. TYPED EMIT — validate + coerce the completed form output against the node's declared `emits[]`
27
+ // (Decision 3/4 — binds are validated, not stringly), producing the typed facts the node hands
28
+ // forward. A "click done" node declares no emits — the degenerate no-emit case.
29
+ // 3. LATE-BINDING — render the emitted facts into a downstream node's brief (`renderHumanEmitBrief`,
30
+ // mirroring `renderResolvedDepsBrief`) and into a qualified bind map keyed `<nodeId>.<fact>`
31
+ // (`humanEmitBind`, mirroring the probe `bind`) so a downstream `capability`/`npm`/`pr` edge
32
+ // binds and pins exactly the value the human handed forward.
33
+ import type { DeliveryFact, DeliveryNodeHuman } from "../nano-generated/api-io.d.ts";
34
+ import { DELIVERY_FACT_TYPES, type DeliveryFactType, FACT_NAME_MAX_LENGTH, FACT_NAME_PATTERN } from "./deliveryGraph.ts";
35
+
36
+ /** The single BPMN `bpmn:userTask` element id every `human` delivery-graph node schedules its work
37
+ * as (`resources/processes/delivery-human.bpmn`). One reusable engine-native body, instantiated once
38
+ * per human node (mirroring the one `readiness-gate` process instantiated per `wait` node). It is
39
+ * registered in `ESCALATION_TASK_ELEMENTS` (`app/agentCompletion.ts`) so a human OR an agent
40
+ * (ADR 0046) can complete it through the ONE canonical `complete-user-task` / `agent-complete` door,
41
+ * and in `USER_TASK_KIND_LABELS` (`app/userTasks.ts`) so it surfaces on the Tasks inbox. */
42
+ export const DELIVERY_HUMAN_ELEMENT = "delivery-human-task";
43
+
44
+ /** The GENERIC fallback form (Decision 4, step 3): captures ONE typed value into the node's single
45
+ * declared emitted fact, so a human node with no explicit/category form can STILL emit downstream. */
46
+ export const GENERIC_HUMAN_FORM = "delivery-human-generic";
47
+
48
+ /** The "click done" category form: a degenerate no-emit acknowledgement ("now do X" → done). */
49
+ export const HUMAN_ACK_FORM = "delivery-human-ack";
50
+
51
+ /** The manual-publish category form: captures a `resolvedArtifact` (`pkg@version`) — the motivating
52
+ * case where a human hands a just-published version forward to a downstream `capability`/`npm`/`pr`
53
+ * edge. */
54
+ export const HUMAN_PUBLISH_FORM = "delivery-human-publish";
55
+
56
+ /** A human node's derived CATEGORY — a coarse classification of what the node emits, used to SELECT a
57
+ * bespoke form (Decision 4, step 2) without the author naming one. Derived from the node's typed
58
+ * `emits[]` so the same typed-fact declaration that drives late-binding also drives form selection —
59
+ * one source of truth, no second author-facing knob (the frozen S0 `human` config carries only
60
+ * `formKey`/`prompt`). `null` ⇒ no bespoke category form applies (fall through to generic/router). */
61
+ export type HumanNodeCategory = "ack" | "publish";
62
+
63
+ /** Category → its bespoke form. Kept as the single source of truth for "which form a category selects"
64
+ * so {@link resolveHumanForm} and any preview/compile step agree. */
65
+ export const HUMAN_CATEGORY_FORMS: Readonly<Record<HumanNodeCategory, string>> = {
66
+ ack: HUMAN_ACK_FORM,
67
+ publish: HUMAN_PUBLISH_FORM,
68
+ };
69
+
70
+ /** How a human node's form was resolved. `explicit` — the node named a `human.formKey`; `category` —
71
+ * a form was selected by the node's derived category; `generic` — the typed-emit-capturing fallback;
72
+ * `agent-router` — NOTHING statically resolved, so a runtime agent must pick/assemble one (the gated
73
+ * exception). */
74
+ export type HumanFormSource = "explicit" | "category" | "generic" | "agent-router";
75
+
76
+ /** The resolved form for a human node (Decision 4). `formKey` is the `.form` to attach — `null` ONLY
77
+ * for `agent-router`, where the runtime supplies it. `category` records the derived category when one
78
+ * applied. `reason` is a human-readable one-liner for the compiled preview. */
79
+ export interface HumanFormResolution {
80
+ readonly source: HumanFormSource;
81
+ readonly formKey: string | null;
82
+ readonly category: HumanNodeCategory | null;
83
+ readonly emits: readonly DeliveryFact[];
84
+ readonly reason: string;
85
+ }
86
+
87
+ /** A typed fact a completed human node hands forward — its declared `name`/`type` plus the validated,
88
+ * canonically-serialised `value` (a string, like the probe `bind`, so it threads uniformly into
89
+ * prompts, messages, and qualified bind maps). */
90
+ export interface BoundFact {
91
+ readonly name: string;
92
+ readonly type: DeliveryFactType;
93
+ readonly value: string;
94
+ }
95
+
96
+ /** The outcome of binding a completed form's output to a node's declared emits: the typed `facts` in
97
+ * declaration order plus any path-qualified `errors` (a missing/ill-typed declared fact). An empty
98
+ * `emits` yields `{ facts: [], errors: [] }` — the degenerate no-emit "click done" case. */
99
+ export interface HumanEmitResult {
100
+ readonly facts: readonly BoundFact[];
101
+ readonly errors: readonly string[];
102
+ }
103
+
104
+ /** Narrow an untyped value to a plain object so its fields can be read defensively. */
105
+ function isRecord(value: unknown): value is Record<string, unknown> {
106
+ return typeof value === "object" && value !== null && !Array.isArray(value);
107
+ }
108
+
109
+ /** True when `value` is one of the closed set of delivery fact types (a type guard, so the pure
110
+ * form/emit logic narrows without an `as` assertion). */
111
+ function isDeliveryFactType(value: unknown): value is DeliveryFactType {
112
+ return typeof value === "string" && DELIVERY_FACT_TYPES.some((t) => t === value);
113
+ }
114
+
115
+ /** Normalise a human node's `emits[]` to the well-formed, typed declarations, dropping anything the
116
+ * S0 validator would already have rejected (a non-record entry, a blank name, a name that is not a
117
+ * dot-free identifier within the 128-char cap, a duplicate name, or an unknown type) so the pure
118
+ * form/emit logic never trips on a malformed declaration — the graph is validated upstream by
119
+ * `validateDeliveryGraph`, this is defence in depth. Fact-name pattern/length are the SAME canonical
120
+ * constants the validator enforces (`FACT_NAME_PATTERN`/`FACT_NAME_MAX_LENGTH`), so a dotted /
121
+ * over-long / duplicate name can't slip through here and make `<nodeId>.<fact>` binding ambiguous. */
122
+ export function normalizeEmits(node: Pick<DeliveryNodeHuman, "emits">): DeliveryFact[] {
123
+ const raw = node.emits;
124
+ if (!Array.isArray(raw)) return [];
125
+ const facts: DeliveryFact[] = [];
126
+ const seen = new Set<string>();
127
+ for (const entry of raw) {
128
+ if (!isRecord(entry)) continue;
129
+ const name = entry.name;
130
+ const type = entry.type;
131
+ if (typeof name !== "string" || name.length === 0) continue;
132
+ if (name.length > FACT_NAME_MAX_LENGTH || !FACT_NAME_PATTERN.test(name)) continue;
133
+ if (seen.has(name)) continue;
134
+ if (!isDeliveryFactType(type)) continue;
135
+ seen.add(name);
136
+ facts.push({ name, type, ...(typeof entry.description === "string" ? { description: entry.description } : {}) });
137
+ }
138
+ return facts;
139
+ }
140
+
141
+ /** Derive a human node's CATEGORY from its typed emits (Decision 4, step 2), or `null` when no bespoke
142
+ * category form applies:
143
+ * - `ack` — the node emits NOTHING (a "click done" acknowledgement).
144
+ * - `publish` — the node emits EXACTLY ONE `artifact` fact (a `pkg@version` handle — the manual-
145
+ * publish-hands-a-version-forward case), which the bespoke publish form captures as a
146
+ * `resolvedArtifact`. A lone `version` (a BARE version, e.g. `1.4.0`) does NOT map here: the
147
+ * publish form captures a `pkg@version` into `resolvedArtifact`, which fails `version` coercion in
148
+ * {@link bindHumanEmits} — so a single `version` falls through to the generic single-value form
149
+ * (which captures a bare `value`, validated against the `version` type).
150
+ * - `null` — a single non-artifact scalar/url/version fact (the generic fallback captures it), OR
151
+ * two-or-more facts (no bespoke or generic single-value form can hold them → the agent-router
152
+ * territory).
153
+ * Kept coarse ON PURPOSE: bespoke category forms are a deterministic convenience, not an open
154
+ * taxonomy; anything they don't cover falls through to the generic fallback or the gated router. */
155
+ export function deriveHumanCategory(emits: readonly DeliveryFact[]): HumanNodeCategory | null {
156
+ if (emits.length === 0) return "ack";
157
+ if (emits.length === 1 && emits[0].type === "artifact") return "publish";
158
+ return null;
159
+ }
160
+
161
+ /** Resolve which form a human node uses, specific-else-generic (ADR 0005 Decision 4). Preference:
162
+ * 1. `explicit` — the node carries a non-blank `human.formKey`.
163
+ * 2. `category` — the node's derived category selects a bespoke form ({@link deriveHumanCategory}).
164
+ * 3. `generic` — the node emits ≤1 fact, captured by the generic typed-emit fallback form.
165
+ * 4. `agent-router` — NOTHING statically resolved (≥2 heterogeneous emits, no explicit/bespoke
166
+ * form): a runtime agent must pick/assemble a form. This is the GATED escape
167
+ * hatch — it fires ONLY here, never in the common path.
168
+ * Pure and total (never throws): the graph is shape/semantic-validated upstream, and malformed emits
169
+ * are normalised away, so this always returns a resolution. Author-time callers (the S1 compiler)
170
+ * attach `formKey` deterministically so the resolved form is visible in the preview; only the
171
+ * `agent-router` case defers to runtime. */
172
+ export function resolveHumanForm(node: Pick<DeliveryNodeHuman, "emits" | "human">): HumanFormResolution {
173
+ const emits = normalizeEmits(node);
174
+ const explicit = node.human?.formKey;
175
+ if (typeof explicit === "string" && explicit.trim().length > 0) {
176
+ return {
177
+ source: "explicit",
178
+ formKey: explicit.trim(),
179
+ category: null,
180
+ emits,
181
+ reason: `explicit form "${explicit.trim()}" attached on the node`,
182
+ };
183
+ }
184
+ const category = deriveHumanCategory(emits);
185
+ if (category !== null) {
186
+ return {
187
+ source: "category",
188
+ formKey: HUMAN_CATEGORY_FORMS[category],
189
+ category,
190
+ emits,
191
+ reason:
192
+ category === "ack"
193
+ ? "no emitted fact — the generic acknowledgement (click-done) form"
194
+ : `emits a single ${emits[0].type} fact — the manual-publish form (captures a resolvedArtifact)`,
195
+ };
196
+ }
197
+ if (emits.length <= 1) {
198
+ return {
199
+ source: "generic",
200
+ formKey: GENERIC_HUMAN_FORM,
201
+ category: null,
202
+ reason: `emits a single ${emits[0].type} fact — the generic typed-emit fallback form`,
203
+ emits,
204
+ };
205
+ }
206
+ return {
207
+ source: "agent-router",
208
+ formKey: null,
209
+ category: null,
210
+ emits,
211
+ reason:
212
+ `emits ${emits.length} typed facts and carries no explicit/bespoke form — no static form can ` +
213
+ "capture them, so a runtime agent-form-router must assemble one (the gated exception)",
214
+ };
215
+ }
216
+
217
+ /** True when a human node resolves to the gated runtime agent-form-router — i.e. nothing statically
218
+ * resolved. A thin predicate over {@link resolveHumanForm} so callers can branch without re-deriving. */
219
+ export function needsAgentFormRouter(node: Pick<DeliveryNodeHuman, "emits" | "human">): boolean {
220
+ return resolveHumanForm(node).source === "agent-router";
221
+ }
222
+
223
+ /** The canonical form-field keys a bespoke/generic form captures its single typed value under, tried
224
+ * (in addition to the fact's own name) when binding a single-fact node's output. The generic form
225
+ * captures `value`; the publish form captures `resolvedArtifact`. */
226
+ const CANONICAL_VALUE_KEYS = ["resolvedArtifact", "value"] as const;
227
+
228
+ /** The ONE accepted shape of a bare version string (an optional `v` then a digit-led `[\w.+-]` run,
229
+ * e.g. `1.4.0`, `v2.0.0-rc.1`). The single source of truth shared by the `version` fact type AND the
230
+ * version segment of an `artifact` handle — so a `pkg@version` artifact validates its version the
231
+ * same way a bare `version` does, with no second notion of "valid version" to drift. */
232
+ const VERSION_PATTERN = /^v?\d[\w.+-]*$/;
233
+
234
+ /** Coerce + validate one raw form value against a declared fact type, returning the canonical string
235
+ * serialisation or an error message. Typed so a bind is validated, not stringly (Decision 3/4): an
236
+ * `artifact` must be `pkg@version` (with a well-formed version segment), a `version` a bare version, a
237
+ * `url` a parseable location, a `number` finite, a `boolean` a real boolean. */
238
+ function coerceFactValue(type: DeliveryFactType, raw: unknown): { value: string } | { error: string } {
239
+ switch (type) {
240
+ case "string": {
241
+ if (typeof raw !== "string" || raw.trim() === "") return { error: "expected a non-empty string" };
242
+ // Return the trimmed value, mirroring the other string-like types (version/artifact/url) so the
243
+ // canonical serialisation does not depend on incidental surrounding whitespace.
244
+ return { value: raw.trim() };
245
+ }
246
+ case "number": {
247
+ const n = typeof raw === "number" ? raw : typeof raw === "string" && raw.trim() !== "" ? Number(raw) : Number.NaN;
248
+ if (!Number.isFinite(n)) return { error: "expected a finite number" };
249
+ return { value: String(n) };
250
+ }
251
+ case "boolean": {
252
+ if (typeof raw === "boolean") return { value: String(raw) };
253
+ // Trim before validating, mirroring the other string-like types (version/artifact/url) so a
254
+ // boolean captured via the generic textfield (e.g. " true ") is not needlessly brittle.
255
+ const trimmed = typeof raw === "string" ? raw.trim() : raw;
256
+ if (trimmed === "true" || trimmed === "false") return { value: trimmed };
257
+ return { error: "expected a boolean (true/false)" };
258
+ }
259
+ case "version": {
260
+ if (typeof raw !== "string" || !VERSION_PATTERN.test(raw.trim())) {
261
+ return { error: "expected a version string (e.g. 1.4.0)" };
262
+ }
263
+ return { value: raw.trim() };
264
+ }
265
+ case "artifact": {
266
+ if (typeof raw !== "string") return { error: "expected a pkg@version artifact handle" };
267
+ const at = raw.trim().lastIndexOf("@");
268
+ const name = raw.trim().slice(0, at);
269
+ const version = raw.trim().slice(at + 1);
270
+ if (at <= 0 || name.length === 0 || version.length === 0 || !VERSION_PATTERN.test(version)) {
271
+ return { error: "expected a pkg@version artifact handle (e.g. @nanobpm/urban@0.54.0)" };
272
+ }
273
+ return { value: raw.trim() };
274
+ }
275
+ case "url": {
276
+ if (typeof raw !== "string" || raw.trim() === "") return { error: "expected a URL" };
277
+ try {
278
+ new URL(raw.trim());
279
+ } catch {
280
+ return { error: "expected a valid URL (with a scheme)" };
281
+ }
282
+ return { value: raw.trim() };
283
+ }
284
+ default:
285
+ return { error: `unknown fact type "${type}"` };
286
+ }
287
+ }
288
+
289
+ /** Bind a completed human node's form `output` to its declared `emits[]` (Decision 3/4). For each
290
+ * declared fact, read its value from the output — preferring the fact's own `name` key, then, for a
291
+ * single-fact node, the canonical capture keys the generic/publish forms use (`resolvedArtifact` /
292
+ * `value`) — and validate/coerce it against the fact's type. Returns the typed facts in declaration
293
+ * order plus one error per missing/ill-typed declared fact. A node with no declared emits yields no
294
+ * facts and no errors (the degenerate "click done" case) regardless of what the form captured.
295
+ *
296
+ * This is the typed-emit contract the runner (S4) enforces on completion before threading the facts
297
+ * downstream via {@link humanEmitBind} / {@link renderHumanEmitBrief} — the emit-side of #263. */
298
+ export function bindHumanEmits(
299
+ emits: readonly DeliveryFact[],
300
+ output: Record<string, unknown> | null | undefined,
301
+ ): HumanEmitResult {
302
+ const out = isRecord(output) ? output : {};
303
+ const facts: BoundFact[] = [];
304
+ const errors: string[] = [];
305
+ const single = emits.length === 1;
306
+ for (const fact of emits) {
307
+ let raw = out[fact.name];
308
+ if (raw === undefined && single) {
309
+ for (const key of CANONICAL_VALUE_KEYS) {
310
+ if (out[key] !== undefined) {
311
+ raw = out[key];
312
+ break;
313
+ }
314
+ }
315
+ }
316
+ if (raw === undefined || raw === null) {
317
+ errors.push(`emits.${fact.name}: no value captured for the declared ${fact.type} fact`);
318
+ continue;
319
+ }
320
+ const coerced = coerceFactValue(fact.type, raw);
321
+ if ("error" in coerced) {
322
+ errors.push(`emits.${fact.name}: ${coerced.error}`);
323
+ continue;
324
+ }
325
+ facts.push({ name: fact.name, type: fact.type, value: coerced.value });
326
+ }
327
+ return { facts, errors };
328
+ }
329
+
330
+ /** Build the qualified bind map a human node's emitted facts publish downstream, keyed exactly as a
331
+ * delivery edge references them — `<nodeId>.<fact>` (mirroring the probe `bind: Record<string,string>`
332
+ * and the `capability` kind's `resolvedArtifact`). A downstream edge `from: "<nodeId>.<fact>"` binds
333
+ * and PINS the human-handed value from this map. Empty for a no-emit node. */
334
+ export function humanEmitBind(nodeId: string, facts: readonly BoundFact[]): Record<string, string> {
335
+ const bind: Record<string, string> = {};
336
+ for (const fact of facts) bind[`${nodeId}.${fact.name}`] = fact.value;
337
+ return bind;
338
+ }
339
+
340
+ /** Neutralise a human-submitted value before it is embedded in a Markdown inline-code span in the
341
+ * brief. A raw backtick would terminate the span (corrupting the brief and letting free-form form
342
+ * text inject unintended prompt content into the downstream agent's prompt), and a newline would
343
+ * break the span; collapse both. Display-only — the authoritative value published downstream via
344
+ * {@link humanEmitBind} is untouched, so the pinned contract is unaffected. */
345
+ function inlineCodeSafe(value: string): string {
346
+ return value.replace(/`/g, "'").replace(/\r?\n/g, " ");
347
+ }
348
+
349
+ /** Render the "human-emitted facts" brief appended to a downstream node's prompt once a human node
350
+ * completes and hands its typed facts forward (#289 §3), mirroring `renderResolvedDepsBrief`. It pins
351
+ * each `name → value` the human declared + captured so a downstream agent/edge consumes EXACTLY that
352
+ * value — no re-derivation, no human re-entry. Returns "" for a no-emit node so callers concatenate
353
+ * unconditionally (the same `if X = null then "" else X` FEEL convention as the other briefs). */
354
+ export function renderHumanEmitBrief(facts: readonly BoundFact[]): string {
355
+ if (facts.length === 0) return "";
356
+ const lines = [
357
+ "",
358
+ "",
359
+ "---",
360
+ "",
361
+ "**Human-emitted facts (authoritative — a scheduled human step handed these forward):**",
362
+ "",
363
+ "A `human` node upstream captured and emitted the typed values below. Consume/pin exactly these —",
364
+ "do NOT re-derive, float, or re-request them:",
365
+ "",
366
+ ];
367
+ for (const fact of facts) lines.push(`- \`${fact.name}\` (${fact.type}) → \`${inlineCodeSafe(fact.value)}\``);
368
+ lines.push("");
369
+ lines.push("These are the contract the human handed forward; a different value is a different run.");
370
+ return lines.join("\n");
371
+ }
package/app/userTasks.ts CHANGED
@@ -19,6 +19,7 @@
19
19
  // it open.
20
20
  import type { DataLayer } from "@nanobpm/urban";
21
21
  import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
22
+ import { DELIVERY_HUMAN_ELEMENT } from "./deliveryHuman.ts";
22
23
  import { FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, type FeatureEscalationRow } from "./feature.ts";
23
24
  import type { PlanReview } from "./plan.ts";
24
25
  import type { TrialMergeAuditRow } from "./trialMerge.ts";
@@ -76,13 +77,14 @@ export const USER_TASK_KIND_LABELS: Readonly<Record<string, string>> = {
76
77
  [PR_WAIT_ANSWER_ELEMENT]: "PR review",
77
78
  [PR_WAIT_MERGE_ANSWER_ELEMENT]: "PR merge",
78
79
  [CONFORMANCE_ESCALATION_ELEMENT]: "Conformance review",
80
+ [DELIVERY_HUMAN_ELEMENT]: "Delivery: human step",
79
81
  };
80
82
 
81
83
  /** The denormalised context the poller has resolved for an open escalation user task. */
82
84
  export interface UserTaskContext {
83
85
  userTaskKey: string;
84
86
  elementId: string;
85
- subjectType: "feature" | "plan" | "pr";
87
+ subjectType: "feature" | "plan" | "pr" | "delivery";
86
88
  subjectKey: string;
87
89
  /** The subject's human-readable title from its own row (`feature_runs`/`plans`/`pull_requests`.
88
90
  * `title`). Optional/blank tolerated — `buildUserTaskRow` coalesces it to `subjectKey` so the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.112.0",
3
+ "version": "0.113.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",
@@ -0,0 +1,19 @@
1
+ {
2
+ "id": "delivery-human-ack",
3
+ "schemaVersion": 18,
4
+ "type": "default",
5
+ "components": [
6
+ {
7
+ "type": "textarea",
8
+ "key": "prompt",
9
+ "label": "Now do this",
10
+ "description": "The scheduled human step this delivery graph is waiting on. Complete it once done — this node emits nothing (the degenerate click-done case) and simply unblocks its dependents.",
11
+ "readonly": true
12
+ },
13
+ {
14
+ "type": "textarea",
15
+ "key": "note",
16
+ "label": "Note (optional)"
17
+ }
18
+ ]
19
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "id": "delivery-human-generic",
3
+ "schemaVersion": 18,
4
+ "type": "default",
5
+ "components": [
6
+ {
7
+ "type": "textarea",
8
+ "key": "prompt",
9
+ "label": "Now do this",
10
+ "description": "The scheduled human step this delivery graph is waiting on.",
11
+ "readonly": true
12
+ },
13
+ {
14
+ "type": "textfield",
15
+ "key": "value",
16
+ "label": "Emitted value",
17
+ "description": "The typed value this step hands forward to its downstream dependents. Validated against the node's declared emitted fact.",
18
+ "validate": {
19
+ "required": true
20
+ }
21
+ },
22
+ {
23
+ "type": "textarea",
24
+ "key": "note",
25
+ "label": "Note (optional)"
26
+ }
27
+ ]
28
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "id": "delivery-human-publish",
3
+ "schemaVersion": 18,
4
+ "type": "default",
5
+ "components": [
6
+ {
7
+ "type": "textarea",
8
+ "key": "prompt",
9
+ "label": "Now do this (manual publish)",
10
+ "description": "The manual step no automation can cross — e.g. an OTP publish + OIDC trusted-publishing setup. Complete it once the artifact is published.",
11
+ "readonly": true
12
+ },
13
+ {
14
+ "type": "textfield",
15
+ "key": "resolvedArtifact",
16
+ "label": "Resolved artifact (pkg@version)",
17
+ "description": "The exact published handle to hand forward, e.g. @nanobpm/urban@0.54.0. A downstream capability/npm/pr edge binds and pins this.",
18
+ "validate": {
19
+ "required": true
20
+ }
21
+ },
22
+ {
23
+ "type": "textarea",
24
+ "key": "note",
25
+ "label": "Note (optional)"
26
+ }
27
+ ]
28
+ }
@@ -0,0 +1,87 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" id="Definitions_nano_workforce_delivery_human" targetNamespace="http://nanobpm.io/nano-workforce">
3
+ <bpmn:process id="delivery-human" name="Delivery graph — human node (scheduled user task)" isExecutable="true">
4
+ <bpmn:startEvent id="Start" name="Human node scheduled">
5
+ <bpmn:outgoing>f_start</bpmn:outgoing>
6
+ </bpmn:startEvent>
7
+ <bpmn:userTask id="delivery-human-task" name="Delivery: human step (now do X)">
8
+ <bpmn:extensionElements>
9
+ <zeebe:formDefinition formId="delivery-human-generic" />
10
+ <zeebe:userTask />
11
+ <zeebe:assignmentDefinition candidateGroups="operators" assignee="=if (is defined(escalationAssignee) and escalationAssignee != null and trim(string(escalationAssignee)) != &#34;&#34;) then escalationAssignee else null" />
12
+ <zeebe:ioMapping>
13
+ <zeebe:output source="=&#34;completed&#34;" target="humanOutcome" />
14
+ <zeebe:output source="=if (is defined(value)) then value else null" target="humanEmitValue" />
15
+ <zeebe:output source="=if (is defined(resolvedArtifact)) then resolvedArtifact else null" target="humanEmitArtifact" />
16
+ <zeebe:output source="=if (is defined(note)) then note else null" target="humanNote" />
17
+ </zeebe:ioMapping>
18
+ </bpmn:extensionElements>
19
+ <bpmn:incoming>f_start</bpmn:incoming>
20
+ <bpmn:outgoing>f_done</bpmn:outgoing>
21
+ </bpmn:userTask>
22
+ <bpmn:boundaryEvent id="be_delivery_human_sla" name="SLA elapsed" attachedToRef="delivery-human-task">
23
+ <bpmn:outgoing>f_sla</bpmn:outgoing>
24
+ <bpmn:timerEventDefinition id="ted_delivery_human_sla">
25
+ <bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=escalationSlaTimeout</bpmn:timeDuration>
26
+ </bpmn:timerEventDefinition>
27
+ </bpmn:boundaryEvent>
28
+ <bpmn:endEvent id="human-done" name="Human step done (fact emitted)">
29
+ <bpmn:incoming>f_done</bpmn:incoming>
30
+ </bpmn:endEvent>
31
+ <bpmn:endEvent id="human-escalated" name="Human step timed out (escalated)">
32
+ <bpmn:extensionElements>
33
+ <zeebe:ioMapping>
34
+ <zeebe:input source="=&#34;escalated&#34;" target="humanOutcome" />
35
+ </zeebe:ioMapping>
36
+ </bpmn:extensionElements>
37
+ <bpmn:incoming>f_sla</bpmn:incoming>
38
+ </bpmn:endEvent>
39
+ <bpmn:sequenceFlow id="f_start" sourceRef="Start" targetRef="delivery-human-task" />
40
+ <bpmn:sequenceFlow id="f_done" sourceRef="delivery-human-task" targetRef="human-done" />
41
+ <bpmn:sequenceFlow id="f_sla" sourceRef="be_delivery_human_sla" targetRef="human-escalated" />
42
+ </bpmn:process>
43
+ <bpmndi:BPMNDiagram id="BPMNDiagram_delivery-human">
44
+ <bpmndi:BPMNPlane id="BPMNPlane_delivery-human" bpmnElement="delivery-human">
45
+ <bpmndi:BPMNShape id="BPMNShape_Start" bpmnElement="Start">
46
+ <dc:Bounds x="80" y="102" width="36" height="36" />
47
+ <bpmndi:BPMNLabel>
48
+ <dc:Bounds x="60" y="143" width="76" height="28" />
49
+ </bpmndi:BPMNLabel>
50
+ </bpmndi:BPMNShape>
51
+ <bpmndi:BPMNShape id="BPMNShape_delivery-human-task" bpmnElement="delivery-human-task">
52
+ <dc:Bounds x="216" y="80" width="100" height="80" />
53
+ </bpmndi:BPMNShape>
54
+ <bpmndi:BPMNShape id="BPMNShape_human-done" bpmnElement="human-done">
55
+ <dc:Bounds x="416" y="102" width="36" height="36" />
56
+ <bpmndi:BPMNLabel>
57
+ <dc:Bounds x="396" y="143" width="76" height="42" />
58
+ </bpmndi:BPMNLabel>
59
+ </bpmndi:BPMNShape>
60
+ <bpmndi:BPMNShape id="BPMNShape_human-escalated" bpmnElement="human-escalated">
61
+ <dc:Bounds x="416" y="262" width="36" height="36" />
62
+ <bpmndi:BPMNLabel>
63
+ <dc:Bounds x="394" y="303" width="81" height="42" />
64
+ </bpmndi:BPMNLabel>
65
+ </bpmndi:BPMNShape>
66
+ <bpmndi:BPMNShape id="BPMNShape_be_delivery_human_sla" bpmnElement="be_delivery_human_sla">
67
+ <dc:Bounds x="248" y="142" width="36" height="36" />
68
+ <bpmndi:BPMNLabel>
69
+ <dc:Bounds x="164" y="183" width="84" height="14" />
70
+ </bpmndi:BPMNLabel>
71
+ </bpmndi:BPMNShape>
72
+ <bpmndi:BPMNEdge id="BPMNEdge_f_start" bpmnElement="f_start">
73
+ <di:waypoint x="116" y="120" />
74
+ <di:waypoint x="216" y="120" />
75
+ </bpmndi:BPMNEdge>
76
+ <bpmndi:BPMNEdge id="BPMNEdge_f_done" bpmnElement="f_done">
77
+ <di:waypoint x="316" y="120" />
78
+ <di:waypoint x="416" y="120" />
79
+ </bpmndi:BPMNEdge>
80
+ <bpmndi:BPMNEdge id="BPMNEdge_f_sla" bpmnElement="f_sla">
81
+ <di:waypoint x="266" y="178" />
82
+ <di:waypoint x="266" y="280" />
83
+ <di:waypoint x="416" y="280" />
84
+ </bpmndi:BPMNEdge>
85
+ </bpmndi:BPMNPlane>
86
+ </bpmndi:BPMNDiagram>
87
+ </bpmn:definitions>
@@ -50,11 +50,12 @@ test("every corpus model is either ported or has a documented blocker", () => {
50
50
  "convergence-loop",
51
51
  "merge-loop",
52
52
  "plan-fanout",
53
+ "delivery-human",
53
54
  ];
54
55
  assertEquals(
55
56
  PORTS.map((p) => p.model),
56
57
  expected,
57
- "PORTS must cover all seven goldens in the epic's authoring order",
58
+ "PORTS must cover all eight goldens in the epic's authoring order",
58
59
  );
59
60
  for (const port of PORTS) {
60
61
  assert(
@@ -66,14 +67,21 @@ test("every corpus model is either ported or has a documented blocker", () => {
66
67
 
67
68
  // The blockers are not guesses — prove each against the goldens themselves.
68
69
  //
69
- // CLASS 1 — five goldens have MORE THAN ONE top-level start and/or end event,
70
+ // CLASS 1 — six goldens have MORE THAN ONE top-level start and/or end event,
70
71
  // which the published `@nanobpm/workflow@0.12.0` compiler (a single
71
72
  // `<startEvent id="Start">` + single `<endEvent id="End">`) cannot derive.
72
73
  test("class-1 blocked goldens genuinely have multiple top-level start/end events", () => {
73
74
  const countTag = (xml: string, tag: string): number =>
74
75
  (xml.match(new RegExp(`<bpmn:${tag}\\b`, "g")) ?? []).length;
75
76
 
76
- const multiStartEndBlocked = new Set(["spine-demo", "readiness-gate", "feature", "merge-loop", "plan-fanout"]);
77
+ const multiStartEndBlocked = new Set([
78
+ "spine-demo",
79
+ "readiness-gate",
80
+ "feature",
81
+ "merge-loop",
82
+ "plan-fanout",
83
+ "delivery-human",
84
+ ]);
77
85
  for (const model of multiStartEndBlocked) {
78
86
  const xml = readFileSync(goldenPath(model), "utf8");
79
87
  const starts = countTag(xml, "startEvent");
@@ -9,13 +9,13 @@
9
9
  // the structurally-derivable goldens at full whole-model parity, park the rest
10
10
  // pending an upstream construct, and do NOT relax to node-surface parity):
11
11
  //
12
- // • `retro` is a GREEN whole-model parity port (see below). The remaining six
12
+ // • `retro` is a GREEN whole-model parity port (see below). The remaining seven
13
13
  // goldens are `blockedReason`-parked, in TWO distinct classes, each awaiting
14
14
  // an upstream `@nanobpm/workflow` (nano-ide) construct + re-release — never a
15
15
  // golden edit and never relaxed acceptance:
16
16
  //
17
17
  // (1) MULTI top-level start/end (spine-demo, readiness-gate, feature,
18
- // merge-loop, plan-fanout). `@nanobpm/workflow` derives EXACTLY
18
+ // merge-loop, plan-fanout, delivery-human). `@nanobpm/workflow` derives EXACTLY
19
19
  // ONE `<bpmn:startEvent id="Start">` + ONE `<bpmn:endEvent id="End">`,
20
20
  // converging every dangler into that single end (see `Compiler.compile`
21
21
  // in the package's `declarative.ts`). Needs a terminal/explicit-end
@@ -146,7 +146,7 @@ const MULTI_START_END_BLOCK =
146
146
  "cannot reproduce. Awaits an upstream terminal/explicit-end (+ multi-start) " +
147
147
  "construct in @nanobpm/workflow (nano-ide).";
148
148
 
149
- /** All seven ports, keyed by model, in the epic's stated authoring order. */
149
+ /** All ports, keyed by model, in the epic's stated authoring order. */
150
150
  export const PORTS: readonly PortEntry[] = [
151
151
  { model: "retro", flow: retroFlow },
152
152
  {
@@ -183,4 +183,8 @@ export const PORTS: readonly PortEntry[] = [
183
183
  model: "plan-fanout",
184
184
  blockedReason: `${MULTI_START_END_BLOCK} (plan-fanout: 3 starts, 3 ends)`,
185
185
  },
186
+ {
187
+ model: "delivery-human",
188
+ blockedReason: `${MULTI_START_END_BLOCK} (delivery-human: 1 start, 2 ends)`,
189
+ },
186
190
  ];