@nanobpm/nano-workforce 0.56.0 → 0.57.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/app/plan.test.ts CHANGED
@@ -121,7 +121,7 @@ test("re-plan of a finished issue clears stale plan_reviews rows", async () => {
121
121
  number: 7,
122
122
  url: "https://github.com/owner/repo/issues/7",
123
123
  planKey: PLAN_KEY,
124
- });
124
+ }, "epic/agent-protocol");
125
125
 
126
126
  assertEquals(stores.plan_reviews.rows.length, 0);
127
127
  assertEquals(stores.plan_tasks.rows.length, 0);
@@ -179,7 +179,7 @@ test("re-plan of a finished issue clears stale open escalations and the denormal
179
179
  number: 8,
180
180
  url: "https://github.com/owner/repo/issues/8",
181
181
  planKey: PLAN_KEY,
182
- });
182
+ }, "epic/agent-protocol");
183
183
 
184
184
  // Stale escalation rows from the prior run must not survive a re-plan …
185
185
  assertEquals(stores.plan_escalations.rows.length, 0);
@@ -428,21 +428,22 @@ test("answerPlanEscalation records directive, clears the plan pointer, and publi
428
428
  );
429
429
  });
430
430
 
431
- // Coverage for the epic base-branch control (issue nano-ide #124 / 019_plan_base_branch.sql).
431
+ // Coverage for the epic base-branch control (issue nano-ide #124 / 019_plan_base_branch.sql; ADR 0003).
432
432
  //
