# Merge Target: Feature Branch Mode Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add a `merge_target` policy field so features can merge to a new `aop/<feature-id>` branch instead of directly to main, enabling PR-based workflows.

**Architecture:** A single policy field (`merge_target: base_branch | feature_branch`) controls the merge destination. When `feature_branch`, the merge service creates a branch from main, merges the feature there, and preserves that branch during cleanup. All validation/gate logic unchanged.

**Tech Stack:** TypeScript, Vitest, JSON Schema (draft-2020-12), YAML

**Spec:** `docs/superpowers/specs/2026-03-29-merge-target-feature-branch-design.md`

---

## File Map

| File                                                           | Action | Responsibility                            |
| :------------------------------------------------------------- | :----- | :---------------------------------------- |
| `agentic/orchestrator/schemas/policy.schema.json`              | Modify | Add `merge_target` enum to `merge_policy` |
| `agentic/orchestrator/schemas/policy.user.schema.json`         | Modify | Add `merge_target` enum to user schema    |
| `agentic/orchestrator/defaults/policy.defaults.yaml`           | Modify | Add `merge_target: base_branch` default   |
| `apps/control-plane/src/application/services/merge-service.ts` | Modify | Branch creation and merge target routing  |
| `apps/control-plane/src/cli/init-command-handler.ts`           | Modify | Add `merge_target` to policy generators   |
| `apps/control-plane/test/merge-service.spec.ts`                | Modify | Tests for feature_branch merge target     |

---

### Task 1: Add merge_target to Policy Schema and Defaults

**Files:**

- Modify: `agentic/orchestrator/schemas/policy.schema.json:442-460`
- Modify: `agentic/orchestrator/schemas/policy.user.schema.json:184-202`
- Modify: `agentic/orchestrator/defaults/policy.defaults.yaml:85-90`

- [ ] **Step 1: Add merge_target to policy.schema.json**

In `agentic/orchestrator/schemas/policy.schema.json`, in the `merge_policy.properties` object (after the `allowed_strategies` property, around line 458), add:

```json
        "merge_target": {
          "type": "string",
          "enum": ["base_branch", "feature_branch"],
          "description": "Where to land the merge. 'base_branch' merges directly to the base branch. 'feature_branch' creates an aop/<feature-id> branch off the base branch and merges there."
        }
```

Do NOT add `merge_target` to the `required` array — it defaults to `base_branch` when omitted.

- [ ] **Step 2: Add merge_target to policy.user.schema.json**

In `agentic/orchestrator/schemas/policy.user.schema.json`, in the `merge_policy.properties` object (after `allowed_strategies`, around line 200), add:

```json
        "merge_target": {
          "type": "string",
          "enum": ["base_branch", "feature_branch"],
          "description": "Where to land the merge. 'base_branch' merges directly to main. 'feature_branch' creates an aop/<feature-id> branch."
        }
```

- [ ] **Step 3: Add merge_target default to policy.defaults.yaml**

In `agentic/orchestrator/defaults/policy.defaults.yaml`, add `merge_target` to the `merge_policy` section (after `allowed_strategies`, around line 90):

```yaml
merge_policy:
  require_user_approval: true
  allowed_strategies:
    - merge_commit
    - squash
    - rebase
  merge_target: base_branch
```

- [ ] **Step 4: Run schema validation**

Run: `npm run validate:mcp-contracts`
Expected: Pass (no schema errors).

Run: `npx vitest run apps/control-plane/test/policy-loader-service.spec.ts`
Expected: Pass (defaults compose correctly).

- [ ] **Step 5: Commit**

```bash
git add agentic/orchestrator/schemas/policy.schema.json agentic/orchestrator/schemas/policy.user.schema.json agentic/orchestrator/defaults/policy.defaults.yaml
git commit -m "feat: add merge_target field to policy schema and defaults"
```

---

### Task 2: Add merge_target to Init Command

**Files:**

- Modify: `apps/control-plane/src/cli/init-command-handler.ts:198-199` (lean policy)
- Modify: `apps/control-plane/src/cli/init-command-handler.ts:289-294` (full policy)

