@nanobpm/nano-workforce 0.186.0 → 0.186.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.186.1](https://github.com/nanobpm/nano-workforce/compare/v0.186.0...v0.186.1) (2026-09-11)
2
+
3
+ ### Bug Fixes
4
+
5
+ * delivery-graph human-node Tasks form renders context-free ([#773](https://github.com/nanobpm/nano-workforce/issues/773)) ([91a8065](https://github.com/nanobpm/nano-workforce/commit/91a806566e157257cc264310e18d7df0bba3e350)), closes [#772](https://github.com/nanobpm/nano-workforce/issues/772) [#772](https://github.com/nanobpm/nano-workforce/issues/772)
6
+
1
7
  ## [0.186.0](https://github.com/nanobpm/nano-workforce/compare/v0.185.1...v0.186.0) (2026-09-08)
2
8
 
3
9
  ### Features
@@ -10,6 +10,7 @@ import type { DeliveryFact } from "../nano-generated/api-io.d.ts";
10
10
  import {
11
11
  bindHumanEmits,
12
12
  DELIVERY_HUMAN_ELEMENT,
13
+ deliveryHumanContextQuestion,
13
14
  deriveHumanCategory,
14
15
  GENERIC_HUMAN_FORM,
15
16
  HUMAN_ACK_FORM,
@@ -304,3 +305,125 @@ test("drift guard: the human element is completer-answerable and surfaces on the
304
305
  "USER_TASK_KIND_LABELS must label the delivery human node",
305
306
  );
306
307
  });
308
+
309
+ // ── issue #772: Tasks-inbox "Decision context" for a parked delivery-graph human node ─────────────
310
+
311
+ test("deliveryHumanContextQuestion: derives the node instruction from human_labels (base + __esc twin)", () => {
312
+ const labels = { "delivery-human-task__n7": "Run the manual OTP publish for @nanobpm/urban" };
313
+ // The parked base task looks up its own id.
314
+ assertEquals(
315
+ deliveryHumanContextQuestion(labels, "delivery-human-task__n7"),
316
+ "Run the manual OTP publish for @nanobpm/urban",
317
+ );
318
+ // The bounded-timeout escalation twin parks on `…__esc`; strip it to find the same stamped label.
319
+ assertEquals(
320
+ deliveryHumanContextQuestion(labels, "delivery-human-task__n7__esc"),
321
+ "Run the manual OTP publish for @nanobpm/urban",
322
+ );
323
+ });
324
+
325
+ test("deliveryHumanContextQuestion: falls back to a static message when no label is stored", () => {
326
+ // A parked human step must never render a blank Decision context — an untracked/absent label still
327
+ // yields actionable guidance rather than null (which would leave the panel empty, issue #772).
328
+ assertEquals(
329
+ deliveryHumanContextQuestion({}, "delivery-human-task__n1"),
330
+ "A scheduled delivery-graph step is waiting to be completed.",
331
+ );
332
+ assertEquals(
333
+ deliveryHumanContextQuestion(undefined, "delivery-human-task__n1"),
334
+ "A scheduled delivery-graph step is waiting to be completed.",
335
+ );
336
+ });
337
+
338
+ test("deliveryHumanContextQuestion: a non-human node's escalation twin (no stored label) gets the node-NEUTRAL fallback, not a 'human step' claim", () => {
339
+ // `isDeliveryHumanElement` also matches the `__esc`/`__contract` escalation twins that bounded
340
+ // `agent`/`wait`/`connector` nodes schedule; those carry NO stored human label. The fallback must
341
+ // not mislabel them as a "human step" (issue #772 review, comment on service.ts contextFor arm).
342
+ const humanLabels = { "delivery-human-task__n7": "Run the manual OTP publish" };
343
+ assertEquals(
344
+ deliveryHumanContextQuestion(humanLabels, "delivery-human-task__agent5__esc"),
345
+ "A scheduled delivery-graph step is waiting to be completed.",
346
+ );
347
+ assertEquals(
348
+ deliveryHumanContextQuestion(humanLabels, "delivery-human-task__agent5__contract"),
349
+ "A scheduled delivery-graph step is waiting to be completed.",
350
+ );
351
+ // A real human node's own `__esc` timeout twin still resolves the base label.
352
+ assertEquals(
353
+ deliveryHumanContextQuestion(humanLabels, "delivery-human-task__n7__esc"),
354
+ "Run the manual OTP publish",
355
+ );
356
+ });
357
+
358
+ test("deliveryHumanContextQuestion: a human node whose id itself ends in __esc resolves its EXACT label, not a sibling's", () => {
359
+ // Node ids may legitimately end in `__esc` (`deliveryGraph.ts` NODE_ID_PATTERN), so such a node is
360
+ // stamped under the exact key `delivery-human-task__<id>` (…__esc). The exact lookup must win over
361
+ // the `__esc`-stripped base, otherwise a sibling `n7` node's label would shadow real node `n7__esc`.
362
+ const labels = {
363
+ "delivery-human-task__n7": "Sibling n7 label",
364
+ "delivery-human-task__n7__esc": "The real n7__esc node label",
365
+ };
366
+ assertEquals(
367
+ deliveryHumanContextQuestion(labels, "delivery-human-task__n7__esc"),
368
+ "The real n7__esc node label",
369
+ );
370
+ });
371
+
372
+ // ── issue #772: the generic form must be static / input-only on the Tasks surface ─────────────────
373
+
374
+ const genericForm = readFileSync("resources/forms/delivery-human-generic.form", "utf8");
375
+
376
+ test("form-structure guard: delivery-human-generic.form uses node-neutral wording", () => {
377
+ // This shared form is also attached to the `__esc`/`__contract` escalation tasks that bounded
378
+ // agent/wait/connector nodes create (`app/deliveryGraphCompiler.ts`), not only scheduled `human`
379
+ // nodes. Copy that calls the task a "scheduled human step" is inaccurate for the escalation family
380
+ // and can obscure that the task is an escalation, so the static text must stay node-neutral.
381
+ assert(
382
+ !/scheduled human step/i.test(genericForm),
383
+ "the shared generic form must use node-neutral wording (it also serves escalation tasks)",
384
+ );
385
+ });
386
+
387
+ test("form-structure guard: delivery-human-generic.form carries no {{…}} tokens", () => {
388
+ // The Tasks surface (`engineForm`) seeds NO form variables, so any `{{token}}` renders literally and
389
+ // any data-dependent `conditional` mis-fires. Deploy-time `{{token}}` templating is removed too, so
390
+ // such a token could never resolve. Assert the surface stays context-free-safe (issue #772).
391
+ assert(!genericForm.includes("{{"), "the generic delivery-human form must not carry {{…}} tokens");
392
+ });
393
+
394
+ test("form-structure guard: delivery-human-generic.form has no data-dependent conditional and an OPTIONAL value", () => {
395
+ const parsed = JSON.parse(genericForm) as {
396
+ components: { key?: string; conditional?: unknown; validate?: { required?: boolean } }[];
397
+ };
398
+ // No component may gate on form data — those blurbs render contradictorily against empty data.
399
+ for (const c of parsed.components) {
400
+ assert(c.conditional === undefined, "no component may carry a data-dependent conditional");
401
+ }
402
+ // `value` must be OPTIONAL — a no-emit ("click done") node completes without entering a value. This
403
+ // S3 generic surface deploys a single static default form and cannot know per-node whether a fact is
404
+ // required, so it must not client-require `value`; per-node typed-emit binding is the S4 form-
405
+ // selection path's job (`bindHumanEmits`), not a client-required field on this generic surface.
406
+ const value = parsed.components.find((c) => c.key === "value");
407
+ assert(value, "the generic form must keep a single `value` field");
408
+ assert(value!.validate?.required !== true, "`value` must be optional on this surface");
409
+ });
410
+
411
+ test("form-structure guard: delivery-human-generic.form has no task-variable-dependent readonly input", () => {
412
+ // The Tasks surface (`engineForm`) seeds NO task-local variables, so a readonly INPUT component
413
+ // (e.g. the old `prompt` textarea) renders permanently blank — a confusing empty "Now do this".
414
+ // The instruction reaches the operator through the read-model Decision context column instead, so
415
+ // the surface must carry no keyed readonly control; a static keyless `text` block may point to it.
416
+ const parsed = JSON.parse(genericForm) as {
417
+ components: { type?: string; key?: string; readonly?: boolean }[];
418
+ };
419
+ for (const c of parsed.components) {
420
+ assert(
421
+ !(c.readonly === true && typeof c.key === "string"),
422
+ `keyed readonly input '${c.key}' renders blank on the variable-free Tasks surface`,
423
+ );
424
+ }
425
+ assert(
426
+ !parsed.components.some((c) => c.key === "prompt"),
427
+ "the never-seeded readonly `prompt` control must not reappear on the Tasks surface",
428
+ );
429
+ });
@@ -54,6 +54,35 @@ export function isDeliveryHumanElement(elementId: string): boolean {
54
54
  return elementId === DELIVERY_HUMAN_ELEMENT || elementId.startsWith(`${DELIVERY_HUMAN_ELEMENT}__`);
55
55
  }
56
56
 
57
+ /** The read-model "Decision context" for a parked delivery-graph `human` node (issue #772). The Tasks
58
+ * surface renders the deployed `.form` against EMPTY data (it seeds no task-local variables), so the
59
+ * node's instruction cannot reach the operator through the form's readonly `prompt` field — it must
60
+ * arrive through the read-model `question` column, exactly like every other escalation kind. The run
61
+ * row already stamps each human node's instruction label in `human_labels` (`buildHumanLabels`), keyed
62
+ * by the node's base user-task element id (`delivery-human-task__<node>`); the bounded-timeout
63
+ * escalation twin parks on the `…__esc` variant. A node id may itself legitimately END in `__esc`
64
+ * (`deliveryGraph.ts` NODE_ID_PATTERN allows it), so the twin-suffix strip is a FALLBACK, not the
65
+ * first probe: look up the EXACT reported element id first (a real `…__esc` human node stores its
66
+ * label under that exact key), and only then the `__esc`-stripped base (the bounded-timeout twin,
67
+ * whose exact id is never stamped, resolves to its real node's label). Returns a static fallback when
68
+ * no label is stored so a parked step is never left with a blank panel.
69
+ *
70
+ * The fallback is deliberately node-NEUTRAL ("…delivery-graph step…", not "…human step…"): only
71
+ * real `human` nodes are stamped into `human_labels`, but `isDeliveryHumanElement` (and hence this
72
+ * helper's caller) also matches the `__esc`/`__contract` escalation twins that BOUNDED `agent`/`wait`/
73
+ * `connector` nodes schedule — those carry no stored label and would otherwise be mislabeled as a
74
+ * "human step". A found label is always a real human node's instruction; the fallback must read true
75
+ * for both an untracked human run AND a non-human escalation twin. */
76
+ export function deliveryHumanContextQuestion(
77
+ humanLabels: Record<string, string> | undefined,
78
+ elementId: string,
79
+ ): string {
80
+ const labels = humanLabels ?? {};
81
+ const base = elementId.replace(/__esc$/, "");
82
+ const label = (labels[elementId] ?? labels[base] ?? "").trim();
83
+ return label || "A scheduled delivery-graph step is waiting to be completed.";
84
+ }
85
+
57
86
  /** The GENERIC fallback form (Decision 4, step 3): captures ONE typed value into the node's single
58
87
  * declared emitted fact, so a human node with no explicit/category form can STILL emit downstream. */
59
88
  export const GENERIC_HUMAN_FORM = "delivery-human-generic";
@@ -661,6 +661,62 @@ test("pollUserTasks (engine-first): surfaces an inlined delivery-graph human tas
661
661
  assertEquals(byKey["35002"].subject_title, "release runbook");
662
662
  });
663
663
 
664
+ test("pollUserTasks: a parked delivery-human node carries its instruction as `question` (Decision context, issue #772)", async () => {
665
+ // The Tasks surface seeds no form variables, so a delivery-graph `human` node's instruction can only
666
+ // reach the operator through the read-model `question` (rendered as "Decision context"). Source it
667
+ // from the run's stamped `human_labels`, keyed by the parked user-task element id — else the panel is
668
+ // blank and the human has no idea what the run is waiting on.
669
+ const { data, stores } = memData({
670
+ delivery_graph_runs: [
671
+ {
672
+ run_key: "delivery-graph-403eb22e",
673
+ process_key: "dg-1",
674
+ status: "running",
675
+ title: "release runbook",
676
+ human_labels: JSON.stringify({
677
+ "delivery-human-task__n7": "Run the manual OTP publish for @nanobpm/urban",
678
+ }),
679
+ },
680
+ ],
681
+ });
682
+ const restore = stubUserTaskSearch([
683
+ { userTaskKey: "20411", elementId: "delivery-human-task__n7", processInstanceKey: "dg-1", state: "CREATED" },
684
+ // the bounded-timeout escalation twin parks on the `…__esc` id but resolves the same base label
685
+ { userTaskKey: "20412", elementId: "delivery-human-task__n7__esc", processInstanceKey: "dg-1", state: "CREATED" },
686
+ ]);
687
+ try {
688
+ await pollUserTasks(data, fakeEngine({}), REST);
689
+ } finally {
690
+ restore();
691
+ }
692
+
693
+ const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
694
+ assertEquals(byKey["20411"].question, "Run the manual OTP publish for @nanobpm/urban");
695
+ assertEquals(byKey["20412"].question, "Run the manual OTP publish for @nanobpm/urban");
696
+ });
697
+
698
+ test("pollUserTasks: a delivery-human node with no stored label still gets a non-blank Decision context (issue #772)", async () => {
699
+ // An untracked run (or a run whose label wasn't stamped) must not leave the panel blank — a static,
700
+ // node-NEUTRAL fallback still tells the operator a delivery-graph step is waiting. It must read true
701
+ // for a non-human escalation twin too, so it must not claim "human step".
702
+ const { data, stores } = memData({});
703
+ const restore = stubUserTaskSearch([
704
+ { userTaskKey: "20411", elementId: "delivery-human-task__n1", processInstanceKey: "dg-9", state: "CREATED" },
705
+ // a bounded `agent`/`wait`/`connector` node's escalation twin: `isDeliveryHumanElement` matches it,
706
+ // but it carries no stored human label, so it gets the neutral fallback, not a "human step" claim.
707
+ { userTaskKey: "20499", elementId: "delivery-human-task__agent5__esc", processInstanceKey: "dg-9", state: "CREATED" },
708
+ ]);
709
+ try {
710
+ await pollUserTasks(data, fakeEngine({}), REST);
711
+ } finally {
712
+ restore();
713
+ }
714
+
715
+ const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
716
+ assertEquals(byKey["20411"].question, "A scheduled delivery-graph step is waiting to be completed.");
717
+ assertEquals(byKey["20499"].question, "A scheduled delivery-graph step is waiting to be completed.");
718
+ });
719
+
664
720
  test("pollUserTasks (engine-first): a delivery-human task on an UNTRACKED run still surfaces (bucketed `delivery`, instance fallback) (issue #442)", async () => {
665
721
  // Even with no `delivery_graph_runs` row referencing the instance, the kind implies its aggregate, so
666
722
  // the row renders and stays answerable — mirroring the orphaned-escalation guarantee (#358).
package/app/service.ts CHANGED
@@ -30,7 +30,7 @@ import { isUniqueConstraintFence } from "./dbFence.ts";
30
30
  import { deriveDelivery, EPIC_LIVE_STATUSES, TERMINAL_STATUSES } from "./delivery.ts";
31
31
  import { sweepExpiredProposals } from "./deliveryGraphProposals.ts";
32
32
  import { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels } from "./deliveryGraphRun.ts";
33
- import { isDeliveryHumanElement } from "./deliveryHuman.ts";
33
+ import { deliveryHumanContextQuestion, isDeliveryHumanElement } from "./deliveryHuman.ts";
34
34
  import { fleetSupportsDurableResume } from "./durableResume.ts";
35
35
  import { deriveEpicPhaseLive, deriveTerminalEpicPhase } from "./epicPhase.ts";
36
36
  import { deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
@@ -2613,6 +2613,10 @@ export async function pollUserTasks(
2613
2613
  url?: string | null;
2614
2614
  deliveryLabel?: string | null;
2615
2615
  conformanceSummary?: string | null;
2616
+ /** The parked human-node instruction labels for a delivery run, keyed by user-task element id
2617
+ * (`delivery-human-task__<node>`) — the run row's stamped `human_labels`, denormalised so the
2618
+ * Tasks-inbox "Decision context" can explain a parked delivery-graph `human` node (issue #772). */
2619
+ humanLabels?: Record<string, string>;
2616
2620
  /** The subject's at-a-glance "waiting on <capability> · …" rollup, denormalised for the readiness
2617
2621
  * escalation question (issue #674): the wait-gate projection on `plans.wait_gate_label`, or the
2618
2622
  * feature run's `delivery_label` for the inline preflight. */
@@ -2640,7 +2644,7 @@ export async function pollUserTasks(
2640
2644
  // feature/plan/pr enrichment. The row's inlined `delivery-human-task__<node>` id is recognised by
2641
2645
  // the shared `userTaskKindLabel` predicate, and buckets as `delivery` (below).
2642
2646
  for (const run of await deliveryGraphRuns(data).all()) {
2643
- if (run.process_key) subjectByInstance.set(run.process_key, { type: "delivery", key: run.run_key, title: run.title, url: null });
2647
+ if (run.process_key) subjectByInstance.set(run.process_key, { type: "delivery", key: run.run_key, title: run.title, url: null, humanLabels: parseHumanLabels(run.human_labels) });
2644
2648
  }
2645
2649
 
2646
2650
  // Per-element subject type for an ORPHANED task (no subject row) — the kind implies its aggregate even
@@ -2719,6 +2723,13 @@ export async function pollUserTasks(
2719
2723
  question = readinessEscalationQuestion(subj?.waitGateLabel ?? null);
2720
2724
  break;
2721
2725
  }
2726
+ // A delivery-graph `human` node's user-task id is inlined per node (`delivery-human-task__<node>`
2727
+ // and its `…__esc` twin), so it can't be a static `case` above (issue #772). Fill its "Decision
2728
+ // context" from the run's stamped `human_labels` — otherwise the panel is blank and, because this
2729
+ // surface seeds no form variables, the operator has no idea what the run is waiting on.
2730
+ if (question === null && isDeliveryHumanElement(elementId)) {
2731
+ question = deliveryHumanContextQuestion(subj?.humanLabels, elementId);
2732
+ }
2722
2733
  return { userTaskKey, elementId, subjectType, subjectKey, subjectTitle: subj?.title ?? null, subjectUrl: subj?.url ?? null, question, processKey: processInstanceKey, formKey: resolvedFormKey };
2723
2734
  };
2724
2735
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.186.0",
3
+ "version": "0.186.1",
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",
@@ -5,43 +5,14 @@
5
5
  "components": [
6
6
  {
7
7
  "type": "text",
8
- "text": "### Delivery graph — node `{{nodeId}}`",
9
- "conditional": {
10
- "hide": "=(nodeId = null) or (nodeId = \"\")"
11
- }
12
- },
13
- {
14
- "type": "textarea",
15
- "key": "prompt",
16
8
  "label": "Now do this",
17
- "description": "The scheduled human step this delivery graph is waiting on.",
18
- "readonly": true
19
- },
20
- {
21
- "type": "text",
22
- "text": "**Emits:** {{emitLabel}} — enter its typed value below (validated against the node's declared fact).",
23
- "conditional": {
24
- "hide": "=emitMode != \"typed\""
25
- }
9
+ "text": "This task's instruction is shown in the Decision context above."
26
10
  },
27
11
  {
28
12
  "type": "textfield",
29
13
  "key": "value",
30
- "label": "Emitted value",
31
- "description": "The typed value this step hands forward to its downstream dependents. Validated against the node's declared emitted fact.",
32
- "conditional": {
33
- "hide": "=emitMode != \"typed\""
34
- },
35
- "validate": {
36
- "required": true
37
- }
38
- },
39
- {
40
- "type": "text",
41
- "text": "_This step emits no typed fact (N/A) — just complete it to unblock its dependents._",
42
- "conditional": {
43
- "hide": "=emitMode = \"typed\""
44
- }
14
+ "label": "Emitted value (only if this step emits a fact)",
15
+ "description": "The typed value this step hands forward to its downstream dependents. Leave blank for a click-done step that emits nothing."
45
16
  },
46
17
  {
47
18
  "type": "textarea",