Skip to content

feat: stack-project tool for read-only gh-stack projection - #25

Open
bitswell wants to merge 5 commits into
mainfrom
loom/ratchet-stack-project
Open

bitswell wants to merge 5 commits into
mainfrom
loom/ratchet-stack-project

Conversation

@bitswell

Copy link
Copy Markdown
Owner

Summary

  • Adds a stack-project orchestrator-only tool that publishes a read-only stacked-PR projection of an integrated LOOM epic via gh-stack.
  • Mirror branches are built by walking git rev-list --reverse base..layer.branch and cherry-picking onto the previous mirror tip — never force-branching to workspace merge commits — which avoids the workspace-history leak Drift flagged in the original Team 1 proposal review.
  • Zero changes to any existing tool source file. No new protocol surface. No new commit trailers. No schema edits.

Closes #24. Picked from the gh-stack RFP round — Team 1 "read-only presentation projection" was the winning angle.

What changed

File Change
src/tools/stack-project.ts NEW — 137 lines, orchestrator-only tool
tests/tools/stack-project.test.ts NEW — 249 lines, 6 vitest scenarios
src/tools/index.ts Registration only (import + export + register call)
tests/runtime/mcp-server.test.ts Tool count fixture bumped 24→25, name list updated (orchestrator post-terminal commits)

Mirror construction algorithm

prev = base   # e.g. "main"
for each layer (NN = 01, 02, ...):
  mirror = stack/<epic>/<NN>-<agent>-<slug>
  commits = git rev-list --reverse base..layer.branch
  git branch -f mirror prev
  git checkout mirror
  git cherry-pick <commits>
  prev = mirror

After all mirrors are built: gh stack init --base <base> --adopt <mirrors...> followed by gh stack submit --auto (with --draft if input.draft). If reproject: true, gh stack unstack runs first. Any error before publish bails with a typed err() — no half-published stack.

Schema

const StackProjectInput = z.object({
  epic: z.string(),
  order: z.array(z.object({
    agent: z.string(),
    slug: z.string(),
    branch: z.string(),
  })),
  base: z.string().optional(),       // default: 'main'
  draft: z.boolean().optional(),      // default: true
  reproject: z.boolean().optional(),  // default: false
});

const StackProjectOutput = z.object({
  mirrorBranches: z.array(z.string()),
  prUrls: z.array(z.string()),
});

roles: ['orchestrator'] — matches pr-create.ts precedent.

Deviation from the issue spec: spec used z.string().default('main') etc.; switched to .optional() with handler-side defaults (input.base ?? 'main') because Zod .default() conflicts with the existing Tool<I,O> typing. Runtime behavior is identical.

Test coverage

tests/tools/stack-project.test.ts — 6/6 pass:

  1. Happy path: 3-layer epic → mirrors built via cherry-pick → gh stack init --adopt + gh stack submit --auto --draft invoked with correct args
  2. reproject: true invokes gh stack unstack as the first exec call
  3. Cherry-pick failure returns err('mirror-cherry-pick-failed', ...) and never calls gh stack init
  4. gh stack init failure returns err('stack-init-failed', ...) and never calls gh stack submit
  5. draft: false omits --draft from the submit command
  6. roles includes exactly orchestrator (role-gating assertion)

Test plan

  • npm test — 403/403 passing
  • npm run lint (= tsc --noEmit) — zero errors
  • Sanity-check the cherry-pick algorithm on a real 2-agent epic in staging (out of scope for this PR; verified via mocked tests)
  • loom-plugin recipe PR (separate follow-up) documenting when the orchestrator should call stack-project — this PR is loom-tools only

🤖 Generated with Claude Code

…ojection