- [ ] **Step 1: Add merge_target to generateLeanPolicyYaml**

In `apps/control-plane/src/cli/init-command-handler.ts`, in `generateLeanPolicyYaml()`, change the `merge_policy` block (around line 198) from:

```yaml
merge_policy:
  require_user_approval: true
```

to:

```yaml
merge_policy:
  require_user_approval: true
  merge_target: base_branch
```

- [ ] **Step 2: Add merge_target to generateFullPolicyYaml**

In the same file, in `generateFullPolicyYaml()`, change the `merge_policy` block (around line 289) from:

```yaml
merge_policy:
  require_user_approval: true
  allowed_strategies:
    - merge_commit
    - squash
    - rebase
```

to:

```yaml
merge_policy:
  require_user_approval: true
  allowed_strategies:
    - merge_commit
    - squash
    - rebase
  merge_target: base_branch
```

- [ ] **Step 3: Run init tests**

Run: `npx vitest run apps/control-plane/test/init-wizard.spec.ts`
Expected: All 31 tests pass.

- [ ] **Step 4: Commit**

```bash
git add apps/control-plane/src/cli/init-command-handler.ts
git commit -m "feat: include merge_target in aop init generated policy"
```

---

### Task 3: Implement Feature Branch Merge Target in MergeService

**Files:**

- Modify: `apps/control-plane/src/application/services/merge-service.ts:448-489` (merge execution)
- Modify: `apps/control-plane/src/application/services/merge-service.ts:571-588` (evidence)
- Modify: `apps/control-plane/src/application/services/merge-service.ts:171-196` (cleanup)
- Modify: `apps/control-plane/src/application/services/merge-service.ts:661-670` (return data)

- [ ] **Step 1: Add merge target resolution**

In `apps/control-plane/src/application/services/merge-service.ts`, in the `featureReadyToMerge` method, after the `baseBranch` variable is set (line 318), add merge target resolution:

```typescript
const mergeTarget: string =
  typeof policy.merge_policy.merge_target === 'string'
    ? policy.merge_policy.merge_target
    : 'base_branch';
const isFeatureBranchTarget = mergeTarget === 'feature_branch';
const targetBranch = isFeatureBranchTarget ? `aop/${featureId}` : baseBranch;
```

- [ ] **Step 2: Replace checkout-base with target branch creation/checkout**

Replace the checkout block (lines 448-461) from:

```typescript
const checkoutBase = await runGit(repoRoot, ['checkout', baseBranch]);
if (checkoutBase.code !== 0) {
  throw {
    normalizedResponse: fail(ERROR_CODES.GIT_FAILURE, 'Failed to checkout base branch for merge', {
      stderr: checkoutBase.stderr,
      retryable: true,
      requires_human: true,
    }),
  };
}
```

with:

```typescript
if (isFeatureBranchTarget) {
  // Delete stale target branch if it exists (from a prior failed attempt)
  await runGit(repoRoot, ['branch', '-D', targetBranch]);

  const createBranch = await runGit(repoRoot, ['branch', targetBranch, baseBranch]);
  if (createBranch.code !== 0) {
    throw {
      normalizedResponse: fail(
        ERROR_CODES.GIT_FAILURE,
        `Failed to create target branch ${targetBranch}`,
        {
          stderr: createBranch.stderr,
          retryable: true,
          requires_human: true,
        },
      ),
    };
  }
}

const checkoutTarget = await runGit(repoRoot, ['checkout', targetBranch]);
if (checkoutTarget.code !== 0) {
  throw {
    normalizedResponse: fail(
      ERROR_CODES.GIT_FAILURE,
      `Failed to checkout ${targetBranch} for merge`,
      {
        stderr: checkoutTarget.stderr,
        retryable: true,
        requires_human: true,
      },
    ),
  };
}
```

- [ ] **Step 3: Update merge commit messages to include target branch**

In the merge strategy block (lines 464-489), update the merge commit message to reference the target:

Change:

```typescript
        '-m',
        `Merge feature ${featureId}`,
```

