@nanobpm/nano-workforce 0.95.0 → 0.96.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.96.0](https://github.com/nanobpm/nano-workforce/compare/v0.95.0...v0.96.0) (2026-08-19)
2
+
3
+
4
+ ### Features
5
+
6
+ * **escalations:** consolidate on native user_tasks — expand half (issue [#305](https://github.com/nanobpm/nano-workforce/issues/305)) ([#310](https://github.com/nanobpm/nano-workforce/issues/310)) ([fc5f4d4](https://github.com/nanobpm/nano-workforce/commit/fc5f4d4680223d0c7ca4572a7f883ce4257a26a5)), closes [nano-ide#333](https://github.com/nano-ide/issues/333)
7
+
1
8
  # [0.95.0](https://github.com/nanobpm/nano-workforce/compare/v0.94.0...v0.95.0) (2026-08-19)
2
9
 
3
10
 
@@ -29,11 +29,23 @@ test("it ships the crew's leaf tokens", () => {
29
29
  "implementation.reviewer",
30
30
  "ci.runner",
31
31
  "decide",
32
+ "senior",
32
33
  ]) {
33
34
  assert(tokens.includes(expected), `expected token ${expected}`);
34
35
  }
35
36
  });
36
37
 
38
+ test("a representative senior worker resolves the rank-gated `senior` role (any cognition, weight>=4)", () => {
39
+ const implementation = crewResolver().resolve({ cognition: "implementation", weight: 5, family: "frontier" });
40
+ const planning = crewResolver().resolve({ cognition: "planning", weight: 4, family: "kimi" });
41
+ assert(implementation.tokens.includes("senior"), "an implementation senior serves `senior`");
42
+ assert(planning.tokens.includes("senior"), "a planning senior serves `senior` (rank, not cognition)");
43
+ });
44
+
45
+ test("a junior-weight worker does not resolve the `senior` rank role", () => {
46
+ assert(!crewResolver().resolve({ cognition: "implementation", weight: 2, family: "kimi" }).tokens.includes("senior"));
47
+ });
48
+
37
49
  test("the spar role carries two distinct-family named seats", () => {
38
50
  const spar = crewResolver().roleForToken("planning.spar");
39
51
  assert(spar !== undefined);
@@ -17,6 +17,8 @@
17
17
  // - implementation.senior / .junior / .reviewer — the build crew (reviewer is red/blue).
18
18
  // - ci.runner — the CI runner.
19
19
  // - decide — the bare decision role.
20
+ // - senior — the bare seniority-rank role the deployed fleet agent job types (`senior:*`)
21
+ // derive onto (issue #323); rank-gated (weight≥4), cognition-agnostic.
20
22
  //
21
23
  // Capability (cognition / weight / family / host) is an ENROLMENT attribute, never a routing token
22
24
  // (ADR 0056 invariant 3): each role's `requires` gate — not the token — decides WHO may fill it.
@@ -132,6 +134,22 @@ export const CREW_VOCAB: VocabDocument = deepFreeze({
132
134
  },
133
135
  },
134
136
  },
137
+ // The deployed fleet agent tasks are colon-form job types `senior:<task>` (`senior:feature`,
138
+ // `senior:retro`, `senior:rebase`, …). The `senior` prefix is a SENIORITY rank, not a cognition —
139
+ // one senior worker serves every `senior:*` task — so it maps to a single bare `senior` routing
140
+ // role gated purely on rank (weight≥4), cognition-agnostic. Any suitably-senior enrolled worker
141
+ // resolves it, so `app/agentic/vocab/job-types.ts`'s `senior:<task>` → `senior` derivation always
142
+ // lands on live supply (issue #323). Deliberately NOT one role per task: a per-task list here
143
+ // would duplicate the deployed models (issue #323 acceptance §3 — derivation over duplication).
144
+ senior: {
145
+ roles: {
146
+ senior: {
147
+ requires: ["weight>=4"],
148
+ weight: 5,
149
+ seats: 1,
150
+ },
151
+ },
152
+ },
135
153
  },
136
154
  });
137
155
 
