@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.
package/app/plan.test.ts CHANGED
@@ -244,10 +244,13 @@ test("startPlan pins the base branch: persisted on the row + seeded as baseBranc
244
244
  // Host-git provisioning (#684): the whole-epic repository envelope so each slice's implementation
245
245
  // agent gets an isolated clone. The epic seed checks out the BASE branch (`ref`) — each slice's
246
246
  // `feat/<task.id>` branch differs per MI child, so NO `branch.create` here (the agent branches).
247
- const repo = seen["io.nanobpm.agentTask"].repository;
247
+ const ns = seen["io.nanobpm.agentTask"];
248
+ const repo = ns.repository;
248
249
  assertEquals(repo.url, "https://github.com/owner/repo.git");
249
250
  assertEquals(repo.ref, "epic/agent-protocol");
250
- assertEquals("branch" in repo, false);
251
+ // No isolation branch on the epic seed — `branch` is a top-level sibling of `repository` (#882),
252
+ // and it is omitted entirely here.
253
+ assertEquals("branch" in ns, false);
251
254
  assertEquals(repo.singleBranch, true);
252
255
  assertEquals(repo.filter, "blob:none");
253
256
  });
@@ -0,0 +1,139 @@
1
+ // Contract test for the agent-task repository envelope (issue #882): the producer
2
+ // (`app/repoEnvelope.ts`) and the consumer (the c8ctl nano worker harness's
3
+ // `normalizeTaskEnvelope`, c8ctl-plugin.js) must agree on WHERE the deterministic
4
+ // `branch.create` lives. The harness reads it from the TOP LEVEL of the envelope
5
+ // (`raw.branch`), NOT nested under `repository`. The producer once nested it under
6
+ // `repository.branch` — a shape the consumer never reads — so `branch.create`
7
+ // silently never took effect (retries couldn't resume their WIP checkpoint, pushes
8
+ // were refused off the work branch; nanobpm/nano-bpm#1344). This is the
9
+ // two-sides-of-one-contract drift #227 guards against, so this test round-trips the
10
+ // emitted envelope through a faithful mirror of the harness's normalizer shape and
11
+ // asserts the consumer actually recovers `branch.create` — pinning producer and
12
+ // consumer together so they cannot drift again.
13
+ import { test } from "node:test";
14
+ import { assert, assertEquals } from "#test-assert";
15
+ import { agentNodeRepoEnvelope, flattenAgentTaskEnvelope, repoEnvelopeVars } from "./repoEnvelope.ts";
16
+
17
+ // ─── A faithful mirror of the harness's consumer shape ───────────────────────────────
18
+ // These mirror the EXACT semantics of c8ctl-plugin-nano `normalizeTaskEnvelope`
19
+ // (c8ctl-plugin.js): `collectEnvelopeFrom` reassembles the reserved
20
+ // `io.nanobpm.agentTask` namespace out of BOTH a whole-object/JSON value AND flattened
21
+ // dotpath keys, `deepMerge`s the two sources, and `normalizeTaskEnvelope` then reads
22
+ // `branch` from the TOP LEVEL of the merged envelope (`isPlainObject(raw.branch)`),
23
+ // NOT from `raw.repository.branch`. Keeping this mirror in lock-step with the harness
24
+ // is the point of the test: if the producer emits a shape the consumer doesn't read,
25
+ // the round-trip below drops `branch.create` and fails.
26
+ const AGENT_TASK_NS = "io.nanobpm.agentTask";
27
+
28
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
29
+ return v != null && typeof v === "object" && !Array.isArray(v);
30
+ }
31
+
32
+ function deepMerge(base: unknown, over: unknown): unknown {
33
+ if (!isPlainObject(base)) return isPlainObject(over) ? deepMerge({}, over) : over;
34
+ const out: Record<string, unknown> = { ...base };
35
+ if (!isPlainObject(over)) return out;
36
+ for (const [k, v] of Object.entries(over)) {
37
+ if (v === undefined) continue;
38
+ out[k] = isPlainObject(v) && isPlainObject(out[k]) ? deepMerge(out[k], v) : v;
39
+ }
40
+ return out;
41
+ }
42
+
43
+ function setPath(obj: Record<string, unknown>, path: string[], value: unknown): void {
44
+ let cur = obj;
45
+ for (let i = 0; i < path.length - 1; i++) {
46
+ const k = path[i];
47
+ if (!isPlainObject(cur[k])) cur[k] = {};
48
+ cur = cur[k] as Record<string, unknown>;
49
+ }
50
+ cur[path[path.length - 1]] = value;
51
+ }
52
+
53
+ /** Mirror of the harness's `collectEnvelopeFrom`: reassemble the reserved namespace from a
54
+ * whole-object/JSON value AND flattened dotpath keys (element templates / task headers emit the
55
+ * latter). */
56
+ function collectEnvelopeFrom(source: Record<string, unknown>): Record<string, unknown> {
57
+ const out: Record<string, unknown> = {};
58
+ if (!isPlainObject(source)) return out;
59
+ const whole = source[AGENT_TASK_NS];
60
+ if (whole != null) {
61
+ let val: unknown = whole;
62
+ if (typeof whole === "string") {
63
+ try {
64
+ val = JSON.parse(whole);
65
+ } catch {
66
+ val = undefined;
67
+ }
68
+ }
69
+ if (isPlainObject(val)) Object.assign(out, deepMerge(out, val));
70
+ }
71
+ const prefix = `${AGENT_TASK_NS}.`;
72
+ for (const [key, value] of Object.entries(source)) {
73
+ if (!key.startsWith(prefix)) continue;
74
+ const rest = key.slice(prefix.length);
75
+ if (!rest) continue;
76
+ setPath(out, rest.split("."), value);
77
+ }
78
+ return out;
79
+ }
80
+
81
+ /** Mirror of the harness's `normalizeTaskEnvelope` branch slice: it reads `branch` from the TOP
82
+ * LEVEL of the merged envelope — `isPlainObject(raw.branch) ? raw.branch : {}` — never from
83
+ * `raw.repository.branch`. Returns the consumer's recovered `branch.create` (undefined when the
84
+ * producer put it somewhere the consumer doesn't look). */
85
+ function harnessReadsBranchCreate(customHeaders: Record<string, unknown>, variables: Record<string, unknown>): string | undefined {
86
+ const raw = deepMerge(collectEnvelopeFrom(customHeaders), collectEnvelopeFrom(variables)) as Record<string, unknown>;
87
+ const branch = isPlainObject(raw.branch) ? raw.branch : {};
88
+ const create = branch.create;
89
+ return create == null ? undefined : String(create);
90
+ }
91
+
92
+ // ─── The round-trip contract ──────────────────────────────────────────────────────────
93
+
94
+ test("contract: repoEnvelopeVars emits branch.create where the harness reads it (envelope TOP LEVEL, not repository.branch) (#882)", () => {
95
+ // The pre-PR implementation path: a deterministic feat/<task.id> branch the harness must cut.
96
+ const vars = repoEnvelopeVars("owner/repo", "main", null, null, "feat/issue-7");
97
+ // Round-trip the emitted PROCESS-VARIABLE envelope through the harness normalizer shape.
98
+ const recovered = harnessReadsBranchCreate({}, vars);
99
+ assertEquals(
100
+ recovered,
101
+ "feat/issue-7",
102
+ "the harness reads branch.create from the envelope top level — nesting it under repository.branch drops it",
103
+ );
104
+ });
105
+
106
+ test("contract: repoEnvelopeVars PR path (ref == create == head) round-trips branch.create to the harness (#882/#231)", () => {
107
+ const vars = repoEnvelopeVars("owner/repo", "feat/issue-12", "main", null, "feat/issue-12");
108
+ assertEquals(harnessReadsBranchCreate({}, vars), "feat/issue-12");
109
+ });
110
+
111
+ test("contract: agentNodeRepoEnvelope round-trips the deterministic per-node branch through the harness (#882/#776)", () => {
112
+ const vars = agentNodeRepoEnvelope("acme/one", "main", "feat/n1");
113
+ assertEquals(harnessReadsBranchCreate({}, vars), "feat/n1");
114
+ });
115
+
116
+ test("contract: the FLATTENED per-cell task headers round-trip branch.create to the harness (#882/#739)", () => {
117
+ // The delivery-graph path delivers the envelope as FLAT dotted task headers, which the harness
118
+ // reassembles via collectEnvelopeFrom's dotpath branch. Round-trip the flattened headers through
119
+ // the same normalizer shape so the header channel can't drift from the consumer either.
120
+ const envelope = agentNodeRepoEnvelope("acme/one", "main", "feat/open-b");
121
+ const flat = flattenAgentTaskEnvelope(envelope);
122
+ // The flattened key must be the TOP-LEVEL `branch.create`, NOT `repository.branch.create`.
123
+ assert(
124
+ "io.nanobpm.agentTask.branch.create" in flat,
125
+ `expected a top-level io.nanobpm.agentTask.branch.create header, got ${JSON.stringify(Object.keys(flat))}`,
126
+ );
127
+ assert(
128
+ !("io.nanobpm.agentTask.repository.branch.create" in flat),
129
+ "branch.create must NOT be nested under repository — the harness never reads it there",
130
+ );
131
+ const recovered = harnessReadsBranchCreate(flat, {});
132
+ assertEquals(recovered, "feat/open-b", "the harness reassembles the flattened header into branch.create");
133
+ });
134
+
135
+ test("contract: an envelope with NO branch round-trips to undefined (the consumer's default), never a bogus value (#882)", () => {
136
+ // A blank branchCreate omits the key entirely; the harness must then see no branch.create.
137
+ const vars = repoEnvelopeVars("owner/repo", "main", null, null, null);
138
+ assertEquals(harnessReadsBranchCreate({}, vars), undefined);
139
+ });
@@ -187,19 +187,25 @@ export function repoEnvelopeVars(
187
187
  // omission — the harness then clones the head branch tip, the pre-#324 behaviour. Omitted too
188
188
  // when the PR has no durable push-checkpoint yet.
189
189
  ...(isCommitSha(commitSha) ? { sha: commitSha } : {}),
190
- // Pre-PR provisioning (issue #684): the implementation path (feature.bpmn / plan-fanout's
191
- // implement-cell) dispatches its agent BEFORE any PR exists, so `ref` is the BASE branch, not a
192
- // head. `branchCreate` asks the harness to cut the deterministic `feat/<task.id>` feature branch
193
- // off that base itself (the agent-guide's `feat/*` convention) instead of the agent branching by
194
- // hand — making the isolated clone land on the right branch deterministically across a resume.
195
- // The PR-based paths (review/fix-ci/rebase) ALSO pass `branchCreate = ref` (the PR head): without
196
- // it the harness's base-branch guard (jwulf/c8ctl-plugin-nano#231) cuts a throwaway
197
- // `nano/agent-work/*` fallback OFF the PR; with `ref == create == head` it commits/pushes to the
198
- // head branch. Only a blank/omitted value drops the key.
199
- ...(typeof branchCreate === "string" && branchCreate.trim() !== ""
200
- ? { branch: { create: branchCreate.trim() } }
201
- : {}),
202
190
  },
