@nanobpm/nano-workforce 0.201.0 → 0.201.1

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.
@@ -19,6 +19,7 @@
19
19
  // Every error carries a JSON-path-qualified `path` (`nodes[2].kind`, `edges[1].from`, …) so the
20
20
  // caller can point the author straight at the offending input.
21
21
 
22
+ import { TRANSCRIPT_URL_BASE_VAR } from "./agentic/transcript-url.ts";
22
23
  import { isPlausibleBranchName } from "./baseBranch.ts";
23
24
  import { isEnvKey } from "./contracts.ts";
24
25
  import { isConvergeTarget } from "./convergeTargets.ts";
@@ -107,10 +108,13 @@ export type DeliveryGraphErrorCode =
107
108
  | "credential-in-job-type"
108
109
  | "embedded-url-in-job-type"
109
110
  | "scheme-relative-url-in-job-type"
111
+ | "invalid-form-key"
112
+ | "credential-in-form-key"
110
113
  | "invalid-credential-env"
111
114
  | "invalid-backoff"
112
115
  | "unbound-pr"
113
- | "partial-scope-close";
116
+ | "partial-scope-close"
117
+ | "human-unroutable-emits";
114
118
 
115
119
  /** A single semantic validation failure. `path` is a JSON-path-qualified pointer at the offending
116
120
  * input (`nodes[2].kind`, `edges[1].from`, `nodes[0].emits[1].name`), `message` is human-actionable,
@@ -259,6 +263,216 @@ function isDeliveryFactType(type: unknown): type is DeliveryFactType {
259
263
  export const FACT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
260
264
  export const FACT_NAME_MAX_LENGTH = 128;
261
265
 
266
+ /** The flat result variables a fleet agent worker returns on job completion — the CANONICAL node-local
267
+ * agent result contract. Every field a `resources/prompts/*.md` output contract tells an agent to
268
+ * return is listed here: the blocked-outcome fallback `{status, summary, question}`, the free-form
269
+ * result fields agents commonly emit, the optional structured `delta` an implementer reports
270
+ * (resources/prompts/feature.md), the PR identity fields, and the review/audit contract fields
271
+ * (`plan-review.md` returns `approved`/`findings`; `conformance.md` returns `commentUrl`,
272
+ * `hasDeviations`, and the per-slice/deviation counts). Nano propagates job-completion variables to the
273
+ * NEAREST scope that defines each name, else the ROOT — so the compiler declares each `null` on the
274
+ * node's subProcess (and the retry reset clears it) to keep a node's report node-local; a field omitted
275
+ * here leaks to the shared root, where a parallel node overwrites it and a retry leaves it stale
276
+ * (issue #863 "Previously missed"). Defined HERE, not in the compiler, so the validator's
277
+ * reserved-fact-name set can derive from it without an import cycle.
278
+ *
279
+ * NOTE: these are NOT reserved as emit names — an agent MAY legitimately emit a fact named `pr` (the
280
+ * canonical `agent → connector[converge] → wait[pr]` shape emits the PR it opened under `pr`). The
281
+ * worker's result write and the emit publish the SAME node-local value, so they agree; the retry reset
282
+ * clearing both is correct (the retry must re-produce the fact). Only the escalation controls and the
283
+ * config variables the reset would wrongly null are reserved — see {@link reservedDeliveryFactNames}. */
284
+ export const AGENT_RESULT_LOCAL_VARS = [
285
+ "status",
286
+ "summary",
287
+ "question",
288
+ "output",
289
+ "error",
290
+ "pr",
291
+ "prUrl",
292
+ "pullRequest",
293
+ "branch",
294
+ "commits",
295
+ "exitCode",
296
+ "next_steps",
297
+ "issue",
298
+ "completed",
299
+ "pushed",
300
+ "truncated",
301
+ "agentCheckpoint",
302
+ "transcriptUrl",
303
+ "delta",
304
+ "approved",
305
+ "findings",
306
+ "commentUrl",
307
+ "hasDeviations",
308
+ "slicesMet",
309
+ "slicesReduced",
310
+ "slicesNotVerified",
311
+ "deviationsRaised",
312
+ "deviationsUnraised",
313
+ // The remaining built-in prompt result contracts (issue #863 review — thread r4181321969). Each is a
314
+ // documented output of a `resources/prompts/*.md` agent that a delivery node may run; omitted here it
315
+ // would write at the shared ROOT, where a parallel node overwrites it and a retry retains a stale
316
+ // value:
317
+ // • `dependsOn` — fix-ci.md / rebase.md: the PR(s) that must merge first (`waiting-on-pr`).
318
+ // • `adversarialFindings` / `adversarialSummary` — adversarial-review.md.
319
+ // • `scopeBlocked` / `scopeBlockReason` — scope-classify.md.
320
+ // • `tasks` — plan.md (the decomposed task list).
321
+ // • `result` / `conflicts` / `failing` — trial-merge.md.
322
+ "dependsOn",
323
+ "adversarialFindings",
324
+ "adversarialSummary",
325
+ "scopeBlocked",
326
+ "scopeBlockReason",
327
+ "tasks",
328
+ "result",
329
+ "conflicts",
330
+ "failing",
331
+ // `note` — plan.md's free-text operator note (returned alongside `{ tasks: [] }`). It MUST be
332
+ // node-local: left at the shared root, a parallel node's `note` overwrites it. The escalation
333
+ // retry-guidance control is the DISTINCT `escalationNote` (ESCALATION_LOCAL_VARS) precisely so this
334
+ // worker result can never be read back as operator input (PR #863 "Previously missed").
335
+ "note",
336
+ ] as const;
337
+
338
+ /** The fixed result metadata the delivery-connector worker returns on every job completion
339
+ * (`connectorOutcome`/`connectorDedupeKey`/`connectorDetail` — see `dispatchConnector` in
340
+ * app/deliveryConnector.ts). Like the agent result set, these are declared node-local and cleared on
341
+ * retry so two parallel connectors never overwrite one shared root result. Defined HERE so the
342
+ * validator's reserved set can derive from it without a cycle. */
343
+ export const CONNECTOR_RESULT_LOCAL_VARS = [
344
+ "connectorOutcome",
345
+ "connectorDedupeKey",
346
+ "connectorDetail",
347
+ ] as const;
348
+
349
+ /** The human-node scratch a `human` subProcess writes/reads while the operator completes (or times out
350
+ * of) its user task — the emit scratch the task output mapping captures (`humanEmitValue`/
351
+ * `humanEmitArtifact`), the task's fixed outcome/note (`humanOutcome`/`humanNote`), and the form
352
+ * controls the output mapping SELECTS from (`value`/`resolvedArtifact`/`note`). None of these is
353
+ * node-unique, so left at the shared ROOT they cross-publish between parallel human nodes exactly as an
354
+ * un-localised agent/connector result once did (PR #863 review, thread r4199949849 "Isolate human
355
+ * subprocess scratch state"): if human A completes while human B times out WITHOUT producing a value,
356
+ * B's subProcess output reads A's root `humanEmitValue`/`humanEmitArtifact` and publishes it as B's own
357
+ * `<el>_<fact>`; likewise a B that completes on a blank form reads A's root `value`/`resolvedArtifact`.
358
+ * Declared node-local on each human subProcess (seeded `=null`, like the agent/connector result sets)
359
+ * so Nano's nearest-scope propagation lands each human's capture inside its OWN subProcess. The
360
+ * node's explicit fact-named control (`<factName>`, read first by a bespoke single-emit form) is
361
+ * localised ALONGSIDE this set from the node's declared emits, since it is dynamic per node. Defined
362
+ * HERE (the validator's home) so the reserved set stays derivable without a cycle. */
363
+ export const HUMAN_RESULT_LOCAL_VARS = [
364
+ "humanEmitValue",
365
+ "humanEmitArtifact",
366
+ "humanOutcome",
367
+ "humanNote",
368
+ "value",
369
+ "resolvedArtifact",
370
+ "note",
371
+ ] as const;
372
+
373
+ /** The escalation-completion variable that chooses how a parked `agent`/`connector` node resumes
374
+ * (retry-node resolution): completing a `__esc` / `__contract` escalation with `{ decision: "retry" }`
375
+ * re-runs the node's job; any other value — or none — continues past it. Defined HERE (the validator's
376
+ * home) so {@link ESCALATION_LOCAL_VARS} derives from it and the compiler imports the ONE source —
377
+ * never a second hardcoded `"decision"` that could drift. */
378
+ export const ESCALATION_DECISION_VAR = "decision";
379
+ export const ESCALATION_DECISION_RETRY = "retry";
380
+
381
+ /** The escalation-completion variable that carries the operator's optional retry-guidance note
382
+ * (retry-node resolution): on `decision: "retry"` the retry reset appends it to the agent's prompt as
383
+ * "Operator guidance for this retry: …". Named `escalationNote` — NOT the plain `note` — because the
384
+ * escalation form's controls live in the SAME node-local subProcess scope a worker's job-completion
385
+ * result propagates into, and `note` is a documented WORKER RESULT field (`resources/prompts/plan.md`
386
+ * returns one): an operator who retries WITHOUT a note (the form permits omitting it) would otherwise
387
+ * have the previous attempt's worker-produced `note` fed back to the agent as if it were operator
388
+ * guidance (PR #863 Copilot "Previously missed", deliveryGraphCompiler.ts:2156). The plain `note`
389
+ * name is instead a node-local WORKER RESULT field ({@link AGENT_RESULT_LOCAL_VARS}), so no emit or
390
+ * escalation control can alias it. Defined HERE (the validator's home) so {@link ESCALATION_LOCAL_VARS}
391
+ * derives from it and the compiler imports the ONE source — never a second hardcoded `"escalationNote"`
392
+ * that could drift. */
393
+ export const ESCALATION_NOTE_VAR = "escalationNote";
394
+
395
+ /** The variables an escalation completion writes (the {@link ESCALATION_DECISION_VAR}, plus the
396
+ * escalation form's `value` + {@link ESCALATION_NOTE_VAR}). They are declared node-LOCAL on the node's
397
+ * subProcess so a completion never leaks into the shared root scope, where a sibling node's escalation
398
+ * would read a stale value. Defined HERE so the validator's reserved set can derive from it without a
399
+ * cycle. */
400
+ export const ESCALATION_LOCAL_VARS = [ESCALATION_DECISION_VAR, "value", ESCALATION_NOTE_VAR] as const;
401
+
402
+ /** The subProcess **config** variable names an `agent` node's compiled `ioMappingLines` seeds from the
403
+ * runner's `nodeInputs.<el>`. These live in the SAME node-local subProcess scope the emit's source
404
+ * variable occupies (a service-node fact's source is the fact's own name), so an emit named after one
405
+ * collides with the node's own configuration. */
406
+ export const AGENT_CONFIG_VARS = ["jobType", "appendPrompt", "nodeTimeout", TRANSCRIPT_URL_BASE_VAR] as const;
407
+
408
+ /** The subProcess **config** variables a `connector` node seeds (see {@link AGENT_CONFIG_VARS}). */
409
+ export const CONNECTOR_CONFIG_VARS = ["target", "dedupeKey", "payload", "nodeTimeout"] as const;
410
+
411
+ /** The subProcess **config** variables a `wait` node seeds. A `wait` emit's source is NOT the fact's own
412
+ * name ({@link factSourceVar} maps it to `mergedSha`/`prCount`/`resolvedArtifact`/`detail`), so these do
413
+ * NOT collide with a wait emit — listed for completeness/derivation, not reserved against a wait emit. */
414
+ export const WAIT_CONFIG_VARS = ["gateKey", "probe", "probeTimeout", "probePollEvery"] as const;
415
+
416
+ /** The subProcess **config** variables a `human` node seeds. A `human` emit's source is NOT the fact's
417
+ * own name ({@link factSourceVar} maps it to `humanEmitValue`/`humanEmitArtifact`), so these do NOT
418
+ * collide with a human emit — listed for completeness/derivation, not reserved against a human emit. */
419
+ export const HUMAN_CONFIG_VARS = [
420
+ "escalationSlaTimeout",
421
+ "escalationAssignee",
422
+ "prompt",
423
+ "nodeId",
424
+ "emitMode",
425
+ "emitLabel",
426
+ ] as const;
427
+
428
+ /** The shared late-binding/preflight scaffolding variables every node's subProcess occupies. An emit
429
+ * named after one collides with the node's own wiring regardless of kind, so it is reserved for ALL
430
+ * kinds. */
431
+ export const DELIVERY_SCAFFOLDING_VARS = ["boundFacts", "nodeInputs", "nodeInputsPresent"] as const;
432
+
433
+ /** The RESERVED variable names an emitted-fact name must not collide with, **kind-aware** (issue #863
434
+ * review — threads r4181027093 / r4181027147 / r4181322008). A reserved name is one a colliding emit
435
+ * would genuinely break, and what breaks depends on the node kind:
436
+ *
437
+ * • **Escalation controls** ({@link ESCALATION_LOCAL_VARS} — `decision`/`value`/`escalationNote`) and the shared
438
+ * **scaffolding** ({@link DELIVERY_SCAFFOLDING_VARS}) are reserved for EVERY kind: they occupy the
439
+ * node's subProcess scope and an escalation Continue / the late-binding wiring writes them no matter
440
+ * how the emit's source is mapped.
441
+ *
442
+ * • **Config variables** are reserved ONLY for the kind whose own config they are — and only for
443
+ * `agent`/`connector`, the kinds whose emit source IS the fact's own name ({@link factSourceVar}).
444
+ * The retry reset clears every declared emit source var, so an `agent` emitting `appendPrompt`/
445
+ * `jobType`/`nodeTimeout`/`transcriptUrlBase`, or a `connector` emitting `target`/`payload`/
446
+ * `dedupeKey`/`nodeTimeout`, has its CONFIG nulled without re-entering the input mappings — the
447
+ * retried node activates unconfigured. An `agent` emitting `target` (a connector-only config var) is
448
+ * NOT a collision: an agent seeds no `target`. A cross-kind name is therefore allowed.
449
+ *
450
+ * • A `wait`/`human` emit's source is a FIXED intermediate (`detail`/`mergedSha`/`prCount`/
451
+ * `resolvedArtifact`, `humanEmitValue`/`humanEmitArtifact`), never the fact's own name, so its config
452
+ * vars cannot collide with an emit source — a `wait` emitting `target` or a `human` emitting `prompt`
453
+ * is fine. (`decision`/`value`/`escalationNote` stay reserved for these kinds via the escalation controls.)
454
+ *
455
+ * The node-local RESULT sets ({@link AGENT_RESULT_LOCAL_VARS} / {@link CONNECTOR_RESULT_LOCAL_VARS})
456
+ * are deliberately NOT reserved — with ONE exception. An agent emitting `pr` (the canonical converge
457
+ * shape) writes the same node-local value the result field holds, so they agree, and the retry reset
458
+ * correctly clears both. Reserving the whole set would forbid the flagship
459
+ * `agent → connector[converge] → wait[pr]` pattern. The exception is the agent completion-control
460
+ * `status`: the producer status gate reads that SAME node-local field as the node's completion status
461
+ * and accepts only `done`/`opened`/`skipped`, so an agent emit named `status` whose routing value is
462
+ * anything else (`approved`, …) ALWAYS escalates, and `status="done"` can never take an `approved`
463
+ * branch — the emit contract and the gate conflict. `status` is therefore reserved for an `agent`
464
+ * (a connector has no producer status gate, so its `status` emit collides with nothing and stays
465
+ * allowed); the rest of the result set (`pr`, `summary`, …) remains unreserved.
466
+ * {@link validateDeliveryGraph} rejects a reserved name (fail-closed at authoring time); derived from
467
+ * the same source-of-truth sets so it can never drift from them. */
468
+ export function reservedDeliveryFactNames(kind: DeliveryNodeKind): readonly string[] {
469
+ const config =
470
+ kind === "agent" ? AGENT_CONFIG_VARS : kind === "connector" ? CONNECTOR_CONFIG_VARS : [];
471
+ // `status` is reserved ONLY for an agent (the producer-gate completion control — see the doc above).
472
+ const completionControl = kind === "agent" ? ["status"] : [];
473
+ return [...new Set<string>([...ESCALATION_LOCAL_VARS, ...DELIVERY_SCAFFOLDING_VARS, ...config, ...completionControl])];
474
+ }
475
+
262
476
  /** A node `id` must match openapi's `DeliveryNodeCommon.id` `^[A-Za-z_][A-Za-z0-9_.-]*$` and stay
263
477
  * within its 128-char cap. Re-enforced here INDEPENDENTLY of the OpenAPI shape gate because later
264
478
  * compile/render steps trust these ids: an id with whitespace, a leading digit, or an over-long value
@@ -2090,6 +2304,71 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
2090
2304
  message: "`human` config, when present, must be an object",
2091
2305
  code: "missing-config",
2092
2306
  });
2307
+ } else if (
2308
+ isRecord(rawNode.human) &&
2309
+ rawNode.human.formKey !== undefined &&
2310
+ typeof rawNode.human.formKey !== "string"
2311
+ ) {
2312
+ // A PRESENT-but-non-string `human.formKey` (`42`, `{}`, `[]`, `true`) is the fail-open sibling of
2313
+ // the string-gated checks below: text/JSON ingress can pass a non-string, the `typeof === "string"`
2314
+ // branch is skipped, the graph is ACCEPTED, and the compiler silently falls back to a generated form
2315
+ // instead of the author's declared executable `<zeebe:formDefinition formId=…>` key. Reject it
2316
+ // explicitly — fail CLOSED on a malformed executable config rather than silently dropping it (PR #863
2317
+ // review). The required-config loop above already rejects a non-string `agent.jobType`/`connector.target`
2318
+ // (both REQUIRED, so `typeof value !== "string"` is caught there); `human` config is optional, leaving
2319
+ // `formKey` the one baked-verbatim field that reaches here untyped.
2320
+ errors.push({
2321
+ path: `${path}.human.formKey`,
2322
+ message: "`human.formKey`, when present, must be a string",
2323
+ code: "invalid-form-key",
2324
+ });
2325
+ } else if (isRecord(rawNode.human) && typeof rawNode.human.formKey === "string") {
2326
+ // A delivery `human` node's explicit `human.formKey` is baked VERBATIM into the executable
2327
+ // `<zeebe:formDefinition formId=…>` attribute (it resolves a deployed `.form` by value), so — exactly
2328
+ // like `agent.jobType` above — it must be REJECTED, not silently rewritten/redacted, when it carries a
2329
+ // character the attribute sanitiser would strip/fold or a credential-bearing URL that would leak into
2330
+ // the compiled BPMN. The compiler renders it through `attr()` (so a quote can no longer break out of
2331
+ // the attribute), but that neither undoes a silent XML-rewrite nor keeps a credential URL out of the
2332
+ // deployed model — this authoring-time gate does (PR #863 review — HIGH formKey finding at
2333
+ // deliveryGraphCompiler.ts:2361). The runtime TRIMS the key before use (`resolveHumanForm`), so
2334
+ // validate the trimmed value and skip blank (blank → generic form, carries nothing to leak).
2335
+ const formKey = rawNode.human.formKey.trim();
2336
+ if (formKey.length > 0) {
2337
+ if (hasXmlInvalidChars(formKey) || hasAttrNormalizedWhitespace(formKey)) {
2338
+ errors.push({
2339
+ path: `${path}.human.formKey`,
2340
+ message:
2341
+ `\`human.formKey\` ${JSON.stringify(redactString(stripXmlInvalidChars(formKey)))} contains a character that would be ` +
2342
+ "silently rewritten when emitted as the executable `<zeebe:formDefinition formId=…>` attribute — an XML-1.0-invalid " +
2343
+ "character (control characters, U+FFFE/U+FFFF, or an unpaired surrogate) that the sanitiser strips, or attribute " +
2344
+ "whitespace (tab, LF, CR) that XML attribute-value normalization folds to a space — so it would resolve a DIFFERENT " +
2345
+ "(wrong) deployed form and must be rejected rather than silently rewritten into a different form key",
2346
+ code: "invalid-form-key",
2347
+ });
2348
+ } else if (
2349
+ isUrlShaped(formKey) ||
2350
+ hasEmbeddedCredential(formKey) ||
2351
+ hasEmbeddedUrl(formKey) ||
2352
+ hasSchemeRelativeAuthority(formKey)
2353
+ ) {
2354
+ // One code (not jobType's five per-shape codes) covers every URL/credential shape: a form key
2355
+ // names a deployed `.form`, so the distinction between a whole-value URL, an embedded
2356
+ // `//user:pass@host`, an explicit-scheme `scheme://…`, and a scheme-relative `//host` is
2357
+ // immaterial — none is a legal form id, and the operator fix is the same. Gated behind the
2358
+ // XML/whitespace check above so a single value is reported once. Message redacted so the 400
2359
+ // never echoes a credential.
2360
+ errors.push({
2361
+ path: `${path}.human.formKey`,
2362
+ message:
2363
+ `\`human.formKey\` ${JSON.stringify(redactString(stripXmlInvalidChars(formKey)))} is URL-/credential-shaped — a form ` +
2364
+ "key names a deployed `.form` resource, never a URL, and since the executable `<zeebe:formDefinition formId=…>` " +
2365
+ "carries it verbatim, a credential embedded in it (`//user:pass@host`, `scheme://…?token=…`, or a scheme-relative " +
2366
+ "`//host?token=…`) would leak into the compiled BPMN even though the preview path redacts it. Use a plain deployed " +
2367
+ "form id and carry any endpoint/credential elsewhere",
2368
+ code: "credential-in-form-key",
2369
+ });
2370
+ }
2371
+ }
2093
2372
  }
2094
2373
 
2095
2374
  // Collect + validate this node's typed emitted facts (uniqueness within the node). Registered
@@ -2142,6 +2421,32 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
2142
2421
  });
2143
2422
  return;
2144
2423
  }
2424
+ if (isDeliveryNodeKind(kind) && reservedDeliveryFactNames(kind).includes(rawFact.name)) {
2425
+ // Fail CLOSED on a reserved-name collision (issue #863 review — threads r4181027093 /
2426
+ // r4181027147 / r4181322008). A fact name is an otherwise-unrestricted identifier, but an
2427
+ // `agent`/`connector` node's emit source IS the fact's own name, declared node-local on the
2428
+ // subProcess — the SAME scope the escalation form controls (`decision`/`value`/`escalationNote`) and
2429
+ // the node's OWN seeded config variables occupy. An emit named `decision` is overwritten by
2430
+ // an escalation Continue (publishing `<el>_decision="continue"` instead of the agent's
2431
+ // routing value); an emit named after the node's OWN config (`target`/`payload`/`dedupeKey`/
2432
+ // `nodeTimeout` on a connector, `appendPrompt`/`jobType`/… on an agent) is NULLED by the
2433
+ // retry reset, so the retried node activates unconfigured. The set is KIND-AWARE
2434
+ // (`reservedDeliveryFactNames`): only the kinds whose emit source is the fact's own name
2435
+ // reserve their own config, so a `wait` emitting `target` or a `human` emitting `prompt`
2436
+ // (whose sources are fixed intermediates) is NOT rejected. Reject the whole CLASS at
2437
+ // authoring time rather than patch one name.
2438
+ errors.push({
2439
+ path: `${path}.emits[${j}].name`,
2440
+ message:
2441
+ `emitted fact name "${rawFact.name}" collides with a reserved delivery variable ` +
2442
+ `(an escalation control, the shared scaffolding, or a ${kind} node's own subProcess ` +
2443
+ "config variable) — an escalation Continue would overwrite it or a Retry would null " +
2444
+ "the node's config. " +
2445
+ "Rename the fact (e.g. `decision` → `verdict`, `target` → `targetRef`).",
2446
+ code: "invalid-fact-name",
2447
+ });
2448
+ return;
2449
+ }
2145
2450
  if (!isDeliveryFactType(rawFact.type)) {
2146
2451
  // emits are TYPED (Decision 3/4). An invalid/missing `type` must be rejected even when the
2147
2452
  // OpenAPI shape validator is bypassed, or a later step reading the type allowlist breaks.
@@ -2163,6 +2468,34 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
2163
2468
  nodeFacts.set(id, facts);
2164
2469
  nodeFactTypes.set(id, factTypes);
2165
2470
  }
2471
+
2472
+ // Reject a `human` node whose emits no static form can capture (PR #863 review — threads
2473
+ // deliveryGraphCompiler.ts:1859 / :2451 / :1927). A human form captures ONE value: the generic form a
2474
+ // single non-artifact `value`, the publish form a single `resolvedArtifact`. A node emitting ≥2 typed
2475
+ // facts resolves to the agent-router (no static form holds them) — but the compiled generic form has
2476
+ // no control for the extra facts, and a human node has no Retry path, so completing the form would
2477
+ // publish NULL for every uncapturable emit and permanently schedule downstream consumers with missing
2478
+ // required facts. An explicit `human.formKey` is NOT a workaround for a ≥2-emit node (thread
2479
+ // r4184443511): for a SINGLE emit the compiled task's ioMapping (`humanBodyLines`) now reads the
2480
+ // fact-named field ahead of the canonical `value`/`resolvedArtifact` control (so a bespoke
2481
+ // single-value/single-artifact form IS wired — thread deliveryGraphCompiler.ts:1929), but that
2482
+ // per-fact mapping is emitted for the single-emit case only; a ≥2-emit explicit-form node still reads
2483
+ // just the fixed `value`/`resolvedArtifact`/`note` controls and would publish null for every extra
2484
+ // emit. Reject the whole CLASS at authoring time: split the emits across single-emit human nodes.
2485
+ if (kind === "human" && factTypes.size >= 2) {
2486
+ errors.push({
2487
+ path: `${path}.emits`,
2488
+ message:
2489
+ `human node "${String(id)}" emits ${factTypes.size} typed facts but no static form can ` +
2490
+ "capture more than one value (the generic form captures a single non-artifact `value`, the " +
2491
+ "publish form a single `resolvedArtifact`) — completing its form would publish null for the " +
2492
+ "uncapturable emits, and a human node has no Retry path to recover. An explicit `human.formKey` " +
2493
+ "does not help: the compiled task's ioMapping reads only the fixed value/resolvedArtifact/note " +
2494
+ "controls, never a bespoke form's per-fact fields. Split the emits across separate single-emit " +
2495
+ "human nodes.",
2496
+ code: "human-unroutable-emits",
2497
+ });
2498
+ }
2166
2499
  });
2167
2500
 
2168
2501
  // Pass 2: edges. Resolve each endpoint against the node set and each qualified `from` against the