@nanobpm/nano-workforce 0.44.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -51,3 +51,10 @@ jobs:
51
51
  # TypeScript types on the fly (Node >= 22.6) — no build step.
52
52
  - name: Test (Node)
53
53
  run: npm test
54
+
55
+ # End-to-end pilot (nano-ide issue #157, S3): boots the whole app in-process against the WASM
56
+ # engine + a virtual clock via @nanobpm/urban-testkit and drives the real /app/api operations.
57
+ # Hermetic (no socket, no GitHub network), so it runs on every push like the unit suite.
58
+ - name: E2E (urban-testkit)
59
+ run: npm run e2e
60
+
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [0.45.0](https://github.com/nanobpm/nano-workforce/compare/v0.44.1...v0.45.0) (2026-08-12)
2
+
3
+
4
+ ### Features
5
+
6
+ * **plan:** pin an epic's base branch so the fleet lands on an integration branch ([#125](https://github.com/nanobpm/nano-workforce/issues/125)) ([1c3bfa1](https://github.com/nanobpm/nano-workforce/commit/1c3bfa1a1dbc212e31e510931766f46904dae1a3)), closes [#124](https://github.com/nanobpm/nano-workforce/issues/124) [nanobpm/nano-workforce#124](https://github.com/nanobpm/nano-workforce/issues/124)
7
+
8
+ ## [0.44.1](https://github.com/nanobpm/nano-workforce/compare/v0.44.0...v0.44.1) (2026-08-11)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * provision isolated agent workspaces via the c8ctl repository envelope ([#124](https://github.com/nanobpm/nano-workforce/issues/124)) ([79c999b](https://github.com/nanobpm/nano-workforce/commit/79c999bc34dd7984ceffd7746a4538705565f2a5))
14
+
1
15
  # [0.44.0](https://github.com/nanobpm/nano-workforce/compare/v0.43.0...v0.44.0) (2026-08-11)
2
16
 
3
17
 
package/SPEC.md CHANGED
@@ -173,7 +173,7 @@ triaging, editing/replying/pushing, and (when `addressed`) re-requesting review.
173
173
  Workspace isolation is the **worker harness's** responsibility, not this app's and
174
174
  not the prompt's. The `c8ctl nano work` host-git provisioning (frozen v1 envelope)
175
175
  gives **each job its own `mkdtemp` run-dir + fresh clone**, runs the agent with
176
- `cwd` set to it (`AGENT_WORKSPACE`/`REPO_URL`/`REPO_BRANCH`/`REPO_REF` env), and
176
+ `cwd` set to it (`AGENT_WORKSPACE`/`AGENT_REPO_URL`/`AGENT_REPO_BRANCH`/`AGENT_REPO_REF` env), and
177
177
  **reaps that run-dir when the job ends**. So multiple agents on one host do **not**
178
178
  collide even in host mode — the isolation lives below the agent.
179
179
 
@@ -183,8 +183,13 @@ Consequences the prompt (`prompts/review-round.md`) encodes:
183
183
  - The agent **cleans up anything it creates outside the commit** before returning
184
184
  (worktrees, scratch branches/clones, temp files), so host mode does not leak.
185
185
  - The harness checks out the PR's **existing head branch** and pushes back to it
186
- (no new branch/PR). The `c8ctl` integration provisions the repo and resolves the
187
- head branch from `prNumber`/`prUrl` the app does not pass a `headBranch` var.
186
+ (no new branch/PR). Provisioning only fires when the job carries a
187
+ `io.nanobpm.agentTask.repository.url`; the **app** supplies it plus the head
188
+ branch as `…repository.ref` — as a process variable at `createInstance`
189
+ (`repoEnvelopeVars` in `app/service.ts`, resolving the head via `fetchPrMeta`/
190
+ `fetchPrHead`). The harness is PR-agnostic: it does **not** derive the head branch
191
+ from `prNumber`/`prUrl`. When the head can't be resolved the envelope is omitted and
192
+ the agent falls back to the worker's launch directory (the legacy behavior).
188
193
 
189
194
  ## 6. Signals
190
195
 
@@ -510,9 +515,10 @@ the loop runs one parallel `implement` MI fan-out per wave:
510
515
 
511
516
  - **Provisioning the existing PR branch** — resolved: the `c8ctl` host-git
512
517
  integration provisions the repo and checks out the PR's head branch (it must
513
- already give the worker repo access to work at all), resolving the branch from
514
- `prNumber`/`prUrl`. The app does **not** pass a `headBranch` job variable; the
515
- job stays engine-shaped and the worker stays a pure provisioner.
518
+ already give the worker repo access to work at all). The **app** resolves the head
519
+ branch and passes it in the `io.nanobpm.agentTask.repository.{url,ref}` envelope
520
+ (a `createInstance` process variable see `repoEnvelopeVars`); the harness is
521
+ PR-agnostic and provisions from that envelope. The worker stays a pure provisioner.
516
522
  - **review-ready via GitHub webhook** — same message, swappable faster trigger,
517
523
  when the app is publicly reachable. Deferred (poller-only for v1).
518
524
  - **Supervised vs external worker** — the agent runs as an external
package/app/github.ts CHANGED
@@ -177,6 +177,10 @@ async function useGh(): Promise<boolean> {
177
177
  export interface PrMeta {
178
178
  title: string | null;
179
179
  body: string;
180
+ /** The PR's head branch name (e.g. `feat/issue-12`). Drives the c8ctl harness's isolated
181
+ * workspace checkout (`io.nanobpm.agentTask.repository.ref`) so the review agent lands on the
182
+ * PR branch instead of the worker's launch directory. `null` when GitHub doesn't return it. */
183
+ headRef: string | null;
180
184
  }
181
185
 
182
186
  export async function fetchPrMeta(
@@ -185,10 +189,10 @@ export async function fetchPrMeta(
185
189
  token: string,
186
190
  ): Promise<PrMeta | null> {
187
191
  if (await useGh()) {
188
- const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "title,body"]);
192
+ const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "title,body,headRefName"]);
189
193
  // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
190
- const j = JSON.parse(out) as { title?: string; body?: string };
191
- return { title: j.title ?? null, body: j.body ?? "" };
194
+ const j = JSON.parse(out) as { title?: string; body?: string; headRefName?: string | null };
195
+ return { title: j.title ?? null, body: j.body ?? "", headRef: j.headRefName ?? null };
192
196
  }
193
197
  if (!token) return null;
194
198
  const r = await fetch(`https://api.github.com/repos/${repo}/pulls/${number}`, {
@@ -196,8 +200,8 @@ export async function fetchPrMeta(
196
200
  });
197
201
  if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
198
202
  // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
199
- const j = (await r.json()) as { title?: string; body?: string };
200
- return { title: j.title ?? null, body: j.body ?? "" };
203
+ const j = (await r.json()) as { title?: string; body?: string; head?: { ref?: string | null } };
204
+ return { title: j.title ?? null, body: j.body ?? "", headRef: j.head?.ref ?? null };
201
205
  }
202
206
 
