@nanobpm/nano-workforce 0.186.3 → 0.187.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,15 @@
1
+ ## [0.187.0](https://github.com/nanobpm/nano-workforce/compare/v0.186.4...v0.187.0) (2026-09-12)
2
+
3
+ ### Features
4
+
5
+ * **agentic:** autoSubscribe opt-out marker + single agentDefinition-external convention ([#782](https://github.com/nanobpm/nano-workforce/issues/782)) ([6a0c616](https://github.com/nanobpm/nano-workforce/commit/6a0c616158db7e83ab2479d66677dd652c12f886)), closes [jwulf/c8ctl-plugin-nano#235](https://github.com/jwulf/c8ctl-plugin-nano/issues/235) [#779](https://github.com/nanobpm/nano-workforce/issues/779) [#745](https://github.com/nanobpm/nano-workforce/issues/745)
6
+
7
+ ## [0.186.4](https://github.com/nanobpm/nano-workforce/compare/v0.186.3...v0.186.4) (2026-09-12)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **agentic:** allow c8ctl GitHub auth for repository provisioning ([#784](https://github.com/nanobpm/nano-workforce/issues/784)) ([323bb05](https://github.com/nanobpm/nano-workforce/commit/323bb05060e63ca57082d42e3bf9c91f63abaa0a)), closes [#770](https://github.com/nanobpm/nano-workforce/issues/770) [#770](https://github.com/nanobpm/nano-workforce/issues/770) [#770](https://github.com/nanobpm/nano-workforce/issues/770)
12
+
1
13
  ## [0.186.3](https://github.com/nanobpm/nano-workforce/compare/v0.186.2...v0.186.3) (2026-09-12)
2
14
 
3
15
  ### Bug Fixes
package/SPEC.md CHANGED
@@ -357,6 +357,41 @@ the app, which deploys on boot), and the next agent job of that type picks it up
357
357
  > (`$AGENT_RESULT_FILE` / `::nano:result::`), and no task may still carry the retired
358
358
  > baked `io.nanobpm.agentTask.task.prompt` header.
359
359
 
360
+ > **`<zeebe:agentDefinition agentType="external" />` is the ONE agentic-task signal.**
361
+ > Every hand-authored `senior:*` agent service task in `resources/processes` carries this
362
+ > engine-native AgentTask marker
363
+ > (issue #745) alongside its `<zeebe:taskDefinition>`, and it is the **single
364
+ > convention** the worker harness `--auto` reconciliation scans to discover agentic
365
+ > tasks — replacing the legacy `linkName="prompt"` / header dual signal so the app and
366
+ > harness converge on one signal (issue #779, harness jwulf/c8ctl-plugin-nano#235). The
367
+ > marker is CI-enforced over the deployed `resources/` process models
368
+ > (`agentTaskTypesMissingExternalMarker`,
369
+ > `agent-marker.test.ts`), so no prompt-bearing agent task in those models relies on
370
+ > prompt-link-only discovery. (The delivery-graph compiler's GENERATED agent BPMN —
371
+ > deployed at run time by `runDeliveryGraph`, not authored under `resources/` — is a
372
+ > separate deployed path NOT covered by this static guard; whether its generated cells
373
+ > should also carry the marker/opt-out convention is tracked separately under issue #745,
374
+ > not #779.) To **exclude** a task from `--auto` — one that must be served only by a
375
+ > worker that explicitly subscribes (`--job-type <type>` / a profile capability) — add
376
+ > the inert opt-out property inside its `extensionElements`, nested in the
377
+ > `<zeebe:properties>` wrapper the models and engine expect (as
378
+ > `resources/processes/feature.bpmn:57-63` does — a bare `<zeebe:property>` placed
379
+ > directly under `<bpmn:extensionElements>` is NOT the accepted shape):
380
+ >
381
+ > ```xml
382
+ > <bpmn:extensionElements>
383
+ > <zeebe:properties>
384
+ > <zeebe:property name="io.nanobpm.agentTask.autoSubscribe" value="false" />
385
+ > </zeebe:properties>
386
+ > </bpmn:extensionElements>
387
+ > ```
388
+ >
389
+ > Absence (or any value other than `"false"`) auto-subscribes as normal — opt-out is
390
+ > explicit and fail-safe. The property is inert to the engine (no migration, no
391
+ > behaviour change). It is a registered contract (`agentTask.autoSubscribe` in
392
+ > `app/contracts.ts`), read by the ONE helper `agentTaskTypesOptedOutOfAuto`
393
+ > (`app/agentic/vocab/job-types.ts`) and guarded by `auto-subscribe.test.ts`.
394
+
360
395
  Per-instance dynamic context still rides **`appendPrompt`** (unchanged): an ioMapping
361
396
  sets a job-local `appendPrompt` string (a plan's rejection findings, a feature task's
362
397
  brief, the failing-check list) which the agent harness concatenates **verbatim** onto
@@ -0,0 +1,261 @@
1
+ // Tests for the `--auto` opt-OUT marker (issue #779, harness jwulf/c8ctl-plugin-nano#235). The single
2
+ // agentic-task signal the harness `--auto` reconciliation scans is `<zeebe:agentDefinition
3
+ // agentType="external" />`; this marker — `<zeebe:property name="io.nanobpm.agentTask.autoSubscribe"
4
+ // value="false" />` inside an agent task's extensionElements — is the explicit escape hatch that
5
+ // EXCLUDES a task from `--auto` so it is served only by a worker that explicitly subscribes. Mirrors
6
+ // agent-marker.test.ts: assert the marker's shape/placement, and guard that any opted-out task in the
7
+ // deployed models is itself a real (externally-marked) agent task, so the opt-out can't drift onto a
8
+ // non-agent element.
9
+ import { readFileSync, readdirSync } from "node:fs";
10
+ import { test } from "node:test";
11
+ import { dirname, join, relative } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { assert, assertEquals } from "#test-assert";
14
+ import {
15
+ agentTaskTypesMissingExternalMarker,
16
+ agentTaskTypesOptedOutMissingExternalMarker,
17
+ agentTaskTypesOptedOutOfAuto,
18
+ MALFORMED_OPTOUT_LABEL,
19
+ } from "./job-types.ts";
20
+
21
+ const RESOURCES_DIR = join(dirname(fileURLToPath(import.meta.url)), "../../../resources");
22
+
23
+ // urban deploys `resources/` recursively (every file at ANY depth), so an opted-out task in a `.bpmn`
24
+ // added under any resources subdirectory — not just `resources/processes` — would still deploy. Root
25
+ // the walk at the `resources/` convention root (mirroring the deploy contract) so a deployed BPMN
26
+ // placed elsewhere under `resources/` cannot bypass this guard.
27
+ function bpmnFiles(): string[] {
28
+ const walk = (dir: string): string[] =>
29
+ readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
30
+ const full = join(dir, entry.name);
31
+ if (entry.isDirectory()) return walk(full);
32
+ return entry.name.endsWith(".bpmn") ? [relative(RESOURCES_DIR, full)] : [];
33
+ });
34
+ return walk(RESOURCES_DIR).sort();
35
+ }
36
+
37
+ test("agentTaskTypesOptedOutOfAuto flags a task carrying the value=\"false\" opt-out property", () => {
38
+ const xml = `
39
+ <bpmn:serviceTask id="agent">
40
+ <bpmn:extensionElements>
41
+ <zeebe:taskDefinition type="senior:special" />
42
+ <zeebe:agentDefinition agentType="external" />
43
+ <zeebe:properties>
44
+ <zeebe:property name="io.nanobpm.agentTask.autoSubscribe" value="false" />
45
+ </zeebe:properties>
46
+ </bpmn:extensionElements>
47
+ </bpmn:serviceTask>`;
48
+ assertEquals(agentTaskTypesOptedOutOfAuto(xml), ["senior:special"]);
49
+ });
50
+
51
+ test("agentTaskTypesOptedOutOfAuto tolerates reversed attribute order (name/value swapped)", () => {
52
+ const xml = `
53
+ <bpmn:serviceTask id="agent">
54
+ <bpmn:extensionElements>
55
+ <zeebe:taskDefinition type="senior:special" />
56
+ <zeebe:properties>
57
+ <zeebe:property value="false" name="io.nanobpm.agentTask.autoSubscribe" />
58
+ </zeebe:properties>
59
+ </bpmn:extensionElements>
60
+ </bpmn:serviceTask>`;
61
+ assertEquals(agentTaskTypesOptedOutOfAuto(xml), ["senior:special"]);
62
+ });
63
+
64
+ test("agentTaskTypesOptedOutOfAuto ignores a task without the marker and one with a non-false value", () => {
65
+ const xml = `
66
+ <bpmn:serviceTask id="plain">
67
+ <bpmn:extensionElements>
68
+ <zeebe:taskDefinition type="senior:feature" />
69
+ <zeebe:agentDefinition agentType="external" />
70
+ </bpmn:extensionElements>
71
+ </bpmn:serviceTask>
72
+ <bpmn:serviceTask id="explicitTrue">
73
+ <bpmn:extensionElements>
74
+ <zeebe:taskDefinition type="senior:retro" />
75
+ <zeebe:properties>
76
+ <zeebe:property name="io.nanobpm.agentTask.autoSubscribe" value="true" />
77
+ </zeebe:properties>
78
+ </bpmn:extensionElements>
79
+ </bpmn:serviceTask>`;
80
+ assertEquals(agentTaskTypesOptedOutOfAuto(xml), []);
81
+ });
82
+
83
+ test("agentTaskTypesOptedOutOfAuto ignores an unrelated property named the same-ish", () => {
84
+ const xml = `
85
+ <bpmn:serviceTask id="agent">
86
+ <bpmn:extensionElements>
87
+ <zeebe:taskDefinition type="senior:feature" />
88
+ <zeebe:properties>
89
+ <zeebe:property name="io.nanobpm.agentTask.autoSubscribeMode" value="false" />
90
+ </zeebe:properties>
91
+ </bpmn:extensionElements>
92
+ </bpmn:serviceTask>`;
93
+ assertEquals(agentTaskTypesOptedOutOfAuto(xml), []);
94
+ });
95
+
96
+ test("agentTaskTypesOptedOutOfAuto ignores a bare opt-out property OUTSIDE the <zeebe:properties> wrapper (placement contract)", () => {
97
+ // The property carries the exact name/value, but it sits directly under <bpmn:extensionElements>
98
+ // rather than inside the <zeebe:properties> wrapper the engine honours — so the engine ignores it
99
+ // and it is NOT an active opt-out. Both the reader and the drift guard must treat it as absent.
100
+ const xml = `
101
+ <bpmn:serviceTask id="bare">
102
+ <bpmn:extensionElements>
103
+ <zeebe:taskDefinition type="senior:special" />
104
+ <zeebe:agentDefinition agentType="external" />
105
+ <zeebe:property name="io.nanobpm.agentTask.autoSubscribe" value="false" />
106
+ </bpmn:extensionElements>
107
+ </bpmn:serviceTask>`;
108
+ assertEquals(agentTaskTypesOptedOutOfAuto(xml), []);
109
+ assertEquals(agentTaskTypesOptedOutMissingExternalMarker(xml), []);
110
+ });
111
+
112
+ test("agentTaskTypesMissingExternalMarker flags a prompt-bearing task whose external marker sits OUTSIDE extensionElements (placement contract)", () => {
113
+ // The task is a real prompt-bearing agent task, but its <zeebe:agentDefinition> marker is a sibling
114
+ // of <serviceTask> rather than inside extensionElements, so the engine/harness ignores it — the
115
+ // task is effectively unmarked. A whole-block scan would see the marker "somewhere" and wrongly
116
+ // pass; the placement-scoped scan flags the drift.
117
+ const xml = `
118
+ <bpmn:serviceTask id="misplaced">
119
+ <zeebe:agentDefinition agentType="external" />
120
+ <bpmn:extensionElements>
121
+ <zeebe:taskDefinition type="senior:special" />
122
+ <zeebe:linkedResource resourceId="prompts/x.md" linkName="prompt" />
123
+ </bpmn:extensionElements>
124
+ </bpmn:serviceTask>`;
125
+ assertEquals(agentTaskTypesMissingExternalMarker(xml), ["senior:special"]);
126
+ });
127
+
128
+ test("agentTaskTypesOptedOutMissingExternalMarker flags an opt-out on a block lacking the external marker", () => {
129
+ // A host task (no external marker, no prompt link) that carries the opt-out is authoring drift:
130
+ // the block-level check catches it even though `agentTaskTypesMissingExternalMarker` (prompt-only)
131
+ // never reports it.
132
+ const xml = `
133
+ <bpmn:serviceTask id="host">
134
+ <bpmn:extensionElements>
135
+ <zeebe:taskDefinition type="pr.finalize" />
136
+ <zeebe:properties>
137
+ <zeebe:property name="io.nanobpm.agentTask.autoSubscribe" value="false" />
138
+ </zeebe:properties>
139
+ </bpmn:extensionElements>
140
+ </bpmn:serviceTask>`;
141
+ assertEquals(agentTaskTypesMissingExternalMarker(xml), []);
142
+ assertEquals(agentTaskTypesOptedOutMissingExternalMarker(xml), ["pr.finalize"]);
143
+ });
144
+
145
+ test("agentTaskTypesOptedOutMissingExternalMarker passes an opt-out on an externally-marked agent task", () => {
146
+ const xml = `
147
+ <bpmn:serviceTask id="agent">
148
+ <bpmn:extensionElements>
149
+ <zeebe:taskDefinition type="senior:special" />
150
+ <zeebe:agentDefinition agentType="external" />
151
+ <zeebe:properties>
152
+ <zeebe:property name="io.nanobpm.agentTask.autoSubscribe" value="false" />
153
+ </zeebe:properties>
154
+ </bpmn:extensionElements>
155
+ </bpmn:serviceTask>`;
156
+ assertEquals(agentTaskTypesOptedOutMissingExternalMarker(xml), []);
157
+ });
158
+
159
+ test("agentTaskTypesOptedOutMissingExternalMarker checks each block independently (a marked sibling does not cover a drifted opt-out)", () => {
160
+ // Two tasks share the `senior:special` type: one is a proper externally-marked agent task, the
161
+ // other opts out but lacks the marker. A deduplicated cross-task comparison would miss this; the
162
+ // per-block check flags the unmarked one.
163
+ const xml = `
164
+ <bpmn:serviceTask id="marked">
165
+ <bpmn:extensionElements>
166
+ <zeebe:taskDefinition type="senior:special" />
167
+ <zeebe:agentDefinition agentType="external" />
168
+ </bpmn:extensionElements>
169
+ </bpmn:serviceTask>
170
+ <bpmn:serviceTask id="drifted">
171
+ <bpmn:extensionElements>
172
+ <zeebe:taskDefinition type="senior:special" />
173
+ <zeebe:properties>
174
+ <zeebe:property name="io.nanobpm.agentTask.autoSubscribe" value="false" />
175
+ </zeebe:properties>
176
+ </bpmn:extensionElements>
177
+ </bpmn:serviceTask>`;
178
+ assertEquals(agentTaskTypesOptedOutMissingExternalMarker(xml), ["senior:special"]);
179
+ });
180
+
181
+ test("agentTaskTypesOptedOutMissingExternalMarker flags an opt-out whose external marker sits OUTSIDE extensionElements (placement contract)", () => {
182
+ // The opt-out is correctly placed inside extensionElements, but the external marker is out of place
183
+ // (a sibling of <serviceTask>, not inside extensionElements) so the engine ignores it — the block
184
+ // is therefore NOT a real marked agent task. A whole-block scan would see the marker "somewhere" and
185
+ // wrongly pass; the placement-scoped scan flags the drift.
186
+ const xml = `
187
+ <bpmn:serviceTask id="misplaced">
188
+ <zeebe:agentDefinition agentType="external" />
189
+ <bpmn:extensionElements>
190
+ <zeebe:taskDefinition type="senior:special" />
191
+ <zeebe:properties>
192
+ <zeebe:property name="io.nanobpm.agentTask.autoSubscribe" value="false" />
193
+ </zeebe:properties>
194
+ </bpmn:extensionElements>
195
+ </bpmn:serviceTask>`;
196
+ assertEquals(agentTaskTypesOptedOutMissingExternalMarker(xml), ["senior:special"]);
197
+ });
198
+
199
+ test("agentTaskTypesOptedOutMissingExternalMarker surfaces an opt-out on a block with a missing/empty taskDefinition type", () => {
200
+ // An opt-out on an unmarked block whose <zeebe:taskDefinition> type is empty cannot be a real agent
201
+ // task, so it is still drift — surfaced under the sentinel rather than silently skipped.
202
+ const xml = `
203
+ <bpmn:serviceTask id="typeless">
204
+ <bpmn:extensionElements>
205
+ <zeebe:taskDefinition type="" />
206
+ <zeebe:properties>
207
+ <zeebe:property name="io.nanobpm.agentTask.autoSubscribe" value="false" />
208
+ </zeebe:properties>
209
+ </bpmn:extensionElements>
210
+ </bpmn:serviceTask>`;
211
+ assertEquals(agentTaskTypesOptedOutMissingExternalMarker(xml), [MALFORMED_OPTOUT_LABEL]);
212
+ });
213
+
214
+ test("agentTaskTypesOptedOutMissingExternalMarker surfaces a MARKED opt-out block with a missing/empty taskDefinition type", () => {
215
+ // The block carries BOTH the external marker AND the opt-out, but its <zeebe:taskDefinition> type
216
+ // is empty — so it still cannot be a real agent task. The malformed check must run BEFORE the
217
+ // external-marker short-circuit, or the marker would wrongly let this typeless opt-out pass.
218
+ const xml = `
219
+ <bpmn:serviceTask id="markedTypeless">
220
+ <bpmn:extensionElements>
221
+ <zeebe:taskDefinition type="" />
222
+ <zeebe:agentDefinition agentType="external" />
223
+ <zeebe:properties>
224
+ <zeebe:property name="io.nanobpm.agentTask.autoSubscribe" value="false" />
225
+ </zeebe:properties>
226
+ </bpmn:extensionElements>
227
+ </bpmn:serviceTask>`;
228
+ assertEquals(agentTaskTypesOptedOutMissingExternalMarker(xml), [MALFORMED_OPTOUT_LABEL]);
229
+ });
230
+
231
+ test("GUARD: the deploy-scan walks from the resources/ convention root (not just resources/processes)", () => {
232
+ // Regression for the deploy-by-convention coverage gap: urban deploys `resources/` recursively, so
233
+ // the guard must root its BPMN walk at `resources/` — a `.bpmn` added under any other resources
234
+ // subdirectory must still be scanned. Assert the walk actually reaches the process models AND that
235
+ // every returned path is relative to the resources root (carries its subdirectory segment), so a
236
+ // future refactor that narrows the root back to `resources/processes` is caught here.
237
+ const files = bpmnFiles();
238
+ assert(files.length > 0, "expected the resources/ walk to discover deployed BPMN models");
239
+ assert(
240
+ files.some((f) => f.startsWith("processes/")),
241
+ `expected resources-root-relative paths (e.g. "processes/…"); got ${JSON.stringify(files.slice(0, 3))}`,
242
+ );
243
+ });
244
+
245
+ test("GUARD: every deployed opted-out task is itself an externally-marked agent task", () => {
246
+ for (const file of bpmnFiles()) {
247
+ const xml = readFileSync(join(RESOURCES_DIR, file), "utf8");
248
+ // Drive the guard DIRECTLY from the block-level, placement-scoped helper rather than gating on
249
+ // `agentTaskTypesOptedOutOfAuto` (which skips missing/empty task-definition types, so a typeless
250
+ // opt-out would never reach the check). The helper scans every service task itself, surfaces a
251
+ // malformed typeless opt-out under the sentinel, and checks the external marker on the SAME
252
+ // block's extensionElements — so a typeless opt-out, an out-of-place property, or a marker that
253
+ // drifted onto a non-agent element all fail CI here.
254
+ const drifted = agentTaskTypesOptedOutMissingExternalMarker(xml);
255
+ assertEquals(
256
+ drifted,
257
+ [],
258
+ `${file}: task(s) ${JSON.stringify(drifted)} opt out of --auto but lack <zeebe:agentDefinition agentType="external" /> on the same block — an opt-out belongs only on a real agent task`,
259
+ );
260
+ }
261
+ });
@@ -48,11 +48,53 @@ export function jobTypeToRoutingToken(jobType: string): string | undefined {
48
48
 
49
49
  const SERVICE_TASK = /<(?:\w+:)?serviceTask\b[\s\S]*?<\/(?:\w+:)?serviceTask>/g;
50
50
  const TASK_DEFINITION_TYPE = /<(?:\w+:)?taskDefinition\b[^>]*\btype="([^"]*)"/;
51
+ const EXTENSION_ELEMENTS = /<(?:\w+:)?extensionElements\b[\s\S]*?<\/(?:\w+:)?extensionElements>/;
52
+ // The engine only honours a `<zeebe:property>` nested inside a `<zeebe:properties>` wrapper (itself
53
+ // inside `<bpmn:extensionElements>`). A bare `<zeebe:property>` placed directly under
54
+ // `extensionElements` (or `serviceTask`) is ignored, so property-contract scans (the `--auto`
55
+ // opt-out) run against THIS wrapper — a misplaced bare property is treated as absent, exactly as the
56
+ // engine treats it.
57
+ const ZEEBE_PROPERTIES = /<(?:\w+:)?properties\b[\s\S]*?<\/(?:\w+:)?properties>/;
51
58
  const PROMPT_LINK = /<(?:\w+:)?linkedResource\b[^>]*\blinkName="prompt"/;
52
59
  // The engine-native AgentTask marker (issue #745): a `<zeebe:agentDefinition agentType="external" />`
53
60
  // sibling of the `<zeebe:taskDefinition>` inside a `senior:*` agent task's extensionElements. It is
54
61
  // what makes the element eligible for engine-native AgentInstance minting by the worker harness.
55
62
  const EXTERNAL_AGENT_MARKER = /<(?:\w+:)?agentDefinition\b[^>]*\bagentType="external"/;
63
+ // The `--auto` opt-OUT marker (issue #779): a `<zeebe:property name="io.nanobpm.agentTask.
64
+ // autoSubscribe" value="false" />` sibling inside an agent task's extensionElements. It declares the
65
+ // task is EXCLUDED from the harness `--auto` reconciliation (which keys on EXTERNAL_AGENT_MARKER) and
66
+ // is served only by a worker that explicitly subscribes. The property is inert to the engine. Only
67
+ // the exact `value="false"` opts out — any other value auto-subscribes as normal (fail-safe).
68
+ const AUTO_SUBSCRIBE_OPTOUT =
69
+ /<(?:\w+:)?property\b[^>]*\bname="io\.nanobpm\.agentTask\.autoSubscribe"[^>]*\bvalue="false"|<(?:\w+:)?property\b[^>]*\bvalue="false"[^>]*\bname="io\.nanobpm\.agentTask\.autoSubscribe"/;
70
+
71
+ // The label surfaced for an opted-out, unmarked service task whose `<zeebe:taskDefinition>` is
72
+ // missing or has an empty `type` (issue #779 drift guard). Such a block cannot be a real
73
+ // externally-marked agent task, so the opt-out is drift regardless of the absent type — we surface it
74
+ // under a descriptive sentinel rather than letting the dedupe skip swallow it.
75
+ export const MALFORMED_OPTOUT_LABEL = "(opted-out task with missing/empty taskDefinition type)";
76
+
77
+ /**
78
+ * The `<bpmn:extensionElements>…</bpmn:extensionElements>` content of a service-task block, or the
79
+ * empty string when the block has none. Placement-contract scans (issue #745/#779: a marker is only
80
+ * honoured by the engine INSIDE `extensionElements`) run against THIS scope, so a property/marker
81
+ * sitting outside `extensionElements` is treated as absent — the engine ignores it, and so must the
82
+ * guard (an out-of-place external marker cannot "cover" an opt-out).
83
+ */
84
+ function extensionElementsOf(block: string): string {
85
+ return block.match(EXTENSION_ELEMENTS)?.[0] ?? "";
86
+ }
87
+
88
+ /**
89
+ * The `<zeebe:properties>…</zeebe:properties>` content nested inside a block's `extensionElements`,
90
+ * or the empty string when absent. The engine only honours `<zeebe:property>` entries INSIDE this
91
+ * wrapper, so the `--auto` opt-out property scan runs against THIS scope — a bare `<zeebe:property>`
92
+ * sitting directly under `extensionElements` (or `serviceTask`) is treated as absent, exactly as the
93
+ * engine treats it, matching the registered/documented shape (`<zeebe:properties>`-nested).
94
+ */
95
+ function optOutPropertiesOf(block: string): string {
96
+ return extensionElementsOf(block).match(ZEEBE_PROPERTIES)?.[0] ?? "";
97
+ }
56
98
 
57
99
  /**
58
100
  * Scan one BPMN document for the job types of its PROMPT-BEARING service tasks — the deployed fleet
@@ -78,15 +120,18 @@ export function promptBearingTaskTypes(xml: string): string[] {
78
120
  * engine-native AgentTask marker `<zeebe:agentDefinition agentType="external" />` (issue #745). Every
79
121
  * deployed `senior:*` agent task must carry the marker so the worker harness mints an AgentInstance
80
122
  * for it; a newly-added agent task that forgets it is a silent drift surface (its run never persists
81
- * durable AgentHistory), so the regression guard fails CI. Returns the offending task types in
82
- * first-occurrence order (empty when every agent task is marked).
123
+ * durable AgentHistory), so the regression guard fails CI. The marker check is scoped to the block's
124
+ * `<bpmn:extensionElements>` (the engine-honoured PLACEMENT scope, via `extensionElementsOf`) — a
125
+ * marker sitting outside `extensionElements` is ignored by the engine, so it must not "cover" a task
126
+ * here either. Returns the offending task types in first-occurrence order (empty when every agent
127
+ * task is marked).
83
128
  */
84
129
  export function agentTaskTypesMissingExternalMarker(xml: string): string[] {
85
130
  const seen = new Set<string>();
86
131
  const missing: string[] = [];
87
132
  for (const [block] of xml.matchAll(SERVICE_TASK)) {
88
133
  if (!PROMPT_LINK.test(block)) continue;
89
- if (EXTERNAL_AGENT_MARKER.test(block)) continue;
134
+ if (EXTERNAL_AGENT_MARKER.test(extensionElementsOf(block))) continue;
90
135
  const type = block.match(TASK_DEFINITION_TYPE)?.[1];
91
136
  if (type === undefined || type.length === 0 || seen.has(type)) continue;
92
137
  seen.add(type);
@@ -94,3 +139,78 @@ export function agentTaskTypesMissingExternalMarker(xml: string): string[] {
94
139
  }
95
140
  return missing;
96
141
  }
142
+
143
+ /**
144
+ * Scan one BPMN document for the job types of agent service tasks that OPT OUT of the harness
145
+ * `--auto` reconciliation (issue #779) — those carrying `<zeebe:property
146
+ * name="io.nanobpm.agentTask.autoSubscribe" value="false" />` inside their extensionElements. An
147
+ * opted-out task is served ONLY by a worker that explicitly subscribes (`--job-type <type>` / a
148
+ * profile capability), never by `--auto` auto-discovery (which keys on the
149
+ * `<zeebe:agentDefinition agentType="external" />` marker). The property is inert to the engine;
150
+ * only the exact `value="false"` opts out (any other value auto-subscribes, fail-safe). The opt-out
151
+ * scan is scoped to the block's `<zeebe:properties>` wrapper inside `<bpmn:extensionElements>` (the
152
+ * engine-honoured PLACEMENT scope, via `optOutPropertiesOf`) — a bare `<zeebe:property>` sitting
153
+ * directly under `<bpmn:extensionElements>` or `<bpmn:serviceTask>` is ignored by the engine, so it
154
+ * must not be reported as an active opt-out here (this keeps the reader consistent with the
155
+ * placement-scoped drift guard `agentTaskTypesOptedOutMissingExternalMarker`). Returns the
156
+ * distinct opted-out task types in first-occurrence order (empty when no task opts out).
157
+ */
158
+ export function agentTaskTypesOptedOutOfAuto(xml: string): string[] {
159
+ const seen = new Set<string>();
160
+ const optedOut: string[] = [];
161
+ for (const [block] of xml.matchAll(SERVICE_TASK)) {
162
+ if (!AUTO_SUBSCRIBE_OPTOUT.test(optOutPropertiesOf(block))) continue;
163
+ const type = block.match(TASK_DEFINITION_TYPE)?.[1];
164
+ if (type === undefined || type.length === 0 || seen.has(type)) continue;
165
+ seen.add(type);
166
+ optedOut.push(type);
167
+ }
168
+ return optedOut;
169
+ }
170
+
171
+ /**
172
+ * Scan one BPMN document for the job types of service tasks that OPT OUT of `--auto` (issue #779) yet
173
+ * are NOT themselves externally-marked agent tasks — i.e. an opt-out property on a block WITHOUT a
174
+ * sibling `<zeebe:agentDefinition agentType="external" />`. An opt-out only makes sense on a real
175
+ * agent task (one that WOULD otherwise be auto-discovered via its external marker); a marker that has
176
+ * drifted onto a host task (e.g. `pr.finalize`, which carries no external marker) or onto one task
177
+ * that merely shares a `taskDefinition` type with a properly-marked sibling is authoring drift. This
178
+ * checks both markers on the SAME service-task block — the opt-out property inside the block's
179
+ * `<zeebe:properties>` wrapper (via `optOutPropertiesOf`, so a bare misplaced `<zeebe:property>` is
180
+ * ignored just as the engine ignores it) and the external marker inside that block's
181
+ * `<bpmn:extensionElements>` (the engine-honoured PLACEMENT scope), so an out-of-place external
182
+ * marker sitting outside `extensionElements` cannot spuriously "cover" the opt-out — so, unlike
183
+ * comparing the deduplicated `agentTaskTypesOptedOutOfAuto` / `agentTaskTypesMissingExternalMarker`
184
+ * lists (the latter only reports PROMPT-BEARING tasks, so a non-prompt host task's opt-out is
185
+ * invisible to it), the drift cannot hide. An opt-out on a block with a missing/empty
186
+ * `<zeebe:taskDefinition>` type (which likewise cannot be a real agent task) is surfaced under a
187
+ * descriptive sentinel BEFORE the external-marker short-circuit — so even a typeless block that
188
+ * carries the external marker is still flagged as malformed drift. Returns the offending task types
189
+ * in first-occurrence order (empty when every opted-out task is a properly-typed, externally-marked
190
+ * agent task).
191
+ */
192
+ export function agentTaskTypesOptedOutMissingExternalMarker(xml: string): string[] {
193
+ const seen = new Set<string>();
194
+ const offending: string[] = [];
195
+ for (const [block] of xml.matchAll(SERVICE_TASK)) {
196
+ const ext = extensionElementsOf(block);
197
+ if (!AUTO_SUBSCRIBE_OPTOUT.test(optOutPropertiesOf(block))) continue;
198
+ const type = block.match(TASK_DEFINITION_TYPE)?.[1];
199
+ // A missing/empty type cannot be a real agent task, so an opt-out here is malformed drift
200
+ // REGARDLESS of any external marker — surface it under the sentinel BEFORE the marker
201
+ // short-circuit, so a marked-yet-typeless opt-out is caught rather than passed by the marker.
202
+ if (type === undefined || type.length === 0) {
203
+ if (seen.has(MALFORMED_OPTOUT_LABEL)) continue;
204
+ seen.add(MALFORMED_OPTOUT_LABEL);
205
+ offending.push(MALFORMED_OPTOUT_LABEL);
206
+ continue;
207
+ }
208
+ // A properly-typed opt-out is fine only when the SAME block is externally marked inside its
209
+ // extensionElements (the engine-honoured placement scope).
210
+ if (EXTERNAL_AGENT_MARKER.test(ext)) continue;
211
+ if (seen.has(type)) continue;
212
+ seen.add(type);
213
+ offending.push(type);
214
+ }
215
+ return offending;
216
+ }
package/app/contracts.ts CHANGED
@@ -399,9 +399,9 @@ export const WIRE_CONTRACTS = {
399
399
  name: "io.nanobpm.agentTask.repository",
400
400
  owner: "app/repoEnvelope.ts",
401
401
  semantics:
402
- "Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars`, app/repoEnvelope.ts) and the c8ctl worker harness consumes to provision an isolated clone — instead of the agent inheriting the worker's launch dir (issue #684). `ref` is the branch checked out: the PR HEAD branch on the PR-based paths (review-round / fix-ci / rebase), or — on the PRE-PR implementation path (feature.bpmn / plan-fanout's `implement-cell`, issue #684; the delivery-graph runner's agent cells, issue #686) — the BASE branch, off which the harness cuts a new feature branch named by the optional `branch.create` (the deterministic `feat/<task.id>`, emitted only for a single-task feature run; the epic seed AND the delivery-graph run-root seed omit it so each fan-out slice's agent branches per node/MI child). Beyond `{provider,url,ref}`, it carries clone-shaping fields for large monorepos (issue #287): `singleBranch:true` + `filter:\"blob:none\"` (a branch-scoped, blobless partial clone — trees fetched up-front, blobs lazily, no `--depth 1` so the merge-base/3-dot diff stays valid) and an optional `baseRef` (the PR base branch, emitted only when resolvable, so the harness fetches its tip and keeps `origin/<base>` reachable) and a `cloneTimeoutMs` (from `NANO_PR_CLONE_TIMEOUT_MS`, default 600000 = 10 min) that raises the harness's 120s default so a large monorepo's blobless single-branch clone provisions instead of dying at 120s (issue #694). World-restore (issue #324, ADR 0062 Slice 4/5): an optional `sha` — the last durable push-checkpoint — is emitted so a REPLACEMENT activation on a fresh worktree reconstructs the tree to the EXACT pushed SHA (inverting the round's `git push` into `git fetch && git checkout <sha>`), omitted when the PR has no checkpoint yet. The field is named `sha` because that is the field the c8ctl harness's `provisionRepo` reads to drive the checkout — an earlier `commitSha` key was a silent no-op (issue #695). Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).",
402
+ "Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars`, app/repoEnvelope.ts) and the c8ctl worker harness consumes to provision an isolated clone — instead of the agent inheriting the worker's launch dir (issue #684). `ref` is the branch checked out: the PR HEAD branch on the PR-based paths (review-round / fix-ci / rebase), or — on the PRE-PR implementation path (feature.bpmn / plan-fanout's `implement-cell`, issue #684; the delivery-graph runner's agent cells, issue #686) — the BASE branch, off which the harness cuts a new feature branch named by the optional `branch.create` (the deterministic `feat/<task.id>`, emitted only for a single-task feature run; the epic seed AND the delivery-graph run-root seed omit it so each fan-out slice's agent branches per node/MI child). Beyond `{provider,url,ref}`, it carries clone-shaping fields for large monorepos (issue #287): `singleBranch:true` + `filter:\"blob:none\"` (a branch-scoped, blobless partial clone — trees fetched up-front, blobs lazily, no `--depth 1` so the merge-base/3-dot diff stays valid) and an optional `baseRef` (the PR base branch, emitted only when resolvable, so the harness fetches its tip and keeps `origin/<base>` reachable) and a `cloneTimeoutMs` (from `NANO_PR_CLONE_TIMEOUT_MS`, default 600000 = 10 min) that raises the harness's 120s default so a large monorepo's blobless single-branch clone provisions instead of dying at 120s (issue #694). World-restore (issue #324, ADR 0062 Slice 4/5): an optional `sha` — the last durable push-checkpoint — is emitted so a REPLACEMENT activation on a fresh worktree reconstructs the tree to the EXACT pushed SHA (inverting the round's `git push` into `git fetch && git checkout <sha>`), omitted when the PR has no checkpoint yet. The field is named `sha` because that is the field the c8ctl harness's `provisionRepo` reads to drive the checkout — an earlier `commitSha` key was a silent no-op (issue #695). Alongside the `repository` slice the envelope carries a sibling `task.allowPr: true` (issue #770): c8ctl-plugin-nano (≥1.60.2) only resolves the git credential (GITHUB_TOKEN, or the `gh` default) for repo provisioning behind that flag, so every repo-backed envelope sets it or the clone dies with `unable to get password from user`; the repoless path emits no envelope and so no `task`. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).",
403
403
  shape:
404
- '{ provider: "github", url: string, ref: string, singleBranch: true, filter: "blob:none", cloneTimeoutMs: number, baseRef?: string, sha?: string, branch?: { create: string } }',
404
+ 'io.nanobpm.agentTask: { repository: { provider: "github", url: string, ref?: string, singleBranch: true, filter: "blob:none", cloneTimeoutMs: number, baseRef?: string, sha?: string, branch?: { create: string } }, task: { allowPr: true } }',
405
405
  },
406
406
  "epicSet.submit": {
407
407
  category: "wire",
@@ -494,6 +494,14 @@ export const WIRE_CONTRACTS = {
494
494
  "The engine-native AgentTask marker (issue #745, umbrella #746 — Camunda 8.10 parity). Every `senior:*` agent service task carries `<zeebe:agentDefinition agentType=\"external\" />` INSIDE its `<bpmn:extensionElements>`, COEXISTING with the existing `<zeebe:taskDefinition type=\"senior:*\"/>` dispatch verb (the verb stays, per #464). The marker makes the element eligible for engine-native AgentInstance minting by the worker harness (jwulf/c8ctl-plugin-nano#194): the harness mints Create/Update/Complete AgentInstance/AgentHistory records against the pinned engine (`@nanobpm/engine-wasm` 0.8.6, broker REST, SDK) while the element still emits its NORMAL `senior:*` job. `agentType=\"external\"` means the agent runs OUTSIDE the engine (a remote fleet worker), not an engine-embedded model call. This is the PRODUCER half; the durable AgentInstance/AgentHistory it mints is read back by the Cockpit historical view via `searchAgentInstanceHistory` (the CONSUMER half — see the `agentTask.historyRead` contract; the read path landed on `@nanobpm/urban`'s EngineClient in urban 0.93 / nanobpm/nano-ide#563). It is authored in the hand-written BPMN semantic model, NOT the generated `<bpmndi:…>` DI, and survives `npm run layout` untouched. Add the marker to a NEW `senior:*` agent task — never a second/synonym marker element.",
495
495
  shape: '<zeebe:agentDefinition agentType="external" /> (sibling of <zeebe:taskDefinition> in a senior:* service task\'s extensionElements)',
496
496
  },
497
+ "agentTask.autoSubscribe": {
498
+ category: "wire",
499
+ name: "agentTask.autoSubscribe",
500
+ owner: "resources/processes/*.bpmn",
501
+ semantics:
502
+ "The `--auto` opt-OUT marker (issue #779, harness jwulf/c8ctl-plugin-nano#235). The ONE agentic-task signal the harness `--auto` reconciliation scans is `<zeebe:agentDefinition agentType=\"external\" />` (the `agentTask.agentDefinition` marker) — it replaces the legacy `linkName=\"prompt\"` / header dual signal so both sides converge on a single convention. This marker is the escape hatch: a `<zeebe:property name=\"io.nanobpm.agentTask.autoSubscribe\" value=\"false\" />` INSIDE an agent task's `<bpmn:extensionElements>` declares the task is EXCLUDED from `--auto` auto-discovery and is served ONLY by a worker that explicitly subscribes (`--job-type <type>` / a profile capability). Absence of the marker (or any value other than the literal string `\"false\"`) means the task auto-subscribes as normal — opt-out is explicit and fail-safe. The `zeebe:property` is INERT to the engine (no runtime/behaviour change, no migration). Authored in the hand-written BPMN semantic model, NOT the generated `<bpmndi:…>` DI, and survives `npm run layout` untouched. The scan helper `agentTaskTypesOptedOutOfAuto(xml)` in app/agentic/vocab/job-types.ts is the ONE reader of this marker (mirrors `agentTaskTypesMissingExternalMarker`); a CI guard asserts its shape/placement. Use THIS one marker to opt a task out — never a second/synonym opt-out property.",
503
+ shape: '<zeebe:properties><zeebe:property name="io.nanobpm.agentTask.autoSubscribe" value="false" /></zeebe:properties> (the <zeebe:property> nested in a <zeebe:properties> wrapper inside a senior:* agent service task\'s <bpmn:extensionElements> — a bare <zeebe:property> directly under extensionElements is NOT the accepted shape; see SPEC.md)',
504
+ },
497
505
  "agentTask.historyRead": {
498
506
  category: "wire",
499
507
  name: "agentTask.historyRead",
@@ -485,6 +485,9 @@ test("prepareDeliveryGraph injects the repository envelope PER agent cell from t
485
485
  assert(headers.some((h) => h.includes('repository.provider" value="github"')), "provider header");
486
486
  assert(headers.some((h) => h.includes('repository.singleBranch" value="true"')), "branch-scoped blobless clone (#287)");
487
487
  assert(headers.some((h) => h.includes('repository.filter" value="blob:none"')), "blobless filter");
488
+ // Repo-provisioning auth gate (issue #770): the per-cell envelope carries `task.allowPr:true` so the
489
+ // c8ctl harness resolves the git credential for the clone instead of failing on password prompt.
490
+ assert(headers.some((h) => h.includes('task.allowPr" value="true"')), `expected a task.allowPr header, got ${JSON.stringify(headers)}`);
488
491
  // No `__repoSpec` marker survives injection — it is the compiler's digest-stable anchor only.
489
492
  assert(!p.bpmn.includes("__repoSpec"), "the __repoSpec marker is fully replaced");
490
493
  // No run-root `io.nanobpm.agentTask` variable — the envelope rides headers now, not a run variable.
@@ -191,6 +191,14 @@ export function repoEnvelopeVars(
191
191
  ? { branch: { create: branchCreate.trim() } }
192
192
  : {}),
193
193
  },
194
+ // Repo-provisioning auth gate (issue #770): c8ctl-plugin-nano (≥1.60.2) only resolves the
195
+ // git credential (GITHUB_TOKEN, or the `gh` default when it's absent) for repository
196
+ // provisioning when `task.allowPr` is truthy — `resolveJobSecrets` pulls the token behind that
197
+ // flag. Without it, a repo-backed clone dies with `unable to get password from user` before the
198
+ // agent even starts. So EVERY repo-backed envelope (this builder only runs past the `owner/repo`
199
+ // guard above) opts the harness into auth-resolved provisioning. The repoless path emits `{}`
200
+ // (no envelope, no `task`), so its behaviour — and its auth posture — is unchanged.
201
+ task: { allowPr: true },
194
202
  },
195
203
  };
196
204
  }
@@ -222,6 +230,11 @@ export function agentNodeRepoEnvelope(repo: string, base: string | null): Record
222
230
  // `feat/<node.id>` off. Omitted when unknown so the harness clones the repo's default branch.
223
231
  ...(ref ? { ref, baseRef: ref } : {}),
224
232
  },
