Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .claude/skills/implementing-github-issues/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ allowed-tools: Bash(gh:*), Bash(git:*), Bash(pnpm:*), Bash(mkdir:*), Read, Write

End-to-end workflow for taking a GitHub issue from "assigned" to "PR open with verification evidence, marked ready for human review." All phases of one issue land on **one branch** and **one PR**; the PR is the long-lasting context for the workstream.

## Dispatch brief (read first)

Before anything else, check for `.middle/prompt.md` in the working directory. If it exists, it is **middle's dispatch brief** — read it and honor it. It carries the operating framing for an autonomous run (work continuously, don't stop to ask questions you can resolve yourself, terminal state is PR-ready), plus any per-dispatch operator notes (plan changes, scope adjustments, "skip X"). Operator notes in the brief override the defaults below. If the file is absent, you're being run interactively by a human — proceed normally.

## Core principles

**The code shows WHAT. The PR explains WHY.** Code comments are reserved for non-obvious constraints. Reasoning, alternatives considered, and tradeoffs go in the **decisions log** (`planning/issues/<num>/decisions.md`), then get distilled into PR review comments and the PR description.
Expand Down
21 changes: 13 additions & 8 deletions packages/adapters/claude/src/prompt.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
/**
* The literal text `send-keys` carries into a tmux session to start or continue
* the agent. `send-keys` cannot cleanly carry a multi-line prompt — embedded
* newlines submit early — so the full prompt lives on disk and this returns a
* one-line `@`-reference that force-includes it. A single `@` prefixes the
* whole relative path.
* the agent.
*
* - `initial`: a slash command that force-invokes the implementing skill on the
* Epic. The skill reads `.middle/prompt.md` (the dispatch brief) itself, so a
* single one-line submission both starts the skill and delivers the brief —
* which matters because the Phase 1 workflow only drives one turn.
* - `resume` / `answer`: an `@`-reference that force-includes the on-disk brief
* (multi-line context the agent reloads). Used by the fuller multi-turn
* workflow; `send-keys` can't carry multi-line text, hence the file pointer.
*/
export function buildPromptText(opts: {
promptFile: string;
kind: "initial" | "resume" | "answer";
epicNumber: number;
}): string {
const ref = `@${opts.promptFile}`;
switch (opts.kind) {
case "initial":
return ref;
return `/implementing-github-issues implement #${opts.epicNumber}`;
case "resume":
return `Resuming this workstream — re-read the linked context and continue. ${ref}`;
return `Resuming this workstream — re-read the linked context and continue. @${opts.promptFile}`;
case "answer":
return `A human answered your open question — read the answer and continue. ${ref}`;
return `A human answered your open question — read the answer and continue. @${opts.promptFile}`;
}
}
12 changes: 9 additions & 3 deletions packages/adapters/claude/test/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,16 +56,21 @@ describe("buildLaunchCommand", () => {
});

describe("buildPromptText", () => {
test("initial returns the bare @-reference one-liner", () => {
test("initial force-invokes the skill via slash command on the epic", () => {
expect(
claudeAdapter.buildPromptText({ promptFile: ".middle/prompt.md", kind: "initial" }),
).toBe("@.middle/prompt.md");
claudeAdapter.buildPromptText({
promptFile: ".middle/prompt.md",
kind: "initial",
epicNumber: 14,
}),
).toBe("/implementing-github-issues implement #14");
});

test("resume frames the @-reference as a continuation", () => {
const text = claudeAdapter.buildPromptText({
promptFile: ".middle/resume.md",
kind: "resume",
epicNumber: 14,
});
expect(text).toContain("@.middle/resume.md");
expect(text.toLowerCase()).toContain("resum");
Expand All @@ -75,6 +80,7 @@ describe("buildPromptText", () => {
const text = claudeAdapter.buildPromptText({
promptFile: ".middle/answer.md",
kind: "answer",
epicNumber: 14,
});
expect(text).toContain("@.middle/answer.md");
expect(text.toLowerCase()).toContain("answer");
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,14 @@ export interface AgentAdapter {

/**
* The literal text to send-keys into the session to start or continue the
* agent — includes the `@`-reference to the on-disk prompt file.
* agent. `initial` invokes the implementing skill directly (a slash command);
* the skill reads the on-disk dispatch brief. `resume`/`answer` carry the
* `@`-reference to the brief for the fuller multi-turn workflow.
*/
buildPromptText(opts: {
promptFile: string; // path, relative to the worktree
kind: "initial" | "resume" | "answer";
epicNumber: number; // the dispatched Epic/issue number
}): string;

/** Put the ready session into auto mode — a launch flag or post-ready keystrokes. */
Expand Down
31 changes: 18 additions & 13 deletions packages/dispatcher/src/workflows/implementation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,11 @@ function sessionNameFor(input: ImplementationInput): string {
}

/**
* Write a plan-style placeholder `.middle/prompt.md` into the worktree if one
* is not already present. An operator (or Phase 3+ `mm init` / Phase 7's
* recommender) can override by committing a real prompt in the source repo —
* the worktree inherits it and this writer leaves it alone.
* Write the default dispatch brief to `.middle/prompt.md` if one isn't already
* present. The skill (invoked by the slash command) reads this file as its
* operating brief — framing, not "use the skill" (the slash command already
* did that). An operator-supplied brief (committed in the repo, or written by a
* future `mm dispatch --note` / the recommender) is left untouched.
*/
function ensurePromptFile(worktreePath: string, epicNumber: number): void {
const middleDir = join(worktreePath, ".middle");
Expand All @@ -80,18 +81,21 @@ function ensurePromptFile(worktreePath: string, epicNumber: number): void {
mkdirSync(middleDir, { recursive: true });
writeFileSync(
promptPath,
`# middle dispatch — Epic #${epicNumber}
`# middle dispatch brief — Epic #${epicNumber}

You are dispatched by middle (the autonomous GitHub-issue dispatcher) to work
on Epic #${epicNumber} in this repository.
You are running autonomously under middle. There is no human watching in real
time. Operating rules for this dispatch:

Use the \`implementing-github-issues\` skill — it is available in this worktree
at \`.claude/skills/implementing-github-issues/SKILL.md\`. Invoke it via the
Skill tool with name \`implementing-github-issues\` and input \`implement #${epicNumber}\`.
- Work through every phase continuously. The mechanical verification gates are
the gates between phases — do not pause for confirmation between them.
- Do not stop to ask questions you can resolve yourself. Pause only if you are
genuinely blocked: ambiguous acceptance criteria, or a decision needing more
candidate forks than the complexity ceiling.
- The terminal state is: every phase verified, the PR marked ready for review,
and the reviewer's brief posted on both the Epic and the PR. Then stop.

If the skill is not present in this worktree (the target repo has not been
\`mm init\`'d and is not middle's own dogfood checkout), write a brief
explanation to \`.middle/failed.json\` as \`{ "reason": "<message>" }\` and stop.
## Operator notes for this dispatch
(none)
`,
);
}
Expand Down Expand Up @@ -230,6 +234,7 @@ export function createImplementationWorkflow(
const promptText = adapter.buildPromptText({
promptFile: ".middle/prompt.md",
kind: "initial",
epicNumber: ctx.input.epicNumber,
});
console.error(`${tag} sending prompt: "${promptText}"`);
await deps.tmux.sendText(sessionName, promptText);
Expand Down