191
+ // Pre-PR provisioning (issue #684): the implementation path (feature.bpmn / plan-fanout's
192
+ // implement-cell) dispatches its agent BEFORE any PR exists, so `ref` is the BASE branch, not a
193
+ // head. `branchCreate` asks the harness to cut the deterministic `feat/<task.id>` feature branch
194
+ // off that base itself (the agent-guide's `feat/*` convention) instead of the agent branching by
195
+ // hand — making the isolated clone land on the right branch deterministically across a resume.
196
+ // The PR-based paths (review/fix-ci/rebase) ALSO pass `branchCreate = ref` (the PR head): without
197
+ // it the harness's base-branch guard (jwulf/c8ctl-plugin-nano#231) cuts a throwaway
198
+ // `nano/agent-work/*` fallback OFF the PR; with `ref == create == head` it commits/pushes to the
199
+ // head branch. Only a blank/omitted value drops the key.
200
+ //
201
+ // `branch` is a TOP-LEVEL sibling of `repository` (issue #882) — the harness's
202
+ // `normalizeTaskEnvelope` reads it from the envelope root (`isPlainObject(raw.branch)`), NOT from
203
+ // `repository.branch`. Nesting it under `repository` was the two-sides-of-one-contract drift
204
+ // (#227) that left `branch.create` silently unread (retries couldn't resume, pushes were
205
+ // refused — nanobpm/nano-bpm#1344).
206
+ ...(typeof branchCreate === "string" && branchCreate.trim() !== ""
207
+ ? { branch: { create: branchCreate.trim() } }
208
+ : {}),
203
209
  // Repo-provisioning auth gate (issue #770): c8ctl-plugin-nano (≥1.60.2) only resolves the