203
207
  /** A PR's merge state, narrowed to what the merge poller needs to classify landability.
@@ -57,6 +57,18 @@ test("escalation arm without the flag still records the round", async () => {
57
57
  assertEquals((inserts.rounds[0] as any).round_no, 3);
58
58
  });
59
59
 
60
+ // The servicing worker name (harness `agent` var) is stamped on both the round it recorded and
61
+ // the escalation it opened, so the durable history identifies who did the work.
62
+ test("persist-escalation records the servicing worker on the round and escalation", async () => {
63
+ const { app, inserts } = fakeApp();
64
+ const job = {
65
+ variables: { prKey: "o/r#1", round: 3, status: "blocked", question: "max rounds", agent: "senior" },
66
+ };
67
+ await handler(job as any, app as any);
68
+ assertEquals((inserts.rounds[0] as any).worker, "senior", "the round carries the worker name");
69
+ assertEquals((inserts.escalations[0] as any).worker, "senior", "the escalation carries the worker name");
70
+ });
71
+
60
72
  // When the convergence-loop passes repo/prNumber and the FK parent is missing (engine/app.db
61
73
  // desync), persist-escalation reconstructs the `pull_requests` row before the rounds/escalations
62
74
  // inserts so opening an escalation never dies with an opaque FOREIGN KEY constraint failure.
@@ -56,6 +56,23 @@ for (const status of ["addressed", "waiting"]) {
56
56
  });
57
57
  }
58
58
 
59
+ // The harness completes each agent job with `agent` (its profile name); persist-round records it
60
+ // on the round so a human can identify the servicing worker from the durable history.
61
+ test("persist-round records the servicing worker name from the agent variable", async () => {
62
+ const { app, inserts } = fakeApp();
63
+ const job = { variables: { prKey: "o/r#1", round: 1, status: "addressed", agent: "senior" } };
64
+ await handler(job as any, app as any);
65
+ assertEquals((inserts.rounds[0] as any).worker, "senior", "the round carries the worker name");
66
+ });
67
+
68
+ // A blank/absent agent name leaves the nullable column NULL (the write boundary omits undefined).
69
+ test("persist-round leaves worker undefined when the agent name is blank", async () => {
70
+ const { app, inserts } = fakeApp();
71
+ const job = { variables: { prKey: "o/r#1", round: 1, status: "addressed", agent: " " } };
72
+ await handler(job as any, app as any);
73
+ assertEquals((inserts.rounds[0] as any).worker, undefined, "blank worker -> NULL column");
74
+ });
75
+
59
76
  // When the convergence-loop passes repo/prNumber and the FK parent is missing (engine/app.db
60
77
  // desync), persist-round reconstructs the `pull_requests` row before recording the round so the
61
78
  // insert never dies with an opaque FOREIGN KEY constraint failure.
package/app/plan.test.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // planner could revise forever. `positiveIntEnv` must fall back to the default on any value that
6
6
  // is not a positive integer, so the loop is always bounded.
7
7
  import { test } from "node:test";
8
- import { assertEquals } from "#test-assert";
8
+ import { assertEquals, assertThrows } from "#test-assert";
9
9
  import { positiveIntEnv } from "./plan.ts";
10
10
 
11
11
  const KEY = "NANO_PLAN_REVIEW_ROUNDS_TEST";
@@ -292,3 +292,121 @@ test("answerTaskEscalation is a no-op when no open escalation matches the correl
292
292
  const r = await answerTaskEscalation(data, engine, "owner/repo#9:missing", "x");
293
293
  assertEquals(r.ok, false);
294
294
  });
295
+
296
+ // Coverage for the epic base-branch control (issue nano-ide #124 / 019_plan_base_branch.sql).
297
+ //
298
+ // A plan may pin a base branch so the fleet branches off — and opens every PR against — a long-lived
299
+ // integration branch instead of the repo default, keeping an epic off the default branch (and off any
300
+ // merge-to-default side effect such as auto-publishing) until the integration branch is deliberately
301
+ // merged. `normalizeBaseBranch` decides "unset" (fall back to default), `renderBaseBranchBrief` is the
302
+ // authoritative prompt override, and `startPlan` must persist the branch and seed BOTH the `baseBranch`
303
+ // variable and the `baseBranchBrief` (which rides `appendPrompt`) — or leave them null when unpinned.
304
+ import { InvalidBaseBranchError, normalizeBaseBranch, renderBaseBranchBrief } from "./plan.ts";
305
+
306
+ test("normalizeBaseBranch: blank/whitespace/undefined → null; a real branch is trimmed", () => {
307
+ assertEquals(normalizeBaseBranch(undefined), null);
308
+ assertEquals(normalizeBaseBranch(null), null);
309
+ assertEquals(normalizeBaseBranch(""), null);
310
+ assertEquals(normalizeBaseBranch(" "), null);
311
+ assertEquals(normalizeBaseBranch(" epic/agent-protocol "), "epic/agent-protocol");
312
+ });
313
+
314
+ test("normalizeBaseBranch: accepts conservative git-branch shapes", () => {
315
+ assertEquals(normalizeBaseBranch("main"), "main");
316
+ assertEquals(normalizeBaseBranch("release-1.2"), "release-1.2");
317
+ assertEquals(normalizeBaseBranch("feature/x_y.z"), "feature/x_y.z");
318
+ });
319
+
320
+ test("normalizeBaseBranch: rejects injection-prone / implausible branch names", () => {
321
+ // `baseBranch` is interpolated into an authoritative agent prompt that carries shell
322
+ // commands, so anything that isn't a plausible git ref must be rejected at the edge —
323
+ // not silently rendered into `git`/`gh` snippets or the prompt Markdown.
324
+ const bad = [
325
+ "foo bar", // whitespace
326
+ "-rf", // leading dash → looks like a CLI flag
327
+ "foo; rm -rf /", // shell metacharacters
328
+ "foo`whoami`", // command substitution
329
+ "foo$(id)", // command substitution
330
+ "foo\nbar", // newline → breaks rendered instructions
331
+ "foo..bar", // git-illegal double dot
332
+ "/foo", // leading slash
333
+ "foo/", // trailing slash
334
+ "foo.", // trailing dot
335
+ "foo//bar", // empty path component
336
+ "foo.lock", // git-reserved .lock suffix
337
+ "épée", // outside the conservative allowlist
338
+ ];
339
+ for (const value of bad) {
340
+ assertThrows(() => normalizeBaseBranch(value), InvalidBaseBranchError);
341
+ }
342
+ });
343
+
344
+ test("renderBaseBranchBrief names the branch in every instruction (branch-off, read, PR base)", () => {
345
+ const brief = renderBaseBranchBrief("epic/agent-protocol");
346
+ // Authoritative marker so it overrides the static "default branch" wording.
347
+ assertEquals(brief.includes("authoritative"), true);
348
+ assertEquals(brief.includes("git checkout -b feat/<task.id> origin/epic/agent-protocol"), true);
349
+ assertEquals(brief.includes("gh pr create --base epic/agent-protocol"), true);
350
+ });
351
+
352
+ test("startPlan pins the base branch: persisted on the row + seeded as baseBranch/baseBranchBrief variables", async () => {
353
+ const PLAN_KEY = "owner/repo#124";
354
+ const stores: Record<string, { rows: any[]; key: string }> = {
355
+ plans: { rows: [], key: "plan_key" },
356
+ plan_tasks: { rows: [], key: "id" },
357
+ plan_reviews: { rows: [], key: "plan_key" },
358
+ plan_escalations: { rows: [], key: "id" },
359
+ plan_task_deps: { rows: [], key: "plan_key" },
360
+ };
361
+ const data = memData(stores);
362
+ let seen: any = null;
363
+ const engine = {
364
+ createInstance: (req: any) => {
365
+ seen = req.variables;
366
+ return Promise.resolve({ processInstanceKey: "PI-1" });
367
+ },
368
+ } as any;
369
+
370
+ await startPlan(
371
+ data,
372
+ engine,
373
+ { repo: "owner/repo", number: 124, url: "https://github.com/owner/repo/issues/124", planKey: PLAN_KEY },
374
+ " epic/agent-protocol ",
375
+ );
376
+
377
+ // Persisted (trimmed) on the plan row for the epic UI + resume.
378
+ assertEquals((stores.plans.rows[0] as any).base_branch, "epic/agent-protocol");
379
+ // Process variables the implement-task consumes.
380
+ assertEquals(seen.baseBranch, "epic/agent-protocol");
381
+ assertEquals(seen.baseBranchBrief.includes("gh pr create --base epic/agent-protocol"), true);
382
+ });
383
+
384
+ test("startPlan without a base branch keeps default-branch behaviour (null row + null variables)", async () => {
385
+ const PLAN_KEY = "owner/repo#200";
386
+ const stores: Record<string, { rows: any[]; key: string }> = {
387
+ plans: { rows: [], key: "plan_key" },
388
+ plan_tasks: { rows: [], key: "id" },
389
+ plan_reviews: { rows: [], key: "plan_key" },
390
+ plan_escalations: { rows: [], key: "id" },
391
+ plan_task_deps: { rows: [], key: "plan_key" },
392
+ };
393
+ const data = memData(stores);
394
+ let seen: any = null;
395
+ const engine = {
396
+ createInstance: (req: any) => {
397
+ seen = req.variables;
398
+ return Promise.resolve({ processInstanceKey: "PI-2" });
399
+ },
400
+ } as any;
401
+
402
+ await startPlan(data, engine, {
403
+ repo: "owner/repo",
404
+ number: 200,
405
+ url: "https://github.com/owner/repo/issues/200",
406
+ planKey: PLAN_KEY,
407
+ });
408
+
409
+ assertEquals((stores.plans.rows[0] as any).base_branch, null);
410
+ assertEquals(seen.baseBranch, null);
411
+ assertEquals(seen.baseBranchBrief, null);
412
+ });
package/app/plan.ts CHANGED
@@ -51,6 +51,10 @@ export interface Plan {
51
51
  // Minted at plan start; baked into the blackboard URL handed to implementer agents. NULL for
52
52
  // plans created before the blackboard shipped.
53
53
  blackboard_token: string | null;
54
+ // Optional target base branch (019_plan_base_branch.sql): when set, the fleet branches off this
55
+ // branch and opens every task PR against it instead of the repository's default branch, landing
56
+ // the whole epic on a long-lived integration branch. NULL keeps the default-branch behaviour.
57
+ base_branch: string | null;
54
58
  created_at: string;
55
59
  updated_at: string;
56
60
  }
@@ -185,14 +189,79 @@ export function parseIssue(input: string): ParsedIssue | null {
185
189
  return null;
186
190
  }
187
191
 
192
+ /** Raised when a caller supplies a `baseBranch` that isn't a plausible git branch name. The
193
+ * value is interpolated into the authoritative implementer prompt (which carries `git`/`gh`
194
+ * shell snippets and inline-code Markdown), so a non-ref value could break the rendered
195
+ * instructions or smuggle in a command/prompt fragment — reject it at the edge instead. */
196
+ export class InvalidBaseBranchError extends Error {
197
+ readonly value: string;
198
+ constructor(value: string) {
199
+ super(`invalid base branch name: ${JSON.stringify(value)}`);
200
+ this.name = "InvalidBaseBranchError";
201
+ this.value = value;
202
+ }
203
+ }
204
+
205
+ /** Conservative allowlist gate for a base-branch name. Stricter than `git check-ref-format` on
206
+ * purpose: only `[A-Za-z0-9._/-]`, no leading `/`/`.`/`-` (a leading dash reads as a CLI flag),
207
+ * no trailing `/`/`.`, no `..`/`//`, no empty or `.lock`-suffixed path component, bounded length.
208
+ * This rejects whitespace, shell metacharacters, command substitution, and newlines outright. */
209
+ function isPlausibleBranchName(s: string): boolean {
210
+ if (s.length === 0 || s.length > 255) return false;
211
+ if (!/^[A-Za-z0-9._/-]+$/.test(s)) return false;
212
+ if (/^[/.-]/.test(s) || /[/.]$/.test(s)) return false;
213
+ if (s.includes("..") || s.includes("//")) return false;
214
+ return s.split("/").every((seg) => seg.length > 0 && !seg.startsWith(".") && !seg.endsWith(".lock"));
215
+ }
216
+
217
+ /** Normalise a caller-supplied base branch: trim, and treat blank as "unset" (null) so the fleet
218
+ * falls back to the repository's default branch — the legacy behaviour. A non-blank value that is
219
+ * not a plausible git branch name is rejected (`InvalidBaseBranchError`) rather than persisted or
220
+ * rendered into the agent prompt; the operation edge maps that to a 400. */
221
+ export function normalizeBaseBranch(input: string | null | undefined): string | null {
222
+ const s = (input ?? "").trim();
223
+ if (s.length === 0) return null;
224
+ if (!isPlausibleBranchName(s)) throw new InvalidBaseBranchError(s);
225
+ return s;
226
+ }
227
+
228
+ /** The per-instance brief appended to an implementer agent's prompt when the plan pins a base
229
+ * branch. It is authoritative over the static "branch off the default branch" wording in
230
+ * prompts/feature.md, so the agent branches off — and opens its PR against — the integration
231
+ * branch, and reads the epic's latest landed state there rather than the repo default branch. */
232
+ export function renderBaseBranchBrief(baseBranch: string): string {
233
+ return [
234
+ "",
235
+ "",
236
+ "---",
237
+ "",
238
+ `**Base branch (authoritative — overrides any "default branch" instruction above): \`${baseBranch}\`.**`,
239
+ "",
240
+ `This epic lands on \`${baseBranch}\`, NOT the repository default branch. Everywhere the`,
241
+ "instructions say \"default branch\", use this branch instead:",
242
+ "",
243
+ `- Branch off it: \`git fetch origin ${baseBranch} && git checkout -b feat/<task.id> origin/${baseBranch}\`.`,
244
+ `- Read the epic's latest landed state from \`${baseBranch}\` (your prerequisites merged there, not into the default branch).`,
245
+ `- Open your PR against it: \`gh pr create --base ${baseBranch} ...\`.`,
246
+ "",
247
+ "Do not target the repository default branch — a PR opened against it will not be merged into the epic.",
248
+ ].join("\n");
249
+ }
250
+
188
251
  /** Register a plan row (if new) and start the plan-fanout process. Idempotent on
189
252
  * planKey: a plan already in flight is not restarted. */
