@nanobpm/nano-workforce 0.201.2 → 0.201.4

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.
@@ -58,6 +58,17 @@ jobs:
58
58
  - name: Check agent prompt templates
59
59
  run: npm run check:prompts
60
60
 
61
+ # Agent-definition gate (issue #884): every `senior:*` agent service task the app deploys —
62
+ # hand-authored in resources/processes/*.bpmn OR produced by the delivery-graph compiler — must
63
+ # carry `<zeebe:agentDefinition agentType="external"/>`, or the engine refuses to mint an
64
+ # AgentInstance for it and the run persists no durable agentic transcript (the 269 failed
65
+ # creates on omarchy). Compiled agent cells are deliberately prompt-LESS (the resolved #884
66
+ # "Decide", Option (a)): the compiler emits the marker on EVERY agent cell unconditionally, and
67
+ # the gate keys agent-ness on the `<rank>:<task>` colon-form type (the type-keyed scanner), so a
68
+ # compiled cell that drops the marker fails here, not silently at runtime.
69
+ - name: Check agent task definitions (external marker)
70
+ run: npm run check:agent-definitions
71
+
61
72
  # Merge-safety gate: migrations are applied in numeric-prefix order, but each fan-out branch
62
73
  # picks "the next" prefix independently, so parallel siblings silently collide on one slot
63
74
  # (the historical 004/005/006/007 pairs; recurred on epic #142). This fails the build on any
package/AGENTS.md CHANGED
@@ -393,13 +393,15 @@ npm run typecheck # tsc --noEmit (Node)
393
393
  npm run check # urban check (manifest validation)
394
394
  npm run layout:check # BPMN diagram freshness (no drift)
395
395
  npm run check:prompts # agent-prompt linkedResource resolution
396
+ npm run check:agent-definitions # every senior:* agent task (authored corpus + compiled delivery-graph output) carries the agentDefinition marker
396
397
  npm run check:migrations # migration prefixes + immutability (no rename/delete/edit of a merged migration)
397
398
  npm run check:contracts # contract registry (no synonyms / undeclared env keys)
398
399
  npm test # unit tests (node --test)