433
- // A plan may pin a base branch so the fleet branches off — and opens every PR against — a long-lived
434
- // integration branch instead of the repo default, keeping an epic off the default branch (and off any
435
- // merge-to-default side effect such as auto-publishing) until the integration branch is deliberately
436
- // merged. `normalizeBaseBranch` decides "unset" (fall back to default), `renderBaseBranchBrief` is the
437
- // authoritative prompt override, and `startPlan` must persist the branch and seed BOTH the `baseBranch`
438
- // variable and the `baseBranchBrief` (which rides `appendPrompt`) or leave them null when unpinned.
439
- import { InvalidBaseBranchError, normalizeBaseBranch, renderBaseBranchBrief } from "./plan.ts";
440
-
441
- test("normalizeBaseBranch: blank/whitespace/undefined → null; a real branch is trimmed", () => {
442
- assertEquals(normalizeBaseBranch(undefined), null);
443
- assertEquals(normalizeBaseBranch(null), null);
444
- assertEquals(normalizeBaseBranch(""), null);
445
- assertEquals(normalizeBaseBranch(" "), null);
433
+ // Every plan must pin a base branch so the fleet branches off — and opens every PR against — a
434
+ // long-lived integration branch instead of the repo default, keeping an epic off the default branch
435
+ // (and off any merge-to-default side effect such as auto-publishing) until the integration branch is
436
+ // deliberately merged. Since ADR 0003, base is REQUIRED: `normalizeBaseBranch` rejects a blank/absent
437
+ // value (`MissingBaseBranchError`) instead of falling back to the default. `renderBaseBranchBrief` is
438
+ // the authoritative prompt override, and `startPlan` persists the branch and seeds BOTH the
439
+ // `baseBranch` variable and the `baseBranchBrief` (which rides `appendPrompt`) unconditionally.
440
+ import { InvalidBaseBranchError, MissingBaseBranchError, normalizeBaseBranch, renderBaseBranchBrief } from "./plan.ts";
441
+
442
+ test("normalizeBaseBranch: blank/whitespace/undefined → MissingBaseBranchError; a real branch is trimmed", () => {
443
+ assertThrows(() => normalizeBaseBranch(undefined), MissingBaseBranchError);
444
+ assertThrows(() => normalizeBaseBranch(null), MissingBaseBranchError);
445
+ assertThrows(() => normalizeBaseBranch(""), MissingBaseBranchError);
446
+ assertThrows(() => normalizeBaseBranch(" "), MissingBaseBranchError);
446
447
  assertEquals(normalizeBaseBranch(" epic/agent-protocol "), "epic/agent-protocol");
447
448
  });
448
449
 
@@ -450,6 +451,8 @@ test("normalizeBaseBranch: accepts conservative git-branch shapes", () => {
450
451
  assertEquals(normalizeBaseBranch("main"), "main");
451
452
  assertEquals(normalizeBaseBranch("release-1.2"), "release-1.2");
452
453
  assertEquals(normalizeBaseBranch("feature/x_y.z"), "feature/x_y.z");
454
+ // A plausible `epic/*` integration branch (the 019 convention) is returned unchanged.
455
+ assertEquals(normalizeBaseBranch("epic/agent-protocol"), "epic/agent-protocol");
453
456
  });
454
457
 
455
458
  test("normalizeBaseBranch: rejects injection-prone / implausible branch names", () => {
@@ -516,7 +519,7 @@ test("startPlan pins the base branch: persisted on the row + seeded as baseBranc
516
519
  assertEquals(seen.baseBranchBrief.includes("gh pr create --base epic/agent-protocol"), true);
517
520
  });
518
521
 
519
- test("startPlan without a base branch keeps default-branch behaviour (null row + null variables)", async () => {
522
+ test("startPlan renders baseBranchBrief unconditionally now that base is required", async () => {
520
523
  const PLAN_KEY = "owner/repo#200";
521
524
  const stores: Record<string, { rows: any[]; key: string }> = {
522
525
  plans: { rows: [], key: "plan_key" },
@@ -539,9 +542,254 @@ test("startPlan without a base branch keeps default-branch behaviour (null row +
539
542
  number: 200,
540
543
  url: "https://github.com/owner/repo/issues/200",
541
544
  planKey: PLAN_KEY,
545
+ }, "epic/gate-branch");
546
+
547
+ assertEquals((stores.plans.rows[0] as any).base_branch, "epic/gate-branch");
548
+ assertEquals(seen.baseBranch, "epic/gate-branch");
549
+ // The brief is always rendered — there is no null fork any more.
550
+ assertEquals(seen.baseBranchBrief.includes("gh pr create --base epic/gate-branch"), true);
551
+ });
552
+
553
+ test("startPlan grandfathers a pre-existing null base_branch row: re-plan reads it without error", async () => {
554
+ // Pre-ADR-0003 / in-flight rows carry base_branch = null (the column stays nullable). Re-planning
555
+ // such a finished issue must read the old null row without error and re-pin it to the new explicit
556
+ // base — the required-ness is enforced at admission of the new launch, not by a DB NOT NULL.
557
+ const PLAN_KEY = "owner/repo#201";
558
+ const stores: Record<string, { rows: any[]; key: string }> = {
559
+ plans: { rows: [{ plan_key: PLAN_KEY, status: "done", task_count: 0, base_branch: null }], key: "plan_key" },
560
+ plan_tasks: { rows: [], key: "id" },
561
+ plan_reviews: { rows: [], key: "plan_key" },
562
+ plan_escalations: { rows: [], key: "id" },
563
+ plan_task_deps: { rows: [], key: "plan_key" },
564
+ };
565
+ const data = memData(stores);
566
+ let seen: any = null;
567
+ const engine = {
568
+ createInstance: (req: any) => {
569
+ seen = req.variables;
570
+ return Promise.resolve({ processInstanceKey: "PI-3" });
571
+ },
572
+ } as any;
573
+
574
+ await startPlan(data, engine, {
575
+ repo: "owner/repo",
576
+ number: 201,
577
+ url: "https://github.com/owner/repo/issues/201",
578
+ planKey: PLAN_KEY,
579
+ }, "epic/gate-branch");
580
+
581
+ // The grandfathered null row is re-pinned to the new explicit base without throwing.
582
+ assertEquals((stores.plans.rows[0] as any).base_branch, "epic/gate-branch");
583
+ assertEquals(seen.baseBranch, "epic/gate-branch");
584
+ });
585
+
586
+ // ── admitPlan decision matrix (ADR 0003 §Decision, rules 1-4) ────────────────
587
+ // The fail-fast admission gate composes four ORDERED rules before any fan-out. These drive it
588
+ // through a faked github transport (token mode + stubbed `globalThis.fetch`) and an in-memory
589
+ // `plans` table, asserting each rule's accept/reject and that the ORDER is load-bearing.
590
+ import { BaseBranchMustExistError } from "./github.ts";
591
+ import { admitPlan, DefaultBaseNotConfirmedError, findActivePlansByBase, SharedBaseError } from "./plan.ts";
592
+
593
+ interface AdmitRepo {
594
+ repo: string;
595
+ defaultBranch: string;
596
+ branches: Set<string>;
597
+ creates: string[]; // refs created via POST
598
+ metaCalls: number; // GETs to /repos/:repo (default-branch resolution)
599
+ }
600
+
601
+ function admitFetch(state: AdmitRepo) {
602
+ return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
603
+ const u = new URL(String(url));
604
+ const method = (init?.method ?? "GET").toUpperCase();
605
+ const path = u.pathname;
606
+ if (method === "GET" && path === `/repos/${state.repo}`) {
607
+ state.metaCalls += 1;
608
+ return Promise.resolve(
609
+ new Response(JSON.stringify({ default_branch: state.defaultBranch }), {
610
+ status: 200,
611
+ headers: { "content-type": "application/json" },
612
+ }),
613
+ );
614
+ }
615
+ const refPrefix = `/repos/${state.repo}/git/ref/heads/`;
616
+ if (method === "GET" && path.startsWith(refPrefix)) {
617
+ const branch = decodeURIComponent(path.slice(refPrefix.length));
618
+ if (!state.branches.has(branch)) return Promise.resolve(new Response("Not Found", { status: 404 }));
619
+ return Promise.resolve(
620
+ new Response(JSON.stringify({ ref: `refs/heads/${branch}`, object: { sha: `${branch}-sha` } }), {
621
+ status: 200,
622
+ headers: { "content-type": "application/json" },
623
+ }),
624
+ );
625
+ }
626
+ if (method === "POST" && path === `/repos/${state.repo}/git/refs`) {
627
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
628
+ const body = JSON.parse(String(init?.body ?? "{}")) as { ref?: string };
629
+ const ref = String(body.ref ?? "");
630
+ const branch = ref.replace(/^refs\/heads\//, "");
631
+ if (state.branches.has(branch)) {
632
+ return Promise.resolve(new Response(JSON.stringify({ message: "Reference already exists" }), { status: 422 }));
633
+ }
634
+ state.creates.push(ref);
635
+ state.branches.add(branch);
636
+ return Promise.resolve(new Response(JSON.stringify({ ref }), { status: 201 }));
637
+ }
638
+ return Promise.resolve(new Response(`unexpected ${method} ${path}`, { status: 500 }));
639
+ };
640
+ }
641
+
642
+ async function withAdmit<T>(state: AdmitRepo, fn: () => Promise<T>): Promise<T> {
643
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
644
+ const prevFetch = globalThis.fetch;
645
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
646
+ globalThis.fetch = admitFetch(state) as typeof fetch;
647
+ try {
648
+ return await fn();
649
+ } finally {
650
+ globalThis.fetch = prevFetch;
651
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
652
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
653
+ }
654
+ }
655
+
656
+ function admitData(planRows: any[] = []) {
657
+ return memData({ plans: { rows: planRows, key: "plan_key" } });
658
+ }
659
+
660
+ test("admitPlan rule 1: blank/absent base → MissingBaseBranchError (before any github call)", async () => {
661
+ const state: AdmitRepo = { repo: "o/r1", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
662
+ await withAdmit(state, async () => {
663
+ await assertRejects(() => admitPlan(admitData(), state.repo, "", "tok"), MissingBaseBranchError);
664
+ await assertRejects(() => admitPlan(admitData(), state.repo, null, "tok"), MissingBaseBranchError);
542
665
  });
666
+ // Rule 1 fires before rule 2/3 — the default-branch endpoint is never hit.
667
+ assertEquals(state.metaCalls, 0);
668
+ assertEquals(state.creates.length, 0);
669
+ });
543
670
 
544
- assertEquals((stores.plans.rows[0] as any).base_branch, null);
545
- assertEquals(seen.baseBranch, null);
546
- assertEquals(seen.baseBranchBrief, null);
671
+ test("admitPlan rule 1: implausible base → InvalidBaseBranchError", async () => {
672
+ const state: AdmitRepo = { repo: "o/r1b", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
673
+ await withAdmit(state, async () => {
674
+ await assertRejects(() => admitPlan(admitData(), state.repo, "bad branch;rm -rf", "tok"), InvalidBaseBranchError);
675
+ });
676
+ assertEquals(state.metaCalls, 0);
677
+ });
678
+
679
+ test("admitPlan rule 2: missing non-epic/* base → BaseBranchMustExistError (synchronous edge-400 path)", async () => {
680
+ const state: AdmitRepo = { repo: "o/r2", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
681
+ await withAdmit(state, async () => {
682
+ await assertRejects(() => admitPlan(admitData(), state.repo, "release-9", "tok"), BaseBranchMustExistError);
683
+ });
684
+ assertEquals(state.creates.length, 0);
685
+ });
686
+
687
+ test("admitPlan rule 2: missing epic/* base → created off default HEAD, then admitted", async () => {
688
+ const state: AdmitRepo = { repo: "o/r3", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
689
+ const base = await withAdmit(state, () => admitPlan(admitData(), state.repo, "epic/new-thing", "tok"));
690
+ assertEquals(base, "epic/new-thing");
691
+ assertEquals(state.creates, ["refs/heads/epic/new-thing"]);
692
+ });
693
+
694
+ test("admitPlan rule 3: default-branch target WITHOUT confirmDefaultBase → DefaultBaseNotConfirmedError", async () => {
695
+ const state: AdmitRepo = { repo: "o/r4", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
696
+ await withAdmit(state, async () => {
697
+ await assertRejects(() => admitPlan(admitData(), state.repo, "main", "tok"), DefaultBaseNotConfirmedError);
698
+ });
699
+ });
700
+
701
+ test("admitPlan rule 3: default-branch target WITH confirmDefaultBase → admitted", async () => {
702
+ const state: AdmitRepo = { repo: "o/r5", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
703
+ const base = await withAdmit(state, () =>
704
+ admitPlan(admitData(), state.repo, "main", "tok", { confirmDefaultBase: true }),
705
+ );
706
+ assertEquals(base, "main");
707
+ });
708
+
709
+ test("admitPlan rule 4: active shared CUSTOM base WITHOUT allowSharedBase → SharedBaseError", async () => {
710
+ const state: AdmitRepo = { repo: "o/r6", defaultBranch: "main", branches: new Set(["main", "epic/shared"]), creates: [], metaCalls: 0 };
711
+ const planRows = [{ plan_key: "o/r6#1", repo: "o/r6", base_branch: "epic/shared", status: "planning" }];
712
+ await withAdmit(state, async () => {
713
+ await assertRejects(() => admitPlan(admitData(planRows), state.repo, "epic/shared", "tok"), SharedBaseError);
714
+ });
715
+ });
716
+
717
+ test("admitPlan rule 4: same-issue re-submit is admitted — selfPlanKey excludes the launch's OWN active row", async () => {
718
+ // Idempotency regression: startPlan short-circuits an in-flight plan to `alreadyRunning`, but that
719
+ // reachable only if admitPlan does NOT 409 the retry against the plan's own active row. With
720
+ // selfPlanKey set, the shared-base guard excludes that row, so the same-issue re-submit is admitted.
721
+ const state: AdmitRepo = { repo: "o/r6b", defaultBranch: "main", branches: new Set(["main", "epic/shared"]), creates: [], metaCalls: 0 };
722
+ const planRows = [{ plan_key: "o/r6b#1", repo: "o/r6b", base_branch: "epic/shared", status: "planning" }];
723
+ const base = await withAdmit(state, () =>
724
+ admitPlan(admitData(planRows), state.repo, "epic/shared", "tok", { selfPlanKey: "o/r6b#1" }),
725
+ );
726
+ assertEquals(base, "epic/shared");
727
+ });
728
+
729
+ test("admitPlan rule 4: a DIFFERENT active plan on the same base still trips the guard even with selfPlanKey set", async () => {
730
+ // selfPlanKey excludes only the launch's own row — a genuine collision with another epic still 409s.
731
+ const state: AdmitRepo = { repo: "o/r6c", defaultBranch: "main", branches: new Set(["main", "epic/shared"]), creates: [], metaCalls: 0 };
732
+ const planRows = [{ plan_key: "o/r6c#2", repo: "o/r6c", base_branch: "epic/shared", status: "planning" }];
733
+ await withAdmit(state, async () => {
734
+ await assertRejects(
735
+ () => admitPlan(admitData(planRows), state.repo, "epic/shared", "tok", { selfPlanKey: "o/r6c#1" }),
736
+ SharedBaseError,
737
+ );
738
+ });
739
+ });
740
+
741
+ test("admitPlan rule 4: same custom base WITH allowSharedBase → admitted", async () => {
742
+ const state: AdmitRepo = { repo: "o/r7", defaultBranch: "main", branches: new Set(["main", "epic/shared"]), creates: [], metaCalls: 0 };
743
+ const planRows = [{ plan_key: "o/r7#1", repo: "o/r7", base_branch: "epic/shared", status: "planning" }];
744
+ const base = await withAdmit(state, () =>
745
+ admitPlan(admitData(planRows), state.repo, "epic/shared", "tok", { allowSharedBase: true }),
746
+ );
747
+ assertEquals(base, "epic/shared");
748
+ });
749
+
750
+ test("admitPlan rule 4: two plans sharing the DEFAULT branch → always admitted (exempt)", async () => {
751
+ const state: AdmitRepo = { repo: "o/r8", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
752
+ // Another active plan already targets the default branch — the shared-base guard exempts it.
753
+ const planRows = [{ plan_key: "o/r8#1", repo: "o/r8", base_branch: "main", status: "planning" }];
754
+ const base = await withAdmit(state, () =>
755
+ admitPlan(admitData(planRows), state.repo, "main", "tok", { confirmDefaultBase: true }),
756
+ );
757
+ assertEquals(base, "main");
758
+ });
759
+
760
+ test("admitPlan rule 4: a TERMINAL-status plan on the same base does NOT trip the guard", async () => {
761
+ const state: AdmitRepo = { repo: "o/r9", defaultBranch: "main", branches: new Set(["main", "epic/done-base"]), creates: [], metaCalls: 0 };
762
+ const planRows = [{ plan_key: "o/r9#1", repo: "o/r9", base_branch: "epic/done-base", status: "done" }];
763
+ const base = await withAdmit(state, () => admitPlan(admitData(planRows), state.repo, "epic/done-base", "tok"));
764
+ assertEquals(base, "epic/done-base");
765
+ });
766
+
767
+ test("admitPlan ORDER: a blank base is rejected before confirm-default / shared-base run", async () => {
768
+ // Even with an active shared plan present AND the base being the default, rule 1 must fire first.
769
+ const state: AdmitRepo = { repo: "o/r10", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
770
+ const planRows = [{ plan_key: "o/r10#1", repo: "o/r10", base_branch: "main", status: "planning" }];
771
+ await withAdmit(state, async () => {
772
+ await assertRejects(() => admitPlan(admitData(planRows), state.repo, " ", "tok"), MissingBaseBranchError);
773
+ });
774
+ assertEquals(state.metaCalls, 0); // never reached rule 3
775
+ });
776
+
777
+ test("admitPlan ORDER: a typo'd non-epic base is rejected (rule 2) before confirm-default (rule 3)", async () => {
778
+ const state: AdmitRepo = { repo: "o/r11", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
779
+ await withAdmit(state, async () => {
780
+ await assertRejects(() => admitPlan(admitData(), state.repo, "mian", "tok"), BaseBranchMustExistError);
781
+ });
782
+ // ensureBaseBranch (rule 2) throws for the missing non-epic/* branch before fetchDefaultBranch (rule 3).
783
+ assertEquals(state.metaCalls, 0);
784
+ });
785
+
786
+ test("findActivePlansByBase returns only non-terminal plans on the matching repo + base", async () => {
787
+ const rows = [
788
+ { plan_key: "o/x#1", repo: "o/x", base_branch: "epic/b", status: "planning" },
789
+ { plan_key: "o/x#2", repo: "o/x", base_branch: "epic/b", status: "done" },
790
+ { plan_key: "o/x#3", repo: "o/x", base_branch: "epic/other", status: "planning" },
791
+ ];
792
+ const active = await findActivePlansByBase(admitData(rows), "o/x", "epic/b");
793
+ assertEquals(active.length, 1);
794
+ assertEquals(active[0].plan_key, "o/x#1");
547
795
  });
package/app/plan.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  // hand-written SQL — matching app/service.ts.
12
12
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
13
13
  import { blackboardUrl, mintBlackboardToken, renderCoordinationBrief } from "./blackboard.ts";
14
+ import { ensureBaseBranch, fetchDefaultBranch } from "./github.ts";
14
15
  import { clearExclusions } from "./mergeExclusion.ts";
15
16
  import { clearTaskDeltas } from "./taskDelta.ts";
16
17
  import { resolveTrialMergeAttention, trialMergeWaveFromTaskId } from "./trialMerge.ts";
@@ -71,9 +72,11 @@ export interface Plan {
71
72
  // Minted at plan start; baked into the blackboard URL handed to implementer agents. NULL for
72
73
  // plans created before the blackboard shipped.
73
74
  blackboard_token: string | null;
74
- // Optional target base branch (019_plan_base_branch.sql): when set, the fleet branches off this
75
- // branch and opens every task PR against it instead of the repository's default branch, landing
76
- // the whole epic on a long-lived integration branch. NULL keeps the default-branch behaviour.
75
+ // Target base branch (019_plan_base_branch.sql; ADR 0003): the fleet branches off this branch and
76
+ // opens every task PR against it instead of the repository's default branch, landing the whole
77
+ // epic on a long-lived integration branch. New launches always set it (base is required at
78
+ // admission); the column stays NULLABLE ONLY to grandfather pre-ADR-0003 / in-flight rows that
79
+ // carry NULL — those must remain readable, so do NOT add a NOT NULL migration.
77
80
  base_branch: string | null;
78
81
  created_at: string;
79
82
  updated_at: string;
@@ -258,6 +261,16 @@ export class InvalidBaseBranchError extends Error {
258
261
  }
259
262
  }
260
263
 
264
+ /** Raised when a caller supplies a blank/absent `baseBranch`. Every epic launch must name its base
265
+ * branch explicitly (ADR 0003): "land on the default branch" is a conscious, named, confirmed choice
266
+ * (the confirm-default gate), never a silent fallback. The operation edge maps this to a 400. */
267
+ export class MissingBaseBranchError extends Error {
268
+ constructor() {
269
+ super("base branch is required (blank/absent base branches are rejected)");
270
+ this.name = "MissingBaseBranchError";
271
+ }
272
+ }
273
+
261
274
  /** Conservative allowlist gate for a base-branch name. Stricter than `git check-ref-format` on
262
275
  * purpose: only `[A-Za-z0-9._/-]`, no leading `/`/`.`/`-` (a leading dash reads as a CLI flag),
263
276
  * no trailing `/`/`.`, no `..`/`//`, no empty or `.lock`-suffixed path component, bounded length.
@@ -270,13 +283,14 @@ function isPlausibleBranchName(s: string): boolean {
270
283
  return s.split("/").every((seg) => seg.length > 0 && !seg.startsWith(".") && !seg.endsWith(".lock"));
271
284
  }
272
285
 
273
- /** Normalise a caller-supplied base branch: trim, and treat blank as "unset" (null) so the fleet
274
- * falls back to the repository's default branch the legacy behaviour. A non-blank value that is
275
- * not a plausible git branch name is rejected (`InvalidBaseBranchError`) rather than persisted or
276
- * rendered into the agent prompt; the operation edge maps that to a 400. */
277
- export function normalizeBaseBranch(input: string | null | undefined): string | null {
286
+ /** Normalise a caller-supplied base branch: trim, then require it. A blank/absent value is rejected
287
+ * (`MissingBaseBranchError`) ADR 0003 removed the implicit default-branch fallback, so every epic
288
+ * launch must name its base explicitly. A non-blank value that is not a plausible git branch name is
289
+ * rejected (`InvalidBaseBranchError`) rather than persisted or rendered into the agent prompt. The
290
+ * operation edge maps both to a 400. Always returns a non-null branch on success. */
291
+ export function normalizeBaseBranch(input: string | null | undefined): string {
278
292
  const s = (input ?? "").trim();
279
- if (s.length === 0) return null;
293
+ if (s.length === 0) throw new MissingBaseBranchError();
280
294
  if (!isPlausibleBranchName(s)) throw new InvalidBaseBranchError(s);
281
295
  return s;
282
296
  }
@@ -304,13 +318,131 @@ export function renderBaseBranchBrief(baseBranch: string): string {
304
318
  ].join("\n");
305
319
  }
306
320
 
321
+ /** Raised when the explicit base branch IS the repository default branch but the caller did not
322
+ * acknowledge the consequence with `confirmDefaultBase: true` (ADR 0003 rule 3). Naming the default
323
+ * is the one dangerous explicit value: every task lands directly on it with no integration buffer,
324
+ * and any merge-to-default side effect fires per task. The operation edge maps this to a 400. */
325
+ export class DefaultBaseNotConfirmedError extends Error {
326
+ readonly branch: string;
327
+ constructor(branch: string) {
328
+ super(
329
+ `base branch "${branch}" is the repository default branch: every task would land directly ` +
330
+ `on "${branch}" with NO integration branch, and any merge-to-default side effect (e.g. ` +
331
+ `auto-publish) would fire per task. Re-submit with confirmDefaultBase: true to acknowledge ` +
332
+ `and proceed, or name an epic/* integration branch instead.`,
333
+ );
334
+ this.name = "DefaultBaseNotConfirmedError";
335
+ this.branch = branch;
336
+ }
337
+ }
338
+
339
+ /** Raised when another ACTIVE plan (status ∉ PLAN_TERMINAL_STATUSES) already targets the same repo
340
+ * + same custom base branch, and the caller did not pass `allowSharedBase: true` (ADR 0003 rule 4).
341
+ * Two in-flight epics sharing one integration branch interleave commits and poison each other's
342
+ * base. The default branch is EXEMPT (many epics target it concurrently without colliding — each
343
+ * task PR is independent). The operation edge maps this to a 409. */
344
+ export class SharedBaseError extends Error {
345
+ readonly repo: string;
346
+ readonly branch: string;
347
+ constructor(repo: string, branch: string) {
348
+ super(
349
+ `base branch "${branch}" on ${repo} is already in use by another active epic. Sharing one ` +
350
+ `integration branch across epics interleaves their commits and poisons the base. Re-submit ` +
351
+ `with allowSharedBase: true only if you intend to stack on it, or name a distinct epic/* ` +
352
+ `branch.`,
353
+ );
354
+ this.name = "SharedBaseError";
355
+ this.repo = repo;
356
+ this.branch = branch;
357
+ }
358
+ }
359
+
360
+ /** Find plans on `repo` targeting `base` whose status is NOT terminal (i.e. still active). Used by
361
+ * the shared-base admission guard to detect a second epic reaching for the same integration branch.
362
+ * Grandfathered `base_branch = null` rows never match a non-null `base`, so they are ignored. */
363
+ export async function findActivePlansByBase(
364
+ data: DataLayer,
365
+ repo: string,
366
+ base: string,
367
+ ): Promise<Plan[]> {
368
+ const rows = await plans(data).find({ repo, base_branch: base });
369
+ return rows.filter((p) => !PLAN_TERMINAL_STATUSES.includes(p.status));
370
+ }
371
+
372
+ /** Options gating the confirm-default (rule 3) and shared-base (rule 4) admission rules. Both
373
+ * default to `false` — a "warn you can't skip": the operator must consciously opt in. */
374
+ export interface AdmitPlanOptions {
375
+ allowSharedBase?: boolean;
376
+ confirmDefaultBase?: boolean;
377
+ /** The `plan_key` of the launch being admitted. When set, the shared-base guard (rule 4)
378
+ * EXCLUDES this plan's own active row, so an idempotent re-submit of the same issue does not
379
+ * trip `SharedBaseError` against itself — `startPlan` is idempotent on `plan_key` and returns
380
+ * `alreadyRunning` for an active plan, so the retry must reach it, not 409 on rule 4. */
381
+ selfPlanKey?: string;
382
+ }
383
+
384
+ /** Fail-fast admission gate for an epic launch (ADR 0003 §Decision). Composes the four ordered
385
+ * admission rules BEFORE any task fans out and returns the normalized base branch on success. The
386
+ * ORDER is load-bearing — the cheapest / most fundamental reject (missing or typo'd base) fires
387
+ * first, so it is NOT reordered:
388
+ *
389
+ * 1. Required + explicit — `normalizeBaseBranch` (blank/absent → `MissingBaseBranchError`;
390
+ * implausible → `InvalidBaseBranchError`).
391
+ * 2. Create-if-missing (epic/* guard), synchronously — `ensureBaseBranch`: a missing non-`epic/*`
392
+ * base throws `BaseBranchMustExistError` HERE (so a typo is a clean edge 400, not a late
393
+ * per-task failure); a missing `epic/*` base is created off default HEAD before fan-out; an
394
+ * existing base is a no-op. It is idempotent, so the durable `ensure-base-branch` head task
395
+ * re-runs it as belt-and-suspenders.
396
+ * 3. Confirm-default — if the base equals the repo default branch and `confirmDefaultBase` is not
397
+ * `true`, throw `DefaultBaseNotConfirmedError`. The default branch is then EXEMPT from rule 4.
398
+ * 4. Shared-base — if a DIFFERENT active plan already targets this same custom base and
399
+ * `allowSharedBase` is not `true`, throw `SharedBaseError`. The launch's own active row is
400
+ * excluded (via `options.selfPlanKey`) so an idempotent same-issue re-submit is not a 409.
401
+ */
402
+ export async function admitPlan(
403
+ data: DataLayer,
404
+ repo: string,
405
+ baseBranch: string | null | undefined,
406
+ token: string,
407
+ options: AdmitPlanOptions = {},
408
+ ): Promise<string> {
409
+ // Rule 1 — required + explicit.
410
+ const base = normalizeBaseBranch(baseBranch);
411
+
412
+ // Rule 2 — create-if-missing (epic/* guard), synchronously at admission. A missing non-epic/*
413
+ // base throws BaseBranchMustExistError → clean edge 400; a missing epic/* base is created off
414
+ // default HEAD; an existing base is a no-op. Idempotent, so the head task safely re-runs it.
415
+ await ensureBaseBranch(repo, base, token);
416
+
417
+ // Rule 3 — confirm-default. Naming the repo default branch is deliberate and requires an explicit
418
+ // acknowledgement. When the base IS the default, it is exempt from the shared-base guard (rule 4),
419
+ // so return here on a confirmed default.
420
+ const defaultBranch = await fetchDefaultBranch(repo, token);
421
+ if (defaultBranch !== null && base === defaultBranch) {
422
+ if (options.confirmDefaultBase !== true) throw new DefaultBaseNotConfirmedError(base);
423
+ return base;
424
+ }
425
+
426
+ // Rule 4 — shared-base guard on a custom integration branch. Exclude this launch's OWN active row
427
+ // (when `selfPlanKey` is given) so an idempotent same-issue re-submit reaches `startPlan`'s
428
+ // `alreadyRunning` short-circuit instead of tripping a 409 against itself.
429
+ if (options.allowSharedBase !== true) {
430
+ const active = (await findActivePlansByBase(data, repo, base)).filter(
431
+ (p) => p.plan_key !== options.selfPlanKey,
432
+ );
433
+ if (active.length > 0) throw new SharedBaseError(repo, base);
434
+ }
435
+
436
+ return base;
437
+ }
438
+
307
439
  /** Register a plan row (if new) and start the plan-fanout process. Idempotent on
308
440
  * planKey: a plan already in flight is not restarted. */
309
441
  export async function startPlan(
310
442
  data: DataLayer,
311
443
  engine: EngineClient,
312
444
  parsed: ParsedIssue,
313
- baseBranch: string | null = null,
445
+ baseBranch: string,
314
446
  ) {
315
447
  const table = plans(data);
316
448
  const existing = await table.get(parsed.planKey);
@@ -399,12 +531,12 @@ export async function startPlan(
399
531
  // out-of-band.
400
532
  blackboardUrl: bbUrl,
401
533
  blackboardBrief: renderCoordinationBrief(bbUrl),
402
- // Optional epic base branch (019_plan_base_branch.sql): the branch the fleet branches off and
403
- // opens every PR against instead of the repo default. `baseBranchBrief` rides `appendPrompt`
404
- // in the implement-task (like `blackboardBrief`); both are null when no base branch is pinned,
405
- // so the agent keeps the default-branch behaviour from prompts/feature.md.
534
+ // Epic base branch (019_plan_base_branch.sql; ADR 0003): the branch the fleet branches off
535
+ // and opens every PR against instead of the repo default. `baseBranchBrief` rides
536
+ // `appendPrompt` in the implement-task (like `blackboardBrief`). Base is now always explicit
537
+ // (normalizeBaseBranch rejects blank), so the brief is always rendered.
406
538
  baseBranch: base,
407
- baseBranchBrief: base == null ? null : renderBaseBranchBrief(base),
539
+ baseBranchBrief: renderBaseBranchBrief(base),
408
540
  },
409
541
  });
410
542
  const processKey = processInstanceKey == null ? null : String(processInstanceKey);
package/nano.app.json CHANGED
@@ -63,6 +63,10 @@
63
63
  }
64
64
  ],
65
65
  "workers": [
66
+ {
67
+ "taskType": "pr.ensure-base-branch",
68
+ "handler": "workers/ensure-base-branch/worker.ts"
69
+ },
66
70
  {
67
71
  "taskType": "pr.persist-round",
68
72
  "handler": "workers/persist-round/worker.ts"