190
- export async function startPlan(data: DataLayer, engine: EngineClient, parsed: ParsedIssue) {
253
+ export async function startPlan(
254
+ data: DataLayer,
255
+ engine: EngineClient,
256
+ parsed: ParsedIssue,
257
+ baseBranch: string | null = null,
258
+ ) {
191
259
  const table = plans(data);
192
260
  const existing = await table.get(parsed.planKey);
193
261
  if (existing && !PLAN_TERMINAL_STATUSES.includes(existing.status)) {
194
262
  return { planKey: parsed.planKey, alreadyRunning: true };
195
263
  }
264
+ const base = normalizeBaseBranch(baseBranch);
196
265
  const ts = now();
197
266
  // Mint (or reuse, on a re-plan) this plan's blackboard capability token, and render the
198
267
  // coordination brief that carries its concrete URL. The token is the credential; agents reach
@@ -235,6 +304,7 @@ export async function startPlan(data: DataLayer, engine: EngineClient, parsed: P
235
304
  open_task_corr_key: null,
236
305
  open_task_id: null,
237
306
  blackboard_token: token,
307
+ base_branch: base,
238
308
  updated_at: ts,
239
309
  });
240
310
  } else {
@@ -246,6 +316,7 @@ export async function startPlan(data: DataLayer, engine: EngineClient, parsed: P
246
316
  status: "planning",
247
317
  task_count: 0,
248
318
  blackboard_token: token,
319
+ base_branch: base,
249
320
  created_at: ts,
250
321
  updated_at: ts,
251
322
  });
@@ -265,6 +336,12 @@ export async function startPlan(data: DataLayer, engine: EngineClient, parsed: P
265
336
  // out-of-band.
266
337
  blackboardUrl: bbUrl,
267
338
  blackboardBrief: renderCoordinationBrief(bbUrl),
339
+ // Optional epic base branch (019_plan_base_branch.sql): the branch the fleet branches off and
340
+ // opens every PR against instead of the repo default. `baseBranchBrief` rides `appendPrompt`
341
+ // in the implement-task (like `blackboardBrief`); both are null when no base branch is pinned,
342
+ // so the agent keeps the default-branch behaviour from prompts/feature.md.
343
+ baseBranch: base,
344
+ baseBranchBrief: base == null ? null : renderBaseBranchBrief(base),
268
345
  },
269
346
  });
270
347
  const processKey = processInstanceKey == null ? null : String(processInstanceKey);
@@ -7,7 +7,7 @@
7
7
  // GitHub transport forced off so it is hermetic.
8
8
  import { test } from "node:test";
9
9
  import { assertEquals } from "#test-assert";
10
- import { pollIncidentsImpl, submitPr } from "./service.ts";
10
+ import { pollIncidentsImpl, repoEnvelopeVars, submitPr } from "./service.ts";
11
11
 
12
12
  function memTable(rows: any[], key: string) {
13
13
  return {
@@ -331,3 +331,55 @@ test("submitPr defaults convergeOnly to false so the global auto-merge default g
331
331
  assertEquals(get(), false);
332
332
  });
333
333
  });