399
400
  ```
400
401
 
401
402
  CI (`.github/workflows/ci.yml`) gates lint, typecheck, `urban check`, `layout:check`,
402
- the prompt check, the migration-prefix check, the contract-registry check, and the Node test suite. Run `npm run layout <file.bpmn>` after
403
+ the prompt check, the agent-definition check, the migration-prefix check, the
404
+ contract-registry check, and the Node test suite. Run `npm run layout <file.bpmn>` after
403
405
  any BPMN flow change and commit the regenerated diagram — the `layout:check`
404
406
  gate fails the build otherwise.
405
407
 
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.201.4](https://github.com/nanobpm/nano-workforce/compare/v0.201.3...v0.201.4) (2026-10-11)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **delivery-graph:** emit zeebe:agentDefinition marker on every compiled agent cell (prompt-less, Option (a)) ([#886](https://github.com/nanobpm/nano-workforce/issues/886)) ([1e4fa4f](https://github.com/nanobpm/nano-workforce/commit/1e4fa4f46aad542b2e6fc175a3af0b46d0980f50)), closes [#464](https://github.com/nanobpm/nano-workforce/issues/464) [#745](https://github.com/nanobpm/nano-workforce/issues/745) [#884](https://github.com/nanobpm/nano-workforce/issues/884) [#884](https://github.com/nanobpm/nano-workforce/issues/884) [#776](https://github.com/nanobpm/nano-workforce/issues/776) [#884](https://github.com/nanobpm/nano-workforce/issues/884) [#884](https://github.com/nanobpm/nano-workforce/issues/884) [#884](https://github.com/nanobpm/nano-workforce/issues/884)
6
+
7
+ ## [0.201.3](https://github.com/nanobpm/nano-workforce/compare/v0.201.2...v0.201.3) (2026-10-10)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **repo-envelope:** emit branch.create as a top-level sibling of repository ([#885](https://github.com/nanobpm/nano-workforce/issues/885)) ([abb7b1c](https://github.com/nanobpm/nano-workforce/commit/abb7b1cc8439229724668fa85ffac22afd95c470)), closes [nanobpm/nano-bpm#1344](https://github.com/nanobpm/nano-bpm/issues/1344) [#227](https://github.com/nanobpm/nano-workforce/issues/227) [#882](https://github.com/nanobpm/nano-workforce/issues/882)
12
+
1
13
  ## [0.201.2](https://github.com/nanobpm/nano-workforce/compare/v0.201.1...v0.201.2) (2026-10-08)
2
14
 
3
15
  ### Bug Fixes
package/SPEC.md CHANGED
@@ -646,11 +646,25 @@ the app, which deploys on boot), and the next agent job of that type picks it up
646
646
  > marker is CI-enforced over the deployed `resources/` process models
647
647
  > (`agentTaskTypesMissingExternalMarker`,
648
648
  > `agent-marker.test.ts`), so no prompt-bearing agent task in those models relies on
649
- > prompt-link-only discovery. (The delivery-graph compiler's GENERATED agent BPMN —
650
- > deployed at run time by `runDeliveryGraph`, not authored under `resources/` — is a
651
- > separate deployed path NOT covered by this static guard; whether its generated cells
652
- > should also carry the marker/opt-out convention is tracked separately under issue #745,
653
- > not #779.) To **exclude** a task from `--auto` — one that must be served only by a
649
+ > prompt-link-only discovery. The delivery-graph compiler's GENERATED agent BPMN —
650
+ > deployed at run time by `runDeliveryGraph`, not authored under `resources/` — is the
651
+ > second producer of agent service tasks, and it is covered too (issue #884): the
652
+ > compiler emits the marker on **every** `agent` cell it compiles. Per the resolved
653
+ > "Decide" half of #884 (Option (a), PR #886 review) a compiled cell carries **no**
654
+ > base-prompt link — the hand-authored process prompts (`prompts/feature.md`, …) are
655
+ > process-specific contracts that require process variables (`variables.task`/
656
+ > `variables.issue`) and a `feat/<task.id>` branch the generic compiled cell never
657
+ > supplies (it seeds only `{jobType, appendPrompt, timeout}` and the harness provisions
658
+ > `feat/<node.id>`, #776), so linking one would hand the cell instructions referencing
659
+ > missing inputs and a conflicting branch. The cell is deliberately prompt-less, steered
660
+ > by its per-node `appendPrompt` alone; the marker alone fixes the #884
661
+ > `createAgentInstance` failure. `npm run check:agent-definitions`
662
+ > (CI gate `check:agent-definitions`, `scripts/check-agent-definitions.ts`) compiles a
663
+ > representative graph — including a prompt-less probe cell — and scans the
664
+ > output with both the prompt-keyed scanner and the prompt-link-independent
665
+ > `externalAgentTaskTypesMissingMarker` (a task is an agent by its `<rank>:<task>`
666
+ > colon-form type alone), so a compiler regression that drops the marker fails the
667
+ > build rather than silently at runtime. To **exclude** a task from `--auto` — one that must be served only by a
654
668
  > worker that explicitly subscribes (`--job-type <type>` / a profile capability) — add
655
669
  > the inert opt-out property inside its `extensionElements`, nested in the
656
670
  > `<zeebe:properties>` wrapper the models and engine expect (as
@@ -9,7 +9,8 @@ import { test } from "node:test";
9
9
  import { dirname, join, relative } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
11
  import { assert, assertEquals } from "#test-assert";
12
- import { agentTaskTypesMissingExternalMarker, promptBearingTaskTypes } from "./job-types.ts";
12
+ import { compileDeliveryGraph } from "../../deliveryGraphCompiler.ts";
13
+ import { agentTaskTypesMissingExternalMarker, missingExternalMarkerAgentTaskTypes, promptBearingTaskTypes } from "./job-types.ts";
13
14
 
14
15
  const PROCESSES_DIR = join(dirname(fileURLToPath(import.meta.url)), "../../../resources/processes");
15
16
 
@@ -72,3 +73,31 @@ test("DEFECT-CLASS GUARD: every deployed prompt-bearing agent task carries the e
72
73
  // Sanity: the models really do declare agent tasks (guard is not vacuously green).
73
74
  assert(anyAgentTasks, "the deployed models declare prompt-bearing agent tasks");
74
75
  });
76
+
77
+ test("DEFECT-CLASS GUARD (compiler output, issue #884): every COMPILED delivery-graph agent cell carries the marker", async () => {
78
+ // The hand-authored corpus above is only HALF the class — the delivery-graph COMPILER
79
+ // (`app/deliveryGraphCompiler.ts`) is the other producer of agent service tasks, and it emitted
80
+ // cells with NO `agentDefinition` until #884: every delivery-graph agent run failed AgentInstance
81
+ // creation (`… has no agentDefinition`) and persisted no durable transcript (269 failed creates on
82
+ // omarchy). Per the resolved "Decide" half of #884 (Option (a), PR #886 review) a compiled cell is
83
+ // prompt-LESS (no base-prompt link), so the prompt-keyed scanner cannot see it — it is the
84
+ // type-keyed half of the union scanner (`missingExternalMarkerAgentTaskTypes`, keyed by the
85
+ // `<rank>:<task>` colon-form type) that sees these cells. Apply the SAME union to the compiler's
86
+ // OUTPUT so a compiler regression that drops the marker fails here — not silently at runtime.
87
+ const r = await compileDeliveryGraph({
88
+ nodes: [
89
+ { id: "impl", kind: "agent", agent: { jobType: "senior:feature", prompt: "implement #1" }, emits: [{ name: "pr", type: "pr" }] },
90
+ { id: "review", kind: "agent", agent: { jobType: "senior:pr-review" } },
91
+ ],
92
+ edges: [{ from: "impl", to: "review" }],
93
+ });
94
+ assert(r.ok, `expected the graph to compile, got ${JSON.stringify(r)}`);
95
+ // The compiled agent cells are prompt-LESS (Option (a)), so the prompt-keyed scan sees none — the
96
+ // union's type-keyed half is what covers them.
97
+ assertEquals(promptBearingTaskTypes(r.bpmn), []);
98
+ const missing = missingExternalMarkerAgentTaskTypes(r.bpmn);
99
+ assert(
100
+ missing.length === 0,
101
+ `compiled delivery-graph agent cell(s) missing <zeebe:agentDefinition agentType="external" /> — ${missing.join(", ")}`,
102
+ );
103
+ });
@@ -9,7 +9,7 @@ import { fileURLToPath } from "node:url";
9
9
  import { assert, assertEquals } from "#test-assert";
10
10
  import type { Capability } from "@nanobpm/agentic/protocol";
11
11
  import { crewResolver } from "./crew-vocab.ts";
12
- import { jobTypeToRoutingToken, promptBearingTaskTypes } from "./job-types.ts";
12
+ import { externalAgentTaskTypesMissingMarker, jobTypeToRoutingToken, missingExternalMarkerAgentTaskTypes, promptBearingTaskTypes } from "./job-types.ts";
13
13
 
14
14
  const PROCESSES_DIR = join(dirname(fileURLToPath(import.meta.url)), "../../../resources/processes");
15
15
 
@@ -105,3 +105,135 @@ test("DEFECT-CLASS GUARD: every deployed prompt-bearing agent job type resolves
105
105
  );
106
106
  }
107
107
  });
108
+
109
+ test("externalAgentTaskTypesMissingMarker flags an UNMAPPED (prompt-less) agent task with no marker", () => {
110
+ // The prompt-keyed scanner (`agentTaskTypesMissingExternalMarker`) cannot see this block — it has no
111
+ // prompt link. The marker scan must key on the colon-form job type ALONE (issue #886 review): a
112
+ // compiled delivery-graph cell for a bespoke `senior:<task>` (no deployed base prompt, deliberately
113
+ // prompt-less) must still carry the marker or it runs transcript-less.
114
+ const xml = `
115
+ <bpmn:serviceTask id="agent">
116
+ <bpmn:extensionElements>
117
+ <zeebe:taskDefinition type="senior:bespoke" />
118
+ </bpmn:extensionElements>
119
+ </bpmn:serviceTask>`;
120
+ assertEquals(externalAgentTaskTypesMissingMarker(xml), ["senior:bespoke"]);
121
+ });
122
+
123
+ test("externalAgentTaskTypesMissingMarker passes a marked prompt-less agent task and ignores a plain host task", () => {
124
+ const xml = `
125
+ <bpmn:serviceTask id="host">
126
+ <bpmn:extensionElements>
127
+ <zeebe:taskDefinition type="pr.finalize" />
128
+ </bpmn:extensionElements>
129
+ </bpmn:serviceTask>
130
+ <bpmn:serviceTask id="agent">
131
+ <bpmn:extensionElements>
132
+ <zeebe:taskDefinition type="senior:bespoke" />
133
+ <zeebe:agentDefinition agentType="external" />
134
+ </bpmn:extensionElements>
135
+ </bpmn:serviceTask>`;
136
+ assertEquals(externalAgentTaskTypesMissingMarker(xml), []);
137
+ });
138
+
139
+ test("externalAgentTaskTypesMissingMarker flags a marked-outside-extensionElements task (placement contract)", () => {
140
+ // A marker sitting OUTSIDE `<bpmn:extensionElements>` is ignored by the engine, so it must not
141
+ // "cover" the task here either (the same placement scope the prompt-keyed scanner enforces).
142
+ const xml = `
143
+ <bpmn:serviceTask id="agent">
144
+ <zeebe:agentDefinition agentType="external" />
145
+ <bpmn:extensionElements>
146
+ <zeebe:taskDefinition type="senior:bespoke" />
147
+ </bpmn:extensionElements>
148
+ </bpmn:serviceTask>`;
149
+ assertEquals(externalAgentTaskTypesMissingMarker(xml), ["senior:bespoke"]);
150
+ });
151
+
152
+ test("externalAgentTaskTypesMissingMarker ignores malformed/non-agent types (no false positives)", () => {
153
+ // Only a `<rank>:<task>` colon-form type (the `jobTypeToRoutingToken` grammar) keys a block as an
154
+ // agent task: a dot-form host job, a typeless block, and a malformed colon form are all out of
155
+ // scope — the scanner must not flag them as unmarked agents.
156
+ const xml = `
157
+ <bpmn:serviceTask id="host1">
158
+ <bpmn:extensionElements>
159
+ <zeebe:taskDefinition type="pr.finalize" />
160
+ </bpmn:extensionElements>
161
+ </bpmn:serviceTask>
162
+ <bpmn:serviceTask id="host2">
163
+ <bpmn:extensionElements>
164
+ <zeebe:taskDefinition type="senior:feature:extra" />
165
+ </bpmn:extensionElements>
166
+ </bpmn:serviceTask>
167
+ <bpmn:serviceTask id="host3">
168
+ <bpmn:extensionElements>
169
+ <zeebe:taskDefinition type="" />
170
+ </bpmn:extensionElements>
171
+ </bpmn:serviceTask>
172
+ <bpmn:serviceTask id="host4">
173
+ <bpmn:extensionElements>
174
+ </bpmn:extensionElements>
175
+ </bpmn:serviceTask>`;
176
+ assertEquals(externalAgentTaskTypesMissingMarker(xml), []);
177
+ });
178
+
179
+ test("missingExternalMarkerAgentTaskTypes unions BOTH scanners — catches a prompt-bearing AND a prompt-less unmarked agent task", () => {
180
+ // The single "every agent task carries the external marker" invariant (PR #886 review): a
181
+ // prompt-BEARING senior:* task that loses its marker is caught only by the prompt-keyed scanner,
182
+ // while a prompt-LESS senior:* task that loses its marker is caught only by the type-keyed scanner.
183
+ // Scanning by just one leaves the other's blind spot open, so the union must flag BOTH in one pass.
184
+ const xml = `
185
+ <bpmn:serviceTask id="prompted">
186
+ <bpmn:extensionElements>
187
+ <zeebe:taskDefinition type="senior:feature" />
188
+ <zeebe:linkedResources>
189
+ <zeebe:linkedResource resourceId="prompts/feature.md" linkName="prompt" />
190
+ </zeebe:linkedResources>
191
+ </bpmn:extensionElements>
192
+ </bpmn:serviceTask>
193
+ <bpmn:serviceTask id="bespoke">
194
+ <bpmn:extensionElements>
195
+ <zeebe:taskDefinition type="senior:bespoke" />
196
+ </bpmn:extensionElements>
197
+ </bpmn:serviceTask>`;
198
+ assertEquals(missingExternalMarkerAgentTaskTypes(xml), ["senior:feature", "senior:bespoke"]);
199
+ });
200
+
201
+ test("missingExternalMarkerAgentTaskTypes passes when every agent task is marked and ignores host tasks", () => {
202
+ const xml = `
203
+ <bpmn:serviceTask id="host">
204
+ <bpmn:extensionElements>
205
+ <zeebe:taskDefinition type="pr.finalize" />
206
+ </bpmn:extensionElements>
207
+ </bpmn:serviceTask>
208
+ <bpmn:serviceTask id="prompted">
209
+ <bpmn:extensionElements>
210
+ <zeebe:taskDefinition type="senior:feature" />
211
+ <zeebe:agentDefinition agentType="external" />
212
+ <zeebe:linkedResources>
213
+ <zeebe:linkedResource resourceId="prompts/feature.md" linkName="prompt" />
214
+ </zeebe:linkedResources>
215
+ </bpmn:extensionElements>
216
+ </bpmn:serviceTask>
217
+ <bpmn:serviceTask id="bespoke">
218
+ <bpmn:extensionElements>
219
+ <zeebe:taskDefinition type="senior:bespoke" />
220
+ <zeebe:agentDefinition agentType="external" />
221
+ </bpmn:extensionElements>
222
+ </bpmn:serviceTask>`;
223
+ assertEquals(missingExternalMarkerAgentTaskTypes(xml), []);
224
+ });
225
+
226
+ test("missingExternalMarkerAgentTaskTypes de-duplicates a type caught by both scanners", () => {
227
+ // A prompt-bearing task missing its marker is seen by BOTH the prompt-keyed and the type-keyed
228
+ // scanner; the union must report it ONCE, not twice.
229
+ const xml = `
230
+ <bpmn:serviceTask id="prompted">
231
+ <bpmn:extensionElements>
232
+ <zeebe:taskDefinition type="senior:feature" />
233
+ <zeebe:linkedResources>
234
+ <zeebe:linkedResource resourceId="prompts/feature.md" linkName="prompt" />
235
+ </zeebe:linkedResources>
236
+ </bpmn:extensionElements>
237
+ </bpmn:serviceTask>`;
238
+ assertEquals(missingExternalMarkerAgentTaskTypes(xml), ["senior:feature"]);
239
+ });
@@ -140,6 +140,63 @@ export function agentTaskTypesMissingExternalMarker(xml: string): string[] {
140
140
  return missing;
141
141
  }
142
142
 
143
+ /**
144
+ * Scan one BPMN document for the job types of agent service tasks — keyed by their `<rank>:<task>`
145
+ * colon-form `<zeebe:taskDefinition type>` ALONE, NOT by a prompt link — that are MISSING the
146
+ * engine-native AgentTask marker `<zeebe:agentDefinition agentType="external" />` (issue #884,
147
+ * PR #886 review). The prompt-keyed scanner (`agentTaskTypesMissingExternalMarker`) is blind to a
148
+ * DELIBERATELY prompt-less agent task — and EVERY compiled delivery-graph cell is prompt-less (the
149
+ * resolved #884 "Decide", Option (a): the cell carries the marker alone and is steered by its
150
+ * per-node `appendPrompt`, never a linked base prompt) — so a marker regression on exactly those
151
+ * cells would pass that scan vacuously. This scan keys a service task as an AGENT by the same grammar
152
+ * the crew bridge uses (`jobTypeToRoutingToken`: a single-colon `<rank>:<task>` whose rank is a bare
153
+ * routing segment), which needs no prompt link, so EVERY agent task — prompt-bearing or not — is
154
+ * inspected for the marker independently of its prompt status. A dot-form host job (`pr.finalize`),
155
+ * a typeless block, and a malformed colon form (`senior:feature:extra`) are not agent job types and
156
+ * are ignored (no false positives). The marker check is scoped to the block's
157
+ * `<bpmn:extensionElements>` (the engine-honoured PLACEMENT scope, via `extensionElementsOf`) — a
158
+ * marker sitting outside `extensionElements` is ignored by the engine, so it must not "cover" a
159
+ * task here either. Returns the offending task types in first-occurrence order (empty when every
160
+ * agent task is marked).
161
+ */
162
+ export function externalAgentTaskTypesMissingMarker(xml: string): string[] {
163
+ const seen = new Set<string>();
164
+ const missing: string[] = [];
165
+ for (const [block] of xml.matchAll(SERVICE_TASK)) {
166
+ const type = block.match(TASK_DEFINITION_TYPE)?.[1];
167
+ if (type === undefined || jobTypeToRoutingToken(type) === undefined) continue;
168
+ if (EXTERNAL_AGENT_MARKER.test(extensionElementsOf(block))) continue;
169
+ if (seen.has(type)) continue;
170
+ seen.add(type);
171
+ missing.push(type);
172
+ }
173
+ return missing;
174
+ }
175
+
176
+ /**
177
+ * The SINGLE "every agent service task carries the engine-native `<zeebe:agentDefinition
178
+ * agentType="external" />` marker" invariant (issue #745/#884, PR #886 review), as the UNION of both
179
+ * marker scanners. The two scanners each have a blind spot: `agentTaskTypesMissingExternalMarker` keys
180
+ * a task as an agent by its PROMPT link, so it cannot see a deliberately prompt-less agent cell;
181
+ * `externalAgentTaskTypesMissingMarker` keys it by its `<rank>:<task>` colon-form type, so it covers
182
+ * the prompt-less case but is the newer path. Scanning a corpus with only one leaves the other's blind
183
+ * spot open — a hand-authored (or compiled) `senior:*` task that loses BOTH its prompt link and its
184
+ * marker would pass a prompt-keyed-only gate. Every producer of agent tasks (the hand-authored corpus
185
+ * AND the delivery-graph compiler) is checked against THIS union, so neither half can regress through
186
+ * the other's blind spot. Returns the distinct offending task types in first-occurrence order (the
187
+ * prompt-keyed matches first, then any type-keyed-only ones), empty when every agent task is marked.
188
+ */
189
+ export function missingExternalMarkerAgentTaskTypes(xml: string): string[] {
190
+ const seen = new Set<string>();
191
+ const missing: string[] = [];
192
+ for (const type of [...agentTaskTypesMissingExternalMarker(xml), ...externalAgentTaskTypesMissingMarker(xml)]) {
193
+ if (seen.has(type)) continue;
194
+ seen.add(type);
195
+ missing.push(type);
196
+ }
197
+ return missing;
198
+ }
199
+
143
200
  /**
144
201
  * Scan one BPMN document for the job types of agent service tasks that OPT OUT of the harness
145
202
  * `--auto` reconciliation (issue #779) — those carrying `<zeebe:property
