Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
2 changes: 1 addition & 1 deletion packages/adapters/claude/src/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import type { BuildPromptOpts } from "@middle/core";
export function buildPromptText(opts: BuildPromptOpts): string {
switch (opts.kind) {
case "initial":
return `/implementing-github-issues implement #${opts.epicNumber}`;
return `/implementing-github-issues implement #${opts.epicRef}`;
case "resume":
return `Resuming this workstream — re-read the linked context and continue. @${opts.promptFile}`;
case "answer":
Expand Down
28 changes: 14 additions & 14 deletions packages/adapters/claude/test/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ describe("buildPromptText", () => {
claudeAdapter.buildPromptText({
promptFile: ".middle/prompt.md",
kind: "initial",
epicNumber: 14,
epicRef: "14",
}),
).toBe("/implementing-github-issues implement #14");
});
Expand All @@ -70,7 +70,7 @@ describe("buildPromptText", () => {
const text = claudeAdapter.buildPromptText({
promptFile: ".middle/resume.md",
kind: "resume",
epicNumber: 14,
epicRef: "14",
});
expect(text).toContain("@.middle/resume.md");
expect(text.toLowerCase()).toContain("resum");
Expand All @@ -80,7 +80,7 @@ describe("buildPromptText", () => {
const text = claudeAdapter.buildPromptText({
promptFile: ".middle/answer.md",
kind: "answer",
epicNumber: 14,
epicRef: "14",
});
expect(text).toContain("@.middle/answer.md");
expect(text.toLowerCase()).toContain("answer");
Expand All @@ -102,25 +102,25 @@ describe("buildPromptText", () => {
expect(text).toBe("/documenting-the-repo @.middle/prompt.md");
});

// Compile-time contract (enforced by `bun run typecheck`): the `kind`/`epicNumber`
// Compile-time contract (enforced by `bun run typecheck`): the `kind`/`epicRef`
// coupling is a discriminated union, so a dispatched-issue kind cannot omit its
// Epic and `recommender` cannot carry one. If the union regresses to a bare
// optional `epicNumber`, these `@ts-expect-error`s go unused and typecheck fails.
test("type contract: dispatched-issue kinds require an epicNumber; recommender forbids one", () => {
// @ts-expect-error — 'initial' must carry an epicNumber
// optional `epicRef`, these `@ts-expect-error`s go unused and typecheck fails.
test("type contract: dispatched-issue kinds require an epicRef; recommender forbids one", () => {
// @ts-expect-error — 'initial' must carry an epicRef
claudeAdapter.buildPromptText({ promptFile: ".middle/prompt.md", kind: "initial" });
// @ts-expect-error — 'resume' must carry an epicNumber
// @ts-expect-error — 'resume' must carry an epicRef
claudeAdapter.buildPromptText({ promptFile: ".middle/prompt.md", kind: "resume" });
// @ts-expect-error — 'answer' must carry an epicNumber
// @ts-expect-error — 'answer' must carry an epicRef
claudeAdapter.buildPromptText({ promptFile: ".middle/prompt.md", kind: "answer" });
// @ts-expect-error — 'recommender' runs against no Epic, so epicNumber is forbidden
// @ts-expect-error — 'recommender' runs against no Epic, so epicRef is forbidden
claudeAdapter.buildPromptText({
promptFile: ".middle/prompt.md",
kind: "recommender",
epicNumber: 1,
epicRef: "1",
});
// @ts-expect-error — 'docs' runs against no Epic, so epicNumber is forbidden
claudeAdapter.buildPromptText({ promptFile: ".middle/prompt.md", kind: "docs", epicNumber: 1 });
// @ts-expect-error — 'docs' runs against no Epic, so epicRef is forbidden
claudeAdapter.buildPromptText({ promptFile: ".middle/prompt.md", kind: "docs", epicRef: "1" });
expect(true).toBe(true);
});
});
Expand Down Expand Up @@ -400,7 +400,7 @@ describe("installHooks", () => {
dispatcherUrl: "http://127.0.0.1:8822",
sessionName: "middle-6",
sessionToken: "tok",
epicNumber: 6,
epicRef: "6",
});
}

Expand Down
2 changes: 1 addition & 1 deletion packages/adapters/codex/src/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import type { BuildPromptOpts } from "@middle/core";
export function buildPromptText(opts: BuildPromptOpts): string {
switch (opts.kind) {
case "initial":
return `/implementing-github-issues implement #${opts.epicNumber}`;
return `/implementing-github-issues implement #${opts.epicRef}`;
case "resume":
return `Resuming this workstream — re-read the linked context and continue. @${opts.promptFile}`;
case "answer":
Expand Down
24 changes: 12 additions & 12 deletions packages/adapters/codex/test/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ describe("buildPromptText", () => {
codexAdapter.buildPromptText({
promptFile: ".middle/prompt.md",
kind: "initial",
epicNumber: 60,
epicRef: "60",
}),
).toBe("/implementing-github-issues implement #60");
});
Expand All @@ -66,7 +66,7 @@ describe("buildPromptText", () => {
const text = codexAdapter.buildPromptText({
promptFile: ".middle/resume.md",
kind: "resume",
epicNumber: 60,
epicRef: "60",
});
expect(text).toContain("@.middle/resume.md");
expect(text.toLowerCase()).toContain("resum");
Expand All @@ -76,7 +76,7 @@ describe("buildPromptText", () => {
const text = codexAdapter.buildPromptText({
promptFile: ".middle/answer.md",
kind: "answer",
epicNumber: 60,
epicRef: "60",
});
expect(text).toContain("@.middle/answer.md");
expect(text.toLowerCase()).toContain("answer");
Expand All @@ -97,21 +97,21 @@ describe("buildPromptText", () => {
// Compile-time contract (enforced by `bun run typecheck`): same discriminated
// union as Claude — a dispatched-issue kind cannot omit its Epic and the
// repo-level kinds cannot carry one.
test("type contract: dispatched-issue kinds require an epicNumber; recommender forbids one", () => {
// @ts-expect-error — 'initial' must carry an epicNumber
test("type contract: dispatched-issue kinds require an epicRef; recommender forbids one", () => {
// @ts-expect-error — 'initial' must carry an epicRef
codexAdapter.buildPromptText({ promptFile: ".middle/prompt.md", kind: "initial" });
// @ts-expect-error — 'resume' must carry an epicNumber
// @ts-expect-error — 'resume' must carry an epicRef
codexAdapter.buildPromptText({ promptFile: ".middle/prompt.md", kind: "resume" });
// @ts-expect-error — 'answer' must carry an epicNumber
// @ts-expect-error — 'answer' must carry an epicRef
codexAdapter.buildPromptText({ promptFile: ".middle/prompt.md", kind: "answer" });
// @ts-expect-error — 'recommender' runs against no Epic, so epicNumber is forbidden
// @ts-expect-error — 'recommender' runs against no Epic, so epicRef is forbidden
codexAdapter.buildPromptText({
promptFile: ".middle/prompt.md",
kind: "recommender",
epicNumber: 1,
epicRef: "1",
});
// @ts-expect-error — 'docs' runs against no Epic, so epicNumber is forbidden
codexAdapter.buildPromptText({ promptFile: ".middle/prompt.md", kind: "docs", epicNumber: 1 });
// @ts-expect-error — 'docs' runs against no Epic, so epicRef is forbidden
codexAdapter.buildPromptText({ promptFile: ".middle/prompt.md", kind: "docs", epicRef: "1" });
expect(true).toBe(true);
});
});
Expand Down Expand Up @@ -390,7 +390,7 @@ describe("installHooks", () => {
dispatcherUrl: "http://127.0.0.1:4120",
sessionName: "middle-60",
sessionToken: "tok",
epicNumber: 60,
epicRef: "60",
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ allowed-tools: Bash(gh:*), Bash(git:status), Read, Grep, Glob

End-to-end workflow for taking a planning artifact (spec, brainstorm, build doc) and producing a set of well-formed GitHub issues with consistent titles, complete acceptance criteria, proper labels, and correct parent/sub-issue hierarchy. The output is the seed set of work that downstream skills (`implementing-github-issues`, `recommending-github-issues`) operate on.

**Two modes.** Everything below is **github mode** (the default): each Epic is a GitHub issue and sub-issues are native GitHub sub-issues, created with `gh`. If the repo runs in **file mode** (`epic_store = "file"`), an Epic is instead a Markdown file under `planning/epics/` and there is **no `gh issue create`** — see the **"File-mode addendum"** section at the end and `references/file-mode-commands.md`. The principles (read the source fully, mandatory acceptance criteria, hierarchy by default, integration rubric) are identical in both modes; only the authoring mechanics differ.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

## Core principles

**Issues are inputs to other skills, not implementation plans.** An issue captures *what* and *why*; the implementer's plan (in their PR's `planning/issues/<num>/plan.md`) captures *how*. Don't pre-decide implementation in the issue body; the implementer needs room to research and adapt.
Expand Down Expand Up @@ -507,6 +509,89 @@ Middle's controlled labels (applied manually by the user, NOT by this skill —

**Titles that only make sense next to the spec.** The recommender ranks from the title alone. "Phase 1, task 3" is useless; "Add SQLite migrations and WAL-mode db wrapper" is rankable.

## File-mode addendum — authoring an Epic file

When the repo runs in **file mode** (`epic_store = "file"` in its `.middle/<repo>.toml`),
you do **not** call `gh issue create`. There are no GitHub issues for Epic data; each
Epic is a single Markdown file at `planning/epics/<slug>.md`, and its sub-issues are
`<!-- middle:sub-issue id=N -->` blocks *inside* that file. PRs, reviews, and CI are
still GitHub-native — but issue creation is not part of file mode at all.

Everything above still applies — read the source end to end, write mandatory acceptance
criteria, default to hierarchy, run the integration rubric — but the output is a set of
Epic files, one per Epic, instead of a parent issue + child sub-issues. See
`references/file-mode-commands.md` for the step-by-step.

### The Epic file structure (mirror these marker names exactly)

```markdown
<!-- middle:epic v1 -->
# <Epic title>

<!-- middle:meta
slug: <slug>
adapter: claude
complexity_ceiling: 3
approved: false
labels: [phase:10, dogfood]
blocked-by: [other-epic-slug]
-->

## Context

<1-3 paragraphs: what this Epic delivers, where in the spec it comes from. Same
content you'd put in a github-mode parent's Context.>

## Acceptance criteria

- [ ] <Epic-level, concrete, verifiable criterion>
- [ ] <…>

## Sub-issues

<!-- middle:sub-issue id=1 -->
- [ ] **1 — <verb-led title>**
<prose body: what this phase is, why it matters>
*Acceptance:* <concrete, verifiable criteria for this sub-issue>
<!-- /middle:sub-issue -->

<!-- middle:sub-issue id=2 -->
- [ ] **2 — <verb-led title>**
<prose body>
*Blocked by:* 1
<!-- /middle:sub-issue -->

<!-- middle:conversation -->
<!-- /middle:conversation -->
```

### The pieces

- **`<!-- middle:epic v1 -->`** — the document marker. Exact bytes; first line of the file.
- **`# <title>`** — the H1, the Epic's title (the most-read line; same title rules as above).
- **`<!-- middle:meta … -->`** — YAML-lite, one key per line. The keys:
- `slug` (required) — the canonical Epic reference; must equal the filename stem.
- `adapter` (optional) — `claude` / `codex` adapter override (the file-mode peer of an `agent:<name>` label).
- `labels` (optional) — display labels, informational only (no GitHub side-effect in file mode).
- `blocked-by` (optional) — a list of other Epic slugs this one waits on (cross-Epic deps the recommender's graph builder reads).
- `complexity_ceiling` (optional) — per-Epic override of the repo default.
- `approved` (optional) — the file-mode stand-in for the `approved` label.
- **`## Context` / `## Acceptance criteria` / `## Sub-issues`** — strict spelling and order; these headings are parsed.
- **`<!-- middle:sub-issue id=N -->` … `<!-- /middle:sub-issue -->`** — one block per phase. The `id` is stable and per-Epic; the `- [ ]` checkbox starts unchecked (the implementer flips it with a provenance suffix when the phase lands).
- **`<!-- middle:conversation --><!-- /middle:conversation -->`** — an empty conversation section. Leave it empty; the dispatcher (via its renderer) is the sole writer of conversation entries — never seed plan/question/dispatch-event content here by hand.

### What's the same, what's different

| Concern | github mode | file mode |
|---|---|---|
| Epic | a GitHub issue | `planning/epics/<slug>.md` |
| Sub-issue | native GitHub sub-issue | `<!-- middle:sub-issue id=N -->` block in the file |
| Creation command | `gh issue create` + `sub_issues` REST attach | author the file — **no `gh issue create`** |
| Adapter pin | `agent:<name>` label | `adapter:` in `<!-- middle:meta -->` |
| Cross-Epic blocker | issue-graph relationship | `blocked-by: [slug]` in meta |
| Acceptance criteria | mandatory | mandatory (same rubric) |
| PRs / reviews / CI | GitHub-native | GitHub-native (unchanged) |

## Related skills

- `verifying-requirements` — the Phase 8.5 second pass. Defines the integration rubric and drives `mm audit-issues`; this skill calls it so weak acceptance criteria are caught before filing, not after work ships unwired.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# creating-github-issues — file-mode commands

Authoring Epic **files** from a planning doc, for a repo running `epic_store = "file"`.
There is **no `gh issue create` in file mode** — Epics and their sub-issues are
Markdown, not GitHub issues. PRs/reviews/CI remain GitHub-native, but issue
creation is not part of file mode at all.

The workflow phases (read the source, inventory, decide hierarchy, triage unknowns,
audit against the integration rubric) are identical to the github-mode body. Only
the "file the issues" mechanics change: instead of `gh issue create` + sub-issue
REST attaches, you write one Epic file per Epic.

## Where files go

`epics_dir` from the repo's `.middle/<repo>.toml` (default `planning/epics/`). One
file per Epic: `planning/epics/<slug>.md`. The `<slug>` is the filename stem **and**
the canonical Epic reference — it must equal the `slug:` in the file's meta.

## Author one Epic file

Write `planning/epics/<slug>.md` with this structure (mirror the marker names
exactly — the markers ARE the structural contract):

```markdown
<!-- middle:epic v1 -->
# <Epic title>

<!-- middle:meta
slug: <slug>
adapter: claude
complexity_ceiling: 3
approved: false
labels: [phase:10, dogfood]
blocked-by: [other-epic-slug]
-->

## Context

<1-3 paragraphs pointing to the spec section; same content as a github-mode
parent's Context.>

## Acceptance criteria

- [ ] <Epic-level, concrete, verifiable criterion>
- [ ] <…>

## Sub-issues

<!-- middle:sub-issue id=1 -->
- [ ] **1 — <verb-led title>**
<prose body>
*Acceptance:* <concrete criteria for this phase>
<!-- /middle:sub-issue -->

<!-- middle:sub-issue id=2 -->
- [ ] **2 — <verb-led title>**
<prose body>
*Blocked by:* 1
<!-- /middle:sub-issue -->

<!-- middle:conversation -->
<!-- /middle:conversation -->
```

## The `<!-- middle:meta -->` keys

YAML-lite, one key per line, between `<!-- middle:meta` and `-->`:

| Key | Required | Meaning |
|---|---|---|
| `slug` | yes | Canonical Epic reference; must equal the filename stem. |
| `adapter` | no | `claude` / `codex` — the file-mode peer of an `agent:<name>` label. |
| `labels` | no | Display labels (informational; no GitHub side-effect in file mode). |
| `blocked-by` | no | List of other Epic slugs this one waits on (cross-Epic deps). |
| `complexity_ceiling` | no | Per-Epic override of the repo's default ceiling. |
| `approved` | no | File-mode stand-in for the `approved` label. |

(`pr:` and `closed:` also live in meta but are written by the dispatcher at
runtime — do not author them.)

## Rules that carry over from the github-mode body

- **Acceptance criteria are mandatory** — both Epic-level (`## Acceptance criteria`)
and per sub-issue (`*Acceptance:*`). Same concrete/verifiable/scoped bar.
- **Integration rubric (Phase 8.5)** — every feature Epic carries ≥1 criterion that
wires the feature into the running product and proves it with an
integration/smoke/e2e test, or a declared `<!-- integration-exempt: <reason> -->`.
- **Hierarchy by default** — the Epic file *is* the parent; its sub-issue blocks are
the children. A genuinely cross-workstream item is a separate Epic file.
- **Titles are the most-read line** — verb-led, scoped, ≤72 chars, both for the H1
and each sub-issue title.

## Leave the conversation empty

Author the file with an empty `<!-- middle:conversation --><!-- /middle:conversation -->`.
The dispatcher's renderer is the **sole writer** of conversation entries (plan,
dispatch events, questions, answers). Never seed conversation content by hand — that
would break the strict-marker contract and the byte-identical round-trip invariant.

## Verify the set

There's no `gh issue list` to confirm against in file mode. Verify by:

```bash
ls planning/epics/*.md
```

and re-reading each file: the H1 matches `slug`, every sub-issue has an id + an
unchecked box, acceptance criteria are present, and the conversation section is
empty. Optionally run the dispatcher's parser over each file (it refuses malformed
markers) before considering the set filed.
Loading