@nanobpm/nano-workforce 0.208.2 → 0.208.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.
package/AGENTS.md CHANGED
@@ -253,6 +253,23 @@ Two different `nano:dataEnvelope` uses have different rules — don't conflate t
253
253
  it as a process variable: besides being unnecessary, seeding a var an
254
254
  in-subprocess gateway reads re-introduces the multi-instance `=null`-shadow
255
255
  gotcha above.
256
+ - **A per-MI-child value reaches an agent job's reserved `io.nanobpm.agentTask`
257
+ envelope via a worker result key literally NAMED with the dotted path — never a
258
+ dotted `ioMapping` target.** The whole-run seed can only set the
259
+ `io.nanobpm.agentTask` object ONCE at the root (uniform for every cell), so a
260
+ per-slice field (e.g. the implement-cell's deterministic `branch.create =
261
+ feat/<task.id>`, issue #945) can't ride it. A `<zeebe:output
262
+ target="io.nanobpm.agentTask.branch.create">` does NOT work — a dotted ioMapping
263
+ target builds a NESTED `{io:{nanobpm:{agentTask:…}}}` object the flat-key harness
264
+ never reads. The working channel: a worker running in a per-slice process instance (e.g.
265
+ `pr.slice-branch`, the first step of each implement-cell instance — NOT a task in the shared MI body, whose undeclared result keys land on the shared root where a sibling overwrites them) returns a result key literally named
266
+ `"io.nanobpm.agentTask.branch.create"`; the engine propagates worker result
267
+ variables verbatim (mapped or not), materialising a FLAT dotted process variable
268
+ that the harness's `collectEnvelopeFrom` deep-merges with the whole-object seed.
269
+ Derive the value from the ONE canonical `sliceBranchCreate` (app/repoEnvelope.ts)
270
+ so the envelope, the prompt's branch instruction, and the reconcile lookup share a
271
+ single source. `recoverBranchCreate(headers, variables)` (same module) is the ONE
272
+ consumer-side mirror tests use to assert "the harness sees this branch".
256
273
 
257
274
  ## Urban page runtime: rendering primitives are not JS-truthy
258
275
 
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.208.4](https://github.com/nanobpm/nano-workforce/compare/v0.208.3...v0.208.4) (2026-11-11)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **plan-fanout:** emit deterministic branch.create on the implement-cell envelope ([#945](https://github.com/nanobpm/nano-workforce/issues/945)) ([#946](https://github.com/nanobpm/nano-workforce/issues/946)) ([0977243](https://github.com/nanobpm/nano-workforce/commit/097724344a2f3942af5b1796e2e0a7e8363b44f9))
6
+
7
+ ## [0.208.3](https://github.com/nanobpm/nano-workforce/compare/v0.208.2...v0.208.3) (2026-11-11)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **merge:** never retire a merge loop into nothing on the convergence hand-off ([#944](https://github.com/nanobpm/nano-workforce/issues/944)) ([ff42e2a](https://github.com/nanobpm/nano-workforce/commit/ff42e2a6fa77fd780949822adf3d4f2b9caefc38)), closes [nanobpm/nano-bpm#1452](https://github.com/nanobpm/nano-bpm/issues/1452) [#939](https://github.com/nanobpm/nano-workforce/issues/939) [#943](https://github.com/nanobpm/nano-workforce/issues/943)
12
+
1
13
  ## [0.208.2](https://github.com/nanobpm/nano-workforce/compare/v0.208.1...v0.208.2) (2026-11-10)
2
14
 
3
15
  ### Bug Fixes
package/app/contracts.ts CHANGED
@@ -479,7 +479,7 @@ export const WIRE_CONTRACTS = {
479
479
  name: "io.nanobpm.agentTask.repository",
480
480
  owner: "app/repoEnvelope.ts",
481
481
  semantics:
482
- "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.",
482
+ "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's per-slice `feat/<task.id>` differs per MI child and so can't ride the whole-epic seed — instead the `pr.slice-branch` worker — the first step of EACH implement-cell process instance, so the value is instance-local and sibling slices cannot overwrite it — stamps it as a FLAT dotted `io.nanobpm.agentTask.branch.create` process variable (issue #945, derived via the ONE canonical `sliceBranchCreate`), which the harness's `collectEnvelopeFrom` deep-merges with the whole-object seed so a retry re-derives the SAME branch and the durable WIP checkpoint restore matches). 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.",
483
483
  shape:
484
484
  '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`).',