package/app/contracts.ts CHANGED
@@ -448,9 +448,9 @@ export const WIRE_CONTRACTS = {
448
448
  name: "io.nanobpm.agentTask.repository",
449
449
  owner: "app/repoEnvelope.ts",
450
450
  semantics:
451
- "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`. On the PRE-PR path `branch.create` is the deterministic `feat/<task.id>` (emitted for a single-task feature run AND — per issue #776 — the deterministic `feat/<node.id>` for each single-instance delivery-graph agent cell, injected per-cell by `agentNodeRepoEnvelope`/`app/deliveryRunner.ts` so a forgetful agent can never be left committing on the base branch and stranding its run on a non-ff push; the epic plan-fanout seed still omits it because its MI children each cut a per-child `feat/<task.id>` the app can't name at compile time, so those agents branch themselves). On the PR-based paths `branch.create` is set to the PR HEAD branch itself (equal to `ref`, jwulf/c8ctl-plugin-nano#231): without it the harness's base-branch guard treats the checkout as base-like and cuts a throwaway `nano/agent-work/<base>-<run>` fallback, stranding the round's commits OFF the PR; with `ref == create == head` the harness does a no-op `checkout -B <head>` and commits/pushes to the PR head branch. So `branch.create` is present on both the pre-PR and PR-based paths (only a blank/omitted value drops the key). 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, branch-cut guard jwulf/c8ctl-plugin-nano#231).",
451
+ "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`. On the PRE-PR path `branch.create` is the deterministic `feat/<task.id>` (emitted for a single-task feature run AND — per issue #776 — the deterministic `feat/<node.id>` for each single-instance delivery-graph agent cell, injected per-cell by `agentNodeRepoEnvelope`/`app/deliveryRunner.ts` so a forgetful agent can never be left committing on the base branch and stranding its run on a non-ff push; the epic plan-fanout seed still omits it because its MI children each cut a per-child `feat/<task.id>` the app can't name at compile time, so those agents branch themselves). On the PR-based paths `branch.create` is set to the PR HEAD branch itself (equal to `ref`, jwulf/c8ctl-plugin-nano#231): without it the harness's base-branch guard treats the checkout as base-like and cuts a throwaway `nano/agent-work/<base>-<run>` fallback, stranding the round's commits OFF the PR; with `ref == create == head` the harness does a no-op `checkout -B <head>` and commits/pushes to the PR head branch. So `branch.create` is present on both the pre-PR and PR-based paths (only a blank/omitted value drops the key). 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, branch-cut guard jwulf/c8ctl-plugin-nano#231). PLACEMENT (issue #882): `branch` is emitted as a TOP-LEVEL sibling of `repository` (and of `task`) under the namespace — the harness `normalizeTaskEnvelope` reads `branch` from the envelope ROOT (`isPlainObject(raw.branch)`), never from `repository.branch`, so the producer must not nest it there (nesting was the #227 two-sides-of-one-contract drift that left `branch.create` silently unread — nanobpm/nano-bpm#1344). The round-trip contract test `app/repoEnvelope.test.ts` pins producer and consumer together.",
452
452
  shape:
453
- '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 } }',
453
+ '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 } } — `branch` is a TOP-LEVEL sibling of `repository` (issue #882): the harness `normalizeTaskEnvelope` reads it from the envelope root (`isPlainObject(raw.branch)`), NOT from `repository.branch`, so the flattened header is `io.nanobpm.agentTask.branch.create` (never `…repository.branch.create`).',
454
454
  },