334
+
335
+ // The repository envelope drives the c8ctl harness's isolated workspace provisioning: it is
336
+ // emitted under the reserved `io.nanobpm.agentTask` namespace with the PR head branch as the
337
+ // checkout ref, and omitted entirely when the head branch couldn't be resolved (so the harness
338
+ // falls back to the legacy launch-dir behavior instead of cloning the wrong default branch).
339
+ test("repoEnvelopeVars emits the repository envelope keyed on the PR head branch", () => {
340
+ const vars = repoEnvelopeVars("owner/repo", "feat/issue-12");
341
+ const env = (vars as any)["io.nanobpm.agentTask"];
342
+ assertEquals(env.repository.url, "https://github.com/owner/repo.git");
343
+ assertEquals(env.repository.ref, "feat/issue-12");
344
+ assertEquals(env.repository.provider, "github");
345
+ });
346
+
347
+ test("repoEnvelopeVars emits nothing when the head branch is unresolved", () => {
348
+ assertEquals(Object.keys(repoEnvelopeVars("owner/repo", null)).length, 0);
349
+ });
350
+
351
+ test("repoEnvelopeVars emits nothing for a malformed repo (not owner/repo)", () => {
352
+ // Defence in depth: a repo that isn't exactly `owner/repo` would build a bogus clone URL, so the
353
+ // helper emits no envelope (harness falls back to the launch dir) rather than a malformed URL.
354
+ for (const bad of [
355
+ "",
356
+ "noslash",
357
+ "a/b/c",
358
+ "owner /repo",
359
+ "owner/re po",
360
+ "/repo",
361
+ "owner/",
362
+ // A trailing `.git` would build a double-suffixed clone URL (…/owner/repo.git.git).
363
+ "owner/repo.git",
364
+ "owner/repo.GIT",
365
+ // Query/fragment/host-injection characters must never reach the clone URL.
366
+ "owner/repo?x",
367
+ "owner/repo#frag",
368
+ "owner/repo:x",
369
+ "owner/re~po",
370
+ // Owner is a GitHub login: no dots or underscores allowed there.
371
+ "own.er/repo",
372
+ "own_er/repo",
373
+ ]) {
374
+ assertEquals(Object.keys(repoEnvelopeVars(bad, "feat/x")).length, 0, `expected no envelope for "${bad}"`);
375
+ }
376
+ // Well-formed repos still emit (guard is not over-eager): hyphens, dots and underscores
377
+ // are legal in the repo-name segment, mixed case is preserved.
378
+ for (const good of ["owner/repo", "my-org/my.repo", "Owner123/Repo_2", "a-b/c-d"]) {
379
+ assertEquals(
380
+ ((repoEnvelopeVars(good, "feat/x") as any)["io.nanobpm.agentTask"].repository.url),
381
+ `https://github.com/${good}.git`,
382
+ `expected envelope for "${good}"`,
383
+ );
384
+ }
385
+ });
package/app/service.ts CHANGED
@@ -12,6 +12,7 @@ import { abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
12
12
  import {
13
13
  classifyMergeability,
14
14
  ensureFreshHeadRun,
15
+ fetchPrHead,
15
16
  fetchPrMeta,
16
17
  fetchPrReviews,
17
18
  fetchPrState,
@@ -277,6 +278,35 @@ async function registerDependencies(data: DataLayer, prKey: string, depKeys: str
277
278
  }
278
279
  }
279
280
 
281
+ /** The reserved namespace key the c8ctl nano worker harness reads the agent-task envelope from
282
+ * (headers ∪ variables, deep-merged). See c8ctl `normalizeTaskEnvelope`. */
283
+ const AGENT_TASK_NS = "io.nanobpm.agentTask";
284
+
285
+ /** Build the repository slice of the agent-task envelope for a PR-based agent job (review-round,
286
+ * fix-ci, rebase). Delivered as a *process variable* under the reserved `io.nanobpm.agentTask`
287
+ * key so the harness provisions an isolated clone checked out on the PR's head branch — instead of
288
+ * the agent inheriting whatever directory the worker was launched from (which only happened to be
289
+ * a usable checkout for repos already present locally). `ref` MUST be the PR head branch; when it
290
+ * is unresolved we emit nothing (no `repository.url`) so the harness falls back to the legacy
291
+ * launch-dir behavior rather than silently cloning the repo's default branch. The static
292
+ * `task.prompt` header on the service task deep-merges with this over the same namespace. */
293
+ export function repoEnvelopeVars(repo: string, ref: string | null): Record<string, unknown> {
294
+ if (!ref) return {};
295
+ // Defence in depth: every current caller derives `repo` from parsePr/parseIssue (regex-bounded to
296
+ // `owner/repo`), but this is an exported helper the fan-out epic gives many new callers. A repo
297
+ // that is not exactly `owner/repo` would build a bogus clone URL, so emit nothing (the harness
298
+ // then falls back to the launch-dir behaviour) rather than handing the harness a malformed URL.
299
+ // The owner is a GitHub login (alphanumeric + hyphen); the repo-name segment additionally allows
300
+ // `.` and `_`. A trailing `.git` is rejected outright so we never emit a double-suffixed
301
+ // `…/owner/repo.git.git`, and the anchored allowlist bars query/fragment/host-injection chars.
302
+ if (!/^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/.test(repo) || /\.git$/i.test(repo)) return {};
303
+ return {
304
+ [AGENT_TASK_NS]: {
305
+ repository: { provider: "github", url: `https://github.com/${repo}.git`, ref },
306
+ },
307
+ };
308
+ }
309
+
280
310
  /** Register a PR row (if new) and start the convergence process. Idempotent on prKey. Optional
281
311
  * `dependsOn` (explicit refs) is unioned with any `Depends-on:` line parsed from the PR body and
282
312
  * recorded as the PR's merge-stage dependency set. */
@@ -298,16 +328,23 @@ export async function submitPr(
298
328
  // A transport failure (no gh/token) must not block submission — we just skip enrichment.
299
329
  const token = process.env.GITHUB_TOKEN ?? "";
300
330
  let title: string | null = null;
331
+ let headRef: string | null = null;
301
332
  const depKeys = new Set(dependsOn.map((d) => parsePr(d)?.prKey).filter((k): k is string => !!k));
302
333
  try {
303
334
  const meta = await fetchPrMeta(parsed.repo, parsed.number, token);
304
335
  if (meta) {
305
336
  title = meta.title;
337
+ headRef = meta.headRef;
306
338
  for (const k of parseDependsOn(meta.body)) depKeys.add(k);
307
339
  }
308
340
  } catch (err) {
309
341
  console.warn(`[submit] ${parsed.prKey} meta fetch: ${err}`);
310
342
  }
343
+ if (!headRef) {
344
+ // Without the head branch the harness can't check out the PR; the review agent then falls
345
+ // back to the worker's launch dir (the legacy behavior) and escalates if it isn't a checkout.
346
+ console.warn(`[submit] ${parsed.prKey} head branch unresolved — agent workspace won't be provisioned`);
347
+ }
311
348
  await registerDependencies(data, parsed.prKey, [...depKeys]);
312
349
 
313
350
  const ts = now();
@@ -375,6 +412,10 @@ export async function submitPr(
375
412
  // review-round agent's prompt, so it can stop before pushing if the run is cancelled.
376
413
  abandonUrl: abUrl,
377
414
  abandonBrief: renderAbandonBrief(abUrl),
415
+ // Host-git provisioning (c8ctl): deliver the repository envelope so the `senior:pr-review`
416
+ // harness clones an isolated workspace checked out on the PR head branch. Spread last so an
417
+ // unresolved head (`{}`) leaves the other vars untouched.
418
+ ...repoEnvelopeVars(parsed.repo, headRef),
378
419
  },
379
420
  });
380
421
  const processKey = processInstanceKey == null ? null : String(processInstanceKey);
@@ -400,6 +441,19 @@ export async function startMerge(
400
441
  await prs(data).update(pr.prKey, { abandon_token: abandonToken, updated_at: now() });
401
442
  }
402
443
  const abUrl = abandonUrl(abandonToken);