@@ -13,6 +13,40 @@ const leaf = (taskType: string): TaskDefinitionLeaf => ({ taskType, process: "p"
13
13
 
14
14
  const plannerFrontier: RegisteredWorker = { instance: "w-front", capability: { cognition: "planning", weight: 5, family: "frontier" } };
15
15
  const plannerKimi: RegisteredWorker = { instance: "w-kimi", capability: { cognition: "planning", weight: 5, family: "kimi" } };
16
+ const seniorImpl: RegisteredWorker = { instance: "w-senior", capability: { cognition: "implementation", weight: 5, family: "frontier" } };
17
+
18
+ test("a deployed colon-form agent job type resolves to live supply from an enrolled senior worker (#323)", () => {
19
+ const report = buildRegistryReport({
20
+ taskDefinitions: [leaf("senior:feature"), leaf("senior:retro"), leaf("senior:rebase")],
21
+ workers: [seniorImpl],
22
+ now: NOW,
23
+ });
24
+ // All three colon-form agent job types bridge onto the bare `senior` routing role, which the senior
25
+ // worker supplies — so the board shows live supply, not a false RED, and nothing is nonAgentic.
26
+ assertEquals(report.missing, []);
27
+ assertEquals(report.nonAgentic, []);
28
+ const senior = report.networks.find((n) => n.network === "senior");
29
+ assert(senior !== undefined, "the `senior` network bucket is present");
30
+ const token = senior.tokens.find((t) => t.token === "senior");
31
+ assert(token !== undefined);
32
+ assertEquals(token.satisfied, true);
33
+ assertEquals(token.supply, 1);
34
+ assertEquals(token.instances, ["w-senior"]);
35
+ });
36
+
37
+ test("an agent job type with no enrolled senior worker is flagged missing, not nonAgentic (#323)", () => {
38
+ const report = buildRegistryReport({ taskDefinitions: [leaf("senior:feature")], workers: [], now: NOW });
39
+ assert(report.missing.includes("senior"), "senior demand with no supplier is missing (red)");
40
+ assertEquals(report.nonAgentic, []);
41
+ assertEquals(report.status, "red");
42
+ });
43
+
44
+ test("ordinary host jobs (pr.*) are not colon-form and pass through the bridge untouched", () => {
45
+ const report = buildRegistryReport({ taskDefinitions: [leaf("pr.finalize")], workers: [], now: NOW });
46
+ const pr = report.networks.find((n) => n.network === "pr");
47
+ assert(pr !== undefined, "pr.finalize stays a pr-network routing token");
48
+ assert(pr.tokens.some((t) => t.token === "pr.finalize"));
49
+ });
16
50
 
17
51
  test("flags a demanded leaf with no supplier as missing (red) and a supplied leaf as satisfied", () => {
18
52
  const report = buildRegistryReport({
@@ -23,6 +23,7 @@ import type { RegistryReport as WireRegistryReport } from "../../../nano-generat
23
23
  import { envVar } from "../../version.ts";
24
24
  import { currentPresenceRegistry } from "../families/presence.family.ts";
25
25
  import { CREW_VOCAB_VERSION, crewResolver } from "./crew-vocab.ts";
26
+ import { jobTypeToRoutingToken } from "./job-types.ts";
26
27
 
27
28
  /** The full registry report: the package's demand×supply model plus this app's version/provenance. */
28
29
  export interface RegistryReport extends DemandSupplyReport {
@@ -82,6 +83,25 @@ export interface BuildRegistryInput {
82
83
  readonly now?: Date;
83
84
  }
84
85
 
86
+ /**
87
+ * Bridge the deployed fleet agent job types onto crew routing tokens so the demand×supply match can
88
+ * see live supply for them. The deployed AGENT tasks are colon-form job types (`senior:feature`,
89
+ * `senior:retro`, …) the crew vocab's dot-form SERVE tokens never string-match; each such leaf is
90
+ * rewritten to the routing token an enrolled worker resolves (`senior:<task>` → `senior`, the bare
91
+ * rank role) via {@link jobTypeToRoutingToken}. Ordinary host jobs (`pr.*`, already dot-form tokens)
92
+ * are not in colon form, so they pass through untouched. Element provenance is preserved. (Issue #323
93
+ * design choice 1b: an app-tier job-type → routing-token derivation, no parallel task-type list.)
94
+ */
95
+ export function bridgeDemandLeaves(
96
+ taskDefinitions: readonly TaskDefinitionLeaf[] | undefined,
97
+ ): readonly TaskDefinitionLeaf[] | undefined {
98
+ if (taskDefinitions === undefined) return undefined;
99
+ return taskDefinitions.map((leaf) => {
100
+ const token = jobTypeToRoutingToken(leaf.taskType);
101
+ return token === undefined ? leaf : { ...leaf, taskType: token };
102
+ });
103
+ }
104
+
85
105
  /**
86
106
  * Compute the registry report from demand + supply. Pure and deterministic (every list the package
87
107
  * emits is sorted), so it is safe to render straight into the board and diff frame-to-frame.
@@ -89,7 +109,7 @@ export interface BuildRegistryInput {
89
109
  export function buildRegistryReport(input: BuildRegistryInput): RegistryReport {
90
110
  const demandUnavailable = input.taskDefinitions === undefined;
91
111
  const report = computeDemandSupply({
92
- taskDefinitions: input.taskDefinitions ?? [],
112
+ taskDefinitions: bridgeDemandLeaves(input.taskDefinitions) ?? [],
93
113
  workers: input.workers,
94
114
  resolver: crewResolver(),
95
115
  });
@@ -0,0 +1,107 @@
1
+ // Tests for the deployed-job-type ↔ crew-routing-token bridge (issue #323), including the
2
+ // defect-class regression guard: every deployed prompt-bearing agent job type must resolve to a
3
+ // SERVE token a representative enrolled senior worker can supply, so a newly-added agent task that is
4
+ // not wired into the crew vocab fails CI instead of silently showing RED on the demand×supply board.
5
+ import { readFileSync, readdirSync } from "node:fs";
6
+ import { test } from "node:test";
7
+ import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { assert, assertEquals } from "#test-assert";
10
+ import type { Capability } from "@nanobpm/agentic/protocol";
11
+ import { crewResolver } from "./crew-vocab.ts";
12
+ import { jobTypeToRoutingToken, promptBearingTaskTypes } from "./job-types.ts";
13
+
14
+ const PROCESSES_DIR = join(dirname(fileURLToPath(import.meta.url)), "../../../resources/processes");
15
+
16
+ /** Every prompt-bearing agent job type deployed across the BPMN models, distinct and sorted. */
17
+ function deployedAgentJobTypes(): string[] {
18
+ const types = new Set<string>();
19
+ for (const file of readdirSync(PROCESSES_DIR)) {
20
+ if (!file.endsWith(".bpmn")) continue;
21
+ for (const type of promptBearingTaskTypes(readFileSync(join(PROCESSES_DIR, file), "utf8"))) {
22
+ types.add(type);
23
+ }
24
+ }
25
+ return [...types].sort();
26
+ }
27
+
28
+ // A representative enrolled senior worker — the staffing the board assumes when it reports supply.
29
+ const seniorWorker: Capability = { cognition: "implementation", weight: 5, family: "frontier", host: "h1" };
30
+
31
+ test("jobTypeToRoutingToken derives the bare rank token from a colon-form agent job type", () => {
32
+ assertEquals(jobTypeToRoutingToken("senior:feature"), "senior");
33
+ assertEquals(jobTypeToRoutingToken("senior:retro"), "senior");
34
+ assertEquals(jobTypeToRoutingToken("senior:plan-review"), "senior");
35
+ assertEquals(jobTypeToRoutingToken("senior:trial-merge"), "senior");
36
+ });
37
+
38
+ test("jobTypeToRoutingToken returns undefined for a non-agent (dot-form host) job type", () => {
39
+ assertEquals(jobTypeToRoutingToken("pr.finalize"), undefined);
40
+ assertEquals(jobTypeToRoutingToken("pr.record-plan"), undefined);
41
+ });
42
+
43
+ test("jobTypeToRoutingToken returns undefined for malformed / non-routing rank forms", () => {
44
+ assertEquals(jobTypeToRoutingToken(":feature"), undefined); // empty rank
45
+ assertEquals(jobTypeToRoutingToken("senior:"), undefined); // empty task
46
+ assertEquals(jobTypeToRoutingToken("senior"), undefined); // no colon at all
47
+ assertEquals(jobTypeToRoutingToken("Senior:feature"), undefined); // not a valid segment
48
+ });
49
+
50
+ test("jobTypeToRoutingToken rejects a dotted (multi-segment) rank prefix", () => {
51
+ // A dotted prefix is a multi-segment routing token, not a bare rank role — bridging it would
52
+ // distort demand/supply matching, so it must not derive.
53
+ assertEquals(jobTypeToRoutingToken("implementation.senior:feature"), undefined);
54
+ assertEquals(jobTypeToRoutingToken("planning.spar:plan"), undefined);
55
+ });
56
+
57
+ test("jobTypeToRoutingToken rejects a multi-colon job type (not `<rank>:<task>` form)", () => {
58
+ assertEquals(jobTypeToRoutingToken("senior:feature:extra"), undefined);
59
+ });
60
+
61
+ test("promptBearingTaskTypes picks a prompt-linked agent task and skips a plain host task", () => {
62
+ const xml = `
63
+ <bpmn:serviceTask id="host">
64
+ <bpmn:extensionElements>
65
+ <zeebe:taskDefinition type="pr.finalize" />
66
+ </bpmn:extensionElements>
67
+ </bpmn:serviceTask>
68
+ <bpmn:serviceTask id="agent">
69
+ <bpmn:extensionElements>
70
+ <zeebe:taskDefinition type="senior:feature" />
71
+ <zeebe:linkedResources>
72
+ <zeebe:linkedResource resourceId="feature.md" resourceType="GenericScript" linkName="prompt" />
73
+ </zeebe:linkedResources>
74
+ </bpmn:extensionElements>
75
+ </bpmn:serviceTask>`;
76
+ assertEquals(promptBearingTaskTypes(xml), ["senior:feature"]);
77
+ });
78
+
79
+ test("a representative senior worker serves the bare `senior` routing role", () => {
80
+ assert(crewResolver().resolve(seniorWorker).tokens.includes("senior"), "senior worker serves `senior`");
81
+ });
82
+
83
+ test("retro and rebase specifically resolve to a supplying role", () => {
84
+ for (const jobType of ["senior:retro", "senior:rebase"]) {
85
+ const token = jobTypeToRoutingToken(jobType);
86
+ assert(token !== undefined, `${jobType} derives a routing token`);
87
+ assert(
88
+ crewResolver().resolve(seniorWorker).tokens.includes(token),
89
+ `${jobType} → ${token} is supplied by a senior worker`,
90
+ );
91
+ }
92
+ });
93
+
94
+ test("DEFECT-CLASS GUARD: every deployed prompt-bearing agent job type resolves to a suppliable SERVE token", () => {
95
+ const jobTypes = deployedAgentJobTypes();
96
+ // Sanity: the models really do declare agent tasks (guard is not vacuously green).
97
+ assert(jobTypes.length > 0, "the deployed models declare prompt-bearing agent tasks");
98
+ const serve = new Set(crewResolver().resolve(seniorWorker).tokens);
99
+ for (const jobType of jobTypes) {
100
+ const token = jobTypeToRoutingToken(jobType);
101
+ assert(token !== undefined, `deployed agent job type ${jobType} is not in <rank>:<task> form`);
102
+ assert(
103
+ serve.has(token),
104
+ `deployed agent job type ${jobType} resolves to ${token}, which no enrolled senior worker supplies — wire it into the crew vocab`,
105
+ );
106
+ }
107
+ });
@@ -0,0 +1,70 @@
1
+ // nano-workforce — the deployed-job-type ↔ crew-routing-token bridge (issue #323).
2
+ //
3
+ // The demand×supply board resolves live SUPPLY through the crew vocab, which emits dot-form routing
4
+ // tokens (`implementation.senior`, `planning.spar`, …). But the deployed fleet agent tasks are
5
+ // COLON-form job types (`senior:feature`, `senior:retro`, …) — the `<zeebe:taskDefinition type>` the
6
+ // engine matches 1:1. The two never string-match, so an advertised agentic demand shows RED (no
7
+ // supply) even when a suitably-capable senior worker is enrolled.
8
+ //
9
+ // This module is the app-tier bridge (design choice 1b): a pure DERIVATION from a deployed agent job
10
+ // type to the crew routing token an enrolled worker resolves — NOT a hand-maintained parallel list of
11
+ // task types (AGENTS.md: derivation over duplication; issue #323 acceptance §3). The colon in a fleet
12
+ // job type is `<rank>:<task>`: the `rank` prefix is a seniority assertion the crew vocab models as a
13
+ // bare rank role, and the `task` suffix is a job selector that does not change WHICH worker serves it
14
+ // (one senior worker serves every `senior:*` task). So the derivation is: take the rank segment as the
15
+ // bare routing token. `senior:retro` and `senior:feature` both derive to the `senior` role a
16
+ // weight≥4 worker fills — no per-task role, no drift surface.
17
+ //
18
+ // The prompt-bearing scan identifies the deployed AGENT tasks (a `<zeebe:linkedResource … linkName=
19
+ // "prompt">` on the service task) so a regression guard can enumerate the real demand corpus straight
20
+ // from the models and assert every agent job type resolves to a suppliable token.
21
+
22
+ import { isSegmentName } from "@nanobpm/agentic/protocol";
23
+
24
+ /**
25
+ * Derive the crew routing token an enrolled worker resolves to for a deployed fleet agent job type,
26
+ * or `undefined` when the type is not an agent job type in `<rank>:<task>` colon form (e.g. an
27
+ * ordinary host job like `pr.finalize`, which is already a dot-form token and is left untouched).
28
+ *
29
+ * The rank segment is returned as the bare routing token: `senior:retro` → `senior`. The derivation
30
+ * is purely syntactic — it never enumerates task types — so a newly-added `senior:<task>` is covered
31
+ * automatically by the same rank role, while a new RANK (`principal:*`) that has no crew role surfaces
32
+ * as unsupplied and trips the regression guard.
33
+ */
34
+ export function jobTypeToRoutingToken(jobType: string): string | undefined {
35
+ const colon = jobType.indexOf(":");
36
+ if (colon <= 0) return undefined;
37
+ const rank = jobType.slice(0, colon);
38
+ const task = jobType.slice(colon + 1);
39
+ if (task.length === 0) return undefined;
40
+ // The grammar is exactly `<rank>:<task>` with a SINGLE colon: a further colon (e.g.
41
+ // `senior:feature:extra`) is not this form, so reject it rather than silently deriving `senior`.
42
+ if (task.indexOf(":") !== -1) return undefined;
43
+ // The rank must be a bare SINGLE-SEGMENT routing token (a role like `senior`) — not a dotted,
44
+ // multi-segment token like `implementation.senior`, which would distort demand/supply matching.
45
+ // `isSegmentName` enforces the single-segment `[a-z][a-z0-9-]*` grammar (no dots, no seat marker).
46
+ return isSegmentName(rank) ? rank : undefined;
47
+ }
48
+
49
+ const SERVICE_TASK = /<(?:\w+:)?serviceTask\b[\s\S]*?<\/(?:\w+:)?serviceTask>/g;
50
+ const TASK_DEFINITION_TYPE = /<(?:\w+:)?taskDefinition\b[^>]*\btype="([^"]*)"/;
51
+ const PROMPT_LINK = /<(?:\w+:)?linkedResource\b[^>]*\blinkName="prompt"/;
52
+
53
+ /**
54
+ * Scan one BPMN document for the job types of its PROMPT-BEARING service tasks — the deployed fleet
55
+ * AGENT tasks. A task is prompt-bearing iff it carries a `<zeebe:linkedResource … linkName="prompt">`
56
+ * (the base-prompt resource the engine delivers to the agent). Ordinary host jobs (no prompt link)
57
+ * are excluded. Returns the distinct task types in first-occurrence order.
58
+ */
59
+ export function promptBearingTaskTypes(xml: string): string[] {
60
+ const seen = new Set<string>();
61
+ const types: string[] = [];
62
+ for (const [block] of xml.matchAll(SERVICE_TASK)) {
63
+ if (!PROMPT_LINK.test(block)) continue;
64
+ const type = block.match(TASK_DEFINITION_TYPE)?.[1];
65
+ if (type === undefined || type.length === 0 || seen.has(type)) continue;
66
+ seen.add(type);
67
+ types.push(type);
68
+ }
69
+ return types;
70
+ }
package/app/feature.ts CHANGED
@@ -180,6 +180,49 @@ export function deriveFeatureDelivery(prStatus: string | null): FeatureDeliveryR
180
180
  * parks on when the agent escalates. `pollFeatureEscalations` reconciles it onto the read model. */
181
181
  export const FEATURE_ESCALATION_ELEMENT = "feature-escalation";
182
182
 
183
+ /** One append-only audit row per `feature-escalation` ENTRY (issue #305). Mirrors the surviving
184
+ * `plan_reviews` / `escalations` / `plan_trial_merges` audit logs: it is the canonical, poller-readable
185
+ * source for a parked run's escalation `question`, so the denormalised `feature_runs.escalation_question`
186
+ * column can be dropped in the later contract phase. `id` is an AUTOINCREMENT PK, so the newest row per
187
+ * `feature_key` is the live question (`latestFeatureEscalationQuestion`). Never updated or deleted. */
188
+ export interface FeatureEscalationRow {
189
+ id: number;
190
+ feature_key: string;
191
+ question: string | null;
192
+ created_at: string;
193
+ /** The engine `jobKey` that wrote the row — an idempotency guard so a `record-feature-escalation`
194
+ * job retried after its insert (crash/timeout pre-completion) reuses its row instead of appending a
195
+ * duplicate (mirrors `plan_reviews.job_key`). NULL only for migration-048 backfill rows. */
196
+ job_key: string | null;
197
+ }
198
+
199
+ /** Accessor for the append-only `feature_escalations` audit log (migration 048). Written by
200
+ * `record-feature-escalation` (one row per escalation entry), read by `pollUserTasks` to enrich the
201
+ * open `feature-escalation` task's question — the feature analogue of `plan_reviews` / `escalations`. */
202
+ export const featureEscalations = (data: DataLayer) =>
203
+ data.table<FeatureEscalationRow>("feature_escalations", "id");
204
+
205
+ /** Append one `feature_escalations` audit row capturing the agent's escalation `question` while it is
206
+ * still in scope on the `record-feature-escalation` job. Append-only, so this is the canonical record
207
+ * of what was asked — `pollUserTasks` reads the newest row per feature as the live question. The
208
+ * `jobKey` is an idempotency guard: `record-feature-escalation` is at-least-once, so a retry after the
209
+ * insert (crash/timeout pre-completion) re-runs with the SAME `jobKey` and must reuse the existing row
210
+ * rather than append a duplicate (mirrors `record-plan-review` guarding `plan_reviews` by `job_key`). */
211
+ export async function recordFeatureEscalation(
212
+ data: DataLayer,
213
+ entry: { featureKey: string; question: string | null; jobKey: string },
214
+ ): Promise<void> {
215
+ const table = featureEscalations(data);
216
+ // A prior attempt of THIS job already recorded its row — reuse it, don't append a duplicate.
217
+ if (await table.findOne({ feature_key: entry.featureKey, job_key: entry.jobKey })) return;
218
+ await table.insert({
219
+ feature_key: entry.featureKey,
220
+ question: entry.question,
221
+ created_at: new Date().toISOString(),
222
+ job_key: entry.jobKey,
223
+ });
224
+ }
225
+
183
226
  /** The parked `feature-escalation` user task, as `pollFeatureEscalations` observes it via
184
227
  * `openUserTasks` (the open-task-scoped query — issue #294): the completable user-task key the pages
185
228
  * drive an attributed answer against. Scoping to `state:"CREATED"` is what keeps a looping run — which