455
455
  "epicSet.submit": {
456
456
  category: "wire",
@@ -538,9 +538,9 @@ export const WIRE_CONTRACTS = {
538
538
  "agentTask.agentDefinition": {
539
539
  category: "wire",
540
540
  name: "agentTask.agentDefinition",
541
- owner: "resources/processes/*.bpmn",
541
+ owner: "resources/processes/*.bpmn + app/deliveryGraphCompiler.ts",
542
542
  semantics:
543
- "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.",
543
+ "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. The class is EVERY agent service task the app generates (issue #884): the hand-authored corpus AND every delivery-graph-compiler `agent` cell. The compiler emits the marker on EVERY cell and — per the resolved \"Decide\" half of #884 (Option (a), PR #886 review) — NO base-prompt link: the hand-authored process prompts are process-specific contracts (they require process variables and a `feat/<task.id>` branch the generic compiled cell never supplies), so a compiled cell is deliberately prompt-LESS and steered by its per-node `appendPrompt` alone. `npm run check:agent-definitions` (scripts/check-agent-definitions.ts, in CI) gates BOTH producers: it scans the compiled output with the prompt-keyed `agentTaskTypesMissingExternalMarker` AND the prompt-link-independent `externalAgentTaskTypesMissingMarker` (an agent task is keyed by its `<rank>:<task>` colon-form type alone, so a marker regression on a prompt-less cell cannot hide). Add the marker to a NEW `senior:*` agent task — never a second/synonym marker element.",
544
544
  shape: '<zeebe:agentDefinition agentType="external" /> (sibling of <zeebe:taskDefinition> in a senior:* service task\'s extensionElements)',
545
545
  },
546
546
  "agentTask.autoSubscribe": {
@@ -14,6 +14,7 @@
14
14
  import { test } from "node:test";
15
15
  import { assert, assertEquals } from "#test-assert";
16
16
  import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
17
+ import { missingExternalMarkerAgentTaskTypes } from "./agentic/vocab/job-types.ts";
17
18
  import { compileDeliveryGraph, digestInvisibleRawValues, graphCarriesRedactedSecrets, nodeDisplay, redactFreeText, resumeValidVar } from "./deliveryGraphCompiler.ts";
18
19
  import { isDeliveryEscalationTwin } from "./deliveryHuman.ts";
19
20
 
@@ -158,6 +159,86 @@ test("trust bound: every node inlines an embedded subProcess delegating to an al
158
159
  assert(/<bpmn:userTask id="delivery-human-task__n\d+__esc"/.test(r.bpmn), "a bounded node inlines an escalation user task");
159
160
  });
160
161
 
162
+ test("#884 agentDefinition: every compiled agent cell carries the engine-native AgentTask marker (durable transcript)", async () => {
163
+ // Issue #884: a compiled `agent` cell's service task carried NO
164
+ // `<zeebe:agentDefinition agentType="external"/>`, so the engine refused to attach an AgentInstance
165
+ // to it (`createAgentInstance failed — … has no agentDefinition`) and every delivery-graph agent run
166
+ // persisted NO durable agentic transcript. The hand-authored processes declare the marker on every
167
+ // `senior:*` task (issue #745); the compiler must emit it on every agent cell it generates — the
168
+ // defect CLASS is "every agent service task the app generates", so this guards the compiler output
169
+ // through the SAME canonical union scanner (`missingExternalMarkerAgentTaskTypes`, the one the
170
+ // repo-wide gate in `check-agent-definitions.ts` uses) rather than a second, driftable regex.
171
+ const r = await compileOk(RELEASE_RUNBOOK);
172
+ // RELEASE_RUNBOOK has exactly one agent node (`open-b` → `senior:feature`); its compiled service
173
+ // task is `<el>_task`. A compiled agent cell is prompt-LESS (the resolved #884 "Decide", Option (a)),
174
+ // so it is the type-keyed half of the union scanner (keyed by the `<rank>:<task>` colon-form type)
175
+ // that sees it — and the marker must be present on THAT cell, not merely somewhere in the document
176
+ // (a stray marker on a DIFFERENT element would not make THIS cell eligible for AgentInstance minting).
177
+ const taskBlock = [...r.bpmn.matchAll(/<bpmn:serviceTask id="n\d+_task"[\s\S]*?<\/bpmn:serviceTask>/g)]
178
+ .map((m) => m[0])
179
+ .find((b) => b.includes('<zeebe:taskDefinition type="senior:feature" />')) ?? "";
180
+ assert(taskBlock !== "", "the agent node compiles to an inner senior:feature service task");
181
+ const ext = /<bpmn:extensionElements>[\s\S]*?<\/bpmn:extensionElements>/.exec(taskBlock)?.[0] ?? "";
182
+ assert(
183
+ /<zeebe:agentDefinition agentType="external" \/>/.test(ext),
184
+ `the compiled agent cell carries <zeebe:agentDefinition agentType="external" /> inside its extensionElements, got:\n${taskBlock}`,
185
+ );
186
+ // The marker is a SIBLING of the dispatch verb, which stays (per #464): both must be present.
187
+ assert(/<zeebe:taskDefinition type="senior:feature" \/>/.test(ext), "the dispatch verb stays alongside the marker");
188
+ // And the CANONICAL union scanner — the one the repo-wide guard applies — finds NO unmarked agent
189
+ // task in the whole compiled document (it keys an agent by its colon-form type, which every compiled
190
+ // agent cell carries; a connector/wait/host task is not colon-form and is correctly ignored).
191
+ assertEquals(missingExternalMarkerAgentTaskTypes(r.bpmn), []);
192
+ });
193
+
194
+ test("#884 agentDefinition: EVERY agent cell in a multi-agent graph is marked (not just the first)", async () => {
195
+ // A marker emitted once (or only on a specific shape) would leave sibling agent cells transcript-less
196
+ // — the 269 failed creates on omarchy came from several processes. Compile a graph whose agent nodes
197
+ // differ (distinct job types, one with a repo spec + emits, one bare) and assert EVERY compiled
198
+ // `<el>_task` service task carries the marker inside its own extensionElements.
199
+ const r = await compileOk({
200
+ nodes: [
201
+ { id: "impl", kind: "agent", agent: { jobType: "senior:feature", repository: "nanobpm/nano-workforce", prompt: "implement #1" }, emits: [{ name: "pr", type: "pr" }] },
202
+ { id: "review", kind: "agent", agent: { jobType: "senior:pr-review" } },
203
+ { id: "ship", kind: "agent", agent: { jobType: "senior:trial-merge", prompt: "merge it" } },
204
+ ],
205
+ edges: [
206
+ { from: "impl", to: "review" },
207
+ { from: "review", to: "ship" },
208
+ ],
209
+ });
210
+ const taskBlocks = [...r.bpmn.matchAll(/<bpmn:serviceTask id="(n\d+_task)"[\s\S]*?<\/bpmn:serviceTask>/g)];
211
+ assertEquals(taskBlocks.length, 3, "all three agent nodes compile to an inner service task");
212
+ for (const [block, id] of taskBlocks) {
213
+ const ext = /<bpmn:extensionElements>[\s\S]*?<\/bpmn:extensionElements>/.exec(block)?.[0] ?? "";
214
+ assert(
215
+ /<zeebe:agentDefinition agentType="external" \/>/.test(ext),
216
+ `agent cell ${id} is missing <zeebe:agentDefinition agentType="external" /> in its extensionElements`,
217
+ );
218
+ }
219
+ });
220
+
221
+ test("#884 prompt-less: a compiled agent cell carries NO base-prompt link (Option (a))", async () => {
222
+ // The resolved "Decide" half of #884 (Option (a), PR #886 review): a compiled agent cell is
223
+ // deliberately prompt-LESS. The hand-authored process prompts (`prompts/feature.md`, …) are
224
+ // process-specific contracts — they require process variables (`variables.task`/`variables.issue`)
225
+ // and a `feat/<task.id>` branch the generic compiled cell never supplies (it seeds only
226
+ // `{jobType, appendPrompt, timeout}` and the harness provisions `feat/<node.id>`, #776) — so linking
227
+ // one hands the cell instructions referencing missing inputs and a conflicting branch. The cell is
228
+ // steered by its per-node `appendPrompt` alone; the marker alone fixes the createAgentInstance
229
+ // failure. Pin that NO compiled agent cell emits a prompt `linkedResource` — for a mapped-looking
230
+ // type, a bespoke type, AND a prototype-chain jobType (which must never resolve to a bogus link).
231
+ for (const jobType of ["senior:feature", "senior:pr-review", "senior:no-such-prompt", "constructor", "__proto__", "toString"]) {
232
+ const r = await compileOk({ nodes: [{ id: "p", kind: "agent", agent: { jobType } }], edges: [] });
233
+ assert(
234
+ !r.bpmn.includes('linkName="prompt"'),
235
+ `compiled agent cell ${JSON.stringify(jobType)} must link NO base prompt (prompt-less, Option (a)), got:\n${
236
+ r.bpmn.split("\n").filter((l) => l.includes("linkedResource")).join("\n")
237
+ }`,
238
+ );
239
+ }
240
+ });
241
+
161
242
  test("late-binding: a fact-qualified edge threads a boundFacts input into the consumer subProcess", async () => {
162
243
  const r = await compileOk(RELEASE_RUNBOOK);
163
244
  // `publish.resolvedArtifact -> consume`: the connector subProcess receives the human's emitted fact as
@@ -1931,7 +1931,7 @@ function innerBodyLines(w: NodeWiring, requiredEmits: ReadonlySet<string>, displ
1931
1931
  // be a leak here), but the `descriptor` is embedded by `serviceBodyLines` into the operator-visible
1932
1932
  // timeout / producer-contract escalation FEEL `prompt` — so it takes the SAME display redaction
1933
1933
  // `nodeDisplay` applies, never the raw value (issue #778 review — thread deliveryGraphCompiler.ts:1606).
1934
- return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), [], redactConnectorValue(node.agent.jobType), contractGate, agentRepoSpecHeaderLines(node), displayName, "agent", nodeEmits);
1934
+ return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), [], redactConnectorValue(node.agent.jobType), contractGate, agentRepoSpecHeaderLines(node), displayName, "agent", nodeEmits, undefined);
1935
1935
  }