204
210
  // git credential (GITHUB_TOKEN, or the `gh` default when it's absent) for repository
205
211
  // provisioning when `task.allowPr` is truthy — `resolveJobSecrets` pulls the token behind that
@@ -251,10 +257,13 @@ export function agentNodeRepoEnvelope(repo: string, base: string | null, branchC
251
257
  // A per-node base (declared, or defaulted from the run level) — the branch the harness cuts its
252
258
  // `feat/<node.id>` off. Omitted when unknown so the harness clones the repo's default branch.
253
259
  ...(ref ? { ref, baseRef: ref } : {}),
254
- // The deterministic isolation branch the harness cuts (issue #776), so the agent can never be
255
- // left committing on the checked-out base branch. Off `ref` when known, else off the default tip.
256
- ...(create ? { branch: { create } } : {}),
257
260
  },
261
+ // The deterministic isolation branch the harness cuts (issue #776), so the agent can never be
262
+ // left committing on the checked-out base branch. Off `ref` when known, else off the default tip.
263
+ // `branch` is a TOP-LEVEL sibling of `repository` (issue #882) — the harness's
264
+ // `normalizeTaskEnvelope` reads it from the envelope root, NOT from `repository.branch`, so the
265
+ // flattened per-cell header is `io.nanobpm.agentTask.branch.create` (not `…repository.branch.create`).
266
+ ...(create ? { branch: { create } } : {}),
258
267
  // Repo-provisioning auth gate (issue #770): as in `repoEnvelopeVars`, a repo-backed cell must
