@nanobpm/nano-workforce 0.44.1 → 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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
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
+
1
8
  ## [0.44.1](https://github.com/nanobpm/nano-workforce/compare/v0.44.0...v0.44.1) (2026-08-11)
2
9
 
3
10
 
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);
@@ -347,3 +347,39 @@ test("repoEnvelopeVars emits the repository envelope keyed on the PR head branch
347
347
  test("repoEnvelopeVars emits nothing when the head branch is unresolved", () => {
348
348
  assertEquals(Object.keys(repoEnvelopeVars("owner/repo", null)).length, 0);
349
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
@@ -292,6 +292,14 @@ const AGENT_TASK_NS = "io.nanobpm.agentTask";
292
292
  * `task.prompt` header on the service task deep-merges with this over the same namespace. */
293
293
  export function repoEnvelopeVars(repo: string, ref: string | null): Record<string, unknown> {
294
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 {};
295
303
  return {
296
304
  [AGENT_TASK_NS]: {
297
305
  repository: { provider: "github", url: `https://github.com/${repo}.git`, ref },
@@ -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;
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.1",
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",
@@ -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
  ]
@@ -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
@@ -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>