1936
1936
  case "connector": {
1937
1937
  // TRIM the target before building the escalation descriptor — `nodeDisplay` and the connector
@@ -2113,6 +2113,27 @@ function serviceBodyLines(
2113
2113
  const taskExt = [
2114
2114
  " <bpmn:extensionElements>",
2115
2115
  ` <zeebe:taskDefinition ${taskDefAttr} />`,
2116
+ // Issue #884: an `agent` cell MUST carry the engine-native AgentTask marker
2117
+ // (`<zeebe:agentDefinition agentType="external"/>`, the `agentTask.agentDefinition` contract —
2118
+ // issue #745) INSIDE its extensionElements, as a sibling of the dispatch verb. Without it the
2119
+ // engine refuses to attach an AgentInstance to the element (`createAgentInstance failed — … has
2120
+ // no agentDefinition`), so every delivery-graph agent run persisted NO durable agentic
2121
+ // transcript (269 failed creates on omarchy). The hand-authored `senior:*` tasks all declare it
2122
+ // (guarded by `agent-marker.test.ts`); the compiler emits it on EVERY agent cell it generates —
2123
+ // the defect class is "every agent service task the app generates". A `connector` delegates to a
2124
+ // HOST worker (`pr.delivery-connector`), not an external agent, so it is NOT marked.
2125
+ ...(kind === "agent" ? [' <zeebe:agentDefinition agentType="external" />'] : []),
2126
+ // Issue #884 (the "Decide" half — resolved Option (a), PR #886 review): an agent cell carries ONLY
2127
+ // the marker above and NO base-prompt `linkedResource`. The hand-authored process prompts
2128
+ // (`prompts/feature.md`, `prompts/review-round.md`, …) are PROCESS-specific contracts, not generic
2129
+ // cell contracts — they require process variables (`variables.task`/`variables.issue`) and a branch
2130
+ // (`feat/<task.id>`) the generic compiled cell never supplies (it seeds only
2131
+ // `{jobType, appendPrompt, timeout}` and the harness provisions `feat/<node.id>`, #776). Linking one
2132
+ // would hand every compiled cell instructions referencing missing inputs and a conflicting branch.
2133
+ // The cell is therefore deliberately prompt-LESS: the per-node `appendPrompt` FEEL (runner-seeded
2134
+ // from `node.agent.prompt` + the emit/producer contracts) is its sole steering channel, matching the
2135
+ // historically-intentional design (`deliveryRunner.ts` — "the delivery agent node carries no
2136
+ // base-prompt resource"). The marker alone is what fixes the #884 `createAgentInstance` failure.
2116
2137
  ...(taskProps.length > 0 ? [" <zeebe:properties>", ...taskProps, " </zeebe:properties>"] : []),
2117
2138
  ...taskHeaders,
2118
2139
  " <zeebe:ioMapping>",
@@ -533,7 +533,7 @@ test("prepareDeliveryGraph injects the repository envelope PER agent cell from t
533
533
  assert(headers.some((h) => h.includes('task.allowPr" value="true"')), `expected a task.allowPr header, got ${JSON.stringify(headers)}`);
534
534
  // The deterministic per-node isolation branch (issue #776): the harness cuts `feat/<node.id>` itself,
535
535
  // so the agent can never be left committing on the base branch (a non-ff push that strands the run).
536
- assert(headers.some((h) => h.includes('repository.branch.create" value="feat/open-b"')), `expected a per-node branch.create header, got ${JSON.stringify(headers)}`);
536
+ assert(headers.some((h) => h.includes('io.nanobpm.agentTask.branch.create" value="feat/open-b"')), `expected a per-node branch.create header, got ${JSON.stringify(headers)}`);
537
537
  // No `__repoSpec` marker survives injection — it is the compiler's digest-stable anchor only.
538
538
  assert(!p.bpmn.includes("__repoSpec"), "the __repoSpec marker is fully replaced");
539
539
  // No run-root `io.nanobpm.agentTask` variable — the envelope rides headers now, not a run variable.
@@ -590,7 +590,7 @@ test("a declared repository WITHOUT a base branch omits ref/baseRef — the harn
590
590
  assert(!headers.some((h) => h.includes("repository.baseRef")), "no baseRef either");
591
591
  // Even without a known base, the deterministic isolation branch is still emitted (issue #776): the
592
592
  // harness cuts `feat/<node.id>` off the cloned default tip, so the agent never commits on the default.
593
- assert(headers.some((h) => h.includes('repository.branch.create" value="feat/a"')), `expected branch.create even without a base, got ${JSON.stringify(headers)}`);
593
+ assert(headers.some((h) => h.includes('io.nanobpm.agentTask.branch.create" value="feat/a"')), `expected branch.create even without a base, got ${JSON.stringify(headers)}`);
594
594
  });
595
595
 
596
596
  test("each agent cell gets its OWN deterministic feat/<node.id> branch.create — never a shared branch (#776)", async () => {
@@ -603,7 +603,7 @@ test("each agent cell gets its OWN deterministic feat/<node.id> branch.create
603
603
  edges: [{ from: "impl-a", to: "impl-b" }],
604
604
  };
605
605
  const p = await prepareOk(graph, {});
606
- const branches = agentHeaders(p.bpmn).filter((h) => h.includes("repository.branch.create"));
606
+ const branches = agentHeaders(p.bpmn).filter((h) => h.includes("io.nanobpm.agentTask.branch.create"));
607
607
  // Two distinct cells → two distinct per-node branches, so no two agents can collide on one branch.
608
608
  assert(branches.some((h) => h.includes('value="feat/impl-a"')), `expected feat/impl-a, got ${JSON.stringify(branches)}`);
609
609
  assert(branches.some((h) => h.includes('value="feat/impl-b"')), `expected feat/impl-b, got ${JSON.stringify(branches)}`);
@@ -612,7 +612,7 @@ test("each agent cell gets its OWN deterministic feat/<node.id> branch.create
612
612
 
613
613
  test("an explicit repoless run emits NO branch.create — no envelope at all (#776/#729)", async () => {
614
614
  const p = await prepareOk(GRAPH, { repoless: true });
615
- assert(!p.bpmn.includes("repository.branch.create"), "a repoless run carries no isolation branch either");
615
+ assert(!p.bpmn.includes("io.nanobpm.agentTask.branch.create"), "a repoless run carries no isolation branch either");
616
616
  });
617
617
 
618
618
  test("a node id that derives an invalid git ref degrades to NO branch.create — never emits an unusable feat/... (#776)", async () => {
@@ -630,7 +630,7 @@ test("a node id that derives an invalid git ref degrades to NO branch.create —
630
630
  edges: [{ from: "a..b", to: "a.lock" }, { from: "a.lock", to: "ok" }],
631
631
  };
632
632
  const p = await prepareOk(graph, {});
633
- const branches = agentHeaders(p.bpmn).filter((h) => h.includes("repository.branch.create"));
633
+ const branches = agentHeaders(p.bpmn).filter((h) => h.includes("io.nanobpm.agentTask.branch.create"));
634
634
  // Only the valid-id cell keeps a branch.create; the two invalid-ref ids emit none (their agents still
635
635
  // provision an isolated clone, they just cut their own branch inside it — the pre-#776 fallback).
636
636
  assertEquals(branches.length, 1, `only the valid id keeps a branch.create, got ${JSON.stringify(branches)}`);
@@ -638,16 +638,19 @@ test("a node id that derives an invalid git ref degrades to NO branch.create —
638
638
  });
639
639
 
640
640
  test("agentNodeRepoEnvelope emits branch.create off the base when known, off the default when not, and omits it when blank (#776)", () => {
641
- const withBase = (agentNodeRepoEnvelope("acme/one", "main", "feat/n1") as any)["io.nanobpm.agentTask"].repository;
642
- assertEquals(withBase.ref, "main", "known base → checked-out ref");
641
+ // `branch` is a TOP-LEVEL sibling of `repository` (issue #882) — the harness reads it from the
642
+ // envelope root, NOT from `repository.branch`.
643
+ const withBase = (agentNodeRepoEnvelope("acme/one", "main", "feat/n1") as any)["io.nanobpm.agentTask"];
644
+ assertEquals(withBase.repository.ref, "main", "known base → checked-out ref");
643
645
  assertEquals(withBase.branch.create, "feat/n1", "the harness cuts feat/<node.id> off the base");
644
- const noBase = (agentNodeRepoEnvelope("acme/one", null, "feat/n2") as any)["io.nanobpm.agentTask"].repository;
645
- assertEquals("ref" in noBase, false, "no base → clone the default branch");
646
+ assertEquals("branch" in withBase.repository, false, "branch must NOT be nested under repository");
647
+ const noBase = (agentNodeRepoEnvelope("acme/one", null, "feat/n2") as any)["io.nanobpm.agentTask"];
648
+ assertEquals("ref" in noBase.repository, false, "no base → clone the default branch");
646
649
  assertEquals(noBase.branch.create, "feat/n2", "branch.create is still emitted off the default tip");
647
650
  // A whitespace-tainted branch is trimmed; a blank/absent one omits the `branch` key (pre-#776 behaviour).
648
- assertEquals((agentNodeRepoEnvelope("acme/one", "main", " feat/n3 ") as any)["io.nanobpm.agentTask"].repository.branch.create, "feat/n3");
651
+ assertEquals((agentNodeRepoEnvelope("acme/one", "main", " feat/n3 ") as any)["io.nanobpm.agentTask"].branch.create, "feat/n3");
649
652
  for (const blank of [null, undefined, "", " "]) {
650
- const r = (agentNodeRepoEnvelope("acme/one", "main", blank as any) as any)["io.nanobpm.agentTask"].repository;
653
+ const r = (agentNodeRepoEnvelope("acme/one", "main", blank as any) as any)["io.nanobpm.agentTask"];
651
654
  assertEquals("branch" in r, false, `expected no branch key for ${JSON.stringify(blank)}`);
652
655
  }
653
656
  });
@@ -196,11 +196,13 @@ test("startFeature: seeds the pre-PR repository envelope so the harness provisio
196
196
  // shared launch dir; with it, the harness clones a throwaway workspace. The implementation path is
197
197
  // PRE-PR, so it checks out the BASE branch (`ref`) and the harness cuts the deterministic
198
198
  // `feat/<task.id>` feature branch off it (`branch.create`).
199
- const repo = (captured.variables as Record<string, any>)["io.nanobpm.agentTask"].repository;
199
+ const ns = (captured.variables as Record<string, any>)["io.nanobpm.agentTask"];
200
+ const repo = ns.repository;
200
201
  assertEquals(repo.url, "https://github.com/owner/repo.git");
201
202
  assertEquals(repo.ref, "epic/x");
202
- assertEquals(repo.branch.create, "feat/issue-42");
203
- assertEquals(repo.branch.create, `feat/${featureTaskId(PARSED.number)}`);
203
+ // `branch` is a TOP-LEVEL sibling of `repository` (issue #882) — the harness reads it from the root.
204
+ assertEquals(ns.branch.create, "feat/issue-42");
205
+ assertEquals(ns.branch.create, `feat/${featureTaskId(PARSED.number)}`);
204
206
  // The blobless/single-branch monorepo shaping rides along, exactly like the PR-based envelope.
205
207
  assertEquals(repo.singleBranch, true);
206
208
  assertEquals(repo.filter, "blob:none");