233
+ // Repo-provisioning auth gate (issue #770): as in `repoEnvelopeVars`, a repo-backed cell must
234
+ // opt the harness into git-credential resolution (`task.allowPr`) or the clone fails with
235
+ // `unable to get password from user`. Emitted on every resolved cell (past the `owner/repo`
236
+ // guard above); an unresolved/`repoless` cell returns `{}` and carries no `task`.
237
+ task: { allowPr: true },
225
238
  },
226
239
  };
227
240
  }
@@ -487,6 +487,24 @@ test("repoEnvelopeVars emits the repository envelope keyed on the PR head branch
487
487
  assertEquals(env.repository.cloneTimeoutMs, 600000);
488
488
  });
489
489
 
490
+ // Repo-provisioning auth gate (issue #770): c8ctl-plugin-nano (≥1.60.2) only resolves the git
491
+ // credential (GITHUB_TOKEN, or the `gh` default when it's absent) for repository provisioning when
492
+ // `task.allowPr` is truthy. Without it a repo-backed clone dies with `unable to get password from
493
+ // user` before the agent starts. Every repo-backed envelope must therefore carry `task.allowPr:
494
+ // true`; the repoless (unresolved) path emits nothing, so it carries no `task` and is unchanged.
495
+ test("repoEnvelopeVars emits task.allowPr:true on every repo-backed envelope (#770)", () => {
496
+ // PR-based path (review-round / fix-ci / rebase).
497
+ const pr = (repoEnvelopeVars("owner/repo", "feat/issue-12", "main") as any)["io.nanobpm.agentTask"];
498
+ assertEquals(pr.task.allowPr, true, "the PR-based envelope opts the harness into auth-resolved provisioning");
499
+ // Pre-PR implementation path (feature.bpmn / plan-fanout's implement-cell, #684).
500
+ const prePr = (repoEnvelopeVars("owner/repo", "main", null, null, "feat/issue-7") as any)["io.nanobpm.agentTask"];
501
+ assertEquals(prePr.task.allowPr, true, "the pre-PR envelope opts in too");
502
+ // A repoless / unresolved input emits NOTHING — no envelope, and so no `task` (auth posture
503
+ // unchanged: the launch-dir fallback needs no repo credential).
504
+ assertEquals(Object.keys(repoEnvelopeVars("owner/repo", null)).length, 0, "unresolved head → no envelope, no task");
505
+ assertEquals(Object.keys(repoEnvelopeVars("not-owner-repo", "feat/x")).length, 0, "malformed repo → no envelope, no task");
506
+ });
507
+
490
508
  test("repoEnvelopeVars emits cloneTimeoutMs from NANO_PR_CLONE_TIMEOUT_MS, default 600000 (#694)", () => {
491
509
  // Default (knob unset): 600000ms = 10 min, raising the harness's 120000ms default so a
492
510
  // branch-scoped blobless clone of a large monorepo provisions instead of dying at 120s.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.186.3",
3
+ "version": "0.187.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",