259
268
  // opt the harness into git-credential resolution (`task.allowPr`) or the clone fails with
260
269
  // `unable to get password from user`. Emitted on every resolved cell (past the `owner/repo`
@@ -1415,27 +1415,30 @@ test("repoEnvelopeVars emits the push-checkpoint under the harness-read `sha` ke
1415
1415
  // to `ref`) so the harness commits/pushes to the PR head branch instead of cutting a throwaway
1416
1416
  // `nano/agent-work/*` fallback (jwulf/c8ctl-plugin-nano#231); see the PR-path test below.
1417
1417
  test("repoEnvelopeVars emits branch.create only for a non-blank pre-PR branch (#684)", () => {
1418
- const repo = (repoEnvelopeVars("owner/repo", "main", null, null, "feat/issue-7") as any)["io.nanobpm.agentTask"]
1419
- .repository;
1418
+ const envelope = repoEnvelopeVars("owner/repo", "main", null, null, "feat/issue-7") as any;
1419
+ const ns = envelope["io.nanobpm.agentTask"];
1420
+ const repo = ns.repository;
1420
1421
  assertEquals(repo.ref, "main", "the pre-PR envelope checks out the BASE branch as its ref");
1421
- assertEquals(repo.branch.create, "feat/issue-7", "the harness cuts the deterministic feature branch off the base");
1422
+ // `branch` is a TOP-LEVEL sibling of `repository` (issue #882) — the harness reads it from the
1423
+ // envelope root, NOT from `repository.branch`.
1424
+ assertEquals(ns.branch.create, "feat/issue-7", "the harness cuts the deterministic feature branch off the base");
1425
+ assertEquals("branch" in repo, false, "branch must NOT be nested under repository — the harness never reads it there");
1422
1426
  // Still branch-scoped and blobless like the PR-based envelope.
1423
1427
  assertEquals(repo.singleBranch, true);
1424
1428
  assertEquals(repo.filter, "blob:none");
1425
1429
  // A whitespace-tainted branch is trimmed; a blank/absent one omits the `branch` key entirely so the
1426
1430
  // PR-based paths (and any caller that doesn't pre-create a branch) are unaffected.
1427
1431
  assertEquals(
1428
- (repoEnvelopeVars("owner/repo", "main", null, null, " feat/issue-9 ") as any)["io.nanobpm.agentTask"].repository
1432
+ (repoEnvelopeVars("owner/repo", "main", null, null, " feat/issue-9 ") as any)["io.nanobpm.agentTask"]
1429
1433
  .branch.create,
1430
1434
  "feat/issue-9",
1431
1435
  );
1432
1436
  for (const blank of [null, undefined, "", " "]) {
1433
- const r = (repoEnvelopeVars("owner/repo", "main", null, null, blank as any) as any)["io.nanobpm.agentTask"]
1434
- .repository;
1437
+ const r = (repoEnvelopeVars("owner/repo", "main", null, null, blank as any) as any)["io.nanobpm.agentTask"];
1435
1438
  assertEquals("branch" in r, false, `expected no branch key for ${JSON.stringify(blank)}`);
1436
1439
  }
1437
1440
  // The default (4-arg) PR-based call never emits a branch.create.
1438
- assertEquals("branch" in (repoEnvelopeVars("owner/repo", "feat/x", "main") as any)["io.nanobpm.agentTask"].repository, false);
1441
+ assertEquals("branch" in (repoEnvelopeVars("owner/repo", "feat/x", "main") as any)["io.nanobpm.agentTask"], false);
1439
1442
  });
1440
1443
 
1441
1444
  // PR-path provisioning (jwulf/c8ctl-plugin-nano#231): the review-round / fix-ci / rebase call sites
@@ -1445,11 +1448,13 @@ test("repoEnvelopeVars emits branch.create only for a non-blank pre-PR branch (#
1445
1448
  // `ref == create == head` the harness does a no-op `checkout -B <head>` and commits/pushes to the PR
1446
1449
  // head branch itself.
1447
1450
  test("repoEnvelopeVars emits branch.create = head on the PR path (ref == create == head) (#231)", () => {
1448
- const repo = (repoEnvelopeVars("owner/repo", "feat/issue-12", "main", null, "feat/issue-12") as any)[
1451
+ const ns = (repoEnvelopeVars("owner/repo", "feat/issue-12", "main", null, "feat/issue-12") as any)[
1449
1452
  "io.nanobpm.agentTask"
1450
- ].repository;
1453
+ ];
1454
+ const repo = ns.repository;
1451
1455
  assertEquals(repo.ref, "feat/issue-12", "the PR-path envelope checks out the head branch");
1452
- assertEquals(repo.branch.create, "feat/issue-12", "and asks the harness to work on that same head branch, not a fallback");
1456
+ // `branch` is a TOP-LEVEL sibling of `repository` (issue #882), read by the harness from the root.
1457
+ assertEquals(ns.branch.create, "feat/issue-12", "and asks the harness to work on that same head branch, not a fallback");
1453
1458
  // Still branch-scoped and blobless, and the base tip stays reachable for the 3-dot diff.
1454
1459
  assertEquals(repo.singleBranch, true);
1455
1460
  assertEquals(repo.filter, "blob:none");
@@ -1496,9 +1501,9 @@ test("submitPr threads the resolved PR head into the envelope branch.create (#23
1496
1501
  url: "https://github.com/owner/repo/pull/12",
1497
1502
  prKey: "owner/repo#12",
1498
1503
  });
1499
- const repo = (get()?.["io.nanobpm.agentTask"] as any)?.repository;
1500
- assertEquals(repo?.ref, "feat/issue-12", "submitPr checks out the PR head branch");
1501
- assertEquals(repo?.branch?.create, "feat/issue-12", "submitPr passes headRef as branch.create, not a fallback");
1504
+ const ns = get()?.["io.nanobpm.agentTask"] as any;
1505
+ assertEquals(ns?.repository?.ref, "feat/issue-12", "submitPr checks out the PR head branch");
1506
+ assertEquals(ns?.branch?.create, "feat/issue-12", "submitPr passes headRef as branch.create, not a fallback");
1502
1507
  });
1503
1508
  });