Implement the stack-project orchestrator-only tool per the design in
issue #24. The tool publishes a stacked-PR ladder over an
already-integrated LOOM epic without modifying any existing protocol
surface. This is the winner of the gh-stack RFP round
(bitswell/bitswell#74, Team 1 — read-only presentation projection).

## Files in scope (only these may be modified)

- src/tools/stack-project.ts           (NEW)
- src/tools/index.ts                   (registration only)
- tests/tools/stack-project.test.ts    (NEW)

Any commit touching files outside scope will be rejected at integration.

## Tool shape

Mirror the structure of src/tools/pr-create.ts exactly:

```ts
import { z } from 'zod';
import type { Tool } from '../types/tool.js';
import { ok, err } from '../types/result.js';
import { exec } from '../util/exec.js';

const StackProjectInput = z.object({
  epic: z.string().describe('Epic slug; used for stack/<epic>/... namespace'),
  order: z.array(z.object({
    agent: z.string(),
    slug: z.string(),
    branch: z.string().describe('Source loom/<agent>-<slug> branch'),
  })).describe('DAG-sorted layer order, bottom (closest to trunk) first'),
  base: z.string().default('main').describe('Trunk branch'),
  draft: z.boolean().default(true).describe('Publish stack PRs as drafts'),
  reproject: z.boolean().default(false).describe('Tear down existing stack first via gh stack unstack'),
});

const StackProjectOutput = z.object({
  mirrorBranches: z.array(z.string()),
  prUrls: z.array(z.string()),
});
```

`roles: ['orchestrator']` — matches pr-create.ts precedent.

## Cherry-pick mirror construction (LOAD-BEARING)

Drift's review of the original Team 1 proposal flagged a real flaw:
force-branching mirrors to workspace merge commits leaks prior history
into per-layer diffs whenever the workspace is not a fresh clone of
main at epic start. The fix is to build mirrors via cherry-pick from
each loom/* source branch directly.

Algorithm:

```
prev = base
for each layer (NN starting at 01):
  mirror = "stack/<epic>/<NN>-<agent>-<slug>"
  commits = git rev-list --reverse base..layer.branch
  git branch -f mirror prev
  git checkout mirror
  git cherry-pick <commits>
  prev = mirror
```

Use NN as zero-padded 2-digit index. After all mirrors are built, run
'gh stack init --base <base> --adopt <mirror1> <mirror2> ...' followed
by 'gh stack submit --auto' (with --draft if input.draft is true).

If reproject is true, run 'gh stack unstack' first to tear down any
existing stack on these branches.

If any cherry-pick fails, abort with err('mirror-cherry-pick-failed', ...).
If gh stack init fails, abort with err('stack-init-failed', ...).
Bail before publishing on any error — leave no half-published stack.

## Tests

tests/tools/stack-project.test.ts — follow the vitest mock-exec pattern
in tests/tools/pr-create.test.ts. Cover at minimum:

1. Happy path: 3 layers, all execs succeed, returns the mirror branch list and PR URLs
2. reproject=true invokes 'gh stack unstack' as the first exec call
3. Cherry-pick failure returns err('mirror-cherry-pick-failed', ...) and never calls 'gh stack init'
4. 'gh stack init' failure returns err('stack-init-failed', ...) and never calls 'gh stack submit'
5. draft=false omits --draft from the submit command

Use mockExec.mockResolvedValueOnce per call. Mirror the test ergonomics
of pr-create.test.ts including the makeCtx helper.

## Registration

Add one import + one export + one register call in src/tools/index.ts,
matching the existing patterns. No reorganization of the file. No new
phase comment.

## Acceptance criteria

- All three scope files exist with the shapes above
- 'npm test' passes
- 'npm run lint' (tsc --noEmit) passes
- Zero edits to any file outside scope
- Final commit has Task-Status: COMPLETED, Files-Changed: 3, at least
  two Key-Finding trailers (one for the cherry-pick approach, one for
  the test coverage)

## References

- gh-stack SKILL: /home/willem/.agents/skills/gh-stack/SKILL.md
  (lines 419-436 for 'init --adopt'; line 789 for strict-linearity limitation)
- src/tools/pr-create.ts and tests/tools/pr-create.test.ts as templates
- Issue: #24
- RFP epic: bitswell/bitswell#74
- Winning Team 1 proposal: docs/rfcs/gh-stack-integration/team-1/proposal.md
  on the loom/team-1-rfp-gh-stack branch in bitswell/bitswell

Agent-Id: bitswell
Session-Id: 51135fdb-2056-42f0-b7b4-967d49dc5b07
Task-Status: ASSIGNED
Assigned-To: ratchet
Assignment: stack-project
Scope: src/tools/stack-project.ts,src/tools/index.ts,tests/tools/stack-project.test.ts
Dependencies: none
Budget: 80000
Issue: #24
Implements the stack-project tool per issue #24. Picked from the
gh-stack RFP round (bitswell/bitswell#74, Team 1 — read-only
presentation projection).

The tool publishes a stacked-PR ladder over an integrated LOOM epic
without touching any existing protocol surface. Mirror branches are
built by walking 'git rev-list --reverse base..layer.branch' and
cherry-picking onto the previous mirror tip — this avoids the
workspace-history leak Drift flagged in the original Team 1 proposal
(force-branching to workspace merge commits would have leaked prior
history into per-layer diffs whenever the workspace was not a fresh
clone of main at epic start).

Schema deviation: spec used z.string().default('main') etc. but the
existing Tool<I,O> typing makes Zod .default() incompatible with
inputSchema typing. Switched to .optional() with handler-side defaults
(input.base ?? 'main', etc.). Runtime behavior identical to spec.

Tests cover all 5 mandated scenarios (happy path, reproject=true,
cherry-pick failure bail, init failure bail, draft=false) plus an
orchestrator role-scope assertion. 6/6 pass.

Closes #24

Agent-Id: ratchet
Session-Id: f3aef7c2-66f4-45c3-8156-7d5f2a6c6e71
Task-Status: IMPLEMENTING
Heartbeat: 2026-04-15T04:05:13Z
stack-project tool is complete. All in-scope files committed in the
preceding commit. Ready for orchestrator integration.

Agent-Id: ratchet
Session-Id: f3aef7c2-66f4-45c3-8156-7d5f2a6c6e71
Task-Status: COMPLETED
Files-Changed: 3
Key-Finding: Mirror branches are built by walking 'git rev-list --reverse base..layer.branch' and cherry-picking onto the previous mirror tip, never force-branching to workspace merge commits — this prevents the workspace-history leak Drift flagged in the Team 1 proposal review
Key-Finding: Test coverage uses vitest mock-exec ergonomics from pr-create.test.ts and exercises all 5 scenarios from the spec plus an orchestrator role-scope assertion, 6/6 pass
Heartbeat: 2026-04-15T04:05:13Z
… tool

tests/runtime/mcp-server.test.ts hardcodes the registered tool count
(24 -> 25). Adding any new tool requires this fixture update; it is
out of scope for the worker (ratchet) since it is downstream of
registration. Orchestrator post-terminal commit per schemas.md §5.7.

Agent-Id: bitswell
Session-Id: 51135fdb-2056-42f0-b7b4-967d49dc5b07
Follow-up to e21e571 — the mcp-server tool list test also hardcodes
the full sorted tool-name array; adding stack-project there too.
Orchestrator post-terminal commit.

Agent-Id: bitswell
Session-Id: 51135fdb-2056-42f0-b7b4-967d49dc5b07

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

stack-project: orchestrator tool for read-only gh-stack projection

2 participants