@nanobpm/nano-workforce 0.171.0 → 0.171.2

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,15 @@
1
+ ## [0.171.2](https://github.com/nanobpm/nano-workforce/compare/v0.171.1...v0.171.2) (2026-08-31)
2
+
3
+ ### Bug Fixes
4
+
5
+ * surface + complete readiness/preflight escalations in the Tasks inbox ([#675](https://github.com/nanobpm/nano-workforce/issues/675)) ([9d874e8](https://github.com/nanobpm/nano-workforce/commit/9d874e889f3155b7887bb48032e39c5f78e33ce3)), closes [#674](https://github.com/nanobpm/nano-workforce/issues/674)
6
+
7
+ ## [0.171.1](https://github.com/nanobpm/nano-workforce/compare/v0.171.0...v0.171.1) (2026-08-31)
8
+
9
+ ### Code Refactoring
10
+
11
+ * **agentic:** consume @nanobpm/agentic/transcript, drop the duplicated grammar ([#677](https://github.com/nanobpm/nano-workforce/issues/677)) ([8c8d3fc](https://github.com/nanobpm/nano-workforce/commit/8c8d3fc393134862557a65e2aab2dd9e16292c66)), closes [#676](https://github.com/nanobpm/nano-workforce/issues/676)
12
+
1
13
  ## [0.171.0](https://github.com/nanobpm/nano-workforce/compare/v0.170.1...v0.171.0) (2026-08-31)
2
14
 
3
15
  ### Features
@@ -286,6 +286,38 @@ test("empty-plan-escalation is HUMAN-completable but NOT agent-completable (issu
286
286
  assertEquals(completed[0].variables, { directive: "revise", notes: "look again" });
287
287
  });
288
288
 
289
+ test("readiness-escalation(-pf) is HUMAN-completable but NOT agent-completable (issue #674)", async () => {
290
+ // A readiness/preflight gate adjudicates whether upstream is ACTUALLY ready (proceed) or the gate
291
+ // should be abandoned. Like feature-blocked/conformance/empty-plan it is a HUMAN operator decision —
292
+ // an agent must never auto-answer it, or the fleet would silently defeat the very readiness gate the
293
+ // task exists to enforce. So both ids stay OUTSIDE `ESCALATION_TASK_ELEMENTS` (agent-refused) but are
294
+ // retired by the HUMAN completer via the one canonical `complete-user-task` door.
295
+ for (const elementId of ["readiness-escalation-pf", "readiness-escalation"] as const) {
296
+ const stores = { task_completions: { rows: [] as any[], key: "id" } };
297
+ const data = memData(stores);
298
+ const { engine, completed } = fakeEngine([{ userTaskKey: "ut-r", elementId }]);
299
+
300
+ const asAgent = await completeEscalationAsAgent(data, engine, {
301
+ userTaskKey: "ut-r",
302
+ agentId: "bot",
303
+ variables: { resolution: "acknowledge" },
304
+ });
305
+ assertEquals(asAgent.ok, false, `the agent completer refuses ${elementId}`);
306
+ assertEquals(asAgent.reason, "not a completable task");
307
+ assertEquals(completed.length, 0);
308
+
309
+ const asHuman = await completeEscalationAsHuman(data, engine, {
310
+ userTaskKey: "ut-r",
311
+ operatorId: "alice",
312
+ variables: { resolution: "abandon", answer: "upstream never published" },
313
+ });
314
+ assertEquals(asHuman.ok, true, `the human completer retires ${elementId}`);
315
+ assertEquals(asHuman.elementId, elementId);
316
+ assertEquals(completed.length, 1);
317
+ assertEquals(completed[0].variables, { resolution: "abandon", answer: "upstream never published" });
318
+ }
319
+ });
320
+
289
321
  test("human completer refuses a non-escalation user task and is a no-op for an unknown key", async () => {
290
322
  const stores = { task_completions: { rows: [] as any[], key: "id" } };
291
323
  const data = memData(stores);
@@ -24,7 +24,7 @@ import { readFileSync } from "node:fs";
24
24
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
25
25
  import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
26
26
  import { DELIVERY_HUMAN_ELEMENT, isDeliveryHumanElement } from "./deliveryHuman.ts";
27
- import { ACP_PERMISSION_ELEMENT, EMPTY_PLAN_ELEMENT } from "./userTasks.ts";
27
+ import { ACP_PERMISSION_ELEMENT, EMPTY_PLAN_ELEMENT, READINESS_ESCALATION_ELEMENT, READINESS_ESCALATION_PF_ELEMENT } from "./userTasks.ts";
28
28
 
29
29
  const now = () => new Date().toISOString();
30
30
 
@@ -101,6 +101,16 @@ export const CONFORMANCE_ESCALATION_TASK_ELEMENT = CONFORMANCE_ESCALATION_ELEMEN
101
101
  * Re-exported from the canonical `EMPTY_PLAN_ELEMENT` (app/userTasks.ts) — one source of truth. */
102
102
  export const EMPTY_PLAN_TASK_ELEMENT = EMPTY_PLAN_ELEMENT;
103
103
 
104
+ /** The readiness/preflight escalation user-task element ids (`readiness-escalation-pf` in feature.bpmn's
105
+ * readiness preflight + plan-fanout.bpmn's producer-capability preflight; `readiness-escalation` in
106
+ * readiness-gate.bpmn / wait-gate.bpmn). Like `feature-blocked`, `conformance-escalation` and
107
+ * `empty-plan-escalation` these are HUMAN-only decisions — an agent must never auto-answer a readiness
108
+ * gate (that would silently defeat the "is upstream actually ready?" adjudication the gate exists for),
109
+ * so they live OUTSIDE `ESCALATION_TASK_ELEMENTS` and only the HUMAN completer accepts them (issue
110
+ * #674). Re-exported from the canonical constants in app/userTasks.ts — one source of truth. */
111
+ export const READINESS_ESCALATION_PF_TASK_ELEMENT = READINESS_ESCALATION_PF_ELEMENT;
112
+ export const READINESS_ESCALATION_TASK_ELEMENT = READINESS_ESCALATION_ELEMENT;
113
+
104
114
  /** The user-task `elementId`s a HUMAN operator may complete from the Tasks inbox via the one canonical
105
115
  * `complete-user-task` door: every agent-answerable escalation PLUS the human-only `feature-blocked`
106
116
  * and `conformance-escalation` acknowledgements, PLUS the advisory ACP permission prompt
@@ -115,6 +125,8 @@ export const HUMAN_COMPLETABLE_ELEMENTS: ReadonlySet<string> = new Set([
115
125
  FEATURE_BLOCKED_TASK_ELEMENT,
116
126
  CONFORMANCE_ESCALATION_TASK_ELEMENT,
117
127
  EMPTY_PLAN_TASK_ELEMENT,
128
+ READINESS_ESCALATION_PF_TASK_ELEMENT,
129
+ READINESS_ESCALATION_TASK_ELEMENT,
118
130
  ACP_PERMISSION_ELEMENT,
119
131
  ]);
120
132
 
@@ -131,6 +143,8 @@ const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
131
143
  "feature-blocked": "feature-blocked",
132
144
  [EMPTY_PLAN_TASK_ELEMENT]: "empty-plan-escalation",
133
145
  [CONFORMANCE_ESCALATION_TASK_ELEMENT]: "conformance-escalation",
146
+ [READINESS_ESCALATION_PF_TASK_ELEMENT]: "readiness-escalation",
147
+ [READINESS_ESCALATION_TASK_ELEMENT]: "readiness-escalation",
134
148
  // NOTE: the delivery-graph `human` node (`DELIVERY_HUMAN_ELEMENT`, ADR 0005 S3) is intentionally
135
149
  // ABSENT here. Unlike the fixed-form escalations above, ONE `delivery-human-task` element is DESIGNED
136
150
  // to render DIFFERENT forms per node (explicit → category → generic → agent-router, `app/deliveryHuman.ts`
@@ -1,11 +1,12 @@
1
- // Drift-guard: exactly ONE parser of the transcript log (ADR 0056, #251).
1
+ // Drift-guard: the transcript grammar is DERIVED from its one owner, never re-forked here (#676).
2
2
  //
3
- // Acceptance criterion (#251): "the cockpit renders from a single derive*() fold, with no independent
4
- // re-parse of raw bytes (drift-guard test asserts one parser)". This is that guard. It enforces
5
- // structurallyby scanning the app-tier source that the raw-chunk typed-event classification
6
- // lives in exactly one module (`transcript-events.ts`), so a second, divergent parser of the same
7
- // bytes cannot creep in. The whole point of the event-sourced model is "the log IS the state": every
8
- // view derives from the one fold, none re-parses the bytes itself.
3
+ // nano-workforce used to carry a byte-for-byte hand-rolled copy of the transcript-event grammar
4
+ // (marker/version, the ONE parser, the vocabulary, the derive fold). That grammar now lives in exactly
5
+ // one place `@nanobpm/agentic/transcript` (agentic 0.10.0)and `transcript-events.ts` is a thin
6
+ // re-export barrel over it. This guard secures the *class* of failure ("a consumer hand-rolls the
7
+ // transcript grammar instead of importing it") structurally, by scanning the app-tier source: it fails
8
+ // if the barrel stops importing agentic, if a local module re-defines the envelope marker literal, or
9
+ // if a transcript consumer re-parses a stored chunk itself instead of folding through the one parser.
9
10
  import { test } from "node:test";
10
11
  import { readdirSync, readFileSync } from "node:fs";
11
12
  import { dirname, join } from "node:path";
@@ -26,26 +27,36 @@ function sourceFiles(dir: string): string[] {
26
27
  return out;
27
28
  }
28
29
 
29
- const PARSER_MODULE = join(AGENTIC_DIR, "transcript-events.ts");
30
+ const BARREL_MODULE = join(AGENTIC_DIR, "transcript-events.ts");
31
+ const AGENTIC_TRANSCRIPT_SPECIFIER = "@nanobpm/agentic/transcript";
30
32
 
31
- test("the transcript-event marker literal is DEFINED in exactly one module (no second parser)", () => {
32
- // Consumers reference the marker via the imported `TRANSCRIPT_EVENT_MARKER` identifier; only the ONE
33
- // parser embeds the marker's string literal. A second module hardcoding it would be a second parser.
34
- // Match every quote form (double, single, backtick) so a second parser can't bypass the guard by
35
- // hardcoding the marker in a different literal style.
33
+ test("the transcript grammar is imported from @nanobpm/agentic/transcript, never redefined locally", () => {
34
+ // The single source of truth is agentic; nano-workforce derives from it via a re-export barrel.
35
+ const barrel = readFileSync(BARREL_MODULE, "utf8");
36
+ assert(
37
+ barrel.includes(AGENTIC_TRANSCRIPT_SPECIFIER),
38
+ `${BARREL_MODULE} must re-export the grammar from "${AGENTIC_TRANSCRIPT_SPECIFIER}"`,
39
+ );
40
+ });
41
+
42
+ test("no local module re-defines the transcript-event marker literal (no second grammar)", () => {
43
+ // Consumers reference the marker via the imported `TRANSCRIPT_EVENT_MARKER` identifier; only a
44
+ // second, forked grammar would embed the marker's string literal in nano-workforce source. Match
45
+ // every quote form (double, single, backtick) so a re-fork can't bypass the guard by hardcoding the
46
+ // marker in a different literal style. Expect ZERO owners — the literal lives in agentic now.
36
47
  const quotedMarkerForms = ['"', "'", "`"].map((q) => `${q}${TRANSCRIPT_EVENT_MARKER}${q}`);
37
48
  const owners = sourceFiles(AGENTIC_DIR).filter((path) => {
38
49
  const src = readFileSync(path, "utf8");
39
50
  return quotedMarkerForms.some((literal) => src.includes(literal));
40
51
  });
41
- assertEquals(owners, [PARSER_MODULE]);
52
+ assertEquals(owners, []);
42
53
  });
43
54
 
44
- test("no transcript consumer re-parses raw chunks — JSON.parse of the log lives only in the parser", () => {
45
- // The cockpit + read projections must fold through the single parser, never JSON.parse a chunk
46
- // themselves. Scan the transcript-facing consumers and assert none contains a raw JSON.parse.
47
- const consumers = sourceFiles(AGENTIC_DIR).filter(
48
- (path) => path !== PARSER_MODULE && /transcript-(read|render|view|derive|fork)\.ts$/.test(path),
55
+ test("no transcript consumer re-parses raw chunks — the log is folded only through the one parser", () => {
56
+ // The cockpit + read projections must fold through the single parser (in agentic), never JSON.parse a
57
+ // chunk themselves. Scan the transcript-facing consumers and assert none contains a raw JSON.parse.
58
+ const consumers = sourceFiles(AGENTIC_DIR).filter((path) =>
59
+ /transcript-(read|render|view|derive|fork)\.ts$/.test(path),
49
60
  );
50
61
  assert(consumers.length >= 3, "expected to scan several transcript consumers");
51
62
  for (const path of consumers) {