444
+ // Resolve the PR head branch so the merge agents (fix-ci, rebase) get an isolated clone checked
445
+ // out on it (same host-git provisioning path as review-round). Best-effort: an unresolved head
446
+ // means the envelope is omitted and the agent falls back to the worker's launch dir.
447
+ const token = process.env.GITHUB_TOKEN ?? "";
448
+ let headRef: string | null = null;
449
+ try {
450
+ headRef = (await fetchPrHead(pr.repo, pr.number, token))?.headRef ?? null;
451
+ } catch (err) {
452
+ console.warn(`[startMerge] ${pr.prKey} head branch fetch: ${err}`);
453
+ }
454
+ if (!headRef) {
455
+ console.warn(`[startMerge] ${pr.prKey} head branch unresolved — merge-agent workspace won't be provisioned`);
456
+ }
403
457
  const { processInstanceKey } = await engine.createInstance({
404
458
  processDefinitionId: MERGE_PROCESS_ID,
405
459
  variables: {
@@ -414,6 +468,9 @@ export async function startMerge(
414
468
  rebaseMax: MAX_REBASE_ROUNDS,
415
469
  abandonUrl: abUrl,
416
470
  abandonBrief: renderAbandonBrief(abUrl),
471
+ // Host-git provisioning (c8ctl): same repository envelope as the convergence loop, so the
472
+ // fix-ci/rebase agents operate on an isolated checkout of the PR head branch.
473
+ ...repoEnvelopeVars(pr.repo, headRef),
417
474
  },
418
475
  });
419
476
  if (processInstanceKey != null) {
@@ -0,0 +1,9 @@
1
+ -- Identify the servicing worker on the durable history. The c8ctl harness completes each agent
2
+ -- job with an `agent` variable (its profile name, e.g. `senior`), which propagates to the
3
+ -- downstream `pr.persist-round` / `pr.persist-escalation` jobs. Recording it next to the round /
4
+ -- escalation the agent produced lets a human tell *which* worker did the work when reading the
5
+ -- transcript — without cross-referencing the transient `pull_requests.active_worker` lease (which
6
+ -- is cleared once the agent finishes).
7
+
8
+ ALTER TABLE rounds ADD COLUMN worker TEXT;
9
+ ALTER TABLE escalations ADD COLUMN worker TEXT;
@@ -0,0 +1,8 @@
1
+ -- Per-plan target base branch (epic base-branch control). When set, the fleet branches off this
2
+ -- branch and opens every task PR against it instead of the repository's default branch, so an
3
+ -- entire epic can land on a long-lived integration branch (e.g. `epic/agent-protocol`) and reach
4
+ -- the default branch — and any merge-to-default side effect such as auto-publishing a package —
5
+ -- only when the integration branch is deliberately merged. NULL keeps the legacy behaviour (the
6
+ -- repo default branch), so pre-migration plans are unaffected.
7
+
8
+ ALTER TABLE plans ADD COLUMN base_branch TEXT;
@@ -0,0 +1,175 @@
1
+ // End-to-end pilot for @nanobpm/urban-testkit (nano-ide issue #157, slice S3).
2
+ //
3
+ // Boots this whole Urban app in-process against the WASM engine and a virtual clock via
4
+ // `bootTestApp`, then drives its real ADR-0059 OpenAPI operations by `operationId` — the same
5
+ // spec-driven `/app/api/*` surface a browser, a CI relay, or Swagger hit in production. No socket
6
+ // is opened, no wall-clock is waited on, and no GitHub network is touched.
7
+ //
8
+ // Network isolation: the app's GitHub transport (app/github.ts) is forced to `token` mode with no
9
+ // token, so every best-effort GitHub read short-circuits to `null`/idle instead of reaching out.
10
+ // That keeps the pilot hermetic and deterministic in CI.
11
+ //
12
+ // Run with `npm run e2e` (a dedicated node:test invocation, kept out of the fast unit `npm test`).
13
+
14
+ import assert from "node:assert/strict";
15
+ import { fileURLToPath } from "node:url";
16
+ import { dirname, join, resolve } from "node:path";
17
+ import { mkdtempSync, rmSync, readFileSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { after, before, describe, test } from "node:test";
20
+ import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
21
+
22
+ // The app root is this repo's root (one level up from `e2e/`) — where nano.app.json + openapi.yaml
23
+ // + db/migrations + resources/processes live.
24
+ const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
25
+
26
+ // Provision the app's SQLite in a throwaway temp dir so the pilot never touches (or leaks into) the
27
+ // repo's real ./app.db, and every run starts from a freshly-migrated, empty schema.
28
+ const DB_DIR = mkdtempSync(join(tmpdir(), "nwf-e2e-"));
29
+
30
+ // Derive the reconciler's poll interval from the app manifest (its single source of truth) rather
31
+ // than hard-coding it, so this test tracks nano.app.json instead of duplicating the value: a change
32
+ // to `pollMs` there stays correct here. Read the `pull_requests` instanceTracking entry's pollMs.
33
+ interface InstanceTrackingEntry {
34
+ table: string;
35
+ pollMs: number;
36
+ }
37
+ interface AppManifest {
38
+ instanceTracking?: InstanceTrackingEntry[];
39
+ }
40
+ const APP_MANIFEST: AppManifest = JSON.parse(
41
+ readFileSync(join(APP_ROOT, "nano.app.json"), "utf8"),
42
+ );
43
+ const PR_TRACKING = APP_MANIFEST.instanceTracking?.find((e) => e.table === "pull_requests");
44
+ assert.ok(PR_TRACKING, "nano.app.json declares a pull_requests instanceTracking entry");
45
+ const PR_POLL_MS = PR_TRACKING.pollMs;
46
+
47
+ // Force the app fully offline. github.ts reads `process.env` directly (not the harness env overlay),
48
+ // so seal the GitHub transport on process.env: `token` mode with no GITHUB_TOKEN means every
49
+ // best-effort GitHub read in submitPr short-circuits to null instead of shelling out to `gh`/fetch.
50
+ const GITHUB_ENV_OVERRIDES: Record<string, string> = {
51
+ NANO_PR_GITHUB_TRANSPORT: "token",
52
+ GITHUB_TOKEN: "",
53
+ };
54
+ const savedEnv = new Map<string, string | undefined>();
55
+
56
+ // The harness `env` overlay drives the runtime's `${NANO_APP_DB_URL}` resolution — provision the
57
+ // app's SQLite in a throwaway temp dir so the pilot never touches (or leaks into) the repo's real
58
+ // ./app.db, and every run starts from a freshly-migrated, empty schema.
59
+ const HARNESS_ENV = {
60
+ NANO_APP_DB_URL: `file:${join(DB_DIR, "app.db")}`,
61
+ } as const;
62
+
63
+ describe("nano-workforce e2e (urban-testkit pilot)", () => {
64
+ let app: TestApp;
65
+
66
+ before(async () => {
67
+ for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
68
+ savedEnv.set(k, process.env[k]);
69
+ process.env[k] = v;
70
+ }
71
+ app = await bootTestApp(APP_ROOT, { env: HARNESS_ENV });
72
+ // This app declares an `api` binding, so the spec-driven driver must be present.
73
+ assert.ok(app.api, "app.api driver should be defined (nano.app.json declares an `api` binding)");
74
+ });
75
+
76
+ after(async () => {
77
+ await app?.stop();
78
+ for (const [k, v] of savedEnv) {
79
+ if (v === undefined) delete process.env[k];
80
+ else process.env[k] = v;
81
+ }
82
+ rmSync(DB_DIR, { recursive: true, force: true });
83
+ });
84
+
85
+ test("drives the blackboard operations round-trip through the OpenAPI driver", async () => {
86
+ const api = app.api;
87
+ assert.ok(api);
88
+
89
+ // Seed a plan with a capability token — the credential the blackboard operations authorize on.
90
+ const token = "pilot-blackboard-token";
91
+ const planKey = "acme/widgets#7";
92
+ const nowIso = new Date(app.now()).toISOString();
93
+ await app.db.table("plans", "plan_key").insert({
94
+ plan_key: planKey,
95
+ repo: "acme/widgets",
96
+ issue_number: 7,
97
+ issue_url: "https://github.com/acme/widgets/issues/7",
98
+ title: "Pilot plan",
99
+ status: "planning",
100
+ task_count: 0,
101
+ blackboard_token: token,
102
+ created_at: nowIso,
103
+ updated_at: nowIso,
104
+ });
105
+
106
+ // POST an entry via the `appendBlackboard` operation (operationId → /app/api/hooks/blackboard).
107
+ const appended = await api.call<{ id: number; inserted: boolean }>("appendBlackboard", {
108
+ query: { token },
109
+ body: { author_task: "t1", kind: "note", body: "hello from the pilot" },
110
+ });
111
+ assert.equal(appended.status, 201, "append returns 201 Created");
112
+ assert.equal(appended.body.inserted, true, "entry was inserted");
113
+ assert.ok(Number.isFinite(appended.body.id), "append returns a numeric entry id");
114
+
115
+ // GET it back via `readBlackboard` — the entry the POST just wrote must be visible.
116
+ const read = await api.call<{ planKey: string; entries: Array<{ id: number; body: string }> }>(
117
+ "readBlackboard",
118
+ { query: { token } },
119
+ );
120
+ assert.equal(read.status, 200, "read returns 200 OK");
121
+ assert.equal(read.body.planKey, planKey, "read is scoped to the seeded plan");
122
+ assert.equal(read.body.entries.length, 1, "exactly the one appended entry is returned");
123
+ assert.equal(read.body.entries[0].body, "hello from the pilot", "round-tripped body matches");
124
+ assert.equal(read.body.entries[0].id, appended.body.id, "read id matches the appended id");
125
+
126
+ // An unknown token is a 404 (never leaks which plans exist).
127
+ const unknown = await api.call("readBlackboard", { query: { token: "nope" } });
128
+ assert.equal(unknown.status, 404, "an unknown token is a 404, not a leak");
129
+ });
130
+
131
+ test("starts the convergence loop and reconciles its tracking row when terminated", async () => {
132
+ const api = app.api;
133
+ assert.ok(api);
134
+
135
+ const prKey = "acme/widgets#42";
136
+ // POST the real production door for starting a review: `startConvergenceLoop`. `convergeOnly`
137
+ // keeps the run off the merge-loop; the offline env keeps `submitPr`'s best-effort GitHub
138
+ // enrichment from touching the network.
139
+ const started = await api.call<{ prKey: string }>("startConvergenceLoop", {
140
+ body: { pr: prKey, convergeOnly: true },
141
+ });
142
+ assert.equal(started.status, 202, "start returns 202 Accepted");
143
+ assert.equal(started.body.prKey, prKey, "the response echoes the parsed PR key");
144
+
145
+ // The operation registered the PR aggregate (instanceTracking table) and started a real engine
146
+ // instance — synchronously, before any worker ran (we never settled).
147
+ const prs = app.db.table<{ pr_key: string; status: string; process_key: string | null }>(
148
+ "pull_requests",
149
+ "pr_key",
150
+ );
151
+ const row = await prs.findOne({ pr_key: prKey });
152
+ assert.ok(row, "a pull_requests row was registered");
153
+ assert.equal(row?.status, "converging", "the PR is tracked as actively converging");
154
+ assert.ok(row?.process_key, "the row carries the engine process-instance key");
155
+
156
+ const processInstanceKey = row!.process_key!;
157
+ const before = await app.engine.searchProcessInstances({
158
+ processInstanceKeys: [processInstanceKey],
159
+ });
160
+ assert.equal(before.length, 1, "the engine has exactly one instance for this PR");
161
+
162
+ // Terminate the instance out-of-band (the class of event the reconciler exists to catch — a
163
+ // PR merged or cancelled independently of the loop). The row is still `converging` until a poll.
164
+ await app.engine.cancelInstance({ processInstanceKey });
165
+ const stillActive = await prs.findOne({ pr_key: prKey });
166
+ assert.equal(stillActive?.status, "converging", "row not yet reconciled before any poll fires");
167
+
168
+ // Advance past the instanceTracking pollMs (derived from nano.app.json above, plus a margin):
169
+ // the reconciler observes TERMINATED and applies the manifest `onTerminated.set` → status
170
+ // `abandoned`, escalation pointers cleared.
171
+ await app.advanceTime(PR_POLL_MS + 1000);
172
+ const reconciled = await prs.findOne({ pr_key: prKey });
173
+ assert.equal(reconciled?.status, "abandoned", "reconciler abandoned the terminated PR's row");
174
+ });
175
+ });
package/openapi.yaml CHANGED
@@ -271,6 +271,15 @@ components:
271
271
  issue:
272
272
  type: string
273
273
  description: "Issue reference: owner/repo#123."
274
+ baseBranch:
275
+ type: string
276
+ description: >-
277
+ Optional target branch the fleet branches off and opens every PR against, instead of the
278
+ repository's default branch. Use this to land an entire epic on a long-lived integration
279
+ branch (e.g. `epic/agent-protocol`) so nothing reaches the default branch — and any
280
+ merge-to-default side effect, such as auto-publishing a package — until you deliberately
281
+ merge the integration branch. Blank/omitted keeps the current behaviour (the repo
282
+ default branch).
274
283
  PlanStartByUrl:
275
284
  type: object
276
285
  additionalProperties: false
@@ -280,6 +289,11 @@ components:
280
289
  url:
281
290
  type: string
282
291
  description: A bare issue URL, when no `owner/repo#123` reference is supplied.
292
+ baseBranch:
293
+ type: string
294
+ description: >-
295
+ Optional target branch the fleet branches off and opens every PR against, instead of the
296
+ repository's default branch. See `PlanStartByIssue.baseBranch`.
283
297
  MessageResult:
284
298
  type: object
285
299
  description: The result of publishing a message / answering an escalation. Shape varies by message
@@ -121,6 +121,18 @@ test("startPlanFanout → 400 (not 500) on a missing request body", async () =>
121
121
  assertEquals(typeof r.body.error, "string");
122
122
  });
123
123
 
124
+ test("startPlanFanout → 400 on an invalid baseBranch (not persisted/rendered)", async () => {
125
+ // A non-blank baseBranch that isn't a plausible git branch name (shell metacharacters here)
126
+ // must be rejected at the edge as a 400 — never persisted or interpolated into the agent prompt.
127
+ const res = await startPlanFanout(
128
+ input({ issue: "owner/repo#123", baseBranch: "epic/agent; rm -rf /" }),
129
+ app,
130
+ );
131
+ const r = res as any;
132
+ assertEquals(r.status, 400);
133
+ assertEquals(typeof r.body.error, "string");
134
+ });
135
+
124
136
  test("startConvergenceLoop narrows the `url` variant (no `pr` key)", async () => {
125
137
  await withGithubOff(async () => {
126
138
  const { app: capApp } = captureApp();
@@ -10,7 +10,7 @@
10
10
  // ONE of `issue` or `url` — so an empty or ambiguous target is a 400 at the edge; this delegate just
11
11
  // narrows the validated variant and keeps the issue-FORMAT parse guard (schema can't express it).
12
12
 
13
- import { parseIssue, startPlan } from "../app/plan.ts";
13
+ import { InvalidBaseBranchError, normalizeBaseBranch, parseIssue, startPlan } from "../app/plan.ts";
14
14
  import { defineOperation } from "../nano-generated/operations.ts";
15
15
 
16
16
  export default defineOperation("startPlanFanout", async ({ body }, app) => {
@@ -26,9 +26,29 @@ export default defineOperation("startPlanFanout", async ({ body }, app) => {
26
26
  app.log.warn("start-plan rejected: unparseable issue reference", { raw });
27
27
  return { status: 400, body: { error: "could not parse issue (use owner/repo#123 or an issue URL)" } };
28
28
  }
29
- const result = await startPlan(app.data, app.engine, parsed);
29
+ // Optional epic base branch: the branch the fleet branches off and opens every PR against instead
30
+ // of the repo default. Present on both oneOf variants; blank/absent keeps the default-branch
31
+ // behaviour. It is later interpolated into the authoritative implementer prompt (with `git`/`gh`
32
+ // shell snippets), so validate/normalise it HERE — a non-blank value that isn't a plausible git
33
+ // branch name is a 400 at the edge, never persisted or rendered. `normalizeBaseBranch` blank → null.
34
+ const baseBranch = "baseBranch" in body && typeof body.baseBranch === "string" ? body.baseBranch : null;
35
+ let normalizedBase: string | null;
36
+ try {
37
+ normalizedBase = normalizeBaseBranch(baseBranch);
38
+ } catch (err) {
39
+ if (err instanceof InvalidBaseBranchError) {
40
+ app.log.warn("start-plan rejected: invalid base branch", { baseBranch: err.value });
41
+ return {
42
+ status: 400,
43
+ body: { error: "invalid baseBranch (must be a plausible git branch name, e.g. epic/agent-protocol)" },
44
+ };
45
+ }
46
+ throw err;
47
+ }
48
+ const result = await startPlan(app.data, app.engine, parsed, normalizedBase);
30
49
  app.log.info("plan fan-out started", {
31
50
  planKey: parsed.planKey,
51
+ baseBranch: normalizedBase ?? "(default branch)",
32
52
  alreadyRunning: "alreadyRunning" in result && result.alreadyRunning === true,
33
53
  });
34
54
  return { status: 202, body: result };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.44.0",
3
+ "version": "0.45.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -41,14 +41,16 @@
41
41
  "layout:check": "node --experimental-strip-types scripts/layout-bpmn.ts --check",
42
42
  "dev": "urban dev",
43
43
  "test": "node --experimental-strip-types --test",
44
- "lint": "biome check app operations workers pages components scripts main.ts",
45
- "lint:fix": "biome check --write app operations workers pages components scripts main.ts"
44
+ "e2e": "node --experimental-strip-types --test \"e2e/**/*.e2e.ts\"",
45
+ "lint": "biome check app operations workers pages components scripts e2e main.ts",
46
+ "lint:fix": "biome check --write app operations workers pages components scripts e2e main.ts"
46
47
  },
47
48
  "dependencies": {
48
49
  "@nanobpm/urban": "^0.42.0"
49
50
  },
50
51
  "devDependencies": {
51
52
  "@biomejs/biome": "^2.4.11",
53
+ "@nanobpm/urban-testkit": "^0.3.0",
52
54
  "@semantic-release/changelog": "^6.0.3",
53
55
  "@semantic-release/git": "^10.0.1",
54
56
  "@semantic-release/npm": "^13.1.5",
@@ -35,7 +35,8 @@
35
35
  "submitLabel": "Plan & implement",
36
36
  "action": { "path": "/app/api/actions/start/plan-fanout", "body": "{{form}}" },
37
37
  "fields": [
38
- { "key": "issue", "label": "owner/repo#123 or a GitHub issue URL", "type": "text" }
38
+ { "key": "issue", "label": "owner/repo#123 or a GitHub issue URL", "type": "text" },
39
+ { "key": "baseBranch", "label": "Base branch (blank = repo default; e.g. epic/agent-protocol to land the whole epic on an integration branch)", "type": "text" }
39
40
  ]
40
41
  }
41
42
  },
@@ -76,6 +77,7 @@
76
77
  "fields": [
77
78
  { "field": "repo", "label": "Repository" },
78
79
  { "field": "issue_number", "label": "Issue number" },
80
+ { "field": "base_branch", "label": "Base branch (blank = repo default)" },
79
81
  { "field": "outcome", "label": "Outcome" },
80
82
  { "field": "open_task_question", "label": "Open escalation question" }
81
83
  ]
@@ -109,6 +109,7 @@
109
109
  "columns": [
110
110
  { "field": "round_no", "header": "#" },
111
111
  { "field": "status", "header": "Result" },
112
+ { "field": "worker", "header": "Worker" },
112
113
  { "field": "summary", "header": "Summary" }
113
114
  ],
114
115
  "lazyField": { "field": "transcript", "label": "Transcript", "lazy": true }
@@ -123,6 +124,7 @@
123
124
  "columns": [
124
125
  { "field": "round_no", "header": "#" },
125
126
  { "field": "kind", "header": "Kind" },
127
+ { "field": "worker", "header": "Worker" },
126
128
  { "field": "question", "header": "Question" },
127
129
  { "field": "status", "header": "Status" },
128
130
  { "field": "answer", "header": "Answer" }
@@ -26,14 +26,26 @@ process with no memory of your last run, the branch name MUST be derivable from
26
26
  (`git ls-remote --heads origin feat/<task.id>` or
27
27
  `gh pr list --head feat/<task.id> --state all`):
28
28
 
29
- - **It does not exist** → this is a first run. Branch off the default branch.
29
+ - **It does not exist** → this is a first run. Branch off the base branch (see
30
+ the note below — usually the repository default branch, but an epic may pin an
31
+ integration branch in your appended task context).
30
32
  - **It exists** → this is a **resume**. `git fetch` and check it out, read its diff
31
33
  and any open (draft) PR, and **continue from there** — do not restart from
32
34
  scratch. Fold in `variables.answer` as the guidance you were waiting on.
33
35
 
36
+ ## Your base branch (default branch, unless the epic pins one)
37
+
38
+ Branch off — and open your PR against — the repository's **default branch**,
39
+ UNLESS your appended task context carries a **"Base branch (authoritative)"**
40
+ note pinning an epic integration branch. When it does, that branch wins
41
+ everywhere below: branch off `origin/<that branch>`, read the epic's latest
42
+ landed state there, and pass `gh pr create --base <that branch>`. A PR opened
43
+ against the wrong base will not be merged into the epic.
44
+
34
45
  ## What to do
35
46
 
36
- 1. Clone / check out the repository's default branch (first run) or your existing
47
+ 1. Clone / check out your base branch (first run — the default branch, or the
48
+ pinned epic branch if your context names one) or your existing
37
49
  `feat/<task.id>` branch (resume — see above).
38
50
  2. Implement `task.prompt`. Keep the change scoped to this slice only.
39
51
  3. Commit (sign off — this repo family enforces DCO: `git commit -s`), push the
package/prompts/fix-ci.md CHANGED
@@ -16,6 +16,15 @@ protocol with a status URL is appended below: **before you push the fix, curl th
16
16
  `-fsS`) and stop immediately if the check **fails** or reports `"abandoned": true`. Re-check right
17
17
  before the push.
18
18
 
19
+ ## Workspace (host mode) — read this first
20
+
21
+ When the worker harness (e.g. `c8ctl nano work`) provisions a workspace, your **current
22
+ working directory is a fresh, isolated clone of the repo checked out on the PR's head
23
+ branch** — exposed via `AGENT_WORKSPACE`, `AGENT_REPO_URL`, `AGENT_REPO_BRANCH`, `AGENT_REPO_REF`.
24
+ When it does, **work only inside `cwd`**, do **not** re-clone, `cd` elsewhere, or add a
25
+ `git worktree`, and do not touch global/host state — other jobs get their own clones. If
26
+ `AGENT_WORKSPACE` is **unset** (no provisioning), check out the PR head branch yourself.
27
+
19
28
  ## Job input (`job.variables`)
20
29
 
21
30
  | var | meaning |
@@ -32,7 +41,8 @@ the PR's checks yourself (`gh pr checks`, `gh run view`).
32
41
 
33
42
  ## What to do
34
43
 
35
- 1. Check out the PR's head branch (it already exists on the remote).
44
+ 1. Check out the PR's head branch (already provisioned as your `cwd` in host mode; else it
45
+ exists on the remote).
36
46
  2. For each failing check, read its logs to find the **root cause** — a real
37
47
  failure (a bug, a broken test, a lint/type error, a missing file). Do **not**
38
48
  paper over it (no `--no-verify`, no disabling the check, no `it.skip`, no
package/prompts/rebase.md CHANGED
@@ -17,6 +17,15 @@ with a status URL is appended below: **before you push the rebased branch, curl
17
17
  `-fsS`) and stop immediately if the check **fails** or reports `"abandoned": true`. Re-check right
18
18
  before the push.
19
19
 
20
+ ## Workspace (host mode) — read this first
21
+
22
+ When the worker harness (e.g. `c8ctl nano work`) provisions a workspace, your **current
23
+ working directory is a fresh, isolated clone of the repo checked out on the PR's head
24
+ branch** — exposed via `AGENT_WORKSPACE`, `AGENT_REPO_URL`, `AGENT_REPO_BRANCH`, `AGENT_REPO_REF`.
25
+ When it does, **work only inside `cwd`**, do **not** re-clone, `cd` elsewhere, or add a
26
+ `git worktree`, and do not touch global/host state — other jobs get their own clones. If
27
+ `AGENT_WORKSPACE` is **unset** (no provisioning), check out the PR head branch yourself.
28
+
20
29
  ## Job input (`job.variables`)
21
30
 
22
31
  | var | meaning |
@@ -29,7 +38,8 @@ before the push.
29
38
 
30
39
  ## What to do
31
40
 
32
- 1. Check out the PR's head branch (it already exists on the remote) and identify
41
+ 1. Check out the PR's head branch (already provisioned as your `cwd` in host mode; else it
42
+ already exists on the remote) and identify
33
43
  the base branch (`gh pr view <prNumber> --repo <repo> --json baseRefName`).
34
44
  2. Update the branch onto the current base. Prefer a **rebase**
35
45
  (`git fetch origin && git rebase origin/<base>`); if the repo's history policy
@@ -31,7 +31,7 @@ cancel can land anytime.
31
31
  The worker harness (e.g. `c8ctl nano work`) has **already provisioned an isolated,
32
32
  per-job workspace for you**: your **current working directory is a fresh clone of
33
33
  the repo, checked out on the PR's head branch**. The harness exposes it via the
34
- `AGENT_WORKSPACE`, `REPO_URL`, `REPO_BRANCH` and `REPO_REF` environment variables,
34
+ `AGENT_WORKSPACE`, `AGENT_REPO_URL`, `AGENT_REPO_BRANCH` and `AGENT_REPO_REF` environment variables,
35
35
  and it **reaps that workspace after the job ends**.
36
36
 
37
37
  Because several agents may run on the same host at once:
@@ -50,11 +50,21 @@ Because several agents may run on the same host at once:
50
50
 
51
51
  1. **Read the latest review.** Fetch the newest Copilot review + its inline
52
52
  comments on the PR (`gh pr view`, `gh api .../pulls/{n}/reviews`, `.../comments`).
53
+ Also read Copilot's **suppressed / low-confidence** advisories — the collapsed
54
+ "low confidence" list Copilot folds into the **review body** (`.../reviews`
55
+ `body`). These are NOT in the default inline-comment API set, so a plain
56
+ `.../comments` read misses them; scan the review body for them explicitly.
53
57
  If `answer` is present, treat it as the human's decision on the escalation you
54
58
  raised last round and act on it first.
55
59
  2. **Triage each comment** into: *fix* (correct, worth doing), *nitpick* (apply
56
60
  silently), *needs human input* (design/product/tradeoff you can't decide), or
57
61
  *push back* (wrong / false positive — reply with evidence, make no change).
62
+ Triage the suppressed / low-confidence advisories the **same** way — but do not
63
+ treat "suppressed" as either automatically actionable or automatically ignorable:
64
+ if one is a **cheap, correct** robustness/correctness win, just do it (a
65
+ *nitpick*); otherwise **decline it explicitly with a one-line rationale in your
66
+ `summary`** (e.g. "declined suppressed advisory X — input already validated
67
+ upstream at Y"). Never silently drop one.
58
68
  3. **Act.** Make the code changes for all fixes + nitpicks in your workspace (`cwd`)
59
69
  in one coherent, signed-off commit (`git commit -s`). Run the repo's
60
70
  build/test/lint locally before pushing. Push to the PR's head branch (the branch
@@ -107,6 +117,9 @@ Consider the PR **converged** when the latest review has no actionable comment:
107
117
  comments") and there are no new inline comments, **or**
108
118
  - every new comment is a nitpick you already handled or intentionally declined,
109
119
  **or**
120
+ - the only remaining items are suppressed / low-confidence advisories you have
121
+ triaged and either applied or declined-with-rationale (a suppressed advisory
122
+ you have recorded a decision on does **not** block convergence), **or**
110
123
  - Copilot is looping — reiterating a point you already addressed or pushed back
111
124
  on (two rounds of the same substantive point = converged).
112
125
 
@@ -93,7 +93,7 @@
93
93
  <zeebe:header key="io.nanobpm.agentTask.task.prompt" value="{{feature}}" />
94
94
  </zeebe:taskHeaders>
95
95
  <zeebe:ioMapping>
96
- <zeebe:input source="=&#34;&#10;&#10;---&#10;&#10;&#34; + task.prompt + (if (blackboardBrief = null) then &#34;&#34; else blackboardBrief)" target="appendPrompt" />
96
+ <zeebe:input source="=&#34;&#10;&#10;---&#10;&#10;&#34; + task.prompt + (if (blackboardBrief = null) then &#34;&#34; else blackboardBrief) + (if (baseBranchBrief = null) then &#34;&#34; else baseBranchBrief)" target="appendPrompt" />
97
97
  </zeebe:ioMapping>
98
98
  </bpmn:extensionElements>
99
99
  <bpmn:incoming>w_toImpl</bpmn:incoming>
package/tsconfig.json CHANGED
@@ -45,7 +45,8 @@
45
45
  "src/**/*.ts",
46
46
  "scripts/**/*.ts",
47
47
  "actions/**/*.ts",
48
- "test/**/*.ts"
48
+ "test/**/*.ts",
49
+ "e2e/**/*.ts"
49
50
  ],
50
51
  "exclude": [
51
52
  "node_modules",
@@ -44,6 +44,13 @@ function transcriptOf(vars: Record<string, unknown>): string | null {
44
44
  return typeof env?.output === "string" ? env.output : null;
45
45
  }
46
46
 
47
+ // The c8ctl harness completes each agent job with an `agent` variable (its profile name), which
48
+ // propagates here. Record it on the round/escalation so a human can identify the servicing worker
49
+ // from the durable history. Reuses the `nonBlank` domain rule (blank/absent -> NULL column).
50
+ function workerOf(vars: Record<string, unknown>): string | undefined {
51
+ return nonBlank(vars.agent);
52
+ }
53
+
47
54
  // Synthesize a concrete, answerable question when the agent left one blank. A blank question is
48
55
  // almost always a *no-result* round: a prompt-less agent that never wrote its result file, so
49
56
  // `status` is empty and `gw-status` falls through its default `f_escalate` arm (the empty
@@ -72,6 +79,7 @@ const handler: AppJobHandler<In> = async (job, app) => {
72
79
  const rawStatus = nonBlank(job.variables.status);
73
80
  const status = rawStatus ?? "needs_input";
74
81
  const transcript = transcriptOf(job.variables);
82
+ const worker = workerOf(job.variables);
75
83
  // A blank question must never open an unanswerable escalation. Every legitimate arm sets a
76
84
  // concrete question — the agent contract requires one for needs_input/blocked, and the
77
85
  // max-rounds + review-timeout arms set a literal via the model. When one is still missing
@@ -110,6 +118,7 @@ const handler: AppJobHandler<In> = async (job, app) => {
110
118
  status,
111
119
  summary,
112
120
  transcript,
121
+ worker,
113
122
  started_at: now,
114
123
  ended_at: now,
115
124
  });
@@ -120,6 +129,7 @@ const handler: AppJobHandler<In> = async (job, app) => {
120
129
  kind,
121
130
  question,
122
131
  transcript,
132
+ worker,
123
133
  status: "open",
124
134
  asked_at: now,
125
135
  });
@@ -34,6 +34,14 @@ function transcriptOf(vars: Record<string, unknown>): string | null {
34
34
  return typeof env?.output === "string" ? env.output : null;
35
35
  }
36
36
 
37
+ // The c8ctl harness completes each agent job with an `agent` variable (its profile name), which
38
+ // propagates here. Record it on the round so a human can identify the servicing worker from the
39
+ // durable history. Undefined (blank/absent) leaves the nullable `worker` column NULL.
40
+ function workerOf(vars: Record<string, unknown>): string | undefined {
41
+ const v = vars.agent;
42
+ return typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
43
+ }
44
+
37
45
  const handler: AppJobHandler<In> = async (job, app) => {
38
46
  // This worker is the "addressed"/"waiting" path, so `status` resolves to one of those
39
47
  // domain values. `summary` is left undefined when absent: the write boundary omits it so the
@@ -66,6 +74,7 @@ const handler: AppJobHandler<In> = async (job, app) => {
66
74
  status,
67
75
  summary,
68
76
  transcript: transcriptOf(job.variables),
77
+ worker: workerOf(job.variables),
69
78
  started_at: now,
70
79
  ended_at: now,
71
80
  });