1504
1509
 
@@ -1521,9 +1526,9 @@ test("startMerge threads the resolved PR head into the envelope branch.create (#
1521
1526
  prKey: "owner/repo#13",
1522
1527
  round: 2,
1523
1528
  });
1524
- const repo = (get()?.["io.nanobpm.agentTask"] as any)?.repository;
1525
- assertEquals(repo?.ref, "feat/issue-13", "startMerge checks out the PR head branch");
1526
- assertEquals(repo?.branch?.create, "feat/issue-13", "startMerge passes headRef as branch.create, not a fallback");
1529
+ const ns = get()?.["io.nanobpm.agentTask"] as any;
1530
+ assertEquals(ns?.repository?.ref, "feat/issue-13", "startMerge checks out the PR head branch");
1531
+ assertEquals(ns?.branch?.create, "feat/issue-13", "startMerge passes headRef as branch.create, not a fallback");
1527
1532
  });
1528
1533
  });
1529
1534
 
@@ -133,11 +133,11 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
133
133
  // carries — proof the implement-cell agent gets an ISOLATED clone (base branch + a harness-cut
134
134
  // `feat/<task.id>`) instead of inheriting the worker's launch dir. It rides the process variable
135
135
  // all the way down through feature.bpmn's `implement` callActivity into the cell's agent job.