485
485
  },
package/app/feature.ts CHANGED
@@ -21,7 +21,7 @@ import { coalesceTitle, fetchIssueTitle } from "./github.ts";
21
21
  import { derivedTrackingTable } from "./instanceTracking.ts";
22
22
  import { ESCALATION_SLA_TIMEOUT, normalizeBaseBranch, type ParsedIssue, renderBaseBranchBrief } from "./plan.ts";
23
23
  import type { ReadinessProbe } from "./readiness.ts";
24
- import { requireRepoEnvelopeVars } from "./repoEnvelope.ts";
24
+ import { requireRepoEnvelopeVars, sliceBranchCreate } from "./repoEnvelope.ts";
25
25
 
26
26
  /** Optional intake-time readiness gate for a feature run (issue #295): the `capability`/`command`/…
27
27
  * probes the run must ALL satisfy before its implementation agent is dispatched (parked, durably, at
@@ -503,7 +503,10 @@ export async function startFeature(
503
503
  const taskId = featureTaskId(parsed.number);
504
504
  // Pre-PR feature branch (issue #684): the deterministic `feat/<task.id>` the harness creates off the
505
505
  // base for the implementation agent (the agent-guide's `feat/*` convention), matching `task.id`.
506
- const prePrBranch = `feat/${taskId}`;
506
+ // Derived via the ONE canonical `sliceBranchCreate` (issue #945) so the whole-run feature seed and the
507
+ // epic plan-fanout's per-slice emission can never drift into two branch-naming sources. A single-issue
508
+ // task id is always `issue-<number>`, so the derivation is never null here.
509
+ const prePrBranch = sliceBranchCreate(taskId) ?? `feat/${taskId}`;
507
510
  const { processInstanceKey } = await engine.createInstance({
508
511
  processDefinitionId: FEATURE_PROCESS_ID,
509
512
  variables: {
@@ -0,0 +1,168 @@
1
+ // DEFECT-CLASS GUARD (issue #945): every process path that activates an agent job backed by a
2
+ // repository envelope MUST name a deterministic `branch.create`, and that branch MUST be the one the
3
+ // agent's prompt tells it to use — `feat/<task.id>` (resources/prompts/feature.md). Before #945 the
4
+ // plan-fanout implement-cell carried NO `branch.create`: the deterministic branch existed only as
5
+ // prose in the prompt, so every c8ctl retry cut a random `nano/agent-work/*` fallback branch and the
6
+ // durable WIP checkpoint (keyed on `feat/<task.id>`) was never restored — each retry discarded the
7
+ // prior session's work. The root cause was a DRIFT SURFACE: two sources of truth (the prompt text and
8
+ // the envelope) for one fact.
9
+ //
10
+ // This guard pins the single source of truth (`sliceBranchCreate`, app/repoEnvelope.ts) and asserts
11
+ // every consumer derives the SAME branch from it, so a new agent-job-with-repo path — or an edit that
12
+ // re-introduces a hand-written branch — fails CI:
13
+ //
14
+ // 1. The ONE derivation: `sliceBranchCreate(taskId)` is `feat/<taskId>`, validated as a plausible git
15
+ // ref (an ill-formed id degrades to omission, never an unusable branch.create).
16
+ // 2. Every consumer delegates to it: the whole-run feature seed (`feature.ts`), the implement-cell
17
+ // reconcile lookup (`implementReconcile.ts` `implementCellBranch`), and the per-slice envelope
18
+ // emission (`workers/caps-prepare`).
19
+ // 3. The prompt's branch instruction is the SAME `feat/<task.id>` the envelope cuts — the two read
20
+ // off one value, not two.
21
+ // 4. Every execution path into the `implement-task` agent job passes through the `pr.slice-branch`
22
+ // emitter, which runs INSIDE each implement-cell process instance (so siblings can't clobber it) —
23
+ // checked on the sourceRef/targetRef graph, not on XML declaration order.
24
+ import { readFileSync } from "node:fs";
25
+ import { test } from "node:test";
26
+ import { dirname, join } from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+ import { assert, assertEquals } from "#test-assert";
29
+ import { implementCellBranch } from "./implementReconcile.ts";
30
+ import { AGENT_TASK_BRANCH_CREATE_VAR, sliceBranchCreate } from "./repoEnvelope.ts";
31
+
32
+ const PROCESSES_DIR = join(dirname(fileURLToPath(import.meta.url)), "../resources/processes");
33
+ const PROMPTS_DIR = join(dirname(fileURLToPath(import.meta.url)), "../resources/prompts");
34
+
35
+ function flat(rel: string): string {
36
+ return readFileSync(join(PROCESSES_DIR, rel), "utf8").replace(/\s+/g, " ");
37
+ }
38
+
39
+ test("contract: sliceBranchCreate is the ONE canonical feat/<task.id> derivation (#945)", () => {
40
+ // The deterministic branch for a real task id.
41
+ assertEquals(sliceBranchCreate("issue-7"), "feat/issue-7");
42
+ assertEquals(sliceBranchCreate("tool-metadata-engine"), "feat/tool-metadata-engine");
43
+ assertEquals(sliceBranchCreate("t1"), "feat/t1");
44
+ // A blank / missing id yields NO branch (the caller degrades to omission, never a bare `feat/`).
45
+ assertEquals(sliceBranchCreate(""), null);
46
+ assertEquals(sliceBranchCreate(" "), null);
47
+ assertEquals(sliceBranchCreate(null), null);
48
+ assertEquals(sliceBranchCreate(undefined), null);
49
+ // An id whose derived branch is not a plausible git ref degrades to null (no unusable branch.create).
50
+ assertEquals(sliceBranchCreate("bad..id"), null);
51
+ assertEquals(sliceBranchCreate("has space"), null);
52
+ });
53
+
54
+ test("contract: every implement-cell branch consumer derives from the ONE source (#945)", () => {
55
+ // The reconcile lookup (`implementCellBranch`) — which finds the adoptable PR on the slice's branch —
56
+ // MUST name the same branch the harness cuts (the envelope) and the prompt instructs. It delegates to
57
+ // `sliceBranchCreate`, so the two can never drift.
58
+ for (const taskId of ["issue-7", "tool-metadata-engine", "t1"]) {
59
+ assertEquals(
60
+ implementCellBranch(taskId),
61
+ sliceBranchCreate(taskId),
62
+ `implementCellBranch(${taskId}) must equal the canonical sliceBranchCreate (one source)`,
63
+ );
64
+ }
65
+ });
66
+
67
+ test("contract: the agent prompt's branch instruction is the SAME feat/<task.id> the envelope cuts (#945)", () => {
68
+ // The implement-cell's base prompt (feature.md) tells the agent to branch `feat/<task.id>`. That
69
+ // prose is the SECOND half of the contract — it must name the very branch `sliceBranchCreate`
70
+ // derives for the envelope, so the agent's self-cut branch (pre-#945 fallback) and the harness-cut
71
+ // branch (post-#945) are identical. Pin the literal placeholder so a prompt edit that renames the
72
+ // branch convention fails here.
73
+ const prompt = readFileSync(join(PROMPTS_DIR, "feature.md"), "utf8");
74
+ assert(
75
+ prompt.includes("feat/<task.id>"),
76
+ "feature.md must instruct the agent to branch `feat/<task.id>` — the same branch sliceBranchCreate derives",
77
+ );
78
+ // And the derivation of a concrete id must slot into that placeholder exactly.
79
+ assertEquals(`feat/<task.id>`.replace("<task.id>", "t1"), sliceBranchCreate("t1"));
80
+ });
81
+
82
+ /** Parse the `<bpmn:sequenceFlow>` graph (sourceRef → targetRef) and the boundary-event attachments
83
+ * (host → boundary, an implicit edge) of one process file. */
84
+ function flowGraph(xml: string): Map<string, string[]> {
85
+ const g = new Map<string, string[]>();
86
+ const add = (from: string, to: string): void => {
87
+ g.set(from, [...(g.get(from) ?? []), to]);
88
+ };
89
+ for (const m of xml.matchAll(/<bpmn:sequenceFlow\b[^>]*>/g)) {
90
+ const src = /sourceRef="([^"]+)"/.exec(m[0])?.[1];
91
+ const tgt = /targetRef="([^"]+)"/.exec(m[0])?.[1];
92
+ if (src && tgt) add(src, tgt);
93
+ }
94
+ for (const m of xml.matchAll(/<bpmn:boundaryEvent\b[^>]*>/g)) {
95
+ const id = /\bid="([^"]+)"/.exec(m[0])?.[1];
96
+ const host = /attachedToRef="([^"]+)"/.exec(m[0])?.[1];
97
+ if (id && host) add(host, id);
98
+ }
99
+ return g;
100
+ }
101
+
102
+ /** Can `to` be reached from any of `from` without visiting `avoid`? */
103
+ function reachableAvoiding(g: Map<string, string[]>, from: string[], to: string, avoid: string): boolean {
104
+ const seen = new Set<string>();
105
+ const stack = from.filter((f) => f !== avoid);
106
+ while (stack.length > 0) {
107
+ const n = stack.pop();
108
+ if (n === undefined || seen.has(n)) continue;
109
+ if (n === to) return true;
110
+ seen.add(n);
111
+ for (const next of g.get(n) ?? []) if (next !== avoid) stack.push(next);
112
+ }
113
+ return false;
114
+ }
115
+
116
+ function startEvents(xml: string): string[] {
117
+ return [...xml.matchAll(/<bpmn:startEvent\b[^>]*\bid="([^"]+)"/g)].map((m) => m[1] ?? "");
118
+ }
119
+
120
+ test("contract: every path into the implement-task agent job passes through the slice-branch emitter (#945)", () => {
121
+ const xml = flat("implement-cell.bpmn");
122
+ assert(
123
+ /<bpmn:serviceTask id="slice-branch"[^>]*> <bpmn:extensionElements> <zeebe:taskDefinition type="pr\.slice-branch"/.test(xml),
124
+ "implement-cell must declare the slice-branch task backed by the pr.slice-branch worker",
125
+ );
126
+ const g = flowGraph(xml);
127
+ const starts = startEvents(xml);
128
+ assert(starts.length > 0, "implement-cell must have a start event");
129
+ assert(reachableAvoiding(g, starts, "implement-task", "__none__"), "implement-task must be reachable at all");
130
+ assert(
131
+ !reachableAvoiding(g, starts, "implement-task", "slice-branch"),
132
+ "no execution path may reach implement-task without first passing the slice-branch emitter",
133
+ );
134
+ });
135
+
136
+ test("contract: the path checker detects a bypass of the emitter (guards the guard) (#945)", () => {
137
+ const bypass = flat("implement-cell.bpmn").replace(
138
+ /<bpmn:sequenceFlow id="ic_toSliceBranch" sourceRef="Start" targetRef="slice-branch" \/>/,
139
+ '<bpmn:sequenceFlow id="ic_toSliceBranch" sourceRef="Start" targetRef="implement-task" />',
140
+ );
141
+ assert(bypass.includes('sourceRef="Start" targetRef="implement-task"'), "fixture rewrite must apply");
142
+ assert(reachableAvoiding(flowGraph(bypass), startEvents(bypass), "implement-task", "slice-branch"));
143
+ });
144
+
145
+ test("contract: plan-fanout composes the implement step as a callActivity to implement-cell (#945)", () => {
146
+ // The emitter must run in a SEPARATE process instance per slice (its result keys land on that
147
+ // instance's root, never the shared epic root), so the MI body must call implement-cell rather than
148
+ // inline the agent task.
149
+ const xml = flat("plan-fanout.bpmn");
150
+ assert(
151
+ /<bpmn:callActivity[^>]*id="implement-cell-call"/.test(xml) &&
152
+ /<zeebe:calledElement processId="implement-cell"/.test(xml),
153
+ "plan-fanout must compose the per-wave implement step as a callActivity to implement-cell",
154
+ );
155
+ assert(!xml.includes('type="senior:feature"'), "plan-fanout must not inline the implement agent task");
156
+ });
157
+
158
+ test("contract: the per-slice branch.create rides the FLAT dotted variable the harness reads (#945)", () => {
159
+ // The emission channel is a process variable literally NAMED `io.nanobpm.agentTask.branch.create` —
160
+ // the dotted key the harness's `collectEnvelopeFrom` reassembles and deep-merges with the whole-object
161
+ // seed envelope. Pin the constant to that exact dotted name so a refactor can't silently move it to a
162
+ // nested object (a `<zeebe:ioMapping>` dotted target) the harness never reads.
163
+ assertEquals(AGENT_TASK_BRANCH_CREATE_VAR, "io.nanobpm.agentTask.branch.create");
164
+ assert(
165
+ AGENT_TASK_BRANCH_CREATE_VAR.startsWith("io.nanobpm.agentTask."),
166
+ "the per-slice branch.create variable must live under the reserved io.nanobpm.agentTask namespace",
167
+ );
168
+ });
@@ -22,6 +22,7 @@ import { hasAnswerableQuestion } from "./escalationTaxonomy.ts";
22
22
  import type { HeadPr } from "./github.ts";