to:

```typescript
        '-m',
        `Merge feature ${featureId} into ${targetBranch}`,
```

And for squash:

```typescript
merge = await runGit(repoRoot, ['commit', '-m', `Squash merge ${featureId}`]);
```

to:

```typescript
merge = await runGit(repoRoot, ['commit', '-m', `Squash merge ${featureId} into ${targetBranch}`]);
```

- [ ] **Step 4: Update merge evidence to include target_branch**

In the state update (around line 574), add `target_branch` to the evidence:

Change:

```typescript
          evidence: {
            ...(frontMatter.evidence ?? {}),
            merge_sha: mergeSha,
            merge_strategy: mergeStrategy,
            merge_at: nowIso(),
          },
```

to:

```typescript
          evidence: {
            ...(frontMatter.evidence ?? {}),
            merge_sha: mergeSha,
            merge_strategy: mergeStrategy,
            merge_target: targetBranch,
            merge_at: nowIso(),
          },
```

- [ ] **Step 5: Update cleanup to preserve target branch**

In `cleanupMergedFeatureArtifacts` (line 171), the method currently deletes the feature branch (`featureId`). When `merge_target: feature_branch`, the `aop/<featureId>` branch must be preserved.

The cleanup method doesn't have access to the target branch info. The simplest approach: the method already receives `featureId` and only deletes the branch named `featureId` (line 188: `git branch -D featureId`). Since the target branch is named `aop/<featureId>` (different name), it is already not deleted by cleanup. **No change needed** — the existing cleanup code naturally preserves the target branch.

Verify this by reading line 188:

```typescript
const removeBranchResult = await runGit(repoRoot, ['branch', '-D', featureId]);
```

This deletes the feature branch (`featureId`), not `aop/featureId`. The target branch survives.

- [ ] **Step 6: Update return data to include target_branch**

In the return statement (around line 661), add `target_branch`:

Change:

```typescript
return {
  data: {
    feature_id: featureId,
    merge_sha: mergeSha,
    merge_strategy: mergeStrategy,
    status: STATUS.MERGED,
    retained_for_cleanup: !shouldCleanupNow,
    cleanup_grace_period_seconds: gracePeriodSeconds,
  },
};
```

to:

```typescript
return {
  data: {
    feature_id: featureId,
    merge_sha: mergeSha,
    merge_strategy: mergeStrategy,
    merge_target: targetBranch,
    status: STATUS.MERGED,
    retained_for_cleanup: !shouldCleanupNow,
    cleanup_grace_period_seconds: gracePeriodSeconds,
  },
};
```

Also update the return type annotation to include `merge_target: string`:

```typescript
  ): Promise<{
    data: {
      feature_id: string;
      merge_sha: string;
      merge_strategy: string;
      merge_target: string;
      status: string;
      retained_for_cleanup?: boolean;
      cleanup_grace_period_seconds?: number;
    };
  }> {
```

- [ ] **Step 7: Verify compilation**

Run: `npx tsc --noEmit`
Expected: Clean.

- [ ] **Step 8: Commit**

```bash
git add apps/control-plane/src/application/services/merge-service.ts
git commit -m "feat: implement feature_branch merge target in merge service"
```

---

### Task 4: Add Tests for Feature Branch Merge Target

**Files:**

- Modify: `apps/control-plane/test/merge-service.spec.ts`

- [ ] **Step 1: Add test — merge_target base_branch preserves current behavior**

Append to the `describe('MergeService', ...)` block in `apps/control-plane/test/merge-service.spec.ts`:

