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
2,249 changes: 2,249 additions & 0 deletions docs/superpowers/plans/2026-05-29-file-backed-epic-store.md

Large diffs are not rendered by default.

519 changes: 519 additions & 0 deletions docs/superpowers/specs/2026-05-29-file-backed-epic-store-design.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/cli/test/db-scripts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ describe("backup.sh + reset-db.sh round-trip", () => {

// The restored db is intact: schema migrated, and the seeded row survived.
const db = openAndMigrate(join(home, "db.sqlite3"));
expect(currentSchemaVersion(db)).toBe(7);
expect(currentSchemaVersion(db)).toBe(9);
const row = db.query("SELECT id FROM workflows WHERE id = 'wf-keep'").get();
expect(row).toEqual({ id: "wf-keep" });
db.close();
Expand Down
4 changes: 2 additions & 2 deletions packages/dashboard/src/db-deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import type {
} from "./wire.ts";

/** A repo's read-write state issue location. */
type StateIssueGateway = {
type StateGateway = {
readBody(repo: string, issueNumber: number): Promise<string>;
};

Expand All @@ -55,7 +55,7 @@ export type DbDepsOptions = {
/** The merged middle config — slot caps, default adapter, dispatcher port. */
config: MiddleConfig;
/** Reads a repo's state-issue body. Absent → NEXT UP / Needs-You read empty. */
stateGateway?: StateIssueGateway;
stateGateway?: StateGateway;
/** The non-terminal lifecycle states (rows holding a slot / in flight). */
spawnTerminal?: TerminalSpawner;
/** Probe whether a tmux session is alive. Defaults to `tmux has-session`. */
Expand Down
4 changes: 2 additions & 2 deletions packages/dispatcher/src/audit-cron.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Database } from "bun:sqlite";
import { Bunqueue } from "bunqueue/client";
import { runBacklogAudit } from "./audit.ts";
import type { GitHubGateway } from "./github.ts";
import type { EpicGateway } from "./github.ts";
import { isPaused, listManagedRepos } from "./repo-config.ts";

/**
Expand All @@ -18,7 +18,7 @@ export const AUDIT_CRON_INTERVAL_MS = 60 * 60_000;
*/
export type AuditCronDeps = {
db: Database;
github: Pick<GitHubGateway, "listOpenIssues" | "addLabel">;
github: Pick<EpicGateway, "listOpenIssues" | "addLabel">;
now?: () => number;
};

Expand Down
4 changes: 2 additions & 2 deletions packages/dispatcher/src/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* (a visible, reversible flag) and never edits an issue body.
*/
import { auditIssueBody, isFeatureIssue } from "@middle/core";
import type { GitHubGateway } from "./github.ts";
import type { EpicGateway } from "./github.ts";

/** The `needs-design` label applied to issues that fail the integration rubric. */
export const NEEDS_DESIGN_LABEL = "needs-design";
Expand All @@ -20,7 +20,7 @@ const DEFAULT_MAX_FLAGS_PER_PASS = 25;
export type BacklogAuditDeps = {
/** The `owner/name` repo slug whose open feature issues are audited. */
repo: string;
github: Pick<GitHubGateway, "listOpenIssues" | "addLabel">;
github: Pick<EpicGateway, "listOpenIssues" | "addLabel">;
/** Cap on issues labelled per pass (default {@link DEFAULT_MAX_FLAGS_PER_PASS}). */
maxFlagsPerPass?: number;
};
Expand Down
10 changes: 3 additions & 7 deletions packages/dispatcher/src/build-deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,17 @@ import { type GateRunReport, runGates } from "./gates/gate-runner.ts";
import { makePrReadyGateHandler, type PrReadyGateHandler } from "./gates/pr-ready-handler.ts";
import type { PlanCommentReader } from "./gates/plan-comment.ts";
import { loadVerifyConfig, verifyConfigPath } from "./gates/verify-config.ts";
import {
ghGitHub,
type GitHubGateway,
resolveAgentLogin as ghResolveAgentLogin,
} from "./github.ts";
import { ghGitHub, type EpicGateway, resolveAgentLogin as ghResolveAgentLogin } from "./github.ts";
import type { SessionGate } from "./hook-server.ts";
import { AGENT_COMMENT_MARKER } from "./poller.ts";
import { killSession, newSession, sendEnter, sendText, status } from "./tmux.ts";
import { findActiveWorkflowBySession, getWorkflow } from "./workflow-record.ts";
import type { ImplementationDeps, ImplementationInput } from "./workflows/implementation.ts";
import { createWorktree, destroyWorktree } from "./worktree.ts";

/** The slice of {@link GitHubGateway} the deps factory reads. */
/** The slice of {@link EpicGateway} the deps factory reads. */
type DepsGitHub = Pick<
GitHubGateway,
EpicGateway,
"findEpicPr" | "getCommentAuthor" | "postComment" | "getIssueLabels"
>;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- 007_repo_config_epic_store.sql
-- Per-repo Epic store selection. The default 'github' makes the migration a
-- no-op for every existing row: the dispatcher's bootstrap selector keeps
-- routing to ghGitHub / ghStateIssueGateway / ghPollGateway unchanged. Opting
-- a repo into file mode is a single config edit:
--
-- [epic_store]
-- mode = "file"
-- epics_dir = "planning/epics" -- relative to repo root
-- state_file = ".middle/state.md"
--
-- epics_dir / state_file are nullable — only populated when mode = 'file';
-- in github mode the existing state_issue_number remains the state-source-of-truth.

ALTER TABLE repo_config ADD COLUMN epic_store TEXT NOT NULL DEFAULT 'github';
ALTER TABLE repo_config ADD COLUMN epics_dir TEXT;
ALTER TABLE repo_config ADD COLUMN state_file TEXT;
18 changes: 18 additions & 0 deletions packages/dispatcher/src/db/migrations/009_workflows_epic_ref.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
-- 008_workflows_epic_ref.sql
-- The canonical Epic identifier becomes a string ref (`epicRef`) so file-mode
-- workflows can use slugs alongside github-mode workflows' issue numbers.
--
-- - `epic_number` stays as-is (already nullable). github-mode dispatch keeps
-- writing it for back-compat (dashboard links, prior queries).
-- - `epic_ref` is the new authoritative reference. github-mode writes both
-- (`epic_ref = String(epic_number)`); file-mode writes only `epic_ref` (slug).
-- - Backfill: every existing row whose `epic_number` is non-null gets
-- `epic_ref = CAST(epic_number AS TEXT)`. Recommender / documentation
-- workflows have null epic_number and stay null epic_ref (no Epic to
-- reference).
-- - `epic_ref` is nullable at the DB level for the same reason. Application
-- code (`createWorkflowRecord` in `workflow-record.ts`) enforces that every
-- implementation workflow has it populated.

ALTER TABLE workflows ADD COLUMN epic_ref TEXT;
UPDATE workflows SET epic_ref = CAST(epic_number AS TEXT) WHERE epic_number IS NOT NULL;
43 changes: 43 additions & 0 deletions packages/dispatcher/src/epic-store/epic-file/markers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Every HTML-comment marker the Epic-file format uses. The marker IS the
* structural contract — never change the bytes here without bumping the
* version suffix (`v1`) on the document marker.
*
* Convention mirrors the state-issue v1 marker (`<!-- AGENT-QUEUE-STATE v1 -->`
* in `packages/state-issue/src/constants.ts:4`): marker + version, exact-match
* required by the parser. Sub-markers carry attributes (`id=`, `status=`, `ts=`)
* the renderer formats from the parsed model — agents/humans only write
* *between* markers, never inside the strict attribute lines (closes #180's
* class of writer/parser drift).
*/

export const EPIC_DOC_MARKER = "<!-- middle:epic v1 -->";

export const META_OPEN = "<!-- middle:meta";
export const META_CLOSE = "-->";

export const SUB_ISSUE_OPEN_RE = /^<!-- middle:sub-issue id=(\d+) -->$/;
export const SUB_ISSUE_CLOSE = "<!-- /middle:sub-issue -->";

export const CONVERSATION_OPEN = "<!-- middle:conversation -->";
export const CONVERSATION_CLOSE = "<!-- /middle:conversation -->";

export const QUESTION_OPEN_RE =
/^<!-- middle:question id=(\d+) status=(open|resolved) ts=([\dT:Z.-]+)(?: kind=(\w+))? -->$/;
export const QUESTION_CLOSE = "<!-- /middle:question -->";

export const ANSWER_OPEN_RE = /^<!-- middle:answer for=(\d+) -->$/;
export const ANSWER_CLOSE = "<!-- /middle:answer -->";

export const DISPATCH_EVENT_OPEN_RE = /^<!-- middle:dispatch-event ts=([\dT:Z.-]+) kind=(\w+) -->$/;
export const DISPATCH_EVENT_CLOSE = "<!-- /middle:dispatch-event -->";

export const PARSE_ERROR_OPEN_RE = /^<!-- middle:parse-error ts=([\dT:Z.-]+) -->$/;
export const PARSE_ERROR_CLOSE = "<!-- /middle:parse-error -->";

/** Section headings — strict spelling + order. */
export const SECTIONS = ["Context", "Acceptance criteria", "Sub-issues"] as const;

/** Placeholder content the renderer writes inside an empty answer block. */
export const ANSWER_PLACEHOLDER =
"<!-- Human edits here. File-watcher fires resume on this section becoming non-empty. -->";
230 changes: 230 additions & 0 deletions packages/dispatcher/src/epic-store/epic-file/parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import {
ANSWER_CLOSE,
ANSWER_OPEN_RE,
CONVERSATION_CLOSE,
CONVERSATION_OPEN,
DISPATCH_EVENT_CLOSE,
DISPATCH_EVENT_OPEN_RE,
EPIC_DOC_MARKER,
META_CLOSE,
META_OPEN,
QUESTION_CLOSE,
QUESTION_OPEN_RE,
SUB_ISSUE_CLOSE,
SUB_ISSUE_OPEN_RE,
} from "./markers.ts";
import type { AcceptanceItem, ConversationEntry, EpicFile, EpicMeta, SubIssue } from "./types.ts";

/**
* Parse an Epic file's body into a typed model. Strict on markers + attributes
* (the structural contract), lenient on prose. Throws with a named-marker error
* when a structural element is malformed so operators can diagnose from the
* Epic file itself without log-tailing.
*
* Round-trip with `renderEpicFile` is byte-identical for any body the parser
* accepts — that property test (`round-trip.test.ts`) is the load-bearing
* guarantee the rest of the file-mode design depends on.
*/
export function parseEpicFile(body: string): EpicFile {
if (!body.startsWith(EPIC_DOC_MARKER)) {
throw new Error(`Epic file missing document marker (${EPIC_DOC_MARKER})`);
}
const lines = body.split("\n");
return {
title: parseTitle(lines),
meta: parseMeta(lines),
context: sectionBody(lines, "Context"),
acceptanceCriteria: parseAcceptance(sectionBody(lines, "Acceptance criteria")),
subIssues: parseSubIssues(sectionBody(lines, "Sub-issues")),
conversation: parseConversation(lines),
};
}

function parseTitle(lines: string[]): string {
const h1 = lines.find((l) => l.startsWith("# "));
if (!h1) throw new Error("Epic file missing H1 title line");
return h1.slice(2).trim();
}

function parseMeta(lines: string[]): EpicMeta {
const openIdx = lines.findIndex((l) => l.trim() === META_OPEN);
if (openIdx === -1) {
throw new Error(`Epic file missing meta block (${META_OPEN}…${META_CLOSE})`);
}
const closeIdx = lines.findIndex((l, i) => i > openIdx && l.trim() === META_CLOSE);
if (closeIdx === -1) throw new Error("Meta block not closed");
const meta: EpicMeta = { slug: "" };
for (const line of lines.slice(openIdx + 1, closeIdx)) {
const m = /^([a-z_-]+):\s*(.+)$/.exec(line.trim());
if (!m) continue;
const [, key, raw] = m;
switch (key) {
case "slug":
meta.slug = raw!;
break;
case "adapter":
meta.adapter = raw!;
break;
case "complexity_ceiling":
meta.complexityCeiling = Number(raw);
break;
case "approved":
meta.approved = raw === "true";
break;
case "labels":
meta.labels = parseArray(raw!);
break;
case "blocked-by":
meta.blockedBy = parseArray(raw!);
break;
case "pr":
meta.pr = Number(raw);
break;
case "closed":
meta.closed = raw === "true";
break;
}
}
if (!meta.slug) throw new Error("Epic meta missing required `slug` key");
return meta;
}

function parseArray(raw: string): string[] {
const stripped = raw.trim().replace(/^\[|\]$/g, "");
return stripped
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
}

function sectionBody(lines: string[], heading: string): string {
const start = lines.findIndex((l) => l.trim() === `## ${heading}`);
if (start === -1) return "";
let end = lines.findIndex((l, i) => i > start && l.startsWith("## "));
if (end === -1) {
// Sub-issues is the last `## ` section before the conversation marker —
// stop at CONVERSATION_OPEN so the conversation block isn't swallowed.
end = lines.findIndex((l, i) => i > start && l.trim() === CONVERSATION_OPEN);
if (end === -1) end = lines.length;
}
return lines
.slice(start + 1, end)
.join("\n")
.trim();
}

function parseAcceptance(body: string): AcceptanceItem[] {
const out: AcceptanceItem[] = [];
for (const line of body.split("\n")) {
const m = /^- \[([ x])\]\s+(.+)$/.exec(line);
if (m) out.push({ checked: m[1] === "x", text: m[2]!.trim() });
}
return out;
}

function parseSubIssues(body: string): SubIssue[] {
const out: SubIssue[] = [];
const lines = body.split("\n");
let i = 0;
while (i < lines.length) {
const open = SUB_ISSUE_OPEN_RE.exec(lines[i]!.trim());
if (!open) {
i++;
continue;
}
const id = Number(open[1]);
let j = i + 1;
while (j < lines.length && lines[j]!.trim() !== SUB_ISSUE_CLOSE) j++;
if (j >= lines.length) {
throw new Error(`Sub-issue id=${id} not closed (expected ${SUB_ISSUE_CLOSE})`);
}
const inner = lines.slice(i + 1, j);
const cb = /^- \[([ x])\]\s+\*\*(.+?)\*\*(.*)$/.exec(inner[0] ?? "");
if (!cb) {
throw new Error(`Sub-issue id=${id} missing canonical "- [ ] **N — title**" line`);
}
const checked = cb[1] === "x";
const title = cb[2]!.trim();
const provenance = (cb[3] ?? "").trim() || undefined;
// Body lines are indented by two spaces in the canonical form; strip the
// leading indent on read so the typed model holds the prose verbatim.
const subBody = inner
.slice(1)
.map((l) => l.replace(/^ {2}/, ""))
.join("\n")
.trim();
out.push({ id, checked, title, body: subBody, provenance });
i = j + 1;
}
return out;
}

function parseConversation(lines: string[]): ConversationEntry[] {
const start = lines.findIndex((l) => l.trim() === CONVERSATION_OPEN);
if (start === -1) return [];
const end = lines.findIndex((l, i) => i > start && l.trim() === CONVERSATION_CLOSE);
if (end === -1) throw new Error("Conversation block not closed");
const inner = lines.slice(start + 1, end);
const entries: ConversationEntry[] = [];
let i = 0;
while (i < inner.length) {
const line = inner[i]!.trim();
if (!line) {
i++;
continue;
}

const dm = DISPATCH_EVENT_OPEN_RE.exec(line);
if (dm) {
const close = inner.findIndex((l, k) => k > i && l.trim() === DISPATCH_EVENT_CLOSE);
if (close === -1) throw new Error("dispatch-event block not closed");
entries.push({
kind: "dispatch-event",
ts: dm[1]!,
eventKind: dm[2]!,
body: inner
.slice(i + 1, close)
.join("\n")
.trim(),
});
i = close + 1;
continue;
}

const qm = QUESTION_OPEN_RE.exec(line);
if (qm) {
const close = inner.findIndex((l, k) => k > i && l.trim() === QUESTION_CLOSE);
if (close === -1) throw new Error("question block not closed");
const block = inner.slice(i + 1, close);
const answerStart = block.findIndex((l) => ANSWER_OPEN_RE.test(l.trim()));
const questionBody = (answerStart === -1 ? block : block.slice(0, answerStart))
.join("\n")
.trim();
let answer: { body: string } | undefined;
if (answerStart !== -1) {
const answerClose = block.findIndex((l, k) => k > answerStart && l.trim() === ANSWER_CLOSE);
if (answerClose === -1) throw new Error("answer block not closed");
const answerBody = block
.slice(answerStart + 1, answerClose)
.filter((l) => !l.trim().startsWith("<!--"))
.join("\n")
.trim();
if (answerBody) answer = { body: answerBody };
}
entries.push({
kind: "question",
id: Number(qm[1]),
status: qm[2] as "open" | "resolved",
ts: qm[3]!,
questionKind: qm[4],
body: questionBody,
answer,
});
i = close + 1;
continue;
}

i++;
}
return entries;
}
Loading