23
23
  import { isClaimedCompletion } from "./implementEscalationReason.ts";
24
24
  import { parsePr } from "./prParse.ts";
25
+ import { sliceBranchCreate } from "./repoEnvelope.ts";
25
26
 
26
27
  /** The escalate-arm inputs the reconcile step reads from the implement-cell scope. `subjectKey` is the
27
28
  * cell's `owner/repo#N` subject (a feature run's `feature_key`, or a wave slice's epic `plan_key`) —
@@ -99,9 +100,14 @@ export function retryNudgeFor(status: string): string {
99
100
  return `\n\n---\n\n**Automatic retry — your previous attempt claimed completion but no adoptable pull request was found.** You reported status "${status}", yet a GitHub lookup confirmed there is no adoptable pull request for this slice on its base branch. Your work may NOT be lost: if you already pushed the \`feat/<task.id>\` branch, recover it — check the remote with \`git ls-remote --heads origin feat/<task.id>\`, and if a pull request exists but targets the WRONG base, retarget it onto this run's base branch with \`gh pr edit --base <base>\`. Otherwise you likely worked OUTSIDE your run workspace (e.g. \`cd /tmp\`), and those edits were discarded on teardown. This is your FINAL automatic retry; the next failure escalates to a human. Work ONLY inside your run workspace, then \`git commit -s\`, push \`feat/<task.id>\`, and open a PR on the correct base with \`gh pr create\`. Report \`status: "opened"\` with the \`pr\` key — not "${status}".`;
100
101
  }
101
102
 
102
- /** The cell's deterministic implement branch — `feat/<task.id>` (resources/prompts/feature.md). */
103
+ /** The cell's deterministic implement branch — `feat/<task.id>` (resources/prompts/feature.md).
104
+ * Delegates to the ONE canonical `sliceBranchCreate` (issue #945) so the reconcile lookup, the
105
+ * repository-envelope `branch.create` the harness cuts, and the prompt's branch instruction all share
106
+ * a single derivation — they can never drift into two branch-naming sources. A task id reaching the
107
+ * reconcile step is always a real id, so the derivation is never null here; the `?? `` fallback is
108
+ * defensive only (an implausible id would simply find no adoptable PR, the pre-existing behaviour). */
103
109
  export function implementCellBranch(taskId: string): string {
104
- return `feat/${taskId}`;
110
+ return sliceBranchCreate(taskId) ?? `feat/${taskId}`;
105
111
  }
106
112
 
107
113
  /** The adoptable PR from a head-branch listing: the first OPEN one (a merged/closed PR on the branch is