```typescript
it('GIVEN_merge_target_is_base_branch_WHEN_feature_merges_THEN_merges_directly_to_main', async () => {
  const harness = makeHarness({
    policyOverrides: {
      merge_policy: {
        allowed_strategies: ['merge_commit'],
        require_user_approval: false,
        merge_target: 'base_branch',
      },
    },
  });
  vi.mocked(runGit)
    .mockResolvedValueOnce(commandResult()) // add -A
    .mockResolvedValueOnce(commandResult()) // commit
    .mockResolvedValueOnce(commandResult({ stdout: '1\n' })) // rev-list ahead count
    .mockResolvedValueOnce(commandResult()) // status check
    .mockResolvedValueOnce(commandResult()) // checkout main
    .mockResolvedValueOnce(commandResult()) // merge
    .mockResolvedValueOnce(commandResult({ stdout: 'abc123\n' })); // rev-parse

  const result = await harness.service.featureReadyToMerge(
    'feature_merge',
    'merge commit',
    'merge_commit',
    null,
  );

  expect(result.data.merge_sha).toBe('abc123');
  expect(result.data.merge_target).toBe('main');
  // Verify checkout was to main, not aop/ branch
  const checkoutCall = vi.mocked(runGit).mock.calls[4];
  expect(checkoutCall[1]).toEqual(['checkout', 'main']);
});
```

- [ ] **Step 2: Add test — merge_target feature_branch creates aop/ branch and merges there**

```typescript
it('GIVEN_merge_target_is_feature_branch_WHEN_feature_merges_THEN_creates_aop_branch_and_merges_there', async () => {
  const harness = makeHarness({
    policyOverrides: {
      merge_policy: {
        allowed_strategies: ['merge_commit'],
        require_user_approval: false,
        merge_target: 'feature_branch',
      },
    },
  });
  vi.mocked(runGit)
    .mockResolvedValueOnce(commandResult()) // add -A
    .mockResolvedValueOnce(commandResult()) // commit
    .mockResolvedValueOnce(commandResult({ stdout: '1\n' })) // rev-list ahead count
    .mockResolvedValueOnce(commandResult()) // status check
    .mockResolvedValueOnce(commandResult({ code: 1 })) // branch -D aop/feature_merge (stale — not found, OK)
    .mockResolvedValueOnce(commandResult()) // branch aop/feature_merge main
    .mockResolvedValueOnce(commandResult()) // checkout aop/feature_merge
    .mockResolvedValueOnce(commandResult()) // merge
    .mockResolvedValueOnce(commandResult({ stdout: 'def456\n' })); // rev-parse

  const result = await harness.service.featureReadyToMerge(
    'feature_merge',
    'merge commit',
    'merge_commit',
    null,
  );

  expect(result.data.merge_sha).toBe('def456');
  expect(result.data.merge_target).toBe('aop/feature_merge');
  // Verify branch creation from main
  const branchCreateCall = vi.mocked(runGit).mock.calls[5];
  expect(branchCreateCall[1]).toEqual(['branch', 'aop/feature_merge', 'main']);
  // Verify checkout to aop/ branch
  const checkoutCall = vi.mocked(runGit).mock.calls[6];
  expect(checkoutCall[1]).toEqual(['checkout', 'aop/feature_merge']);
});
```

- [ ] **Step 3: Add test — feature_branch mode leaves main untouched**

```typescript
it('GIVEN_merge_target_is_feature_branch_WHEN_merge_completes_THEN_main_is_untouched', async () => {
  const harness = makeHarness({
    policyOverrides: {
      merge_policy: {
        allowed_strategies: ['merge_commit'],
        require_user_approval: false,
        merge_target: 'feature_branch',
      },
    },
  });
  vi.mocked(runGit)
    .mockResolvedValueOnce(commandResult()) // add -A
    .mockResolvedValueOnce(commandResult()) // commit
    .mockResolvedValueOnce(commandResult({ stdout: '1\n' })) // rev-list
    .mockResolvedValueOnce(commandResult()) // status
    .mockResolvedValueOnce(commandResult({ code: 1 })) // branch -D (stale)
    .mockResolvedValueOnce(commandResult()) // branch create
    .mockResolvedValueOnce(commandResult()) // checkout aop/
    .mockResolvedValueOnce(commandResult()) // merge
    .mockResolvedValueOnce(commandResult({ stdout: 'sha1\n' })); // rev-parse

  await harness.service.featureReadyToMerge('feature_merge', 'merge commit', 'merge_commit', null);

  // No git call should have 'checkout main' — verify none of the calls checkout main
  const allCalls = vi.mocked(runGit).mock.calls;
  const checkoutMainCalls = allCalls.filter(
    (call) => Array.isArray(call[1]) && call[1][0] === 'checkout' && call[1][1] === 'main',
  );
  expect(checkoutMainCalls).toHaveLength(0);
});
```