136
- let agentRepo: any = null;
136
+ let agentTask: any = null;
137
137
  await withApp(
138
138
  {
139
139
  "senior:feature": (job) => {
140
- agentRepo = (job.variables as Record<string, any>)["io.nanobpm.agentTask"]?.repository ?? null;
140
+ agentTask = (job.variables as Record<string, any>)["io.nanobpm.agentTask"] ?? null;
141
141
  return { status: "opened", pr: "owner/repo#101", summary: "built it" };
142
142
  },
143
143
  },
@@ -150,10 +150,12 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
150
150
  );
151
151
  assert.ok(!flows.includes("gw-converge->converge"), "the converge hand-off branch was NOT taken");
152
152
 
153
- assert.ok(agentRepo, "the senior:feature job carried the io.nanobpm.agentTask repository envelope");
153
+ assert.ok(agentTask, "the senior:feature job carried the io.nanobpm.agentTask repository envelope");
154
+ const agentRepo = agentTask.repository;
154
155
  assert.equal(agentRepo.url, "https://github.com/owner/repo.git", "the harness clones the target repo");
155
156
  assert.equal(agentRepo.ref, "epic/e2e", "a PRE-PR job checks out the BASE branch, not a head");
156
- assert.equal(agentRepo.branch.create, "feat/issue-7", "the harness cuts the deterministic feature branch");
157
+ // `branch` is a TOP-LEVEL sibling of `repository` (issue #882) — the harness reads it from the root.
158
+ assert.equal(agentTask.branch.create, "feat/issue-7", "the harness cuts the deterministic feature branch");
157
159
 
158
160
  const run = await featureRow(app, featureKey);
159
161
  assert.equal(run.status, "opened", "the run settled at opened");
@@ -131,22 +131,24 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
131
131
  // rides down through the wave subprocess + `implement-cell-call` callActivity into each agent job.
132
132
  // The epic seed carries the BASE branch as `ref` but NO `branch.create` — each slice's
133
133
  // `feat/<task.id>` differs per MI child, so the agent cuts its own branch inside the clone.