- [ ] **Step 4: Add test — cleanup preserves aop/ branch**

```typescript
it('GIVEN_merge_target_is_feature_branch_WHEN_cleanup_runs_THEN_aop_branch_is_preserved', async () => {
  const harness = makeHarness({
    policyOverrides: {
      merge_policy: {
        allowed_strategies: ['merge_commit'],
        require_user_approval: false,
        merge_target: 'feature_branch',
      },
      cleanup: {
        auto_after_merge: true,
        grace_period_seconds: 0,
      },
    },
  });
  vi.mocked(runGit)
    .mockResolvedValueOnce(commandResult()) // add -A
    .mockResolvedValueOnce(commandResult()) // commit
    .mockResolvedValueOnce(commandResult({ stdout: '1\n' })) // rev-list
    .mockResolvedValueOnce(commandResult()) // status
    .mockResolvedValueOnce(commandResult({ code: 1 })) // branch -D stale
    .mockResolvedValueOnce(commandResult()) // branch create
    .mockResolvedValueOnce(commandResult()) // checkout aop/
    .mockResolvedValueOnce(commandResult()) // merge
    .mockResolvedValueOnce(commandResult({ stdout: 'sha1\n' })) // rev-parse
    .mockResolvedValueOnce(commandResult()) // worktree remove (cleanup)
    .mockResolvedValueOnce(commandResult()); // branch -D feature_merge (cleanup)

  await harness.service.featureReadyToMerge('feature_merge', 'merge commit', 'merge_commit', null);

  // Cleanup deletes the feature branch, not the aop/ branch
  const branchDeleteCalls = vi
    .mocked(runGit)
    .mock.calls.filter(
      (call) => Array.isArray(call[1]) && call[1][0] === 'branch' && call[1][1] === '-D',
    );
  const deletedBranches = branchDeleteCalls.map((call) => (call[1] as string[])[2]);
  expect(deletedBranches).toContain('feature_merge');
  expect(deletedBranches).not.toContain('aop/feature_merge');
});
```

- [ ] **Step 5: Add test — merge_target omitted defaults to base_branch**

```typescript
it('GIVEN_merge_target_omitted_WHEN_feature_merges_THEN_defaults_to_base_branch', async () => {
  const harness = makeHarness(); // No merge_target in policy
  vi.mocked(runGit)
    .mockResolvedValueOnce(commandResult()) // add -A
    .mockResolvedValueOnce(commandResult()) // commit
    .mockResolvedValueOnce(commandResult({ stdout: '1\n' })) // rev-list
    .mockResolvedValueOnce(commandResult()) // status
    .mockResolvedValueOnce(commandResult()) // checkout main
    .mockResolvedValueOnce(commandResult()) // merge
    .mockResolvedValueOnce(commandResult({ stdout: 'abc\n' })); // rev-parse

  const result = await harness.service.featureReadyToMerge(
    'feature_merge',
    'merge commit',
    'merge_commit',
    null,
  );

  expect(result.data.merge_target).toBe('main');
  // Checkout was directly to main
  const checkoutCall = vi.mocked(runGit).mock.calls[4];
  expect(checkoutCall[1]).toEqual(['checkout', 'main']);
});
```

- [ ] **Step 6: Add test — squash strategy works with feature_branch target**

```typescript
it('GIVEN_merge_target_is_feature_branch_with_squash_WHEN_feature_merges_THEN_squash_lands_on_aop_branch', async () => {
  const harness = makeHarness({
    policyOverrides: {
      merge_policy: {
        allowed_strategies: ['squash'],
        require_user_approval: false,
        merge_target: 'feature_branch',
      },
    },
  });
  vi.mocked(runGit)
    .mockResolvedValueOnce(commandResult()) // add -A
    .mockResolvedValueOnce(commandResult()) // commit
    .mockResolvedValueOnce(commandResult({ stdout: '1\n' })) // rev-list
    .mockResolvedValueOnce(commandResult()) // status
    .mockResolvedValueOnce(commandResult({ code: 1 })) // branch -D stale
    .mockResolvedValueOnce(commandResult()) // branch create
    .mockResolvedValueOnce(commandResult()) // checkout aop/
    .mockResolvedValueOnce(commandResult()) // merge --squash
    .mockResolvedValueOnce(commandResult()) // commit (squash)
    .mockResolvedValueOnce(commandResult({ stdout: 'squash-sha\n' })); // rev-parse

  const result = await harness.service.featureReadyToMerge(
    'feature_merge',
    'merge commit',
    'squash',
    null,
  );

  expect(result.data.merge_sha).toBe('squash-sha');
  expect(result.data.merge_target).toBe('aop/feature_merge');
});
```

- [ ] **Step 7: Run the tests**

Run: `npx vitest run apps/control-plane/test/merge-service.spec.ts`
Expected: All tests pass (existing + 6 new).

- [ ] **Step 8: Commit**

```bash
git add apps/control-plane/test/merge-service.spec.ts
git commit -m "test: add merge_target feature_branch tests to merge service"
```

---

### Task 5: Final Verification & Progress Update

**Files:**

- Modify: `spec-files/progress.md`

- [ ] **Step 1: Run full test suite**

Run: `npx vitest run`
Expected: All tests pass.

- [ ] **Step 2: Run typecheck**

Run: `npx tsc --noEmit`
Expected: Clean.

- [ ] **Step 3: Run lint**

Run: `npm run lint`
Expected: Clean.

- [ ] **Step 4: Run schema validators**

Run: `npm run validate:mcp-contracts`
Expected: Pass.

- [ ] **Step 5: Update progress.md**

Append entries to `spec-files/progress.md`:

```markdown
✅ **Entry N — Add merge_target policy field (schema, defaults, init)**

- **Goal:** Enable policy-driven control over whether features merge to main or to a PR branch.
- **Changes made:**
  - `agentic/orchestrator/schemas/policy.schema.json` — Added `merge_target` enum (`base_branch`|`feature_branch`) to `merge_policy`.
  - `agentic/orchestrator/schemas/policy.user.schema.json` — Added `merge_target` to user schema.
  - `agentic/orchestrator/defaults/policy.defaults.yaml` — Added `merge_target: base_branch` default.
  - `apps/control-plane/src/cli/init-command-handler.ts` — Added `merge_target: base_branch` to lean and full policy generators.
- **Result:** Schema validation ✅. Init tests ✅.

✅ **Entry N+1 — Implement feature_branch merge target in merge service**

- **Goal:** When `merge_target: feature_branch`, create `aop/<feature-id>` branch from main and merge there instead of directly to main.
- **Changes made:**
  - `apps/control-plane/src/application/services/merge-service.ts` — Added merge target resolution, branch creation/checkout for feature_branch mode, target_branch in evidence and return data, stale branch cleanup before creation.
  - `apps/control-plane/test/merge-service.spec.ts` — 6 tests added: base_branch behavior, feature_branch creation, main untouched, cleanup preserves aop/ branch, default fallback, squash strategy interaction.
- **Result:** `npm test` ✅. `npm run lint` ✅. `npm run typecheck` ✅.
```

- [ ] **Step 6: Commit**

```bash
git add spec-files/progress.md
git commit -m "docs: update progress.md with merge_target entries"
```

---

## Task Dependency Graph

```
Task 1 (schema + defaults) ──┬── Task 3 (merge service) ── Task 4 (tests) ──┐
Task 2 (init command) ───────┘                                               │
                                                              Task 5 (verify) ┘
```

- Tasks 1 and 2 are independent and can run in parallel.
- Task 3 depends on Task 1 (schema must exist for policy validation).
- Task 4 depends on Task 3 (needs the implementation to test).
- Task 5 depends on all prior tasks.