134
- let agentRepo: any = null;
134
+ let agentTask: any = null;
135
135
  await withApp(
136
136
  {
137
137
  "senior:plan": singleTaskPlan,
138
138
  "senior:plan-review": approveReview,
139
139
  "senior:feature": (job) => {
140
- agentRepo = (job.variables as Record<string, any>)["io.nanobpm.agentTask"]?.repository ?? null;
140
+ agentTask = (job.variables as Record<string, any>)["io.nanobpm.agentTask"] ?? null;
141
141
  return { status: "blocked", summary: "n/a" };
142
142
  },
143
143
  },
144
144
  async ({ app }) => {
145
145
  await app.settle();
146
- assert.ok(agentRepo, "the epic slice's senior:feature job carried the io.nanobpm.agentTask envelope");
146
+ assert.ok(agentTask, "the epic slice's senior:feature job carried the io.nanobpm.agentTask envelope");
147
+ const agentRepo = agentTask.repository;
147
148
  assert.equal(agentRepo.url, "https://github.com/owner/repo.git", "the harness clones the target repo");
148
149
  assert.equal(agentRepo.ref, "epic/e2e", "the epic seed checks out the BASE (integration) branch");
149
- assert.equal("branch" in agentRepo, false, "the epic seed omits branch.create — the agent branches per slice");
150
+ // `branch` is a top-level sibling of `repository` (#882); the epic seed omits it entirely.
151
+ assert.equal("branch" in agentTask, false, "the epic seed omits branch.create — the agent branches per slice");
150
152
  assert.equal(agentRepo.singleBranch, true, "the blobless/single-branch monorepo shaping rides along");
151
153
  assert.equal(agentRepo.filter, "blob:none");
152
154
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.201.2",
3
+ "version": "0.201.4",
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",
@@ -37,6 +37,7 @@
37
37
  "typecheck": "tsc --noEmit",
38
38
  "pretypecheck": "urban gen",
39
39
  "check:prompts": "node --experimental-strip-types scripts/check-agent-prompts.ts",
40
+ "check:agent-definitions": "node --experimental-strip-types scripts/check-agent-definitions.ts",
40
41
  "check:migrations": "node --experimental-strip-types scripts/check-migrations.ts",
41
42
  "heal:migrations": "node --experimental-strip-types scripts/heal-migration-ledger.ts",
42
43
  "check:contracts": "node --experimental-strip-types scripts/check-contracts.ts",
@@ -0,0 +1,126 @@
1
+ // Red/green coverage for the agent-definition repo-wide guard (scripts/check-agent-definitions.ts,
2
+ // issue #884). The gate fails the build when any `senior:*` agent service task — hand-authored in
3
+ // `resources/processes/*.bpmn` OR produced by the delivery-graph compiler — lacks the engine-native
4
+ // `<zeebe:agentDefinition agentType="external" />` marker (the omission that left every delivery-graph
5
+ // agent run without a durable transcript). These cases assert the scanner wiring flags an unmarked
6
+ // prompt-bearing task and that the gate's compiled-output half sees a real compiled agent cell.
7
+ import { readFileSync } from "node:fs";
8
+ import { test } from "node:test";
9
+ import { dirname, join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { assert, assertEquals } from "#test-assert";
12
+ import {
13
+ agentTaskTypesMissingExternalMarker,
14
+ externalAgentTaskTypesMissingMarker,
15
+ } from "../app/agentic/vocab/job-types.ts";
16
+ import { checkAuthoredProcesses, checkCompiledOutput } from "./check-agent-definitions.ts";
17
+
18
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
19
+
20
+ test("check-agent-definitions: the canonical scanner flags an unmarked prompt-bearing senior:* task", () => {
21
+ // The gate's red path rests entirely on the canonical scanner — pin that wiring here so the gate
22
+ // can never rot into a vacuous green (a scanner that stopped flagging would pass every corpus).
23
+ const xml = `
24
+ <bpmn:serviceTask id="agent">
25
+ <bpmn:extensionElements>
26
+ <zeebe:taskDefinition type="senior:feature" />
27
+ <zeebe:linkedResources>
28
+ <zeebe:linkedResource resourceId="prompts/feature.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
29
+ </zeebe:linkedResources>
30
+ </bpmn:extensionElements>
31
+ </bpmn:serviceTask>`;
32
+ assertEquals(agentTaskTypesMissingExternalMarker(xml), ["senior:feature"]);
33
+ });
34
+
35
+ test("check-agent-definitions: the hand-authored corpus passes (every senior:* task is marked)", () => {
36
+ const r = checkAuthoredProcesses();
37
+ assert(r.ok, `expected the authored corpus to pass, got ${JSON.stringify(r.errors)}`);
38
+ // Sanity: the corpus really does declare agent tasks (the guard is not vacuously green).
39
+ assert(r.agentTypes.length > 0, "the authored corpus declares prompt-bearing senior:* agent tasks");
40
+ assert(r.agentTypes.includes("senior:feature"), "senior:feature is among the scanned agent types");
41
+ });
42
+
43
+ test("check-agent-definitions: the compiled-output half sees a real compiled agent cell and finds it marked", async () => {
44
+ const r = await checkCompiledOutput();
45
+ assert(r.ok, `expected the compiled output to pass, got ${JSON.stringify(r.errors)}`);
46
+ // Every compiled agent cell is prompt-LESS (Option (a)), so it is the type-keyed half of the union
47
+ // scanner (keyed by the `<rank>:<task>` colon-form type) that sees them. The probe's agent types are
48
+ // derived by that same grammar — assert the cells really compiled (the scan is not vacuously green).
49
+ assert(r.agentTypes.length > 0, "the compiled probe declares senior:* agent cells");
50
+ assert(r.agentTypes.includes("senior:feature"), "the compiled senior:feature cell is scanned");
51
+ });
52
+
53
+ test("check-agent-definitions: the compiled probe includes a prompt-LESS agent cell, and it is marked", async () => {
54
+ // Every compiled agent cell is deliberately prompt-less (the resolved #884 "Decide", Option (a)), so
55
+ // the prompt-keyed scanner never sees it — the gate must probe one and assert its marker with the
56
+ // type-keyed scan, or a compiler regression that drops the marker on exactly those cells reports ok
57
+ // (the false-negative path from the PR #886 review).
58
+ const r = await checkCompiledOutput();
59
+ assert(r.ok, `expected the compiled output to pass, got ${JSON.stringify(r.errors)}`);
60
+ assert(
61
+ r.agentTypes.includes("senior:bespoke-promptless"),
62
+ `the compiled probe includes a prompt-less agent cell, got [${r.agentTypes.join(", ")}]`,
63
+ );
64
+ });
65
+
66
+ test("check-agent-definitions: the type-keyed scanner flags a prompt-less compiled cell that lost its marker", () => {
67
+ // Pin the false-negative shape the review cited: a prompt-less agent task with NO marker. The
68
+ // prompt-keyed scan reports nothing (it cannot see the block); the type-keyed scan must flag it.
69
+ const xml = `
70
+ <bpmn:serviceTask id="n0_task">
71
+ <bpmn:extensionElements>
72
+ <zeebe:taskDefinition type="senior:bespoke-promptless" />
73
+ </bpmn:extensionElements>
74
+ </bpmn:serviceTask>`;
75
+ assertEquals(agentTaskTypesMissingExternalMarker(xml), []);
76
+ assertEquals(externalAgentTaskTypesMissingMarker(xml), ["senior:bespoke-promptless"]);
77
+ });
78
+
79
+ test("check-agent-definitions: the authored half unions BOTH scanners — a prompt-less + marker-less senior:* task fails", () => {
80
+ // The authored-process half must hold the SAME "every senior:* task carries the marker" invariant
81
+ // the compiled half does (PR #886 review). A hand-authored senior:* task that loses BOTH its prompt
82
+ // link and its marker is invisible to the prompt-keyed scanner, so a prompt-keyed-only authored gate
83
+ // reports ok — the false-negative the compiled half already closes. Inject a doc with exactly that
84
+ // shape and assert the gate flags it (via the type-keyed half of the union).
85
+ const bad = `
86
+ <bpmn:serviceTask id="agent">
87
+ <bpmn:extensionElements>
88
+ <zeebe:taskDefinition type="senior:bespoke-promptless" />
89
+ </bpmn:extensionElements>
90
+ </bpmn:serviceTask>`;
91
+ const r = checkAuthoredProcesses([{ file: "bad.bpmn", xml: bad }]);
92
+ assert(!r.ok, "a prompt-less + marker-less senior:* authored task must fail the gate");
93
+ assert(
94
+ r.errors.some((e) => e.includes("bad.bpmn") && e.includes("senior:bespoke-promptless")),
95
+ `the error names the unmarked prompt-less task, got ${JSON.stringify(r.errors)}`,
96
+ );
97
+ });
98
+
99
+ test("check-agent-definitions: the authored half still flags a prompt-BEARING task that lost its marker", () => {
100
+ const bad = `
101
+ <bpmn:serviceTask id="agent">
102
+ <bpmn:extensionElements>
103
+ <zeebe:taskDefinition type="senior:feature" />
104
+ <zeebe:linkedResources>
105
+ <zeebe:linkedResource resourceId="prompts/feature.md" linkName="prompt" />
106
+ </zeebe:linkedResources>
107
+ </bpmn:extensionElements>
108
+ </bpmn:serviceTask>`;
109
+ const r = checkAuthoredProcesses([{ file: "bad.bpmn", xml: bad }]);
110
+ assert(!r.ok, "a prompt-bearing senior:* authored task without a marker must fail the gate");
111
+ assert(
112
+ r.errors.some((e) => e.includes("bad.bpmn") && e.includes("senior:feature")),
113
+ `the error names the unmarked prompt-bearing task, got ${JSON.stringify(r.errors)}`,
114
+ );
115
+ });
116
+
117
+ test("check-agent-definitions: AGENTS.md documents the check:agent-definitions CI gate", () => {
118
+ // Advisory (PR #886 review): adding the `check:agent-definitions` CI command left the contributor
119
+ // guide's "Runtime & CI gates" list stale — AGENTS.md tells contributors how to match CI locally
120
+ // but did not name the new gate. Pin the doc so the gate cannot silently drop out of the guide.
121
+ const agents = readFileSync(join(ROOT, "AGENTS.md"), "utf8");
122
+ assert(
123
+ agents.includes("check:agent-definitions"),
124
+ "AGENTS.md must document the check:agent-definitions gate in its Runtime & CI gates list",
125
+ );
126
+ });