From a17dcea4b7e4d0d34d9220dff6432626cab8740e Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Wed, 3 Jun 2026 02:34:03 -0400 Subject: [PATCH 01/10] docs(epic-store): plan + decisions log for Epic #190 --- planning/issues/190/decisions.md | 4 +++ planning/issues/190/plan.md | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 planning/issues/190/decisions.md create mode 100644 planning/issues/190/plan.md diff --git a/planning/issues/190/decisions.md b/planning/issues/190/decisions.md new file mode 100644 index 00000000..7fbc70b2 --- /dev/null +++ b/planning/issues/190/decisions.md @@ -0,0 +1,4 @@ +# Decisions — Epic #190 (file-backed Epic store) + +Running log of decisions worth more than two lines. Distilled into PR review comments +at finalize time. diff --git a/planning/issues/190/plan.md b/planning/issues/190/plan.md new file mode 100644 index 00000000..43e39234 --- /dev/null +++ b/planning/issues/190/plan.md @@ -0,0 +1,54 @@ +# Epic #190: feat(epic-store): file-backed Epic store (opt-in hybrid) + +**Link:** https://github.com/thejustinwalsh/middle/issues/190 +**Branch:** middle-issue-190 + +## Goal +Ship an opt-in, per-repo **file-backed Epic store** as a peer to today's GitHub-backed +mode. One Markdown file per Epic under `planning/epics/`, recommender state in +`.middle/state.md`; PRs/reviews/CI stay GitHub-native in both modes ("hybrid"). +Workflow bodies, gates, watchdog, hook server, and poller stay **unchanged** — the +three DI'd gateway interfaces (`EpicGateway`, `StateGateway`, `PollGateway`) gain +parallel file implementations selected per-repo at bootstrap. + +## Approach +- The foundation (gateway rename, migrations 007/008/009, Epic-file parser/renderer + + byte-identical round-trip) merged in PR #188 and is already on this branch's base. +- Make the workflow seam string-keyed (`epicRef: string`) so a file slug is a + first-class Epic identifier; github mode parses `Number(epicRef)` at the `gh` boundary. +- Add three composite file gateways: Epic/state methods read/write local files via the + existing pure parser+renderer; PR-shaped methods delegate to an injected `gh` backend. +- Select the gateway trio per-repo from `repo_config.epic_store` in `build-deps.ts`. + The agent's `blocked.json` flow plugs in unchanged at the `postQuestion` DI seam. +- Surface it through `mm` (init/dispatch/doctor/resume), abstract the Epic-aware skills + mode-agnostically, and prove "no workflow code changes" with a parametrized parity test. +- Phase 2 adds the mtime-poll file-watcher Q&A loop on the existing 120s poller cron. + +## Phases (= open sub-issues, in dependency order) +1. **#191** refactor(dispatcher): `EpicGateway` takes `epicRef: string` everywhere +2. **#192** feat(epic-store): file-backed gateway implementations (Epic, State, Poll) — *blocked by #191* +3. **#193** feat(epic-store): bootstrap selector + `postQuestion` file-mode wiring — *blocked by #192* +4. **#194** feat(cli): `mm init/dispatch/doctor/resume` — file-mode support — *blocked by #193* +5. **#195** refactor(skills): abstract Epic-aware skills + dispatch-brief mode injection — *blocked by #193* +6. **#196** test(epic-store): parity test (github ⇔ file) + Phase 1 smoke — *blocked by #194, #195* +7. **#197** feat(epic-store): Phase 2 — file-watcher Q&A loop on the poller cron — *blocked by #196* + +## Files likely to change +- `packages/dispatcher/src/github.ts`, `state-issue.ts`, `poller.ts`, `poller-gateway.ts` — `epicRef: string` on the interfaces; `ghGitHub` parses to number at the boundary (#191) +- `packages/dispatcher/src/workflows/{implementation,recommender,documentation}.ts`, `main.ts`, `build-deps.ts`, `auto-dispatch.ts`, `hook-server.ts`, `workflow-record.ts`, `gates/*`, `reconcilers/*`, `recovery.ts` — `epicNumber` → `epicRef` threading + `epic_ref` column reads/writes (#191) +- `packages/dispatcher/src/epic-store/{index.ts,file-epic-gateway.ts,file-state-gateway.ts,file-poll-gateway.ts,watcher.ts}` — new (#192, #193, #197) +- `packages/dispatcher/src/build-deps.ts` — `buildGitHubGateways`/`buildFileGateways` switch (#193) +- `packages/cli/src/commands/{init,dispatch,doctor,resume}.ts` — file-mode CLI (#194) +- `packages/skills/{implementing,recommending,creating}-github-issues/` — abstract bodies + `references/-mode-commands.md` (#195) +- `packages/dispatcher/test/epic-store/*` — gateway unit tests + `parity.test.ts` (#192, #196) + +## Out of scope +- File-backed PRs/reviews/CI (GitHub-native in both modes) +- GitHub→file migration (`mm migrate-to-file`) +- Real-time `chokidar`/`fs.watch` (Phase 2 uses mtime polling on the 120s cron) +- An abstract `EpicStore` interface above the gateways (Approach B; only if a 3rd backend appears) +- Cross-repo Epic references + +## Open questions +- None blocking. The "Open design fork" from PR #188 (epicRef refactor — option A full + refactor) is already resolved in favor of A per #191's body. From a5620e9d36804b4c75c4df5a6f41a7af840effb7 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Wed, 3 Jun 2026 04:13:03 -0400 Subject: [PATCH 02/10] refactor(dispatcher): string-keyed epicRef seam (EpicGateway/PollGateway) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close #191. Make the workflow seam string-keyed: the Epic/issue identifier flows as `epicRef: string` (a stringified issue number in github mode, a slug in file mode) so a file slug can be a first-class Epic reference. - EpicGateway/PollGateway issue-identifier params become a string `ref`/`epicRef`; `ghGitHub`/`ghPollGateway` parse it to an int at the `gh` boundary via the new `refToIssueNumber`, throwing a clear error on a non-numeric ref (github mode contract: numeric-string only). PR numbers and comment ids stay numeric. - `ImplementationInput.epicRef`, `AdapterDispatchOptions`/`InstallHookOpts.epicRef`, `ControlDispatchInput.epicRef`, the worktree seam (`epicRef` → `issue-`), and the build-deps callbacks thread the string end-to-end; external entry points (control route, auto-dispatch, manual dispatch) stringify their numeric input. - `createWorkflowRecord` writes both `epic_number` (derived from a numeric ref) and `epic_ref` (migration 009's dual-column contract); the resume/reconcile read types (`PollableWait`/`ParkedWorkflow`/`RunningWorkflow`) and `hasNonTerminalEpicWorkflow` read `epic_ref`. Display read types (`ActiveImplementationWorkflow`, `NonTerminalWorkflow`, SSE `epic`) stay numeric. - Two #187 dashboard tests now assert a github row's `epicRef` is the stringified number (was null against the foundation's incomplete write path); `EpicRef` rendering is unchanged (it keys `#N` off the numeric `epic`). Full suite green (1174), typecheck/lint/format clean. github-mode behavior unchanged. --- packages/adapters/claude/src/prompt.ts | 2 +- packages/adapters/claude/test/adapter.test.ts | 28 ++--- packages/adapters/codex/src/prompt.ts | 2 +- packages/adapters/codex/test/adapter.test.ts | 24 ++-- packages/cli/test/status.test.ts | 4 +- packages/core/src/adapter.ts | 15 ++- packages/dashboard/test/api.test.ts | 6 +- packages/dashboard/test/helpers.ts | 17 ++- packages/dashboard/test/sse.test.ts | 4 +- packages/dispatcher/src/audit.ts | 2 +- packages/dispatcher/src/build-deps.ts | 18 +-- .../src/gates/checkbox-revert-pass.ts | 6 +- .../dispatcher/src/gates/gate-evidence.ts | 8 +- packages/dispatcher/src/gates/plan-comment.ts | 8 +- .../dispatcher/src/gates/pr-ready-handler.ts | 10 +- packages/dispatcher/src/github.ts | 57 +++++++--- packages/dispatcher/src/hook-server.ts | 11 +- packages/dispatcher/src/main.ts | 24 ++-- packages/dispatcher/src/poller-gateway.ts | 10 +- packages/dispatcher/src/poller.ts | 26 ++--- .../src/reconcilers/pr-divergence.ts | 18 +-- packages/dispatcher/src/recovery.ts | 4 +- packages/dispatcher/src/staleness.ts | 2 +- packages/dispatcher/src/workflow-record.ts | 60 +++++----- .../dispatcher/src/workflows/documentation.ts | 4 +- .../src/workflows/implementation.ts | 74 ++++++------- .../dispatcher/src/workflows/recommender.ts | 4 +- packages/dispatcher/src/worktree.ts | 12 +- .../test/adapter-conformance.test.ts | 4 +- .../dispatcher/test/backlog-audit.test.ts | 14 +-- packages/dispatcher/test/build-deps.test.ts | 14 +-- .../dispatcher/test/control-routes.test.ts | 18 +-- .../dispatcher/test/epic-143-demo.test.ts | 4 +- .../test/gates/checkbox-revert-pass.test.ts | 18 +-- .../test/gates/plan-comment.test.ts | 12 +- .../test/gates/pr-ready-handler.test.ts | 2 +- packages/dispatcher/test/gates/verify.test.ts | 6 +- packages/dispatcher/test/hook-store.test.ts | 2 +- .../test/implementation-workflow.test.ts | 25 +++-- packages/dispatcher/test/metrics.test.ts | 2 +- packages/dispatcher/test/poller.test.ts | 8 +- .../test/pr-divergence-integration.test.ts | 28 +++-- .../dispatcher/test/pr-divergence.test.ts | 30 ++--- .../test/recommender-workflow.test.ts | 12 +- packages/dispatcher/test/reconcile.test.ts | 6 +- packages/dispatcher/test/recovery.test.ts | 8 +- packages/dispatcher/test/slots.test.ts | 2 +- .../dispatcher/test/staleness-cron.test.ts | 6 +- packages/dispatcher/test/staleness.test.ts | 6 +- packages/dispatcher/test/watchdog.test.ts | 2 +- .../dispatcher/test/workflow-record.test.ts | 103 ++++++++++-------- packages/dispatcher/test/worktree.test.ts | 26 ++--- planning/issues/190/decisions.md | 77 +++++++++++++ 53 files changed, 521 insertions(+), 374 deletions(-) diff --git a/packages/adapters/claude/src/prompt.ts b/packages/adapters/claude/src/prompt.ts index 47188c33..89c4c1e4 100644 --- a/packages/adapters/claude/src/prompt.ts +++ b/packages/adapters/claude/src/prompt.ts @@ -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": diff --git a/packages/adapters/claude/test/adapter.test.ts b/packages/adapters/claude/test/adapter.test.ts index 9dd8ce74..1c49a5de 100644 --- a/packages/adapters/claude/test/adapter.test.ts +++ b/packages/adapters/claude/test/adapter.test.ts @@ -61,7 +61,7 @@ describe("buildPromptText", () => { claudeAdapter.buildPromptText({ promptFile: ".middle/prompt.md", kind: "initial", - epicNumber: 14, + epicRef: "14", }), ).toBe("/implementing-github-issues implement #14"); }); @@ -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"); @@ -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"); @@ -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); }); }); @@ -400,7 +400,7 @@ describe("installHooks", () => { dispatcherUrl: "http://127.0.0.1:8822", sessionName: "middle-6", sessionToken: "tok", - epicNumber: 6, + epicRef: "6", }); } diff --git a/packages/adapters/codex/src/prompt.ts b/packages/adapters/codex/src/prompt.ts index b039b424..993ce55c 100644 --- a/packages/adapters/codex/src/prompt.ts +++ b/packages/adapters/codex/src/prompt.ts @@ -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": diff --git a/packages/adapters/codex/test/adapter.test.ts b/packages/adapters/codex/test/adapter.test.ts index 55f77815..8fde7472 100644 --- a/packages/adapters/codex/test/adapter.test.ts +++ b/packages/adapters/codex/test/adapter.test.ts @@ -57,7 +57,7 @@ describe("buildPromptText", () => { codexAdapter.buildPromptText({ promptFile: ".middle/prompt.md", kind: "initial", - epicNumber: 60, + epicRef: "60", }), ).toBe("/implementing-github-issues implement #60"); }); @@ -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"); @@ -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"); @@ -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); }); }); @@ -390,7 +390,7 @@ describe("installHooks", () => { dispatcherUrl: "http://127.0.0.1:4120", sessionName: "middle-60", sessionToken: "tok", - epicNumber: 60, + epicRef: "60", }); } diff --git a/packages/cli/test/status.test.ts b/packages/cli/test/status.test.ts index 7dc7276b..72eb28df 100644 --- a/packages/cli/test/status.test.ts +++ b/packages/cli/test/status.test.ts @@ -43,14 +43,14 @@ describe("runStatus", () => { id: "w1", kind: "implementation", repo: "thejustinwalsh/middle", - epicNumber: 6, + epicRef: "6", adapter: "claude", }); createWorkflowRecord(db, { id: "w2", kind: "implementation", repo: "thejustinwalsh/middle", - epicNumber: 7, + epicRef: "7", adapter: "claude", }); updateWorkflow(db, "w2", { state: "completed" }); diff --git a/packages/core/src/adapter.ts b/packages/core/src/adapter.ts index df04c372..5107ca7e 100644 --- a/packages/core/src/adapter.ts +++ b/packages/core/src/adapter.ts @@ -62,23 +62,26 @@ export interface AgentAdapter { /** * Args for {@link AgentAdapter.buildPromptText}. A discriminated union on - * `kind` so the `kind`/`epicNumber` coupling is enforced at compile time: every - * dispatched-issue kind (`initial`/`resume`/`answer`) carries an `epicNumber`, + * `kind` so the `kind`/`epicRef` coupling is enforced at compile time: every + * dispatched-issue kind (`initial`/`resume`/`answer`) carries an `epicRef`, * and the repo-level kinds (`recommender`, `docs`) — which have no Epic — must - * omit it. A bare `epicNumber?: number` across the whole union let + * omit it. A bare `epicRef?: string` across the whole union let * `kind: "initial"` compile without an Epic and produce a malformed launch * prompt (`implement #undefined`). + * + * `epicRef` is a string: the stringified issue number in github mode, a file + * slug in file mode (the canonical Epic reference in both). */ export type BuildPromptOpts = | { promptFile: string; // path, relative to the worktree kind: "initial" | "resume" | "answer"; - epicNumber: number; // the dispatched Epic/issue number + epicRef: string; // the dispatched Epic/issue reference } | { promptFile: string; // path, relative to the worktree kind: "recommender" | "docs"; - epicNumber?: never; // the recommender / docs bot run against no Epic + epicRef?: never; // the recommender / docs bot run against no Epic }; export type InstallHookOpts = { @@ -87,7 +90,7 @@ export type InstallHookOpts = { dispatcherUrl: string; // http://127.0.0.1:4120 sessionName: string; sessionToken: string; // HMAC token for hook auth - epicNumber: number; // the Epic (or standalone issue) being dispatched + epicRef: string; // the Epic (or standalone issue) being dispatched }; export type LaunchOpts = { diff --git a/packages/dashboard/test/api.test.ts b/packages/dashboard/test/api.test.ts index bab93f3e..aeb700d8 100644 --- a/packages/dashboard/test/api.test.ts +++ b/packages/dashboard/test/api.test.ts @@ -91,8 +91,8 @@ describe("dashboard JSON API", () => { }); test("github-mode IN FLIGHT row carries epicRef alongside the numeric epic (#187)", async () => { - // createWorkflowRecord doesn't write epic_ref, so a github-mode row's epicRef - // is null over the wire; the UI keys its numeric render off `epic`, not epicRef. + // github mode writes both columns: the row's epicRef is the stringified issue + // number over the wire; the UI keys its numeric render off `epic`, not epicRef. seedWorkflow(db, { id: "w1", repo: "o/alpha", @@ -105,7 +105,7 @@ describe("dashboard JSON API", () => { const detail = (await ( await fetch(`${base}/api/repos/${encodeURIComponent("o/alpha")}`) ).json()) as RepoDetail; - expect(detail.inFlight[0]).toMatchObject({ session: "sess-7", epic: 7, epicRef: null }); + expect(detail.inFlight[0]).toMatchObject({ session: "sess-7", epic: 7, epicRef: "7" }); }); test("file-mode IN FLIGHT row surfaces epic_ref as epicRef with a null epic (#187)", async () => { diff --git a/packages/dashboard/test/helpers.ts b/packages/dashboard/test/helpers.ts index 5e66a85a..e3f7e4a9 100644 --- a/packages/dashboard/test/helpers.ts +++ b/packages/dashboard/test/helpers.ts @@ -60,7 +60,11 @@ export type SeedWorkflow = { id: string; repo: string; epicNumber?: number | null; - /** File-mode Epic slug. Set directly (createWorkflowRecord doesn't write it). */ + /** + * File-mode Epic slug. When omitted, a numeric `epicNumber` is stringified into + * the ref (github mode writes both `epic_number` and `epic_ref`); set this + * explicitly for a file-mode slug or a blank-ref edge case. + */ epicRef?: string | null; adapter?: string; state?: WorkflowState; @@ -76,11 +80,17 @@ export type SeedWorkflow = { /** Insert an implementation workflow row with the given fields set. */ export function seedWorkflow(db: Database, w: SeedWorkflow): void { + // The ref is the source of truth: an explicit `epicRef` (file-mode slug or + // blank-ref edge) wins; otherwise a numeric `epicNumber` is stringified (github + // mode). createWorkflowRecord derives `epic_number` from a numeric ref and + // leaves it null for a slug — exactly the dual-column contract production uses. + const ref = + w.epicRef !== undefined ? w.epicRef : w.epicNumber != null ? String(w.epicNumber) : null; createWorkflowRecord(db, { id: w.id, kind: "implementation", repo: w.repo, - epicNumber: w.epicNumber ?? null, + epicRef: ref, adapter: w.adapter ?? "claude", }); updateWorkflow(db, w.id, { @@ -91,9 +101,6 @@ export function seedWorkflow(db: Database, w: SeedWorkflow): void { worktreePath: w.worktreePath, }); // Columns updateWorkflow doesn't cover — set directly. - if (w.epicRef !== undefined) { - db.run("UPDATE workflows SET epic_ref = ? WHERE id = ?", [w.epicRef, w.id]); - } if (w.currentSubIssue !== undefined) { db.run("UPDATE workflows SET current_sub_issue = ? WHERE id = ?", [w.currentSubIssue, w.id]); } diff --git a/packages/dashboard/test/sse.test.ts b/packages/dashboard/test/sse.test.ts index 988d6ded..6fa5b67c 100644 --- a/packages/dashboard/test/sse.test.ts +++ b/packages/dashboard/test/sse.test.ts @@ -169,8 +169,8 @@ describe("dashboard SSE channels", () => { epicRef: string | null; state: string; }; - // github-mode row: epic set, epic_ref unset (createWorkflowRecord doesn't write it). - expect(data).toEqual({ id: "wf-1", repo, epic: 7, epicRef: null, state: "running" }); + // github-mode row writes both columns: epic set and epic_ref the stringified number. + expect(data).toEqual({ id: "wf-1", repo, epic: 7, epicRef: "7", state: "running" }); } finally { dispose(); } diff --git a/packages/dispatcher/src/audit.ts b/packages/dispatcher/src/audit.ts index b692325e..a6d264f5 100644 --- a/packages/dispatcher/src/audit.ts +++ b/packages/dispatcher/src/audit.ts @@ -42,7 +42,7 @@ export async function runBacklogAudit(deps: BacklogAuditDeps): Promise<{ flagged const finding = auditIssueBody(issue.body, { title: issue.title }); if (finding.pass) continue; try { - await deps.github.addLabel(deps.repo, issue.number, NEEDS_DESIGN_LABEL); + await deps.github.addLabel(deps.repo, String(issue.number), NEEDS_DESIGN_LABEL); flagged.push(issue.number); console.error( `[backlog-audit] ${deps.repo}#${issue.number} fails the integration rubric → ${NEEDS_DESIGN_LABEL}`, diff --git a/packages/dispatcher/src/build-deps.ts b/packages/dispatcher/src/build-deps.ts index b3aaea42..16c59581 100644 --- a/packages/dispatcher/src/build-deps.ts +++ b/packages/dispatcher/src/build-deps.ts @@ -147,10 +147,10 @@ export async function buildImplementationDeps( const active = findActiveWorkflowBySession(args.db, sessionName); if (!active) return null; const workflow = getWorkflow(args.db, active.id); - if (!workflow || workflow.epicNumber === null) return null; - return { repo: workflow.repo, epicNumber: workflow.epicNumber }; + if (!workflow || workflow.epicRef === null) return null; + return { repo: workflow.repo, epicRef: workflow.epicRef }; }, - findEpicPr: (repo, epicNumber) => github.findEpicPr(repo, epicNumber), + findEpicPr: (repo, epicRef) => github.findEpicPr(repo, epicRef), resolveCommentAuthor: (url) => github.getCommentAuthor(args.repoSlug ?? "", url), }); @@ -174,23 +174,23 @@ export async function buildImplementationDeps( agentLogin, // Positive done-signal (#80): a bare-stop only completes if the Epic // already has a ready, non-draft PR. - epicPrReadiness: async (repo, epicNumber) => { - const pr = await github.findEpicPr(repo, epicNumber); + epicPrReadiness: async (repo, epicRef) => { + const pr = await github.findEpicPr(repo, epicRef); return { exists: pr !== null, isDraft: pr?.isDraft ?? false }; }, // Default surface: comment the pause on the Epic (framed by kind) via `gh`, // so the recommender can classify a complexity pause under `complexity pause`. postQuestion: args.postQuestion ?? - (async ({ repo, epicNumber, question, context, kind }) => { - await github.postComment(repo, epicNumber, formatPauseComment({ question, context, kind })); + (async ({ repo, epicRef, question, context, kind }) => { + await github.postComment(repo, epicRef, formatPauseComment({ question, context, kind })); }), resolveComplexityCeiling: args.resolveComplexityCeiling, // Default: the Epic is approved iff it carries the `approved` label (#53). isEpicApproved: args.isEpicApproved ?? - (async (repo, epicNumber) => - (await github.getIssueLabels(repo, epicNumber)).includes(APPROVED_LABEL)), + (async (repo, epicRef) => + (await github.getIssueLabels(repo, epicRef)).includes(APPROVED_LABEL)), launchTimeoutMs: args.launchTimeoutMs, stopTimeoutMs: args.stopTimeoutMs, livenessPollMs: args.livenessPollMs, diff --git a/packages/dispatcher/src/gates/checkbox-revert-pass.ts b/packages/dispatcher/src/gates/checkbox-revert-pass.ts index fa66665c..7d79a04b 100644 --- a/packages/dispatcher/src/gates/checkbox-revert-pass.ts +++ b/packages/dispatcher/src/gates/checkbox-revert-pass.ts @@ -108,7 +108,7 @@ export async function runCheckboxRevertPass(deps: CheckboxRevertPassDeps): Promi const config = loadConfig(wf.worktreePath); if (!config) continue; // no gates to enforce → nothing to revert - const pr = await deps.github.findEpicPr(wf.repo, wf.epicNumber); + const pr = await deps.github.findEpicPr(wf.repo, wf.epicRef); if (!pr) continue; // PR not opened yet const previous = getCheckboxReconcileState(deps.db, wf.id); @@ -135,7 +135,7 @@ export async function runCheckboxRevertPass(deps: CheckboxRevertPassDeps): Promi await deps.github.editPullRequestBody(wf.repo, pr.number, next); }, postComment: async (text) => { - await deps.github.postComment(wf.repo, pr.number, text); + await deps.github.postComment(wf.repo, String(pr.number), text); }, runGates, getPreviousState: async () => previous.state, @@ -149,7 +149,7 @@ export async function runCheckboxRevertPass(deps: CheckboxRevertPassDeps): Promi reverted += result.reverted.length; } catch (error) { console.error( - `[checkbox-revert] pass failed for workflow ${wf.id} (${wf.repo}#${wf.epicNumber}): ${(error as Error).message}`, + `[checkbox-revert] pass failed for workflow ${wf.id} (${wf.repo}#${wf.epicRef}): ${(error as Error).message}`, ); } } diff --git a/packages/dispatcher/src/gates/gate-evidence.ts b/packages/dispatcher/src/gates/gate-evidence.ts index d13960e0..9e312de3 100644 --- a/packages/dispatcher/src/gates/gate-evidence.ts +++ b/packages/dispatcher/src/gates/gate-evidence.ts @@ -11,8 +11,8 @@ import type { IssueComment } from "./plan-comment.ts"; /** The GitHub seam evidence posting needs (a subset of `EpicGateway`). */ export interface EvidenceGateway { - listIssueComments(repo: string, issueNumber: number): Promise; - postComment(repo: string, issueNumber: number, body: string): Promise; + listIssueComments(repo: string, ref: string): Promise; + postComment(repo: string, ref: string, body: string): Promise; editComment(repo: string, commentId: number, body: string): Promise; } @@ -98,13 +98,13 @@ export async function upsertEvidenceComment(opts: { const marker = evidenceMarker(opts.subIssue); const body = renderEvidence(opts.subIssue, opts.report); - const comments = await opts.gh.listIssueComments(opts.repo, opts.prNumber); + const comments = await opts.gh.listIssueComments(opts.repo, String(opts.prNumber)); const existing = comments.find((c) => c.body.includes(marker)); const id = existing ? commentId(existing.url) : null; if (existing && id !== null) { await opts.gh.editComment(opts.repo, id, body); } else { - await opts.gh.postComment(opts.repo, opts.prNumber, body); + await opts.gh.postComment(opts.repo, String(opts.prNumber), body); } } diff --git a/packages/dispatcher/src/gates/plan-comment.ts b/packages/dispatcher/src/gates/plan-comment.ts index eebdb4c9..d07d5e9a 100644 --- a/packages/dispatcher/src/gates/plan-comment.ts +++ b/packages/dispatcher/src/gates/plan-comment.ts @@ -17,7 +17,7 @@ export type IssueComment = { /** The GitHub read seam this gate depends on (satisfied by the gh-CLI gateway). */ export interface PlanCommentReader { - listIssueComments(repo: string, issueNumber: number): Promise; + listIssueComments(repo: string, ref: string): Promise; } export type GateResult = { ok: true } | { ok: false; reason: string }; @@ -36,14 +36,14 @@ function normalize(text: string): string { export async function verifyPlanComment(opts: { gh: PlanCommentReader; repo: string; - epicNumber: number; + epicRef: string; planBody: string; /** When set, only comments by this account count (the agent's gh identity). */ agentLogin?: string; }): Promise { const miss: GateResult = { ok: false, - reason: `Plan-comment guard: no plan comment found on Epic #${opts.epicNumber}`, + reason: `Plan-comment guard: no plan comment found on Epic #${opts.epicRef}`, }; const needle = normalize(opts.planBody); @@ -51,7 +51,7 @@ export async function verifyPlanComment(opts: { // body trivially "contains" the empty string. if (needle === "") return miss; - const comments = await opts.gh.listIssueComments(opts.repo, opts.epicNumber); + const comments = await opts.gh.listIssueComments(opts.repo, opts.epicRef); for (const comment of comments) { if (opts.agentLogin !== undefined && comment.authorLogin !== opts.agentLogin) continue; if (normalize(comment.body).includes(needle)) return { ok: true }; diff --git a/packages/dispatcher/src/gates/pr-ready-handler.ts b/packages/dispatcher/src/gates/pr-ready-handler.ts index 89739b95..52c0f1b2 100644 --- a/packages/dispatcher/src/gates/pr-ready-handler.ts +++ b/packages/dispatcher/src/gates/pr-ready-handler.ts @@ -17,10 +17,10 @@ import { } from "./pr-ready.ts"; export type PrReadyGateDeps = { - /** Map a session name to its workflow's repo + Epic number, or null. */ - resolveSession: (sessionName: string) => { repo: string; epicNumber: number } | null; + /** Map a session name to its workflow's repo + Epic ref, or null. */ + resolveSession: (sessionName: string) => { repo: string; epicRef: string } | null; /** The open Epic PR (its body carries the union of sub-issue criteria), or null. */ - findEpicPr: (repo: string, epicNumber: number) => Promise<{ body: string } | null>; + findEpicPr: (repo: string, epicRef: string) => Promise<{ body: string } | null>; /** Resolve a deferral comment's author (for the non-bot check). */ resolveCommentAuthor: CommentAuthorResolver; }; @@ -44,11 +44,11 @@ export function makePrReadyGateHandler(deps: PrReadyGateDeps): PrReadyGateHandle }; } - const pr = await deps.findEpicPr(workflow.repo, workflow.epicNumber); + const pr = await deps.findEpicPr(workflow.repo, workflow.epicRef); if (!pr) { return { decision: "deny", - reason: `PR-ready guard: no open Epic PR found for #${workflow.epicNumber}.`, + reason: `PR-ready guard: no open Epic PR found for #${workflow.epicRef}.`, }; } diff --git a/packages/dispatcher/src/github.ts b/packages/dispatcher/src/github.ts index 35566cd4..3f14e40b 100644 --- a/packages/dispatcher/src/github.ts +++ b/packages/dispatcher/src/github.ts @@ -95,37 +95,60 @@ export function parseEpicsList(stdout: string): EpicListItem[] { return out; } +/** + * The Epic-store gateway. The issue/Epic identifier flows as a string `ref` (the + * "string-keyed seam"): the stringified issue number in github mode, a file slug + * in file mode. The github implementation (`ghGitHub`) parses each `ref` back to + * an integer at its `gh`-CLI boundary (see {@link refToIssueNumber}); a file + * implementation reads the ref as a slug. PR numbers and comment ids stay + * numeric — PRs/reviews are GitHub-native in both modes. + */ export interface EpicGateway { /** Comments on an issue or PR (PRs are issues for the comments endpoint). */ - listIssueComments(repo: string, issueNumber: number): Promise; - /** The open PR for an Epic — the one whose body closes `#epicNumber`. */ - findEpicPr(repo: string, epicNumber: number): Promise; + listIssueComments(repo: string, ref: string): Promise; + /** The open PR for an Epic — the one whose body closes the Epic referenced by `epicRef`. */ + findEpicPr(repo: string, epicRef: string): Promise; /** A single PR by number. */ getPullRequest(repo: string, prNumber: number): Promise; /** Overwrite a PR's body (used by the checkbox-revert reconciler). */ editPullRequestBody(repo: string, prNumber: number, body: string): Promise; - /** Post a comment on an issue or PR. */ - postComment(repo: string, issueNumber: number, body: string): Promise; + /** Post a comment on an issue or PR (`ref` is the issue/Epic ref; PR comments pass the PR number as the ref). */ + postComment(repo: string, ref: string, body: string): Promise; /** Edit an existing issue/PR comment in place (used to upsert gate evidence). */ editComment(repo: string, commentId: number, body: string): Promise; /** Resolve the author of a comment from its URL; null if unresolvable. */ getCommentAuthor(repo: string, commentUrl: string): Promise; /** The label names on an issue/Epic (e.g. to check for `approved`). */ - getIssueLabels(repo: string, issueNumber: number): Promise; + getIssueLabels(repo: string, ref: string): Promise; /** Open Epics in a repo (issues with ≥1 sub-issue), each with sub-issue progress. */ listOpenEpics(repo: string): Promise; /** Every open issue (not PRs) with body + labels — for the requirements/staleness audits. */ listOpenIssues(repo: string): Promise; /** Add a label to an issue (no-op if already present). */ - addLabel(repo: string, issueNumber: number, label: string): Promise; + addLabel(repo: string, ref: string, label: string): Promise; /** Recently-merged PRs and the issues each closes — for landed-but-open detection. */ listMergedPrsClosingRefs(repo: string): Promise; /** Close an issue with an evidence comment (the anti-staleness reconcile trail). */ - closeIssue(repo: string, issueNumber: number, comment: string): Promise; + closeIssue(repo: string, ref: string, comment: string): Promise; /** File a new issue (the proposal-first reconcile task). Returns its number. */ createIssue(repo: string, issue: NewIssue): Promise; } +/** + * Parse a string Epic/issue `ref` to the integer GitHub issue number `gh` + * requires. github mode's contract is numeric-string refs only; a slug (the + * file-mode reference) is rejected here with a clear error rather than silently + * coercing to `NaN` and producing a confusing `gh` failure downstream. + */ +export function refToIssueNumber(ref: string): number { + if (!/^\d+$/.test(ref.trim())) { + throw new Error( + `github mode requires a numeric issue/Epic reference, got "${ref}" (file-mode slugs are not valid here)`, + ); + } + return Number(ref.trim()); +} + async function run( argv: string[], stdin?: string, @@ -175,7 +198,8 @@ export async function resolveAgentLogin(): Promise { } export const ghGitHub: EpicGateway = { - async listIssueComments(repo, issueNumber) { + async listIssueComments(repo, ref) { + const issueNumber = refToIssueNumber(ref); const result = await run([ "gh", "issue", @@ -198,7 +222,8 @@ export const ghGitHub: EpicGateway = { .map((line) => JSON.parse(line) as IssueComment); }, - async findEpicPr(repo, epicNumber) { + async findEpicPr(repo, epicRef) { + const epicNumber = refToIssueNumber(epicRef); const result = await run([ "gh", "pr", @@ -236,7 +261,8 @@ export const ghGitHub: EpicGateway = { return prs.find((pr) => closes.test(pr.body)) ?? null; }, - async getIssueLabels(repo, issueNumber) { + async getIssueLabels(repo, ref) { + const issueNumber = refToIssueNumber(ref); const result = await run([ "gh", "issue", @@ -311,7 +337,8 @@ export const ghGitHub: EpicGateway = { })); }, - async addLabel(repo, issueNumber, label) { + async addLabel(repo, ref, label) { + const issueNumber = refToIssueNumber(ref); const result = await run([ "gh", "issue", @@ -356,7 +383,8 @@ export const ghGitHub: EpicGateway = { })); }, - async closeIssue(repo, issueNumber, comment) { + async closeIssue(repo, ref, comment) { + const issueNumber = refToIssueNumber(ref); const result = await run([ "gh", "issue", @@ -450,7 +478,8 @@ export const ghGitHub: EpicGateway = { } }, - async postComment(repo, issueNumber, body) { + async postComment(repo, ref, body) { + const issueNumber = refToIssueNumber(ref); const result = await run( ["gh", "issue", "comment", String(issueNumber), "--repo", repo, "--body-file", "-"], body, diff --git a/packages/dispatcher/src/hook-server.ts b/packages/dispatcher/src/hook-server.ts index 7e7d61d2..b2633d10 100644 --- a/packages/dispatcher/src/hook-server.ts +++ b/packages/dispatcher/src/hook-server.ts @@ -23,7 +23,12 @@ export const SSE_IDLE_TIMEOUT_SECONDS = Math.ceil((DEFAULT_HEARTBEAT_MS / 1000) export type ControlDispatchInput = { repo: string; repoPath: string; - epicNumber: number; + /** + * The Epic reference threaded into the dispatch (the workflow seam is + * string-keyed). In github mode the control route validates a numeric + * `epicNumber` in the request body and stringifies it here. + */ + epicRef: string; adapter: string; }; @@ -412,7 +417,9 @@ export class HookServer implements SessionGate { return this.#badRequest(reject); } - const dispatchInput = { repo: normalizedRepo, repoPath, epicNumber, adapter }; + // github mode's control API takes a numeric `epicNumber`; the dispatcher seam + // is string-keyed, so stringify it into `epicRef` at this boundary. + const dispatchInput = { repo: normalizedRepo, repoPath, epicRef: String(epicNumber), adapter }; // Manual dispatch respects slot limits — refuse with 429 when the repo/adapter // has no free slot (build spec → "Auto-dispatch loop": manual force-dispatch diff --git a/packages/dispatcher/src/main.ts b/packages/dispatcher/src/main.ts index 5cd1444b..115674e4 100644 --- a/packages/dispatcher/src/main.ts +++ b/packages/dispatcher/src/main.ts @@ -167,7 +167,7 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise { // released once the row exists — the first broadcast that resolves it, below — // after which `hasNonTerminalEpicWorkflow` (the DB) is the source of truth. const inFlightEpics = new Set(); - const epicKey = (repo: string, epicNumber: number): string => `${repo}#${epicNumber}`; + const epicKey = (repo: string, epicRef: string): string => `${repo}#${epicRef}`; const lastBroadcastState = new Map(); const broadcastWorkflow = (executionId: string, state: string): void => { @@ -176,7 +176,7 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise { const row = getWorkflow(db, executionId); // The workflow row now exists → drop any pre-row dispatch reservation; the // DB collision check covers this epic from here on. - if (row && row.epicNumber !== null) inFlightEpics.delete(epicKey(row.repo, row.epicNumber)); + if (row && row.epicRef !== null) inFlightEpics.delete(epicKey(row.repo, row.epicRef)); hub.broadcast({ type: "workflow", data: { id: executionId, repo: row?.repo ?? "", epic: row?.epicNumber ?? null, state }, @@ -237,8 +237,8 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise { input: ControlDispatchInput, source: "manual" | "auto", ): Promise { - const key = epicKey(input.repo, input.epicNumber); - if (inFlightEpics.has(key) || hasNonTerminalEpicWorkflow(db, input.repo, input.epicNumber)) { + const key = epicKey(input.repo, input.epicRef); + if (inFlightEpics.has(key) || hasNonTerminalEpicWorkflow(db, input.repo, input.epicRef)) { return null; } inFlightEpics.add(key); @@ -246,7 +246,7 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise { rememberRepoPath(input.repo, input.repoPath); const handle = await engine.start("implementation", { repo: input.repo, - epicNumber: input.epicNumber, + epicRef: input.epicRef, adapter: input.adapter, source, }); @@ -338,7 +338,7 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise { rateLimitedAdapters: () => rateLimitedAdapters(adapters), getSlotState: () => getSlotState(db, repo, limits), enqueue: ({ repo: r, epicNumber, adapter }) => - startDispatchImpl({ repo: r, repoPath, epicNumber, adapter }, "auto"), + startDispatchImpl({ repo: r, repoPath, epicRef: String(epicNumber), adapter }, "auto"), }); } catch (error) { // Announce a parse failure on the state issue (deduped); other errors fall @@ -408,7 +408,7 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise { if (reject !== null) { return { status: 400, body: JSON.stringify({ error: reject }) }; } - const input = { repo: normalizedRepo, repoPath, epicNumber, adapter }; + const input = { repo: normalizedRepo, repoPath, epicRef: String(epicNumber), adapter }; if (!slotAvailable(input)) { return { status: 429, @@ -666,20 +666,20 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise { const orphans = await reconcileOrphanedSignals({ db, hasExecution: (id) => engine.getExecution(id) !== null, - surface: ({ workflowId, repo, epicNumber, signalName }) => { + surface: ({ workflowId, repo, epicRef, signalName }) => { console.error( - `[recover] orphaned parked signal '${signalName}' for ${repo}#${epicNumber ?? "?"} (workflow ${workflowId}) — no recoverable execution; finalized failed`, + `[recover] orphaned parked signal '${signalName}' for ${repo}#${epicRef ?? "?"} (workflow ${workflowId}) — no recoverable execution; finalized failed`, ); - if (epicNumber === null) return; + if (epicRef === null) return; return ghGitHub .postComment( repo, - epicNumber, + epicRef, `⚠️ middle could not recover this Epic's parked workflow after a daemon restart (no durable execution for \`${workflowId}\`). The run was finalized as \`failed\`; re-dispatch the Epic to continue.`, ) .catch((error: unknown) => { console.error( - `[recover] orphan Epic comment for ${repo}#${epicNumber} failed: ${(error as Error).message}`, + `[recover] orphan Epic comment for ${repo}#${epicRef} failed: ${(error as Error).message}`, ); }); }, diff --git a/packages/dispatcher/src/poller-gateway.ts b/packages/dispatcher/src/poller-gateway.ts index 4a56d535..b096301b 100644 --- a/packages/dispatcher/src/poller-gateway.ts +++ b/packages/dispatcher/src/poller-gateway.ts @@ -7,6 +7,7 @@ import type { PrSnapshot, RateLimitStatus, } from "./poller.ts"; +import { refToIssueNumber } from "./github.ts"; /** * One `statusCheckRollup` entry as `gh pr view` returns it. A **CheckRun** @@ -76,7 +77,8 @@ function isBotLogin(login: string, type: string | undefined): boolean { } export const ghPollGateway: PollGateway = { - async listIssueComments(repo: string, issueNumber: number): Promise { + async listIssueComments(repo: string, ref: string): Promise { + const issueNumber = refToIssueNumber(ref); // `--slurp` wraps the per-page arrays into one outer array; `gh` without it // emits one JSON array *per page*, which `JSON.parse` chokes on past page 1. const out = await gh([ @@ -104,7 +106,8 @@ export const ghPollGateway: PollGateway = { })); }, - async findPrForEpic(repo: string, epicNumber: number): Promise { + async findPrForEpic(repo: string, epicRef: string): Promise { + const epicNumber = refToIssueNumber(epicRef); // The Epic's one PR closes the Epic — find the open PR referencing it. // The server-side search is a prefix match, so `Closes #3` also surfaces // `Closes #30`/`#300`; re-confirm the exact closing reference client-side on @@ -175,7 +178,8 @@ export const ghPollGateway: PollGateway = { }; }, - async findEpicPrLifecycle(repo: string, epicNumber: number): Promise { + async findEpicPrLifecycle(repo: string, epicRef: string): Promise { + const epicNumber = refToIssueNumber(epicRef); // Same `Closes #` linkage as findPrForEpic, but across ALL states so a // merged/closed PR is visible. The server-side search is a prefix match, so // re-confirm the exact closing reference client-side (anchored boundary). diff --git a/packages/dispatcher/src/poller.ts b/packages/dispatcher/src/poller.ts index 3150b98f..f977be2e 100644 --- a/packages/dispatcher/src/poller.ts +++ b/packages/dispatcher/src/poller.ts @@ -82,15 +82,15 @@ export type EpicPrLifecycle = { number: number; state: "OPEN" | "MERGED" | "CLOS /** The read-only GitHub surface the poller needs — injectable so tests need no `gh`. */ export type PollGateway = { - listIssueComments(repo: string, issueNumber: number): Promise; + listIssueComments(repo: string, ref: string): Promise; /** The Epic's one open PR, or null if it hasn't been opened yet. */ - findPrForEpic(repo: string, epicNumber: number): Promise; + findPrForEpic(repo: string, epicRef: string): Promise; /** * The Epic's PR lifecycle across every state (open/merged/closed), or null if * no PR references the Epic. Unlike {@link findPrForEpic} (open-only), this * sees a merged/closed PR so a parked workflow can be reconciled to terminal. */ - findEpicPrLifecycle(repo: string, epicNumber: number): Promise; + findEpicPrLifecycle(repo: string, epicRef: string): Promise; /** * Current REST budget. Read from `gh api rate_limit`, whose own request does * not consume quota — so the poller can consult it every pass for free. @@ -292,14 +292,14 @@ export async function runPoller(deps: PollerDeps): Promise { // gate + burst cap apply to the real workload (the skipped ones below cost // nothing). type PollableWait = ReturnType[number]; - const actionable: Array<{ wait: PollableWait; reason: ResumeReason; epicNumber: number }> = []; + const actionable: Array<{ wait: PollableWait; reason: ResumeReason; epicRef: string }> = []; for (const wait of loadPollableWaits(deps.db)) { - if (wait.firedAt !== null || wait.epicNumber === null) continue; + if (wait.firedAt !== null || wait.epicRef === null) continue; const reason = reasonFromSignalName(wait.signalName); if (reason === null) continue; - // epicNumber is narrowed to `number` past the guard above; capture it so the + // epicRef is narrowed to `string` past the guard above; capture it so the // gh calls in the loop below don't see the row's nullable type again. - actionable.push({ wait, reason, epicNumber: wait.epicNumber }); + actionable.push({ wait, reason, epicRef: wait.epicRef }); } if (actionable.length === 0) return 0; @@ -314,10 +314,10 @@ export async function runPoller(deps: PollerDeps): Promise { let fired = 0; // Burst cap: bound the calls fired in one tick; the rest wait for next pass. - for (const { wait, reason, epicNumber } of actionable.slice(0, maxPerPass)) { + for (const { wait, reason, epicRef } of actionable.slice(0, maxPerPass)) { try { if (reason === "answered-question") { - const comments = await deps.github.listIssueComments(wait.repo, epicNumber); + const comments = await deps.github.listIssueComments(wait.repo, epicRef); const reply = classifyNewHumanReply(comments, wait.createdAt); if (!reply) continue; await deps.fireSignal(wait.workflowId, { @@ -325,7 +325,7 @@ export async function runPoller(deps: PollerDeps): Promise { reply: { commentId: reply.id, authorLogin: reply.authorLogin, body: reply.body }, }); } else { - const pr = await deps.github.findPrForEpic(wait.repo, epicNumber); + const pr = await deps.github.findPrForEpic(wait.repo, epicRef); if (!pr) continue; const verdict = classifyReviewOutcome(pr, wait.createdAt); if (!verdict) continue; @@ -412,7 +412,7 @@ export async function reconcileMergedParks(deps: ReconcileDeps): Promise const mergedRepos = new Set(); for (const wf of parked.slice(0, maxPerPass)) { try { - const life = await deps.github.findEpicPrLifecycle(wf.repo, wf.epicNumber); + const life = await deps.github.findEpicPrLifecycle(wf.repo, wf.epicRef); // Open PR (a live review park) or no PR yet (a pending question) → leave it // for `runPoller` / the human; only a landed/abandoned PR is reconciled. if (!life || life.state === "OPEN") continue; @@ -423,7 +423,7 @@ export async function reconcileMergedParks(deps: ReconcileDeps): Promise if (!finalizeParkedWorkflow(deps.db, wf.id, finalState)) continue; reconciled++; console.error( - `[reconcile] ${wf.repo}#${wf.epicNumber} PR ${life.state} → ${finalState} (workflow ${wf.id})`, + `[reconcile] ${wf.repo}#${wf.epicRef} PR ${life.state} → ${finalState} (workflow ${wf.id})`, ); if (deps.removeWorktree) { try { @@ -451,7 +451,7 @@ export async function reconcileMergedParks(deps: ReconcileDeps): Promise } } catch (error) { console.error( - `[reconcile] failed for workflow ${wf.id} (${wf.repo}#${wf.epicNumber}): ${(error as Error).message}`, + `[reconcile] failed for workflow ${wf.id} (${wf.repo}#${wf.epicRef}): ${(error as Error).message}`, ); } } diff --git a/packages/dispatcher/src/reconcilers/pr-divergence.ts b/packages/dispatcher/src/reconcilers/pr-divergence.ts index 9153f636..dc300eb0 100644 --- a/packages/dispatcher/src/reconcilers/pr-divergence.ts +++ b/packages/dispatcher/src/reconcilers/pr-divergence.ts @@ -302,7 +302,7 @@ export type WorktreeOpsDeps = { createWorktree?: (opts: { repoPath: string; repo: string; - issueNumber: number; + epicRef: string; worktreeRoot?: string; }) => Promise; }; @@ -329,7 +329,7 @@ export async function resolveWorktreePath( await create({ repoPath: deps.resolveRepoPath(repo), repo, - issueNumber: epicNumber, + epicRef: String(epicNumber), worktreeRoot: deps.worktreeRoot, }); return { worktreePath, epicNumber }; @@ -398,8 +398,8 @@ export type ReconciliationResolution = "rebased" | "merged-new-work-as-base"; * {@link "../github.ts".EpicGateway} method names so the daemon-side * composition is a thin `Pick`. */ export type PrCommentGateway = { - listIssueComments(repo: string, issueNumber: number): Promise<{ body: string }[]>; - postComment(repo: string, issueNumber: number, body: string): Promise; + listIssueComments(repo: string, ref: string): Promise<{ body: string }[]>; + postComment(repo: string, ref: string, body: string): Promise; }; /** Deps for `applySuccess` — the union of worktree resolution (head ref + @@ -470,11 +470,11 @@ export async function applySuccess( // also fail to record the success. if (mainCommitSha !== null) { const marker = reconciledMarker(resolution, mainCommitSha); - const existing = await deps.github.listIssueComments(repo, prNumber); + const existing = await deps.github.listIssueComments(repo, String(prNumber)); const alreadyPosted = existing.some((c) => (c.body ?? "").includes(marker)); if (!alreadyPosted) { const body = `🔁 Reconciled with main (${resolution}) after ${mainCommitSha.slice(0, 9)}\n\n${marker}`; - await deps.github.postComment(repo, prNumber, body); + await deps.github.postComment(repo, String(prNumber), body); } } @@ -613,7 +613,7 @@ export async function applyDemoteToWork( } const marker = demoteMarker(epicNumber); - const epicComments = await deps.github.listIssueComments(repo, epicNumber); + const epicComments = await deps.github.listIssueComments(repo, String(epicNumber)); const epicAlreadyDemoted = epicComments.some((c) => (c.body ?? "").includes(marker)); // Most-recently-closed sub-issue, if any. Skip the reopen when the Epic @@ -638,9 +638,9 @@ export async function applyDemoteToWork( const existing = issueNumber === epicNumber ? epicComments - : await deps.github.listIssueComments(repo, issueNumber); + : await deps.github.listIssueComments(repo, String(issueNumber)); if (!existing.some((c) => (c.body ?? "").includes(marker))) { - await deps.github.postComment(repo, issueNumber, escalationBody); + await deps.github.postComment(repo, String(issueNumber), escalationBody); } } diff --git a/packages/dispatcher/src/recovery.ts b/packages/dispatcher/src/recovery.ts index 029bd282..e76bb354 100644 --- a/packages/dispatcher/src/recovery.ts +++ b/packages/dispatcher/src/recovery.ts @@ -91,7 +91,7 @@ export async function recoverEngine(engine: Engine): Promise "?").join(", "); const row = db .query( `SELECT 1 AS n FROM workflows - WHERE kind = 'implementation' AND repo = ? AND epic_number = ? + WHERE kind = 'implementation' AND repo = ? AND epic_ref = ? AND state NOT IN (${placeholders}) LIMIT 1`, ) - .get(repo, epicNumber, ...TERMINAL_STATES) as { n: number } | null; + .get(repo, epicRef, ...TERMINAL_STATES) as { n: number } | null; return row !== null; } @@ -712,33 +720,33 @@ export function listNonTerminalWorkflows(db: Database): NonTerminalWorkflow[] { export type ParkedWorkflow = { id: string; repo: string; - epicNumber: number; + epicRef: string; worktreePath: string | null; }; /** * Parked `kind = 'implementation'` workflows (`state = 'waiting-human'`) that own * an Epic — the set the merged/closed-PR reconciler walks. Rows with a null - * `epic_number` are excluded: with no Epic there's no PR lifecycle to consult. + * `epic_ref` are excluded: with no Epic there's no PR lifecycle to consult. * Ordered oldest-first so the burst cap reconciles the longest-stuck rows first. */ export function listParkedImplementationWorkflows(db: Database): ParkedWorkflow[] { const rows = db .query( - `SELECT id, repo, epic_number, worktree_path FROM workflows - WHERE kind = 'implementation' AND state = 'waiting-human' AND epic_number IS NOT NULL + `SELECT id, repo, epic_ref, worktree_path FROM workflows + WHERE kind = 'implementation' AND state = 'waiting-human' AND epic_ref IS NOT NULL ORDER BY created_at ASC, rowid ASC`, ) .all() as { id: string; repo: string; - epic_number: number; + epic_ref: string; worktree_path: string | null; }[]; return rows.map((r) => ({ id: r.id, repo: r.repo, - epicNumber: r.epic_number, + epicRef: r.epic_ref, worktreePath: r.worktree_path, })); } @@ -747,7 +755,7 @@ export function listParkedImplementationWorkflows(db: Database): ParkedWorkflow[ export type RunningWorkflow = { id: string; repo: string; - epicNumber: number; + epicRef: string; worktreePath: string; }; @@ -761,21 +769,21 @@ export type RunningWorkflow = { export function listRunningImplementationWorkflows(db: Database): RunningWorkflow[] { const rows = db .query( - `SELECT id, repo, epic_number, worktree_path FROM workflows + `SELECT id, repo, epic_ref, worktree_path FROM workflows WHERE kind = 'implementation' AND state = 'running' - AND epic_number IS NOT NULL AND worktree_path IS NOT NULL + AND epic_ref IS NOT NULL AND worktree_path IS NOT NULL ORDER BY created_at ASC, rowid ASC`, ) .all() as { id: string; repo: string; - epic_number: number; + epic_ref: string; worktree_path: string; }[]; return rows.map((r) => ({ id: r.id, repo: r.repo, - epicNumber: r.epic_number, + epicRef: r.epic_ref, worktreePath: r.worktree_path, })); } diff --git a/packages/dispatcher/src/workflows/documentation.ts b/packages/dispatcher/src/workflows/documentation.ts index 4bdf8c14..32d8cdd7 100644 --- a/packages/dispatcher/src/workflows/documentation.ts +++ b/packages/dispatcher/src/workflows/documentation.ts @@ -204,7 +204,7 @@ export function createDocumentationWorkflow(deps: DocumentationDeps): Workflow boolean | Promise; + isEpicApproved?: (repo: string, epicRef: string) => boolean | Promise; /** * Enqueue a continuation execution for the next round (a resume). Injected so * the workflow stays free of the engine: in prod the dispatcher wires this to @@ -232,7 +232,7 @@ export type ImplementationDeps = { */ epicPrReadiness?: ( repo: string, - epicNumber: number, + epicRef: string, ) => Promise<{ exists: boolean; isDraft: boolean }>; /** Max "continue" nudges on a bare-stop before parking in waiting-human. */ maxNudges?: number; @@ -277,7 +277,7 @@ const DEFAULT_VERIFY_ROUND_CAP = 3; */ function sessionNameFor(input: ImplementationInput): string { const repoSlug = input.repo.replace(/[^A-Za-z0-9_-]/g, "-"); - return `middle-${repoSlug}-${input.epicNumber}`; + return `middle-${repoSlug}-${input.epicRef}`; } /** @@ -289,7 +289,7 @@ function sessionNameFor(input: ImplementationInput): string { */ function ensurePromptFile( worktreePath: string, - epicNumber: number, + epicRef: string, complexityCeiling: number, approved: boolean, ): void { @@ -297,7 +297,7 @@ function ensurePromptFile( const promptPath = join(middleDir, "prompt.md"); if (existsSync(promptPath)) return; mkdirSync(middleDir, { recursive: true }); - writeFileSync(promptPath, defaultDispatchBrief(epicNumber, complexityCeiling, approved)); + writeFileSync(promptPath, defaultDispatchBrief(epicRef, complexityCeiling, approved)); } /** @@ -309,7 +309,7 @@ function ensurePromptFile( * "Complexity and architectural forks"; #53). */ function defaultDispatchBrief( - epicNumber: number, + epicRef: string, complexityCeiling: number, approved: boolean, ): string { @@ -322,7 +322,7 @@ function defaultDispatchBrief( decision needing more than ${complexityCeiling} candidate forks (the complexity ceiling) to resolve. To pause, write \`.middle/blocked.json\` and exit; for a complexity overrun include \`"kind": "complexity"\` in it.`; - return `# middle dispatch brief — Epic #${epicNumber} + return `# middle dispatch brief — Epic #${epicRef} You are running autonomously under middle. There is no human watching in real time. Operating rules for this dispatch: @@ -357,7 +357,7 @@ ${complexityRule} */ function writeResumeBrief( worktreePath: string, - epicNumber: number, + epicRef: string, resume: ResumeInput, reviewRoundCap: number, ): void { @@ -380,7 +380,7 @@ function writeResumeBrief( : "(the human's reply text was unavailable — check the Epic thread on GitHub)"; writeFileSync( promptPath, - `# middle dispatch brief — Epic #${epicNumber} (resumed: a human answered) + `# middle dispatch brief — Epic #${epicRef} (resumed: a human answered) A human answered the open question you parked on. Their reply: @@ -403,7 +403,7 @@ ${operatingRules}`, if (decision === CI_FAILED_DECISION) { writeFileSync( promptPath, - `# middle dispatch brief — Epic #${epicNumber} (resumed: CI is failing — round ${resume.round} of ${reviewRoundCap}) + `# middle dispatch brief — Epic #${epicRef} (resumed: CI is failing — round ${resume.round} of ${reviewRoundCap}) The PR's CI is **red** — a PR can't be reviewed until it builds. Investigate and fix the failing checks now: @@ -426,7 +426,7 @@ ${operatingRules}`, writeFileSync( promptPath, - `# middle dispatch brief — Epic #${epicNumber} (resumed: address review — round ${resume.round} of ${reviewRoundCap}) + `# middle dispatch brief — Epic #${epicRef} (resumed: address review — round ${resume.round} of ${reviewRoundCap}) A reviewer requested changes on the PR${decision ? ` (decision: ${decision})` : ""}. Address this review pass now, following the \`implementing-github-issues\` skill's @@ -470,10 +470,10 @@ function reasonFor(kind: DriveOutcome["kind"]): ResumeReason { } /** Read the workstream's committed plan from the worktree (for the plan-comment guard). */ -function readPlanBody(worktreePath: string, epicNumber: number): string { +function readPlanBody(worktreePath: string, epicRef: string): string { try { return readFileSync( - join(worktreePath, "planning", "issues", String(epicNumber), "plan.md"), + join(worktreePath, "planning", "issues", String(epicRef), "plan.md"), "utf8", ); } catch { @@ -606,12 +606,12 @@ export function createImplementationWorkflow( sessionName: string; worktree: string; repo: string; - epicNumber: number; + epicRef: string; classifyAt: (payload: Awaited>) => StopClassification; }): Promise { const readiness = deps.epicPrReadiness!; for (let nudges = 0; ; nudges += 1) { - const pr = await readiness(args.repo, args.epicNumber); + const pr = await readiness(args.repo, args.epicRef); if (pr.exists && !pr.isDraft) { console.error(`${args.tag} positive done-signal: ready Epic PR — completing`); return { kind: "done" }; @@ -648,7 +648,7 @@ export function createImplementationWorkflow( sessionName: string; worktree: string; repo: string; - epicNumber: number; + epicRef: string; classifyAt: (payload: Awaited>) => StopClassification; }): Promise { const runVerify = deps.runVerifyGates!; @@ -695,7 +695,7 @@ export function createImplementationWorkflow( sessionName: args.sessionName, worktree: args.worktree, repo: args.repo, - epicNumber: args.epicNumber, + epicRef: args.epicRef, classifyAt: args.classifyAt, }); if (settled.kind !== "done") return settled; @@ -712,7 +712,7 @@ export function createImplementationWorkflow( id: ctx.executionId, kind: "implementation", repo: ctx.input.repo, - epicNumber: ctx.input.epicNumber, + epicRef: ctx.input.epicRef, adapter: ctx.input.adapter, source: ctx.input.source ?? "auto", }); @@ -722,7 +722,7 @@ export function createImplementationWorkflow( // no new branch, no new PR) and re-prime the brief for this resume reason. const handle = resume.worktree; updateWorkflow(deps.db, ctx.executionId, { worktreePath: handle.path }); - writeResumeBrief(handle.path, ctx.input.epicNumber, resume, reviewRoundCap); + writeResumeBrief(handle.path, ctx.input.epicRef, resume, reviewRoundCap); return { handle }; } // NB: a *terminal* failure of this (first) step strands the row at `pending` @@ -735,7 +735,7 @@ export function createImplementationWorkflow( const handle = await deps.worktree.createWorktree({ repoPath: deps.resolveRepoPath(ctx.input.repo), repo: ctx.input.repo, - issueNumber: ctx.input.epicNumber, + epicRef: ctx.input.epicRef, worktreeRoot: deps.worktreeRoot, }); updateWorkflow(deps.db, ctx.executionId, { worktreePath: handle.path }); @@ -785,14 +785,14 @@ export function createImplementationWorkflow( complexityCeiling = await deps.resolveComplexityCeiling(ctx.input.repo); } if (deps.isEpicApproved) { - approved = await deps.isEpicApproved(ctx.input.repo, ctx.input.epicNumber); + approved = await deps.isEpicApproved(ctx.input.repo, ctx.input.epicRef); } } catch (error) { console.error( `${tag} brief-context resolution failed, using defaults (ceiling=${DEFAULT_COMPLEXITY_CEILING}, approved=false): ${(error as Error).message}`, ); } - ensurePromptFile(handle.path, ctx.input.epicNumber, complexityCeiling, approved); + ensurePromptFile(handle.path, ctx.input.epicRef, complexityCeiling, approved); } console.error(`${tag} installing hooks in ${handle.path}`); @@ -802,7 +802,7 @@ export function createImplementationWorkflow( dispatcherUrl: deps.dispatcherUrl, sessionName, sessionToken, - epicNumber: ctx.input.epicNumber, + epicRef: ctx.input.epicRef, }); const { argv, env } = adapter.buildLaunchCommand({ @@ -811,7 +811,7 @@ export function createImplementationWorkflow( sessionToken, envOverrides: { MIDDLE_DISPATCHER_URL: deps.dispatcherUrl, - MIDDLE_EPIC: String(ctx.input.epicNumber), + MIDDLE_EPIC: String(ctx.input.epicRef), }, }); // Clear any orphaned session of the same name left by a prior dispatch @@ -851,7 +851,7 @@ export function createImplementationWorkflow( const promptText = adapter.buildPromptText({ promptFile: ".middle/prompt.md", kind: promptKind, - epicNumber: ctx.input.epicNumber, + epicRef: ctx.input.epicRef, }); console.error(`${tag} sending prompt (${promptKind}): "${promptText}"`); await deps.tmux.sendText(sessionName, promptText); @@ -886,7 +886,7 @@ export function createImplementationWorkflow( sessionName, worktree: handle.path, repo: ctx.input.repo, - epicNumber: ctx.input.epicNumber, + epicRef: ctx.input.epicRef, classifyAt, }); } @@ -894,11 +894,11 @@ export function createImplementationWorkflow( // if the agent posted its plan as an Epic comment. Demote an unposted // `done` to `failed` here so it never enters the review-resolve park. if (outcome.kind === "done" && deps.planCommentReader) { - const planBody = readPlanBody(handle.path, ctx.input.epicNumber); + const planBody = readPlanBody(handle.path, ctx.input.epicRef); const guard = await verifyPlanComment({ gh: deps.planCommentReader, repo: ctx.input.repo, - epicNumber: ctx.input.epicNumber, + epicRef: ctx.input.epicRef, planBody, agentLogin: deps.agentLogin, }); @@ -917,7 +917,7 @@ export function createImplementationWorkflow( sessionName, worktree: handle.path, repo: ctx.input.repo, - epicNumber: ctx.input.epicNumber, + epicRef: ctx.input.epicRef, classifyAt, }); } @@ -963,7 +963,7 @@ export function createImplementationWorkflow( if (!isWaitForArmed(deps.db, ctx.executionId)) { armWaitForSignal( deps.db, - signalNameFor(ctx.input.epicNumber, reason), + signalNameFor(ctx.input.epicRef, reason), ctx.executionId, JSON.stringify({ reason }), ); @@ -976,7 +976,7 @@ export function createImplementationWorkflow( try { await deps.postQuestion({ repo: ctx.input.repo, - epicNumber: ctx.input.epicNumber, + epicRef: ctx.input.epicRef, question: outcome.sentinel?.question ?? "(question text unavailable)", context: outcome.sentinel?.context, kind, @@ -1097,7 +1097,7 @@ export function createImplementationWorkflow( // changed, so the poller retries cleanly on its next pass. await deps.enqueueContinuation({ repo: ctx.input.repo, - epicNumber: ctx.input.epicNumber, + epicRef: ctx.input.epicRef, adapter: ctx.input.adapter, source: ctx.input.source, // a continuation keeps the origin of its workstream resume: { reason: payload.reason, round: nextRound, worktree: handle, payload }, diff --git a/packages/dispatcher/src/workflows/recommender.ts b/packages/dispatcher/src/workflows/recommender.ts index 359e488c..98bd7fee 100644 --- a/packages/dispatcher/src/workflows/recommender.ts +++ b/packages/dispatcher/src/workflows/recommender.ts @@ -431,7 +431,7 @@ export function createRecommenderWorkflow(deps: RecommenderDeps): Workflow { const repoPath = realpathSync(opts.repoPath); const root = resolveRoot(opts.worktreeRoot); - const unit = opts.unit ?? unitName(opts.issueNumber); + const unit = opts.unit ?? unitName(opts.epicRef); const path = join(root, opts.repo, unit); // Guard against a malicious/garbled `repo` (e.g. "../../x" from a crafted // git remote) escaping the worktree root — otherwise destroyWorktree's diff --git a/packages/dispatcher/test/adapter-conformance.test.ts b/packages/dispatcher/test/adapter-conformance.test.ts index 63e31240..590f4e63 100644 --- a/packages/dispatcher/test/adapter-conformance.test.ts +++ b/packages/dispatcher/test/adapter-conformance.test.ts @@ -73,7 +73,7 @@ describe.each(knownAdapters())("AgentAdapter contract — %s", (name) => { test("buildPromptText: initial is the skill slash-command on the Epic", () => { expect( - adapter.buildPromptText({ promptFile: ".middle/prompt.md", kind: "initial", epicNumber: 60 }), + adapter.buildPromptText({ promptFile: ".middle/prompt.md", kind: "initial", epicRef: "60" }), ).toBe("/implementing-github-issues implement #60"); }); @@ -94,7 +94,7 @@ describe.each(knownAdapters())("AgentAdapter contract — %s", (name) => { dispatcherUrl: "http://127.0.0.1:4120", sessionName: "middle-60", sessionToken: "tok", - epicNumber: 60, + epicRef: "60", }); const hook = await Bun.file(join(worktree, ".middle/hooks/hook.sh")).text(); expect(hook).toStartWith("#!/bin/sh"); diff --git a/packages/dispatcher/test/backlog-audit.test.ts b/packages/dispatcher/test/backlog-audit.test.ts index 02795c6c..983343c4 100644 --- a/packages/dispatcher/test/backlog-audit.test.ts +++ b/packages/dispatcher/test/backlog-audit.test.ts @@ -17,12 +17,12 @@ const WEAK = "## Acceptance criteria\n- [ ] it works\n- [ ] unit tests pass"; /** An in-memory GitHub gateway recording label writes. */ function fakeGithub(issues: IssueSummary[]) { - const labelled: { n: number; label: string }[] = []; + const labelled: { ref: string; label: string }[] = []; return { labelled, listOpenIssues: async () => issues, - addLabel: async (_repo: string, n: number, label: string) => { - labelled.push({ n, label }); + addLabel: async (_repo: string, ref: string, label: string) => { + labelled.push({ ref, label }); }, }; } @@ -36,7 +36,7 @@ describe("runBacklogAudit", () => { ]); const { flagged } = await runBacklogAudit({ repo: "o/r", github: gh }); expect(flagged).toEqual([2]); - expect(gh.labelled).toEqual([{ n: 2, label: NEEDS_DESIGN_LABEL }]); + expect(gh.labelled).toEqual([{ ref: "2", label: NEEDS_DESIGN_LABEL }]); }); test("does not re-label an issue already marked needs-design", async () => { @@ -67,9 +67,9 @@ describe("runBacklogAudit", () => { { number: 1, title: "Weak A", body: WEAK, labels: ["enhancement"] }, { number: 2, title: "Weak B", body: WEAK, labels: ["enhancement"] }, ], - addLabel: async (_repo: string, n: number) => { - if (n === 1) throw new Error("boom"); - gh.labelled.push(n); + addLabel: async (_repo: string, ref: string) => { + if (ref === "1") throw new Error("boom"); + gh.labelled.push(Number(ref)); }, }; const { flagged } = await runBacklogAudit({ repo: "o/r", github: gh }); diff --git a/packages/dispatcher/test/build-deps.test.ts b/packages/dispatcher/test/build-deps.test.ts index a1b2e3df..4cc878be 100644 --- a/packages/dispatcher/test/build-deps.test.ts +++ b/packages/dispatcher/test/build-deps.test.ts @@ -41,7 +41,7 @@ describe("buildImplementationDeps", () => { const db = openAndMigrate(dbPath); try { const epicPr: PullRequest = { number: 7, body: "Closes #5", isDraft: false }; - const findEpicPrCalls: Array<[string, number]> = []; + const findEpicPrCalls: Array<[string, string]> = []; const getAdapter = (name: string): AgentAdapter => { if (name !== "claude") throw new Error(`unknown adapter: ${name}`); return fakeAdapter(); @@ -92,9 +92,9 @@ describe("buildImplementationDeps", () => { expect(deps.getAdapter).toBe(getAdapter); // epicPrReadiness delegates to github.findEpicPr. - const readiness = await deps.epicPrReadiness!("o/r", 5); + const readiness = await deps.epicPrReadiness!("o/r", "5"); expect(readiness).toEqual({ exists: true, isDraft: false }); - expect(findEpicPrCalls).toEqual([["o/r", 5]]); + expect(findEpicPrCalls).toEqual([["o/r", "5"]]); } finally { db.close(); } @@ -119,7 +119,7 @@ describe("buildImplementationDeps", () => { }, bindServer: () => ({ sessionGate: noopGate, dispatcherUrl: "http://127.0.0.1:1" }), }); - expect(await deps.epicPrReadiness!("o/r", 9)).toEqual({ exists: false, isDraft: false }); + expect(await deps.epicPrReadiness!("o/r", "9")).toEqual({ exists: false, isDraft: false }); expect(deps.agentLogin).toBeUndefined(); } finally { db.close(); @@ -133,7 +133,7 @@ describe("buildImplementationDeps", () => { }); test("the default postQuestion posts a gh comment framed by pause kind", async () => { - const posted: Array<{ repo: string; issue: number; body: string }> = []; + const posted: Array<{ repo: string; issue: string; body: string }> = []; const db = openAndMigrate(dbPath); try { const { deps } = await buildImplementationDeps({ @@ -155,14 +155,14 @@ describe("buildImplementationDeps", () => { }); await deps.postQuestion!({ repo: "o/r", - epicNumber: 7, + epicRef: "7", question: "4 designs, no winner", context: "A/B/C/D", kind: "complexity", }); expect(posted).toHaveLength(1); expect(posted[0]!.repo).toBe("o/r"); - expect(posted[0]!.issue).toBe(7); + expect(posted[0]!.issue).toBe("7"); // The complexity-pause framing the recommender keys off for its label. expect(posted[0]!.body).toContain("complexity pause"); expect(posted[0]!.body).toContain("4 designs, no winner"); diff --git a/packages/dispatcher/test/control-routes.test.ts b/packages/dispatcher/test/control-routes.test.ts index 36e96485..d4cb2f64 100644 --- a/packages/dispatcher/test/control-routes.test.ts +++ b/packages/dispatcher/test/control-routes.test.ts @@ -10,8 +10,8 @@ import { type ControlPlane, HookServer, SSE_IDLE_TIMEOUT_SECONDS } from "../src/ let server: HookServer; let base: string; -let startCalls: Array<{ repo: string; repoPath: string; epicNumber: number; adapter: string }>; -let collisionEpics: Set; +let startCalls: Array<{ repo: string; repoPath: string; epicRef: string; adapter: string }>; +let collisionEpics: Set; let hub: EventHub; function makeControl(overrides: Partial = {}): ControlPlane { @@ -23,7 +23,7 @@ function makeControl(overrides: Partial = {}): ControlPlane { startDispatch: async (input) => { // `startDispatch` is the single source of truth for the 409 guard: a // colliding Epic resolves `null`. The stub drives that off `collisionEpics`. - if (collisionEpics.has(input.epicNumber)) return null; + if (collisionEpics.has(input.epicRef)) return null; startCalls.push(input); return "wf-abc"; }, @@ -74,7 +74,7 @@ describe("HookServer control routes", () => { expect(res.status).toBe(200); expect(await res.json()).toEqual({ workflowId: "wf-abc" }); expect(startCalls).toEqual([ - { repo: "o/r", repoPath: "/abs/checkout", epicNumber: 7, adapter: "claude" }, + { repo: "o/r", repoPath: "/abs/checkout", epicRef: "7", adapter: "claude" }, ]); }); @@ -188,7 +188,7 @@ describe("HookServer control routes", () => { test("POST /control/dispatch rejects a colliding Epic with 409", async () => { startWith(makeControl()); - collisionEpics.add(7); + collisionEpics.add("7"); const res = await fetch(`${base}/control/dispatch`, { method: "POST", body: JSON.stringify({ @@ -207,15 +207,15 @@ describe("HookServer control routes", () => { // for the live-engine race test); here the stub emulates a single-winner // reserve to pin that the *route* faithfully relays it — it must not // reintroduce a non-atomic pre-check that lets both requests through. - const reserved = new Set(); + const reserved = new Set(); startWith( makeControl({ startDispatch: async (input) => { - if (reserved.has(input.epicNumber)) return null; // sync check + add: no await between - reserved.add(input.epicNumber); + if (reserved.has(input.epicRef)) return null; // sync check + add: no await between + reserved.add(input.epicRef); await Bun.sleep(5); // hold so the two requests genuinely overlap startCalls.push(input); - return `wf-${input.epicNumber}`; + return `wf-${input.epicRef}`; }, }), ); diff --git a/packages/dispatcher/test/epic-143-demo.test.ts b/packages/dispatcher/test/epic-143-demo.test.ts index ef74a0e9..ab97eed7 100644 --- a/packages/dispatcher/test/epic-143-demo.test.ts +++ b/packages/dispatcher/test/epic-143-demo.test.ts @@ -48,7 +48,7 @@ describe("Epic #143 — integration-verified requirements + freshness", () => { test("3. reconciliation surfaces a landed-but-open issue and a drifted spec line", async () => { const created: NewIssue[] = []; - const closed: number[] = []; + const closed: string[] = []; const open: IssueSummary[] = [ { number: 50, title: "Build the widget UI", body: "", labels: ["enhancement", "phase:9"] }, ]; @@ -70,7 +70,7 @@ describe("Epic #143 — integration-verified requirements + freshness", () => { specPath: "planning/middle-management-build-spec.md", }); - expect(closed).toEqual([50]); // landed-but-open issue closed + expect(closed).toEqual(["50"]); // landed-but-open issue closed expect(result.drift.map((d) => d.phase)).toEqual([9]); // drifted spec line surfaced expect(created.map((i) => i.title)).toEqual([reconcileTaskTitle(9)]); // proposal-first reconcile task }); diff --git a/packages/dispatcher/test/gates/checkbox-revert-pass.test.ts b/packages/dispatcher/test/gates/checkbox-revert-pass.test.ts index 1d23ab40..76d0d0ea 100644 --- a/packages/dispatcher/test/gates/checkbox-revert-pass.test.ts +++ b/packages/dispatcher/test/gates/checkbox-revert-pass.test.ts @@ -56,7 +56,7 @@ function scratchWorktree(): string { } /** In-memory GitHub stub: a mutable Epic PR (body + headSha) and a comment log. */ -function fakeGithub(opts: { body: string; headSha?: string; epicNumber?: number }) { +function fakeGithub(opts: { body: string; headSha?: string; epicRef?: string }) { const pr: PullRequest = { number: PR_NUMBER, body: opts.body, @@ -72,7 +72,7 @@ function fakeGithub(opts: { body: string; headSha?: string; epicNumber?: number const github: EpicGateway = { async findEpicPr(_repo, epic) { findCalls++; - return epic === (opts.epicNumber ?? 1) ? pr : null; + return epic === (opts.epicRef ?? "1") ? pr : null; }, async editPullRequestBody(_repo, _num, body) { pr.body = body; @@ -112,12 +112,12 @@ function fakeGithub(opts: { body: string; headSha?: string; epicNumber?: number } /** Seed a running implementation workflow on the given worktree. */ -function seedRunning(id: string, worktreePath: string, epicNumber = 1): void { +function seedRunning(id: string, worktreePath: string, epicRef = "1"): void { createWorkflowRecord(db, { id, kind: "implementation", repo: REPO, - epicNumber, + epicRef, adapter: "claude", }); updateWorkflow(db, id, { state: "running", worktreePath }); @@ -269,17 +269,17 @@ describe("runCheckboxRevertPass", () => { const wtBad = scratchWorktree(); const wtGood = scratchWorktree(); try { - seedRunning("bad", wtBad, 1); - seedRunning("good", wtGood, 2); + seedRunning("bad", wtBad, "1"); + seedRunning("good", wtGood, "2"); const good = fakeGithub({ body: STATUS(["- [x] #101 — fails"]), headSha: "sha1", - epicNumber: 2, + epicRef: "2", }); const github: EpicGateway = { ...good.github, async findEpicPr(repo, epic) { - if (epic === 1) throw new Error("GitHub down"); + if (epic === "1") throw new Error("GitHub down"); return good.github.findEpicPr(repo, epic); }, }; @@ -300,7 +300,7 @@ describe("runCheckboxRevertPass", () => { id: "w", kind: "implementation", repo: REPO, - epicNumber: 1, + epicRef: "1", adapter: "claude", }); updateWorkflow(db, "w", { state: "waiting-human", worktreePath: wt }); diff --git a/packages/dispatcher/test/gates/plan-comment.test.ts b/packages/dispatcher/test/gates/plan-comment.test.ts index f4d68cfc..6737160e 100644 --- a/packages/dispatcher/test/gates/plan-comment.test.ts +++ b/packages/dispatcher/test/gates/plan-comment.test.ts @@ -25,7 +25,7 @@ describe("verifyPlanComment", () => { const result = await verifyPlanComment({ gh: reader([{ authorLogin: "agentbot", body: PLAN, url: "u1" }]), repo: "o/r", - epicNumber: 27, + epicRef: "27", planBody: PLAN, agentLogin: "agentbot", }); @@ -36,7 +36,7 @@ describe("verifyPlanComment", () => { const result = await verifyPlanComment({ gh: reader([{ authorLogin: "agentbot", body: "lgtm, shipping", url: "u1" }]), repo: "o/r", - epicNumber: 27, + epicRef: "27", planBody: PLAN, agentLogin: "agentbot", }); @@ -50,7 +50,7 @@ describe("verifyPlanComment", () => { const result = await verifyPlanComment({ gh: reader([{ authorLogin: "someone-else", body: PLAN, url: "u1" }]), repo: "o/r", - epicNumber: 27, + epicRef: "27", planBody: PLAN, agentLogin: "agentbot", }); @@ -62,7 +62,7 @@ describe("verifyPlanComment", () => { const result = await verifyPlanComment({ gh: reader([{ authorLogin: "agentbot", body: crlf, url: "u1" }]), repo: "o/r", - epicNumber: 27, + epicRef: "27", planBody: PLAN, agentLogin: "agentbot", }); @@ -73,7 +73,7 @@ describe("verifyPlanComment", () => { const result = await verifyPlanComment({ gh: reader([{ authorLogin: "whoever", body: `prefix\n\n${PLAN}`, url: "u1" }]), repo: "o/r", - epicNumber: 27, + epicRef: "27", planBody: PLAN, }); expect(result.ok).toBe(true); @@ -83,7 +83,7 @@ describe("verifyPlanComment", () => { const result = await verifyPlanComment({ gh: reader([{ authorLogin: "agentbot", body: "anything at all", url: "u1" }]), repo: "o/r", - epicNumber: 27, + epicRef: "27", planBody: " \n \n", agentLogin: "agentbot", }); diff --git a/packages/dispatcher/test/gates/pr-ready-handler.test.ts b/packages/dispatcher/test/gates/pr-ready-handler.test.ts index 776b767b..416546f6 100644 --- a/packages/dispatcher/test/gates/pr-ready-handler.test.ts +++ b/packages/dispatcher/test/gates/pr-ready-handler.test.ts @@ -7,7 +7,7 @@ const UNEVIDENCED = "## Acceptance criteria\n- [ ] not done yet, no evidence\n"; function deps(over: Partial): PrReadyGateDeps { return { - resolveSession: () => ({ repo: "o/r", epicNumber: 27 }), + resolveSession: () => ({ repo: "o/r", epicRef: "27" }), findEpicPr: async () => ({ body: EVIDENCED }), resolveCommentAuthor: async () => ({ login: "human", isBot: false }), ...over, diff --git a/packages/dispatcher/test/gates/verify.test.ts b/packages/dispatcher/test/gates/verify.test.ts index a0a6b255..e2820828 100644 --- a/packages/dispatcher/test/gates/verify.test.ts +++ b/packages/dispatcher/test/gates/verify.test.ts @@ -85,7 +85,7 @@ describe("verification gates wired into checkbox-revert (end to end)", () => { w.state.body = body; }, async postComment(body) { - await w.github.postComment("o/r", 99, body); + await w.github.postComment("o/r", "99", body); }, runGates, async getPreviousState() { @@ -204,7 +204,7 @@ describe("verification gates wired into checkbox-revert (end to end)", () => { w.state.body = b; }, async postComment(b) { - await w.github.postComment("o/r", 99, b); + await w.github.postComment("o/r", "99", b); }, runGates, async getPreviousState() { @@ -252,7 +252,7 @@ describe("verification gates wired into checkbox-revert (end to end)", () => { w.state.body = b; }, async postComment(b) { - await w.github.postComment("o/r", 99, b); + await w.github.postComment("o/r", "99", b); }, runGates, async getPreviousState() { diff --git a/packages/dispatcher/test/hook-store.test.ts b/packages/dispatcher/test/hook-store.test.ts index fdf7188c..9c892b62 100644 --- a/packages/dispatcher/test/hook-store.test.ts +++ b/packages/dispatcher/test/hook-store.test.ts @@ -28,7 +28,7 @@ function seedSession(sessionName: string, token: string): string { id, kind: "implementation", repo: "thejustinwalsh/middle", - epicNumber: 14, + epicRef: "14", adapter: "claude", }); updateWorkflow(db, id, { state: "running", sessionName, sessionToken: token }); diff --git a/packages/dispatcher/test/implementation-workflow.test.ts b/packages/dispatcher/test/implementation-workflow.test.ts index 48be8fee..1905a7e2 100644 --- a/packages/dispatcher/test/implementation-workflow.test.ts +++ b/packages/dispatcher/test/implementation-workflow.test.ts @@ -215,7 +215,8 @@ function expectNoSessionLeak(tmux: { created: string[]; killed: string[] }): voi } const EPIC = 6; -const INPUT = { repo: "thejustinwalsh/middle", epicNumber: EPIC, adapter: "stub" }; +const EPIC_REF = String(EPIC); +const INPUT = { repo: "thejustinwalsh/middle", epicRef: EPIC_REF, adapter: "stub" }; async function start(deps: ImplementationDeps): Promise { engine.register(createImplementationWorkflow(deps)); @@ -633,7 +634,7 @@ describe("implementation workflow — dispatch source (#53)", () => { expect(getWorkflowSource(db, manual.id)).toBe("manual"); // A fresh dispatch with no source defaults to 'auto'. - const auto = await engine.start("implementation", { ...INPUT, epicNumber: 99 }); + const auto = await engine.start("implementation", { ...INPUT, epicRef: "99" }); await awaitParked(auto.id); expect(getWorkflowSource(db, auto.id)).toBe("auto"); }); @@ -643,7 +644,7 @@ describe("implementation workflow — asked-question park → answer → resume test("parks on asked-question, a human reply resumes a fresh continuation with the answer injected", async () => { const tmux = makeTmuxStub(); const prompts: string[] = []; - const postQuestionCalls: Array<{ epicNumber: number; question: string; context?: string }> = []; + const postQuestionCalls: Array<{ epicRef: string; question: string; context?: string }> = []; // One shared stub instance so its classification sequence advances across // both executions: initial → asked-question, the continuation → done. const adapter = makeAdapterStub( @@ -662,7 +663,7 @@ describe("implementation workflow — asked-question park → answer → resume getAdapter: () => adapter, postQuestion: async (opts) => { postQuestionCalls.push({ - epicNumber: opts.epicNumber, + epicRef: opts.epicRef, question: opts.question, context: opts.context, }); @@ -673,11 +674,11 @@ describe("implementation workflow — asked-question park → answer → resume // Parked: waiting-human, the epic-scoped 'answered' signal armed, worktree kept. await awaitParked(id0); expect(getWaitForSignal(db, id0)).toEqual({ - signalName: signalNameFor(EPIC, "answered-question"), + signalName: signalNameFor(EPIC_REF, "answered-question"), payloadJson: JSON.stringify({ reason: "answered-question" }), }); expect(postQuestionCalls).toEqual([ - { epicNumber: EPIC, question: "Option A or B?", context: "Both compile." }, + { epicRef: EPIC_REF, question: "Option A or B?", context: "Both compile." }, ]); expect((await listWorktrees({ repoPath, worktreeRoot })).length).toBe(1); expect(prompts).toEqual(["initial"]); // continuation not yet driven @@ -703,7 +704,7 @@ describe("implementation workflow — asked-question park → answer → resume expect(brief).toContain("@alice"); // An answered question does not advance the review counter; it parks on review. expect(getWaitForSignal(db, id1)).toEqual({ - signalName: signalNameFor(EPIC, "review-changes"), + signalName: signalNameFor(EPIC_REF, "review-changes"), payloadJson: JSON.stringify({ reason: "review-changes" }), }); @@ -728,7 +729,7 @@ describe("implementation workflow — done park → review-changes → resume (e await awaitParked(id0); expect(getWaitForSignal(db, id0)).toEqual({ - signalName: signalNameFor(EPIC, "review-changes"), + signalName: signalNameFor(EPIC_REF, "review-changes"), payloadJson: JSON.stringify({ reason: "review-changes" }), }); expect((await listWorktrees({ repoPath, worktreeRoot })).length).toBe(1); @@ -855,10 +856,10 @@ function makeWorktreeStub(planBody: string | null) { return { handles, ops: { - async createWorktree(opts: { repoPath: string; repo: string; issueNumber?: number }) { + async createWorktree(opts: { repoPath: string; repo: string; epicRef?: string }) { const path = realpathSync(mkdtempSync(join(tmpdir(), "middle-wt-stub-"))); if (planBody !== null) { - const dir = join(path, "planning", "issues", String(opts.issueNumber)); + const dir = join(path, "planning", "issues", String(opts.epicRef)); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, "plan.md"), planBody); } @@ -867,7 +868,7 @@ function makeWorktreeStub(planBody: string | null) { path, branch: "stub-branch", repo: opts.repo, - unit: `issue-${opts.issueNumber}`, + unit: `issue-${opts.epicRef}`, }; handles.push(handle); return handle; @@ -1175,7 +1176,7 @@ describe("implementation workflow — durable recovery across daemon restart (#1 const { id: id0 } = await e1.start("implementation", INPUT); await awaitParkedOn(e1, id0); expect(getWaitForSignal(db, id0)).toEqual({ - signalName: signalNameFor(EPIC, "review-changes"), + signalName: signalNameFor(EPIC_REF, "review-changes"), payloadJson: JSON.stringify({ reason: "review-changes" }), }); expect((await listWorktrees({ repoPath, worktreeRoot })).length).toBe(1); diff --git a/packages/dispatcher/test/metrics.test.ts b/packages/dispatcher/test/metrics.test.ts index 8bcd0672..4d4af948 100644 --- a/packages/dispatcher/test/metrics.test.ts +++ b/packages/dispatcher/test/metrics.test.ts @@ -28,7 +28,7 @@ function seed( adapter: string, state: Parameters[2]["state"], ): void { - createWorkflowRecord(db, { id, kind, repo, epicNumber: 1, adapter }); + createWorkflowRecord(db, { id, kind, repo, epicRef: "1", adapter }); if (state) updateWorkflow(db, id, { state }); } diff --git a/packages/dispatcher/test/poller.test.ts b/packages/dispatcher/test/poller.test.ts index 129f43c5..8ba1b40c 100644 --- a/packages/dispatcher/test/poller.test.ts +++ b/packages/dispatcher/test/poller.test.ts @@ -49,13 +49,13 @@ function seedParked(reason: ResumeReason, epic = EPIC): string { id, kind: "implementation", repo: REPO, - epicNumber: epic, + epicRef: String(epic), adapter: "claude", }); updateWorkflow(db, id, { state: "waiting-human" }); // armWaitForSignal stamps created_at = Date.now(); normalize it to ARMED_AT so // recency comparisons in the poller are deterministic. - armWaitForSignal(db, signalNameFor(epic, reason), id, JSON.stringify({ reason })); + armWaitForSignal(db, signalNameFor(String(epic), reason), id, JSON.stringify({ reason })); db.run("UPDATE waitfor_signals SET created_at = ? WHERE workflow_id = ?", [ARMED_AT, id]); return id; } @@ -464,9 +464,9 @@ describe("runPoller — resilience", () => { let n = 0; const github: PollGateway = { - async listIssueComments(_repo, epicNumber) { + async listIssueComments(_repo, epicRef) { n++; - if (epicNumber === 200) throw new Error("API rate limit exceeded"); + if (epicRef === "200") throw new Error("API rate limit exceeded"); return [comment({ id: 1, authorLogin: "human", body: "answer" })]; }, async findPrForEpic() { diff --git a/packages/dispatcher/test/pr-divergence-integration.test.ts b/packages/dispatcher/test/pr-divergence-integration.test.ts index 88cd19da..4c2e32a0 100644 --- a/packages/dispatcher/test/pr-divergence-integration.test.ts +++ b/packages/dispatcher/test/pr-divergence-integration.test.ts @@ -338,8 +338,8 @@ describe("applySuccess — fixture repo", () => { * `EpicGateway` subset {@link applySuccess} consumes. */ function makeCommentSpy(): { - listIssueComments: (repo: string, prNumber: number) => Promise<{ body: string }[]>; - postComment: (repo: string, prNumber: number, body: string) => Promise; + listIssueComments: (repo: string, ref: string) => Promise<{ body: string }[]>; + postComment: (repo: string, ref: string, body: string) => Promise; posted: string[]; } { const posted: string[] = []; @@ -497,11 +497,11 @@ describe("reconcileOpenPRs — end-to-end against the fixture repo", () => { getMainCommitSha: 0, getMergeability: 0 as number, getPrHeadRef: 0 as number, - postComment: [] as { issueNumber: number; body: string }[], + postComment: [] as { ref: string; body: string }[], convertPrToDraft: [] as number[], reopenIssue: [] as { issueNumber: number; comment: string | undefined }[], }; - const comments = new Map(); + const comments = new Map(); const drafts = new Set(); const gateway: ReconcilerGateway = { async listOpenManagedPrs() { @@ -533,14 +533,14 @@ describe("reconcileOpenPRs — end-to-end against the fixture repo", () => { async reopenIssue(_repo, issueNumber, options) { calls.reopenIssue.push({ issueNumber, comment: options?.comment }); }, - async listIssueComments(_repo, issueNumber) { - return (comments.get(issueNumber) ?? []).map((body) => ({ body })); + async listIssueComments(_repo, ref) { + return (comments.get(ref) ?? []).map((body) => ({ body })); }, - async postComment(_repo, issueNumber, body) { - calls.postComment.push({ issueNumber, body }); - const bucket = comments.get(issueNumber) ?? []; + async postComment(_repo, ref, body) { + calls.postComment.push({ ref, body }); + const bucket = comments.get(ref) ?? []; bucket.push(body); - comments.set(issueNumber, bucket); + comments.set(ref, bucket); }, }; return { gateway, calls, comments, drafts }; @@ -579,7 +579,7 @@ describe("reconcileOpenPRs — end-to-end against the fixture repo", () => { expect(r1).toEqual({ reconciled: 1, passed: 0, failed: 0, skippedForBudget: false }); // The rebase moved feature on top of main; applySuccess pushed and posted. expect(fixture.calls.postComment.length).toBe(1); - expect(fixture.calls.postComment[0]?.issueNumber).toBe(100); + expect(fixture.calls.postComment[0]?.ref).toBe("100"); expect(fixture.calls.postComment[0]?.body).toContain("(rebased)"); expect(getDivergenceState(db, "o/r", 100)?.state).toBe("CLEAN"); @@ -687,9 +687,7 @@ describe("reconcileOpenPRs — end-to-end against the fixture repo", () => { expect(fixture.calls.convertPrToDraft).toEqual([102]); expect(fixture.calls.reopenIssue.length).toBe(1); expect(fixture.calls.reopenIssue[0]?.issueNumber).toBe(50); - expect(new Set(fixture.calls.postComment.map((c) => c.issueNumber))).toEqual( - new Set([102, 32]), - ); + expect(new Set(fixture.calls.postComment.map((c) => c.ref))).toEqual(new Set(["102", "32"])); for (const c of fixture.calls.postComment) { expect(c.body).toContain(""); } @@ -778,7 +776,7 @@ describe("reconcileOpenPRs — end-to-end against the fixture repo", () => { // PR 200 got an applySuccess comment; PR 201 (CLEAN) got nothing. expect(fixture.calls.postComment.length).toBe(1); - expect(fixture.calls.postComment[0]?.issueNumber).toBe(200); + expect(fixture.calls.postComment[0]?.ref).toBe("200"); // Both rows are persisted reflecting their classified state. expect(getDivergenceState(db, "o/r", 200)?.state).toBe("CLEAN" satisfies DivergenceState); // applySuccess wrote CLEAN expect(getDivergenceState(db, "o/r", 201)?.state).toBe("CLEAN" satisfies DivergenceState); // classifier wrote CLEAN diff --git a/packages/dispatcher/test/pr-divergence.test.ts b/packages/dispatcher/test/pr-divergence.test.ts index 8c2c3cd5..a1c5bae9 100644 --- a/packages/dispatcher/test/pr-divergence.test.ts +++ b/packages/dispatcher/test/pr-divergence.test.ts @@ -201,15 +201,15 @@ type DemoteSpy = { state: { isDraft: boolean; headRef: string | null; - /** Comments per issue number, keyed by issue number. */ - comments: Map; + /** Comments per issue/Epic ref, keyed by the string ref. */ + comments: Map; closedSubs: ClosedSubIssue[]; }; calls: { convertPrToDraft: Array<[string, number]>; reopenIssue: Array<{ repo: string; issueNumber: number; comment: string | undefined }>; listClosedSubIssues: Array<[string, number]>; - postComment: Array<{ repo: string; issueNumber: number; body: string }>; + postComment: Array<{ repo: string; ref: string; body: string }>; }; }; @@ -245,14 +245,14 @@ function makeDemoteSpy(over: Partial = {}): DemoteSpy { async reopenIssue(repo, issueNumber, options) { calls.reopenIssue.push({ repo, issueNumber, comment: options?.comment }); }, - async listIssueComments(_repo, issueNumber) { - return (state.comments.get(issueNumber) ?? []).map((body) => ({ body })); + async listIssueComments(_repo, ref) { + return (state.comments.get(ref) ?? []).map((body) => ({ body })); }, - async postComment(repo, issueNumber, body) { - calls.postComment.push({ repo, issueNumber, body }); - const bucket = state.comments.get(issueNumber) ?? []; + async postComment(repo, ref, body) { + calls.postComment.push({ repo, ref, body }); + const bucket = state.comments.get(ref) ?? []; bucket.push(body); - state.comments.set(issueNumber, bucket); + state.comments.set(ref, bucket); }, }; return { gateway, state, calls }; @@ -282,7 +282,7 @@ describe("applyDemoteToWork", () => { expect(spy.calls.reopenIssue[0]?.comment).toContain("PR #99 for Epic #32"); expect(spy.calls.postComment.length).toBe(2); // Dual surface: one on the PR (99) and one on the Epic (32 — derived from head ref). - expect(new Set(spy.calls.postComment.map((c) => c.issueNumber))).toEqual(new Set([99, 32])); + expect(new Set(spy.calls.postComment.map((c) => c.ref))).toEqual(new Set(["99", "32"])); // Conflicting paths are surfaced in the escalation body. for (const post of spy.calls.postComment) { expect(post.body).toContain("packages/dispatcher/src/main.ts"); @@ -352,7 +352,7 @@ describe("applyDemoteToWork", () => { expect(spy.calls.reopenIssue.length).toBe(1); expect(spy.calls.reopenIssue[0]?.issueNumber).toBe(50); expect(spy.calls.postComment.length).toBe(2); - expect(new Set(spy.calls.postComment.map((c) => c.issueNumber))).toEqual(new Set([99, 32])); + expect(new Set(spy.calls.postComment.map((c) => c.ref))).toEqual(new Set(["99", "32"])); expect(enqueues).toEqual([[REPO, 32]]); expect(getDivergenceState(db, REPO, 99)?.state).toBe("DEMOTED"); }); @@ -363,7 +363,7 @@ describe("applyDemoteToWork", () => { // To trigger that path we keep PR.isDraft=false (so the function proceeds) // and pre-seed the PR's comments with the marker. const spy = makeDemoteSpy({ - comments: new Map([[99, ["…earlier escalation… "]]]), + comments: new Map([["99", ["…earlier escalation… "]]]), }); await applyDemoteToWork( { @@ -378,7 +378,7 @@ describe("applyDemoteToWork", () => { ); // Only the Epic comment posts — the PR's existing marker gates the duplicate. expect(spy.calls.postComment.length).toBe(1); - expect(spy.calls.postComment[0]?.issueNumber).toBe(32); + expect(spy.calls.postComment[0]?.ref).toBe("32"); }); test("Epic with no closed sub-issues: still demotes + comments + enqueues; no reopen call", async () => { @@ -433,7 +433,7 @@ describe("applyDemoteToWork", () => { // sub-issue close isn't undone. const spy = makeDemoteSpy({ comments: new Map([ - [32, ["…earlier demote escalation… "]], + ["32", ["…earlier demote escalation… "]], ]), }); const enqueues: Array<[string, number]> = []; @@ -460,7 +460,7 @@ describe("applyDemoteToWork", () => { expect(getDivergenceState(db, REPO, 99)?.state).toBe("DEMOTED"); // The marker on the Epic also gates the duplicate Epic comment — only PR // gets a fresh comment (its marker is absent). - expect(new Set(spy.calls.postComment.map((c) => c.issueNumber))).toEqual(new Set([99])); + expect(new Set(spy.calls.postComment.map((c) => c.ref))).toEqual(new Set(["99"])); }); test("PR doesn't exist (gateway returns null) → no-op", async () => { diff --git a/packages/dispatcher/test/recommender-workflow.test.ts b/packages/dispatcher/test/recommender-workflow.test.ts index 2409abf5..598aa8e1 100644 --- a/packages/dispatcher/test/recommender-workflow.test.ts +++ b/packages/dispatcher/test/recommender-workflow.test.ts @@ -679,7 +679,13 @@ describe("recommender workflow — #44 buildRecommenderContext: from dispatcher session: string, state?: string, ) => { - createWorkflowRecord(db, { id, kind: "implementation", repo: REPO, epicNumber: epic, adapter }); + createWorkflowRecord(db, { + id, + kind: "implementation", + repo: REPO, + epicRef: epic === null ? null : String(epic), + adapter, + }); updateWorkflow(db, id, { sessionName: session, state: (state ?? "running") as never }); }; @@ -733,7 +739,7 @@ describe("recommender workflow — #44 buildRecommenderContext: from dispatcher id: "rec", kind: "recommender", repo: REPO, - epicNumber: null, + epicRef: null, adapter: "claude", }); updateWorkflow(db, "rec", { state: "running" }); @@ -756,7 +762,7 @@ describe("recommender workflow — #44 buildRecommenderContext: from dispatcher id: "b", kind: "implementation", repo: "other/repo", - epicNumber: 9, + epicRef: "9", adapter: "claude", }); updateWorkflow(db, "b", { sessionName: "other-9", state: "running" as never }); diff --git a/packages/dispatcher/test/reconcile.test.ts b/packages/dispatcher/test/reconcile.test.ts index 23fafd1a..674fd065 100644 --- a/packages/dispatcher/test/reconcile.test.ts +++ b/packages/dispatcher/test/reconcile.test.ts @@ -34,7 +34,7 @@ function seedParked(epic: number, worktreePath: string = `/wt/issue-${epic}`): s id, kind: "implementation", repo: REPO, - epicNumber: epic, + epicRef: String(epic), adapter: "claude", }); updateWorkflow(db, id, { state: "waiting-human", worktreePath }); @@ -50,8 +50,8 @@ function makeDeps( const deps = { db, github: { - async findEpicPrLifecycle(_repo: string, epic: number) { - return lifecycleByEpic[epic] ?? null; + async findEpicPrLifecycle(_repo: string, epicRef: string) { + return lifecycleByEpic[Number(epicRef)] ?? null; }, async getRateLimit() { return { remaining: opts.remaining ?? 5000, resetAt: 0 }; diff --git a/packages/dispatcher/test/recovery.test.ts b/packages/dispatcher/test/recovery.test.ts index f0f175c5..731493ea 100644 --- a/packages/dispatcher/test/recovery.test.ts +++ b/packages/dispatcher/test/recovery.test.ts @@ -43,7 +43,7 @@ function seedParked(epic: number | null, signalName: string): string { id, kind: "implementation", repo: REPO, - epicNumber: epic, + epicRef: epic === null ? null : String(epic), adapter: "claude", }); updateWorkflow(db, id, { state: "waiting-human" }); @@ -68,7 +68,7 @@ describe("reconcileOrphanedSignals", () => { expect(orphans[0]).toMatchObject({ workflowId: id, repo: REPO, - epicNumber: 6, + epicRef: "6", signalName: "epic-6-review-resolved", }); // Finalized to a terminal state so the poller stops watching it (its @@ -156,7 +156,7 @@ describe("reconcileOrphanedSignals", () => { }); expect(orphans).toHaveLength(1); - expect(surfaced[0]?.epicNumber).toBeNull(); + expect(surfaced[0]?.epicRef).toBeNull(); expect(getWorkflow(db, id)?.state).toBe("failed"); }); @@ -168,7 +168,7 @@ describe("reconcileOrphanedSignals", () => { id, kind: "implementation", repo: REPO, - epicNumber: 10, + epicRef: "10", adapter: "claude", }); armWaitForSignal(db, "epic-10-answered", id, null); diff --git a/packages/dispatcher/test/slots.test.ts b/packages/dispatcher/test/slots.test.ts index b9dfc236..78c55696 100644 --- a/packages/dispatcher/test/slots.test.ts +++ b/packages/dispatcher/test/slots.test.ts @@ -28,7 +28,7 @@ function addWorkflow( id, kind, repo, - epicNumber: kind === "recommender" ? null : 1, + epicRef: kind === "recommender" ? null : "1", adapter, }); updateWorkflow(db, id, { state: "running" }); diff --git a/packages/dispatcher/test/staleness-cron.test.ts b/packages/dispatcher/test/staleness-cron.test.ts index 69e70427..7d16afd3 100644 --- a/packages/dispatcher/test/staleness-cron.test.ts +++ b/packages/dispatcher/test/staleness-cron.test.ts @@ -47,14 +47,14 @@ function writeRepoConfig(checkout: string, toml: string): void { function fakeGithub(open: IssueSummary[], merged: MergedPrRef[]) { const created: NewIssue[] = []; - const closed: number[] = []; + const closed: string[] = []; return { created, closed, listOpenIssues: async () => open, listMergedPrsClosingRefs: async () => merged, - closeIssue: async (_r: string, n: number) => { - closed.push(n); + closeIssue: async (_r: string, ref: string) => { + closed.push(ref); }, createIssue: async (_r: string, issue: NewIssue) => { created.push(issue); diff --git a/packages/dispatcher/test/staleness.test.ts b/packages/dispatcher/test/staleness.test.ts index b6fa0a01..7d10a651 100644 --- a/packages/dispatcher/test/staleness.test.ts +++ b/packages/dispatcher/test/staleness.test.ts @@ -37,7 +37,7 @@ describe("detectSpecDrift", () => { /** An in-memory gateway recording closes + created issues. */ function fakeGithub(opts: { open: IssueSummary[]; merged: MergedPrRef[] }) { - const closed: { n: number; comment: string }[] = []; + const closed: { ref: string; comment: string }[] = []; const created: NewIssue[] = []; let nextNumber = 1000; return { @@ -45,8 +45,8 @@ function fakeGithub(opts: { open: IssueSummary[]; merged: MergedPrRef[] }) { created, listOpenIssues: async () => opts.open, listMergedPrsClosingRefs: async () => opts.merged, - closeIssue: async (_repo: string, n: number, comment: string) => { - closed.push({ n, comment }); + closeIssue: async (_repo: string, ref: string, comment: string) => { + closed.push({ ref, comment }); }, createIssue: async (_repo: string, issue: NewIssue) => { created.push(issue); diff --git a/packages/dispatcher/test/watchdog.test.ts b/packages/dispatcher/test/watchdog.test.ts index 05d28c58..368d2c1f 100644 --- a/packages/dispatcher/test/watchdog.test.ts +++ b/packages/dispatcher/test/watchdog.test.ts @@ -51,7 +51,7 @@ function seed(opts: SeedOpts): string { id, kind: "implementation", repo: "thejustinwalsh/middle", - epicNumber: 14, + epicRef: "14", adapter: "claude", }); db.run( diff --git a/packages/dispatcher/test/workflow-record.test.ts b/packages/dispatcher/test/workflow-record.test.ts index f67895fb..0e7aa871 100644 --- a/packages/dispatcher/test/workflow-record.test.ts +++ b/packages/dispatcher/test/workflow-record.test.ts @@ -42,18 +42,18 @@ afterEach(() => { describe("getWorkflow epic_ref (#187)", () => { test("reads back epic_ref straight from the column (slug, number-string, or null)", () => { - // github-mode: createWorkflowRecord writes only epic_number; epic_ref stays null - // until the dispatch write path populates it. getWorkflow reflects the column verbatim. + // github-mode: createWorkflowRecord writes a numeric-string epic_ref (and the + // back-compat epic_number derived from it). getWorkflow reflects the column verbatim. createWorkflowRecord(db, { id: "gh", kind: "implementation", repo: "o/r", - epicNumber: 7, + epicRef: "7", adapter: "claude", }); - expect(getWorkflow(db, "gh")!.epicRef).toBeNull(); + expect(getWorkflow(db, "gh")!.epicRef).toBe("7"); - // A row whose epic_ref is set (file-mode slug, or a github-mode backfill) round-trips. + // A row whose epic_ref is set to a slug (file-mode, or a github-mode backfill) round-trips. db.run("UPDATE workflows SET epic_ref = ? WHERE id = ?", ["rollout-epic-store", "gh"]); expect(getWorkflow(db, "gh")!.epicRef).toBe("rollout-epic-store"); @@ -62,7 +62,7 @@ describe("getWorkflow epic_ref (#187)", () => { id: "file", kind: "implementation", repo: "o/r", - epicNumber: null, + epicRef: null, adapter: "claude", }); db.run("UPDATE workflows SET epic_ref = ? WHERE id = ?", ["another-slug", "file"]); @@ -78,7 +78,7 @@ describe("dispatch source (#53)", () => { id: "m", kind: "implementation", repo: "o/r", - epicNumber: 1, + epicRef: "1", adapter: "claude", source: "manual", }); @@ -86,7 +86,7 @@ describe("dispatch source (#53)", () => { id: "a", kind: "implementation", repo: "o/r", - epicNumber: 2, + epicRef: "2", adapter: "claude", source: "auto", }); @@ -94,7 +94,7 @@ describe("dispatch source (#53)", () => { id: "none", kind: "recommender", repo: "o/r", - epicNumber: null, + epicRef: null, adapter: "claude", }); expect(getWorkflowSource(db, "m")).toBe("manual"); @@ -110,7 +110,7 @@ describe("workflow meta_json accessors", () => { id: "w", kind: "recommender", repo: "o/r", - epicNumber: null, + epicRef: null, adapter: "claude", }); expect(readWorkflowMeta(db, "absent")).toEqual({}); @@ -127,7 +127,7 @@ describe("workflow meta_json accessors", () => { id: "w", kind: "implementation", repo: "o/r", - epicNumber: 1, + epicRef: "1", adapter: "claude", source: "manual", }); @@ -154,7 +154,7 @@ describe("workflow meta_json accessors", () => { id: "w", kind: "implementation", repo: "o/r", - epicNumber: 1, + epicRef: "1", adapter: "claude", }); const before = getWorkflow(db, "w")!.updatedAt; @@ -168,7 +168,7 @@ describe("workflow meta_json accessors", () => { id: "w", kind: "implementation", repo: "o/r", - epicNumber: 1, + epicRef: "1", adapter: "claude", }); expect(getCheckboxReconcileState(db, "w")).toEqual({ headSha: null, state: {} }); @@ -187,7 +187,7 @@ describe("workflow meta_json accessors", () => { id: "w", kind: "implementation", repo: "o/r", - epicNumber: 1, + epicRef: "1", adapter: "claude", }); const setMeta = (meta: unknown) => @@ -222,7 +222,7 @@ describe("listRunningImplementationWorkflows", () => { opts: { kind?: "implementation" | "recommender" | "documentation"; state?: "running" | "waiting-human" | "pending"; - epicNumber?: number | null; + epicRef?: string | null; worktreePath?: string | null; } = {}, ) => { @@ -230,7 +230,7 @@ describe("listRunningImplementationWorkflows", () => { id, kind: opts.kind ?? "implementation", repo: "o/r", - epicNumber: opts.epicNumber === undefined ? 1 : opts.epicNumber, + epicRef: opts.epicRef === undefined ? "1" : opts.epicRef, adapter: "claude", }); const patch: Parameters[2] = { state: opts.state ?? "running" }; @@ -243,14 +243,14 @@ describe("listRunningImplementationWorkflows", () => { seed("run-2", { worktreePath: "/wt/2" }); seed("parked", { state: "waiting-human" }); seed("pending", { state: "pending" }); - seed("recommender", { kind: "recommender", epicNumber: null }); - seed("no-epic", { epicNumber: null }); + seed("recommender", { kind: "recommender", epicRef: null }); + seed("no-epic", { epicRef: null }); seed("no-worktree", { worktreePath: null }); const ids = listRunningImplementationWorkflows(db).map((r) => r.id); expect(ids).toEqual(["run-1", "run-2"]); const first = listRunningImplementationWorkflows(db)[0]!; - expect(first).toEqual({ id: "run-1", repo: "o/r", epicNumber: 1, worktreePath: "/wt/1" }); + expect(first).toEqual({ id: "run-1", repo: "o/r", epicRef: "1", worktreePath: "/wt/1" }); }); }); @@ -260,7 +260,7 @@ describe("createWorkflowRecord", () => { id: "exec-1", kind: "implementation", repo: "thejustinwalsh/middle", - epicNumber: 6, + epicRef: "6", adapter: "claude", }); const row = getWorkflow(db, "exec-1"); @@ -281,7 +281,7 @@ describe("createWorkflowRecord", () => { id: "exec-retry", kind: "implementation", repo: "o/r", - epicNumber: 6, + epicRef: "6", adapter: "claude", }); // Advance the row the way prepare-worktree does after the INSERT, so we can @@ -294,7 +294,7 @@ describe("createWorkflowRecord", () => { id: "exec-retry", kind: "recommender", repo: "other/repo", - epicNumber: 99, + epicRef: "99", adapter: "codex", }), ).not.toThrow(); @@ -318,7 +318,7 @@ describe("createWorkflowRecord", () => { id: "exec-bad-kind", kind: "nonsense" as CreateWorkflowRecordInput["kind"], repo: "o/r", - epicNumber: 1, + epicRef: "1", adapter: "claude", }), ).toThrow(); @@ -332,7 +332,14 @@ describe("countActiveImplementationSlots", () => { kind: "implementation" | "recommender", adapter: string, epic: number | null, - ) => createWorkflowRecord(db, { id, kind, repo: "o/r", epicNumber: epic, adapter }); + ) => + createWorkflowRecord(db, { + id, + kind, + repo: "o/r", + epicRef: epic === null ? null : String(epic), + adapter, + }); test("counts non-terminal implementation rows, grouped by adapter", () => { mk("a", "implementation", "claude", 1); @@ -365,7 +372,7 @@ describe("updateWorkflow", () => { id: "exec-1", kind: "implementation", repo: "o/r", - epicNumber: 6, + epicRef: "6", adapter: "claude", }); const before = getWorkflow(db, "exec-1")!.updatedAt; @@ -381,7 +388,7 @@ describe("updateWorkflow", () => { id: "exec-1", kind: "implementation", repo: "o/r", - epicNumber: 6, + epicRef: "6", adapter: "claude", }); updateWorkflow(db, "exec-1", { worktreePath: "/wt/issue-6" }); @@ -404,7 +411,7 @@ describe("updateWorkflow", () => { id: "exec-1", kind: "implementation", repo: "o/r", - epicNumber: 6, + epicRef: "6", adapter: "claude", }); updateWorkflow(db, "exec-1", {}); @@ -424,12 +431,12 @@ describe("hasNonTerminalEpicWorkflow", () => { id: "a", kind: "implementation", repo: "o/r", - epicNumber: 7, + epicRef: "7", adapter: "claude", }); - expect(hasNonTerminalEpicWorkflow(db, "o/r", 7)).toBe(true); + expect(hasNonTerminalEpicWorkflow(db, "o/r", "7")).toBe(true); updateWorkflow(db, "a", { state: "completed" }); - expect(hasNonTerminalEpicWorkflow(db, "o/r", 7)).toBe(false); + expect(hasNonTerminalEpicWorkflow(db, "o/r", "7")).toBe(false); }); test("scopes by repo and epic; a recommender row never collides", () => { @@ -437,19 +444,19 @@ describe("hasNonTerminalEpicWorkflow", () => { id: "a", kind: "implementation", repo: "o/r", - epicNumber: 7, + epicRef: "7", adapter: "claude", }); - expect(hasNonTerminalEpicWorkflow(db, "o/r", 8)).toBe(false); // different epic - expect(hasNonTerminalEpicWorkflow(db, "x/y", 7)).toBe(false); // different repo + expect(hasNonTerminalEpicWorkflow(db, "o/r", "8")).toBe(false); // different epic + expect(hasNonTerminalEpicWorkflow(db, "x/y", "7")).toBe(false); // different repo createWorkflowRecord(db, { id: "rec", kind: "recommender", repo: "o/r", - epicNumber: 9, + epicRef: "9", adapter: "claude", }); - expect(hasNonTerminalEpicWorkflow(db, "o/r", 9)).toBe(false); // recommender doesn't claim the slot + expect(hasNonTerminalEpicWorkflow(db, "o/r", "9")).toBe(false); // recommender doesn't claim the slot }); }); @@ -459,14 +466,14 @@ describe("listActiveImplementationWorkflows (#180)", () => { id: "a", kind: "implementation", repo: "o/r", - epicNumber: 1, + epicRef: "1", adapter: "claude", }); createWorkflowRecord(db, { id: "b", kind: "implementation", repo: "o/r", - epicNumber: 2, + epicRef: "2", adapter: "claude", }); touchHeartbeat(db, "b", 1_700_000_000_000); @@ -484,21 +491,21 @@ describe("listNonTerminalWorkflows", () => { id: "a", kind: "implementation", repo: "o/r", - epicNumber: 1, + epicRef: "1", adapter: "claude", }); createWorkflowRecord(db, { id: "b", kind: "implementation", repo: "o/r", - epicNumber: 2, + epicRef: "2", adapter: "claude", }); createWorkflowRecord(db, { id: "rec", kind: "recommender", repo: "o/r", - epicNumber: null, + epicRef: null, adapter: "claude", }); updateWorkflow(db, "a", { state: "waiting-human" }); @@ -514,7 +521,7 @@ describe("workflow observers", () => { id: "a", kind: "implementation", repo: "o/r", - epicNumber: 1, + epicRef: "1", adapter: "claude", }); const seen: Array<{ id: string; state?: string }> = []; @@ -538,7 +545,7 @@ describe("workflow observers", () => { id: "a", kind: "implementation", repo: "o/r", - epicNumber: 1, + epicRef: "1", adapter: "claude", }); const dispose = addWorkflowObserver(() => { @@ -557,7 +564,7 @@ describe("workflow observers", () => { id: "a", kind: "implementation", repo: "o/r", - epicNumber: 1, + epicRef: "1", adapter: "claude", }); const seenA: Array<{ id: string; state?: string }> = []; @@ -586,7 +593,7 @@ describe("workflow observers", () => { id: "a", kind: "implementation", repo: "o/r", - epicNumber: 1, + epicRef: "1", adapter: "claude", }); updateWorkflow(db, "a", { state: "waiting-human" }); @@ -610,7 +617,7 @@ describe("promotePendingToFailed — orphaned prepare-worktree (issue #179)", () id, kind: "implementation", repo: "o/r", - epicNumber: 1, + epicRef: "1", adapter: "claude", }); } @@ -621,7 +628,7 @@ describe("promotePendingToFailed — orphaned prepare-worktree (issue #179)", () expect(promotePendingToFailed(db, "a")).toBe(true); expect(getWorkflow(db, "a")!.state).toBe("failed"); // A terminal row no longer blocks the Epic's next dispatch (the 409 guard). - expect(hasNonTerminalEpicWorkflow(db, "o/r", 1)).toBe(false); + expect(hasNonTerminalEpicWorkflow(db, "o/r", "1")).toBe(false); }); test("no-ops on a row already past pending (e.g. a later step's compensated failure)", () => { @@ -647,7 +654,7 @@ describe("promotePendingToFailed — orphaned prepare-worktree (issue #179)", () id: "rec", kind: "recommender", repo: "o/r", - epicNumber: null, + epicRef: null, adapter: "claude", }); expect(promotePendingToFailed(db, "rec")).toBe(false); @@ -659,7 +666,7 @@ describe("promotePendingToFailed — orphaned prepare-worktree (issue #179)", () id: "doc", kind: "documentation", repo: "o/r", - epicNumber: null, + epicRef: null, adapter: "claude", }); expect(promotePendingToFailed(db, "doc")).toBe(false); diff --git a/packages/dispatcher/test/worktree.test.ts b/packages/dispatcher/test/worktree.test.ts index 13e020d5..1fb78bda 100644 --- a/packages/dispatcher/test/worktree.test.ts +++ b/packages/dispatcher/test/worktree.test.ts @@ -68,7 +68,7 @@ describe("createWorktree → listWorktrees → destroyWorktree", () => { const handle = await createWorktree({ repoPath, repo: "thejustinwalsh/middle", - issueNumber: 6, + epicRef: "6", worktreeRoot, }); expect(handle.path).toBe(join(worktreeRoot, "thejustinwalsh/middle", "issue-6")); @@ -88,15 +88,15 @@ describe("createWorktree → listWorktrees → destroyWorktree", () => { }); test("list enumerates active worktrees under the root", async () => { - await createWorktree({ repoPath, repo: "o/r", issueNumber: 6, worktreeRoot }); - await createWorktree({ repoPath, repo: "o/r", issueNumber: 7, worktreeRoot }); + await createWorktree({ repoPath, repo: "o/r", epicRef: "6", worktreeRoot }); + await createWorktree({ repoPath, repo: "o/r", epicRef: "7", worktreeRoot }); const listed = await listWorktrees({ repoPath, worktreeRoot }); expect(listed.map((w) => w.unit).sort()).toEqual(["issue-6", "issue-7"]); expect(listed.every((w) => w.repo === "o/r")).toBe(true); }); test("destroy removes the worktree directory and its branch", async () => { - const handle = await createWorktree({ repoPath, repo: "o/r", issueNumber: 6, worktreeRoot }); + const handle = await createWorktree({ repoPath, repo: "o/r", epicRef: "6", worktreeRoot }); await destroyWorktree(handle); expect(existsSync(handle.path)).toBe(false); expect(await listWorktrees({ repoPath, worktreeRoot })).toEqual([]); @@ -110,13 +110,13 @@ describe("createWorktree → listWorktrees → destroyWorktree", () => { describe("idempotency", () => { test("creating an already-existing worktree returns the handle without throwing", async () => { - const first = await createWorktree({ repoPath, repo: "o/r", issueNumber: 6, worktreeRoot }); - const second = await createWorktree({ repoPath, repo: "o/r", issueNumber: 6, worktreeRoot }); + const first = await createWorktree({ repoPath, repo: "o/r", epicRef: "6", worktreeRoot }); + const second = await createWorktree({ repoPath, repo: "o/r", epicRef: "6", worktreeRoot }); expect(second).toEqual(first); }); test("destroying an already-removed worktree is a no-op, not a throw", async () => { - const handle = await createWorktree({ repoPath, repo: "o/r", issueNumber: 6, worktreeRoot }); + const handle = await createWorktree({ repoPath, repo: "o/r", epicRef: "6", worktreeRoot }); await destroyWorktree(handle); await destroyWorktree(handle); // must not throw expect(existsSync(handle.path)).toBe(false); @@ -130,7 +130,7 @@ describe("branch reuse (issue #179)", () => { const handle = await createWorktree({ repoPath, repo: "o/r", - issueNumber: 9, + epicRef: "9", worktreeRoot, }); expect(handle.branch).toBe("middle-issue-9"); @@ -149,7 +149,7 @@ describe("branch reuse (issue #179)", () => { const headSha = await gitOut(repoPath, ["rev-parse", "HEAD"]); expect(headSha).not.toBe(firstSha); - const handle = await createWorktree({ repoPath, repo: "o/r", issueNumber: 9, worktreeRoot }); + const handle = await createWorktree({ repoPath, repo: "o/r", epicRef: "9", worktreeRoot }); expect(await gitOut(handle.path, ["rev-parse", "HEAD"])).toBe(firstSha); }); @@ -160,7 +160,7 @@ describe("branch reuse (issue #179)", () => { ); expect(await branchCheck.exited).not.toBe(0); // precondition: no such branch - const handle = await createWorktree({ repoPath, repo: "o/r", issueNumber: 9, worktreeRoot }); + const handle = await createWorktree({ repoPath, repo: "o/r", epicRef: "9", worktreeRoot }); expect(handle.branch).toBe("middle-issue-9"); expect( await gitOut(repoPath, ["rev-parse", "--verify", "refs/heads/middle-issue-9"]), @@ -170,7 +170,7 @@ describe("branch reuse (issue #179)", () => { test("dispatch → prune (branch survives) → re-dispatch all succeed", async () => { // pruneWorktreeAt deliberately leaves the local branch (the reconciler path), // so the second createWorktree must reuse it rather than re-creating with -b. - const first = await createWorktree({ repoPath, repo: "o/r", issueNumber: 9, worktreeRoot }); + const first = await createWorktree({ repoPath, repo: "o/r", epicRef: "9", worktreeRoot }); await pruneWorktreeAt(repoPath, first.path); expect(existsSync(first.path)).toBe(false); // Branch still exists after the prune. @@ -178,7 +178,7 @@ describe("branch reuse (issue #179)", () => { await gitOut(repoPath, ["rev-parse", "--verify", "refs/heads/middle-issue-9"]), ).toBeTruthy(); - const second = await createWorktree({ repoPath, repo: "o/r", issueNumber: 9, worktreeRoot }); + const second = await createWorktree({ repoPath, repo: "o/r", epicRef: "9", worktreeRoot }); expect(second).toEqual(first); expect(existsSync(second.path)).toBe(true); }); @@ -187,7 +187,7 @@ describe("branch reuse (issue #179)", () => { describe("failure surfacing", () => { test("create against a non-git directory throws WorktreeError", async () => { await expect( - createWorktree({ repoPath: scratch, repo: "o/r", issueNumber: 6, worktreeRoot }), + createWorktree({ repoPath: scratch, repo: "o/r", epicRef: "6", worktreeRoot }), ).rejects.toBeInstanceOf(WorktreeError); }); }); diff --git a/planning/issues/190/decisions.md b/planning/issues/190/decisions.md index 7fbc70b2..7f4f14b1 100644 --- a/planning/issues/190/decisions.md +++ b/planning/issues/190/decisions.md @@ -2,3 +2,80 @@ Running log of decisions worth more than two lines. Distilled into PR review comments at finalize time. + +## Gateway param naming: `ref: string` (generic) vs `epicRef: string` (seam) +**File(s):** `packages/dispatcher/src/github.ts`, `poller.ts`, `state-issue.ts` +**Date:** 2026-06-03 + +**Decision:** Gateway methods take a generic `ref: string` for their issue/PR identifier; +the workflow seam (`ImplementationInput`, `WorkflowRecord`, build-deps callbacks, gate +inputs) uses `epicRef: string` where the value genuinely is the Epic. +**Why:** Sub-issue #191's text literally says "`epicRef: string`", but `listIssueComments`, +`postComment`, `getIssueLabels`, `addLabel`, `closeIssue` are called with PR numbers and +sub-issue numbers too — not just the Epic — so naming the param `epicRef` would be wrong at +those callsites (a reviewer would flag it). The authoritative spec +(`docs/superpowers/specs/2026-05-29-file-backed-epic-store-design.md`, "The three new file +gateways") uses the generic name `ref` in every gateway-method table. Spec is source of +truth on the interface shape; the seam name `epicRef` is correct where the value is the Epic. +**Evidence:** spec gateway tables (`listIssueComments(repo, ref)`, `postComment(repo, ref, body)`, +`findEpicPr(repo, ref)`); callsites `gate-evidence.ts` (PR number), `pr-divergence.ts` (sub-issue), +`plan-comment.ts` (epic) all share `listIssueComments`. + +## ghGitHub parses `ref`/`epicRef` to a number at the gh boundary +**File(s):** `packages/dispatcher/src/github.ts`, `poller-gateway.ts` +**Date:** 2026-06-03 + +**Decision:** A single `refToIssueNumber(ref)` helper converts the string ref to an integer +at each `gh`-calling method; it throws a clear error when the ref is not a parseable positive +integer (github mode contract: numeric-string refs only). +**Why:** github mode keeps working unchanged — the workflow layer now speaks strings, and the +only place that needs an int is the `gh` CLI call itself. Centralizing the parse keeps the +error message uniform and the "numeric-string only" contract in one place. +**Evidence:** sub-issue #191 acceptance criterion 2. + +## Scope boundary: which read-types became `epicRef`, which stayed numeric +**File(s):** `packages/dispatcher/src/workflow-record.ts` +**Date:** 2026-06-03 + +**Decision:** Only the **resume/reconcile-seam** read types became string-keyed — +`PollableWait`, `ParkedWorkflow`, `RunningWorkflow` now expose `epicRef: string` +(SELECT `epic_ref`, filter `epic_ref IS NOT NULL`), because their values flow into +the now-string gateway methods. The **display** read types stayed numeric: +`ActiveImplementationWorkflow` (feeds the state-issue `InFlightItem.issue: number`) +and `NonTerminalWorkflow` + the `/control/events` SSE `epic` field (the dashboard's +numeric column). `getWorkflow` returns BOTH `epicNumber` (derived) and `epicRef`. +**Why:** `InFlightItem.issue: number` is part of the authoritative state-issue schema +(`schema.v1.ts`) with a byte-identical round-trip invariant — changing it is a schema +bump, explicitly NOT in #191's "files likely to change", and a file-mode concern for a +later phase. The criterion "every SELECT reads/writes epic_ref" is scoped to the SELECTs +that feed the epicRef seam; display SELECTs feeding numeric schemas are out of scope. +**Evidence:** `packages/state-issue/CLAUDE.md` (round-trip invariant); Epic #190 plan's +#191 file list omits `schema.v1.ts`/`packages/state-issue`. + +## `createWorkflowRecord` writes both columns; dashboard tests updated +**File(s):** `packages/dispatcher/src/workflow-record.ts`, `packages/dashboard/test/{helpers,api,sse}.*` +**Date:** 2026-06-03 + +**Decision:** github-mode `createWorkflowRecord` now writes BOTH `epic_number` (parsed +from the numeric ref) and `epic_ref` (the stringified number). Two #187 dashboard tests +that asserted a github row's `epicRef` is `null` were updated to expect the stringified +number; the `EpicRef` component is unaffected (it keys its `#N` render off `epic`, only +consulting `epicRef` when `epic === null`). +**Why:** The spec's dual-column contract is "github mode writes both columns"; the #187 +tests were written against the foundation's incomplete `createWorkflowRecord` (which +wrote only `epic_number`). Completing the write path makes those `epicRef: null` +assertions stale — the faithful fix is to assert the new value, not to fake the old DB +state in the test helper. github-mode *rendering* is byte-for-byte unchanged. +**Evidence:** spec "Config schema" (dual-column); `EpicRef.tsx` (epicNumber-first render). + +## Worktree seam is string-keyed (`epicRef`), unit path unchanged +**File(s):** `packages/dispatcher/src/worktree.ts` +**Date:** 2026-06-03 + +**Decision:** `CreateWorktreeOpts.issueNumber?: number` became `epicRef?: string`; the +dispatch-unit directory stays `issue-${epicRef}` (so `issue-27` is byte-identical for a +github ref). The pr-divergence reconciler parses the numeric epic from the head ref and +stringifies it at the `createWorktree` boundary. +**Why:** The workflow seam now threads a string; the worktree directory must accept it so +a file-mode slug yields `issue-` without a numeric coercion. github paths are unchanged. +**Evidence:** sub-issue #191 (string seam everywhere); worktree layout in root `CLAUDE.md`/spec. From 2a81facdb613dae532e9a092fe2a77fc4b2c2e19 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Wed, 3 Jun 2026 04:28:11 -0400 Subject: [PATCH 03/10] feat(epic-store): file-backed gateway implementations (Epic, State, Poll) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close #192. Three composite gateways behind the existing interfaces — Epic/state methods read/write local files via the round-trip-pure parser/renderer; PR-shaped and github-native methods delegate to an injected gh backend (the hybrid). - `epic-file-io.ts`: read/parse + render/atomic-write (temp + rename, tmp cleaned up on failure) and slug listing — the only disk-touching layer over the pure parser/renderer. - `file-epic-gateway.ts`: `listOpenEpics`/`listIssueComments`/`getCommentAuthor`/ `getIssueLabels`/`postComment`/`addLabel`/`closeIssue`/`findEpicPr` file-backed; `getPullRequest`/`editPullRequestBody`/`editComment`/`listOpenIssues`/ `listMergedPrsClosingRefs`/`createIssue` delegate to gh. A ref routes to the file iff an Epic file exists for it (slug → file, PR/issue number → gh). - `file-state-gateway.ts`: atomic `readBody`/`writeBody` against `state_file`. - `file-poll-gateway.ts`: `listIssueComments` maps the conversation with `authorIsBot` derived structurally from the marker (question/dispatch-event → bot, answer → human — closes #178's class for file mode); `getRateLimit` delegates; `findPrForEpic`/`findEpicPrLifecycle` delegate a numeric ref but return null for a slug (file-mode review-resume is Phase 2). - `EpicListItem` gains `ref` and a nullable `number` so file Epics are representable; the numeric browse cache skips null-numbered (file) rows. - Unit tests per gateway (happy + edges: missing/malformed file, atomic-write tmp cleanup, gh-delegation) and a real-FS composite integration test driving the dispatch-record → human-answer → poll-detect lifecycle. typecheck/lint/format clean; full suite green (1199). --- .../dispatcher/src/epic-store/epic-file-io.ts | 60 ++++ .../src/epic-store/file-epic-gateway.ts | 185 +++++++++++++ .../src/epic-store/file-poll-gateway.ts | 93 +++++++ .../src/epic-store/file-state-gateway.ts | 52 ++++ packages/dispatcher/src/epics-cache.ts | 4 + packages/dispatcher/src/github.ts | 8 +- .../test/epic-store/file-epic-gateway.test.ts | 256 ++++++++++++++++++ .../file-gateways-integration.test.ts | 132 +++++++++ .../test/epic-store/file-poll-gateway.test.ts | 124 +++++++++ .../epic-store/file-state-gateway.test.ts | 55 ++++ packages/dispatcher/test/epics-cache.test.ts | 22 +- packages/dispatcher/test/github-epics.test.ts | 3 +- planning/issues/190/decisions.md | 31 +++ 13 files changed, 1016 insertions(+), 9 deletions(-) create mode 100644 packages/dispatcher/src/epic-store/epic-file-io.ts create mode 100644 packages/dispatcher/src/epic-store/file-epic-gateway.ts create mode 100644 packages/dispatcher/src/epic-store/file-poll-gateway.ts create mode 100644 packages/dispatcher/src/epic-store/file-state-gateway.ts create mode 100644 packages/dispatcher/test/epic-store/file-epic-gateway.test.ts create mode 100644 packages/dispatcher/test/epic-store/file-gateways-integration.test.ts create mode 100644 packages/dispatcher/test/epic-store/file-poll-gateway.test.ts create mode 100644 packages/dispatcher/test/epic-store/file-state-gateway.test.ts diff --git a/packages/dispatcher/src/epic-store/epic-file-io.ts b/packages/dispatcher/src/epic-store/epic-file-io.ts new file mode 100644 index 00000000..bcc20a32 --- /dev/null +++ b/packages/dispatcher/src/epic-store/epic-file-io.ts @@ -0,0 +1,60 @@ +/** + * Filesystem IO for Epic files — the read/parse and render/atomic-write helpers + * the file-backed gateways share. Kept separate from the pure + * `epic-file/{parser,renderer}` so those stay side-effect-free (and trivially + * round-trip-testable); this module is the only place that touches disk. + */ + +import { existsSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { parseEpicFile } from "./epic-file/parser.ts"; +import { renderEpicFile } from "./epic-file/renderer.ts"; +import type { EpicFile } from "./epic-file/types.ts"; + +/** Absolute path of the Epic file for `slug` under `epicsDir` (`/.md`). */ +export function epicFilePath(epicsDir: string, slug: string): string { + return join(epicsDir, `${slug}.md`); +} + +/** Whether an Epic file exists for `slug` — the discriminator the composite gateways + * use to route a ref to the file path (slug → file) vs the gh backend (PR/issue number). */ +export function epicFileExists(epicsDir: string, slug: string): boolean { + return existsSync(epicFilePath(epicsDir, slug)); +} + +/** + * Read + parse the Epic file for `slug`, or `null` when the file is absent. A + * present-but-malformed file throws (via `parseEpicFile`) with a named-marker + * error — that's a real authoring fault worth surfacing, not a missing Epic. + */ +export function readEpicFile(epicsDir: string, slug: string): EpicFile | null { + const path = epicFilePath(epicsDir, slug); + if (!existsSync(path)) return null; + return parseEpicFile(readFileSync(path, "utf8")); +} + +/** + * Render `epic` and write it to `slug`'s file atomically: write a sibling + * `..md.tmp`, then `rename` over the target (rename is atomic within a + * filesystem, so a concurrent reader never sees a half-written file). The temp + * is removed on a write failure so a botched write can't strand a `.tmp`. + */ +export function writeEpicFile(epicsDir: string, slug: string, epic: EpicFile): void { + const path = epicFilePath(epicsDir, slug); + const tmp = join(epicsDir, `.${slug}.md.tmp`); + try { + writeFileSync(tmp, renderEpicFile(epic)); + renameSync(tmp, path); + } catch (error) { + rmSync(tmp, { force: true }); + throw error; + } +} + +/** Every Epic slug in `epicsDir` (file stems of `*.md`, excluding dotfiles/`.tmp`). */ +export function listEpicSlugs(epicsDir: string): string[] { + if (!existsSync(epicsDir)) return []; + return readdirSync(epicsDir) + .filter((name) => name.endsWith(".md") && !name.startsWith(".")) + .map((name) => name.slice(0, -".md".length)); +} diff --git a/packages/dispatcher/src/epic-store/file-epic-gateway.ts b/packages/dispatcher/src/epic-store/file-epic-gateway.ts new file mode 100644 index 00000000..55df6feb --- /dev/null +++ b/packages/dispatcher/src/epic-store/file-epic-gateway.ts @@ -0,0 +1,185 @@ +/** + * `fileEpicGateway` — the file-backed `EpicGateway`. A **composite**: Epic-shaped + * methods read/write the local Epic file (via the round-trip-pure + * `epic-file/{parser,renderer}`); PR-shaped and github-native-issue methods + * delegate to an injected `gh` backend (PRs/reviews/CI are GitHub-native in both + * modes — the "hybrid" of the design). + * + * Routing: a method that takes a `ref` checks whether an Epic file exists for it + * (`epicFileExists`). A slug ("rollout-epic-store") resolves to the file; a + * numeric PR/issue ref ("42", with no `42.md`) falls through to the gh backend. + * This is what lets one gateway serve both an Epic-file comment and a PR comment. + */ + +import type { IssueComment } from "../gates/plan-comment.ts"; +import type { CommentAuthor, EpicGateway, EpicListItem, PullRequest } from "../github.ts"; +import { epicFileExists, listEpicSlugs, readEpicFile, writeEpicFile } from "./epic-file-io.ts"; +import type { ConversationEntry, EpicFile } from "./epic-file/types.ts"; + +/** The synthetic login the file store attributes bot-authored conversation entries to. */ +export const FILE_AGENT_LOGIN = "middle-agent"; +/** The synthetic login a human `answer` block is attributed to. */ +export const FILE_HUMAN_LOGIN = "human"; + +export type FileEpicGatewayDeps = { + /** Absolute path to this repo's Epic directory (`planning/epics`). */ + epicsDir: string; + /** Backend for PR-shaped + github-native-issue methods (the hybrid half). */ + gh: EpicGateway; + /** Wall-clock for the dispatch-event timestamp; injectable for deterministic tests. */ + now?: () => Date; +}; + +/** Build the `file://` comment URL for a conversation entry — the address + * `getCommentAuthor` resolves back to agent/human. */ +function commentUrl(epicsDir: string, slug: string, fragment: string): string { + return `file://${epicsDir}/${slug}.md#${fragment}`; +} + +/** Map an Epic file's conversation into the flat `{authorLogin, body, url}` comment + * list `EpicGateway.listIssueComments` returns (the plan-comment gate reads this). */ +function conversationToComments( + epicsDir: string, + slug: string, + conversation: ConversationEntry[], +): IssueComment[] { + const comments: IssueComment[] = []; + conversation.forEach((entry, i) => { + if (entry.kind === "dispatch-event") { + comments.push({ + authorLogin: FILE_AGENT_LOGIN, + body: entry.body, + url: commentUrl(epicsDir, slug, `dispatch-event-${i}`), + }); + } else if (entry.kind === "question") { + comments.push({ + authorLogin: FILE_AGENT_LOGIN, + body: entry.body, + url: commentUrl(epicsDir, slug, `question-${entry.id}`), + }); + if (entry.answer) { + comments.push({ + authorLogin: FILE_HUMAN_LOGIN, + body: entry.answer.body, + url: commentUrl(epicsDir, slug, `answer-${entry.id}`), + }); + } + } + }); + return comments; +} + +export function makeFileEpicGateway(deps: FileEpicGatewayDeps): EpicGateway { + const { epicsDir, gh } = deps; + const now = deps.now ?? (() => new Date()); + + return { + // ── delegated to gh (PR-shaped + github-native; hybrid half) ────────────── + getPullRequest: (repo, prNumber) => gh.getPullRequest(repo, prNumber), + editPullRequestBody: (repo, prNumber, body) => gh.editPullRequestBody(repo, prNumber, body), + // editComment edits a GitHub PR/issue comment in place (gate-evidence upsert), + // which is github-native in file mode too — delegate. + editComment: (repo, commentId, body) => gh.editComment(repo, commentId, body), + listOpenIssues: (repo) => gh.listOpenIssues(repo), + listMergedPrsClosingRefs: (repo) => gh.listMergedPrsClosingRefs(repo), + createIssue: (repo, issue) => gh.createIssue(repo, issue), + + // ── file-backed (Epic-shaped), with gh fallback for non-Epic refs ───────── + + async listOpenEpics(_repo): Promise { + const out: EpicListItem[] = []; + for (const slug of listEpicSlugs(epicsDir)) { + const epic = readEpicFile(epicsDir, slug); + if (!epic || epic.meta.closed) continue; + out.push({ + ref: epic.meta.slug, + number: null, // file-mode Epics have a slug, not a GitHub issue number + title: epic.title, + state: "open", + labels: epic.meta.labels ?? [], + subTotal: epic.subIssues.length, + subClosed: epic.subIssues.filter((s) => s.checked).length, + }); + } + return out; + }, + + async listIssueComments(repo, ref): Promise { + if (!epicFileExists(epicsDir, ref)) return gh.listIssueComments(repo, ref); + const epic = readEpicFile(epicsDir, ref); + if (!epic) return []; + return conversationToComments(epicsDir, ref, epic.conversation); + }, + + async getCommentAuthor(repo, commentUrlArg): Promise { + if (!commentUrlArg.startsWith("file://")) return gh.getCommentAuthor(repo, commentUrlArg); + // A `#answer-N` fragment is a human reply; everything else (question, + // dispatch-event) is the agent/dispatcher — the structural discrimination + // that closes #178's class for file mode (no author-login heuristics). + if (/#answer-\d+$/.test(commentUrlArg)) { + return { login: FILE_HUMAN_LOGIN, isBot: false }; + } + return { login: FILE_AGENT_LOGIN, isBot: true }; + }, + + async getIssueLabels(repo, ref): Promise { + if (!epicFileExists(epicsDir, ref)) return gh.getIssueLabels(repo, ref); + const epic = readEpicFile(epicsDir, ref); + return epic?.meta.labels ?? []; + }, + + async addLabel(repo, ref, label): Promise { + if (!epicFileExists(epicsDir, ref)) { + await gh.addLabel(repo, ref, label); + return; + } + const epic = readEpicFile(epicsDir, ref); + if (!epic) return; + const labels = epic.meta.labels ?? []; + if (labels.includes(label)) return; // no-op if already present (gateway contract) + writeEpicFile(epicsDir, ref, { ...epic, meta: { ...epic.meta, labels: [...labels, label] } }); + }, + + async closeIssue(repo, ref, comment): Promise { + if (!epicFileExists(epicsDir, ref)) { + await gh.closeIssue(repo, ref, comment); + return; + } + const epic = readEpicFile(epicsDir, ref); + if (!epic) return; + const closed = appendDispatchEvent(epic, now().toISOString(), "closed", comment); + writeEpicFile(epicsDir, ref, { ...closed, meta: { ...closed.meta, closed: true } }); + }, + + async postComment(repo, ref, body): Promise { + if (!epicFileExists(epicsDir, ref)) { + await gh.postComment(repo, ref, body); + return; + } + const epic = readEpicFile(epicsDir, ref); + if (!epic) return; + writeEpicFile(epicsDir, ref, appendDispatchEvent(epic, now().toISOString(), "comment", body)); + }, + + async findEpicPr(repo, epicRef): Promise { + const epic = readEpicFile(epicsDir, epicRef); + // The Epic file stamps `pr:` in its meta when the PR opens (durable backup + // for the PR-body marker). No file or no stamped PR → no PR yet. + if (!epic || epic.meta.pr === undefined) return null; + return gh.getPullRequest(repo, epic.meta.pr); + }, + }; +} + +/** Append a `` entry to an Epic's conversation. */ +function appendDispatchEvent( + epic: EpicFile, + ts: string, + eventKind: string, + body: string, +): EpicFile { + return { + ...epic, + conversation: [...epic.conversation, { kind: "dispatch-event", ts, eventKind, body }], + }; +} diff --git a/packages/dispatcher/src/epic-store/file-poll-gateway.ts b/packages/dispatcher/src/epic-store/file-poll-gateway.ts new file mode 100644 index 00000000..cbca88ba --- /dev/null +++ b/packages/dispatcher/src/epic-store/file-poll-gateway.ts @@ -0,0 +1,93 @@ +/** + * `filePollGateway` — the file-backed `PollGateway`. Its load-bearing method is + * `listIssueComments`: it maps the Epic file's conversation into the poller's + * `IssueComment[]` with `authorIsBot` derived **structurally** from the marker + * (`question`/`dispatch-event` → bot; `answer` → human) rather than from an + * author-login heuristic — that's what closes #178's class for file mode (the + * poller's `classifyNewHumanReply` keys resume off `!authorIsBot`). + * + * The PR-poll methods are GitHub-native: `getRateLimit` delegates straight to gh. + * `findPrForEpic`/`findEpicPrLifecycle` delegate for a numeric ref but return + * `null` for a file-mode slug — github's PR-finders resolve by `Closes #`, + * which a file Epic (slug, no GitHub issue) can't carry. File-mode review-resume + * rides Phase 2's watcher work (see `planning/issues/190/decisions.md`). + */ + +import type { EpicPrLifecycle, IssueComment, PollGateway, PrSnapshot } from "../poller.ts"; +import { FILE_AGENT_LOGIN, FILE_HUMAN_LOGIN } from "./file-epic-gateway.ts"; +import { epicFileExists, readEpicFile } from "./epic-file-io.ts"; +import type { ConversationEntry } from "./epic-file/types.ts"; + +export type FilePollGatewayDeps = { + /** Absolute path to this repo's Epic directory (`planning/epics`). */ + epicsDir: string; + /** Backend for the GitHub-native PR-poll methods. */ + gh: PollGateway; +}; + +/** `true` when the ref is a numeric string — the only kind gh's `Closes #N` + * PR-finders can resolve (a file-mode slug is not). */ +function isNumericRef(ref: string): boolean { + return /^\d+$/.test(ref.trim()); +} + +/** Map an Epic file's conversation into the poller's `IssueComment[]`, with + * `authorIsBot` discriminated by marker kind. */ +function conversationToPollComments(conversation: ConversationEntry[]): IssueComment[] { + const out: IssueComment[] = []; + conversation.forEach((entry, i) => { + if (entry.kind === "dispatch-event") { + out.push({ + id: i, + authorLogin: FILE_AGENT_LOGIN, + authorIsBot: true, + createdAt: Date.parse(entry.ts), + body: entry.body, + }); + } else if (entry.kind === "question") { + out.push({ + id: entry.id, + authorLogin: FILE_AGENT_LOGIN, + authorIsBot: true, + createdAt: Date.parse(entry.ts), + body: entry.body, + }); + if (entry.answer) { + // The answer block has no own timestamp (the file-watcher uses file mtime, + // not this createdAt); inherit the question's ts. `authorIsBot: false` is + // the human-reply signal the poller resumes on. + out.push({ + id: entry.id, + authorLogin: FILE_HUMAN_LOGIN, + authorIsBot: false, + createdAt: Date.parse(entry.ts), + body: entry.answer.body, + }); + } + } + }); + return out; +} + +export function makeFilePollGateway(deps: FilePollGatewayDeps): PollGateway { + const { epicsDir, gh } = deps; + return { + async listIssueComments(repo, ref): Promise { + if (!epicFileExists(epicsDir, ref)) return gh.listIssueComments(repo, ref); + const epic = readEpicFile(epicsDir, ref); + if (!epic) return []; + return conversationToPollComments(epic.conversation); + }, + + async findPrForEpic(repo, epicRef): Promise { + // A file-mode slug has no `Closes #` linkage gh can search. + return isNumericRef(epicRef) ? gh.findPrForEpic(repo, epicRef) : null; + }, + + async findEpicPrLifecycle(repo, epicRef): Promise { + return isNumericRef(epicRef) ? gh.findEpicPrLifecycle(repo, epicRef) : null; + }, + + getRateLimit: () => gh.getRateLimit(), + }; +} diff --git a/packages/dispatcher/src/epic-store/file-state-gateway.ts b/packages/dispatcher/src/epic-store/file-state-gateway.ts new file mode 100644 index 00000000..803e02cb --- /dev/null +++ b/packages/dispatcher/src/epic-store/file-state-gateway.ts @@ -0,0 +1,52 @@ +/** + * `fileStateGateway` — the file-backed `StateGateway`. The recommender's state + * lives in a local Markdown file (`state_file`, e.g. `.middle/state.md`) instead + * of a GitHub issue. `readBody`/`writeBody` are atomic against that one file; the + * `issueNumber` arg is part of the shared interface but unused in file mode (the + * path comes from config, not an issue id). + * + * The `applyDispatcherSections` / `renderStateIssue` flow in `state-issue.ts` is + * unchanged — same parser, same byte-identical round-trip — so the dispatcher + * writes the In-flight section directly, closing #180's out-of-band-rewrite class + * for file mode. + */ + +import { dirname, join } from "node:path"; +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import type { StateGateway } from "../state-issue.ts"; + +export type FileStateGatewayDeps = { + /** Absolute path to this repo's recommender state file (e.g. `.middle/state.md`). */ + stateFile: string; +}; + +export function makeFileStateGateway(deps: FileStateGatewayDeps): StateGateway { + const { stateFile } = deps; + return { + async readBody(_repo, _issueNumber): Promise { + if (!existsSync(stateFile)) { + throw new Error(`state file not found: ${stateFile} (run \`mm init\` for this repo)`); + } + return readFileSync(stateFile, "utf8"); + }, + + async writeBody(_repo, _issueNumber, body): Promise { + // Atomic write: temp sibling + rename, so a concurrent reader never sees a + // half-written state file. The temp is cleaned up on a write failure. + mkdirSync(dirname(stateFile), { recursive: true }); + const tmp = join(dirname(stateFile), `.${pathStem(stateFile)}.tmp`); + try { + writeFileSync(tmp, body); + renameSync(tmp, stateFile); + } catch (error) { + rmSync(tmp, { force: true }); + throw error; + } + }, + }; +} + +/** The filename (without directory) of a path — for naming the sibling temp file. */ +function pathStem(path: string): string { + return path.slice(path.lastIndexOf("/") + 1); +} diff --git a/packages/dispatcher/src/epics-cache.ts b/packages/dispatcher/src/epics-cache.ts index fb55081b..e2e34ce2 100644 --- a/packages/dispatcher/src/epics-cache.ts +++ b/packages/dispatcher/src/epics-cache.ts @@ -37,6 +37,10 @@ export async function refreshEpics(db: Database, repo: string, github: EpicGatew const open = new Set(); const tx = db.transaction(() => { for (const e of epics) { + // The browse cache is numeric-keyed (`(repo, number)`); a file-mode Epic has + // only a slug (`number === null`) and isn't representable here yet, so skip it. + // (File-mode Epic browse is a later phase; github-mode rows are unaffected.) + if (e.number === null) continue; upsert.run(repo, e.number, e.title, JSON.stringify(e.labels), e.subTotal, e.subClosed, now); open.add(e.number); } diff --git a/packages/dispatcher/src/github.ts b/packages/dispatcher/src/github.ts index 3f14e40b..1d8a49a8 100644 --- a/packages/dispatcher/src/github.ts +++ b/packages/dispatcher/src/github.ts @@ -30,9 +30,12 @@ export type CommentAuthor = { isBot: boolean; }; -/** An open Epic discovered from GitHub's issues API, with its sub-issue progress. */ +/** An open Epic discovered from a store (GitHub issues or local files), with its sub-issue progress. */ export type EpicListItem = { - number: number; + /** Canonical Epic reference: `String(number)` in github mode, the slug in file mode. */ + ref: string; + /** GitHub issue number; `null` for a file-mode Epic (which has only a slug). */ + number: number | null; title: string; state: string; labels: string[]; @@ -84,6 +87,7 @@ export function parseEpicsList(stdout: string): EpicListItem[] { const summary = issue.sub_issues_summary; if (!summary || summary.total <= 0) continue; out.push({ + ref: String(issue.number), number: issue.number, title: issue.title, state: issue.state, diff --git a/packages/dispatcher/test/epic-store/file-epic-gateway.test.ts b/packages/dispatcher/test/epic-store/file-epic-gateway.test.ts new file mode 100644 index 00000000..e829abb3 --- /dev/null +++ b/packages/dispatcher/test/epic-store/file-epic-gateway.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, readdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { EpicGateway, PullRequest } from "../../src/github.ts"; +import { + FILE_AGENT_LOGIN, + FILE_HUMAN_LOGIN, + makeFileEpicGateway, +} from "../../src/epic-store/file-epic-gateway.ts"; +import { readEpicFile } from "../../src/epic-store/epic-file-io.ts"; +import { renderEpicFile } from "../../src/epic-store/epic-file/renderer.ts"; +import type { EpicFile } from "../../src/epic-store/epic-file/types.ts"; + +function tmpEpicsDir(): string { + return mkdtempSync(join(tmpdir(), "middle-epics-")); +} + +/** A minimal valid Epic file model, overridable per test. */ +function epicFixture(over: Partial = {}): EpicFile { + return { + title: "feat: rollout the epic store", + meta: { slug: "rollout-epic-store", labels: ["epic", "agent:claude"], ...over.meta }, + context: "Roll out the file-backed Epic store.", + acceptanceCriteria: [{ checked: false, text: "ship it" }], + subIssues: [ + { id: 1, checked: true, title: "1 — foundation", body: "" }, + { id: 2, checked: false, title: "2 — gateways", body: "" }, + ], + conversation: [], + ...over, + }; +} + +/** Write an Epic fixture to `/.md` via the renderer (guaranteed parseable). */ +function seedEpic(dir: string, epic: EpicFile): void { + writeFileSync(join(dir, `${epic.meta.slug}.md`), renderEpicFile(epic)); +} + +/** A gh backend stub that records the delegated calls and returns canned PRs. */ +function ghStub(over: Partial = {}): { + gh: EpicGateway; + calls: { postComment: Array<{ ref: string; body: string }>; getPullRequest: number[] }; +} { + const calls = { + postComment: [] as Array<{ ref: string; body: string }>, + getPullRequest: [] as number[], + }; + const gh = { + async postComment(_repo: string, ref: string, body: string) { + calls.postComment.push({ ref, body }); + }, + async getPullRequest(_repo: string, prNumber: number): Promise { + calls.getPullRequest.push(prNumber); + return { number: prNumber, body: "PR body", isDraft: true }; + }, + async getCommentAuthor() { + return { login: "octocat", isBot: false }; + }, + async getIssueLabels() { + return ["from-gh"]; + }, + async listIssueComments() { + return [ + { authorLogin: "octocat", body: "gh comment", url: "https://github.com/o/r/issues/42" }, + ]; + }, + ...over, + } as unknown as EpicGateway; + return { gh, calls }; +} + +describe("fileEpicGateway", () => { + test("listOpenEpics scans the dir, derives sub-issue progress, skips closed", async () => { + const dir = tmpEpicsDir(); + seedEpic(dir, epicFixture()); + seedEpic( + dir, + epicFixture({ meta: { slug: "done-epic", closed: true }, title: "old", subIssues: [] }), + ); + const { gh } = ghStub(); + const epics = await makeFileEpicGateway({ epicsDir: dir, gh }).listOpenEpics("o/r"); + expect(epics).toHaveLength(1); + expect(epics[0]).toMatchObject({ + ref: "rollout-epic-store", + number: null, + title: "feat: rollout the epic store", + state: "open", + labels: ["epic", "agent:claude"], + subTotal: 2, + subClosed: 1, + }); + }); + + test("listIssueComments maps the conversation; answer is attributed to the human", async () => { + const dir = tmpEpicsDir(); + seedEpic( + dir, + epicFixture({ + conversation: [ + { + kind: "dispatch-event", + ts: "2026-06-03T00:00:00.000Z", + eventKind: "comment", + body: "dispatched", + }, + { + kind: "question", + id: 1, + status: "resolved", + ts: "2026-06-03T01:00:00.000Z", + body: "which approach?", + answer: { body: "approach A" }, + }, + ], + }), + ); + const { gh } = ghStub(); + const comments = await makeFileEpicGateway({ epicsDir: dir, gh }).listIssueComments( + "o/r", + "rollout-epic-store", + ); + expect(comments.map((c) => ({ authorLogin: c.authorLogin, body: c.body }))).toEqual([ + { authorLogin: FILE_AGENT_LOGIN, body: "dispatched" }, + { authorLogin: FILE_AGENT_LOGIN, body: "which approach?" }, + { authorLogin: FILE_HUMAN_LOGIN, body: "approach A" }, + ]); + expect(comments[2]!.url).toMatch(/#answer-1$/); + }); + + test("listIssueComments delegates to gh for a non-Epic (PR-number) ref", async () => { + const dir = tmpEpicsDir(); + const { gh } = ghStub(); + const comments = await makeFileEpicGateway({ epicsDir: dir, gh }).listIssueComments( + "o/r", + "42", + ); + expect(comments[0]!.body).toBe("gh comment"); + }); + + test("getCommentAuthor discriminates human (answer) from agent by the file:// fragment", async () => { + const dir = tmpEpicsDir(); + const gw = makeFileEpicGateway({ epicsDir: dir, gh: ghStub().gh }); + expect(await gw.getCommentAuthor("o/r", "file:///e/rollout.md#answer-1")).toEqual({ + login: FILE_HUMAN_LOGIN, + isBot: false, + }); + expect(await gw.getCommentAuthor("o/r", "file:///e/rollout.md#question-1")).toEqual({ + login: FILE_AGENT_LOGIN, + isBot: true, + }); + }); + + test("getCommentAuthor delegates a github.com URL to gh", async () => { + const gw = makeFileEpicGateway({ epicsDir: tmpEpicsDir(), gh: ghStub().gh }); + expect( + await gw.getCommentAuthor("o/r", "https://github.com/o/r/issues/1#issuecomment-9"), + ).toEqual({ + login: "octocat", + isBot: false, + }); + }); + + test("getIssueLabels reads the Epic meta labels", async () => { + const dir = tmpEpicsDir(); + seedEpic(dir, epicFixture()); + const labels = await makeFileEpicGateway({ epicsDir: dir, gh: ghStub().gh }).getIssueLabels( + "o/r", + "rollout-epic-store", + ); + expect(labels).toEqual(["epic", "agent:claude"]); + }); + + test("postComment appends a re-parseable dispatch-event block", async () => { + const dir = tmpEpicsDir(); + seedEpic(dir, epicFixture()); + await makeFileEpicGateway({ + epicsDir: dir, + gh: ghStub().gh, + now: () => new Date("2026-06-03T02:00:00.000Z"), + }).postComment("o/r", "rollout-epic-store", "recorded a dispatch"); + const reparsed = readEpicFile(dir, "rollout-epic-store"); + expect(reparsed!.conversation).toEqual([ + { + kind: "dispatch-event", + ts: "2026-06-03T02:00:00.000Z", + eventKind: "comment", + body: "recorded a dispatch", + }, + ]); + }); + + test("postComment delegates a PR-number ref to gh (no Epic file for it)", async () => { + const { gh, calls } = ghStub(); + await makeFileEpicGateway({ epicsDir: tmpEpicsDir(), gh }).postComment( + "o/r", + "42", + "PR comment", + ); + expect(calls.postComment).toEqual([{ ref: "42", body: "PR comment" }]); + }); + + test("findEpicPr returns null without a stamped pr, and delegates to gh when present", async () => { + const dir = tmpEpicsDir(); + seedEpic(dir, epicFixture()); + const { gh, calls } = ghStub(); + const gw = makeFileEpicGateway({ epicsDir: dir, gh }); + expect(await gw.findEpicPr("o/r", "rollout-epic-store")).toBeNull(); + + seedEpic(dir, epicFixture({ meta: { slug: "rollout-epic-store", pr: 88 } })); + const pr = await gw.findEpicPr("o/r", "rollout-epic-store"); + expect(pr).toMatchObject({ number: 88, isDraft: true }); + expect(calls.getPullRequest).toEqual([88]); + }); + + test("findEpicPr returns null when the Epic file is absent", async () => { + const gw = makeFileEpicGateway({ epicsDir: tmpEpicsDir(), gh: ghStub().gh }); + expect(await gw.findEpicPr("o/r", "no-such-epic")).toBeNull(); + }); + + test("addLabel appends to meta labels and is a no-op if already present", async () => { + const dir = tmpEpicsDir(); + seedEpic(dir, epicFixture()); + const gw = makeFileEpicGateway({ epicsDir: dir, gh: ghStub().gh }); + await gw.addLabel("o/r", "rollout-epic-store", "approved"); + expect(readEpicFile(dir, "rollout-epic-store")!.meta.labels).toEqual([ + "epic", + "agent:claude", + "approved", + ]); + await gw.addLabel("o/r", "rollout-epic-store", "approved"); // no-op + expect(readEpicFile(dir, "rollout-epic-store")!.meta.labels).toEqual([ + "epic", + "agent:claude", + "approved", + ]); + }); + + test("a present-but-malformed Epic file surfaces the parser's named error", async () => { + const dir = tmpEpicsDir(); + writeFileSync(join(dir, "broken.md"), "not an epic file\n"); + const gw = makeFileEpicGateway({ epicsDir: dir, gh: ghStub().gh }); + await expect(gw.getIssueLabels("o/r", "broken")).rejects.toThrow(/document marker/); + }); + + test("postComment writes atomically — no `.tmp` sibling left behind", async () => { + const dir = tmpEpicsDir(); + seedEpic(dir, epicFixture()); + await makeFileEpicGateway({ epicsDir: dir, gh: ghStub().gh }).postComment( + "o/r", + "rollout-epic-store", + "x", + ); + expect(readdirSync(dir).filter((n) => n.endsWith(".tmp"))).toEqual([]); + }); +}); diff --git a/packages/dispatcher/test/epic-store/file-gateways-integration.test.ts b/packages/dispatcher/test/epic-store/file-gateways-integration.test.ts new file mode 100644 index 00000000..cdfbf72d --- /dev/null +++ b/packages/dispatcher/test/epic-store/file-gateways-integration.test.ts @@ -0,0 +1,132 @@ +/** + * Integration: the three file gateways composed over one real on-disk Epic file, + * exercising the Phase-1 file-mode lifecycle through the real parser/renderer and + * filesystem (no mocks of the file layer): + * + * author Epic → dispatcher records a dispatch-event (fileEpicGateway.postComment) + * → agent asks a question → human edits the answer block on disk + * → filePollGateway.listIssueComments surfaces the answer as a human reply. + * + * This is the gateway-level integration; the daemon-HTTP-boot file-mode dispatch + * (selector wiring) lands with #193's bootstrap selector and #196's parity test. + */ + +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { makeFileEpicGateway } from "../../src/epic-store/file-epic-gateway.ts"; +import { makeFilePollGateway } from "../../src/epic-store/file-poll-gateway.ts"; +import { makeFileStateGateway } from "../../src/epic-store/file-state-gateway.ts"; +import { epicFilePath, readEpicFile } from "../../src/epic-store/epic-file-io.ts"; +import { renderEpicFile } from "../../src/epic-store/epic-file/renderer.ts"; +import { classifyNewHumanReply } from "../../src/poller.ts"; +import type { EpicGateway } from "../../src/github.ts"; +import type { PollGateway } from "../../src/poller.ts"; + +const SLUG = "rollout-epic-store"; + +function ghEpicStub(): EpicGateway { + return {} as unknown as EpicGateway; +} +function ghPollStub(): PollGateway { + return {} as unknown as PollGateway; +} + +describe("file gateways — Phase-1 lifecycle integration", () => { + test("dispatch-event record, human answer on disk, poll surfaces the human reply", async () => { + const epicsDir = mkdtempSync(join(tmpdir(), "middle-epics-int-")); + // Author the Epic file the way `mm init` / a human would (via the renderer). + writeFileSync( + epicFilePath(epicsDir, SLUG), + renderEpicFile({ + title: "feat: file-backed epic store", + meta: { slug: SLUG, adapter: "claude", labels: ["epic"] }, + context: "Roll out the store.", + acceptanceCriteria: [{ checked: false, text: "ship" }], + subIssues: [{ id: 1, checked: false, title: "1 — gateways", body: "" }], + conversation: [], + }), + ); + + const epicGw = makeFileEpicGateway({ + epicsDir, + gh: ghEpicStub(), + now: () => new Date("2026-06-03T00:00:00.000Z"), + }); + const pollGw = makeFilePollGateway({ epicsDir, gh: ghPollStub() }); + + // 1. Dispatcher records a dispatch-event on the Epic (the github-mode + // "comment on the Epic" equivalent), appended to the conversation on disk. + await epicGw.postComment("o/r", SLUG, "Dispatched wf_abc with the claude adapter."); + expect(readEpicFile(epicsDir, SLUG)!.conversation).toEqual([ + { + kind: "dispatch-event", + ts: "2026-06-03T00:00:00.000Z", + eventKind: "comment", + body: "Dispatched wf_abc with the claude adapter.", + }, + ]); + + // 2. Agent parks asking a question — append a question block (open, no answer). + const parked = readEpicFile(epicsDir, SLUG)!; + writeFileSync( + epicFilePath(epicsDir, SLUG), + renderEpicFile({ + ...parked, + conversation: [ + ...parked.conversation, + { + kind: "question", + id: 1, + status: "open", + ts: "2026-06-03T01:00:00.000Z", + body: "Approach A or B?", + }, + ], + }), + ); + + // Before the human answers, the poll sees only bot entries → no human reply. + const parkMs = Date.parse("2026-06-03T00:30:00.000Z"); + const beforeAnswer = await pollGw.listIssueComments("o/r", SLUG); + expect(classifyNewHumanReply(beforeAnswer, parkMs)).toBeNull(); + + // 3. Human edits the answer block on disk (what the file-watcher detects in + // Phase 2). Re-render with the answer populated. + const questioned = readEpicFile(epicsDir, SLUG)!; + writeFileSync( + epicFilePath(epicsDir, SLUG), + renderEpicFile({ + ...questioned, + conversation: questioned.conversation.map((e) => + e.kind === "question" && e.id === 1 + ? { ...e, status: "resolved", answer: { body: "Go with A." } } + : e, + ), + }), + ); + + // 4. The poll now surfaces the answer as a human (non-bot) reply. + const afterAnswer = await pollGw.listIssueComments("o/r", SLUG); + const reply = classifyNewHumanReply(afterAnswer, parkMs); + expect(reply?.body).toBe("Go with A."); + expect(reply?.authorIsBot).toBe(false); + + // And the Epic gateway attributes that comment's URL to the human. + const epicComments = await epicGw.listIssueComments("o/r", SLUG); + const answerComment = epicComments.find((c) => c.body === "Go with A."); + const author = await epicGw.getCommentAuthor("o/r", answerComment!.url); + expect(author).toEqual({ login: "human", isBot: false }); + }); + + test("state gateway round-trips the recommender state file atomically", async () => { + const dir = mkdtempSync(join(tmpdir(), "middle-state-int-")); + const stateFile = join(dir, ".middle", "state.md"); + const stateGw = makeFileStateGateway({ stateFile }); + const body = "\n# state\n\nbody\n"; + await stateGw.writeBody("o/r", 0, body); + expect(await stateGw.readBody("o/r", 0)).toBe(body); + expect(readFileSync(stateFile, "utf8")).toBe(body); + }); +}); diff --git a/packages/dispatcher/test/epic-store/file-poll-gateway.test.ts b/packages/dispatcher/test/epic-store/file-poll-gateway.test.ts new file mode 100644 index 00000000..e05fee63 --- /dev/null +++ b/packages/dispatcher/test/epic-store/file-poll-gateway.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { makeFilePollGateway } from "../../src/epic-store/file-poll-gateway.ts"; +import { renderEpicFile } from "../../src/epic-store/epic-file/renderer.ts"; +import type { EpicFile } from "../../src/epic-store/epic-file/types.ts"; +import type { + EpicPrLifecycle, + PollGateway, + PrSnapshot, + RateLimitStatus, +} from "../../src/poller.ts"; + +function tmpEpicsDir(): string { + return mkdtempSync(join(tmpdir(), "middle-poll-")); +} + +function seedEpic(dir: string, epic: EpicFile): void { + writeFileSync(join(dir, `${epic.meta.slug}.md`), renderEpicFile(epic)); +} + +function baseEpic(conversation: EpicFile["conversation"]): EpicFile { + return { + title: "feat: x", + meta: { slug: "rollout-epic-store" }, + context: "ctx", + acceptanceCriteria: [], + subIssues: [], + conversation, + }; +} + +/** A poll gh backend stub recording delegated calls. */ +function ghStub(): { + gh: PollGateway; + calls: { findPrForEpic: string[]; findEpicPrLifecycle: string[]; rateLimit: number }; +} { + const calls = { + findPrForEpic: [] as string[], + findEpicPrLifecycle: [] as string[], + rateLimit: 0, + }; + const gh: PollGateway = { + async listIssueComments() { + return [{ id: 1, authorLogin: "octocat", authorIsBot: false, createdAt: 0, body: "gh" }]; + }, + async findPrForEpic(_repo, epicRef): Promise { + calls.findPrForEpic.push(epicRef); + return { number: 5, reviewDecision: null, reviews: [], labels: [] }; + }, + async findEpicPrLifecycle(_repo, epicRef): Promise { + calls.findEpicPrLifecycle.push(epicRef); + return { number: 5, state: "OPEN" }; + }, + async getRateLimit(): Promise { + calls.rateLimit += 1; + return { remaining: 4999, resetAt: 0 }; + }, + }; + return { gh, calls }; +} + +describe("filePollGateway", () => { + test("listIssueComments derives authorIsBot structurally from the marker kind", async () => { + const dir = tmpEpicsDir(); + seedEpic( + dir, + baseEpic([ + { kind: "dispatch-event", ts: "2026-06-03T00:00:00.000Z", eventKind: "comment", body: "e" }, + { + kind: "question", + id: 1, + status: "resolved", + ts: "2026-06-03T01:00:00.000Z", + body: "q", + answer: { body: "a" }, + }, + ]), + ); + const comments = await makeFilePollGateway({ + epicsDir: dir, + gh: ghStub().gh, + }).listIssueComments("o/r", "rollout-epic-store"); + expect(comments.map((c) => ({ body: c.body, authorIsBot: c.authorIsBot }))).toEqual([ + { body: "e", authorIsBot: true }, // dispatch-event → bot + { body: "q", authorIsBot: true }, // question → bot + { body: "a", authorIsBot: false }, // answer → human (the resume signal) + ]); + expect(comments[1]!.createdAt).toBe(Date.parse("2026-06-03T01:00:00.000Z")); + }); + + test("listIssueComments delegates to gh for a non-Epic (PR-number) ref", async () => { + const comments = await makeFilePollGateway({ + epicsDir: tmpEpicsDir(), + gh: ghStub().gh, + }).listIssueComments("o/r", "42"); + expect(comments[0]!.body).toBe("gh"); + }); + + test("findPrForEpic delegates a numeric ref but returns null for a file-mode slug", async () => { + const { gh, calls } = ghStub(); + const gw = makeFilePollGateway({ epicsDir: tmpEpicsDir(), gh }); + expect(await gw.findPrForEpic("o/r", "rollout-epic-store")).toBeNull(); + expect(calls.findPrForEpic).toEqual([]); // slug never reaches gh's `Closes #N` search + expect(await gw.findPrForEpic("o/r", "42")).toMatchObject({ number: 5 }); + expect(calls.findPrForEpic).toEqual(["42"]); + }); + + test("findEpicPrLifecycle delegates a numeric ref but returns null for a slug", async () => { + const { gh, calls } = ghStub(); + const gw = makeFilePollGateway({ epicsDir: tmpEpicsDir(), gh }); + expect(await gw.findEpicPrLifecycle("o/r", "rollout-epic-store")).toBeNull(); + expect(await gw.findEpicPrLifecycle("o/r", "42")).toMatchObject({ number: 5, state: "OPEN" }); + expect(calls.findEpicPrLifecycle).toEqual(["42"]); + }); + + test("getRateLimit delegates straight to gh", async () => { + const { gh, calls } = ghStub(); + const budget = await makeFilePollGateway({ epicsDir: tmpEpicsDir(), gh }).getRateLimit(); + expect(budget.remaining).toBe(4999); + expect(calls.rateLimit).toBe(1); + }); +}); diff --git a/packages/dispatcher/test/epic-store/file-state-gateway.test.ts b/packages/dispatcher/test/epic-store/file-state-gateway.test.ts new file mode 100644 index 00000000..f2be59f8 --- /dev/null +++ b/packages/dispatcher/test/epic-store/file-state-gateway.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { makeFileStateGateway } from "../../src/epic-store/file-state-gateway.ts"; + +function tmpRepo(): string { + return mkdtempSync(join(tmpdir(), "middle-state-")); +} + +describe("fileStateGateway", () => { + test("readBody returns the state file contents verbatim", async () => { + const dir = tmpRepo(); + const stateFile = join(dir, ".middle", "state.md"); + writeFileSync(join(dir, "x"), ""); // ensure dir exists for the write below + const gw = makeFileStateGateway({ stateFile }); + await gw.writeBody("o/r", 0, "# state\n\nbody\n"); + expect(await gw.readBody("o/r", 0)).toBe("# state\n\nbody\n"); + }); + + test("readBody throws a clear error when the state file is absent", async () => { + const dir = tmpRepo(); + const gw = makeFileStateGateway({ stateFile: join(dir, "missing", "state.md") }); + await expect(gw.readBody("o/r", 0)).rejects.toThrow(/state file not found/); + }); + + test("writeBody creates the parent directory and round-trips", async () => { + const dir = tmpRepo(); + const stateFile = join(dir, "nested", "deeper", "state.md"); + const gw = makeFileStateGateway({ stateFile }); + await gw.writeBody("o/r", 0, "hello\n"); + expect(readFileSync(stateFile, "utf8")).toBe("hello\n"); + }); + + test("writeBody is atomic: leaves no `.tmp` sibling after a successful write", async () => { + const dir = tmpRepo(); + const stateDir = join(dir, ".middle"); + const stateFile = join(stateDir, "state.md"); + const gw = makeFileStateGateway({ stateFile }); + await gw.writeBody("o/r", 0, "first\n"); + await gw.writeBody("o/r", 0, "second\n"); + expect(readFileSync(stateFile, "utf8")).toBe("second\n"); + expect(readdirSync(stateDir).filter((n) => n.endsWith(".tmp"))).toEqual([]); + }); + + test("writeBody overwrites an existing file", async () => { + const dir = tmpRepo(); + const stateFile = join(dir, "state.md"); + writeFileSync(stateFile, "old\n"); + const gw = makeFileStateGateway({ stateFile }); + await gw.writeBody("o/r", 0, "new\n"); + expect(readFileSync(stateFile, "utf8")).toBe("new\n"); + expect(existsSync(stateFile)).toBe(true); + }); +}); diff --git a/packages/dispatcher/test/epics-cache.test.ts b/packages/dispatcher/test/epics-cache.test.ts index 4afd136e..685b98ca 100644 --- a/packages/dispatcher/test/epics-cache.test.ts +++ b/packages/dispatcher/test/epics-cache.test.ts @@ -30,8 +30,16 @@ describe("epics-cache", () => { db, "o/r", fakeGitHub([ - { number: 10, title: "A", state: "open", labels: ["epic"], subTotal: 3, subClosed: 1 }, - { number: 20, title: "B", state: "open", labels: [], subTotal: 2, subClosed: 2 }, + { + ref: "10", + number: 10, + title: "A", + state: "open", + labels: ["epic"], + subTotal: 3, + subClosed: 1, + }, + { ref: "20", number: 20, title: "B", state: "open", labels: [], subTotal: 2, subClosed: 2 }, ]), ); const rows = readEpics(db, "o/r"); @@ -51,7 +59,7 @@ describe("epics-cache", () => { db, "o/r", fakeGitHub([ - { number: 10, title: "A", state: "open", labels: [], subTotal: 1, subClosed: 0 }, + { ref: "10", number: 10, title: "A", state: "open", labels: [], subTotal: 1, subClosed: 0 }, ]), ); await refreshEpics(db, "o/r", fakeGitHub([])); // 10 no longer open @@ -68,7 +76,7 @@ describe("epics-cache", () => { db, "o/r", fakeGitHub([ - { number: 10, title: "A", state: "open", labels: [], subTotal: 1, subClosed: 0 }, + { ref: "10", number: 10, title: "A", state: "open", labels: [], subTotal: 1, subClosed: 0 }, ]), ); // Refresh with empty list — #10 is now closed. @@ -79,7 +87,7 @@ describe("epics-cache", () => { db, "o/r", fakeGitHub([ - { number: 10, title: "A", state: "open", labels: [], subTotal: 1, subClosed: 0 }, + { ref: "10", number: 10, title: "A", state: "open", labels: [], subTotal: 1, subClosed: 0 }, ]), ); const rows = readEpics(db, "o/r"); @@ -94,7 +102,9 @@ describe("epics-cache", () => { await refreshEpics( db, "o/a", - fakeGitHub([{ number: 1, title: "A", state: "open", labels: [], subTotal: 1, subClosed: 0 }]), + fakeGitHub([ + { ref: "1", number: 1, title: "A", state: "open", labels: [], subTotal: 1, subClosed: 0 }, + ]), ); await refreshEpics(db, "o/b", fakeGitHub([])); expect(readEpics(db, "o/a").map((r) => r.number)).toEqual([1]); diff --git a/packages/dispatcher/test/github-epics.test.ts b/packages/dispatcher/test/github-epics.test.ts index ecb2e9df..28ff8ad6 100644 --- a/packages/dispatcher/test/github-epics.test.ts +++ b/packages/dispatcher/test/github-epics.test.ts @@ -22,6 +22,7 @@ describe("parseEpicsList", () => { expect(parseEpicsList(ndjson)).toEqual([ { + ref: "247", number: 247, title: "OAuth refresh", state: "open", @@ -45,7 +46,7 @@ describe("parseEpicsList", () => { sub_issues_summary: { total: 1, completed: 0 }, }); expect(parseEpicsList(ndjson)).toEqual([ - { number: 2, title: "y", state: "open", labels: [], subTotal: 1, subClosed: 0 }, + { ref: "2", number: 2, title: "y", state: "open", labels: [], subTotal: 1, subClosed: 0 }, ]); }); }); diff --git a/planning/issues/190/decisions.md b/planning/issues/190/decisions.md index 7f4f14b1..a7d31daa 100644 --- a/planning/issues/190/decisions.md +++ b/planning/issues/190/decisions.md @@ -79,3 +79,34 @@ stringifies it at the `createWorktree` boundary. **Why:** The workflow seam now threads a string; the worktree directory must accept it so a file-mode slug yields `issue-` without a numeric coercion. github paths are unchanged. **Evidence:** sub-issue #191 (string seam everywhere); worktree layout in root `CLAUDE.md`/spec. + +## `EpicListItem` gains `ref`; `number` nullable; numeric epics cache skips file Epics +**File(s):** `packages/dispatcher/src/github.ts`, `epics-cache.ts` +**Date:** 2026-06-03 + +**Decision:** `EpicListItem` gains a required `ref: string` (github: `String(number)`, +file: slug) and `number` becomes `number | null` (null for a file Epic). `refreshEpics` +skips rows with `number === null` — the browse cache table is numeric-keyed `(repo, number)`. +**Why:** `fileEpicGateway.listOpenEpics` must return file Epics, which have only a slug. +The browse cache is github-only this phase (`refreshEpics` is always called with `ghGitHub` +in `main.ts`); a file-aware browse cache is a later phase, so skipping null-numbered rows +keeps the numeric table honest without a schema change. github rows are unaffected. +**Evidence:** `epics-cache.ts` `(repo, number)` PK; #192 integration scope (dispatch+postComment, not browse). + +## File-mode PR-poll resolution (`findPrForEpic`) is a Phase-2 refinement +**File(s):** `packages/dispatcher/src/epic-store/file-poll-gateway.ts` +**Date:** 2026-06-03 + +**Decision:** `filePollGateway.listIssueComments` is fully file-backed (conversation → +poll comments with `authorIsBot` from the marker — the #178-class closure). `getRateLimit` +delegates to gh. `findPrForEpic`/`findEpicPrLifecycle` delegate to gh for a numeric ref but +return `null` for a file-mode slug (no PR yet / Phase-1 limitation) rather than feed a slug +into gh's `Closes #` search (which `refToIssueNumber` would reject). +**Why:** github's PR-finders resolve a PR by `Closes #`, which a file Epic (slug, +no GitHub issue) can't carry; the file↔PR link is the `` body +marker + `meta.pr`, and `PollGateway` has no by-PR-number snapshot method to fetch through. +Spec Phase 1 is "File-Epic dispatch (no watcher)"; review-resume on file mode rides Phase 2's +watcher work. Returning null is non-throwing and honest; question-resume (the Phase-1 path) +is unaffected. +**Evidence:** spec "Phase plan" (Phase 1 vs 2); spec poll-gateway table ("delegate to gh"); +`poller-gateway.ts` `Closes #${epicNumber}` search. From 89de4c855bb60255231524debfb64bae71eb3797 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Wed, 3 Jun 2026 04:40:49 -0400 Subject: [PATCH 04/10] feat(epic-store): bootstrap selector + postQuestion file-mode wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close #193. Wire the per-repo Epic-store mode selector and the file-mode postQuestion seam. - `epic-store/index.ts`: `buildGitHubGateways` (today's gh trio, lifted into a named helper), `buildFileGateways` (the file-backed trio for one repo), `makeRoutingEpicGateway` (a daemon-global gateway that delegates each call to the repo's file or gh backend, keyed on the method's `repo` arg), and `appendQuestion` (append a `` block via the renderer). - `repo-config.ts`: `readEpicStoreConfig`/`setEpicStoreConfig` over migration 008's `epic_store`/`epics_dir`/`state_file` columns; defaults match today (github). - `build-deps.ts`: `github`/`planCommentReader` default to the router; `postQuestion` routes by mode — file → `appendQuestion` to the Epic file, github → `formatPauseComment` via gh. - `hook-server.ts`: `/control/dispatch` accepts a string `epicRef` (file slug) or a numeric `epicNumber` (github), so a file-mode dispatch is reachable end-to-end. - Tests: selector unit tests (factories, per-repo routing, appendQuestion) and an integration test driving a real file-mode dispatch through the workflow to an asked-question park (row carries the slug as `epic_ref`, the Epic file gains a re-parseable question block) plus the real `buildImplementationDeps` postQuestion routing for file vs github repos. Default mode stays github; existing repos behave identically. typecheck/lint/format clean; full suite green (1206). --- packages/dispatcher/src/build-deps.ts | 30 ++- packages/dispatcher/src/epic-store/index.ts | 175 +++++++++++++ packages/dispatcher/src/hook-server.ts | 19 +- packages/dispatcher/src/repo-config.ts | 55 +++++ .../file-dispatch-integration.test.ts | 233 ++++++++++++++++++ .../test/epic-store/selector.test.ts | 147 +++++++++++ planning/issues/190/decisions.md | 32 +++ 7 files changed, 681 insertions(+), 10 deletions(-) create mode 100644 packages/dispatcher/src/epic-store/index.ts create mode 100644 packages/dispatcher/test/epic-store/file-dispatch-integration.test.ts create mode 100644 packages/dispatcher/test/epic-store/selector.test.ts diff --git a/packages/dispatcher/src/build-deps.ts b/packages/dispatcher/src/build-deps.ts index 16c59581..6d2c29e1 100644 --- a/packages/dispatcher/src/build-deps.ts +++ b/packages/dispatcher/src/build-deps.ts @@ -4,7 +4,10 @@ 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 { join } from "node:path"; import { ghGitHub, type EpicGateway, resolveAgentLogin as ghResolveAgentLogin } from "./github.ts"; +import { appendQuestion, makeRoutingEpicGateway } from "./epic-store/index.ts"; +import { readEpicStoreConfig } from "./repo-config.ts"; import type { SessionGate } from "./hook-server.ts"; import { AGENT_COMMENT_MARKER } from "./poller.ts"; import { killSession, newSession, sendEnter, sendText, status } from "./tmux.ts"; @@ -137,7 +140,17 @@ export type BuildImplementationDepsArgs = { export async function buildImplementationDeps( args: BuildImplementationDepsArgs, ): Promise<{ deps: ImplementationDeps; prReadyGate: PrReadyGateHandler }> { - const github = args.github ?? ghGitHub; + // The daemon registers one workflow with one deps, but Epic-store mode is + // per-repo — so the default gateway is a router that delegates each call to the + // repo's file or gh backend (keyed on the method's `repo` arg). An injected + // `args.github` (tests) overrides it. github-mode repos route to `ghGitHub`, so + // behavior is identical when no repo opts into file mode. + const routingGh = makeRoutingEpicGateway({ + db: args.db, + resolveRepoPath: args.resolveRepoPath, + ghEpic: ghGitHub, + }); + const github = args.github ?? routingGh; const resolveLogin = args.resolveAgentLogin ?? ghResolveAgentLogin; // The PR-ready gate resolves a session to its Epic via the workflow row, then @@ -170,7 +183,7 @@ export async function buildImplementationDeps( worktreeRoot: args.worktreeRoot, dispatcherUrl, enqueueContinuation: args.enqueueContinuation, - planCommentReader: args.planCommentReader ?? ghGitHub, + planCommentReader: args.planCommentReader ?? routingGh, agentLogin, // Positive done-signal (#80): a bare-stop only completes if the Epic // already has a ready, non-draft PR. @@ -183,7 +196,18 @@ export async function buildImplementationDeps( postQuestion: args.postQuestion ?? (async ({ repo, epicRef, question, context, kind }) => { - await github.postComment(repo, epicRef, formatPauseComment({ question, context, kind })); + // file mode: append a `` block to the Epic file + // (the agent-side of #178's class). github mode: comment on the Epic. + const cfg = readEpicStoreConfig(args.db, repo); + if (cfg.mode === "file") { + appendQuestion(join(args.resolveRepoPath(repo), cfg.epicsDir), epicRef, { + question, + context, + kind, + }); + } else { + await github.postComment(repo, epicRef, formatPauseComment({ question, context, kind })); + } }), resolveComplexityCeiling: args.resolveComplexityCeiling, // Default: the Epic is approved iff it carries the `approved` label (#53). diff --git a/packages/dispatcher/src/epic-store/index.ts b/packages/dispatcher/src/epic-store/index.ts new file mode 100644 index 00000000..df59579f --- /dev/null +++ b/packages/dispatcher/src/epic-store/index.ts @@ -0,0 +1,175 @@ +/** + * @packageDocumentation + * @module @middle/dispatcher/epic-store + * + * File-backed Epic store: the parallel implementations of the dispatcher's three + * DI'd gateway interfaces, plus the per-repo bootstrap selector that picks the + * github-backed or file-backed trio. + * + * Public surface: + * - `buildGitHubGateways` — today's `gh`-backed trio, lifted into a named helper + * - `buildFileGateways` — the file-backed trio for one repo (Epic dir + state file) + * - `makeRoutingEpicGateway` — a daemon-global `EpicGateway` that delegates each + * call to the per-repo file or gh backend, keyed on the method's `repo` arg + * - `appendQuestion` — append a `` block to an Epic file + * (the file-mode `postQuestion` endpoint) + * - `make{File,}…Gateway` re-exports from the gateway modules + * + * Where things live: + * - `index.ts` — factories + the per-repo router + `appendQuestion` + * - `file-epic-gateway.ts` / `file-state-gateway.ts` / `file-poll-gateway.ts` — the gateways + * - `epic-file-io.ts` — disk read/parse + render/atomic-write + * - `epic-file/` — the pure parser/renderer/types/markers (round-trip invariant) + * + * Gotchas: + * - The daemon registers ONE implementation workflow with ONE deps, but mode is + * per-repo — so the wired gateway is a *router* that reads `repo_config` per call + * and delegates by `repo`. A file gateway is a composite: Epic methods are + * file-backed, PR/github-native methods delegate to gh. + * + * claude-md: false + */ + +import type { Database } from "bun:sqlite"; +import { ghGitHub, type EpicGateway } from "../github.ts"; +import { ghPollGateway } from "../poller-gateway.ts"; +import { ghStateIssueGateway, type StateGateway } from "../state-issue.ts"; +import type { PollGateway } from "../poller.ts"; +import { readEpicStoreConfig } from "../repo-config.ts"; +import { join } from "node:path"; +import { readEpicFile, writeEpicFile } from "./epic-file-io.ts"; +import { makeFileEpicGateway } from "./file-epic-gateway.ts"; +import { makeFilePollGateway } from "./file-poll-gateway.ts"; +import { makeFileStateGateway } from "./file-state-gateway.ts"; + +export { makeFileEpicGateway } from "./file-epic-gateway.ts"; +export { makeFilePollGateway } from "./file-poll-gateway.ts"; +export { makeFileStateGateway } from "./file-state-gateway.ts"; + +/** The three gateways a dispatch path needs, behind their shared interfaces. */ +export type GatewayTrio = { + epicGateway: EpicGateway; + stateGateway: StateGateway; + pollGateway: PollGateway; +}; + +/** Today's `gh`-backed trio, lifted into a named helper (the github-mode wiring). */ +export function buildGitHubGateways(over?: { + ghEpic?: EpicGateway; + ghState?: StateGateway; + ghPoll?: PollGateway; +}): GatewayTrio { + return { + epicGateway: over?.ghEpic ?? ghGitHub, + stateGateway: over?.ghState ?? ghStateIssueGateway, + pollGateway: over?.ghPoll ?? ghPollGateway, + }; +} + +/** + * The file-backed trio for one repo. Epic/state methods read/write local files + * under `epicsDir`/`stateFile`; PR-shaped + github-native methods delegate to the + * injected `gh` backends (the hybrid). `epicsDir`/`stateFile` are absolute. + */ +export function buildFileGateways(args: { + epicsDir: string; + stateFile: string; + ghEpic?: EpicGateway; + ghPoll?: PollGateway; +}): GatewayTrio { + const ghEpic = args.ghEpic ?? ghGitHub; + const ghPoll = args.ghPoll ?? ghPollGateway; + return { + epicGateway: makeFileEpicGateway({ epicsDir: args.epicsDir, gh: ghEpic }), + stateGateway: makeFileStateGateway({ stateFile: args.stateFile }), + pollGateway: makeFilePollGateway({ epicsDir: args.epicsDir, gh: ghPoll }), + }; +} + +/** Resolve a repo's file-mode gateway trio (absolute paths from `resolveRepoPath`), + * or the github trio when the repo isn't in file mode. */ +function trioForRepo( + db: Database, + repo: string, + resolveRepoPath: (repo: string) => string, + gh: { epic: EpicGateway; poll: PollGateway }, +): GatewayTrio { + const cfg = readEpicStoreConfig(db, repo); + if (cfg.mode !== "file") { + return buildGitHubGateways({ ghEpic: gh.epic, ghPoll: gh.poll }); + } + const root = resolveRepoPath(repo); + return buildFileGateways({ + epicsDir: join(root, cfg.epicsDir), + stateFile: join(root, cfg.stateFile), + ghEpic: gh.epic, + ghPoll: gh.poll, + }); +} + +/** + * A daemon-global `EpicGateway` that routes each call to the right per-repo + * backend (file or gh), keyed on the method's `repo` argument. The daemon + * registers one implementation workflow with one deps, but Epic-store mode is + * per-repo — this router is what lets repo A run github mode while repo B runs + * file mode under the same daemon. Per-repo file gateways are built lazily and + * cached (config is read fresh per call, so a mode flip is picked up on the next + * dispatch without a daemon restart — the cache only memoizes the file gateway + * object, which is stateless). + */ +export function makeRoutingEpicGateway(deps: { + db: Database; + resolveRepoPath: (repo: string) => string; + ghEpic?: EpicGateway; + ghPoll?: PollGateway; +}): EpicGateway { + const ghEpic = deps.ghEpic ?? ghGitHub; + const ghPoll = deps.ghPoll ?? ghPollGateway; + const gatewayFor = (repo: string): EpicGateway => + trioForRepo(deps.db, repo, deps.resolveRepoPath, { epic: ghEpic, poll: ghPoll }).epicGateway; + return { + listOpenEpics: (repo) => gatewayFor(repo).listOpenEpics(repo), + listIssueComments: (repo, ref) => gatewayFor(repo).listIssueComments(repo, ref), + findEpicPr: (repo, epicRef) => gatewayFor(repo).findEpicPr(repo, epicRef), + getPullRequest: (repo, prNumber) => gatewayFor(repo).getPullRequest(repo, prNumber), + editPullRequestBody: (repo, prNumber, body) => + gatewayFor(repo).editPullRequestBody(repo, prNumber, body), + postComment: (repo, ref, body) => gatewayFor(repo).postComment(repo, ref, body), + editComment: (repo, commentId, body) => gatewayFor(repo).editComment(repo, commentId, body), + getCommentAuthor: (repo, url) => gatewayFor(repo).getCommentAuthor(repo, url), + getIssueLabels: (repo, ref) => gatewayFor(repo).getIssueLabels(repo, ref), + listOpenIssues: (repo) => gatewayFor(repo).listOpenIssues(repo), + addLabel: (repo, ref, label) => gatewayFor(repo).addLabel(repo, ref, label), + listMergedPrsClosingRefs: (repo) => gatewayFor(repo).listMergedPrsClosingRefs(repo), + closeIssue: (repo, ref, comment) => gatewayFor(repo).closeIssue(repo, ref, comment), + createIssue: (repo, issue) => gatewayFor(repo).createIssue(repo, issue), + }; +} + +/** + * Append a `` block to an Epic file's conversation — the + * file-mode `postQuestion` endpoint (the agent-side of #178's class, structurally + * distinct from any human-written ``). The renderer is the + * sole writer of the strict marker attributes, so the round-trip survives. + */ +export function appendQuestion( + epicsDir: string, + slug: string, + opts: { question: string; context?: string; kind: "question" | "complexity"; now?: () => Date }, +): void { + const epic = readEpicFile(epicsDir, slug); + if (!epic) + throw new Error(`cannot post question: no Epic file for slug "${slug}" in ${epicsDir}`); + const nextId = + epic.conversation.reduce((max, e) => (e.kind === "question" ? Math.max(max, e.id) : max), 0) + + 1; + const ts = (opts.now ?? (() => new Date()))().toISOString(); + const body = opts.context ? `${opts.question}\n\n${opts.context}` : opts.question; + writeEpicFile(epicsDir, slug, { + ...epic, + conversation: [ + ...epic.conversation, + { kind: "question", id: nextId, status: "open", ts, questionKind: opts.kind, body }, + ], + }); +} diff --git a/packages/dispatcher/src/hook-server.ts b/packages/dispatcher/src/hook-server.ts index b2633d10..ab674364 100644 --- a/packages/dispatcher/src/hook-server.ts +++ b/packages/dispatcher/src/hook-server.ts @@ -396,7 +396,7 @@ export class HookServer implements SessionGate { const body: Record = typeof parsed === "object" && parsed !== null ? (parsed as Record) : {}; - const { repo, repoPath, epicNumber, adapter } = body; + const { repo, repoPath, epicNumber, epicRef, adapter } = body; // Normalize `repo` up front: a whitespace-only value would otherwise pass an // `=== ""` check and seed a malformed workflow-ownership key. const normalizedRepo = typeof repo === "string" ? repo.trim() : ""; @@ -406,8 +406,15 @@ export class HookServer implements SessionGate { if (typeof repoPath !== "string" || !isAbsolute(repoPath)) { return this.#badRequest("repoPath must be an absolute path"); } - if (typeof epicNumber !== "number" || !Number.isInteger(epicNumber) || epicNumber < 1) { - return this.#badRequest("epicNumber must be an integer >= 1"); + // The Epic reference is a string slug (file mode) OR a numeric `epicNumber` + // (github mode) stringified — the workflow seam is string-keyed either way. + let ref: string; + if (typeof epicRef === "string" && epicRef.trim() !== "") { + ref = epicRef.trim(); + } else if (typeof epicNumber === "number" && Number.isInteger(epicNumber) && epicNumber >= 1) { + ref = String(epicNumber); + } else { + return this.#badRequest("provide epicRef (non-empty string) or epicNumber (integer >= 1)"); } if (typeof adapter !== "string") { return this.#badRequest("unknown adapter: (missing)"); @@ -417,9 +424,7 @@ export class HookServer implements SessionGate { return this.#badRequest(reject); } - // github mode's control API takes a numeric `epicNumber`; the dispatcher seam - // is string-keyed, so stringify it into `epicRef` at this boundary. - const dispatchInput = { repo: normalizedRepo, repoPath, epicRef: String(epicNumber), adapter }; + const dispatchInput = { repo: normalizedRepo, repoPath, epicRef: ref, adapter }; // Manual dispatch respects slot limits — refuse with 429 when the repo/adapter // has no free slot (build spec → "Auto-dispatch loop": manual force-dispatch @@ -434,7 +439,7 @@ export class HookServer implements SessionGate { const workflowId = await control.startDispatch(dispatchInput); if (workflowId === null) { return Response.json( - { error: `Epic #${epicNumber} in ${normalizedRepo} already has an active workflow` }, + { error: `Epic ${ref} in ${normalizedRepo} already has an active workflow` }, { status: 409 }, ); } diff --git a/packages/dispatcher/src/repo-config.ts b/packages/dispatcher/src/repo-config.ts index d78e8aca..0c296297 100644 --- a/packages/dispatcher/src/repo-config.ts +++ b/packages/dispatcher/src/repo-config.ts @@ -111,6 +111,61 @@ export function listManagedRepos(db: Database): ManagedRepo[] { return rows.map((r) => ({ repo: r.repo, checkoutPath: r.checkout_path })); } +/** Default Epic directory for a file-mode repo (relative to the repo root). */ +export const DEFAULT_EPICS_DIR = "planning/epics"; +/** Default recommender state file for a file-mode repo (relative to the repo root). */ +export const DEFAULT_STATE_FILE = ".middle/state.md"; + +/** + * A repo's Epic-store mode (migration 008). `"github"` is the default for every + * existing row; `"file"` carries the repo-root-relative `epicsDir`/`stateFile` + * (defaulted when the columns are null). The bootstrap selector reads this to pick + * the github-backed vs file-backed gateway trio per repo. + */ +export type EpicStoreConfig = + | { mode: "github" } + | { mode: "file"; epicsDir: string; stateFile: string }; + +/** Read a repo's {@link EpicStoreConfig}. Absent row or `epic_store !== 'file'` → github mode. */ +export function readEpicStoreConfig(db: Database, repo: string): EpicStoreConfig { + const row = db + .query("SELECT epic_store, epics_dir, state_file FROM repo_config WHERE repo = ?") + .get(repo) as { + epic_store: string; + epics_dir: string | null; + state_file: string | null; + } | null; + if (!row || row.epic_store !== "file") return { mode: "github" }; + return { + mode: "file", + epicsDir: row.epics_dir ?? DEFAULT_EPICS_DIR, + stateFile: row.state_file ?? DEFAULT_STATE_FILE, + }; +} + +/** + * Set a repo's Epic-store mode + paths (`mm init` for a file-mode repo). Upserts + * like the other config writers, touching only the epic-store columns on conflict. + * Pass `mode: "github"` to clear the file-mode paths back to null. + */ +export function setEpicStoreConfig( + db: Database, + repo: string, + cfg: EpicStoreConfig, + now: number = Date.now(), +): void { + const epicsDir = cfg.mode === "file" ? cfg.epicsDir : null; + const stateFile = cfg.mode === "file" ? cfg.stateFile : null; + db.run( + `INSERT INTO repo_config (repo, config_json, epic_store, epics_dir, state_file, last_synced_at) + VALUES (?, '{}', ?, ?, ?, ?) + ON CONFLICT(repo) DO UPDATE SET epic_store = excluded.epic_store, + epics_dir = excluded.epics_dir, state_file = excluded.state_file, + last_synced_at = excluded.last_synced_at`, + [repo, cfg.mode, epicsDir, stateFile, now], + ); +} + /** When the recommender last ran for a repo (unix-ms), or null if never. */ export function getLastRecommenderRun(db: Database, repo: string): number | null { const row = db.query("SELECT last_recommender_run FROM repo_config WHERE repo = ?").get(repo) as { diff --git a/packages/dispatcher/test/epic-store/file-dispatch-integration.test.ts b/packages/dispatcher/test/epic-store/file-dispatch-integration.test.ts new file mode 100644 index 00000000..a02576c1 --- /dev/null +++ b/packages/dispatcher/test/epic-store/file-dispatch-integration.test.ts @@ -0,0 +1,233 @@ +/** + * Integration: a file-mode dispatch through the real implementation workflow and + * the real `buildImplementationDeps` selector. + * + * Test A drives the actual workflow (real engine + `createWorktree`, stub + * adapter/gate/tmux) for a `epic_store="file"` repo whose agent parks asking a + * question, and asserts the workflow row carries the slug as `epic_ref` AND the + * Epic file gains a re-parseable `` block. + * + * Test B exercises the real `buildImplementationDeps` wiring (no hand-rolled + * postQuestion): the per-repo selector routes the `postQuestion` seam to the + * file-backed writer for a file repo and to the gh comment poster for a github + * repo. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Engine } from "bunqueue/workflow"; +import type { AgentAdapter, HookPayload, StopClassification } from "@middle/core"; +import { buildImplementationDeps } from "../../src/build-deps.ts"; +import { openAndMigrate } from "../../src/db.ts"; +import type { EpicGateway } from "../../src/github.ts"; +import type { SessionGate } from "../../src/hook-server.ts"; +import { registerManagedRepo, setEpicStoreConfig } from "../../src/repo-config.ts"; +import { readEpicFile } from "../../src/epic-store/epic-file-io.ts"; +import { appendQuestion } from "../../src/epic-store/index.ts"; +import { renderEpicFile } from "../../src/epic-store/epic-file/renderer.ts"; +import { getWorkflow } from "../../src/workflow-record.ts"; +import { + createImplementationWorkflow, + type ImplementationDeps, +} from "../../src/workflows/implementation.ts"; +import { createWorktree, destroyWorktree } from "../../src/worktree.ts"; +import type { Database } from "bun:sqlite"; + +const SLUG = "rollout-epic-store"; +const REPO = "o/file-repo"; +const GIT_ENV = { + ...process.env, + GIT_AUTHOR_NAME: "middle-test", + GIT_AUTHOR_EMAIL: "middle-test@example.invalid", + GIT_COMMITTER_NAME: "middle-test", + GIT_COMMITTER_EMAIL: "middle-test@example.invalid", +}; + +async function git(cwd: string, args: string[]): Promise { + const proc = Bun.spawn(["git", "-C", cwd, ...args], { + stdout: "ignore", + stderr: "pipe", + env: GIT_ENV, + }); + if ((await proc.exited) !== 0) { + throw new Error(`git ${args.join(" ")}: ${await new Response(proc.stderr).text()}`); + } +} + +let scratch: string; +let repoPath: string; +let worktreeRoot: string; +let epicsDir: string; +let db: Database; +let engine: Engine; + +beforeEach(async () => { + scratch = realpathSync(mkdtempSync(join(tmpdir(), "middle-fdisp-"))); + repoPath = join(scratch, "repo"); + worktreeRoot = join(scratch, "worktrees"); + await git(scratch, ["init", "repo"]); + await git(repoPath, ["commit", "--allow-empty", "-m", "init"]); + epicsDir = join(repoPath, "planning", "epics"); + mkdirSync(epicsDir, { recursive: true }); + writeFileSync( + join(epicsDir, `${SLUG}.md`), + renderEpicFile({ + title: "feat: file-backed epic store", + meta: { slug: SLUG, adapter: "stub" }, + context: "ctx", + acceptanceCriteria: [{ checked: false, text: "ship" }], + subIssues: [{ id: 1, checked: false, title: "1 — gateways", body: "" }], + conversation: [], + }), + ); + db = openAndMigrate(join(scratch, "db.sqlite3")); + registerManagedRepo(db, REPO, repoPath); + setEpicStoreConfig(db, REPO, { + mode: "file", + epicsDir: "planning/epics", + stateFile: ".middle/state.md", + }); + engine = new Engine({ embedded: true }); +}); + +afterEach(async () => { + await engine.close(true); + db.close(); + rmSync(scratch, { recursive: true, force: true }); +}); + +/** A SessionGate whose Stop wait never resolves — models an agent that hangs. */ +const hangingGate: SessionGate = { + awaitSessionStart: async () => + ({ session_id: "stub", transcript_path: "/tmp/stub.jsonl" }) as HookPayload, + awaitStop: () => new Promise(() => {}), +}; + +/** Adapter that writes a real `.middle/blocked.json` on installHooks → asked-question. */ +function blockedAdapter(): AgentAdapter { + const asked: StopClassification = { + kind: "asked-question", + sentinelPath: "/x/.middle/blocked.json", + sentinel: { question: "Approach A or B?" }, + }; + return { + name: "stub", + readyEvent: "session.started", + async installHooks(opts) { + mkdirSync(join(opts.worktree, ".middle"), { recursive: true }); + writeFileSync( + join(opts.worktree, ".middle", "blocked.json"), + JSON.stringify({ question: "Approach A or B?" }), + ); + }, + buildLaunchCommand: () => ({ argv: ["true"], env: {} }), + buildPromptText: () => "@.middle/prompt.md", + async enterAutoMode() {}, + resolveTranscriptPath: (p) => p.transcript_path as string, + readTranscriptState: () => ({ + lastActivity: "", + contextTokens: 0, + turnCount: 0, + lastToolUse: null, + }), + classifyStop: () => asked, + }; +} + +function makeDeps(over: Partial): ImplementationDeps { + return { + db, + getAdapter: () => blockedAdapter(), + sessionGate: hangingGate, + tmux: { + async newSession() {}, + async sendText() {}, + async sendEnter() {}, + async killSession() {}, + status: async () => ({ alive: false }), + }, + worktree: { createWorktree, destroyWorktree }, + resolveRepoPath: () => repoPath, + worktreeRoot, + dispatcherUrl: "http://127.0.0.1:8822", + launchTimeoutMs: 2000, + stopTimeoutMs: 2000, + livenessPollMs: 20, + enqueueContinuation: async () => { + throw new Error("unexpected continuation"); + }, + // file-mode postQuestion: append a question block to the Epic file (the seam + // the real build-deps wires; exercised directly in Test B below). + postQuestion: async ({ epicRef, question, context, kind }) => { + appendQuestion(epicsDir, epicRef, { question, context, kind }); + }, + ...over, + }; +} + +async function awaitParked(id: string, timeoutMs = 6000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (getWorkflow(db, id)?.state === "waiting-human") return; + await Bun.sleep(20); + } + throw new Error(`workflow ${id} did not park (state '${getWorkflow(db, id)?.state}')`); +} + +describe("file-mode dispatch — Test A: real workflow drive", () => { + test("a file-mode Epic parks asking a question → row carries the slug, Epic file gains a question block", async () => { + engine.register(createImplementationWorkflow(makeDeps({}))); + const handle = await engine.start("implementation", { + repo: REPO, + epicRef: SLUG, + adapter: "stub", + }); + await awaitParked(handle.id); + + // The workflow row carries the slug as epic_ref (and a null numeric epic_number). + const row = getWorkflow(db, handle.id); + expect(row?.epicRef).toBe(SLUG); + expect(row?.epicNumber).toBeNull(); + + // The Epic file gained a re-parseable question block (round-trip survived the write). + const epic = readEpicFile(epicsDir, SLUG); + expect(epic!.conversation).toHaveLength(1); + expect(epic!.conversation[0]).toMatchObject({ kind: "question", status: "open", id: 1 }); + }); +}); + +describe("file-mode dispatch — Test B: real buildImplementationDeps selector", () => { + test("postQuestion routes to the Epic file for a file repo, and to gh for a github repo", async () => { + const ghCalls: Array<{ repo: string; ref: string; body: string }> = []; + const ghStub = { + async postComment(repo: string, ref: string, body: string) { + ghCalls.push({ repo, ref, body }); + }, + } as unknown as EpicGateway; + + const { deps } = await buildImplementationDeps({ + db, + getAdapter: () => blockedAdapter(), + resolveRepoPath: () => repoPath, + worktreeRoot, + enqueueContinuation: async () => {}, + bindServer: () => ({ sessionGate: hangingGate, dispatcherUrl: "http://127.0.0.1:0" }), + github: ghStub, + resolveAgentLogin: async () => "middle-bot", + }); + + // file repo → the question lands in the Epic file; gh is NOT called. + await deps.postQuestion!({ repo: REPO, epicRef: SLUG, question: "A or B?", kind: "question" }); + expect(ghCalls).toHaveLength(0); + const epic = readEpicFile(epicsDir, SLUG); + expect(epic!.conversation[0]).toMatchObject({ kind: "question", body: "A or B?" }); + + // github repo (no config row) → the question is posted via gh. + await deps.postQuestion!({ repo: "o/gh-repo", epicRef: "7", question: "Q?", kind: "question" }); + expect(ghCalls).toHaveLength(1); + expect(ghCalls[0]).toMatchObject({ repo: "o/gh-repo", ref: "7" }); + expect(ghCalls[0]!.body).toContain("Q?"); + }); +}); diff --git a/packages/dispatcher/test/epic-store/selector.test.ts b/packages/dispatcher/test/epic-store/selector.test.ts new file mode 100644 index 00000000..459e3996 --- /dev/null +++ b/packages/dispatcher/test/epic-store/selector.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { openAndMigrate } from "../../src/db.ts"; +import { + appendQuestion, + buildFileGateways, + buildGitHubGateways, + makeRoutingEpicGateway, +} from "../../src/epic-store/index.ts"; +import { ghGitHub } from "../../src/github.ts"; +import { ghPollGateway } from "../../src/poller-gateway.ts"; +import { ghStateIssueGateway } from "../../src/state-issue.ts"; +import { readEpicFile } from "../../src/epic-store/epic-file-io.ts"; +import { renderEpicFile } from "../../src/epic-store/epic-file/renderer.ts"; +import type { EpicFile } from "../../src/epic-store/epic-file/types.ts"; +import { registerManagedRepo, setEpicStoreConfig } from "../../src/repo-config.ts"; +import type { EpicGateway } from "../../src/github.ts"; + +function tmpDir(prefix: string): string { + return mkdtempSync(join(tmpdir(), prefix)); +} + +function seedEpic( + epicsDir: string, + slug: string, + conversation: EpicFile["conversation"] = [], +): void { + writeFileSync( + join(epicsDir, `${slug}.md`), + renderEpicFile({ + title: "feat: x", + meta: { slug }, + context: "ctx", + acceptanceCriteria: [], + subIssues: [], + conversation, + }), + ); +} + +describe("buildGitHubGateways / buildFileGateways", () => { + test("buildGitHubGateways defaults to the real gh-backed trio", () => { + const trio = buildGitHubGateways(); + expect(trio.epicGateway).toBe(ghGitHub); + expect(trio.stateGateway).toBe(ghStateIssueGateway); + expect(trio.pollGateway).toBe(ghPollGateway); + }); + + test("buildFileGateways returns file-backed implementations (not the gh trio)", () => { + const dir = tmpDir("middle-sel-"); + const trio = buildFileGateways({ epicsDir: dir, stateFile: join(dir, "state.md") }); + expect(trio.epicGateway).not.toBe(ghGitHub); + expect(trio.pollGateway).not.toBe(ghPollGateway); + expect(typeof trio.epicGateway.findEpicPr).toBe("function"); + }); +}); + +describe("makeRoutingEpicGateway", () => { + test("routes per-repo: file repo → file backend, github repo → gh backend", async () => { + const scratch = tmpDir("middle-route-"); + const db = openAndMigrate(join(scratch, "db.sqlite3")); + try { + const repoDir = join(scratch, "repo"); + const epicsDir = join(repoDir, "planning", "epics"); + // file-mode repo with one Epic on disk + mkdirSync(epicsDir, { recursive: true }); + seedEpic(epicsDir, "rollout", []); + registerManagedRepo(db, "o/file", repoDir); + setEpicStoreConfig(db, "o/file", { + mode: "file", + epicsDir: "planning/epics", + stateFile: ".middle/state.md", + }); + // github-mode repo: default config, gh backend recorded via a stub + let ghLabelsCalled = 0; + const ghStub = { + ...ghGitHub, + async getIssueLabels() { + ghLabelsCalled += 1; + return ["gh-label"]; + }, + } as unknown as EpicGateway; + + const router = makeRoutingEpicGateway({ + db, + resolveRepoPath: () => repoDir, + ghEpic: ghStub, + }); + + // file repo → reads the Epic file's meta (no labels set → []) + expect(await router.getIssueLabels("o/file", "rollout")).toEqual([]); + expect(ghLabelsCalled).toBe(0); // gh backend not consulted for a file repo + + // github repo (no config row) → delegates to the gh backend + expect(await router.getIssueLabels("o/github", "7")).toEqual(["gh-label"]); + expect(ghLabelsCalled).toBe(1); + } finally { + db.close(); + } + }); +}); + +describe("appendQuestion", () => { + test("appends an open question block that re-parses; ids increment", () => { + const dir = tmpDir("middle-q-"); + seedEpic(dir, "rollout", []); + appendQuestion(dir, "rollout", { + question: "A or B?", + context: "some context", + kind: "question", + now: () => new Date("2026-06-03T00:00:00.000Z"), + }); + let epic = readEpicFile(dir, "rollout")!; + expect(epic.conversation).toEqual([ + { + kind: "question", + id: 1, + status: "open", + ts: "2026-06-03T00:00:00.000Z", + questionKind: "question", + body: "A or B?\n\nsome context", + }, + ]); + + appendQuestion(dir, "rollout", { + question: "more?", + kind: "complexity", + now: () => new Date("2026-06-03T01:00:00.000Z"), + }); + epic = readEpicFile(dir, "rollout")!; + expect(epic.conversation).toHaveLength(2); + expect(epic.conversation[1]).toMatchObject({ + id: 2, + questionKind: "complexity", + body: "more?", + }); + }); + + test("throws a clear error when the Epic file is absent", () => { + const dir = tmpDir("middle-q2-"); + expect(() => appendQuestion(dir, "nope", { question: "q", kind: "question" })).toThrow( + /no Epic file for slug/, + ); + }); +}); diff --git a/planning/issues/190/decisions.md b/planning/issues/190/decisions.md index a7d31daa..848850a4 100644 --- a/planning/issues/190/decisions.md +++ b/planning/issues/190/decisions.md @@ -110,3 +110,35 @@ watcher work. Returning null is non-throwing and honest; question-resume (the Ph is unaffected. **Evidence:** spec "Phase plan" (Phase 1 vs 2); spec poll-gateway table ("delegate to gh"); `poller-gateway.ts` `Closes #${epicNumber}` search. + +## Per-repo selection is a routing gateway, not a per-repo deps build +**File(s):** `packages/dispatcher/src/epic-store/index.ts`, `build-deps.ts` +**Date:** 2026-06-03 + +**Decision:** The daemon builds ONE `ImplementationDeps` and registers ONE workflow +(unchanged). Per-repo mode is implemented as a **routing `EpicGateway`** +(`makeRoutingEpicGateway`) that reads `repo_config` per call and delegates to the +repo's file or gh backend, keyed on the method's `repo` arg. `build-deps` defaults +`github`/`planCommentReader` to the router and routes `postQuestion` by mode +(file → `appendQuestion`, github → `formatPauseComment` via gh). +**Why:** The spec's "buildImplementationDeps picks the trio" reads as a single +selection, but the daemon serves many repos through one registration — so the +selection must happen per-call. Every gateway method already takes `repo` first, so +a router is the minimal, interface-preserving way to run github repo A and file repo +B under one daemon. An injected `args.github`/`args.postQuestion` still overrides +(tests). github-mode repos route to `ghGitHub`, so behavior is byte-identical. +**Evidence:** spec "Architecture" ("daemon runs both modes simultaneously"); `main.ts` +registers one workflow with one deps. + +## `/control/dispatch` accepts a string `epicRef` (file slug) or numeric `epicNumber` +**File(s):** `packages/dispatcher/src/hook-server.ts` +**Date:** 2026-06-03 + +**Decision:** The control endpoint now accepts either a non-empty string `epicRef` +(file mode) or an integer `epicNumber` ≥ 1 (github mode, stringified), building the +string-keyed `ControlDispatchInput.epicRef` from whichever is present. +**Why:** A file-mode dispatch references a slug, which the prior numeric-only +validation rejected. This makes the dispatch entry mode-agnostic so #193's selector +is reachable end-to-end; the CLI sends the right field in #194. Existing numeric +clients are unaffected (the github branch is unchanged). +**Evidence:** #193 integration criterion (HTTP dispatch with a file-mode slug). From b9b0fe8afd09d892e1c50b5c31e516c3bec6fb6d Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Wed, 3 Jun 2026 05:01:33 -0400 Subject: [PATCH 05/10] =?UTF-8?q?feat(cli):=20mm=20init/dispatch/doctor/re?= =?UTF-8?q?sume=20=E2=80=94=20file-mode=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close #194. User-facing CLI surface for file mode. - `mm init --epic-store=file `: scaffolds `planning/epics/{README.md,.keep}`, `.middle/state.md` (v1 marker, round-trips), and `.middle/.toml` with `[epic_store] mode="file"`, and records the mode in `repo_config` — zero gh calls in the file path; github mode unchanged. (bootstrap `file-store.ts` + `initRepo` mode branch + `--epic-store` flag.) - `mm dispatch ` / `--epic `: accepts a slug or a number; a slug skips the gh label fetch and POSTs `epicRef`; numeric refs unchanged. - `mm doctor`: reads the cwd repo's `repo_config.epic_store` — github runs the state-issue check, file skips it and adds an `epics_dir exists` check. - `mm resume --answer ""`: new answer-resume — POSTs the new `/control/resume` endpoint; the daemon (`control.resume`) finds the parked workflow by `(repo, epicRef)` and fires its resume signal. `mm resume ` still clears the pause. - `/control/dispatch` accepts a numeric `epicNumber` or a string `epicRef`; `findParkedWorkflowByRef` backs the resume lookup. - Tests: file-mode init scaffold (zero gh), mode-aware doctor, slug dispatch, `/control/resume` route (200/404/400), `findParkedWorkflowByRef`, and a scripted CLI smoke (slug dispatch → workflow row with `epic_ref=`, file mode selected). typecheck/lint/format clean; full suite green (1222). --- packages/cli/src/bootstrap/deps.ts | 10 + packages/cli/src/bootstrap/file-store.ts | 157 ++++++++++++++ packages/cli/src/bootstrap/index.ts | 11 + packages/cli/src/bootstrap/init.ts | 92 +++++--- packages/cli/src/bootstrap/types.ts | 23 +- packages/cli/src/commands/dispatch.ts | 30 ++- packages/cli/src/commands/doctor.ts | 83 +++++++- packages/cli/src/commands/init.ts | 61 +++++- packages/cli/src/commands/resume-answer.ts | 71 +++++++ packages/cli/src/index.ts | 76 ++++++- packages/cli/test/bootstrap-init.test.ts | 1 + packages/cli/test/dispatch.test.ts | 34 ++- packages/cli/test/doctor.test.ts | 79 ++++++- packages/cli/test/file-mode-smoke.test.ts | 133 ++++++++++++ packages/cli/test/init-file-store.test.ts | 201 ++++++++++++++++++ packages/cli/test/init-register.test.ts | 1 + packages/dispatcher/src/hook-server.ts | 55 +++++ packages/dispatcher/src/main.ts | 16 ++ packages/dispatcher/src/workflow-record.ts | 22 ++ .../dispatcher/test/control-routes.test.ts | 47 ++++ .../dispatcher/test/workflow-record.test.ts | 23 ++ planning/issues/190/decisions.md | 31 +++ 22 files changed, 1187 insertions(+), 70 deletions(-) create mode 100644 packages/cli/src/bootstrap/file-store.ts create mode 100644 packages/cli/src/commands/resume-answer.ts create mode 100644 packages/cli/test/file-mode-smoke.test.ts create mode 100644 packages/cli/test/init-file-store.test.ts diff --git a/packages/cli/src/bootstrap/deps.ts b/packages/cli/src/bootstrap/deps.ts index 1842a0aa..80c9becc 100644 --- a/packages/cli/src/bootstrap/deps.ts +++ b/packages/cli/src/bootstrap/deps.ts @@ -168,6 +168,16 @@ export const realDeps: BootstrapDeps = { return { owner: slug.owner, name: slug.name, defaultBranch }; }, + async resolveRepoInfoLocal(repo: string): Promise { + // File mode is offline — derive owner/name from the local `origin` URL and + // never shell out to `gh`. The default branch isn't knowable without GitHub, + // so it falls back to "main" (file mode doesn't depend on it). + const url = (await this.getRemoteUrl(repo)) ?? ""; + const slug = parseRepoSlug(url); + if (!slug) throw new Error(`could not parse owner/name from origin remote: "${url}"`); + return { owner: slug.owner, name: slug.name, defaultBranch: "main" }; + }, + github: realGithub, now: () => new Date(), }; diff --git a/packages/cli/src/bootstrap/file-store.ts b/packages/cli/src/bootstrap/file-store.ts new file mode 100644 index 00000000..4bc13bfc --- /dev/null +++ b/packages/cli/src/bootstrap/file-store.ts @@ -0,0 +1,157 @@ +// File-mode Epic-store scaffolding for `mm init --epic-store=file`. Writes the +// local Epic directory + recommender state file + per-repo Epic-store config a +// file-mode repo needs, with ZERO `gh`/GitHub calls. The github-mode path is +// untouched — this module is only reached when `epicStore === "file"`. + +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { renderStateIssue } from "@middle/state-issue"; +import type { ParsedState } from "@middle/state-issue"; +import { DEFAULT_EPICS_DIR, DEFAULT_STATE_FILE } from "@middle/dispatcher/src/repo-config.ts"; +import type { RepoInfo } from "./types.ts"; + +/** Default Epic directory + state file a file-mode repo scaffolds (repo-root relative). */ +export const FILE_EPICS_DIR = DEFAULT_EPICS_DIR; +export const FILE_STATE_FILE = DEFAULT_STATE_FILE; + +/** + * The repo-slug stem used to name the per-repo Epic-store config file + * (`.middle/.toml`). A slug can't be a path segment (`owner/name` + * contains a `/`), so it's flattened to `owner-name`. + */ +export function repoSlugStem(info: RepoInfo): string { + return `${info.owner}-${info.name}`; +} + +/** + * Render a minimal, schema-conforming empty recommender state body — the + * file-mode equivalent of the state issue's initial body. It carries the + * state-issue v1 marker and round-trips byte-identically through + * `parseStateIssue`/`renderStateIssue`, so the dispatcher can edit it + * section-by-section like a GitHub state issue. + */ +export function renderEmptyStateBody(now: Date): string { + const empty: ParsedState = { + version: 1, + generated: now.toISOString(), + runId: "init", + intervalMinutes: 30, + readyToDispatch: [], + needsHumanInput: [], + blocked: [], + inFlight: [], + excluded: [], + rateLimits: { claude: "ok", codex: "ok", github: "ok" }, + slotUsage: { adapters: [], total: { used: 0, max: 0 }, global: { used: 0, max: 0 } }, + }; + return renderStateIssue(empty); +} + +/** + * The `planning/epics/README.md` explainer — a one-screen description of + * file-mode Epics plus a copy-paste Epic-file template. The template is a + * valid v1 Epic body (parses with `parseEpicFile`), so a human can author a + * first Epic by copying it. + */ +export function renderEpicsReadme(): string { + return `# Epics (file mode) + +This repo runs middle-management in **file mode**: each Epic is a Markdown file +in this directory instead of a GitHub issue. The recommender ranks the open +Epics here; \`mm dispatch\` runs an agent against one. + +- One file per Epic: \`.md\` (the filename stem is the Epic's \`slug\`). +- The recommender's dispatch state lives in \`${FILE_STATE_FILE}\` (not a GitHub issue). +- Markers (\`\`) are the structural contract — write your prose + *between* markers; the dispatcher owns the marker attribute lines. + +## Epic file template + +Copy this into \`.md\` and fill it in: + +\`\`\`md + +# Short Epic title + + + +## Context + +Why this Epic exists and what "done" looks like. + +## Acceptance criteria + +- [ ] First observable, testable outcome +- [ ] Second observable, testable outcome + +## Sub-issues + + +- [ ] **1 — First phase** + What this phase delivers. + + + + +\`\`\` +`; +} + +export type FileStoreScaffoldOptions = { + /** Absolute path to the target repo checkout. */ + repo: string; + /** Resolved repo identity (used to name the per-repo config file). */ + info: RepoInfo; + /** Clock seam for the state body's `generated` timestamp. */ + now: Date; +}; + +/** Absolute paths of the four files the file-mode scaffold writes. */ +export type FileStorePaths = { + epicsReadme: string; + epicsKeep: string; + stateFile: string; + configToml: string; +}; + +/** The absolute paths the file-mode scaffold targets for a given repo. */ +export function fileStorePaths(repo: string, info: RepoInfo): FileStorePaths { + return { + epicsReadme: join(repo, FILE_EPICS_DIR, "README.md"), + epicsKeep: join(repo, FILE_EPICS_DIR, ".keep"), + stateFile: join(repo, FILE_STATE_FILE), + configToml: join(repo, ".middle", `${repoSlugStem(info)}.toml`), + }; +} + +/** The `[epic_store]` config block for a file-mode repo. */ +export function renderEpicStoreToml(): string { + return `[epic_store] +mode = "file" +epics_dir = "${FILE_EPICS_DIR}" +state_file = "${FILE_STATE_FILE}" +`; +} + +/** + * Write the file-mode scaffold: `planning/epics/README.md` + `.keep`, the empty + * recommender state file, and the per-repo `[epic_store]` config TOML. Pure + * filesystem work — never touches `gh`/GitHub. Returns the paths written. + */ +export async function writeFileStoreScaffold( + opts: FileStoreScaffoldOptions, +): Promise { + const paths = fileStorePaths(opts.repo, opts.info); + await mkdir(join(opts.repo, FILE_EPICS_DIR), { recursive: true }); + await mkdir(join(opts.repo, ".middle"), { recursive: true }); + await Promise.all([ + Bun.write(paths.epicsReadme, renderEpicsReadme()), + Bun.write(paths.epicsKeep, ""), + Bun.write(paths.stateFile, renderEmptyStateBody(opts.now)), + Bun.write(paths.configToml, renderEpicStoreToml()), + ]); + return paths; +} diff --git a/packages/cli/src/bootstrap/index.ts b/packages/cli/src/bootstrap/index.ts index 7fae1837..46a15b0e 100644 --- a/packages/cli/src/bootstrap/index.ts +++ b/packages/cli/src/bootstrap/index.ts @@ -10,6 +10,7 @@ * - `realDeps` — the production dependency bundle (fs + git + gh) * - `buildInitialStateIssueBody` — state-issue content template * - `renderRepoPolicy` / `renderLocalConfig` — the committed-policy / local-cache templates + * - `writeFileStoreScaffold` + `render*` — file-mode Epic-store scaffolding (#194) * - bootstrap types + constants (`BOOTSTRAP_VERSION`, `STATE_ISSUE_TITLE`, …) * * Where things live: @@ -18,6 +19,7 @@ * - `assets.ts`, `hook-config.ts`, `gitignore.ts` — what gets stamped * - `skills-sync.ts` — the canonical↔mirror skills invariant * - `state-issue-body.ts`, `config-template.ts` — generated content + * - `file-store.ts` — file-mode Epic-store scaffold (README/.keep/state.md/toml) * - `types.ts` — shared bootstrap types + constants * * Gotchas: @@ -32,9 +34,18 @@ export { uninitRepo } from "./uninit.ts"; export { realDeps } from "./deps.ts"; export { buildInitialStateIssueBody } from "./state-issue-body.ts"; export { renderLocalConfig, renderRepoPolicy } from "./config-template.ts"; +export { + FILE_EPICS_DIR, + FILE_STATE_FILE, + renderEmptyStateBody, + renderEpicsReadme, + renderEpicStoreToml, + writeFileStoreScaffold, +} from "./file-store.ts"; export type { BootstrapDeps, BootstrapOptions, + EpicStoreMode, GithubGateway, InitResult, RepoInfo, diff --git a/packages/cli/src/bootstrap/init.ts b/packages/cli/src/bootstrap/init.ts index 9c1e4114..53fc86d4 100644 --- a/packages/cli/src/bootstrap/init.ts +++ b/packages/cli/src/bootstrap/init.ts @@ -7,10 +7,12 @@ import { renderLocalConfig, renderRepoPolicy } from "./config-template.ts"; import { addMiddleIgnore } from "./gitignore.ts"; import { writeClaudeHookSettings, writeCodexHookConfig } from "./hook-config.ts"; import { buildInitialStateIssueBody } from "./state-issue-body.ts"; +import { writeFileStoreScaffold } from "./file-store.ts"; import { BOOTSTRAP_VERSION, type BootstrapDeps, type BootstrapOptions, + type EpicStoreMode, type InitResult, STATE_ISSUE_TITLE, type RepoInfo, @@ -42,7 +44,11 @@ function readExistingConfig(repo: string): ExistingConfig | null { return { version, stateIssueNumber: number }; } -async function validateTarget(repo: string, deps: BootstrapDeps): Promise { +async function validateTarget( + repo: string, + deps: BootstrapDeps, + epicStore: EpicStoreMode, +): Promise { if (!existsSync(join(repo, ".git"))) { throw new BootstrapError(`"${repo}" is not a git repository`); } @@ -52,6 +58,12 @@ async function validateTarget(repo: string, deps: BootstrapDeps): Promise { - const info = await validateTarget(repo, deps); + const epicStore: EpicStoreMode = opts.epicStore ?? "github"; + const info = await validateTarget(repo, deps, epicStore); const existing = readExistingConfig(repo); const mode: InitResult["mode"] = existing === null ? "fresh" : existing.version === BOOTSTRAP_VERSION ? "reinit" : "migrate"; @@ -92,37 +105,56 @@ export async function initRepo( await writeCodexHookConfig(repo, hookScriptPath); } - // Steps 5-6: resolve the state issue. A local config number is trusted as-is. - // Otherwise (fresh install, or a config carrying no usable number) the labeled - // GitHub issue is the source of truth — reconcile against it before creating, - // so a second machine / fresh clone reuses the repo's existing state issue - // instead of filing a duplicate. config.toml only caches the number. + // Steps 5-6: resolve the recommender state. + // + // File mode (#194): the recommender state + Epics live in local files, not a + // GitHub issue — so this path makes ZERO `gh`/GitHub calls. Scaffold the Epic + // directory (README + .keep), an empty state file carrying the state-issue v1 + // marker, and the per-repo `[epic_store]` config TOML; the state issue number + // stays 0. let stateIssue = existing?.stateIssueNumber ?? 0; - const needsStateIssue = stateIssue <= 0; - if (needsStateIssue) { - if (dry) { - note("reconcile against GitHub: reuse the existing agent-queue:state issue, else create one"); - } else { - const found = await deps.github.findStateIssues(info); - if (found.length > 0) { - stateIssue = found[0]!; - note(`reuse existing state issue #${stateIssue} (found on GitHub)`); - if (found.length > 1) { - note( - `WARNING: ${found.length} open state issues found (#${found.join(", #")}); ` + - `reusing the oldest (#${stateIssue}) — close the duplicates`, - ); - } + let needsStateIssue = false; + if (epicStore === "file") { + note(`scaffold .middle/${info.owner}-${info.name}.toml ([epic_store] mode=file)`); + note("scaffold planning/epics/ (README.md + .keep)"); + note("scaffold .middle/state.md (empty recommender state, v1 marker)"); + if (!dry) { + await writeFileStoreScaffold({ repo, info, now: deps.now() }); + } + } else { + // A local config number is trusted as-is. Otherwise (fresh install, or a + // config carrying no usable number) the labeled GitHub issue is the source of + // truth — reconcile against it before creating, so a second machine / fresh + // clone reuses the repo's existing state issue instead of filing a duplicate. + // config.toml only caches the number. + needsStateIssue = stateIssue <= 0; + if (needsStateIssue) { + if (dry) { + note( + "reconcile against GitHub: reuse the existing agent-queue:state issue, else create one", + ); } else { - await deps.github.ensureStateLabel(info); - const body = buildInitialStateIssueBody(deps.now()); - stateIssue = await deps.github.createStateIssue(info, STATE_ISSUE_TITLE, body); - note("create the agent-queue:state label (if absent)"); - note("create the state issue and capture its number"); + const found = await deps.github.findStateIssues(info); + if (found.length > 0) { + stateIssue = found[0]!; + note(`reuse existing state issue #${stateIssue} (found on GitHub)`); + if (found.length > 1) { + note( + `WARNING: ${found.length} open state issues found (#${found.join(", #")}); ` + + `reusing the oldest (#${stateIssue}) — close the duplicates`, + ); + } + } else { + await deps.github.ensureStateLabel(info); + const body = buildInitialStateIssueBody(deps.now()); + stateIssue = await deps.github.createStateIssue(info, STATE_ISSUE_TITLE, body); + note("create the agent-queue:state label (if absent)"); + note("create the state issue and capture its number"); + } } + } else { + note(`keep existing state issue #${stateIssue}`); } - } else { - note(`keep existing state issue #${stateIssue}`); } // Committed repo policy (issue #103). Written ONLY when absent — a re-init or @@ -160,5 +192,5 @@ export async function initRepo( note("add .middle/ to .gitignore"); if (!dry) await addMiddleIgnore(repo); - return { dryRun: dry, mode, info, stateIssue, actions }; + return { dryRun: dry, mode, info, epicStore, stateIssue, actions }; } diff --git a/packages/cli/src/bootstrap/types.ts b/packages/cli/src/bootstrap/types.ts index 534fb0b5..1196331c 100644 --- a/packages/cli/src/bootstrap/types.ts +++ b/packages/cli/src/bootstrap/types.ts @@ -44,15 +44,30 @@ export type BootstrapDeps = { getRemoteUrl(repo: string): Promise; /** True iff `gh` is authenticated. */ isGhAuthenticated(): Promise; - /** Resolve owner/name/defaultBranch for the repo. */ + /** Resolve owner/name/defaultBranch for the repo (uses `gh repo view`). */ resolveRepoInfo(repo: string): Promise; + /** + * Resolve owner/name for the repo from the local `origin` remote only — no + * `gh` call. Used by the file-mode init path, which stays fully offline; the + * default branch falls back to `"main"` since GitHub isn't consulted. + */ + resolveRepoInfoLocal(repo: string): Promise; github: GithubGateway; /** Clock seam — the state-issue `generated` timestamp and `installed_at`. */ now(): Date; }; +/** Where a repo's Epics + recommender state live: GitHub issues, or local files. */ +export type EpicStoreMode = "github" | "file"; + export type BootstrapOptions = { dryRun: boolean; + /** + * Epic-store mode for this repo (#194). `"github"` (default) is today's + * behavior — a labeled state issue + GitHub-issue Epics. `"file"` scaffolds a + * local Epic directory + state file and makes ZERO `gh`/GitHub calls. + */ + epicStore?: EpicStoreMode; }; /** What `mm init` did (or, under `--dry-run`, would do). */ @@ -61,6 +76,12 @@ export type InitResult = { /** "fresh", "reinit" (matching version), or "migrate" (differing version). */ mode: "fresh" | "reinit" | "migrate"; info: RepoInfo; + /** The Epic-store mode this init wrote (`"github"` or `"file"`). */ + epicStore: EpicStoreMode; + /** + * The resolved state issue number — `0` in file mode (the recommender state + * lives in a local file, not a GitHub issue). + */ stateIssue: number; /** Human-readable lines describing each performed/planned action. */ actions: string[]; diff --git a/packages/cli/src/commands/dispatch.ts b/packages/cli/src/commands/dispatch.ts index 6259095a..307a7cf1 100644 --- a/packages/cli/src/commands/dispatch.ts +++ b/packages/cli/src/commands/dispatch.ts @@ -259,9 +259,22 @@ export async function runDispatch( epicArg: string, opts: DispatchOptions = {}, ): Promise { - const epicNumber = Number(epicArg); - if (!Number.isInteger(epicNumber) || epicNumber < 1) { - console.error(`mm dispatch: invalid epic number "${epicArg}"`); + // The Epic reference is a slug (file mode) OR a numeric issue number (github + // mode). A numeric ref additionally drives the `agent:` label lookup via + // gh; a slug skips gh entirely (file-mode Epics carry their adapter in the + // file's meta, which the daemon reads). + const epicRef = epicArg.trim(); + const isNumeric = /^\d+$/.test(epicRef); + if (epicRef === "") { + console.error(`mm dispatch: missing epic (pass a slug or an issue number)`); + return 1; + } + // A digit-leading ref must be a whole issue number ≥ 1 (the github contract); a + // non-digit-leading ref is a file-mode slug and is accepted as-is. + if (/^\d/.test(epicRef) && (!isNumeric || Number(epicRef) < 1)) { + console.error( + `mm dispatch: invalid epic "${epicArg}" (a numeric ref must be a whole number ≥ 1)`, + ); return 1; } if (!existsSync(join(repoPath, ".git"))) { @@ -302,9 +315,10 @@ export async function runDispatch( } adapterName = opts.adapter; } else { - const labels = await (opts.fetchLabels ?? fetchEpicLabels)(repoSlug, epicNumber).catch( - () => [], - ); + // Only a numeric (github) ref has GitHub labels to consult; a file slug skips gh. + const labels = isNumeric + ? await (opts.fetchLabels ?? fetchEpicLabels)(repoSlug, Number(epicRef)).catch(() => []) + : []; try { adapterName = selectAdapter({ labels, @@ -358,7 +372,7 @@ export async function runDispatch( body: JSON.stringify({ repo: repoSlug, repoPath: resolve(repoPath), - epicNumber, + epicRef, adapter: adapterName, }), signal: ac.signal, @@ -382,7 +396,7 @@ export async function runDispatch( return 1; } - console.log(`mm dispatch: ${repoSlug} epic #${epicNumber} → workflow ${workflowId}`); + console.log(`mm dispatch: ${repoSlug} epic ${epicRef} → workflow ${workflowId}`); return await followWorkflow( base, reader, diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 5f1472eb..f392c3b6 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -1,15 +1,16 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { isAbsolute, join } from "node:path"; import { loadConfig, type MiddleConfig } from "@middle/core"; import { currentSchemaVersion, openDb } from "@middle/dispatcher/src/db.ts"; +import { type EpicStoreConfig, readEpicStoreConfig } from "@middle/dispatcher/src/repo-config.ts"; import { collectRetentionStatus, type RetentionStatus } from "@middle/dispatcher/src/retention.ts"; import { getTmuxVersion, MIN_TMUX_VERSION, tmuxVersionAtLeast, } from "@middle/dispatcher/src/tmux.ts"; -import { defaultPidFile } from "../paths.ts"; +import { defaultPidFile, deriveRepoSlug } from "../paths.ts"; import { BOOTSTRAP_SKILLS_DIR, CANONICAL_SKILLS_DIR, @@ -400,19 +401,87 @@ function checkDatabase(config: MiddleConfig | null): Check { } } +/** + * Best-effort read of the cwd repo's Epic-store mode from `repo_config`. Opens + * the db read-only and reads {@link readEpicStoreConfig} for the cwd repo's slug. + * Any failure — no db file, db unreadable, slug underivable — resolves to github + * mode (the historical default), so a partially-configured machine still runs + * the state-issue check rather than erroring. Never throws. + */ +async function resolveEpicStore( + config: MiddleConfig | null, + opts: DoctorOptions, +): Promise { + const dbPath = config?.global.dbPath ?? join(homedir(), ".middle", "db.sqlite3"); + if (!existsSync(dbPath)) return { mode: "github" }; + let db: ReturnType | null = null; + try { + const repoPath = opts.repoPath ?? process.cwd(); + const slug = await (opts.resolveSlug ?? deriveRepoSlug)(repoPath); + db = openDb(dbPath); + return readEpicStoreConfig(db, slug); + } catch { + return { mode: "github" }; + } finally { + db?.close(); + } +} + +/** + * The state-store check row, chosen by the cwd repo's Epic-store mode. File-mode + * repos keep no GitHub state issue, so the parser round-trip check is irrelevant; + * instead we confirm the configured Epic directory exists under the repo + * (`✓ epics_dir exists` / `✗ … missing`). GitHub-mode (and any repo whose + * mode can't be determined) keeps the existing repo-agnostic `state-issue` check. + */ +function checkEpicStore(store: EpicStoreConfig, opts: DoctorOptions): Check { + if (store.mode !== "file") { + const stateIssue = checkStateIssue(); + return { name: "state-issue", status: stateIssue.status, detail: stateIssue.detail }; + } + const repoPath = opts.repoPath ?? process.cwd(); + const absDir = isAbsolute(store.epicsDir) ? store.epicsDir : join(repoPath, store.epicsDir); + if (existsSync(absDir)) { + return { name: "epics_dir", status: "pass", detail: `${store.epicsDir} exists` }; + } + return { + name: "epics_dir", + status: "fail", + detail: `${store.epicsDir} missing — run \`mm init --epic-store=file\``, + }; +} + +/** + * Overrides for {@link runDoctor} — the repo checkout to resolve the Epic-store + * mode against and the slug derivation, so a test can redirect them away from the + * live cwd and git remote. All optional; the defaults are `process.cwd()` and the + * git-remote derivation. + */ +export type DoctorOptions = { + /** Run `--fix` actions (PATH repair) after reporting. */ + fix?: boolean; + /** The repo checkout whose Epic-store mode is resolved (defaults to `process.cwd()`). */ + repoPath?: string; + /** Resolve the repo's `owner/name` slug (defaults to the git-remote derivation). */ + resolveSlug?: (repoPath: string) => Promise; +}; + /** * `mm doctor` — full operator health check. Validates the toolchain every * dispatch shells out to (`bun`, `tmux` ≥ 3.5, each configured adapter's binary * — e.g. `claude`, `codex` — `git`, `gh`, and `gh` auth), that config parses, - * the dispatcher is reachable, the state-issue parser still round-trips against - * its v1 schema, and reports SQLite row counts + recent retention status — plus + * the dispatcher is reachable, the cwd repo's Epic store is sound (the + * state-issue parser round-trips against its v1 schema for a github-mode repo, + * or the configured Epic directory exists for a file-mode repo), and reports + * SQLite row counts + recent retention status — plus * the repo's skills/docs-convention drift warnings. Exits 0 when no check fails; * 1 if anything is missing or broken. Warnings (degraded but functional — a * missing adapter binary among others present) do not fail the run. */ -export async function runDoctor({ fix }: { fix?: boolean } = {}): Promise { +export async function runDoctor(opts: DoctorOptions = {}): Promise { + const { fix } = opts; const { check: configCheck, config } = loadDoctorConfig(); - const stateIssue = checkStateIssue(); + const epicStore = await resolveEpicStore(config, opts); const checks: Check[] = [ await checkBinary("bun", ["bun", "--version"]), await checkBunPath(), @@ -423,7 +492,7 @@ export async function runDoctor({ fix }: { fix?: boolean } = {}): Promise void; + /** + * Persist the repo's Epic-store mode to the daemon db (#194) — wired by the CLI + * entry to `setEpicStoreConfig`; injectable for tests. Called for every mode + * (including `"github"`) so a re-init can flip the mode. Best-effort, like + * `registerRepo`. Omitted → no write (e.g. unit tests that don't assert it). + */ + setEpicStore?: (repo: string, cfg: EpicStoreRegistration) => void; }; /** @@ -22,26 +46,33 @@ export async function runInit(pathArg: string, opts: InitCliOptions = {}): Promi const repo = resolve(pathArg); const deps = opts.deps ?? realDeps; try { - const result = await initRepo(repo, deps, { dryRun: opts.dryRun ?? false }); + const result = await initRepo(repo, deps, { + dryRun: opts.dryRun ?? false, + epicStore: opts.epicStore ?? "github", + }); const slug = `${result.info.owner}/${result.info.name}`; if (result.dryRun) { - console.log(`mm init (dry run) — ${slug} [${result.mode}]\n`); + console.log(`mm init (dry run) — ${slug} [${result.mode}, ${result.epicStore}]\n`); for (const action of result.actions) console.log(` • ${action}`); console.log("\nno changes made."); return 0; } - const issueLine = - result.mode === "fresh" - ? `state issue created: #${result.stateIssue}` - : `state issue: #${result.stateIssue} (kept)`; console.log( `✓ middle initialized for ${slug}${result.mode === "fresh" ? "" : ` [${result.mode}]`}`, ); console.log(" skills installed at .claude/skills/, .codex/skills/"); console.log(" hook script at .middle/hooks/hook.sh"); - console.log(` ${issueLine}`); + if (result.epicStore === "file") { + console.log(` epic store: file (Epics in ${FILE_EPICS_DIR}/, state in ${FILE_STATE_FILE})`); + } else { + const issueLine = + result.mode === "fresh" + ? `state issue created: #${result.stateIssue}` + : `state issue: #${result.stateIssue} (kept)`; + console.log(` ${issueLine}`); + } console.log(" config: .middle/config.toml"); console.log(` auto-dispatch: OFF (enable with \`mm config ${slug} auto_dispatch true\`)`); @@ -53,6 +84,20 @@ export async function runInit(pathArg: string, opts: InitCliOptions = {}): Promi } catch (error) { console.error(` (note: managed-repo registry write skipped — ${(error as Error).message})`); } + + // Persist the Epic-store mode to the daemon db (#194) so the bootstrap + // selector routes this repo to the file- or gh-backed gateways. Best-effort, + // for the same reason as the managed-repo registry write above. + try { + opts.setEpicStore?.( + slug, + result.epicStore === "file" + ? { mode: "file", epicsDir: FILE_EPICS_DIR, stateFile: FILE_STATE_FILE } + : { mode: "github" }, + ); + } catch (error) { + console.error(` (note: epic-store config write skipped — ${(error as Error).message})`); + } return 0; } catch (error) { console.error(`mm init: ${(error as Error).message}`); diff --git a/packages/cli/src/commands/resume-answer.ts b/packages/cli/src/commands/resume-answer.ts new file mode 100644 index 00000000..cd217b66 --- /dev/null +++ b/packages/cli/src/commands/resume-answer.ts @@ -0,0 +1,71 @@ +import { loadConfig } from "@middle/core"; +import { deriveRepoSlug } from "../paths.ts"; + +export type ResumeAnswerOptions = { + /** Override the global config path (defaults to `~/.middle/config.toml`). */ + configPath?: string; + /** Injectable repo-slug resolver (defaults to the git-remote derivation). */ + resolveSlug?: (repoPath: string) => Promise; +}; + +/** + * `mm resume --answer ""` — manually unblock a parked Epic by + * POSTing the answer to the daemon's `/control/resume`. The daemon looks up the + * `waiting-human` workflow by `(repo, epicRef)` and fires its resume signal with + * the answer text. Works in both modes (the lookup is by `epic_ref`, a slug or a + * stringified number). The Phase-1 escape hatch before the file-watcher lands. + * + * Returns a process exit code: 0 on a resumed workflow, 1 on a bad ref, an + * unreachable daemon, or a 404 (no parked workflow owns the ref). + */ +export async function runResumeAnswer( + repoPath: string, + epicArg: string, + answer: string, + opts: ResumeAnswerOptions = {}, +): Promise { + const epicRef = epicArg.trim(); + if (epicRef === "") { + console.error("mm resume: missing epic (pass the slug or issue number to resume)"); + return 1; + } + if (answer.trim() === "") { + console.error("mm resume: --answer must be a non-empty string"); + return 1; + } + + let config: ReturnType; + try { + config = loadConfig({ globalPath: opts.configPath }); + } catch (error) { + console.error(`mm resume: failed to load config — ${(error as Error).message}`); + return 1; + } + + const repo = await (opts.resolveSlug ?? deriveRepoSlug)(repoPath); + const base = `http://127.0.0.1:${config.global.dispatcherPort}`; + + try { + const res = await fetch(`${base}/control/resume`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ repo, epicRef, answer }), + }); + if (res.status === 404) { + console.error(`mm resume: no parked workflow for Epic ${epicRef} in ${repo}`); + return 1; + } + if (!res.ok) { + const detail = await res.text().catch(() => ""); + console.error(`mm resume: rejected (${res.status})${detail ? ` — ${detail}` : ""}`); + return 1; + } + const body = (await res.json().catch(() => null)) as { workflowId?: unknown } | null; + const workflowId = typeof body?.workflowId === "string" ? body.workflowId : "(unknown)"; + console.log(`mm resume: ${repo} epic ${epicRef} → resumed workflow ${workflowId}`); + return 0; + } catch (error) { + console.error(`mm resume: could not reach dispatcher on ${base} — ${(error as Error).message}`); + return 1; + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 04d36e32..4a47c446 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -31,8 +31,8 @@ import { runDocs } from "./commands/docs.ts"; import { runDoctor } from "./commands/doctor.ts"; import { loadConfig } from "@middle/core"; import { openAndMigrate } from "@middle/dispatcher/src/db.ts"; -import { registerManagedRepo } from "@middle/dispatcher/src/repo-config.ts"; -import { runInit } from "./commands/init.ts"; +import { registerManagedRepo, setEpicStoreConfig } from "@middle/dispatcher/src/repo-config.ts"; +import { runInit, type EpicStoreRegistration } from "./commands/init.ts"; /** * Record an initialized repo in the daemon's managed-repo registry (#135) so the @@ -49,7 +49,23 @@ function registerRepoInDaemonDb(repo: string, repoPath: string): void { db.close(); } } + +/** + * Persist a repo's Epic-store mode to the daemon db (#194) via + * `setEpicStoreConfig` so the dispatcher's per-repo gateway selector routes it to + * the file- or gh-backed gateways. Best-effort, like {@link registerRepoInDaemonDb}. + */ +function setEpicStoreInDaemonDb(repo: string, cfg: EpicStoreRegistration): void { + const config = loadConfig({ globalPath: process.env.MIDDLE_CONFIG }); + const db = openAndMigrate(config.global.dbPath); + try { + setEpicStoreConfig(db, repo, cfg); + } finally { + db.close(); + } +} import { runPause, runResume } from "./commands/pause.ts"; +import { runResumeAnswer } from "./commands/resume-answer.ts"; import { runRecommender } from "./commands/run-recommender.ts"; import { runStartCommand } from "./commands/start.ts"; import { runStatus } from "./commands/status.ts"; @@ -69,11 +85,26 @@ program .description("Bootstrap middle into a target repo (skills, hooks, config, state issue)") .argument("", "path to the local repo checkout") .option("--dry-run", "print planned actions without executing") - .action(async (path: string, options: { dryRun?: boolean }) => + .option( + "--epic-store ", + "where Epics + recommender state live: 'github' (default) or 'file'", + "github", + ) + .action(async (path: string, options: { dryRun?: boolean; epicStore?: string }) => { + const mode = options.epicStore ?? "github"; + if (mode !== "github" && mode !== "file") { + console.error(`mm init: --epic-store must be 'github' or 'file' (got '${mode}')`); + process.exit(1); + } process.exit( - await runInit(path, { dryRun: options.dryRun, registerRepo: registerRepoInDaemonDb }), - ), - ); + await runInit(path, { + dryRun: options.dryRun, + epicStore: mode, + registerRepo: registerRepoInDaemonDb, + setEpicStore: setEpicStoreInDaemonDb, + }), + ); + }); program .command("uninit") @@ -114,13 +145,21 @@ program .command("dispatch") .description("Force-dispatch an Epic (or standalone issue) through the implementation workflow") .argument("", "path to the local repo checkout") - .argument("", "Epic or standalone issue number") + .argument("[epic]", "Epic ref — a file-mode slug or a github issue number (or use --epic)") + .option("--epic ", "Epic ref (alternative to the positional ; a slug or a number)") .option( "--adapter ", "adapter to dispatch with (overrides the agent: label and default)", ) - .action(async (repo: string, epic: string, opts: { adapter?: string }) => - process.exit(await runDispatch(repo, epic, { adapter: opts.adapter })), + .action( + async (repo: string, epic: string | undefined, opts: { epic?: string; adapter?: string }) => { + const ref = epic ?? opts.epic; + if (ref === undefined) { + console.error("mm dispatch: provide an epic ref (positional or --epic )"); + process.exit(1); + } + process.exit(await runDispatch(repo, ref, { adapter: opts.adapter })); + }, ); program @@ -139,9 +178,24 @@ program program .command("resume") - .description("Resume auto-dispatch for a repo (clear its pause)") + .description( + "Resume a repo's auto-dispatch (clear its pause), or — with --answer — unblock a parked Epic", + ) .argument("", "path to the local repo checkout") - .action(async (repo: string) => process.exit(await runResume(repo))); + .argument("[epic]", "Epic ref to unblock (a slug or number); omit to clear the repo's pause") + .option("--answer ", "human answer that resumes the parked Epic") + .action(async (repo: string, epic: string | undefined, opts: { answer?: string }) => { + // `mm resume --answer "…"` fires the parked Epic's resume + // signal; the bare `mm resume ` keeps clearing the repo's pause. + if (epic !== undefined || opts.answer !== undefined) { + if (epic === undefined || opts.answer === undefined) { + console.error("mm resume: unblocking a parked Epic needs both and --answer "); + process.exit(1); + } + process.exit(await runResumeAnswer(repo, epic, opts.answer)); + } + process.exit(await runResume(repo)); + }); program .command("config") diff --git a/packages/cli/test/bootstrap-init.test.ts b/packages/cli/test/bootstrap-init.test.ts index f2cb93fc..50e03686 100644 --- a/packages/cli/test/bootstrap-init.test.ts +++ b/packages/cli/test/bootstrap-init.test.ts @@ -29,6 +29,7 @@ function makeFakeDeps(): { deps: BootstrapDeps; calls: Calls } { getRemoteUrl: async () => "git@github.com:acme/widget.git", isGhAuthenticated: async () => true, resolveRepoInfo: async () => ({ owner: "acme", name: "widget", defaultBranch: "main" }), + resolveRepoInfoLocal: async () => ({ owner: "acme", name: "widget", defaultBranch: "main" }), github: { ensureStateLabel: async () => { calls.ensureLabel++; diff --git a/packages/cli/test/dispatch.test.ts b/packages/cli/test/dispatch.test.ts index a86f579f..15ada580 100644 --- a/packages/cli/test/dispatch.test.ts +++ b/packages/cli/test/dispatch.test.ts @@ -122,19 +122,21 @@ function fakeDaemon(opts: FakeDaemonOpts): { } describe("runDispatch — input validation", () => { - test("rejects a non-integer epic number", async () => { + test("rejects a malformed numeric epic (digit-leading but not a whole number)", async () => { + const repoPath = makeRepo(); const restore = silenceLogs(); try { - expect(await runDispatch(dir, "not-a-number")).toBe(1); + expect(await runDispatch(repoPath, "12abc")).toBe(1); } finally { restore(); } }); test("rejects an epic number below 1", async () => { + const repoPath = makeRepo(); const restore = silenceLogs(); try { - expect(await runDispatch(dir, "0")).toBe(1); + expect(await runDispatch(repoPath, "0")).toBe(1); } finally { restore(); } @@ -167,8 +169,32 @@ describe("runDispatch — control client", () => { }); expect(code).toBe(0); expect(spawned).toBe(false); // health was up → never spawned + expect(dispatchBodies).toEqual([{ repo: "repo", repoPath, epicRef: "6", adapter: "claude" }]); + } finally { + restore(); + server.stop(true); + } + }); + + test("a file-mode slug dispatches with epicRef and skips the gh label fetch", async () => { + const repoPath = makeRepo(); + const { server, dispatchBodies } = fakeDaemon({ states: ["running", "completed"] }); + const configPath = writeConfig(server.port); + let labelFetches = 0; + const restore = silenceLogs(); + try { + const code = await runDispatch(repoPath, "rollout-epic-store", { + configPath, + startDaemon: () => 0, + fetchLabels: async () => { + labelFetches += 1; + return []; + }, + }); + expect(code).toBe(0); + expect(labelFetches).toBe(0); // a slug has no GitHub labels to consult expect(dispatchBodies).toEqual([ - { repo: "repo", repoPath, epicNumber: 6, adapter: "claude" }, + { repo: "repo", repoPath, epicRef: "rollout-epic-store", adapter: "claude" }, ]); } finally { restore(); diff --git a/packages/cli/test/doctor.test.ts b/packages/cli/test/doctor.test.ts index a6cb5ccb..b7d8fb20 100644 --- a/packages/cli/test/doctor.test.ts +++ b/packages/cli/test/doctor.test.ts @@ -1,5 +1,10 @@ -import { describe, expect, spyOn, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { AdapterConfig, MiddleConfig } from "@middle/core"; +import { openAndMigrate } from "@middle/dispatcher/src/db.ts"; +import { setEpicStoreConfig } from "@middle/dispatcher/src/repo-config.ts"; import type { RetentionStatus } from "@middle/dispatcher/src/retention.ts"; import { checkAdapterBinaries, @@ -53,6 +58,78 @@ describe("runDoctor — happy path", () => { }); }); +describe("runDoctor — mode-aware Epic-store check", () => { + const SLUG = "acme/widgets"; + let tmp: string; + let prevMiddleConfig: string | undefined; + + // Seed a migrated db pointed at by a temp global config, so loadDoctorConfig + // resolves db_path → our db and resolveEpicStore reads the row we set. The + // repo slug is injected (resolveSlug) so no git remote is consulted. + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "mm-doctor-")); + const dbPath = join(tmp, "db.sqlite3"); + openAndMigrate(dbPath).close(); + const configPath = join(tmp, "config.toml"); + writeFileSync(configPath, `[global]\ndb_path = "${dbPath}"\n`); + prevMiddleConfig = process.env.MIDDLE_CONFIG; + process.env.MIDDLE_CONFIG = configPath; + }); + + afterEach(() => { + if (prevMiddleConfig === undefined) delete process.env.MIDDLE_CONFIG; + else process.env.MIDDLE_CONFIG = prevMiddleConfig; + rmSync(tmp, { recursive: true, force: true }); + }); + + const setMode = (cfg: Parameters[2]) => { + const db = openAndMigrate(join(tmp, "db.sqlite3")); + try { + setEpicStoreConfig(db, SLUG, cfg); + } finally { + db.close(); + } + }; + + const run = async (repoPath: string): Promise => { + const lines: string[] = []; + const spy = spyOn(console, "log").mockImplementation((...args: unknown[]) => { + lines.push(args.join(" ")); + }); + try { + await runDoctor({ repoPath, resolveSlug: async () => SLUG }); + } finally { + spy.mockRestore(); + } + return lines.join("\n"); + }; + + test("file mode + existing epics dir → epics_dir pass, no state-issue row", async () => { + mkdirSync(join(tmp, "planning", "epics"), { recursive: true }); + setMode({ mode: "file", epicsDir: "planning/epics", stateFile: ".middle/state.md" }); + + const output = await run(tmp); + expect(output).toContain("✓ epics_dir planning/epics exists"); + expect(output).not.toContain("state-issue"); + }); + + test("file mode + missing epics dir → epics_dir fail, no state-issue row", async () => { + setMode({ mode: "file", epicsDir: "planning/epics", stateFile: ".middle/state.md" }); + + const output = await run(tmp); + expect(output).toContain("✗ epics_dir"); + expect(output).toContain("planning/epics missing"); + expect(output).toContain("mm init --epic-store=file"); + expect(output).not.toContain("state-issue"); + }); + + test("github mode (no config row) → state-issue row, no epics_dir row", async () => { + const output = await run(tmp); + expect(output).toContain("state-issue"); + expect(output).not.toContain("epics_dir"); + }); +}); + describe("checkAdapterBinaries", () => { const adapter = (enabled: boolean, binary: string): AdapterConfig => ({ enabled, diff --git a/packages/cli/test/file-mode-smoke.test.ts b/packages/cli/test/file-mode-smoke.test.ts new file mode 100644 index 00000000..4a275b0d --- /dev/null +++ b/packages/cli/test/file-mode-smoke.test.ts @@ -0,0 +1,133 @@ +/** + * Scripted file-mode smoke (#194): a file-mode repo is configured + an Epic file + * authored, then `mm dispatch --epic ` is driven against a daemon that + * creates the workflow row exactly as the real `/control/dispatch` → engine path + * does. Asserts the row lands with `epic_ref = ` and that the repo is + * file-mode through the bootstrap selector (`readEpicStoreConfig`). + * + * The daemon's real engine/tmux drive is out of scope here (covered by the + * dispatcher's `epic-store/file-dispatch-integration.test.ts`); this pins the + * CLI → control-plane → workflows-table contract for a slug dispatch. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Database } from "bun:sqlite"; +import { openAndMigrate } from "@middle/dispatcher/src/db.ts"; +import { createWorkflowRecord, getWorkflow } from "@middle/dispatcher/src/workflow-record.ts"; +import { readEpicStoreConfig, setEpicStoreConfig } from "@middle/dispatcher/src/repo-config.ts"; +import { renderEpicFile } from "@middle/dispatcher/src/epic-store/epic-file/renderer.ts"; +import { runDispatch } from "../src/commands/dispatch.ts"; + +type BunServer = ReturnType; + +let scratch: string; +let repoPath: string; +let db: Database; +let server: BunServer; + +const SLUG = "rollout-epic-store"; + +async function git(cwd: string, args: string[]): Promise { + const proc = Bun.spawn(["git", "-C", cwd, ...args], { + stdout: "ignore", + stderr: "ignore", + env: { + ...process.env, + GIT_AUTHOR_NAME: "t", + GIT_AUTHOR_EMAIL: "t@e.invalid", + GIT_COMMITTER_NAME: "t", + GIT_COMMITTER_EMAIL: "t@e.invalid", + }, + }); + await proc.exited; +} + +beforeEach(async () => { + scratch = mkdtempSync(join(tmpdir(), "middle-smoke-")); + repoPath = join(scratch, "repo"); + mkdirSync(repoPath, { recursive: true }); + await git(repoPath, ["init"]); + // A managed file-mode repo: db config + an authored Epic file on disk. + db = openAndMigrate(join(scratch, "db.sqlite3")); + const slug = "repo"; // deriveRepoSlug falls back to the dir basename without a remote + setEpicStoreConfig(db, slug, { + mode: "file", + epicsDir: "planning/epics", + stateFile: ".middle/state.md", + }); + const epicsDir = join(repoPath, "planning", "epics"); + mkdirSync(epicsDir, { recursive: true }); + writeFileSync( + join(epicsDir, `${SLUG}.md`), + renderEpicFile({ + title: "feat: rollout", + meta: { slug: SLUG, adapter: "claude" }, + context: "ctx", + acceptanceCriteria: [{ checked: false, text: "ship" }], + subIssues: [{ id: 1, checked: false, title: "1 — gateways", body: "" }], + conversation: [], + }), + ); +}); + +afterEach(() => { + server?.stop(true); + db.close(); +}); + +describe("file-mode CLI smoke (#194)", () => { + test("mm dispatch --epic lands a workflow row with epic_ref= (file mode selected)", async () => { + // The repo is file-mode through the bootstrap selector. + expect(readEpicStoreConfig(db, "repo").mode).toBe("file"); + + // A daemon that creates the workflow row from the posted epicRef — exactly + // what `/control/dispatch` → `startDispatchImpl` → `createWorkflowRecord` does. + const configPath = join(scratch, "config.toml"); + let workflowId = ""; + server = Bun.serve({ + port: 0, + async fetch(req) { + const { pathname } = new URL(req.url); + if (req.method === "GET" && pathname === "/health") { + return Response.json({ ok: true, port: 0, version: "test" }); + } + if (req.method === "POST" && pathname === "/control/dispatch") { + const body = (await req.json()) as { epicRef: string; adapter: string }; + workflowId = `wf-${body.epicRef}`; + createWorkflowRecord(db, { + id: workflowId, + kind: "implementation", + repo: "repo", + epicRef: body.epicRef, + adapter: body.adapter, + source: "manual", + }); + return Response.json({ workflowId }); + } + if (req.method === "GET" && pathname === "/control/events") { + const frame = `event: workflow\ndata: ${JSON.stringify({ id: workflowId || `wf-${SLUG}`, state: "completed" })}\n\n`; + return new Response(frame, { headers: { "content-type": "text/event-stream" } }); + } + return new Response("not found", { status: 404 }); + }, + }); + writeFileSync(configPath, `[global]\ndispatcher_port = ${server.port}\n`); + + const restoreLog = console.log; + console.log = () => {}; + try { + const code = await runDispatch(repoPath, SLUG, { configPath, startDaemon: () => 0 }); + expect(code).toBe(0); + } finally { + console.log = restoreLog; + } + + // The row landed with the slug as epic_ref and a null numeric epic_number. + const row = getWorkflow(db, `wf-${SLUG}`); + expect(row?.epicRef).toBe(SLUG); + expect(row?.epicNumber).toBeNull(); + }); +}); diff --git a/packages/cli/test/init-file-store.test.ts b/packages/cli/test/init-file-store.test.ts new file mode 100644 index 00000000..8873933c --- /dev/null +++ b/packages/cli/test/init-file-store.test.ts @@ -0,0 +1,201 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse as parseToml } from "smol-toml"; +import { isParseError, parseStateIssue, renderStateIssue, validate } from "@middle/state-issue"; +import { parseEpicFile } from "@middle/dispatcher/src/epic-store/epic-file/parser.ts"; +import type { EpicStoreRegistration } from "../src/commands/init.ts"; +import { runInit } from "../src/commands/init.ts"; +import type { BootstrapDeps } from "../src/bootstrap/types.ts"; + +// #194: `mm init --epic-store=file` scaffolds a local Epic store and makes ZERO +// `gh`/GitHub calls. These tests pin that contract (the four scaffold files + +// the setEpicStore seam + the no-gh invariant), and that github mode is unchanged. + +/** A deps bundle that THROWS on every `gh`/GitHub-touching method. The file path + * must never reach any of these — git-only methods (clean worktree, remote URL, + * local repo info) are allowed since file mode is offline. */ +function makeNoGhDeps(): BootstrapDeps { + const die = (name: string) => () => { + throw new Error(`gh call '${name}' must not happen in file mode`); + }; + return { + isCleanWorktree: async () => true, + getRemoteUrl: async () => "git@github.com:acme/widget.git", + isGhAuthenticated: die("isGhAuthenticated") as () => Promise, + resolveRepoInfo: die("resolveRepoInfo") as () => Promise, + resolveRepoInfoLocal: async () => ({ owner: "acme", name: "widget", defaultBranch: "main" }), + github: { + ensureStateLabel: die("ensureStateLabel") as () => Promise, + createStateIssue: die("createStateIssue") as () => Promise, + closeStateIssue: die("closeStateIssue") as () => Promise, + findStateIssues: die("findStateIssues") as () => Promise, + }, + now: () => new Date("2026-06-03T12:00:00.000Z"), + }; +} + +/** A normal github-mode deps bundle (gh calls succeed). */ +function makeGithubDeps(): { + deps: BootstrapDeps; + created: Array<{ title: string; body: string }>; +} { + const created: Array<{ title: string; body: string }> = []; + const deps: BootstrapDeps = { + isCleanWorktree: async () => true, + getRemoteUrl: async () => "git@github.com:acme/widget.git", + isGhAuthenticated: async () => true, + resolveRepoInfo: async () => ({ owner: "acme", name: "widget", defaultBranch: "main" }), + resolveRepoInfoLocal: async () => ({ owner: "acme", name: "widget", defaultBranch: "main" }), + github: { + ensureStateLabel: async () => {}, + createStateIssue: async (_info, title, body) => { + created.push({ title, body }); + return 142; + }, + closeStateIssue: async () => {}, + findStateIssues: async () => [], + }, + now: () => new Date("2026-06-03T12:00:00.000Z"), + }; + return { deps, created }; +} + +let repo: string; +let silence: () => void; + +beforeEach(() => { + repo = mkdtempSync(join(tmpdir(), "mm-init-file-")); + mkdirSync(join(repo, ".git")); + const log = spyOn(console, "log").mockImplementation(() => {}); + const err = spyOn(console, "error").mockImplementation(() => {}); + silence = () => { + log.mockRestore(); + err.mockRestore(); + }; +}); +afterEach(() => { + silence(); + rmSync(repo, { recursive: true, force: true }); +}); + +describe("mm init --epic-store=file", () => { + test("writes the four scaffold files and makes zero gh calls", async () => { + const code = await runInit(repo, { epicStore: "file", deps: makeNoGhDeps() }); + expect(code).toBe(0); // the no-gh deps never threw → no gh call happened + + // README explainer + const readmePath = join(repo, "planning/epics/README.md"); + expect(existsSync(readmePath)).toBe(true); + const readme = readFileSync(readmePath, "utf8"); + expect(readme).toContain("file mode"); + expect(readme).toContain(""); // template snippet + + // .keep is present and empty + const keepPath = join(repo, "planning/epics/.keep"); + expect(existsSync(keepPath)).toBe(true); + expect(readFileSync(keepPath, "utf8")).toBe(""); + + // state file carries the v1 marker and re-parses + validates + const statePath = join(repo, ".middle/state.md"); + expect(existsSync(statePath)).toBe(true); + const stateBody = readFileSync(statePath, "utf8"); + expect(stateBody).toContain(""); + const parsed = parseStateIssue(stateBody); + expect(isParseError(parsed)).toBe(false); + if (!isParseError(parsed)) { + // byte-identical round-trip — the dispatcher edits it section-by-section + expect(renderStateIssue(parsed)).toBe(stateBody); + expect(validate(parsed, { adapters: ["claude", "codex"] }).ok).toBe(true); + } + + // per-repo [epic_store] config TOML, named -.toml + const tomlPath = join(repo, ".middle/acme-widget.toml"); + expect(existsSync(tomlPath)).toBe(true); + const toml = parseToml(readFileSync(tomlPath, "utf8")) as { + epic_store: { mode: string; epics_dir: string; state_file: string }; + }; + expect(toml.epic_store.mode).toBe("file"); + expect(toml.epic_store.epics_dir).toBe("planning/epics"); + expect(toml.epic_store.state_file).toBe(".middle/state.md"); + + // mode-agnostic writes still happen + expect(existsSync(join(repo, ".claude/skills/implementing-github-issues/SKILL.md"))).toBe(true); + expect(existsSync(join(repo, ".middle/hooks/hook.sh"))).toBe(true); + expect(existsSync(join(repo, ".middle/config.toml"))).toBe(true); + + // NO state issue created in file mode + expect(existsSync(join(repo, ".github"))).toBe(false); + }); + + test("the README template snippet is a parseable v1 Epic body", async () => { + await runInit(repo, { epicStore: "file", deps: makeNoGhDeps() }); + const readme = readFileSync(join(repo, "planning/epics/README.md"), "utf8"); + // extract the fenced ```md … ``` block and confirm parseEpicFile accepts it + const fence = /```md\n([\s\S]*?)```/.exec(readme); + expect(fence).not.toBeNull(); + const epic = parseEpicFile(fence![1]!); + expect(epic.meta.slug).toBe("my-epic-slug"); + expect(epic.acceptanceCriteria.length).toBeGreaterThan(0); + }); + + test("calls the setEpicStore callback with file mode + default paths", async () => { + const calls: Array<{ repo: string; cfg: EpicStoreRegistration }> = []; + const code = await runInit(repo, { + epicStore: "file", + deps: makeNoGhDeps(), + setEpicStore: (r, cfg) => calls.push({ repo: r, cfg }), + }); + expect(code).toBe(0); + expect(calls).toEqual([ + { + repo: "acme/widget", + cfg: { mode: "file", epicsDir: "planning/epics", stateFile: ".middle/state.md" }, + }, + ]); + }); + + test("a setEpicStore write failure is best-effort — init still succeeds", async () => { + const code = await runInit(repo, { + epicStore: "file", + deps: makeNoGhDeps(), + setEpicStore: () => { + throw new Error("db locked"); + }, + }); + expect(code).toBe(0); // the throw is swallowed; the scaffold already landed + expect(existsSync(join(repo, ".middle/state.md"))).toBe(true); + }); + + test("--dry-run writes nothing and makes no gh calls", async () => { + const code = await runInit(repo, { epicStore: "file", dryRun: true, deps: makeNoGhDeps() }); + expect(code).toBe(0); + expect(existsSync(join(repo, "planning/epics"))).toBe(false); + expect(existsSync(join(repo, ".middle/state.md"))).toBe(false); + }); +}); + +describe("mm init — github mode is unchanged", () => { + test("default mode creates the state issue and writes no file-store scaffold", async () => { + const { deps, created } = makeGithubDeps(); + const code = await runInit(repo, { deps }); + expect(code).toBe(0); + + // github-mode artifacts + expect(created).toHaveLength(1); + expect(readFileSync(join(repo, ".middle/config.toml"), "utf8")).toContain("number = 142"); + + // file-mode scaffold must NOT be written in github mode + expect(existsSync(join(repo, "planning/epics"))).toBe(false); + expect(existsSync(join(repo, ".middle/state.md"))).toBe(false); + expect(existsSync(join(repo, ".middle/acme-widget.toml"))).toBe(false); + }); + + test("setEpicStore is called with github mode in the default path", async () => { + const { deps } = makeGithubDeps(); + const calls: EpicStoreRegistration[] = []; + await runInit(repo, { deps, setEpicStore: (_r, cfg) => calls.push(cfg) }); + expect(calls).toEqual([{ mode: "github" }]); + }); +}); diff --git a/packages/cli/test/init-register.test.ts b/packages/cli/test/init-register.test.ts index b51b8784..9b885636 100644 --- a/packages/cli/test/init-register.test.ts +++ b/packages/cli/test/init-register.test.ts @@ -15,6 +15,7 @@ function makeFakeDeps(): BootstrapDeps { getRemoteUrl: async () => "git@github.com:acme/widget.git", isGhAuthenticated: async () => true, resolveRepoInfo: async () => ({ owner: "acme", name: "widget", defaultBranch: "main" }), + resolveRepoInfoLocal: async () => ({ owner: "acme", name: "widget", defaultBranch: "main" }), github: { ensureStateLabel: async () => {}, createStateIssue: async () => 142, diff --git a/packages/dispatcher/src/hook-server.ts b/packages/dispatcher/src/hook-server.ts index ab674364..e691999a 100644 --- a/packages/dispatcher/src/hook-server.ts +++ b/packages/dispatcher/src/hook-server.ts @@ -62,6 +62,15 @@ export type ControlPlane = { * worktree). The route maps `null` to 409. */ startDispatch: (input: ControlDispatchInput) => Promise; + /** + * Manually resume a parked Epic with a human answer (`mm resume + * --answer`). Looks up the `waiting-human` workflow by `(repo, epicRef)` and + * fires its resume signal with the answer text — the Phase-1 escape hatch + * before the file-watcher lands, and the github-mode manual-unblock too. + * Resolves the resumed workflow id, or `null` when no parked workflow owns the + * ref (the route maps `null` to 404). Absent in gate-only mode. + */ + resume?: (input: { repo: string; epicRef: string; answer: string }) => Promise; /** * Whether this dispatch has a free slot right now. The route consults it before * a manual dispatch so `mm dispatch` respects slot limits (a full queue → 429). @@ -230,6 +239,9 @@ export class HookServer implements SessionGate { if (req.method === "POST" && pathname === "/control/dispatch") { return this.#handleControlDispatch(req); } + if (req.method === "POST" && pathname === "/control/resume") { + return this.#handleControlResume(req); + } if (req.method === "POST" && pathname === "/gates/pr-ready") { return this.#handleGate(req); } @@ -456,6 +468,49 @@ export class HookServer implements SessionGate { return Response.json({ workflowId }); } + /** + * `POST /control/resume` — manually answer a parked Epic (`mm resume + * --answer`). Validates `{ repo, epicRef, answer }` → 400; delegates the + * `(repo, epicRef)` lookup + signal fire to `control.resume`, mapping a `null` + * (no parked workflow owns the ref) to 404. 404 in gate-only mode. On success + * returns `{ workflowId }`. + */ + async #handleControlResume(req: Request): Promise { + const control = this.#control; + if (!control?.resume) return new Response("not found", { status: 404 }); + + let parsed: unknown; + try { + parsed = await req.json(); + } catch { + return this.#badRequest("body must be valid JSON"); + } + const body: Record = + typeof parsed === "object" && parsed !== null ? (parsed as Record) : {}; + const { repo, epicRef, answer } = body; + const normalizedRepo = typeof repo === "string" ? repo.trim() : ""; + if (normalizedRepo === "") return this.#badRequest("repo must be a non-empty string"); + if (typeof epicRef !== "string" || epicRef.trim() === "") { + return this.#badRequest("epicRef must be a non-empty string"); + } + if (typeof answer !== "string" || answer.trim() === "") { + return this.#badRequest("answer must be a non-empty string"); + } + + const workflowId = await control.resume({ + repo: normalizedRepo, + epicRef: epicRef.trim(), + answer, + }); + if (workflowId === null) { + return Response.json( + { error: `no parked workflow for Epic ${epicRef.trim()} in ${normalizedRepo}` }, + { status: 404 }, + ); + } + return Response.json({ workflowId }); + } + #badRequest(reason: string): Response { return Response.json({ error: reason }, { status: 400 }); } diff --git a/packages/dispatcher/src/main.ts b/packages/dispatcher/src/main.ts index 115674e4..a6a14f6c 100644 --- a/packages/dispatcher/src/main.ts +++ b/packages/dispatcher/src/main.ts @@ -45,9 +45,11 @@ import { buildRecommenderContext, createRecommenderWorkflow } from "./workflows/ import { addWorkflowObserver, clearWorkflowObservers, + findParkedWorkflowByRef, getWorkflow, hasNonTerminalEpicWorkflow, listNonTerminalWorkflows, + markSignalFired, promotePendingToFailed, } from "./workflow-record.ts"; import type { ControlDispatchInput } from "./hook-server.ts"; @@ -542,6 +544,20 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise { adapterRejection: adapterRejectionReason, // A route dispatch is a manual `mm dispatch` — recorded `source: 'manual'`. startDispatch: (input) => startDispatchImpl(input, "manual"), + // `mm resume --answer` — fire the parked Epic's resume + // signal with the human answer (the Phase-1 manual-unblock, both modes). + // Mirrors the poller's fire: signal the execution, then mark the durable + // wait fired so the poller doesn't re-fire it. + resume: async ({ repo, epicRef, answer }) => { + const workflowId = findParkedWorkflowByRef(db, repo, epicRef); + if (workflowId === null) return null; + await engine.signal(workflowId, RESUME_EVENT, { + reason: "answered-question", + reply: { commentId: 0, authorLogin: "human", body: answer }, + }); + markSignalFired(db, workflowId); + return workflowId; + }, // Manual dispatch respects slot limits (the loop does its own accounting). slotAvailable, // Trigger #4: a manual `mm dispatch` (a route dispatch) re-runs the loop diff --git a/packages/dispatcher/src/workflow-record.ts b/packages/dispatcher/src/workflow-record.ts index 58f5be91..05e9f689 100644 --- a/packages/dispatcher/src/workflow-record.ts +++ b/packages/dispatcher/src/workflow-record.ts @@ -681,6 +681,28 @@ export function hasNonTerminalEpicWorkflow(db: Database, repo: string, epicRef: return row !== null; } +/** + * The parked (`waiting-human`) implementation workflow for an Epic, by ref — the + * `/control/resume` lookup (a human manually answering a parked Epic). Matched on + * `epic_ref` so it works for both a github numeric ref and a file slug. Returns + * the workflow id, or null when no parked workflow owns that ref. + */ +export function findParkedWorkflowByRef( + db: Database, + repo: string, + epicRef: string, +): string | null { + const row = db + .query( + `SELECT id FROM workflows + WHERE kind = 'implementation' AND repo = ? AND epic_ref = ? AND state = 'waiting-human' + ORDER BY created_at DESC, rowid DESC + LIMIT 1`, + ) + .get(repo, epicRef) as { id: string } | null; + return row?.id ?? null; +} + /** A non-terminal workflow as the control-plane init-replay reports it. */ export type NonTerminalWorkflow = { id: string; diff --git a/packages/dispatcher/test/control-routes.test.ts b/packages/dispatcher/test/control-routes.test.ts index d4cb2f64..308dddaa 100644 --- a/packages/dispatcher/test/control-routes.test.ts +++ b/packages/dispatcher/test/control-routes.test.ts @@ -302,11 +302,58 @@ describe("HookServer control routes", () => { expect((await fetch(`${base}/control/metrics`)).status).toBe(404); }); + test("POST /control/resume fires the parked Epic's resume and returns its id", async () => { + const resumeCalls: Array<{ repo: string; epicRef: string; answer: string }> = []; + startWith( + makeControl({ + resume: async (input) => { + resumeCalls.push(input); + return input.epicRef === "missing" ? null : "wf-resumed"; + }, + }), + ); + const res = await fetch(`${base}/control/resume`, { + method: "POST", + body: JSON.stringify({ repo: "o/r", epicRef: "rollout-epic-store", answer: "go with A" }), + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ workflowId: "wf-resumed" }); + expect(resumeCalls).toEqual([ + { repo: "o/r", epicRef: "rollout-epic-store", answer: "go with A" }, + ]); + }); + + test("POST /control/resume 404s when no parked workflow owns the ref", async () => { + startWith(makeControl({ resume: async () => null })); + const res = await fetch(`${base}/control/resume`, { + method: "POST", + body: JSON.stringify({ repo: "o/r", epicRef: "missing", answer: "x" }), + }); + expect(res.status).toBe(404); + }); + + test("POST /control/resume 400s on a missing epicRef or answer", async () => { + startWith(makeControl({ resume: async () => "wf" })); + for (const body of [ + { repo: "o/r", answer: "x" }, // no epicRef + { repo: "o/r", epicRef: "s" }, // no answer + { epicRef: "s", answer: "x" }, // no repo + ]) { + const res = await fetch(`${base}/control/resume`, { + method: "POST", + body: JSON.stringify(body), + }); + expect(res.status).toBe(400); + } + }); + test("control routes 404 in gate-only mode (no control plane wired)", async () => { startWith(undefined); expect((await fetch(`${base}/control/events`)).status).toBe(404); const d = await fetch(`${base}/control/dispatch`, { method: "POST", body: "{}" }); expect(d.status).toBe(404); + const r = await fetch(`${base}/control/resume`, { method: "POST", body: "{}" }); + expect(r.status).toBe(404); // The metric exports need the control plane's seam → 404. expect((await fetch(`${base}/metrics`)).status).toBe(404); // /health is unconditional liveness; version is empty without a control plane. diff --git a/packages/dispatcher/test/workflow-record.test.ts b/packages/dispatcher/test/workflow-record.test.ts index 0e7aa871..08c83927 100644 --- a/packages/dispatcher/test/workflow-record.test.ts +++ b/packages/dispatcher/test/workflow-record.test.ts @@ -11,6 +11,7 @@ import { createWorkflowRecord, type CreateWorkflowRecordInput, finalizeParkedWorkflow, + findParkedWorkflowByRef, getCheckboxReconcileState, getWorkflow, getWorkflowSource, @@ -460,6 +461,28 @@ describe("hasNonTerminalEpicWorkflow", () => { }); }); +describe("findParkedWorkflowByRef", () => { + test("finds the waiting-human workflow for a ref (slug or number); null otherwise", () => { + createWorkflowRecord(db, { + id: "a", + kind: "implementation", + repo: "o/r", + epicRef: "rollout-epic-store", + adapter: "claude", + }); + // Not parked yet → no match. + expect(findParkedWorkflowByRef(db, "o/r", "rollout-epic-store")).toBeNull(); + updateWorkflow(db, "a", { state: "waiting-human" }); + expect(findParkedWorkflowByRef(db, "o/r", "rollout-epic-store")).toBe("a"); + // Scoped by repo + ref. + expect(findParkedWorkflowByRef(db, "x/y", "rollout-epic-store")).toBeNull(); + expect(findParkedWorkflowByRef(db, "o/r", "other-slug")).toBeNull(); + // A resumed (non-parked) workflow no longer matches. + updateWorkflow(db, "a", { state: "running" }); + expect(findParkedWorkflowByRef(db, "o/r", "rollout-epic-store")).toBeNull(); + }); +}); + describe("listActiveImplementationWorkflows (#180)", () => { test("returns lastHeartbeat (null when none observed, the touched epoch otherwise)", () => { createWorkflowRecord(db, { diff --git a/planning/issues/190/decisions.md b/planning/issues/190/decisions.md index 848850a4..6eb41e63 100644 --- a/planning/issues/190/decisions.md +++ b/planning/issues/190/decisions.md @@ -142,3 +142,34 @@ validation rejected. This makes the dispatch entry mode-agnostic so #193's selec is reachable end-to-end; the CLI sends the right field in #194. Existing numeric clients are unaffected (the github branch is unchanged). **Evidence:** #193 integration criterion (HTTP dispatch with a file-mode slug). + +## `mm resume` is overloaded: clear-pause vs answer-a-parked-Epic +**File(s):** `packages/cli/src/index.ts`, `commands/resume-answer.ts` +**Date:** 2026-06-03 + +**Decision:** `mm resume ` keeps its existing meaning (clear the repo's +auto-dispatch pause). `mm resume --answer ""` is the NEW +answer-resume: it POSTs `/control/resume`, which fires the parked Epic's resume +signal. The router branches on whether ``/`--answer` are present (both +required together). +**Why:** sub-issue #194 specifies `mm resume --answer`, but `mm resume` +already exists (pause-clear) and middle commands always take a ``. Overloading +one command with an optional ` --answer` preserves back-compat while adding the +Phase-1 manual-unblock escape hatch. The daemon's `control.resume` mirrors the +poller's fire (`engine.signal(workflowId, RESUME_EVENT, …)` + `markSignalFired`) and +looks up the parked workflow by `epic_ref`, so it works in both modes. +**Evidence:** existing `runResume` (pause-clear) in `commands/pause.ts`; poller's +`fireSignal` wiring in `main.ts`. + +## `mm dispatch` accepts a slug or number; numeric refs keep the gh label fetch +**File(s):** `packages/cli/src/commands/dispatch.ts`, `index.ts` +**Date:** 2026-06-03 + +**Decision:** `mm dispatch ` (and `--epic `) accept a file slug or +a github issue number. A digit-leading ref must be a whole number ≥ 1 (else rejected); +a non-digit-leading ref is a slug. Only a numeric ref triggers the `agent:` +label lookup via gh; a slug skips gh (file-mode Epics carry their adapter in the file +meta, which the daemon reads). The POST body now sends `epicRef` (string). +**Why:** file-mode Epics are slugs; the dispatch entry must accept them and avoid a gh +call for a non-GitHub Epic. github-mode numeric dispatch is unchanged in behavior. +**Evidence:** sub-issue #194 (slug-or-number positional + `--epic`). From b1bfe383166a46c6dafb4ffb545f967f0689cf1a Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Wed, 3 Jun 2026 05:15:31 -0400 Subject: [PATCH 06/10] refactor(skills): abstract Epic-aware skills + dispatch-brief mode injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close #195. Make the three Epic-aware skills mode-agnostic and mirror the run's mode-specific commands into the dispatched worktree. - `implementing-github-issues` / `recommending-github-issues` SKILL.md bodies now talk about "the Epic" / "the Epic's plan comment" / "closing the sub-issue with evidence" without baking in `gh` command lines; the concrete incantations live in new `references/{github,file}-mode-commands.md` (file mode writes the Epic file's conversation/state via the renderer — the sole-writer rule keeps #180's class closed; PRs/reviews/CI stay GitHub-native in both modes). `creating-github-issues` gains a file-mode addendum for authoring an Epic file (meta keys + section structure, no `gh issue create`). Bootstrap-assets mirror re-synced. - `ensurePromptFile`'s sibling `mirrorModeCommands` copies the run's `/.claude/skills/implementing-github-issues/references/-mode-commands.md` into `/.middle/skills/.../references/` so the agent reads only the incantations for its store. Mode comes from the new `resolveEpicStoreMode` deps seam (default `readEpicStoreConfig(db, repo).mode`); best-effort. - Integration test drives a file-mode dispatch and asserts the worktree gains `file-mode-commands.md` byte-identical to `packages/skills/`, and that github mode does not mirror the file reference. typecheck/lint/format clean; sync-skills in sync; full suite green (1224). --- .../skills/creating-github-issues/SKILL.md | 85 +++++++ .../references/file-mode-commands.md | 111 +++++++++ .../implementing-github-issues/SKILL.md | 89 +++----- .../references/file-mode-commands.md | 116 ++++++++++ .../references/github-mode-commands.md | 116 ++++++++++ .../recommending-github-issues/SKILL.md | 80 ++++--- .../references/file-mode-commands.md | 85 +++++++ .../references/github-mode-commands.md | 65 ++++++ packages/dispatcher/src/build-deps.ts | 3 + .../src/workflows/implementation.ts | 44 +++- .../epic-store/mode-commands-mirror.test.ts | 214 ++++++++++++++++++ .../skills/creating-github-issues/SKILL.md | 85 +++++++ .../references/file-mode-commands.md | 111 +++++++++ .../implementing-github-issues/SKILL.md | 89 +++----- .../references/file-mode-commands.md | 116 ++++++++++ .../references/github-mode-commands.md | 116 ++++++++++ .../recommending-github-issues/SKILL.md | 80 ++++--- .../references/file-mode-commands.md | 85 +++++++ .../references/github-mode-commands.md | 65 ++++++ planning/issues/190/decisions.md | 18 ++ 20 files changed, 1581 insertions(+), 192 deletions(-) create mode 100644 packages/cli/src/bootstrap-assets/skills/creating-github-issues/references/file-mode-commands.md create mode 100644 packages/cli/src/bootstrap-assets/skills/implementing-github-issues/references/file-mode-commands.md create mode 100644 packages/cli/src/bootstrap-assets/skills/implementing-github-issues/references/github-mode-commands.md create mode 100644 packages/cli/src/bootstrap-assets/skills/recommending-github-issues/references/file-mode-commands.md create mode 100644 packages/cli/src/bootstrap-assets/skills/recommending-github-issues/references/github-mode-commands.md create mode 100644 packages/dispatcher/test/epic-store/mode-commands-mirror.test.ts create mode 100644 packages/skills/creating-github-issues/references/file-mode-commands.md create mode 100644 packages/skills/implementing-github-issues/references/file-mode-commands.md create mode 100644 packages/skills/implementing-github-issues/references/github-mode-commands.md create mode 100644 packages/skills/recommending-github-issues/references/file-mode-commands.md create mode 100644 packages/skills/recommending-github-issues/references/github-mode-commands.md diff --git a/packages/cli/src/bootstrap-assets/skills/creating-github-issues/SKILL.md b/packages/cli/src/bootstrap-assets/skills/creating-github-issues/SKILL.md index 8fb5032b..faa19c6f 100644 --- a/packages/cli/src/bootstrap-assets/skills/creating-github-issues/SKILL.md +++ b/packages/cli/src/bootstrap-assets/skills/creating-github-issues/SKILL.md @@ -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. + ## 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//plan.md`) captures *how*. Don't pre-decide implementation in the issue body; the implementer needs room to research and adapt. @@ -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/.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/.md`, and its sub-issues are +`` 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 + +# + + + +## 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 + +- [ ] +- [ ] <…> + +## Sub-issues + + +- [ ] **1 — ** + + *Acceptance:* + + + +- [ ] **2 — ** + + *Blocked by:* 1 + + + + +``` + +### The pieces + +- **``** — the document marker. Exact bytes; first line of the file. +- **`# `** — 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. diff --git a/packages/cli/src/bootstrap-assets/skills/creating-github-issues/references/file-mode-commands.md b/packages/cli/src/bootstrap-assets/skills/creating-github-issues/references/file-mode-commands.md new file mode 100644 index 00000000..75affbe3 --- /dev/null +++ b/packages/cli/src/bootstrap-assets/skills/creating-github-issues/references/file-mode-commands.md @@ -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. diff --git a/packages/cli/src/bootstrap-assets/skills/implementing-github-issues/SKILL.md b/packages/cli/src/bootstrap-assets/skills/implementing-github-issues/SKILL.md index 4f0c548e..cf45bcba 100644 --- a/packages/cli/src/bootstrap-assets/skills/implementing-github-issues/SKILL.md +++ b/packages/cli/src/bootstrap-assets/skills/implementing-github-issues/SKILL.md @@ -6,7 +6,9 @@ allowed-tools: Bash(gh:*), Bash(git:*), Bash(pnpm:*), Bash(mkdir:*), Read, Write # Implementing GitHub Issues -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. +End-to-end workflow for taking an Epic from "assigned" to "PR open with verification evidence, marked ready for human review." All phases of one Epic land on **one branch** and **one PR**; the PR is the long-lasting context for the workstream. + +**Mode-specific commands:** the Epic's data and the agent-↔-human conversation live in one of two stores — a GitHub issue (**github mode**) or a Markdown file under `planning/epics/` (**file mode**). PRs, reviews, and CI are GitHub-native in *both* modes. This skill body is mode-agnostic: it says "fetch the Epic", "post the plan to the Epic", "close the sub-issue with evidence", "post the reviewer's brief", "mark the PR ready" — the concrete incantations live in `references/<mode>-mode-commands.md` (`github-mode-commands.md` / `file-mode-commands.md`), mirrored into your worktree at `.middle/skills/implementing-github-issues/references/` for your run's mode. Use that file for every Epic/plan/sub-issue/conversation operation; use the PR commands (identical in both modes) inline below. ## Dispatch brief (read first) @@ -147,17 +149,15 @@ git worktree add .claude/worktrees/<branch>-fork-B -b <branch>-fork-B **Once chosen, collapse and clean up.** Don't leave the losing branch hanging "just in case." Delete the worktree, delete the branch, close the PR. Forks are disambiguation, not insurance. -## Phase 1 — Fetch issue context +## Phase 1 — Fetch the Epic's context -```bash -gh issue view <num> --json number,title,body,labels,assignees,milestone,comments,url -``` +Fetch the Epic — its title, body, acceptance criteria, sub-issues, and conversation log (see `references/<mode>-mode-commands.md` for the exact read). -Read the body AND every comment. The latest decisions are often in comments, not the description. Note: +Read the body AND every conversation entry. The latest decisions are often in the conversation, not the description. Note: - Acceptance criteria (explicit or implicit) - Linked issues / PRs / discussions - Constraints called out by the reporter -- Anyone @-mentioned who might be a stakeholder +- Anyone called out who might be a stakeholder ## Phase 2 — Research the codebase @@ -168,7 +168,7 @@ Before drafting a plan, ground yourself: - Read the relevant `CLAUDE.md` (root + nested) — these are the source of architectural patterns and conventions - For broad investigations, dispatch `Explore` subagent (50-100x context savings) -**STOP if:** The issue is ambiguous, the acceptance criteria are unclear, or the research reveals the issue's premise is wrong. Comment on the issue with your questions and wait, rather than guessing. +**STOP if:** The Epic is ambiguous, the acceptance criteria are unclear, or the research reveals the Epic's premise is wrong. Post your questions to the Epic's conversation and wait, rather than guessing. (Headless under middle, "ask a question" is a sentinel-write — see "Running under middle".) ## Phase 3 — Draft a lightweight plan @@ -204,15 +204,13 @@ N. ... **Lightweight means lightweight.** If you're writing more than ~100 lines for a multi-phase plan, you're either over-planning or the issue should be split. For genuinely complex multi-day work, use `superpowers:writing-plans` and link from the issue comment. -## Phase 4 — Post plan as issue comment +## Phase 4 — Post the plan to the Epic -```bash -gh issue comment <num> --body-file planning/issues/<num>/plan.md -``` +Post the plan body to the Epic's conversation (the Epic's **plan comment**). See `references/<mode>-mode-commands.md` for the exact write — in github mode it's an issue comment; in file mode the renderer appends it to the Epic file's conversation section (never hand-edit the strict markers). -This is non-negotiable. The plan-as-comment serves three purposes: +This is non-negotiable. The plan-on-the-Epic serves three purposes: 1. The reporter / stakeholders can correct your direction before you write code -2. It's discoverable from the issue itself (not buried in a branch) +2. It's discoverable from the Epic itself (not buried in a branch) 3. It creates a public commitment that disciplines the work If you skip this step, you've broken the contract of this skill. @@ -351,7 +349,7 @@ If during implementation you spot: - Performance concerns you noticed but didn't fix - API surface that should be reconsidered -…before reaching for `gh issue create`, walk this decision tree: +…before filing a new follow-up, walk this decision tree: ```dot digraph followup_decision { @@ -393,51 +391,15 @@ If you genuinely believe an acceptance-criterion item should be deferred, ask th ### Filing a sub-issue under an existing parent -GitHub supports native sub-issues via REST API. `gh` CLI 2.67+ doesn't have a `--parent` flag yet, so use `gh api`: - -```bash -OWNER=<owner>; REPO=<repo>; PARENT=<parent-issue-number> - -# 1. Create the child issue -URL=$(gh issue create --repo $OWNER/$REPO \ - --title "<descriptive title>" \ - --body "$(cat <<'EOF' -**Parent:** #<parent-num> (PR #<pr> surfaced this) - -**Context:** <what you saw, where> - -**Why a sub-issue and not in-scope:** <e.g., "parallelizable; another agent can pick this up while we work on Phase 2"> - -**Suggested approach:** <if you have one — otherwise omit> -EOF -)") -CHILD_NUM=$(basename "$URL") - -# 2. Look up the child's database id (NOT issue number, NOT node_id) -CHILD_ID=$(gh api /repos/$OWNER/$REPO/issues/$CHILD_NUM --jq '.id') - -# 3. Attach as sub-issue under parent. -# CRITICAL: use -F (integer) not -f (string). The endpoint rejects strings: -# `Invalid property /sub_issue_id: "12345" is not of type integer`. -gh api --method POST /repos/$OWNER/$REPO/issues/$PARENT/sub_issues \ - -F sub_issue_id=$CHILD_ID -``` +File the follow-up as a sub-issue under its parent — the body carries the parent, the context (what you saw, where), why it's a sub-issue and not in-scope, and a suggested approach if you have one. See `references/<mode>-mode-commands.md` for the exact mechanics (github mode: create the child issue then attach it under the parent via the sub-issues REST endpoint; file mode: append a new `<!-- middle:sub-issue id=N -->` block to the Epic file via the renderer). ### Creating a parent for a natural collection -If you spotted ≥2 related items and there's no parent issue yet, create the parent FIRST, then file each item as a sub-issue under it. The parent issue body should describe the umbrella concern; sub-issue bodies stay focused on their specific scope. - -```bash -PARENT_URL=$(gh issue create --repo $OWNER/$REPO \ - --title "<umbrella concern>" \ - --body "Tracks several related items surfaced during PR #<pr>. See sub-issues.") -PARENT_NUM=$(basename "$PARENT_URL") -# Then file each child as a sub-issue under $PARENT_NUM as above. -``` +If you spotted ≥2 related items and there's no parent yet, create the parent FIRST, then file each item as a sub-issue under it. The parent describes the umbrella concern; sub-issue bodies stay focused on their specific scope. See `references/<mode>-mode-commands.md`. ### Standalone issue (the exception) -Only when the work is genuinely a different workstream — affects packages outside the current issue's surface area, or is a separate feature/initiative entirely. File without `--parent` linkage; the body's "Discovered while working on:" line is enough cross-reference. +Only when the work is genuinely a different workstream — affects packages outside the current Epic's surface area, or is a separate feature/initiative entirely. File without parent linkage; a "Discovered while working on:" line is enough cross-reference. See `references/<mode>-mode-commands.md`. ### PR description cross-linking @@ -601,14 +563,19 @@ git merge origin/main # one holistic resolution instead of N brittle per-comm ## Quick reference +Epic/plan/sub-issue/conversation operations are mode-specific — see `references/<mode>-mode-commands.md`. PR/CI operations are GitHub-native in both modes and listed inline here. + | Step | Command | |---|---| -| Fetch issue | `gh issue view <n> --json number,title,body,labels,comments,url` | +| Fetch the Epic | mode-specific — `references/<mode>-mode-commands.md` | +| Post the plan to the Epic | mode-specific — `references/<mode>-mode-commands.md` | +| Close a sub-issue with evidence | mode-specific — `references/<mode>-mode-commands.md` | +| File sub-issue under parent | mode-specific — `references/<mode>-mode-commands.md` (see Phase 9) | +| File standalone follow-up | mode-specific — `references/<mode>-mode-commands.md` (only if truly parallel/new workstream) | | Enable conflict-resolution replay | `git config rerere.enabled true` (once, at workstream start) | | Sync branch (default) | `git fetch origin && git rebase origin/main` | | Sync branch (deep interleave) | `git fetch origin && git merge origin/main` (new-work-as-base; re-verify) | | Check mergeability before ready | `gh pr view <n> --json mergeable,mergeStateStatus` | -| Comment plan | `gh issue comment <n> --body-file planning/issues/<n>/plan.md` | | Open draft PR up front | `gh pr create --draft --title "..." --body "..."` | | Update PR body | `gh pr edit <n> --body-file ...` (or `gh api PATCH ...` if blocked) | | Mark PR ready | `gh pr ready <n>` | @@ -616,8 +583,6 @@ git merge origin/main # one holistic resolution instead of N brittle per-comm | Verify functionally | `pnpm test`, `pnpm typecheck`, `pnpm build` (per project) | | Worktree fork option | `git worktree add .claude/worktrees/<branch>-fork-A -b <branch>-fork-A` | | Review comment | `gh api .../pulls/<n>/comments -F line=N -f path=... -f body=...` | -| File sub-issue under parent | `gh issue create … && gh api …/issues/$PARENT/sub_issues -f sub_issue_id=$ID` (see Phase 9) | -| File standalone follow-up | `gh issue create --title "..."` (only if truly parallel/new workstream) | | Get PR comments | `gh api repos/{o}/{r}/pulls/<n>/comments` | ## Red flags — STOP and self-correct @@ -649,7 +614,7 @@ These thoughts mean you're about to violate the workflow: | "The PR description can just be the commit list" | Full report or it didn't happen. Why > what. Include verification evidence per phase. | | "No need for stumbling points — it went fine" | Then your "Stumbling points" section is "None." But write the section. | | "Decisions log is overkill for this issue" | It's overkill *until* you need to write the PR review comments. Then it's the source. | -| "Initial plan turned out wrong, no need to update" | Edit `plan.md` and edit the issue comment (`gh issue comment --edit-last`). The plan must reflect reality. | +| "Initial plan turned out wrong, no need to update" | Edit `plan.md` and update the plan on the Epic (see `references/<mode>-mode-commands.md`). The plan must reflect reality. | ## Common mistakes @@ -689,7 +654,7 @@ Everything above describes the skill running interactively. When **middle-manage ### You are pointed at an Epic — its sub-issues are your plan phases -middle dispatches **Epics**, not individual issues. The issue you're pointed at is an Epic: it has sub-issues, and **its open sub-issues ARE the phases of your plan**. Don't invent a phase breakdown — fetch the Epic's sub-issues (`gh api /repos/{owner}/{repo}/issues/{epic}/sub_issues`), and each one is a phase. Your `plan.md` Phases list and the PR's Status checkboxes are one-per-sub-issue. +middle dispatches **Epics**, not individual issues. The Epic you're pointed at has sub-issues, and **its open sub-issues ARE the phases of your plan**. Don't invent a phase breakdown — fetch the Epic's sub-issues (see `references/<mode>-mode-commands.md`: github mode reads the sub-issues REST graph; file mode reads the `<!-- middle:sub-issue id=N -->` blocks in the Epic file), and each one is a phase. Your `plan.md` Phases list and the PR's Status checkboxes are one-per-sub-issue. One Epic → one worktree → one branch → one PR. You work *down* the sub-issues in dependency order on that single branch, ticking each Status checkbox as its sub-issue's work verifies. Do **not** open a PR per sub-issue, and do **not** wait for review between sub-issues — the whole Epic is reviewed once, as one PR, when every sub-issue is done. @@ -701,7 +666,7 @@ The dispatcher created your worktree and branch and spawned you inside it. Do ** ### Asking a question = write `.middle/blocked.json` and exit (overrides Phase 2's "comment and wait") -You cannot "comment on the issue and wait" — headless, there is nothing to wait *in*. When you genuinely need human input (ambiguous acceptance criteria, a decision CLAUDE.md/skills/docs don't resolve and that isn't worth a fork), write `<worktree>/.middle/blocked.json` containing the question and the context a human needs to answer it, then **exit cleanly**. Middle's exit classifier detects the sentinel, parks the workflow on a `waitFor` signal, and surfaces the question on the issue. Do not guess past a real blocker; do not spin idle. +You cannot "comment on the Epic and wait" — headless, there is nothing to wait *in*. When you genuinely need human input (ambiguous acceptance criteria, a decision CLAUDE.md/skills/docs don't resolve and that isn't worth a fork), write `<worktree>/.middle/blocked.json` containing the question and the context a human needs to answer it, then **exit cleanly**. The `blocked.json` sentinel is mode-agnostic — you write it the same way in both modes. Middle's exit classifier detects it, parks the workflow on a `waitFor` signal, and surfaces the question on the Epic (github mode: an issue comment; file mode: a `<!-- middle:question -->` block the dispatcher appends to the Epic file via the renderer). The human answers (github mode: a reply comment; file mode: editing the `<!-- middle:answer -->` block, or running `mm resume <repo> <slug> --answer "…"`), and you're re-spawned with the answer. Do not guess past a real blocker; do not spin idle. ### Complexity is fork branching factor — pause the sub-issue past the ceiling @@ -718,7 +683,7 @@ When a human answers, middle re-spawns you with the answer injected into your pr ### The plan comment is mechanically gated (reinforces Phase 4) -After your plan step, the dispatcher's **plan-comment guard** verifies a comment by your account containing the plan body exists on the issue. No plan comment → the workflow fails. Phase 4 was always "non-negotiable"; under middle it is literally enforced. +After your plan step, the dispatcher's **plan-comment guard** verifies the plan body was posted to the Epic (github mode: a comment by your account on the issue; file mode: a plan entry in the Epic file's conversation section, written by the renderer). No plan on the Epic → the workflow fails. Phase 4 was always "non-negotiable"; under middle it is literally enforced. ### `gh pr ready` is mechanically gated (reinforces Phase 10) diff --git a/packages/cli/src/bootstrap-assets/skills/implementing-github-issues/references/file-mode-commands.md b/packages/cli/src/bootstrap-assets/skills/implementing-github-issues/references/file-mode-commands.md new file mode 100644 index 00000000..066b8e4e --- /dev/null +++ b/packages/cli/src/bootstrap-assets/skills/implementing-github-issues/references/file-mode-commands.md @@ -0,0 +1,116 @@ +# implementing-github-issues — file-mode commands + +The file-mode equivalents of every Epic/plan/sub-issue/conversation operation the skill body refers to mode-agnostically. In **file mode** the Epic is a Markdown file at `planning/epics/<slug>.md` (the slug is the file's stem and the canonical Epic reference), and the agent-↔-human conversation lives in that file's `<!-- middle:conversation -->` section. **PRs, reviews, and CI stay GitHub-native** — the PR/CI commands are identical to github mode (`gh pr …`). + +## The one rule that governs every write below + +**The renderer is the sole writer of strict markers.** Every `<!-- middle:* -->` marker (and its strict attribute line — `id=`, `status=`, `ts=`, `kind=`, the `<!-- middle:meta -->` keys) is written and rewritten only by the dispatcher's renderer (`renderEpicFile`). You never hand-edit a strict marker or its attributes. You write **only** between markers — sub-issue checkboxes, prose bodies, conversation entry bodies. This is what keeps #180's writer/parser-drift class closed for file mode, and it's what makes the file's byte-identical round-trip invariant hold under concurrent dispatcher + human edits. + +Practically: the dispatcher appends conversation entries (plan, dispatch-event, question) **for** you via the renderer when you write `.middle/blocked.json` or hit a gated step; you flip sub-issue checkboxes and append provenance prose yourself. + +## The Epic file format (mirror these marker names exactly) + +```markdown +<!-- middle:epic v1 --> +# <Title> + +<!-- middle:meta +slug: <slug> +adapter: <claude|codex> # optional +complexity_ceiling: <N> # optional +approved: <true|false> # optional +labels: [<label>, <label>] # optional, informational +blocked-by: [<other-slug>] # optional, cross-Epic deps +pr: <number> # stamped by dispatcher when the PR opens +--> + +## Context +<prose> + +## Acceptance criteria +- [ ] <criterion> + +## Sub-issues + +<!-- middle:sub-issue id=1 --> +- [ ] **1 — <title>** + <prose body> + *Acceptance:* <…> +<!-- /middle:sub-issue --> + +<!-- middle:conversation --> +<!-- /middle:conversation --> +``` + +## Fetch the Epic's context (Phase 1) + +Read the Epic file: + +```bash +cat planning/epics/<slug>.md +``` + +Read the body, the `## Acceptance criteria`, every `<!-- middle:sub-issue -->` block, and every entry inside `<!-- middle:conversation -->` — questions, dispatch events, and any answers are all there. The latest decisions are often in the conversation section. + +## Fetch the Epic's sub-issues (the phases of your plan) + +Each `<!-- middle:sub-issue id=N -->` block is one phase. An *open* sub-issue is one whose checkbox is unchecked (`- [ ]`); a *closed* one is checked (`- [x]`). Work the open ones in dependency order (`*Blocked by:* N` lines express the order). + +## Post the plan to the Epic (Phase 4) + +The plan goes into the Epic file's `<!-- middle:conversation -->` section as a conversation entry — **written by the renderer, not by hand.** Under middle's dispatch this is the plan step the dispatcher records via the renderer; the plan-comment guard then verifies a plan entry exists in the conversation section. You author the plan body (in `planning/epics/<slug>.md`'s adjacent `planning/issues/<slug>/plan.md`, same as github mode); the renderer appends it to the conversation. Do not edit the conversation markers yourself. + +## Close a sub-issue with evidence + +Closing a sub-issue = flipping its checkbox from `- [ ]` to `- [x]` and appending a one-line provenance suffix to the title line. The checkbox and the title prose are *between* markers, so you edit them directly: + +```markdown +<!-- middle:sub-issue id=1 --> +- [x] **1 — Implement the CodexAdapter** *(done in wf_…oyy4c4m1, sha abc1234)* + Full AgentAdapter: … +<!-- /middle:sub-issue --> +``` + +The recommender's "open sub-issues" count scans for unchecked boxes, so a checked box with a provenance suffix is the file-mode equivalent of `gh issue close --reason completed --comment "Done in <sha> …"`. Do **not** touch the `<!-- middle:sub-issue id=N -->` marker or its `id=` attribute — only the checkbox glyph and the prose. + +## Ask a question / surface a blocker + +Identical agent action to github mode: write `<worktree>/.middle/blocked.json` and exit. The dispatcher's file-backed writer appends a `<!-- middle:question id=N status=open … -->` block to the conversation section **via the renderer** — you never write the question marker yourself. The human answers by editing the `<!-- middle:answer for=N -->` block in the file (the file-watcher fires resume when that block becomes non-empty) or by running: + +```bash +mm resume <repo> <slug> --answer "…" +``` + +`mm resume` is the manual unblock — the Phase 1 escape hatch before the watcher, and a permanent fallback. + +## File a follow-up as a sub-issue under a parent (Phase 9) + +A sub-issue is a new `<!-- middle:sub-issue id=N -->` block in the Epic file. Append it **via the renderer** (the renderer assigns the next `id` and emits the strict marker) — under middle's dispatch this is the same write path the dispatcher uses; do not hand-author the marker. Author the block body: + +```markdown +- [ ] **N — <descriptive title>** + Context: <what you saw, where>. + Why a sub-issue and not in-scope: <…>. + *Suggested approach:* <if you have one> +``` + +A "parent for a natural collection" is the Epic itself — file each related item as an additional sub-issue block under `## Sub-issues`. **There is no `gh issue create` in file mode** for Epic data; everything lives in the Epic file. + +## File a standalone follow-up (the exception) + +A genuinely cross-workstream item is a *new Epic file*: author `planning/epics/<other-slug>.md` with its own `<!-- middle:epic v1 -->` + `<!-- middle:meta -->` (see "creating-github-issues" file-mode addendum). A "Discovered while working on: <slug>" line in its Context is the cross-reference. Again — no `gh issue create`. + +## PR / CI operations (GitHub-native — same as github mode) + +PRs, reviews, and CI are GitHub-native in file mode too. Use the same commands the skill body lists inline: + +| Operation | Command | +|---|---| +| Open draft PR up front | `gh pr create --draft --title "..." --body "..."` (include `<!-- middle:epic <slug> -->` in the PR body so `findEpicPr` can match it) | +| Update PR body | `gh pr edit <pr> --body-file ...` (or PATCH via `gh api`) | +| Check mergeability | `gh pr view <pr> --json mergeable,mergeStateStatus` | +| Mark PR ready | `gh pr ready <pr>` | +| Post a file/line review comment | `gh api .../pulls/<pr>/comments -F line=N -f path=... -f body=...` | +| Get PR review comments | `gh api repos/{o}/{r}/pulls/<pr>/comments` | + +The dispatcher stamps `pr: <number>` into the Epic file's `<!-- middle:meta -->` when the PR opens (a durable backup for the PR-body marker) — that write is the renderer's, not yours. diff --git a/packages/cli/src/bootstrap-assets/skills/implementing-github-issues/references/github-mode-commands.md b/packages/cli/src/bootstrap-assets/skills/implementing-github-issues/references/github-mode-commands.md new file mode 100644 index 00000000..38d8476e --- /dev/null +++ b/packages/cli/src/bootstrap-assets/skills/implementing-github-issues/references/github-mode-commands.md @@ -0,0 +1,116 @@ +# implementing-github-issues — github-mode commands + +The concrete `gh` incantations for every Epic/plan/sub-issue/conversation operation the skill body refers to mode-agnostically. **github mode** is the default: the Epic is a GitHub issue, its sub-issues are native GitHub sub-issues, and the agent-↔-human conversation flows through issue comments. PRs, reviews, and CI are GitHub-native here too (and identical in file mode). + +Throughout, `<epic>` is the Epic's issue number, `<owner>`/`<repo>` the repository. + +## Fetch the Epic's context (Phase 1) + +```bash +gh issue view <epic> --json number,title,body,labels,assignees,milestone,comments,url +``` + +Read the body AND every comment — the latest decisions are often in comments. + +## Fetch the Epic's sub-issues (the phases of your plan) + +GitHub exposes sub-issues via a REST endpoint (`gh` has no flag for it yet): + +```bash +gh api /repos/<owner>/<repo>/issues/<epic>/sub_issues \ + --jq '.[] | {number, title, state}' +``` + +Each open sub-issue is one phase. Work them in dependency order. + +## Post the plan to the Epic (Phase 4) + +The plan is a comment on the Epic by your account: + +```bash +gh issue comment <epic> --body-file planning/issues/<epic>/plan.md +``` + +The plan-comment guard greps for a comment by your account containing the plan body. If the plan changes, update it: + +```bash +gh issue comment <epic> --edit-last --body-file planning/issues/<epic>/plan.md +``` + +(`--edit-last` has been unreliable in some cases — if it edits the wrong comment, post a fresh comment instead and note the supersession.) + +## Close a sub-issue with evidence + +When sub-issue N's work is verified and landed, close it with a comment that marks where it landed. The Epic auto-checks it off: + +```bash +gh issue close <sub-issue-number> --reason completed \ + --comment "Done in <sha> on PR #<pr> — <area>" +``` + +## Ask a question / surface a blocker + +You don't post the question yourself when headless — write `<worktree>/.middle/blocked.json` and exit. The dispatcher posts the question as an issue comment on the Epic and parks the workflow. The human answers by replying on the issue (or `mm resume <repo> <epic> --answer "…"`). + +## File a follow-up as a sub-issue under a parent (Phase 9) + +`gh` CLI doesn't have a `--parent` flag, so attach via the sub-issues REST endpoint: + +```bash +OWNER=<owner>; REPO=<repo>; PARENT=<parent-issue-number> + +# 1. Create the child issue +URL=$(gh issue create --repo $OWNER/$REPO \ + --title "<descriptive title>" \ + --body "$(cat <<'EOF' +**Parent:** #<parent-num> (PR #<pr> surfaced this) + +**Context:** <what you saw, where> + +**Why a sub-issue and not in-scope:** <e.g., "parallelizable; another agent can pick this up while we work on Phase 2"> + +**Suggested approach:** <if you have one — otherwise omit> +EOF +)") +CHILD_NUM=$(basename "$URL") + +# 2. Look up the child's database id (NOT issue number, NOT node_id) +CHILD_ID=$(gh api /repos/$OWNER/$REPO/issues/$CHILD_NUM --jq '.id') + +# 3. Attach as sub-issue under parent. +# CRITICAL: use -F (integer) not -f (string). The endpoint rejects strings: +# `Invalid property /sub_issue_id: "12345" is not of type integer`. +gh api --method POST /repos/$OWNER/$REPO/issues/$PARENT/sub_issues \ + -F sub_issue_id=$CHILD_ID +``` + +## Create a parent for a natural collection + +```bash +PARENT_URL=$(gh issue create --repo $OWNER/$REPO \ + --title "<umbrella concern>" \ + --body "Tracks several related items surfaced during PR #<pr>. See sub-issues.") +PARENT_NUM=$(basename "$PARENT_URL") +# Then file each child as a sub-issue under $PARENT_NUM as above. +``` + +## File a standalone follow-up (the exception) + +Only when the work is a genuinely different workstream. Skip the sub-issue attachment; a "Discovered while working on: #<epic>" line in the body is enough cross-reference: + +```bash +gh issue create --repo <owner>/<repo> --title "<descriptive title>" --body "..." +``` + +## PR / CI operations (identical in file mode) + +These are GitHub-native in both modes — listed here for completeness; the same commands appear inline in the skill body. + +| Operation | Command | +|---|---| +| Open draft PR up front | `gh pr create --draft --title "..." --body "..."` | +| Update PR body | `gh pr edit <pr> --body-file ...` (or PATCH via `gh api` if the projects-classic GraphQL bug bites) | +| Check mergeability | `gh pr view <pr> --json mergeable,mergeStateStatus` | +| Mark PR ready | `gh pr ready <pr>` | +| Post a file/line review comment | `gh api .../pulls/<pr>/comments -F line=N -f path=... -f body=...` | +| Get PR review comments | `gh api repos/{o}/{r}/pulls/<pr>/comments` | diff --git a/packages/cli/src/bootstrap-assets/skills/recommending-github-issues/SKILL.md b/packages/cli/src/bootstrap-assets/skills/recommending-github-issues/SKILL.md index 8540625e..c379d660 100644 --- a/packages/cli/src/bootstrap-assets/skills/recommending-github-issues/SKILL.md +++ b/packages/cli/src/bootstrap-assets/skills/recommending-github-issues/SKILL.md @@ -6,9 +6,20 @@ allowed-tools: Bash(gh:*), Bash(git:log:*), Bash(git:status), Read, Grep, Glob # Recommending GitHub Issues -You are the dispatch recommender for a single GitHub repository. Your only job -is to rewrite ONE state issue's body with a ranked plan of work to dispatch and -a digest of items needing human attention. +You are the dispatch recommender for a single repository. Your only job is to +rewrite ONE **state body** with a ranked plan of work to dispatch and a digest +of items needing human attention. + +**Mode-specific commands:** the repo runs in one of two modes. In **github mode** +the dispatch units are GitHub issues/Epics and the state body is a GitHub issue +(the `agent-queue:state` issue). In **file mode** the dispatch units are Epic +files under `epics_dir` and the state body is the `state_file` on disk. This +skill body is mode-agnostic — it says "fetch the repo's dispatch units", "read +the prior state body", "write the state body". The concrete reads/writes live in +`references/<mode>-mode-commands.md` (`github-mode-commands.md` / +`file-mode-commands.md`), mirrored into your worktree at +`.middle/skills/recommending-github-issues/references/` for your run's mode. PRs, +reviews, and CI are GitHub-native in *both* modes (`gh pr …`). middle dispatches **Epics** (issues with sub-issues) and **standalone issues** — never bare sub-issues. You rank dispatch units, not individual sub-issues. A @@ -52,28 +63,24 @@ the dispatcher inputs. ### Phase 2 — Fetch repo state and resolve the Epic graph -Run, in order: - -```bash -gh issue list --state open --limit 200 \ - --json number,title,labels,assignees,body,comments,createdAt,updatedAt -gh pr list --state open --limit 100 \ - --json number,title,labels,headRefName,isDraft,reviewDecision,statusCheckRollup,body,createdAt,updatedAt -``` - -If >200 open issues, filter to `--label agent-queue:eligible` (document the filter -you used in your run-summary comment). - -Then resolve the **dispatch-unit structure** from GitHub's native sub-issue graph -(`gh api /repos/{owner}/{repo}/issues/{n}/sub_issues`): -- An issue with sub-issues is an **Epic** — a dispatch unit. -- An issue with a parent is a **sub-issue** — NOT a dispatch unit. It is scope inside - its Epic; never classify or rank it on its own. -- An issue with neither is a **standalone issue** — a dispatch unit (a one-phase Epic). - -**Exclude the state issue itself.** The issue you are rewriting (and any issue carrying the -`agent-queue:state` label) is the dispatcher's surface, never a dispatch unit. Never classify -or rank it. +Fetch the repo's **dispatch units** and **open PRs**, and resolve each unit's +sub-issue structure. The exact reads are mode-specific — see +`references/<mode>-mode-commands.md`. In github mode you list open issues + PRs +and read the native sub-issue graph; in file mode you scan `epics_dir` for Epic +files, parse each one's `<!-- middle:meta -->` and sub-issue blocks, and still +list open PRs from GitHub. + +Resolve the **dispatch-unit structure**: +- A unit with sub-issues is an **Epic** — a dispatch unit. +- A sub-issue (an issue with a parent in github mode; a `<!-- middle:sub-issue -->` + block inside an Epic file in file mode) is **NOT** a dispatch unit. It is scope + inside its Epic; never classify or rank it on its own. +- A unit with neither is a **standalone issue** — a dispatch unit (a one-phase Epic). + +**Exclude the state surface itself.** In github mode the issue you are rewriting +(and any issue carrying the `agent-queue:state` label) is the dispatcher's surface, +never a dispatch unit. In file mode the `state_file` is not an Epic file and never +appears in `epics_dir`. Never classify or rank the state surface. **Cross-reference open PRs to detect in-flight / awaiting-review units.** The dispatcher's `in_flight` is authoritative when present, but it can be empty or stale (e.g. the dispatcher @@ -159,11 +166,14 @@ Verify before writing: ### Phase 6 — Write and log -```bash -gh issue edit <state_issue> --body-file <generated-body.md> -``` +Write the rendered body to the state surface — see `references/<mode>-mode-commands.md`. +In github mode it's `gh issue edit <state_issue> --body-file …`. In file mode you +write `state_file` **via the renderer** (`renderStateIssue`), never by hand — the +renderer is the sole writer, which closes #180's class for this skill too. -Then post a single comment with the diff summary against prior_body: +Then log a single diff summary against prior_body. In github mode that's a comment +on the state issue; in file mode the run summary is recorded the same way the +dispatcher records it (no separate GitHub comment — the state surface is a file): > ## Run a3f8c10b summary > @@ -212,13 +222,17 @@ the problem and stop. Dispatcher will surface to human. ## Files this skill creates -None on filesystem. Output is the state issue body via `gh issue edit` and one -diff comment via `gh issue comment`. +Mode-dependent (see `references/<mode>-mode-commands.md`). In github mode: none on +filesystem — output is the state issue body via `gh issue edit` plus one diff +comment. In file mode: the `state_file` on disk, written via `renderStateIssue` +(the renderer is the sole writer — never hand-edited). ## Files this skill reads - Schema at the path provided by the dispatcher -- Repo's open issues and PRs via `gh`, and the sub-issue graph via `gh api` +- The repo's dispatch units and their sub-issue structure (github mode: open issues + + the sub-issue graph via `gh`; file mode: Epic files scanned from `epics_dir`) +- Open PRs via `gh` (GitHub-native in both modes) - Recent git log on main - Source files when needed to assess Epic readiness (skim, don't read fully) — the - sub-issue count comes from the graph, never from estimation + sub-issue count comes from the graph/file, never from estimation diff --git a/packages/cli/src/bootstrap-assets/skills/recommending-github-issues/references/file-mode-commands.md b/packages/cli/src/bootstrap-assets/skills/recommending-github-issues/references/file-mode-commands.md new file mode 100644 index 00000000..26ca08c6 --- /dev/null +++ b/packages/cli/src/bootstrap-assets/skills/recommending-github-issues/references/file-mode-commands.md @@ -0,0 +1,85 @@ +# recommending-github-issues — file-mode commands + +The file-mode equivalents of the recommender's state read/write. In **file mode** +the dispatch units are Epic files under `epics_dir` (default `planning/epics/`), +and the state body is the `state_file` on disk (default `.middle/state.md`). PRs, +reviews, and CI are GitHub-native (`gh pr …`) — only the Epic *data* and the state +body are file-backed. + +## The one rule that governs the write + +**The renderer is the sole writer of the state body.** You write `state_file` via +`renderStateIssue` (the same parser + renderer + byte-identical-round-trip +invariant as github mode's state-issue flow) — **never by hand**. There is no +recommender-agent rewriting strict sections out-of-band; this closes #180's class +entirely for file mode. You compose the state model and render it; you do not +hand-edit the file's markers or the dispatcher-owned sections (In-flight, Rate +limits, Slot usage). + +## Scan the dispatch units (Phase 2) + +The recommender **scans `epics_dir`** for Epic files and parses each: + +```bash +ls epics_dir/*.md # epics_dir from the repo config (default planning/epics/) +``` + +For each `planning/epics/<slug>.md`: +- Read `<!-- middle:meta -->` for `slug`, `adapter`, `labels`, `approved`, + `closed`, and `blocked-by` (the cross-Epic dependency slugs the graph builder + reads). +- Skip files marked `closed: true` in meta — they're out of the open set. +- Each `<!-- middle:sub-issue id=N -->` block is a phase; an **open** sub-issue is + an unchecked box (`- [ ]`), a **closed** one is checked (`- [x]`). The open- + sub-issue count is the Epic's phase count — a fact from the file, never an + estimate. +- An Epic with no open sub-issues is `excluded` (`no open sub-issues`). + +The `state_file` is not an Epic file and never appears in `epics_dir` — it is never +a dispatch unit. + +## Cross-reference open PRs (Phase 2, GitHub-native) + +PRs/reviews/CI stay on GitHub in file mode. Match each open PR to its Epic by the +`<!-- middle:epic <slug> -->` marker in the PR body (or the `pr:` field in the Epic +file's `<!-- middle:meta -->`): + +```bash +gh pr list --state open --limit 100 \ + --json number,title,headRefName,isDraft,reviewDecision,statusCheckRollup,body,createdAt,updatedAt +``` + +An Epic with an open draft PR is in-flight; with a ready (non-draft) PR is +`needs-human` (awaiting review). + +## Cross-Epic blocked-by + +In file mode the "blocked on" relationship is a slug reference in each Epic's meta: + +```yaml +<!-- middle:meta +slug: copilot-adapter +blocked-by: [codex-adapter] +--> +``` + +The graph builder reads `blocked-by` slugs to mark a unit `blocked` until its +blocker Epic closes. + +## Write the state body (Phase 6) + +Render the composed state model and write it to `state_file` **via +`renderStateIssue`** (atomic write — temp + rename — is the gateway's job). Do not +hand-edit `state_file`. + +The run-summary diff against `prior_body` is recorded the same way the dispatcher +records it for a file-backed state — there is no separate GitHub comment, because +the state surface is a file, not an issue. + +## What you never do + +- Never write `state_file` by hand — only via `renderStateIssue`. +- Never author or edit an Epic file (that's the implementer's / creator's job). +- Never `gh pr create` / `gh pr merge` / `gh pr review`. +- Never touch dispatcher-owned state sections (In-flight, Rate limits, Slots) — + copy them from dispatcher input verbatim. diff --git a/packages/cli/src/bootstrap-assets/skills/recommending-github-issues/references/github-mode-commands.md b/packages/cli/src/bootstrap-assets/skills/recommending-github-issues/references/github-mode-commands.md new file mode 100644 index 00000000..81712e42 --- /dev/null +++ b/packages/cli/src/bootstrap-assets/skills/recommending-github-issues/references/github-mode-commands.md @@ -0,0 +1,65 @@ +# recommending-github-issues — github-mode commands + +The concrete state-issue read/write commands for **github mode**: dispatch units +are GitHub issues/Epics, and the state body is the `agent-queue:state` issue. + +## Fetch repo state and resolve the Epic graph (Phase 2) + +```bash +gh issue list --state open --limit 200 \ + --json number,title,labels,assignees,body,comments,createdAt,updatedAt +gh pr list --state open --limit 100 \ + --json number,title,labels,headRefName,isDraft,reviewDecision,statusCheckRollup,body,createdAt,updatedAt +``` + +If >200 open issues, filter to `--label agent-queue:eligible` (document the filter +you used in your run-summary comment). + +Then resolve the dispatch-unit structure from GitHub's native sub-issue graph: + +```bash +gh api /repos/{owner}/{repo}/issues/{n}/sub_issues +``` + +- An issue with sub-issues is an **Epic** — a dispatch unit. +- An issue with a parent is a **sub-issue** — never a dispatch unit. +- An issue with neither is a **standalone issue** — a one-phase Epic. + +Exclude the state issue itself (and any issue carrying `agent-queue:state`). + +You may also gauge recent merge cadence: + +```bash +git log --oneline -50 main +``` + +## Read the prior state body (Phase 1) + +The dispatcher passes `prior_body` in your prompt. If you need to re-read it live: + +```bash +gh issue view <state_issue> --json body --jq '.body' +``` + +## Write the state body (Phase 6) + +```bash +gh issue edit <state_issue> --body-file <generated-body.md> +``` + +Then post a single diff-summary comment against `prior_body`: + +```bash +gh issue comment <state_issue> --body-file <run-summary.md> +``` + +If zero changes, post `No changes this run.` — confirms the recommender is alive +without polluting the timeline. + +## What you never do + +- Never `gh issue edit` any issue other than the state issue. +- Never `gh issue comment` on any issue other than the state issue. +- Never add or remove labels (`gh issue edit --add-label` / `--remove-label`). +- Never `gh pr create` / `gh pr merge` / `gh pr review` — you implement and merge + nothing. diff --git a/packages/dispatcher/src/build-deps.ts b/packages/dispatcher/src/build-deps.ts index 6d2c29e1..06da8f0a 100644 --- a/packages/dispatcher/src/build-deps.ts +++ b/packages/dispatcher/src/build-deps.ts @@ -210,6 +210,9 @@ export async function buildImplementationDeps( } }), resolveComplexityCeiling: args.resolveComplexityCeiling, + // The repo's Epic-store mode selects which mode-commands reference the brief + // mirrors into the worktree; read from `repo_config` (defaults to github). + resolveEpicStoreMode: (repo) => readEpicStoreConfig(args.db, repo).mode, // Default: the Epic is approved iff it carries the `approved` label (#53). isEpicApproved: args.isEpicApproved ?? diff --git a/packages/dispatcher/src/workflows/implementation.ts b/packages/dispatcher/src/workflows/implementation.ts index 97a07fae..aafc24ce 100644 --- a/packages/dispatcher/src/workflows/implementation.ts +++ b/packages/dispatcher/src/workflows/implementation.ts @@ -1,5 +1,5 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; import type { Database } from "bun:sqlite"; import type { AgentAdapter, HookPayload, StopClassification } from "@middle/core"; import { Workflow } from "bunqueue/workflow"; @@ -218,6 +218,13 @@ export type ImplementationDeps = { * reuses the prior round's worktree via `input.resume.worktree`. */ enqueueContinuation: (input: ImplementationInput) => Promise<void>; + /** + * The repo's Epic-store mode — selects which `references/<mode>-mode-commands.md` + * the dispatch brief mirrors into the worktree (the agent reads only the + * incantations that apply to its run). Injected so the workflow stays db-free; + * the dispatcher wires it to `readEpicStoreConfig`. Defaults to `"github"`. + */ + resolveEpicStoreMode?: (repo: string) => "github" | "file" | Promise<"github" | "file">; /** * The review-round ceiling: after this many `CHANGES_REQUESTED` passes without * an `APPROVED`, the workflow parks in `waiting-human` and stops auto-resuming @@ -300,6 +307,27 @@ function ensurePromptFile( writeFileSync(promptPath, defaultDispatchBrief(epicRef, complexityCeiling, approved)); } +/** The skill whose mode-specific command reference the dispatch brief mirrors. */ +const MODE_COMMANDS_SKILL = "implementing-github-issues"; + +/** + * Mirror the run's mode-specific commands reference into the worktree so the + * agent's implementer skill reads only the incantations that apply to its Epic + * store. Copies `<worktree>/.claude/skills/<skill>/references/<mode>-mode-commands.md` + * (installed by `mm init`) to `<worktree>/.middle/skills/<skill>/references/` — the + * mode-resolved single file the dispatch brief points the agent at. Best-effort: a + * worktree whose installed skill predates the per-mode references (no source file) + * is a no-op, never a dispatch failure. + */ +function mirrorModeCommands(worktreePath: string, mode: "github" | "file"): void { + const rel = join("skills", MODE_COMMANDS_SKILL, "references", `${mode}-mode-commands.md`); + const src = join(worktreePath, ".claude", rel); + if (!existsSync(src)) return; + const dest = join(worktreePath, ".middle", rel); + mkdirSync(dirname(dest), { recursive: true }); + copyFileSync(src, dest); +} + /** * The default dispatch brief written to `.middle/prompt.md`. Carries the repo's * `complexity_ceiling` so the agent knows its fork budget (the max candidate @@ -795,6 +823,18 @@ export function createImplementationWorkflow( ensurePromptFile(handle.path, ctx.input.epicRef, complexityCeiling, approved); } + // Mirror the run's mode-specific commands reference into the worktree so the + // implementer skill reads only the incantations for this Epic's store. Always + // run (not gated on the prompt.md write above) and failure-safe. + try { + const mode = deps.resolveEpicStoreMode + ? await deps.resolveEpicStoreMode(ctx.input.repo) + : "github"; + mirrorModeCommands(handle.path, mode); + } catch (error) { + console.error(`${tag} mode-commands mirror skipped: ${(error as Error).message}`); + } + console.error(`${tag} installing hooks in ${handle.path}`); await adapter.installHooks({ worktree: handle.path, diff --git a/packages/dispatcher/test/epic-store/mode-commands-mirror.test.ts b/packages/dispatcher/test/epic-store/mode-commands-mirror.test.ts new file mode 100644 index 00000000..0d934771 --- /dev/null +++ b/packages/dispatcher/test/epic-store/mode-commands-mirror.test.ts @@ -0,0 +1,214 @@ +/** + * Integration (#195): a file-mode dispatch mirrors the file-mode commands + * reference into the dispatched agent's worktree. Drives the real implementation + * workflow (stub adapter/gate/tmux, real engine + `createWorktree`) for a repo + * whose installed skill carries `references/file-mode-commands.md`, and asserts + * the worktree gains `.middle/skills/implementing-github-issues/references/ + * file-mode-commands.md` byte-identical to the source in `packages/skills/`. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + cpSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Engine } from "bunqueue/workflow"; +import type { AgentAdapter, HookPayload, StopClassification } from "@middle/core"; +import { openAndMigrate } from "../../src/db.ts"; +import type { SessionGate } from "../../src/hook-server.ts"; +import { renderEpicFile } from "../../src/epic-store/epic-file/renderer.ts"; +import { getWorkflow } from "../../src/workflow-record.ts"; +import { + createImplementationWorkflow, + type ImplementationDeps, +} from "../../src/workflows/implementation.ts"; +import { createWorktree, destroyWorktree } from "../../src/worktree.ts"; +import type { Database } from "bun:sqlite"; + +const SLUG = "rollout-epic-store"; +const REPO = "o/file-repo"; +const SKILL_REF_REL = join( + "skills", + "implementing-github-issues", + "references", + "file-mode-commands.md", +); +// The canonical reference the mirror's source is installed from (test runs from repo root). +const SOURCE_REF = join( + process.cwd(), + "packages", + "skills", + SKILL_REF_REL.replace(/^skills\//, ""), +); + +const GIT_ENV = { + ...process.env, + GIT_AUTHOR_NAME: "t", + GIT_AUTHOR_EMAIL: "t@e.invalid", + GIT_COMMITTER_NAME: "t", + GIT_COMMITTER_EMAIL: "t@e.invalid", +}; +async function git(cwd: string, args: string[]): Promise<void> { + const proc = Bun.spawn(["git", "-C", cwd, ...args], { + stdout: "ignore", + stderr: "pipe", + env: GIT_ENV, + }); + if ((await proc.exited) !== 0) { + throw new Error(`git ${args.join(" ")}: ${await new Response(proc.stderr).text()}`); + } +} + +let scratch: string; +let repoPath: string; +let worktreeRoot: string; +let db: Database; +let engine: Engine; + +beforeEach(async () => { + scratch = realpathSync(mkdtempSync(join(tmpdir(), "middle-mirror-"))); + repoPath = join(scratch, "repo"); + worktreeRoot = join(scratch, "worktrees"); + await git(scratch, ["init", "repo"]); + // Install the implementer skill's file-mode reference into the repo (as `mm init` + // would) and an Epic file, then commit so the git worktree checkout carries them. + const installedRef = join(repoPath, ".claude", SKILL_REF_REL); + mkdirSync(join(repoPath, ".claude", "skills", "implementing-github-issues", "references"), { + recursive: true, + }); + cpSync(SOURCE_REF, installedRef); + const epicsDir = join(repoPath, "planning", "epics"); + mkdirSync(epicsDir, { recursive: true }); + writeFileSync( + join(epicsDir, `${SLUG}.md`), + renderEpicFile({ + title: "feat: x", + meta: { slug: SLUG, adapter: "stub" }, + context: "ctx", + acceptanceCriteria: [{ checked: false, text: "ship" }], + subIssues: [{ id: 1, checked: false, title: "1 — gateways", body: "" }], + conversation: [], + }), + ); + await git(repoPath, ["add", "-A"]); + await git(repoPath, ["commit", "-m", "init"]); + db = openAndMigrate(join(scratch, "db.sqlite3")); + engine = new Engine({ embedded: true }); +}); + +afterEach(async () => { + await engine.close(true); + db.close(); + rmSync(scratch, { recursive: true, force: true }); +}); + +const hangingGate: SessionGate = { + awaitSessionStart: async () => + ({ session_id: "stub", transcript_path: "/tmp/stub.jsonl" }) as HookPayload, + awaitStop: () => new Promise<HookPayload>(() => {}), +}; + +function blockedAdapter(): AgentAdapter { + const asked: StopClassification = { + kind: "asked-question", + sentinelPath: "/x/.middle/blocked.json", + sentinel: { question: "A or B?" }, + }; + return { + name: "stub", + readyEvent: "session.started", + async installHooks(opts) { + mkdirSync(join(opts.worktree, ".middle"), { recursive: true }); + writeFileSync( + join(opts.worktree, ".middle", "blocked.json"), + JSON.stringify({ question: "?" }), + ); + }, + buildLaunchCommand: () => ({ argv: ["true"], env: {} }), + buildPromptText: () => "@.middle/prompt.md", + async enterAutoMode() {}, + resolveTranscriptPath: (p) => p.transcript_path as string, + readTranscriptState: () => ({ + lastActivity: "", + contextTokens: 0, + turnCount: 0, + lastToolUse: null, + }), + classifyStop: () => asked, + }; +} + +function makeDeps(over: Partial<ImplementationDeps>): ImplementationDeps { + return { + db, + getAdapter: () => blockedAdapter(), + sessionGate: hangingGate, + tmux: { + async newSession() {}, + async sendText() {}, + async sendEnter() {}, + async killSession() {}, + status: async () => ({ alive: false }), + }, + worktree: { createWorktree, destroyWorktree }, + resolveRepoPath: () => repoPath, + worktreeRoot, + dispatcherUrl: "http://127.0.0.1:8822", + launchTimeoutMs: 2000, + stopTimeoutMs: 2000, + livenessPollMs: 20, + enqueueContinuation: async () => {}, + postQuestion: async () => {}, + ...over, + }; +} + +async function awaitParked(id: string, timeoutMs = 6000): Promise<void> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (getWorkflow(db, id)?.state === "waiting-human") return; + await Bun.sleep(20); + } + throw new Error(`workflow ${id} did not park (state '${getWorkflow(db, id)?.state}')`); +} + +describe("dispatch brief — mode-commands mirror (#195)", () => { + test("a file-mode dispatch mirrors file-mode-commands.md into the worktree, byte-identical", async () => { + engine.register(createImplementationWorkflow(makeDeps({ resolveEpicStoreMode: () => "file" }))); + const handle = await engine.start("implementation", { + repo: REPO, + epicRef: SLUG, + adapter: "stub", + }); + await awaitParked(handle.id); + + const worktreePath = getWorkflow(db, handle.id)?.worktreePath; + expect(worktreePath).toBeTruthy(); + const mirrored = join(worktreePath!, ".middle", SKILL_REF_REL); + expect(readFileSync(mirrored, "utf8")).toBe(readFileSync(SOURCE_REF, "utf8")); + }); + + test("a github-mode dispatch does not mirror the file-mode reference", async () => { + engine.register( + createImplementationWorkflow(makeDeps({ resolveEpicStoreMode: () => "github" })), + ); + const handle = await engine.start("implementation", { + repo: REPO, + epicRef: SLUG, + adapter: "stub", + }); + await awaitParked(handle.id); + + const worktreePath = getWorkflow(db, handle.id)?.worktreePath; + // github mode mirrors github-mode-commands.md (absent here) → no file-mode file. + const mirrored = join(worktreePath!, ".middle", SKILL_REF_REL); + expect(() => readFileSync(mirrored, "utf8")).toThrow(); + }); +}); diff --git a/packages/skills/creating-github-issues/SKILL.md b/packages/skills/creating-github-issues/SKILL.md index 8fb5032b..faa19c6f 100644 --- a/packages/skills/creating-github-issues/SKILL.md +++ b/packages/skills/creating-github-issues/SKILL.md @@ -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. + ## 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. @@ -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. diff --git a/packages/skills/creating-github-issues/references/file-mode-commands.md b/packages/skills/creating-github-issues/references/file-mode-commands.md new file mode 100644 index 00000000..75affbe3 --- /dev/null +++ b/packages/skills/creating-github-issues/references/file-mode-commands.md @@ -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. diff --git a/packages/skills/implementing-github-issues/SKILL.md b/packages/skills/implementing-github-issues/SKILL.md index 4f0c548e..cf45bcba 100644 --- a/packages/skills/implementing-github-issues/SKILL.md +++ b/packages/skills/implementing-github-issues/SKILL.md @@ -6,7 +6,9 @@ allowed-tools: Bash(gh:*), Bash(git:*), Bash(pnpm:*), Bash(mkdir:*), Read, Write # Implementing GitHub Issues -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. +End-to-end workflow for taking an Epic from "assigned" to "PR open with verification evidence, marked ready for human review." All phases of one Epic land on **one branch** and **one PR**; the PR is the long-lasting context for the workstream. + +**Mode-specific commands:** the Epic's data and the agent-↔-human conversation live in one of two stores — a GitHub issue (**github mode**) or a Markdown file under `planning/epics/` (**file mode**). PRs, reviews, and CI are GitHub-native in *both* modes. This skill body is mode-agnostic: it says "fetch the Epic", "post the plan to the Epic", "close the sub-issue with evidence", "post the reviewer's brief", "mark the PR ready" — the concrete incantations live in `references/<mode>-mode-commands.md` (`github-mode-commands.md` / `file-mode-commands.md`), mirrored into your worktree at `.middle/skills/implementing-github-issues/references/` for your run's mode. Use that file for every Epic/plan/sub-issue/conversation operation; use the PR commands (identical in both modes) inline below. ## Dispatch brief (read first) @@ -147,17 +149,15 @@ git worktree add .claude/worktrees/<branch>-fork-B -b <branch>-fork-B **Once chosen, collapse and clean up.** Don't leave the losing branch hanging "just in case." Delete the worktree, delete the branch, close the PR. Forks are disambiguation, not insurance. -## Phase 1 — Fetch issue context +## Phase 1 — Fetch the Epic's context -```bash -gh issue view <num> --json number,title,body,labels,assignees,milestone,comments,url -``` +Fetch the Epic — its title, body, acceptance criteria, sub-issues, and conversation log (see `references/<mode>-mode-commands.md` for the exact read). -Read the body AND every comment. The latest decisions are often in comments, not the description. Note: +Read the body AND every conversation entry. The latest decisions are often in the conversation, not the description. Note: - Acceptance criteria (explicit or implicit) - Linked issues / PRs / discussions - Constraints called out by the reporter -- Anyone @-mentioned who might be a stakeholder +- Anyone called out who might be a stakeholder ## Phase 2 — Research the codebase @@ -168,7 +168,7 @@ Before drafting a plan, ground yourself: - Read the relevant `CLAUDE.md` (root + nested) — these are the source of architectural patterns and conventions - For broad investigations, dispatch `Explore` subagent (50-100x context savings) -**STOP if:** The issue is ambiguous, the acceptance criteria are unclear, or the research reveals the issue's premise is wrong. Comment on the issue with your questions and wait, rather than guessing. +**STOP if:** The Epic is ambiguous, the acceptance criteria are unclear, or the research reveals the Epic's premise is wrong. Post your questions to the Epic's conversation and wait, rather than guessing. (Headless under middle, "ask a question" is a sentinel-write — see "Running under middle".) ## Phase 3 — Draft a lightweight plan @@ -204,15 +204,13 @@ N. ... **Lightweight means lightweight.** If you're writing more than ~100 lines for a multi-phase plan, you're either over-planning or the issue should be split. For genuinely complex multi-day work, use `superpowers:writing-plans` and link from the issue comment. -## Phase 4 — Post plan as issue comment +## Phase 4 — Post the plan to the Epic -```bash -gh issue comment <num> --body-file planning/issues/<num>/plan.md -``` +Post the plan body to the Epic's conversation (the Epic's **plan comment**). See `references/<mode>-mode-commands.md` for the exact write — in github mode it's an issue comment; in file mode the renderer appends it to the Epic file's conversation section (never hand-edit the strict markers). -This is non-negotiable. The plan-as-comment serves three purposes: +This is non-negotiable. The plan-on-the-Epic serves three purposes: 1. The reporter / stakeholders can correct your direction before you write code -2. It's discoverable from the issue itself (not buried in a branch) +2. It's discoverable from the Epic itself (not buried in a branch) 3. It creates a public commitment that disciplines the work If you skip this step, you've broken the contract of this skill. @@ -351,7 +349,7 @@ If during implementation you spot: - Performance concerns you noticed but didn't fix - API surface that should be reconsidered -…before reaching for `gh issue create`, walk this decision tree: +…before filing a new follow-up, walk this decision tree: ```dot digraph followup_decision { @@ -393,51 +391,15 @@ If you genuinely believe an acceptance-criterion item should be deferred, ask th ### Filing a sub-issue under an existing parent -GitHub supports native sub-issues via REST API. `gh` CLI 2.67+ doesn't have a `--parent` flag yet, so use `gh api`: - -```bash -OWNER=<owner>; REPO=<repo>; PARENT=<parent-issue-number> - -# 1. Create the child issue -URL=$(gh issue create --repo $OWNER/$REPO \ - --title "<descriptive title>" \ - --body "$(cat <<'EOF' -**Parent:** #<parent-num> (PR #<pr> surfaced this) - -**Context:** <what you saw, where> - -**Why a sub-issue and not in-scope:** <e.g., "parallelizable; another agent can pick this up while we work on Phase 2"> - -**Suggested approach:** <if you have one — otherwise omit> -EOF -)") -CHILD_NUM=$(basename "$URL") - -# 2. Look up the child's database id (NOT issue number, NOT node_id) -CHILD_ID=$(gh api /repos/$OWNER/$REPO/issues/$CHILD_NUM --jq '.id') - -# 3. Attach as sub-issue under parent. -# CRITICAL: use -F (integer) not -f (string). The endpoint rejects strings: -# `Invalid property /sub_issue_id: "12345" is not of type integer`. -gh api --method POST /repos/$OWNER/$REPO/issues/$PARENT/sub_issues \ - -F sub_issue_id=$CHILD_ID -``` +File the follow-up as a sub-issue under its parent — the body carries the parent, the context (what you saw, where), why it's a sub-issue and not in-scope, and a suggested approach if you have one. See `references/<mode>-mode-commands.md` for the exact mechanics (github mode: create the child issue then attach it under the parent via the sub-issues REST endpoint; file mode: append a new `<!-- middle:sub-issue id=N -->` block to the Epic file via the renderer). ### Creating a parent for a natural collection -If you spotted ≥2 related items and there's no parent issue yet, create the parent FIRST, then file each item as a sub-issue under it. The parent issue body should describe the umbrella concern; sub-issue bodies stay focused on their specific scope. - -```bash -PARENT_URL=$(gh issue create --repo $OWNER/$REPO \ - --title "<umbrella concern>" \ - --body "Tracks several related items surfaced during PR #<pr>. See sub-issues.") -PARENT_NUM=$(basename "$PARENT_URL") -# Then file each child as a sub-issue under $PARENT_NUM as above. -``` +If you spotted ≥2 related items and there's no parent yet, create the parent FIRST, then file each item as a sub-issue under it. The parent describes the umbrella concern; sub-issue bodies stay focused on their specific scope. See `references/<mode>-mode-commands.md`. ### Standalone issue (the exception) -Only when the work is genuinely a different workstream — affects packages outside the current issue's surface area, or is a separate feature/initiative entirely. File without `--parent` linkage; the body's "Discovered while working on:" line is enough cross-reference. +Only when the work is genuinely a different workstream — affects packages outside the current Epic's surface area, or is a separate feature/initiative entirely. File without parent linkage; a "Discovered while working on:" line is enough cross-reference. See `references/<mode>-mode-commands.md`. ### PR description cross-linking @@ -601,14 +563,19 @@ git merge origin/main # one holistic resolution instead of N brittle per-comm ## Quick reference +Epic/plan/sub-issue/conversation operations are mode-specific — see `references/<mode>-mode-commands.md`. PR/CI operations are GitHub-native in both modes and listed inline here. + | Step | Command | |---|---| -| Fetch issue | `gh issue view <n> --json number,title,body,labels,comments,url` | +| Fetch the Epic | mode-specific — `references/<mode>-mode-commands.md` | +| Post the plan to the Epic | mode-specific — `references/<mode>-mode-commands.md` | +| Close a sub-issue with evidence | mode-specific — `references/<mode>-mode-commands.md` | +| File sub-issue under parent | mode-specific — `references/<mode>-mode-commands.md` (see Phase 9) | +| File standalone follow-up | mode-specific — `references/<mode>-mode-commands.md` (only if truly parallel/new workstream) | | Enable conflict-resolution replay | `git config rerere.enabled true` (once, at workstream start) | | Sync branch (default) | `git fetch origin && git rebase origin/main` | | Sync branch (deep interleave) | `git fetch origin && git merge origin/main` (new-work-as-base; re-verify) | | Check mergeability before ready | `gh pr view <n> --json mergeable,mergeStateStatus` | -| Comment plan | `gh issue comment <n> --body-file planning/issues/<n>/plan.md` | | Open draft PR up front | `gh pr create --draft --title "..." --body "..."` | | Update PR body | `gh pr edit <n> --body-file ...` (or `gh api PATCH ...` if blocked) | | Mark PR ready | `gh pr ready <n>` | @@ -616,8 +583,6 @@ git merge origin/main # one holistic resolution instead of N brittle per-comm | Verify functionally | `pnpm test`, `pnpm typecheck`, `pnpm build` (per project) | | Worktree fork option | `git worktree add .claude/worktrees/<branch>-fork-A -b <branch>-fork-A` | | Review comment | `gh api .../pulls/<n>/comments -F line=N -f path=... -f body=...` | -| File sub-issue under parent | `gh issue create … && gh api …/issues/$PARENT/sub_issues -f sub_issue_id=$ID` (see Phase 9) | -| File standalone follow-up | `gh issue create --title "..."` (only if truly parallel/new workstream) | | Get PR comments | `gh api repos/{o}/{r}/pulls/<n>/comments` | ## Red flags — STOP and self-correct @@ -649,7 +614,7 @@ These thoughts mean you're about to violate the workflow: | "The PR description can just be the commit list" | Full report or it didn't happen. Why > what. Include verification evidence per phase. | | "No need for stumbling points — it went fine" | Then your "Stumbling points" section is "None." But write the section. | | "Decisions log is overkill for this issue" | It's overkill *until* you need to write the PR review comments. Then it's the source. | -| "Initial plan turned out wrong, no need to update" | Edit `plan.md` and edit the issue comment (`gh issue comment --edit-last`). The plan must reflect reality. | +| "Initial plan turned out wrong, no need to update" | Edit `plan.md` and update the plan on the Epic (see `references/<mode>-mode-commands.md`). The plan must reflect reality. | ## Common mistakes @@ -689,7 +654,7 @@ Everything above describes the skill running interactively. When **middle-manage ### You are pointed at an Epic — its sub-issues are your plan phases -middle dispatches **Epics**, not individual issues. The issue you're pointed at is an Epic: it has sub-issues, and **its open sub-issues ARE the phases of your plan**. Don't invent a phase breakdown — fetch the Epic's sub-issues (`gh api /repos/{owner}/{repo}/issues/{epic}/sub_issues`), and each one is a phase. Your `plan.md` Phases list and the PR's Status checkboxes are one-per-sub-issue. +middle dispatches **Epics**, not individual issues. The Epic you're pointed at has sub-issues, and **its open sub-issues ARE the phases of your plan**. Don't invent a phase breakdown — fetch the Epic's sub-issues (see `references/<mode>-mode-commands.md`: github mode reads the sub-issues REST graph; file mode reads the `<!-- middle:sub-issue id=N -->` blocks in the Epic file), and each one is a phase. Your `plan.md` Phases list and the PR's Status checkboxes are one-per-sub-issue. One Epic → one worktree → one branch → one PR. You work *down* the sub-issues in dependency order on that single branch, ticking each Status checkbox as its sub-issue's work verifies. Do **not** open a PR per sub-issue, and do **not** wait for review between sub-issues — the whole Epic is reviewed once, as one PR, when every sub-issue is done. @@ -701,7 +666,7 @@ The dispatcher created your worktree and branch and spawned you inside it. Do ** ### Asking a question = write `.middle/blocked.json` and exit (overrides Phase 2's "comment and wait") -You cannot "comment on the issue and wait" — headless, there is nothing to wait *in*. When you genuinely need human input (ambiguous acceptance criteria, a decision CLAUDE.md/skills/docs don't resolve and that isn't worth a fork), write `<worktree>/.middle/blocked.json` containing the question and the context a human needs to answer it, then **exit cleanly**. Middle's exit classifier detects the sentinel, parks the workflow on a `waitFor` signal, and surfaces the question on the issue. Do not guess past a real blocker; do not spin idle. +You cannot "comment on the Epic and wait" — headless, there is nothing to wait *in*. When you genuinely need human input (ambiguous acceptance criteria, a decision CLAUDE.md/skills/docs don't resolve and that isn't worth a fork), write `<worktree>/.middle/blocked.json` containing the question and the context a human needs to answer it, then **exit cleanly**. The `blocked.json` sentinel is mode-agnostic — you write it the same way in both modes. Middle's exit classifier detects it, parks the workflow on a `waitFor` signal, and surfaces the question on the Epic (github mode: an issue comment; file mode: a `<!-- middle:question -->` block the dispatcher appends to the Epic file via the renderer). The human answers (github mode: a reply comment; file mode: editing the `<!-- middle:answer -->` block, or running `mm resume <repo> <slug> --answer "…"`), and you're re-spawned with the answer. Do not guess past a real blocker; do not spin idle. ### Complexity is fork branching factor — pause the sub-issue past the ceiling @@ -718,7 +683,7 @@ When a human answers, middle re-spawns you with the answer injected into your pr ### The plan comment is mechanically gated (reinforces Phase 4) -After your plan step, the dispatcher's **plan-comment guard** verifies a comment by your account containing the plan body exists on the issue. No plan comment → the workflow fails. Phase 4 was always "non-negotiable"; under middle it is literally enforced. +After your plan step, the dispatcher's **plan-comment guard** verifies the plan body was posted to the Epic (github mode: a comment by your account on the issue; file mode: a plan entry in the Epic file's conversation section, written by the renderer). No plan on the Epic → the workflow fails. Phase 4 was always "non-negotiable"; under middle it is literally enforced. ### `gh pr ready` is mechanically gated (reinforces Phase 10) diff --git a/packages/skills/implementing-github-issues/references/file-mode-commands.md b/packages/skills/implementing-github-issues/references/file-mode-commands.md new file mode 100644 index 00000000..066b8e4e --- /dev/null +++ b/packages/skills/implementing-github-issues/references/file-mode-commands.md @@ -0,0 +1,116 @@ +# implementing-github-issues — file-mode commands + +The file-mode equivalents of every Epic/plan/sub-issue/conversation operation the skill body refers to mode-agnostically. In **file mode** the Epic is a Markdown file at `planning/epics/<slug>.md` (the slug is the file's stem and the canonical Epic reference), and the agent-↔-human conversation lives in that file's `<!-- middle:conversation -->` section. **PRs, reviews, and CI stay GitHub-native** — the PR/CI commands are identical to github mode (`gh pr …`). + +## The one rule that governs every write below + +**The renderer is the sole writer of strict markers.** Every `<!-- middle:* -->` marker (and its strict attribute line — `id=`, `status=`, `ts=`, `kind=`, the `<!-- middle:meta -->` keys) is written and rewritten only by the dispatcher's renderer (`renderEpicFile`). You never hand-edit a strict marker or its attributes. You write **only** between markers — sub-issue checkboxes, prose bodies, conversation entry bodies. This is what keeps #180's writer/parser-drift class closed for file mode, and it's what makes the file's byte-identical round-trip invariant hold under concurrent dispatcher + human edits. + +Practically: the dispatcher appends conversation entries (plan, dispatch-event, question) **for** you via the renderer when you write `.middle/blocked.json` or hit a gated step; you flip sub-issue checkboxes and append provenance prose yourself. + +## The Epic file format (mirror these marker names exactly) + +```markdown +<!-- middle:epic v1 --> +# <Title> + +<!-- middle:meta +slug: <slug> +adapter: <claude|codex> # optional +complexity_ceiling: <N> # optional +approved: <true|false> # optional +labels: [<label>, <label>] # optional, informational +blocked-by: [<other-slug>] # optional, cross-Epic deps +pr: <number> # stamped by dispatcher when the PR opens +--> + +## Context +<prose> + +## Acceptance criteria +- [ ] <criterion> + +## Sub-issues + +<!-- middle:sub-issue id=1 --> +- [ ] **1 — <title>** + <prose body> + *Acceptance:* <…> +<!-- /middle:sub-issue --> + +<!-- middle:conversation --> +<!-- /middle:conversation --> +``` + +## Fetch the Epic's context (Phase 1) + +Read the Epic file: + +```bash +cat planning/epics/<slug>.md +``` + +Read the body, the `## Acceptance criteria`, every `<!-- middle:sub-issue -->` block, and every entry inside `<!-- middle:conversation -->` — questions, dispatch events, and any answers are all there. The latest decisions are often in the conversation section. + +## Fetch the Epic's sub-issues (the phases of your plan) + +Each `<!-- middle:sub-issue id=N -->` block is one phase. An *open* sub-issue is one whose checkbox is unchecked (`- [ ]`); a *closed* one is checked (`- [x]`). Work the open ones in dependency order (`*Blocked by:* N` lines express the order). + +## Post the plan to the Epic (Phase 4) + +The plan goes into the Epic file's `<!-- middle:conversation -->` section as a conversation entry — **written by the renderer, not by hand.** Under middle's dispatch this is the plan step the dispatcher records via the renderer; the plan-comment guard then verifies a plan entry exists in the conversation section. You author the plan body (in `planning/epics/<slug>.md`'s adjacent `planning/issues/<slug>/plan.md`, same as github mode); the renderer appends it to the conversation. Do not edit the conversation markers yourself. + +## Close a sub-issue with evidence + +Closing a sub-issue = flipping its checkbox from `- [ ]` to `- [x]` and appending a one-line provenance suffix to the title line. The checkbox and the title prose are *between* markers, so you edit them directly: + +```markdown +<!-- middle:sub-issue id=1 --> +- [x] **1 — Implement the CodexAdapter** *(done in wf_…oyy4c4m1, sha abc1234)* + Full AgentAdapter: … +<!-- /middle:sub-issue --> +``` + +The recommender's "open sub-issues" count scans for unchecked boxes, so a checked box with a provenance suffix is the file-mode equivalent of `gh issue close --reason completed --comment "Done in <sha> …"`. Do **not** touch the `<!-- middle:sub-issue id=N -->` marker or its `id=` attribute — only the checkbox glyph and the prose. + +## Ask a question / surface a blocker + +Identical agent action to github mode: write `<worktree>/.middle/blocked.json` and exit. The dispatcher's file-backed writer appends a `<!-- middle:question id=N status=open … -->` block to the conversation section **via the renderer** — you never write the question marker yourself. The human answers by editing the `<!-- middle:answer for=N -->` block in the file (the file-watcher fires resume when that block becomes non-empty) or by running: + +```bash +mm resume <repo> <slug> --answer "…" +``` + +`mm resume` is the manual unblock — the Phase 1 escape hatch before the watcher, and a permanent fallback. + +## File a follow-up as a sub-issue under a parent (Phase 9) + +A sub-issue is a new `<!-- middle:sub-issue id=N -->` block in the Epic file. Append it **via the renderer** (the renderer assigns the next `id` and emits the strict marker) — under middle's dispatch this is the same write path the dispatcher uses; do not hand-author the marker. Author the block body: + +```markdown +- [ ] **N — <descriptive title>** + Context: <what you saw, where>. + Why a sub-issue and not in-scope: <…>. + *Suggested approach:* <if you have one> +``` + +A "parent for a natural collection" is the Epic itself — file each related item as an additional sub-issue block under `## Sub-issues`. **There is no `gh issue create` in file mode** for Epic data; everything lives in the Epic file. + +## File a standalone follow-up (the exception) + +A genuinely cross-workstream item is a *new Epic file*: author `planning/epics/<other-slug>.md` with its own `<!-- middle:epic v1 -->` + `<!-- middle:meta -->` (see "creating-github-issues" file-mode addendum). A "Discovered while working on: <slug>" line in its Context is the cross-reference. Again — no `gh issue create`. + +## PR / CI operations (GitHub-native — same as github mode) + +PRs, reviews, and CI are GitHub-native in file mode too. Use the same commands the skill body lists inline: + +| Operation | Command | +|---|---| +| Open draft PR up front | `gh pr create --draft --title "..." --body "..."` (include `<!-- middle:epic <slug> -->` in the PR body so `findEpicPr` can match it) | +| Update PR body | `gh pr edit <pr> --body-file ...` (or PATCH via `gh api`) | +| Check mergeability | `gh pr view <pr> --json mergeable,mergeStateStatus` | +| Mark PR ready | `gh pr ready <pr>` | +| Post a file/line review comment | `gh api .../pulls/<pr>/comments -F line=N -f path=... -f body=...` | +| Get PR review comments | `gh api repos/{o}/{r}/pulls/<pr>/comments` | + +The dispatcher stamps `pr: <number>` into the Epic file's `<!-- middle:meta -->` when the PR opens (a durable backup for the PR-body marker) — that write is the renderer's, not yours. diff --git a/packages/skills/implementing-github-issues/references/github-mode-commands.md b/packages/skills/implementing-github-issues/references/github-mode-commands.md new file mode 100644 index 00000000..38d8476e --- /dev/null +++ b/packages/skills/implementing-github-issues/references/github-mode-commands.md @@ -0,0 +1,116 @@ +# implementing-github-issues — github-mode commands + +The concrete `gh` incantations for every Epic/plan/sub-issue/conversation operation the skill body refers to mode-agnostically. **github mode** is the default: the Epic is a GitHub issue, its sub-issues are native GitHub sub-issues, and the agent-↔-human conversation flows through issue comments. PRs, reviews, and CI are GitHub-native here too (and identical in file mode). + +Throughout, `<epic>` is the Epic's issue number, `<owner>`/`<repo>` the repository. + +## Fetch the Epic's context (Phase 1) + +```bash +gh issue view <epic> --json number,title,body,labels,assignees,milestone,comments,url +``` + +Read the body AND every comment — the latest decisions are often in comments. + +## Fetch the Epic's sub-issues (the phases of your plan) + +GitHub exposes sub-issues via a REST endpoint (`gh` has no flag for it yet): + +```bash +gh api /repos/<owner>/<repo>/issues/<epic>/sub_issues \ + --jq '.[] | {number, title, state}' +``` + +Each open sub-issue is one phase. Work them in dependency order. + +## Post the plan to the Epic (Phase 4) + +The plan is a comment on the Epic by your account: + +```bash +gh issue comment <epic> --body-file planning/issues/<epic>/plan.md +``` + +The plan-comment guard greps for a comment by your account containing the plan body. If the plan changes, update it: + +```bash +gh issue comment <epic> --edit-last --body-file planning/issues/<epic>/plan.md +``` + +(`--edit-last` has been unreliable in some cases — if it edits the wrong comment, post a fresh comment instead and note the supersession.) + +## Close a sub-issue with evidence + +When sub-issue N's work is verified and landed, close it with a comment that marks where it landed. The Epic auto-checks it off: + +```bash +gh issue close <sub-issue-number> --reason completed \ + --comment "Done in <sha> on PR #<pr> — <area>" +``` + +## Ask a question / surface a blocker + +You don't post the question yourself when headless — write `<worktree>/.middle/blocked.json` and exit. The dispatcher posts the question as an issue comment on the Epic and parks the workflow. The human answers by replying on the issue (or `mm resume <repo> <epic> --answer "…"`). + +## File a follow-up as a sub-issue under a parent (Phase 9) + +`gh` CLI doesn't have a `--parent` flag, so attach via the sub-issues REST endpoint: + +```bash +OWNER=<owner>; REPO=<repo>; PARENT=<parent-issue-number> + +# 1. Create the child issue +URL=$(gh issue create --repo $OWNER/$REPO \ + --title "<descriptive title>" \ + --body "$(cat <<'EOF' +**Parent:** #<parent-num> (PR #<pr> surfaced this) + +**Context:** <what you saw, where> + +**Why a sub-issue and not in-scope:** <e.g., "parallelizable; another agent can pick this up while we work on Phase 2"> + +**Suggested approach:** <if you have one — otherwise omit> +EOF +)") +CHILD_NUM=$(basename "$URL") + +# 2. Look up the child's database id (NOT issue number, NOT node_id) +CHILD_ID=$(gh api /repos/$OWNER/$REPO/issues/$CHILD_NUM --jq '.id') + +# 3. Attach as sub-issue under parent. +# CRITICAL: use -F (integer) not -f (string). The endpoint rejects strings: +# `Invalid property /sub_issue_id: "12345" is not of type integer`. +gh api --method POST /repos/$OWNER/$REPO/issues/$PARENT/sub_issues \ + -F sub_issue_id=$CHILD_ID +``` + +## Create a parent for a natural collection + +```bash +PARENT_URL=$(gh issue create --repo $OWNER/$REPO \ + --title "<umbrella concern>" \ + --body "Tracks several related items surfaced during PR #<pr>. See sub-issues.") +PARENT_NUM=$(basename "$PARENT_URL") +# Then file each child as a sub-issue under $PARENT_NUM as above. +``` + +## File a standalone follow-up (the exception) + +Only when the work is a genuinely different workstream. Skip the sub-issue attachment; a "Discovered while working on: #<epic>" line in the body is enough cross-reference: + +```bash +gh issue create --repo <owner>/<repo> --title "<descriptive title>" --body "..." +``` + +## PR / CI operations (identical in file mode) + +These are GitHub-native in both modes — listed here for completeness; the same commands appear inline in the skill body. + +| Operation | Command | +|---|---| +| Open draft PR up front | `gh pr create --draft --title "..." --body "..."` | +| Update PR body | `gh pr edit <pr> --body-file ...` (or PATCH via `gh api` if the projects-classic GraphQL bug bites) | +| Check mergeability | `gh pr view <pr> --json mergeable,mergeStateStatus` | +| Mark PR ready | `gh pr ready <pr>` | +| Post a file/line review comment | `gh api .../pulls/<pr>/comments -F line=N -f path=... -f body=...` | +| Get PR review comments | `gh api repos/{o}/{r}/pulls/<pr>/comments` | diff --git a/packages/skills/recommending-github-issues/SKILL.md b/packages/skills/recommending-github-issues/SKILL.md index 8540625e..c379d660 100644 --- a/packages/skills/recommending-github-issues/SKILL.md +++ b/packages/skills/recommending-github-issues/SKILL.md @@ -6,9 +6,20 @@ allowed-tools: Bash(gh:*), Bash(git:log:*), Bash(git:status), Read, Grep, Glob # Recommending GitHub Issues -You are the dispatch recommender for a single GitHub repository. Your only job -is to rewrite ONE state issue's body with a ranked plan of work to dispatch and -a digest of items needing human attention. +You are the dispatch recommender for a single repository. Your only job is to +rewrite ONE **state body** with a ranked plan of work to dispatch and a digest +of items needing human attention. + +**Mode-specific commands:** the repo runs in one of two modes. In **github mode** +the dispatch units are GitHub issues/Epics and the state body is a GitHub issue +(the `agent-queue:state` issue). In **file mode** the dispatch units are Epic +files under `epics_dir` and the state body is the `state_file` on disk. This +skill body is mode-agnostic — it says "fetch the repo's dispatch units", "read +the prior state body", "write the state body". The concrete reads/writes live in +`references/<mode>-mode-commands.md` (`github-mode-commands.md` / +`file-mode-commands.md`), mirrored into your worktree at +`.middle/skills/recommending-github-issues/references/` for your run's mode. PRs, +reviews, and CI are GitHub-native in *both* modes (`gh pr …`). middle dispatches **Epics** (issues with sub-issues) and **standalone issues** — never bare sub-issues. You rank dispatch units, not individual sub-issues. A @@ -52,28 +63,24 @@ the dispatcher inputs. ### Phase 2 — Fetch repo state and resolve the Epic graph -Run, in order: - -```bash -gh issue list --state open --limit 200 \ - --json number,title,labels,assignees,body,comments,createdAt,updatedAt -gh pr list --state open --limit 100 \ - --json number,title,labels,headRefName,isDraft,reviewDecision,statusCheckRollup,body,createdAt,updatedAt -``` - -If >200 open issues, filter to `--label agent-queue:eligible` (document the filter -you used in your run-summary comment). - -Then resolve the **dispatch-unit structure** from GitHub's native sub-issue graph -(`gh api /repos/{owner}/{repo}/issues/{n}/sub_issues`): -- An issue with sub-issues is an **Epic** — a dispatch unit. -- An issue with a parent is a **sub-issue** — NOT a dispatch unit. It is scope inside - its Epic; never classify or rank it on its own. -- An issue with neither is a **standalone issue** — a dispatch unit (a one-phase Epic). - -**Exclude the state issue itself.** The issue you are rewriting (and any issue carrying the -`agent-queue:state` label) is the dispatcher's surface, never a dispatch unit. Never classify -or rank it. +Fetch the repo's **dispatch units** and **open PRs**, and resolve each unit's +sub-issue structure. The exact reads are mode-specific — see +`references/<mode>-mode-commands.md`. In github mode you list open issues + PRs +and read the native sub-issue graph; in file mode you scan `epics_dir` for Epic +files, parse each one's `<!-- middle:meta -->` and sub-issue blocks, and still +list open PRs from GitHub. + +Resolve the **dispatch-unit structure**: +- A unit with sub-issues is an **Epic** — a dispatch unit. +- A sub-issue (an issue with a parent in github mode; a `<!-- middle:sub-issue -->` + block inside an Epic file in file mode) is **NOT** a dispatch unit. It is scope + inside its Epic; never classify or rank it on its own. +- A unit with neither is a **standalone issue** — a dispatch unit (a one-phase Epic). + +**Exclude the state surface itself.** In github mode the issue you are rewriting +(and any issue carrying the `agent-queue:state` label) is the dispatcher's surface, +never a dispatch unit. In file mode the `state_file` is not an Epic file and never +appears in `epics_dir`. Never classify or rank the state surface. **Cross-reference open PRs to detect in-flight / awaiting-review units.** The dispatcher's `in_flight` is authoritative when present, but it can be empty or stale (e.g. the dispatcher @@ -159,11 +166,14 @@ Verify before writing: ### Phase 6 — Write and log -```bash -gh issue edit <state_issue> --body-file <generated-body.md> -``` +Write the rendered body to the state surface — see `references/<mode>-mode-commands.md`. +In github mode it's `gh issue edit <state_issue> --body-file …`. In file mode you +write `state_file` **via the renderer** (`renderStateIssue`), never by hand — the +renderer is the sole writer, which closes #180's class for this skill too. -Then post a single comment with the diff summary against prior_body: +Then log a single diff summary against prior_body. In github mode that's a comment +on the state issue; in file mode the run summary is recorded the same way the +dispatcher records it (no separate GitHub comment — the state surface is a file): > ## Run a3f8c10b summary > @@ -212,13 +222,17 @@ the problem and stop. Dispatcher will surface to human. ## Files this skill creates -None on filesystem. Output is the state issue body via `gh issue edit` and one -diff comment via `gh issue comment`. +Mode-dependent (see `references/<mode>-mode-commands.md`). In github mode: none on +filesystem — output is the state issue body via `gh issue edit` plus one diff +comment. In file mode: the `state_file` on disk, written via `renderStateIssue` +(the renderer is the sole writer — never hand-edited). ## Files this skill reads - Schema at the path provided by the dispatcher -- Repo's open issues and PRs via `gh`, and the sub-issue graph via `gh api` +- The repo's dispatch units and their sub-issue structure (github mode: open issues + + the sub-issue graph via `gh`; file mode: Epic files scanned from `epics_dir`) +- Open PRs via `gh` (GitHub-native in both modes) - Recent git log on main - Source files when needed to assess Epic readiness (skim, don't read fully) — the - sub-issue count comes from the graph, never from estimation + sub-issue count comes from the graph/file, never from estimation diff --git a/packages/skills/recommending-github-issues/references/file-mode-commands.md b/packages/skills/recommending-github-issues/references/file-mode-commands.md new file mode 100644 index 00000000..26ca08c6 --- /dev/null +++ b/packages/skills/recommending-github-issues/references/file-mode-commands.md @@ -0,0 +1,85 @@ +# recommending-github-issues — file-mode commands + +The file-mode equivalents of the recommender's state read/write. In **file mode** +the dispatch units are Epic files under `epics_dir` (default `planning/epics/`), +and the state body is the `state_file` on disk (default `.middle/state.md`). PRs, +reviews, and CI are GitHub-native (`gh pr …`) — only the Epic *data* and the state +body are file-backed. + +## The one rule that governs the write + +**The renderer is the sole writer of the state body.** You write `state_file` via +`renderStateIssue` (the same parser + renderer + byte-identical-round-trip +invariant as github mode's state-issue flow) — **never by hand**. There is no +recommender-agent rewriting strict sections out-of-band; this closes #180's class +entirely for file mode. You compose the state model and render it; you do not +hand-edit the file's markers or the dispatcher-owned sections (In-flight, Rate +limits, Slot usage). + +## Scan the dispatch units (Phase 2) + +The recommender **scans `epics_dir`** for Epic files and parses each: + +```bash +ls epics_dir/*.md # epics_dir from the repo config (default planning/epics/) +``` + +For each `planning/epics/<slug>.md`: +- Read `<!-- middle:meta -->` for `slug`, `adapter`, `labels`, `approved`, + `closed`, and `blocked-by` (the cross-Epic dependency slugs the graph builder + reads). +- Skip files marked `closed: true` in meta — they're out of the open set. +- Each `<!-- middle:sub-issue id=N -->` block is a phase; an **open** sub-issue is + an unchecked box (`- [ ]`), a **closed** one is checked (`- [x]`). The open- + sub-issue count is the Epic's phase count — a fact from the file, never an + estimate. +- An Epic with no open sub-issues is `excluded` (`no open sub-issues`). + +The `state_file` is not an Epic file and never appears in `epics_dir` — it is never +a dispatch unit. + +## Cross-reference open PRs (Phase 2, GitHub-native) + +PRs/reviews/CI stay on GitHub in file mode. Match each open PR to its Epic by the +`<!-- middle:epic <slug> -->` marker in the PR body (or the `pr:` field in the Epic +file's `<!-- middle:meta -->`): + +```bash +gh pr list --state open --limit 100 \ + --json number,title,headRefName,isDraft,reviewDecision,statusCheckRollup,body,createdAt,updatedAt +``` + +An Epic with an open draft PR is in-flight; with a ready (non-draft) PR is +`needs-human` (awaiting review). + +## Cross-Epic blocked-by + +In file mode the "blocked on" relationship is a slug reference in each Epic's meta: + +```yaml +<!-- middle:meta +slug: copilot-adapter +blocked-by: [codex-adapter] +--> +``` + +The graph builder reads `blocked-by` slugs to mark a unit `blocked` until its +blocker Epic closes. + +## Write the state body (Phase 6) + +Render the composed state model and write it to `state_file` **via +`renderStateIssue`** (atomic write — temp + rename — is the gateway's job). Do not +hand-edit `state_file`. + +The run-summary diff against `prior_body` is recorded the same way the dispatcher +records it for a file-backed state — there is no separate GitHub comment, because +the state surface is a file, not an issue. + +## What you never do + +- Never write `state_file` by hand — only via `renderStateIssue`. +- Never author or edit an Epic file (that's the implementer's / creator's job). +- Never `gh pr create` / `gh pr merge` / `gh pr review`. +- Never touch dispatcher-owned state sections (In-flight, Rate limits, Slots) — + copy them from dispatcher input verbatim. diff --git a/packages/skills/recommending-github-issues/references/github-mode-commands.md b/packages/skills/recommending-github-issues/references/github-mode-commands.md new file mode 100644 index 00000000..81712e42 --- /dev/null +++ b/packages/skills/recommending-github-issues/references/github-mode-commands.md @@ -0,0 +1,65 @@ +# recommending-github-issues — github-mode commands + +The concrete state-issue read/write commands for **github mode**: dispatch units +are GitHub issues/Epics, and the state body is the `agent-queue:state` issue. + +## Fetch repo state and resolve the Epic graph (Phase 2) + +```bash +gh issue list --state open --limit 200 \ + --json number,title,labels,assignees,body,comments,createdAt,updatedAt +gh pr list --state open --limit 100 \ + --json number,title,labels,headRefName,isDraft,reviewDecision,statusCheckRollup,body,createdAt,updatedAt +``` + +If >200 open issues, filter to `--label agent-queue:eligible` (document the filter +you used in your run-summary comment). + +Then resolve the dispatch-unit structure from GitHub's native sub-issue graph: + +```bash +gh api /repos/{owner}/{repo}/issues/{n}/sub_issues +``` + +- An issue with sub-issues is an **Epic** — a dispatch unit. +- An issue with a parent is a **sub-issue** — never a dispatch unit. +- An issue with neither is a **standalone issue** — a one-phase Epic. + +Exclude the state issue itself (and any issue carrying `agent-queue:state`). + +You may also gauge recent merge cadence: + +```bash +git log --oneline -50 main +``` + +## Read the prior state body (Phase 1) + +The dispatcher passes `prior_body` in your prompt. If you need to re-read it live: + +```bash +gh issue view <state_issue> --json body --jq '.body' +``` + +## Write the state body (Phase 6) + +```bash +gh issue edit <state_issue> --body-file <generated-body.md> +``` + +Then post a single diff-summary comment against `prior_body`: + +```bash +gh issue comment <state_issue> --body-file <run-summary.md> +``` + +If zero changes, post `No changes this run.` — confirms the recommender is alive +without polluting the timeline. + +## What you never do + +- Never `gh issue edit` any issue other than the state issue. +- Never `gh issue comment` on any issue other than the state issue. +- Never add or remove labels (`gh issue edit --add-label` / `--remove-label`). +- Never `gh pr create` / `gh pr merge` / `gh pr review` — you implement and merge + nothing. diff --git a/planning/issues/190/decisions.md b/planning/issues/190/decisions.md index 6eb41e63..5316ce00 100644 --- a/planning/issues/190/decisions.md +++ b/planning/issues/190/decisions.md @@ -173,3 +173,21 @@ meta, which the daemon reads). The POST body now sends `epicRef` (string). **Why:** file-mode Epics are slugs; the dispatch entry must accept them and avoid a gh call for a non-GitHub Epic. github-mode numeric dispatch is unchanged in behavior. **Evidence:** sub-issue #194 (slug-or-number positional + `--epic`). + +## Mode-commands mirror reads the worktree's installed skill (no dispatcher→cli import) +**File(s):** `packages/dispatcher/src/workflows/implementation.ts`, `build-deps.ts` +**Date:** 2026-06-03 + +**Decision:** `ensurePromptFile`'s sibling `mirrorModeCommands` copies the run's +`<worktree>/.claude/skills/implementing-github-issues/references/<mode>-mode-commands.md` +(installed by `mm init`, byte-identical to `packages/skills/` via the sync mirror) into +`<worktree>/.middle/skills/.../references/`. The mode comes from a new +`resolveEpicStoreMode` deps seam (default: `readEpicStoreConfig(db, repo).mode`). It's +best-effort — a worktree predating the per-mode references is a no-op, never a failure. +**Why:** the source is the worktree's own installed skill, so the dispatcher needs no +import of the CLI's `CANONICAL_SKILLS_DIR` (which would invert the cli→dispatcher +dependency). The agent then reads one mode-resolved file instead of choosing between +both. The mode is injected (DI) so the workflow stays db-free, matching +`resolveComplexityCeiling`/`isEpicApproved`. +**Evidence:** sub-issue #195 integration criterion; `bootstrap/skills-sync.ts` +(canonical↔mirror byte-identity). From b915fafa8bdc5fe3a7d2c3749a745d32bc17dfe9 Mon Sep 17 00:00:00 2001 From: Justin Walsh <contact.me@thejustinwalsh.com> Date: Wed, 3 Jun 2026 05:20:48 -0400 Subject: [PATCH 07/10] =?UTF-8?q?test(epic-store):=20parity=20test=20(gith?= =?UTF-8?q?ub=20=E2=87=94=20file)=20+=20Phase=201=20smoke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close #196. The load-bearing proof of the design's "no workflow code changes between modes" promise. - `packages/dispatcher/test/epic-store/parity.test.ts`: `describe.each(["github", "file"])` runs the real implementation workflow end-to-end against each gateway backend and asserts the same outcome for the same input — - happy-path dispatch reaches `completed`; - park → resume-answer (the `mm resume` / control.resume fire) → continuation reaches `completed`. Named per-mode test-deps builders (`buildTestDepsWith{GitHub,File}Gateways`) reuse the stub adapter/gate/tmux pattern; the only per-mode difference is where the agent's question lands (a recorded gh comment vs a `<!-- middle:question -->` block in the Epic file via the renderer). Criterion 5 (a live `mm init --epic-store=file` throwaway GitHub repo + real agent run opening a real draft PR) is a manual operator smoke a headless dispatch can't perform — the automated parity test exercises the same code paths deterministically and is the integration evidence; the live-repo smoke is left for the human reviewer. Full suite green (1228); typecheck/lint/format clean. --- .../dispatcher/test/epic-store/parity.test.ts | 275 ++++++++++++++++++ planning/issues/190/decisions.md | 16 + 2 files changed, 291 insertions(+) create mode 100644 packages/dispatcher/test/epic-store/parity.test.ts diff --git a/packages/dispatcher/test/epic-store/parity.test.ts b/packages/dispatcher/test/epic-store/parity.test.ts new file mode 100644 index 00000000..0d0779b7 --- /dev/null +++ b/packages/dispatcher/test/epic-store/parity.test.ts @@ -0,0 +1,275 @@ +/** + * Parity (#196): the load-bearing proof of the design's central promise — **no + * workflow code changes between modes**. One fixture runs the real implementation + * workflow end-to-end against each Epic-store backend (github vs file) and asserts + * the same outcome for the same input: + * - a happy-path dispatch reaches `completed`; + * - a park → resume-answer → continuation reaches `completed`. + * The only per-mode difference is where the agent's question lands (a recorded gh + * comment vs a `<!-- middle:question -->` block in the Epic file) — the workflow + * body, gates, and engine are identical. A future divergence here is the contract + * catching a regression. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Engine } from "bunqueue/workflow"; +import type { AgentAdapter, HookPayload, StopClassification } from "@middle/core"; +import { openAndMigrate } from "../../src/db.ts"; +import type { SessionGate } from "../../src/hook-server.ts"; +import { appendQuestion } from "../../src/epic-store/index.ts"; +import { readEpicFile } from "../../src/epic-store/epic-file-io.ts"; +import { renderEpicFile } from "../../src/epic-store/epic-file/renderer.ts"; +import { getWaitForSignal, getWorkflow } from "../../src/workflow-record.ts"; +import { + createImplementationWorkflow, + RESUME_EVENT, + type ImplementationDeps, + type ImplementationInput, +} from "../../src/workflows/implementation.ts"; +import { createWorktree, destroyWorktree } from "../../src/worktree.ts"; +import type { Database } from "bun:sqlite"; + +type Mode = "github" | "file"; + +const SLUG = "rollout-epic-store"; +const REPO = "o/parity-repo"; +/** github mode references a number; file mode references the slug. */ +function epicRefFor(mode: Mode): string { + return mode === "file" ? SLUG : "6"; +} + +const GIT_ENV = { + ...process.env, + GIT_AUTHOR_NAME: "t", + GIT_AUTHOR_EMAIL: "t@e.invalid", + GIT_COMMITTER_NAME: "t", + GIT_COMMITTER_EMAIL: "t@e.invalid", +}; +async function git(cwd: string, args: string[]): Promise<void> { + const proc = Bun.spawn(["git", "-C", cwd, ...args], { + stdout: "ignore", + stderr: "pipe", + env: GIT_ENV, + }); + if ((await proc.exited) !== 0) { + throw new Error(`git ${args.join(" ")}: ${await new Response(proc.stderr).text()}`); + } +} + +let scratch: string; +let repoPath: string; +let worktreeRoot: string; +let epicsDir: string; +let db: Database; +let engine: Engine; + +beforeEach(async () => { + scratch = realpathSync(mkdtempSync(join(tmpdir(), "middle-parity-"))); + repoPath = join(scratch, "repo"); + worktreeRoot = join(scratch, "worktrees"); + await git(scratch, ["init", "repo"]); + await git(repoPath, ["commit", "--allow-empty", "-m", "init"]); + epicsDir = join(repoPath, "planning", "epics"); + mkdirSync(epicsDir, { recursive: true }); + // The file-mode Epic on disk (github mode ignores it). + writeFileSync( + join(epicsDir, `${SLUG}.md`), + renderEpicFile({ + title: "feat: parity", + meta: { slug: SLUG, adapter: "stub" }, + context: "ctx", + acceptanceCriteria: [{ checked: false, text: "ship" }], + subIssues: [{ id: 1, checked: false, title: "1 — gateways", body: "" }], + conversation: [], + }), + ); + db = openAndMigrate(join(scratch, "db.sqlite3")); + engine = new Engine({ embedded: true }); +}); + +afterEach(async () => { + await engine.close(true); + db.close(); + rmSync(scratch, { recursive: true, force: true }); +}); + +const hangingGate: SessionGate = { + awaitSessionStart: async () => + ({ session_id: "stub", transcript_path: "/tmp/stub.jsonl" }) as HookPayload, + awaitStop: () => new Promise<HookPayload>(() => {}), +}; +const readyGate: SessionGate = { + awaitSessionStart: async () => + ({ session_id: "stub", transcript_path: "/tmp/stub.jsonl" }) as HookPayload, + awaitStop: async () => ({ reason: "turn-end" }) as HookPayload, +}; + +/** Stub adapter returning each classification in turn (last repeats). Writes a + * blocked.json on install so an `asked-question` drive parks via the real sentinel path. */ +function makeAdapter(classifications: StopClassification[]): AgentAdapter { + const seq = [...classifications]; + let i = 0; + return { + name: "stub", + readyEvent: "session.started", + async installHooks(opts) { + mkdirSync(join(opts.worktree, ".middle"), { recursive: true }); + writeFileSync( + join(opts.worktree, ".middle", "blocked.json"), + JSON.stringify({ question: "?" }), + ); + }, + buildLaunchCommand: () => ({ argv: ["true"], env: {} }), + buildPromptText: () => "@.middle/prompt.md", + async enterAutoMode() {}, + resolveTranscriptPath: (p) => p.transcript_path as string, + readTranscriptState: () => ({ + lastActivity: "", + contextTokens: 0, + turnCount: 0, + lastToolUse: null, + }), + classifyStop: () => seq[Math.min(i++, seq.length - 1)]!, + }; +} + +type RecordedQuestion = { epicRef: string; question: string }; + +/** + * Build implementation deps for a mode, sharing the stub adapter/tmux/worktree and + * wiring `postQuestion` to the mode's real side-effect: github records the comment + * (stand-in for the gh post), file appends a `<!-- middle:question -->` block to the + * Epic file via the renderer. `gate` is the SessionGate (ready for the happy path, + * hanging for the park path so the blocked.json sentinel decides the outcome). + */ +function buildTestDeps( + mode: Mode, + opts: { adapter: AgentAdapter; gate: SessionGate; recorded: RecordedQuestion[] }, +): ImplementationDeps { + return { + db, + getAdapter: () => opts.adapter, + sessionGate: opts.gate, + tmux: { + async newSession() {}, + async sendText() {}, + async sendEnter() {}, + async killSession() {}, + status: async () => ({ alive: false }), + }, + worktree: { createWorktree, destroyWorktree }, + resolveRepoPath: () => repoPath, + worktreeRoot, + dispatcherUrl: "http://127.0.0.1:8822", + launchTimeoutMs: 2000, + stopTimeoutMs: 2000, + livenessPollMs: 20, + resolveEpicStoreMode: () => mode, + enqueueContinuation: async (input) => { + await engine.start("implementation", input); + }, + postQuestion: + mode === "file" + ? async ({ epicRef, question, context, kind }) => { + appendQuestion(epicsDir, epicRef, { question, context, kind }); + } + : async ({ epicRef, question }) => { + opts.recorded.push({ epicRef, question }); + }, + }; +} + +/** The github-mode test-deps builder (criterion: a named builder per mode). */ +function buildTestDepsWithGitHubGateways(o: { + adapter: AgentAdapter; + gate: SessionGate; + recorded: RecordedQuestion[]; +}): ImplementationDeps { + return buildTestDeps("github", o); +} +/** The file-mode test-deps builder. */ +function buildTestDepsWithFileGateways(o: { + adapter: AgentAdapter; + gate: SessionGate; + recorded: RecordedQuestion[]; +}): ImplementationDeps { + return buildTestDeps("file", o); +} + +function buildFor( + mode: Mode, + o: { adapter: AgentAdapter; gate: SessionGate; recorded: RecordedQuestion[] }, +): ImplementationDeps { + return mode === "file" ? buildTestDepsWithFileGateways(o) : buildTestDepsWithGitHubGateways(o); +} + +async function awaitState(id: string, state: string, timeoutMs = 8000): Promise<void> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (getWorkflow(db, id)?.state === state) return; + await Bun.sleep(20); + } + throw new Error(`workflow ${id} did not reach '${state}' (was '${getWorkflow(db, id)?.state}')`); +} + +describe.each<Mode>(["github", "file"])("implementation parity — %s mode", (mode) => { + test("happy-path dispatch reaches completed", async () => { + const recorded: RecordedQuestion[] = []; + const deps = buildFor(mode, { + adapter: makeAdapter([{ kind: "bare-stop" }]), + gate: readyGate, + recorded, + }); + engine.register(createImplementationWorkflow(deps)); + const input: ImplementationInput = { repo: REPO, epicRef: epicRefFor(mode), adapter: "stub" }; + const handle = await engine.start("implementation", input); + await awaitState(handle.id, "completed"); + // Identical terminal state, and the row carries the mode's ref. + expect(getWorkflow(db, handle.id)?.epicRef).toBe(epicRefFor(mode)); + }); + + test("park → resume-answer → continuation reaches completed", async () => { + const recorded: RecordedQuestion[] = []; + // First drive parks (asked-question via blocked.json + hanging Stop); the + // continuation drives a bare-stop to completion. + const deps = buildFor(mode, { + adapter: makeAdapter([ + { + kind: "asked-question", + sentinelPath: "/x/.middle/blocked.json", + sentinel: { question: "A or B?" }, + }, + { kind: "bare-stop" }, + ]), + gate: hangingGate, + recorded, + }); + engine.register(createImplementationWorkflow(deps)); + const input: ImplementationInput = { repo: REPO, epicRef: epicRefFor(mode), adapter: "stub" }; + const handle = await engine.start("implementation", input); + + // Parked identically in both modes; the resume signal is armed. + await awaitState(handle.id, "waiting-human"); + expect(getWaitForSignal(db, handle.id)).not.toBeNull(); + + // Mode-appropriate park side-effect: file → a question block on disk; github → recorded. + if (mode === "file") { + expect(readEpicFile(epicsDir, SLUG)!.conversation.some((e) => e.kind === "question")).toBe( + true, + ); + } else { + expect(recorded).toEqual([{ epicRef: "6", question: "A or B?" }]); + } + + // `mm resume`'s fire (control.resume): signal the parked execution with the answer. + await engine.signal(handle.id, RESUME_EVENT, { + reason: "answered-question", + reply: { commentId: 0, authorLogin: "human", body: "Go with A." }, + }); + // The original execution hands off to the continuation, which completes. + await awaitState(handle.id, "completed"); + }); +}); diff --git a/planning/issues/190/decisions.md b/planning/issues/190/decisions.md index 5316ce00..eb477697 100644 --- a/planning/issues/190/decisions.md +++ b/planning/issues/190/decisions.md @@ -191,3 +191,19 @@ both. The mode is injected (DI) so the workflow stays db-free, matching `resolveComplexityCeiling`/`isEpicApproved`. **Evidence:** sub-issue #195 integration criterion; `bootstrap/skills-sync.ts` (canonical↔mirror byte-identity). + +## #196 live-repo smoke (criterion 5) needs an operator; the parity test is the integration evidence +**File(s):** `packages/dispatcher/test/epic-store/parity.test.ts` +**Date:** 2026-06-03 + +**Decision:** The automated `parity.test.ts` (`describe.each(["github","file"])`, +happy-path + park→resume, real workflow end-to-end through both gateway backends) is +the load-bearing integration evidence for the Epic's "no workflow code changes" +promise. Sub-issue #196's criterion 5 — `mm init --epic-store=file` a throwaway +GitHub repo, dispatch a real agent, observe a real draft PR — is a **manual operator +smoke** that a headless dispatch cannot perform (no authority to create throwaway +repos, no real agent spawn, no interactive `gh`). It's left for the human reviewer to +run; the automated parity test covers the same code paths deterministically. +**Why:** honest integration accounting — claim the automated evidence delivered, +flag the live-repo smoke as operator-only rather than silently checking it off. +**Evidence:** parity.test.ts (4 cases green); the dispatch is headless (no live agent). From dd32eebf06d77a79eb44323a5be6de53d14f2bb9 Mon Sep 17 00:00:00 2001 From: Justin Walsh <contact.me@thejustinwalsh.com> Date: Wed, 3 Jun 2026 05:31:25 -0400 Subject: [PATCH 08/10] =?UTF-8?q?feat(epic-store):=20Phase=202=20=E2=80=94?= =?UTF-8?q?=20file-watcher=20Q&A=20loop=20on=20the=20poller=20cron?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close #197. File-mode answers resume natively via an mtime-poll file-watcher hung off the existing poller cron (no new cron, same 120s cadence). - `epic-store/watcher.ts`: `collectChangedSince` (mtime poll — no chokidar), `pollFileSignals` (open question with a non-empty answer in a changed file → `{ ref, questionId, body }`), `resolveQuestion` (flip to resolved via the renderer — the dedup write), and `runFileWatcherTick` (one pass over file-mode repos: fire each newly-answered Epic's resume signal, mark the wait fired, flip the question resolved). `filePollGateway.pollFileSignals` exposes the scan. - `poller-cron.ts`: a new optional `fileWatcher` pass, guarded like the others. - `main.ts`: wires `runFileWatcherTick` over the managed file-mode repos, tracking `lastWatcherTick`, firing `engine.signal(workflowId, RESUME_EVENT, {reason: "answered-question", ...})` — exactly the github-comment resume shape. - Tests: watcher unit tests (placeholder/empty answer don't trigger; only the first non-empty edit fires; mtime gate skips unchanged files; resolveQuestion idempotent) and an integration test that boots the poller cron, edits a parked Epic's answer block, and asserts the resume fires and the continuation reaches `completed`. typecheck/lint/format clean; full suite green (1237). --- .../src/epic-store/file-poll-gateway.ts | 10 +- packages/dispatcher/src/epic-store/watcher.ts | 125 +++++++++ packages/dispatcher/src/main.ts | 36 ++- packages/dispatcher/src/poller-cron.ts | 15 ++ .../file-watcher-integration.test.ts | 249 ++++++++++++++++++ .../test/epic-store/watcher.test.ts | 111 ++++++++ planning/issues/190/decisions.md | 21 ++ 7 files changed, 565 insertions(+), 2 deletions(-) create mode 100644 packages/dispatcher/src/epic-store/watcher.ts create mode 100644 packages/dispatcher/test/epic-store/file-watcher-integration.test.ts create mode 100644 packages/dispatcher/test/epic-store/watcher.test.ts diff --git a/packages/dispatcher/src/epic-store/file-poll-gateway.ts b/packages/dispatcher/src/epic-store/file-poll-gateway.ts index cbca88ba..ea6ae595 100644 --- a/packages/dispatcher/src/epic-store/file-poll-gateway.ts +++ b/packages/dispatcher/src/epic-store/file-poll-gateway.ts @@ -16,8 +16,15 @@ import type { EpicPrLifecycle, IssueComment, PollGateway, PrSnapshot } from "../poller.ts"; import { FILE_AGENT_LOGIN, FILE_HUMAN_LOGIN } from "./file-epic-gateway.ts"; import { epicFileExists, readEpicFile } from "./epic-file-io.ts"; +import { type FileAnswerSignal, pollFileSignals } from "./watcher.ts"; import type { ConversationEntry } from "./epic-file/types.ts"; +/** The file poll gateway plus the Phase-2 file-watcher method (not on the shared interface). */ +export type FilePollGateway = PollGateway & { + /** Newly-answered questions (open question → non-empty answer) in files changed since `sinceMs`. */ + pollFileSignals(sinceMs: number): FileAnswerSignal[]; +}; + export type FilePollGatewayDeps = { /** Absolute path to this repo's Epic directory (`planning/epics`). */ epicsDir: string; @@ -69,9 +76,10 @@ function conversationToPollComments(conversation: ConversationEntry[]): IssueCom return out; } -export function makeFilePollGateway(deps: FilePollGatewayDeps): PollGateway { +export function makeFilePollGateway(deps: FilePollGatewayDeps): FilePollGateway { const { epicsDir, gh } = deps; return { + pollFileSignals: (sinceMs) => pollFileSignals(epicsDir, sinceMs), async listIssueComments(repo, ref): Promise<IssueComment[]> { if (!epicFileExists(epicsDir, ref)) return gh.listIssueComments(repo, ref); const epic = readEpicFile(epicsDir, ref); diff --git a/packages/dispatcher/src/epic-store/watcher.ts b/packages/dispatcher/src/epic-store/watcher.ts new file mode 100644 index 00000000..50486799 --- /dev/null +++ b/packages/dispatcher/src/epic-store/watcher.ts @@ -0,0 +1,125 @@ +/** + * Phase-2 file-watcher mechanics: stat-based mtime polling of `epics_dir` (no + * `chokidar`, no extra dependency — the spec's deliberate choice). The poller + * cron calls this on its existing 120s tick; a human editing an Epic file's + * `<!-- middle:answer for=N -->` block to non-empty content is detected and fires + * the resume signal exactly like a new GitHub comment does in github mode. + * + * Dedup is structural: only an `open` question with a non-empty answer is a + * signal, and firing flips that question to `resolved` (via the renderer) — so a + * later tick over the same (now-resolved) block never re-fires. The mtime gate is + * the cheap pre-filter that skips unchanged files. + */ + +import type { Database } from "bun:sqlite"; +import { existsSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import type { ResumeSignalPayload } from "../poller.ts"; +import { findParkedWorkflowByRef, markSignalFired } from "../workflow-record.ts"; +import { readEpicFile, writeEpicFile } from "./epic-file-io.ts"; + +/** A newly-answered question detected on disk: which Epic, which question, the reply. */ +export type FileAnswerSignal = { ref: string; questionId: number; body: string }; + +/** Epic slugs in `epicsDir` whose file `mtime > sinceMs` (the mtime poll). */ +export function collectChangedSince(epicsDir: string, sinceMs: number): string[] { + if (!existsSync(epicsDir)) return []; + const out: string[] = []; + for (const name of readdirSync(epicsDir)) { + if (!name.endsWith(".md") || name.startsWith(".")) continue; + if (statSync(join(epicsDir, name)).mtimeMs > sinceMs) out.push(name.slice(0, -".md".length)); + } + return out; +} + +/** + * Scan `epicsDir` for Epic files changed since `sinceMs` and return one signal per + * `open` question that now carries a non-empty answer. The parser already drops + * the answer placeholder + empty/whitespace answers (an `answer` is set only when + * non-empty), so a placeholder-only or empty edit yields nothing; the `open` + * filter (paired with the caller's flip-to-`resolved`) ensures only the first + * non-empty edit per question triggers. + */ +export function pollFileSignals(epicsDir: string, sinceMs: number): FileAnswerSignal[] { + const out: FileAnswerSignal[] = []; + for (const ref of collectChangedSince(epicsDir, sinceMs)) { + const epic = readEpicFile(epicsDir, ref); + if (!epic) continue; + for (const entry of epic.conversation) { + if ( + entry.kind === "question" && + entry.status === "open" && + entry.answer !== undefined && + entry.answer.body.trim() !== "" + ) { + out.push({ ref, questionId: entry.id, body: entry.answer.body }); + } + } + } + return out; +} + +/** + * Flip a question's status to `resolved` in the Epic file (via the renderer — the + * sole writer of strict markers). Idempotent: a no-op if the file/question is + * gone or already resolved. This is the dedup write the watcher does right after + * firing the resume, so the next tick doesn't re-fire the same answer. + */ +export function resolveQuestion(epicsDir: string, ref: string, questionId: number): void { + const epic = readEpicFile(epicsDir, ref); + if (!epic) return; + let changed = false; + const conversation = epic.conversation.map((entry) => { + if (entry.kind === "question" && entry.id === questionId && entry.status === "open") { + changed = true; + return { ...entry, status: "resolved" as const }; + } + return entry; + }); + if (changed) writeEpicFile(epicsDir, ref, { ...epic, conversation }); +} + +/** Deps for one {@link runFileWatcherTick} pass. */ +export type FileWatcherTickDeps = { + db: Database; + /** The file-mode repos to scan, each with its absolute Epic directory. */ + fileModeRepos: () => Array<{ repo: string; epicsDir: string }>; + /** Deliver the resume signal to the engine (the daemon wires `engine.signal`). */ + fireSignal: (workflowId: string, payload: ResumeSignalPayload) => Promise<void>; +}; + +/** + * One file-watcher pass over every file-mode repo (hung off the poller cron): + * mtime-poll `epics_dir` for parked Epics whose answer block became non-empty + * since `sinceMs`, fire each one's resume signal (`reason: "answered-question"`, + * exactly like a new GitHub comment), mark the durable wait fired so the resume + * poll doesn't double-fire, and flip the question to `resolved` so a later tick + * never re-fires it. Per-repo scan failures are isolated. Returns the count fired. + */ +export async function runFileWatcherTick( + deps: FileWatcherTickDeps, + sinceMs: number, +): Promise<number> { + let fired = 0; + for (const { repo, epicsDir } of deps.fileModeRepos()) { + let signals: FileAnswerSignal[]; + try { + signals = pollFileSignals(epicsDir, sinceMs); + } catch (error) { + console.error(`[file-watcher] ${repo} scan failed: ${(error as Error).message}`); + continue; + } + for (const sig of signals) { + const workflowId = findParkedWorkflowByRef(deps.db, repo, sig.ref); + if (workflowId === null) continue; + await deps.fireSignal(workflowId, { + reason: "answered-question", + reply: { commentId: sig.questionId, authorLogin: "human", body: sig.body }, + }); + markSignalFired(deps.db, workflowId); + resolveQuestion(epicsDir, sig.ref, sig.questionId); + fired += 1; + } + } + return fired; +} diff --git a/packages/dispatcher/src/main.ts b/packages/dispatcher/src/main.ts index a6a14f6c..4472aae4 100644 --- a/packages/dispatcher/src/main.ts +++ b/packages/dispatcher/src/main.ts @@ -35,7 +35,13 @@ import { startRetentionCron } from "./retention-cron.ts"; import { runRetentionPass } from "./retention.ts"; import { startAuditCron } from "./audit-cron.ts"; import { startStalenessCron } from "./staleness-cron.ts"; -import { isPaused, listManagedRepos, registerManagedRepo } from "./repo-config.ts"; +import { + isPaused, + listManagedRepos, + readEpicStoreConfig, + registerManagedRepo, +} from "./repo-config.ts"; +import { runFileWatcherTick } from "./epic-store/watcher.ts"; import { getSlotState, hasFreeSlot } from "./slots.ts"; import { ghStateIssueGateway, readState, type StateGateway } from "./state-issue.ts"; import { capturePane, killSession, newSession, sendEnter, sendText, status } from "./tmux.ts"; @@ -756,6 +762,32 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<void> { } } + // Phase-2 file-watcher (#197): on each poller tick, mtime-poll every file-mode + // repo's `epics_dir` and fire the resume signal for any parked Epic whose + // `<!-- middle:answer -->` block became non-empty since the last tick — the + // file-mode equivalent of a new GitHub comment, on the same 120s cron (no new + // cron). Firing flips the question to `resolved` so it never re-fires. + let lastWatcherTick = Date.now(); + const fileWatcherTick = async (): Promise<void> => { + const since = lastWatcherTick; + const tickStart = Date.now(); + await runFileWatcherTick( + { + db, + fileModeRepos: () => + listManagedRepos(db).flatMap((m) => { + const cfg = readEpicStoreConfig(db, m.repo); + return cfg.mode === "file" + ? [{ repo: m.repo, epicsDir: join(m.checkoutPath, cfg.epicsDir) }] + : []; + }), + fireSignal: (workflowId, payload) => engine.signal(workflowId, RESUME_EVENT, payload), + }, + since, + ); + lastWatcherTick = tickStart; + }; + // GitHub poller: every POLLER_INTERVAL_MS (the pinned constant in // poller-cron.ts; the dispatcher's CLAUDE.md cadence contract holds it // and this doc in sync), for each parked workflow with an armed wait, @@ -804,6 +836,8 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<void> { }, onMergedTransition: (repo) => reconcileOpenPRsForRepo(repo), }, + // Phase-2 file-mode answer watcher (#197), hung off the same cron. + fileWatcher: fileWatcherTick, }, ); diff --git a/packages/dispatcher/src/poller-cron.ts b/packages/dispatcher/src/poller-cron.ts index ecc53552..333cf7be 100644 --- a/packages/dispatcher/src/poller-cron.ts +++ b/packages/dispatcher/src/poller-cron.ts @@ -41,6 +41,14 @@ export type StartPollerOptions = { checkboxRevert?: CheckboxRevertPassDeps; /** Tick cadence override (default {@link POLLER_INTERVAL_MS}). */ intervalMs?: number; + /** + * The Phase-2 file-mode answer watcher (#197). When wired, each tick also runs + * one mtime-poll pass over file-mode repos' `epics_dir`, firing the resume + * signal for any parked Epic whose `<!-- middle:answer -->` block became + * non-empty. Hung off the existing cron (no new cron, same 120s cadence). + * Omitted → file-mode answers resume only via the manual `mm resume` escape hatch. + */ + fileWatcher?: () => Promise<void>; /** * Open-PR divergence reconciler hooks (Epic #168). When provided, each tick * runs `perTickSweep` after the resume + merged-parks reconciliation, and @@ -99,6 +107,13 @@ export async function startPoller( console.error(`[checkbox-revert] pass failed: ${(error as Error).message}`); } } + if (opts.fileWatcher) { + try { + await opts.fileWatcher(); + } catch (error) { + console.error(`[file-watcher] pass failed: ${(error as Error).message}`); + } + } }, }); await queue.every("poller-tick", opts.intervalMs ?? POLLER_INTERVAL_MS); diff --git a/packages/dispatcher/test/epic-store/file-watcher-integration.test.ts b/packages/dispatcher/test/epic-store/file-watcher-integration.test.ts new file mode 100644 index 00000000..0ebc08bf --- /dev/null +++ b/packages/dispatcher/test/epic-store/file-watcher-integration.test.ts @@ -0,0 +1,249 @@ +/** + * Integration (#197): the Phase-2 file-watcher resumes a parked file-mode Epic. + * A real file-mode dispatch parks asking a question (`waiting-human`); a human + * edits the Epic file's `<!-- middle:answer -->` block to non-empty content; the + * poller cron's file-watcher pass detects the mtime change on the next tick, fires + * the resume signal, and the continuation drives to `completed`. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Engine } from "bunqueue/workflow"; +import type { AgentAdapter, HookPayload, StopClassification } from "@middle/core"; +import { openAndMigrate } from "../../src/db.ts"; +import type { SessionGate } from "../../src/hook-server.ts"; +import type { + EpicPrLifecycle, + PollGateway, + PrSnapshot, + RateLimitStatus, +} from "../../src/poller.ts"; +import { startPoller } from "../../src/poller-cron.ts"; +import { registerManagedRepo, setEpicStoreConfig } from "../../src/repo-config.ts"; +import { appendQuestion } from "../../src/epic-store/index.ts"; +import { readEpicFile, writeEpicFile } from "../../src/epic-store/epic-file-io.ts"; +import { runFileWatcherTick } from "../../src/epic-store/watcher.ts"; +import { renderEpicFile } from "../../src/epic-store/epic-file/renderer.ts"; +import { getWorkflow } from "../../src/workflow-record.ts"; +import { + createImplementationWorkflow, + RESUME_EVENT, + type ImplementationDeps, +} from "../../src/workflows/implementation.ts"; +import { createWorktree, destroyWorktree } from "../../src/worktree.ts"; +import type { Database } from "bun:sqlite"; + +const SLUG = "rollout-epic-store"; +const REPO = "o/file-repo"; +const GIT_ENV = { + ...process.env, + GIT_AUTHOR_NAME: "t", + GIT_AUTHOR_EMAIL: "t@e.invalid", + GIT_COMMITTER_NAME: "t", + GIT_COMMITTER_EMAIL: "t@e.invalid", +}; +async function git(cwd: string, args: string[]): Promise<void> { + const proc = Bun.spawn(["git", "-C", cwd, ...args], { + stdout: "ignore", + stderr: "pipe", + env: GIT_ENV, + }); + if ((await proc.exited) !== 0) + throw new Error(`git ${args.join(" ")}: ${await new Response(proc.stderr).text()}`); +} + +let scratch: string; +let repoPath: string; +let worktreeRoot: string; +let epicsDir: string; +let db: Database; +let engine: Engine; + +beforeEach(async () => { + scratch = realpathSync(mkdtempSync(join(tmpdir(), "middle-fw-"))); + repoPath = join(scratch, "repo"); + worktreeRoot = join(scratch, "worktrees"); + await git(scratch, ["init", "repo"]); + await git(repoPath, ["commit", "--allow-empty", "-m", "init"]); + epicsDir = join(repoPath, "planning", "epics"); + mkdirSync(epicsDir, { recursive: true }); + writeFileSync( + join(epicsDir, `${SLUG}.md`), + renderEpicFile({ + title: "feat: x", + meta: { slug: SLUG, adapter: "stub" }, + context: "ctx", + acceptanceCriteria: [{ checked: false, text: "ship" }], + subIssues: [{ id: 1, checked: false, title: "1 — gateways", body: "" }], + conversation: [], + }), + ); + db = openAndMigrate(join(scratch, "db.sqlite3")); + registerManagedRepo(db, REPO, repoPath); + setEpicStoreConfig(db, REPO, { + mode: "file", + epicsDir: "planning/epics", + stateFile: ".middle/state.md", + }); + engine = new Engine({ embedded: true }); +}); + +afterEach(async () => { + await engine.close(true); + db.close(); + rmSync(scratch, { recursive: true, force: true }); +}); + +const hangingGate: SessionGate = { + awaitSessionStart: async () => + ({ session_id: "stub", transcript_path: "/tmp/stub.jsonl" }) as HookPayload, + awaitStop: () => new Promise<HookPayload>(() => {}), +}; + +function makeAdapter(): AgentAdapter { + const seq: StopClassification[] = [ + { + kind: "asked-question", + sentinelPath: "/x/.middle/blocked.json", + sentinel: { question: "A or B?" }, + }, + { kind: "bare-stop" }, + ]; + let i = 0; + return { + name: "stub", + readyEvent: "session.started", + async installHooks(opts) { + mkdirSync(join(opts.worktree, ".middle"), { recursive: true }); + writeFileSync( + join(opts.worktree, ".middle", "blocked.json"), + JSON.stringify({ question: "?" }), + ); + }, + buildLaunchCommand: () => ({ argv: ["true"], env: {} }), + buildPromptText: () => "@.middle/prompt.md", + async enterAutoMode() {}, + resolveTranscriptPath: (p) => p.transcript_path as string, + readTranscriptState: () => ({ + lastActivity: "", + contextTokens: 0, + turnCount: 0, + lastToolUse: null, + }), + classifyStop: () => seq[Math.min(i++, seq.length - 1)]!, + }; +} + +/** A github PollGateway stub that surfaces no github-side resume (file mode resumes via the watcher). */ +const stubGithubPoll: PollGateway = { + async listIssueComments() { + return []; + }, + async findPrForEpic(): Promise<PrSnapshot | null> { + return null; + }, + async findEpicPrLifecycle(): Promise<EpicPrLifecycle | null> { + return null; + }, + async getRateLimit(): Promise<RateLimitStatus> { + return { remaining: 5000, resetAt: 0 }; + }, +}; + +function makeDeps(): ImplementationDeps { + return { + db, + getAdapter: () => makeAdapter(), + sessionGate: hangingGate, + tmux: { + async newSession() {}, + async sendText() {}, + async sendEnter() {}, + async killSession() {}, + status: async () => ({ alive: false }), + }, + worktree: { createWorktree, destroyWorktree }, + resolveRepoPath: () => repoPath, + worktreeRoot, + dispatcherUrl: "http://127.0.0.1:8822", + launchTimeoutMs: 2000, + stopTimeoutMs: 2000, + livenessPollMs: 20, + resolveEpicStoreMode: () => "file", + enqueueContinuation: async (input) => { + await engine.start("implementation", input); + }, + postQuestion: async ({ epicRef, question, context, kind }) => { + appendQuestion(epicsDir, epicRef, { question, context, kind }); + }, + }; +} + +async function awaitState(id: string, state: string, timeoutMs = 8000): Promise<void> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (getWorkflow(db, id)?.state === state) return; + await Bun.sleep(20); + } + throw new Error(`workflow ${id} did not reach '${state}' (was '${getWorkflow(db, id)?.state}')`); +} + +describe("file-watcher Q&A loop (#197)", () => { + test("poller cron detects a non-empty answer edit and resumes the parked Epic to completion", async () => { + engine.register(createImplementationWorkflow(makeDeps())); + const handle = await engine.start("implementation", { + repo: REPO, + epicRef: SLUG, + adapter: "stub", + }); + await awaitState(handle.id, "waiting-human"); + // The park wrote an open question into the Epic file. + const parked = readEpicFile(epicsDir, SLUG)!; + expect(parked.conversation.some((e) => e.kind === "question" && e.status === "open")).toBe( + true, + ); + + // Human edits the answer block to non-empty content (what the watcher detects). + writeEpicFile(epicsDir, SLUG, { + ...parked, + conversation: parked.conversation.map((e) => + e.kind === "question" && e.id === 1 ? { ...e, answer: { body: "Go with A." } } : e, + ), + }); + + // Boot the poller cron with the real file-watcher pass (since=0 so the first + // tick catches the edit); the github poll side is a no-op stub. + const stop = await startPoller( + { + db, + github: stubGithubPoll, + fireSignal: (id, payload) => engine.signal(id, RESUME_EVENT, payload), + }, + { + intervalMs: 40, + fileWatcher: async () => { + await runFileWatcherTick( + { + db, + fileModeRepos: () => [{ repo: REPO, epicsDir }], + fireSignal: (id, payload) => engine.signal(id, RESUME_EVENT, payload), + }, + 0, + ); + }, + }, + ); + try { + // The watcher fires the resume → continuation drives to completion. + await awaitState(handle.id, "completed"); + // The answered question was flipped to resolved (dedup — won't re-fire). + expect( + readEpicFile(epicsDir, SLUG)!.conversation.find((e) => e.kind === "question")?.status, + ).toBe("resolved"); + } finally { + await stop(); + } + }); +}); diff --git a/packages/dispatcher/test/epic-store/watcher.test.ts b/packages/dispatcher/test/epic-store/watcher.test.ts new file mode 100644 index 00000000..4874a2ce --- /dev/null +++ b/packages/dispatcher/test/epic-store/watcher.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + collectChangedSince, + pollFileSignals, + resolveQuestion, +} from "../../src/epic-store/watcher.ts"; +import { readEpicFile } from "../../src/epic-store/epic-file-io.ts"; +import { renderEpicFile } from "../../src/epic-store/epic-file/renderer.ts"; +import type { ConversationEntry, EpicFile } from "../../src/epic-store/epic-file/types.ts"; + +function tmpDir(): string { + return mkdtempSync(join(tmpdir(), "middle-watch-")); +} + +function writeEpic(dir: string, slug: string, conversation: ConversationEntry[]): void { + const epic: EpicFile = { + title: "feat: x", + meta: { slug }, + context: "ctx", + acceptanceCriteria: [], + subIssues: [], + conversation, + }; + writeFileSync(join(dir, `${slug}.md`), renderEpicFile(epic)); +} + +const Q_OPEN_ANSWERED: ConversationEntry = { + kind: "question", + id: 1, + status: "open", + ts: "2026-06-03T00:00:00.000Z", + body: "A or B?", + answer: { body: "Go with A." }, +}; +const Q_OPEN_UNANSWERED: ConversationEntry = { + kind: "question", + id: 1, + status: "open", + ts: "2026-06-03T00:00:00.000Z", + body: "A or B?", +}; + +describe("collectChangedSince", () => { + test("includes files with mtime > sinceMs, excludes older + dotfiles/.tmp", () => { + const dir = tmpDir(); + writeEpic(dir, "rollout", []); + writeFileSync(join(dir, ".keep"), ""); + writeFileSync(join(dir, ".rollout.md.tmp"), "x"); + const mt = statSync(join(dir, "rollout.md")).mtimeMs; + expect(collectChangedSince(dir, mt - 1)).toEqual(["rollout"]); + expect(collectChangedSince(dir, mt + 1000)).toEqual([]); + }); + + test("missing dir → empty", () => { + expect(collectChangedSince(join(tmpDir(), "nope"), 0)).toEqual([]); + }); +}); + +describe("pollFileSignals", () => { + test("emits an open question that has a non-empty answer", () => { + const dir = tmpDir(); + writeEpic(dir, "rollout", [Q_OPEN_ANSWERED]); + expect(pollFileSignals(dir, 0)).toEqual([ + { ref: "rollout", questionId: 1, body: "Go with A." }, + ]); + }); + + test("an unanswered question (placeholder) does NOT trigger", () => { + const dir = tmpDir(); + writeEpic(dir, "rollout", [Q_OPEN_UNANSWERED]); + // The renderer writes the answer placeholder; the parser reads answer=undefined. + expect(readEpicFile(dir, "rollout")!.conversation[0]).toMatchObject({ answer: undefined }); + expect(pollFileSignals(dir, 0)).toEqual([]); + }); + + test("a resolved question does NOT trigger (only the first non-empty edit fires)", () => { + const dir = tmpDir(); + writeEpic(dir, "rollout", [{ ...Q_OPEN_ANSWERED, status: "resolved" }]); + expect(pollFileSignals(dir, 0)).toEqual([]); + }); + + test("the mtime gate skips unchanged files", () => { + const dir = tmpDir(); + writeEpic(dir, "rollout", [Q_OPEN_ANSWERED]); + const mt = statSync(join(dir, "rollout.md")).mtimeMs; + expect(pollFileSignals(dir, mt + 1000)).toEqual([]); // not changed since → skipped + }); +}); + +describe("resolveQuestion", () => { + test("flips an open question to resolved (the dedup write); idempotent", () => { + const dir = tmpDir(); + writeEpic(dir, "rollout", [Q_OPEN_ANSWERED]); + resolveQuestion(dir, "rollout", 1); + const after = readEpicFile(dir, "rollout")!; + expect(after.conversation[0]).toMatchObject({ kind: "question", status: "resolved" }); + // After resolving, the watcher no longer emits it. + expect(pollFileSignals(dir, 0)).toEqual([]); + // Idempotent: a second resolve is a no-op (no throw). + resolveQuestion(dir, "rollout", 1); + expect(readEpicFile(dir, "rollout")!.conversation[0]).toMatchObject({ status: "resolved" }); + }); + + test("a missing file/question is a no-op", () => { + const dir = tmpDir(); + expect(() => resolveQuestion(dir, "nope", 1)).not.toThrow(); + }); +}); diff --git a/planning/issues/190/decisions.md b/planning/issues/190/decisions.md index eb477697..2c57470b 100644 --- a/planning/issues/190/decisions.md +++ b/planning/issues/190/decisions.md @@ -207,3 +207,24 @@ run; the automated parity test covers the same code paths deterministically. **Why:** honest integration accounting — claim the automated evidence delivered, flag the live-repo smoke as operator-only rather than silently checking it off. **Evidence:** parity.test.ts (4 cases green); the dispatch is headless (no live agent). + +## File-watcher is an mtime pass hung off the existing poller cron (no new cron) +**File(s):** `packages/dispatcher/src/epic-store/watcher.ts`, `poller-cron.ts`, `main.ts` +**Date:** 2026-06-03 + +**Decision:** Phase 2 adds `collectChangedSince` (mtime poll, no `chokidar`) + +`pollFileSignals` (open question with a non-empty answer in a changed file) + +`resolveQuestion` (flip to resolved via the renderer) + `runFileWatcherTick` (the +per-tick pass). It's wired as an optional `fileWatcher` pass on the EXISTING poller +cron (`StartPollerOptions.fileWatcher`) — same 120s cadence, no new cron. The daemon +tracks `lastWatcherTick` and scans each file-mode repo's `epics_dir`; firing the +resume marks the durable wait fired and flips the question to `resolved` (structural +dedup — a later tick never re-fires). +**Why:** file answers can't be detected by the github `createdAt > sinceMs` path (a +file answer inherits the question's ts, which predates the park) — so file mode needs +the mtime + open-question-status mechanism. Sharing the cron keeps the cadence +symmetric with github comment polling and avoids a second timer. `runFileWatcherTick` +is extracted (not inlined in main.ts) so the daemon and the integration test share one +implementation — no drift. The github resume poll stays untouched. +**Evidence:** spec "filePollGateway" file-watcher paragraph; `poller.ts` +`classifyNewHumanReply` (createdAt-gated); sub-issue #197 "no new cron". From 84d0d9fb999eeea12db1fa4aab87d0fcb93b29af Mon Sep 17 00:00:00 2001 From: Justin Walsh <contact.me@thejustinwalsh.com> Date: Wed, 3 Jun 2026 05:45:46 -0400 Subject: [PATCH 09/10] fix(epic-store): route the poller + recovery surface; guard the watcher reason MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review hardening (clean-eyes pass over the branch diff). - The poller, its merged-parks reconciler, and the orphan-recovery comment surface were wired to the raw `ghPollGateway`/`ghGitHub`, so a parked file-mode Epic's slug reached gh's numeric `Closes #<n>` finders and threw `refToIssueNumber` every tick (caught + logged, but noisy and rate-limit-probe contention in a mixed deployment). Add `makeRoutingPollGateway` (the poller's counterpart to `makeRoutingEpicGateway`) and wire the poller's `github` + the orphan surface to the per-repo routers in `main.ts`. File-mode repos now resolve PR-finders to null and comment-listing to the Epic file → the github resume poll is a clean no-op (the file-watcher owns that resume). - `runFileWatcherTick` now only fires when the parked workflow's armed signal is the `answered-question` one — an answer edit can't resume a workflow parked for another reason (mirrors the github poller's reason-keyed dispatch). - Deduped main.ts's `resolveRepoPath` into one shared helper. Tests: routing poll gateway (file slug → null, gh never consulted; github delegates) and the watcher reason-guard (non-answered park isn't resumed). Full suite 1240 green; typecheck/lint/format clean. github mode unchanged. --- packages/dispatcher/src/epic-store/index.ts | 28 +++++++ packages/dispatcher/src/epic-store/watcher.ts | 10 ++- packages/dispatcher/src/main.ts | 31 ++++++-- .../test/epic-store/selector.test.ts | 56 +++++++++++++ .../test/epic-store/watcher.test.ts | 79 +++++++++++++++++++ planning/issues/190/decisions.md | 22 ++++++ 6 files changed, 217 insertions(+), 9 deletions(-) diff --git a/packages/dispatcher/src/epic-store/index.ts b/packages/dispatcher/src/epic-store/index.ts index df59579f..601fb728 100644 --- a/packages/dispatcher/src/epic-store/index.ts +++ b/packages/dispatcher/src/epic-store/index.ts @@ -146,6 +146,34 @@ export function makeRoutingEpicGateway(deps: { }; } +/** + * A daemon-global `PollGateway` that routes each call to the right per-repo backend + * (file or gh), keyed on the method's `repo` argument — the poller's counterpart to + * {@link makeRoutingEpicGateway}. Without it, the poller would feed a file-mode + * slug into gh's `Closes #<number>` finders (which `refToIssueNumber` rejects), + * throwing every tick for a parked file-mode Epic; routed, a file-mode repo's + * PR-finders return null and its comment-listing reads the Epic file, so the github + * resume poll is a clean no-op for file mode (the file-watcher owns that resume). + * `getRateLimit` has no repo and always delegates to gh (the budget is global). + */ +export function makeRoutingPollGateway(deps: { + db: Database; + resolveRepoPath: (repo: string) => string; + ghEpic?: EpicGateway; + ghPoll?: PollGateway; +}): PollGateway { + const ghEpic = deps.ghEpic ?? ghGitHub; + const ghPoll = deps.ghPoll ?? ghPollGateway; + const pollFor = (repo: string): PollGateway => + trioForRepo(deps.db, repo, deps.resolveRepoPath, { epic: ghEpic, poll: ghPoll }).pollGateway; + return { + listIssueComments: (repo, ref) => pollFor(repo).listIssueComments(repo, ref), + findPrForEpic: (repo, epicRef) => pollFor(repo).findPrForEpic(repo, epicRef), + findEpicPrLifecycle: (repo, epicRef) => pollFor(repo).findEpicPrLifecycle(repo, epicRef), + getRateLimit: () => ghPoll.getRateLimit(), + }; +} + /** * Append a `<!-- middle:question -->` block to an Epic file's conversation — the * file-mode `postQuestion` endpoint (the agent-side of #178's class, structurally diff --git a/packages/dispatcher/src/epic-store/watcher.ts b/packages/dispatcher/src/epic-store/watcher.ts index 50486799..38e392cd 100644 --- a/packages/dispatcher/src/epic-store/watcher.ts +++ b/packages/dispatcher/src/epic-store/watcher.ts @@ -14,8 +14,8 @@ import type { Database } from "bun:sqlite"; import { existsSync, readdirSync, statSync } from "node:fs"; import { join } from "node:path"; -import type { ResumeSignalPayload } from "../poller.ts"; -import { findParkedWorkflowByRef, markSignalFired } from "../workflow-record.ts"; +import { reasonFromSignalName, type ResumeSignalPayload } from "../poller.ts"; +import { findParkedWorkflowByRef, getWaitForSignal, markSignalFired } from "../workflow-record.ts"; import { readEpicFile, writeEpicFile } from "./epic-file-io.ts"; /** A newly-answered question detected on disk: which Epic, which question, the reply. */ @@ -112,6 +112,12 @@ export async function runFileWatcherTick( for (const sig of signals) { const workflowId = findParkedWorkflowByRef(deps.db, repo, sig.ref); if (workflowId === null) continue; + // Only resume a workflow that is actually parked on a question (its armed + // signal is the `answered` one) — an answer edit must not resume a workflow + // parked for some other reason (e.g. review-changes), mirroring the github + // poller's reason-keyed dispatch. + const armed = getWaitForSignal(deps.db, workflowId); + if (!armed || reasonFromSignalName(armed.signalName) !== "answered-question") continue; await deps.fireSignal(workflowId, { reason: "answered-question", reply: { commentId: sig.questionId, authorLogin: "human", body: sig.body }, diff --git a/packages/dispatcher/src/main.ts b/packages/dispatcher/src/main.ts index 4472aae4..f12d7f4a 100644 --- a/packages/dispatcher/src/main.ts +++ b/packages/dispatcher/src/main.ts @@ -41,6 +41,7 @@ import { readEpicStoreConfig, registerManagedRepo, } from "./repo-config.ts"; +import { makeRoutingEpicGateway, makeRoutingPollGateway } from "./epic-store/index.ts"; import { runFileWatcherTick } from "./epic-store/watcher.ts"; import { getSlotState, hasFreeSlot } from "./slots.ts"; import { ghStateIssueGateway, readState, type StateGateway } from "./state-issue.ts"; @@ -234,6 +235,23 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<void> { registerManagedRepo(db, repo, repoPath); }; + const resolveRepoPath = (repo: string): string => { + const path = repoPaths.get(repo); + if (path === undefined) throw new Error(`no checkout path registered for repo ${repo}`); + return path; + }; + // Per-repo routing gateways: the daemon serves many repos through one poller + + // recovery surface, but Epic-store mode is per-repo. These route each call to the + // repo's file or gh backend so a file-mode slug never reaches gh's numeric + // `Closes #<n>` finders (which would throw `refToIssueNumber` every tick). + const routingEpicGateway = makeRoutingEpicGateway({ db, resolveRepoPath, ghEpic: ghGitHub }); + const routingPollGateway = makeRoutingPollGateway({ + db, + resolveRepoPath, + ghEpic: ghGitHub, + ghPoll: ghPollGateway, + }); + // ── Auto-dispatch (build spec → "Auto-dispatch loop") ────────────────────── // The collision-guarded enqueue: the single source of truth for the 409 guard // (the active-check and reservation run with no intervening await). Both the @@ -513,11 +531,7 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<void> { const { deps } = await buildImplementationDeps({ db, getAdapter, - resolveRepoPath: (repo) => { - const path = repoPaths.get(repo); - if (path === undefined) throw new Error(`no checkout path registered for repo ${repo}`); - return path; - }, + resolveRepoPath, worktreeRoot: config.global.worktreeRoot, // The dispatch brief tells the agent its fork budget — the repo's // `[limits] complexity_ceiling` (default 3), resolved per repo. @@ -693,7 +707,8 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<void> { `[recover] orphaned parked signal '${signalName}' for ${repo}#${epicRef ?? "?"} (workflow ${workflowId}) — no recoverable execution; finalized failed`, ); if (epicRef === null) return; - return ghGitHub + // Route per-repo: a file-mode orphan's slug would throw on the raw gh poster. + return routingEpicGateway .postComment( repo, epicRef, @@ -802,7 +817,9 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<void> { const stopPoller = await startPoller( { db, - github: ghPollGateway, + // Per-repo routing: a file-mode parked Epic's slug never hits gh's numeric + // PR-finders (file-mode resume rides the file-watcher pass below). + github: routingPollGateway, fireSignal: (workflowId, payload) => engine.signal(workflowId, RESUME_EVENT, payload), // Reconcile pass: when a parked Epic's PR has merged/closed, finalize the row // and best-effort tear down its worktree (repo checkout from the registry). diff --git a/packages/dispatcher/test/epic-store/selector.test.ts b/packages/dispatcher/test/epic-store/selector.test.ts index 459e3996..9998fa18 100644 --- a/packages/dispatcher/test/epic-store/selector.test.ts +++ b/packages/dispatcher/test/epic-store/selector.test.ts @@ -8,7 +8,14 @@ import { buildFileGateways, buildGitHubGateways, makeRoutingEpicGateway, + makeRoutingPollGateway, } from "../../src/epic-store/index.ts"; +import type { + EpicPrLifecycle, + PollGateway, + PrSnapshot, + RateLimitStatus, +} from "../../src/poller.ts"; import { ghGitHub } from "../../src/github.ts"; import { ghPollGateway } from "../../src/poller-gateway.ts"; import { ghStateIssueGateway } from "../../src/state-issue.ts"; @@ -102,6 +109,55 @@ describe("makeRoutingEpicGateway", () => { }); }); +describe("makeRoutingPollGateway", () => { + test("a file-mode slug never reaches gh's numeric PR-finders; github delegates", async () => { + const scratch = tmpDir("middle-pollroute-"); + const db = openAndMigrate(join(scratch, "db.sqlite3")); + try { + const repoDir = join(scratch, "repo"); + const epicsDir = join(repoDir, "planning", "epics"); + mkdirSync(epicsDir, { recursive: true }); + seedEpic(epicsDir, "rollout", []); + registerManagedRepo(db, "o/file", repoDir); + setEpicStoreConfig(db, "o/file", { + mode: "file", + epicsDir: "planning/epics", + stateFile: ".middle/state.md", + }); + const ghCalls: string[] = []; + const ghPoll: PollGateway = { + async listIssueComments() { + return []; + }, + async findPrForEpic(_repo, epicRef): Promise<PrSnapshot | null> { + ghCalls.push(`findPr:${epicRef}`); + return { number: 5, reviewDecision: null, reviews: [], labels: [] }; + }, + async findEpicPrLifecycle(): Promise<EpicPrLifecycle | null> { + return { number: 5, state: "OPEN" }; + }, + async getRateLimit(): Promise<RateLimitStatus> { + ghCalls.push("rate"); + return { remaining: 4999, resetAt: 0 }; + }, + }; + const router = makeRoutingPollGateway({ db, resolveRepoPath: () => repoDir, ghPoll }); + + // file repo: the slug routes to the file poll gateway → null, gh never consulted. + expect(await router.findPrForEpic("o/file", "rollout")).toBeNull(); + expect(ghCalls).toEqual([]); + // github repo (no config): delegates to the gh poll backend. + expect(await router.findPrForEpic("o/github", "7")).toMatchObject({ number: 5 }); + expect(ghCalls).toEqual(["findPr:7"]); + // getRateLimit always delegates to gh (the budget is global, no repo). + await router.getRateLimit(); + expect(ghCalls).toContain("rate"); + } finally { + db.close(); + } + }); +}); + describe("appendQuestion", () => { test("appends an open question block that re-parses; ids increment", () => { const dir = tmpDir("middle-q-"); diff --git a/packages/dispatcher/test/epic-store/watcher.test.ts b/packages/dispatcher/test/epic-store/watcher.test.ts index 4874a2ce..8e05fa64 100644 --- a/packages/dispatcher/test/epic-store/watcher.test.ts +++ b/packages/dispatcher/test/epic-store/watcher.test.ts @@ -6,10 +6,20 @@ import { collectChangedSince, pollFileSignals, resolveQuestion, + runFileWatcherTick, } from "../../src/epic-store/watcher.ts"; import { readEpicFile } from "../../src/epic-store/epic-file-io.ts"; import { renderEpicFile } from "../../src/epic-store/epic-file/renderer.ts"; import type { ConversationEntry, EpicFile } from "../../src/epic-store/epic-file/types.ts"; +import { openAndMigrate } from "../../src/db.ts"; +import { + armWaitForSignal, + createWorkflowRecord, + loadPollableWaits, + updateWorkflow, +} from "../../src/workflow-record.ts"; +import { signalNameFor } from "../../src/workflows/implementation.ts"; +import type { ResumeSignalPayload } from "../../src/poller.ts"; function tmpDir(): string { return mkdtempSync(join(tmpdir(), "middle-watch-")); @@ -109,3 +119,72 @@ describe("resolveQuestion", () => { expect(() => resolveQuestion(dir, "nope", 1)).not.toThrow(); }); }); + +describe("runFileWatcherTick", () => { + function parkedDb(signalReason: "answered-question" | "review-changes") { + const dir = tmpDir(); + const db = openAndMigrate(join(dir, "db.sqlite3")); + createWorkflowRecord(db, { + id: "wf1", + kind: "implementation", + repo: "o/r", + epicRef: "rollout", + adapter: "claude", + }); + updateWorkflow(db, "wf1", { state: "waiting-human" }); + armWaitForSignal(db, signalNameFor("rollout", signalReason), "wf1"); + const epicsDir = tmpDir(); + writeEpic(epicsDir, "rollout", [Q_OPEN_ANSWERED]); + return { db, epicsDir }; + } + + test("fires the resume + resolves the question for an answered-question park", async () => { + const { db, epicsDir } = parkedDb("answered-question"); + const fired: Array<{ id: string; payload: ResumeSignalPayload }> = []; + const n = await runFileWatcherTick( + { + db, + fileModeRepos: () => [{ repo: "o/r", epicsDir }], + fireSignal: async (id, payload) => { + fired.push({ id, payload }); + }, + }, + 0, + ); + expect(n).toBe(1); + expect(fired).toEqual([ + { + id: "wf1", + payload: { + reason: "answered-question", + reply: { commentId: 1, authorLogin: "human", body: "Go with A." }, + }, + }, + ]); + // The durable wait is marked fired (so the github poll won't re-fire it) and + // the question flipped to resolved (so the next watcher tick won't re-fire it). + expect(loadPollableWaits(db).find((w) => w.workflowId === "wf1")?.firedAt).not.toBeNull(); + expect(readEpicFile(epicsDir, "rollout")!.conversation[0]).toMatchObject({ + status: "resolved", + }); + db.close(); + }); + + test("does NOT resume a workflow parked on a non-answered signal (reason guard)", async () => { + const { db, epicsDir } = parkedDb("review-changes"); + const fired: string[] = []; + const n = await runFileWatcherTick( + { + db, + fileModeRepos: () => [{ repo: "o/r", epicsDir }], + fireSignal: async (id) => { + fired.push(id); + }, + }, + 0, + ); + expect(n).toBe(0); + expect(fired).toEqual([]); + db.close(); + }); +}); diff --git a/planning/issues/190/decisions.md b/planning/issues/190/decisions.md index 2c57470b..12023f40 100644 --- a/planning/issues/190/decisions.md +++ b/planning/issues/190/decisions.md @@ -228,3 +228,25 @@ is extracted (not inlined in main.ts) so the daemon and the integration test sha implementation — no drift. The github resume poll stays untouched. **Evidence:** spec "filePollGateway" file-watcher paragraph; `poller.ts` `classifyNewHumanReply` (createdAt-gated); sub-issue #197 "no new cron". + +## Self-review hardening: route the poller + recovery surface; guard the watcher's reason +**File(s):** `epic-store/index.ts`, `main.ts`, `epic-store/watcher.ts` +**Date:** 2026-06-03 + +**Decision:** A clean-eyes review caught that the poller, its merged-parks reconciler, +and the orphan-recovery comment surface were still wired to the raw `ghPollGateway`/ +`ghGitHub` — so a parked **file-mode** Epic's slug reached gh's numeric `Closes #<n>` +finders and threw `refToIssueNumber` every 120s tick (caught + logged, but noisy and +burning the rate-limit probe in a mixed deployment). Fixed by adding +`makeRoutingPollGateway` (the poller's counterpart to `makeRoutingEpicGateway`) and +wiring the poller's `github` + the orphan surface to the per-repo routers in `main.ts`. +For a file-mode repo the routed poll gateway's PR-finders return null and its +comment-listing reads the Epic file, so the github resume poll is a clean no-op (the +file-watcher owns that resume). Also added a reason-guard to `runFileWatcherTick`: it +only fires when the parked workflow's armed signal is the `answered-question` one (so +an answer edit can't resume a workflow parked for another reason). +**Why:** "github mode unchanged" must extend to "file mode doesn't spam/contend the +github poller". Resolving the class (route every gh-facing poller/recovery seam, not +just the implementation deps) within the review's blast radius, each with a test. +**Evidence:** the adversarial review's BUG 1 / RISK 2 / RISK 3; tests +`selector.test.ts` (routing poll gateway) + `watcher.test.ts` (reason guard). From de4a1793278cc8dc928b9fad977da4e2591729ba Mon Sep 17 00:00:00 2001 From: Justin Walsh <contact.me@thejustinwalsh.com> Date: Wed, 3 Jun 2026 06:22:08 -0400 Subject: [PATCH 10/10] =?UTF-8?q?fix(epic-store):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20GitHub=20casing,=20TSDoc,=20separator-safe=20temp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batched response to the CodeRabbit pass on PR #198, resolving each finding class-wide rather than per-instance: - GitHub capitalization: "github mode"/"github-mode"/prose "github" → "GitHub" across the skill docs (canonical + bootstrap-assets mirror), plan.md, decisions.md, and the new epic-store/bootstrap source comments. Literal filenames (`github-mode-commands.md`, `github.ts`), code identifiers (`github:` keys, `args.github`), the `["github","file"]` test literal, and URLs are deliberately left lowercase. - TSDoc on public exports: split the shared FILE_EPICS_DIR/FILE_STATE_FILE comment, documented the FileStoreScaffoldOptions type, and added doc blocks to resolveRepoInfoLocal, makeFileEpicGateway, makeFilePollGateway, and makeFileStateGateway (the last two beyond the flagged ones — same class, same module). - Separator-safe temp naming: file-state-gateway's writeBody now derives the sibling temp via node:path `basename` instead of slicing on "/" (Windows safe); the custom pathStem helper is removed, with a test covering a nested, multi-dot state filename. - Dropped a redundant `writeFileSync` setup line in the state-gateway test. --- .../skills/creating-github-issues/SKILL.md | 6 +-- .../references/file-mode-commands.md | 6 +-- .../implementing-github-issues/SKILL.md | 12 ++--- .../references/file-mode-commands.md | 8 +-- .../references/github-mode-commands.md | 4 +- .../recommending-github-issues/SKILL.md | 16 +++--- .../references/file-mode-commands.md | 2 +- .../references/github-mode-commands.md | 4 +- packages/cli/src/bootstrap/deps.ts | 11 ++-- packages/cli/src/bootstrap/file-store.ts | 9 +++- .../src/epic-store/file-epic-gateway.ts | 16 ++++-- .../src/epic-store/file-poll-gateway.ts | 10 +++- .../src/epic-store/file-state-gateway.ts | 17 ++++--- .../epic-store/file-state-gateway.test.ts | 16 +++++- .../skills/creating-github-issues/SKILL.md | 6 +-- .../references/file-mode-commands.md | 6 +-- .../implementing-github-issues/SKILL.md | 12 ++--- .../references/file-mode-commands.md | 8 +-- .../references/github-mode-commands.md | 4 +- .../recommending-github-issues/SKILL.md | 16 +++--- .../references/file-mode-commands.md | 2 +- .../references/github-mode-commands.md | 4 +- planning/issues/190/decisions.md | 50 +++++++++---------- planning/issues/190/plan.md | 4 +- 24 files changed, 146 insertions(+), 103 deletions(-) diff --git a/packages/cli/src/bootstrap-assets/skills/creating-github-issues/SKILL.md b/packages/cli/src/bootstrap-assets/skills/creating-github-issues/SKILL.md index faa19c6f..0a4330e9 100644 --- a/packages/cli/src/bootstrap-assets/skills/creating-github-issues/SKILL.md +++ b/packages/cli/src/bootstrap-assets/skills/creating-github-issues/SKILL.md @@ -8,7 +8,7 @@ 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. +**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. ## Core principles @@ -540,7 +540,7 @@ 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.> +content you'd put in a GitHub-mode parent's Context.> ## Acceptance criteria @@ -582,7 +582,7 @@ content you'd put in a github-mode parent's Context.> ### What's the same, what's different -| Concern | github mode | file mode | +| 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 | diff --git a/packages/cli/src/bootstrap-assets/skills/creating-github-issues/references/file-mode-commands.md b/packages/cli/src/bootstrap-assets/skills/creating-github-issues/references/file-mode-commands.md index 75affbe3..d6fa5814 100644 --- a/packages/cli/src/bootstrap-assets/skills/creating-github-issues/references/file-mode-commands.md +++ b/packages/cli/src/bootstrap-assets/skills/creating-github-issues/references/file-mode-commands.md @@ -6,7 +6,7 @@ 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 +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. @@ -36,7 +36,7 @@ blocked-by: [other-epic-slug] ## Context -<1-3 paragraphs pointing to the spec section; same content as a github-mode +<1-3 paragraphs pointing to the spec section; same content as a GitHub-mode parent's Context.> ## Acceptance criteria @@ -78,7 +78,7 @@ YAML-lite, one key per line, between `<!-- middle:meta` and `-->`: (`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 +## 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. diff --git a/packages/cli/src/bootstrap-assets/skills/implementing-github-issues/SKILL.md b/packages/cli/src/bootstrap-assets/skills/implementing-github-issues/SKILL.md index cf45bcba..78339652 100644 --- a/packages/cli/src/bootstrap-assets/skills/implementing-github-issues/SKILL.md +++ b/packages/cli/src/bootstrap-assets/skills/implementing-github-issues/SKILL.md @@ -8,7 +8,7 @@ allowed-tools: Bash(gh:*), Bash(git:*), Bash(pnpm:*), Bash(mkdir:*), Read, Write End-to-end workflow for taking an Epic from "assigned" to "PR open with verification evidence, marked ready for human review." All phases of one Epic land on **one branch** and **one PR**; the PR is the long-lasting context for the workstream. -**Mode-specific commands:** the Epic's data and the agent-↔-human conversation live in one of two stores — a GitHub issue (**github mode**) or a Markdown file under `planning/epics/` (**file mode**). PRs, reviews, and CI are GitHub-native in *both* modes. This skill body is mode-agnostic: it says "fetch the Epic", "post the plan to the Epic", "close the sub-issue with evidence", "post the reviewer's brief", "mark the PR ready" — the concrete incantations live in `references/<mode>-mode-commands.md` (`github-mode-commands.md` / `file-mode-commands.md`), mirrored into your worktree at `.middle/skills/implementing-github-issues/references/` for your run's mode. Use that file for every Epic/plan/sub-issue/conversation operation; use the PR commands (identical in both modes) inline below. +**Mode-specific commands:** the Epic's data and the agent-↔-human conversation live in one of two stores — a GitHub issue (**GitHub mode**) or a Markdown file under `planning/epics/` (**file mode**). PRs, reviews, and CI are GitHub-native in *both* modes. This skill body is mode-agnostic: it says "fetch the Epic", "post the plan to the Epic", "close the sub-issue with evidence", "post the reviewer's brief", "mark the PR ready" — the concrete incantations live in `references/<mode>-mode-commands.md` (`github-mode-commands.md` / `file-mode-commands.md`), mirrored into your worktree at `.middle/skills/implementing-github-issues/references/` for your run's mode. Use that file for every Epic/plan/sub-issue/conversation operation; use the PR commands (identical in both modes) inline below. ## Dispatch brief (read first) @@ -206,7 +206,7 @@ N. ... ## Phase 4 — Post the plan to the Epic -Post the plan body to the Epic's conversation (the Epic's **plan comment**). See `references/<mode>-mode-commands.md` for the exact write — in github mode it's an issue comment; in file mode the renderer appends it to the Epic file's conversation section (never hand-edit the strict markers). +Post the plan body to the Epic's conversation (the Epic's **plan comment**). See `references/<mode>-mode-commands.md` for the exact write — in GitHub mode it's an issue comment; in file mode the renderer appends it to the Epic file's conversation section (never hand-edit the strict markers). This is non-negotiable. The plan-on-the-Epic serves three purposes: 1. The reporter / stakeholders can correct your direction before you write code @@ -391,7 +391,7 @@ If you genuinely believe an acceptance-criterion item should be deferred, ask th ### Filing a sub-issue under an existing parent -File the follow-up as a sub-issue under its parent — the body carries the parent, the context (what you saw, where), why it's a sub-issue and not in-scope, and a suggested approach if you have one. See `references/<mode>-mode-commands.md` for the exact mechanics (github mode: create the child issue then attach it under the parent via the sub-issues REST endpoint; file mode: append a new `<!-- middle:sub-issue id=N -->` block to the Epic file via the renderer). +File the follow-up as a sub-issue under its parent — the body carries the parent, the context (what you saw, where), why it's a sub-issue and not in-scope, and a suggested approach if you have one. See `references/<mode>-mode-commands.md` for the exact mechanics (GitHub mode: create the child issue then attach it under the parent via the sub-issues REST endpoint; file mode: append a new `<!-- middle:sub-issue id=N -->` block to the Epic file via the renderer). ### Creating a parent for a natural collection @@ -654,7 +654,7 @@ Everything above describes the skill running interactively. When **middle-manage ### You are pointed at an Epic — its sub-issues are your plan phases -middle dispatches **Epics**, not individual issues. The Epic you're pointed at has sub-issues, and **its open sub-issues ARE the phases of your plan**. Don't invent a phase breakdown — fetch the Epic's sub-issues (see `references/<mode>-mode-commands.md`: github mode reads the sub-issues REST graph; file mode reads the `<!-- middle:sub-issue id=N -->` blocks in the Epic file), and each one is a phase. Your `plan.md` Phases list and the PR's Status checkboxes are one-per-sub-issue. +middle dispatches **Epics**, not individual issues. The Epic you're pointed at has sub-issues, and **its open sub-issues ARE the phases of your plan**. Don't invent a phase breakdown — fetch the Epic's sub-issues (see `references/<mode>-mode-commands.md`: GitHub mode reads the sub-issues REST graph; file mode reads the `<!-- middle:sub-issue id=N -->` blocks in the Epic file), and each one is a phase. Your `plan.md` Phases list and the PR's Status checkboxes are one-per-sub-issue. One Epic → one worktree → one branch → one PR. You work *down* the sub-issues in dependency order on that single branch, ticking each Status checkbox as its sub-issue's work verifies. Do **not** open a PR per sub-issue, and do **not** wait for review between sub-issues — the whole Epic is reviewed once, as one PR, when every sub-issue is done. @@ -666,7 +666,7 @@ The dispatcher created your worktree and branch and spawned you inside it. Do ** ### Asking a question = write `.middle/blocked.json` and exit (overrides Phase 2's "comment and wait") -You cannot "comment on the Epic and wait" — headless, there is nothing to wait *in*. When you genuinely need human input (ambiguous acceptance criteria, a decision CLAUDE.md/skills/docs don't resolve and that isn't worth a fork), write `<worktree>/.middle/blocked.json` containing the question and the context a human needs to answer it, then **exit cleanly**. The `blocked.json` sentinel is mode-agnostic — you write it the same way in both modes. Middle's exit classifier detects it, parks the workflow on a `waitFor` signal, and surfaces the question on the Epic (github mode: an issue comment; file mode: a `<!-- middle:question -->` block the dispatcher appends to the Epic file via the renderer). The human answers (github mode: a reply comment; file mode: editing the `<!-- middle:answer -->` block, or running `mm resume <repo> <slug> --answer "…"`), and you're re-spawned with the answer. Do not guess past a real blocker; do not spin idle. +You cannot "comment on the Epic and wait" — headless, there is nothing to wait *in*. When you genuinely need human input (ambiguous acceptance criteria, a decision CLAUDE.md/skills/docs don't resolve and that isn't worth a fork), write `<worktree>/.middle/blocked.json` containing the question and the context a human needs to answer it, then **exit cleanly**. The `blocked.json` sentinel is mode-agnostic — you write it the same way in both modes. Middle's exit classifier detects it, parks the workflow on a `waitFor` signal, and surfaces the question on the Epic (GitHub mode: an issue comment; file mode: a `<!-- middle:question -->` block the dispatcher appends to the Epic file via the renderer). The human answers (GitHub mode: a reply comment; file mode: editing the `<!-- middle:answer -->` block, or running `mm resume <repo> <slug> --answer "…"`), and you're re-spawned with the answer. Do not guess past a real blocker; do not spin idle. ### Complexity is fork branching factor — pause the sub-issue past the ceiling @@ -683,7 +683,7 @@ When a human answers, middle re-spawns you with the answer injected into your pr ### The plan comment is mechanically gated (reinforces Phase 4) -After your plan step, the dispatcher's **plan-comment guard** verifies the plan body was posted to the Epic (github mode: a comment by your account on the issue; file mode: a plan entry in the Epic file's conversation section, written by the renderer). No plan on the Epic → the workflow fails. Phase 4 was always "non-negotiable"; under middle it is literally enforced. +After your plan step, the dispatcher's **plan-comment guard** verifies the plan body was posted to the Epic (GitHub mode: a comment by your account on the issue; file mode: a plan entry in the Epic file's conversation section, written by the renderer). No plan on the Epic → the workflow fails. Phase 4 was always "non-negotiable"; under middle it is literally enforced. ### `gh pr ready` is mechanically gated (reinforces Phase 10) diff --git a/packages/cli/src/bootstrap-assets/skills/implementing-github-issues/references/file-mode-commands.md b/packages/cli/src/bootstrap-assets/skills/implementing-github-issues/references/file-mode-commands.md index 066b8e4e..f79bff43 100644 --- a/packages/cli/src/bootstrap-assets/skills/implementing-github-issues/references/file-mode-commands.md +++ b/packages/cli/src/bootstrap-assets/skills/implementing-github-issues/references/file-mode-commands.md @@ -1,6 +1,6 @@ # implementing-github-issues — file-mode commands -The file-mode equivalents of every Epic/plan/sub-issue/conversation operation the skill body refers to mode-agnostically. In **file mode** the Epic is a Markdown file at `planning/epics/<slug>.md` (the slug is the file's stem and the canonical Epic reference), and the agent-↔-human conversation lives in that file's `<!-- middle:conversation -->` section. **PRs, reviews, and CI stay GitHub-native** — the PR/CI commands are identical to github mode (`gh pr …`). +The file-mode equivalents of every Epic/plan/sub-issue/conversation operation the skill body refers to mode-agnostically. In **file mode** the Epic is a Markdown file at `planning/epics/<slug>.md` (the slug is the file's stem and the canonical Epic reference), and the agent-↔-human conversation lives in that file's `<!-- middle:conversation -->` section. **PRs, reviews, and CI stay GitHub-native** — the PR/CI commands are identical to GitHub mode (`gh pr …`). ## The one rule that governs every write below @@ -58,7 +58,7 @@ Each `<!-- middle:sub-issue id=N -->` block is one phase. An *open* sub-issue is ## Post the plan to the Epic (Phase 4) -The plan goes into the Epic file's `<!-- middle:conversation -->` section as a conversation entry — **written by the renderer, not by hand.** Under middle's dispatch this is the plan step the dispatcher records via the renderer; the plan-comment guard then verifies a plan entry exists in the conversation section. You author the plan body (in `planning/epics/<slug>.md`'s adjacent `planning/issues/<slug>/plan.md`, same as github mode); the renderer appends it to the conversation. Do not edit the conversation markers yourself. +The plan goes into the Epic file's `<!-- middle:conversation -->` section as a conversation entry — **written by the renderer, not by hand.** Under middle's dispatch this is the plan step the dispatcher records via the renderer; the plan-comment guard then verifies a plan entry exists in the conversation section. You author the plan body (in `planning/epics/<slug>.md`'s adjacent `planning/issues/<slug>/plan.md`, same as GitHub mode); the renderer appends it to the conversation. Do not edit the conversation markers yourself. ## Close a sub-issue with evidence @@ -75,7 +75,7 @@ The recommender's "open sub-issues" count scans for unchecked boxes, so a checke ## Ask a question / surface a blocker -Identical agent action to github mode: write `<worktree>/.middle/blocked.json` and exit. The dispatcher's file-backed writer appends a `<!-- middle:question id=N status=open … -->` block to the conversation section **via the renderer** — you never write the question marker yourself. The human answers by editing the `<!-- middle:answer for=N -->` block in the file (the file-watcher fires resume when that block becomes non-empty) or by running: +Identical agent action to GitHub mode: write `<worktree>/.middle/blocked.json` and exit. The dispatcher's file-backed writer appends a `<!-- middle:question id=N status=open … -->` block to the conversation section **via the renderer** — you never write the question marker yourself. The human answers by editing the `<!-- middle:answer for=N -->` block in the file (the file-watcher fires resume when that block becomes non-empty) or by running: ```bash mm resume <repo> <slug> --answer "…" @@ -100,7 +100,7 @@ A "parent for a natural collection" is the Epic itself — file each related ite A genuinely cross-workstream item is a *new Epic file*: author `planning/epics/<other-slug>.md` with its own `<!-- middle:epic v1 -->` + `<!-- middle:meta -->` (see "creating-github-issues" file-mode addendum). A "Discovered while working on: <slug>" line in its Context is the cross-reference. Again — no `gh issue create`. -## PR / CI operations (GitHub-native — same as github mode) +## PR / CI operations (GitHub-native — same as GitHub mode) PRs, reviews, and CI are GitHub-native in file mode too. Use the same commands the skill body lists inline: diff --git a/packages/cli/src/bootstrap-assets/skills/implementing-github-issues/references/github-mode-commands.md b/packages/cli/src/bootstrap-assets/skills/implementing-github-issues/references/github-mode-commands.md index 38d8476e..cc2cb631 100644 --- a/packages/cli/src/bootstrap-assets/skills/implementing-github-issues/references/github-mode-commands.md +++ b/packages/cli/src/bootstrap-assets/skills/implementing-github-issues/references/github-mode-commands.md @@ -1,6 +1,6 @@ -# implementing-github-issues — github-mode commands +# implementing-github-issues — GitHub-mode commands -The concrete `gh` incantations for every Epic/plan/sub-issue/conversation operation the skill body refers to mode-agnostically. **github mode** is the default: the Epic is a GitHub issue, its sub-issues are native GitHub sub-issues, and the agent-↔-human conversation flows through issue comments. PRs, reviews, and CI are GitHub-native here too (and identical in file mode). +The concrete `gh` incantations for every Epic/plan/sub-issue/conversation operation the skill body refers to mode-agnostically. **GitHub mode** is the default: the Epic is a GitHub issue, its sub-issues are native GitHub sub-issues, and the agent-↔-human conversation flows through issue comments. PRs, reviews, and CI are GitHub-native here too (and identical in file mode). Throughout, `<epic>` is the Epic's issue number, `<owner>`/`<repo>` the repository. diff --git a/packages/cli/src/bootstrap-assets/skills/recommending-github-issues/SKILL.md b/packages/cli/src/bootstrap-assets/skills/recommending-github-issues/SKILL.md index c379d660..5a8695e8 100644 --- a/packages/cli/src/bootstrap-assets/skills/recommending-github-issues/SKILL.md +++ b/packages/cli/src/bootstrap-assets/skills/recommending-github-issues/SKILL.md @@ -10,7 +10,7 @@ You are the dispatch recommender for a single repository. Your only job is to rewrite ONE **state body** with a ranked plan of work to dispatch and a digest of items needing human attention. -**Mode-specific commands:** the repo runs in one of two modes. In **github mode** +**Mode-specific commands:** the repo runs in one of two modes. In **GitHub mode** the dispatch units are GitHub issues/Epics and the state body is a GitHub issue (the `agent-queue:state` issue). In **file mode** the dispatch units are Epic files under `epics_dir` and the state body is the `state_file` on disk. This @@ -65,19 +65,19 @@ the dispatcher inputs. Fetch the repo's **dispatch units** and **open PRs**, and resolve each unit's sub-issue structure. The exact reads are mode-specific — see -`references/<mode>-mode-commands.md`. In github mode you list open issues + PRs +`references/<mode>-mode-commands.md`. In GitHub mode you list open issues + PRs and read the native sub-issue graph; in file mode you scan `epics_dir` for Epic files, parse each one's `<!-- middle:meta -->` and sub-issue blocks, and still list open PRs from GitHub. Resolve the **dispatch-unit structure**: - A unit with sub-issues is an **Epic** — a dispatch unit. -- A sub-issue (an issue with a parent in github mode; a `<!-- middle:sub-issue -->` +- A sub-issue (an issue with a parent in GitHub mode; a `<!-- middle:sub-issue -->` block inside an Epic file in file mode) is **NOT** a dispatch unit. It is scope inside its Epic; never classify or rank it on its own. - A unit with neither is a **standalone issue** — a dispatch unit (a one-phase Epic). -**Exclude the state surface itself.** In github mode the issue you are rewriting +**Exclude the state surface itself.** In GitHub mode the issue you are rewriting (and any issue carrying the `agent-queue:state` label) is the dispatcher's surface, never a dispatch unit. In file mode the `state_file` is not an Epic file and never appears in `epics_dir`. Never classify or rank the state surface. @@ -167,11 +167,11 @@ Verify before writing: ### Phase 6 — Write and log Write the rendered body to the state surface — see `references/<mode>-mode-commands.md`. -In github mode it's `gh issue edit <state_issue> --body-file …`. In file mode you +In GitHub mode it's `gh issue edit <state_issue> --body-file …`. In file mode you write `state_file` **via the renderer** (`renderStateIssue`), never by hand — the renderer is the sole writer, which closes #180's class for this skill too. -Then log a single diff summary against prior_body. In github mode that's a comment +Then log a single diff summary against prior_body. In GitHub mode that's a comment on the state issue; in file mode the run summary is recorded the same way the dispatcher records it (no separate GitHub comment — the state surface is a file): @@ -222,7 +222,7 @@ the problem and stop. Dispatcher will surface to human. ## Files this skill creates -Mode-dependent (see `references/<mode>-mode-commands.md`). In github mode: none on +Mode-dependent (see `references/<mode>-mode-commands.md`). In GitHub mode: none on filesystem — output is the state issue body via `gh issue edit` plus one diff comment. In file mode: the `state_file` on disk, written via `renderStateIssue` (the renderer is the sole writer — never hand-edited). @@ -230,7 +230,7 @@ comment. In file mode: the `state_file` on disk, written via `renderStateIssue` ## Files this skill reads - Schema at the path provided by the dispatcher -- The repo's dispatch units and their sub-issue structure (github mode: open issues +- The repo's dispatch units and their sub-issue structure (GitHub mode: open issues + the sub-issue graph via `gh`; file mode: Epic files scanned from `epics_dir`) - Open PRs via `gh` (GitHub-native in both modes) - Recent git log on main diff --git a/packages/cli/src/bootstrap-assets/skills/recommending-github-issues/references/file-mode-commands.md b/packages/cli/src/bootstrap-assets/skills/recommending-github-issues/references/file-mode-commands.md index 26ca08c6..6d804283 100644 --- a/packages/cli/src/bootstrap-assets/skills/recommending-github-issues/references/file-mode-commands.md +++ b/packages/cli/src/bootstrap-assets/skills/recommending-github-issues/references/file-mode-commands.md @@ -10,7 +10,7 @@ body are file-backed. **The renderer is the sole writer of the state body.** You write `state_file` via `renderStateIssue` (the same parser + renderer + byte-identical-round-trip -invariant as github mode's state-issue flow) — **never by hand**. There is no +invariant as GitHub mode's state-issue flow) — **never by hand**. There is no recommender-agent rewriting strict sections out-of-band; this closes #180's class entirely for file mode. You compose the state model and render it; you do not hand-edit the file's markers or the dispatcher-owned sections (In-flight, Rate diff --git a/packages/cli/src/bootstrap-assets/skills/recommending-github-issues/references/github-mode-commands.md b/packages/cli/src/bootstrap-assets/skills/recommending-github-issues/references/github-mode-commands.md index 81712e42..85c30a7e 100644 --- a/packages/cli/src/bootstrap-assets/skills/recommending-github-issues/references/github-mode-commands.md +++ b/packages/cli/src/bootstrap-assets/skills/recommending-github-issues/references/github-mode-commands.md @@ -1,6 +1,6 @@ -# recommending-github-issues — github-mode commands +# recommending-github-issues — GitHub-mode commands -The concrete state-issue read/write commands for **github mode**: dispatch units +The concrete state-issue read/write commands for **GitHub mode**: dispatch units are GitHub issues/Epics, and the state body is the `agent-queue:state` issue. ## Fetch repo state and resolve the Epic graph (Phase 2) diff --git a/packages/cli/src/bootstrap/deps.ts b/packages/cli/src/bootstrap/deps.ts index 80c9becc..1175f502 100644 --- a/packages/cli/src/bootstrap/deps.ts +++ b/packages/cli/src/bootstrap/deps.ts @@ -168,10 +168,15 @@ export const realDeps: BootstrapDeps = { return { owner: slug.owner, name: slug.name, defaultBranch }; }, + /** + * Resolve `{ owner, name }` for a repo from its local `origin` remote only — + * the offline counterpart to {@link BootstrapDeps.resolveRepoInfo} used by the + * file-mode init path. Parses `getRemoteUrl(repo)` with `parseRepoSlug` and + * **never shells out to `gh`**, so `defaultBranch` isn't knowable and falls + * back to `"main"` (file mode doesn't depend on it). Throws if the origin URL + * can't be parsed into an owner/name. + */ async resolveRepoInfoLocal(repo: string): Promise<RepoInfo> { - // File mode is offline — derive owner/name from the local `origin` URL and - // never shell out to `gh`. The default branch isn't knowable without GitHub, - // so it falls back to "main" (file mode doesn't depend on it). const url = (await this.getRemoteUrl(repo)) ?? ""; const slug = parseRepoSlug(url); if (!slug) throw new Error(`could not parse owner/name from origin remote: "${url}"`); diff --git a/packages/cli/src/bootstrap/file-store.ts b/packages/cli/src/bootstrap/file-store.ts index 4bc13bfc..6929cbfe 100644 --- a/packages/cli/src/bootstrap/file-store.ts +++ b/packages/cli/src/bootstrap/file-store.ts @@ -1,6 +1,6 @@ // File-mode Epic-store scaffolding for `mm init --epic-store=file`. Writes the // local Epic directory + recommender state file + per-repo Epic-store config a -// file-mode repo needs, with ZERO `gh`/GitHub calls. The github-mode path is +// file-mode repo needs, with ZERO `gh`/GitHub calls. The GitHub-mode path is // untouched — this module is only reached when `epicStore === "file"`. import { mkdir } from "node:fs/promises"; @@ -10,8 +10,9 @@ import type { ParsedState } from "@middle/state-issue"; import { DEFAULT_EPICS_DIR, DEFAULT_STATE_FILE } from "@middle/dispatcher/src/repo-config.ts"; import type { RepoInfo } from "./types.ts"; -/** Default Epic directory + state file a file-mode repo scaffolds (repo-root relative). */ +/** Default Epic directory a file-mode repo scaffolds (repo-root-relative; from `DEFAULT_EPICS_DIR`). */ export const FILE_EPICS_DIR = DEFAULT_EPICS_DIR; +/** Default recommender state file a file-mode repo scaffolds (repo-root-relative; from `DEFAULT_STATE_FILE`). */ export const FILE_STATE_FILE = DEFAULT_STATE_FILE; /** @@ -100,6 +101,10 @@ Why this Epic exists and what "done" looks like. `; } +/** + * Inputs to `writeFileStoreScaffold` — the target repo, its resolved identity (for + * naming the per-repo config), and a clock seam for the generated state body. + */ export type FileStoreScaffoldOptions = { /** Absolute path to the target repo checkout. */ repo: string; diff --git a/packages/dispatcher/src/epic-store/file-epic-gateway.ts b/packages/dispatcher/src/epic-store/file-epic-gateway.ts index 55df6feb..49d25ff3 100644 --- a/packages/dispatcher/src/epic-store/file-epic-gateway.ts +++ b/packages/dispatcher/src/epic-store/file-epic-gateway.ts @@ -1,7 +1,7 @@ /** * `fileEpicGateway` — the file-backed `EpicGateway`. A **composite**: Epic-shaped * methods read/write the local Epic file (via the round-trip-pure - * `epic-file/{parser,renderer}`); PR-shaped and github-native-issue methods + * `epic-file/{parser,renderer}`); PR-shaped and GitHub-native-issue methods * delegate to an injected `gh` backend (PRs/reviews/CI are GitHub-native in both * modes — the "hybrid" of the design). * @@ -24,7 +24,7 @@ export const FILE_HUMAN_LOGIN = "human"; export type FileEpicGatewayDeps = { /** Absolute path to this repo's Epic directory (`planning/epics`). */ epicsDir: string; - /** Backend for PR-shaped + github-native-issue methods (the hybrid half). */ + /** Backend for PR-shaped + GitHub-native-issue methods (the hybrid half). */ gh: EpicGateway; /** Wall-clock for the dispatch-event timestamp; injectable for deterministic tests. */ now?: () => Date; @@ -69,16 +69,24 @@ function conversationToComments( return comments; } +/** + * Build the file-backed `EpicGateway` for one repo's Epic directory — a composite. + * Each method that takes a `ref` routes by `epicFileExists`: a slug (a `<slug>.md` + * file) is served from the local Epic file via the round-trip-pure + * `epic-file/{parser,renderer}`; a numeric PR/issue ref with no matching file falls + * through to the injected `gh` backend (PRs/reviews/CI stay GitHub-native). `now` is + * an injectable clock seam (defaults to `Date`) for deterministic tests. + */ export function makeFileEpicGateway(deps: FileEpicGatewayDeps): EpicGateway { const { epicsDir, gh } = deps; const now = deps.now ?? (() => new Date()); return { - // ── delegated to gh (PR-shaped + github-native; hybrid half) ────────────── + // ── delegated to gh (PR-shaped + GitHub-native; hybrid half) ────────────── getPullRequest: (repo, prNumber) => gh.getPullRequest(repo, prNumber), editPullRequestBody: (repo, prNumber, body) => gh.editPullRequestBody(repo, prNumber, body), // editComment edits a GitHub PR/issue comment in place (gate-evidence upsert), - // which is github-native in file mode too — delegate. + // which is GitHub-native in file mode too — delegate. editComment: (repo, commentId, body) => gh.editComment(repo, commentId, body), listOpenIssues: (repo) => gh.listOpenIssues(repo), listMergedPrsClosingRefs: (repo) => gh.listMergedPrsClosingRefs(repo), diff --git a/packages/dispatcher/src/epic-store/file-poll-gateway.ts b/packages/dispatcher/src/epic-store/file-poll-gateway.ts index ea6ae595..d7aa2a95 100644 --- a/packages/dispatcher/src/epic-store/file-poll-gateway.ts +++ b/packages/dispatcher/src/epic-store/file-poll-gateway.ts @@ -8,7 +8,7 @@ * * The PR-poll methods are GitHub-native: `getRateLimit` delegates straight to gh. * `findPrForEpic`/`findEpicPrLifecycle` delegate for a numeric ref but return - * `null` for a file-mode slug — github's PR-finders resolve by `Closes #<number>`, + * `null` for a file-mode slug — GitHub's PR-finders resolve by `Closes #<number>`, * which a file Epic (slug, no GitHub issue) can't carry. File-mode review-resume * rides Phase 2's watcher work (see `planning/issues/190/decisions.md`). */ @@ -76,6 +76,14 @@ function conversationToPollComments(conversation: ConversationEntry[]): IssueCom return out; } +/** + * Build the file-backed `PollGateway` (plus the Phase-2 `pollFileSignals`) for one + * repo's Epic directory. `listIssueComments` reads the Epic file's conversation when + * a file exists for the ref (deriving `authorIsBot` structurally from marker kind), + * else delegates to the injected `gh` backend. The PR-poll methods (`findPrForEpic`, + * `findEpicPrLifecycle`) delegate only for a numeric ref and return `null` for a + * file-mode slug (no `Closes #N` linkage); `getRateLimit` always delegates to `gh`. + */ export function makeFilePollGateway(deps: FilePollGatewayDeps): FilePollGateway { const { epicsDir, gh } = deps; return { diff --git a/packages/dispatcher/src/epic-store/file-state-gateway.ts b/packages/dispatcher/src/epic-store/file-state-gateway.ts index 803e02cb..92afeb62 100644 --- a/packages/dispatcher/src/epic-store/file-state-gateway.ts +++ b/packages/dispatcher/src/epic-store/file-state-gateway.ts @@ -11,7 +11,7 @@ * for file mode. */ -import { dirname, join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import type { StateGateway } from "../state-issue.ts"; @@ -20,6 +20,14 @@ export type FileStateGatewayDeps = { stateFile: string; }; +/** + * Build the file-backed `StateGateway` for one repo's recommender state file. + * `readBody` returns the file verbatim (throwing a `mm init` hint when it's + * absent); `writeBody` is atomic — it writes a hidden sibling temp (`.<name>.tmp`, + * named via `node:path` `basename` so it's separator-safe) then `rename`s it over + * the target, creating parent dirs and cleaning up the temp on failure. The + * `_issueNumber` arg is interface-shared but unused in file mode. + */ export function makeFileStateGateway(deps: FileStateGatewayDeps): StateGateway { const { stateFile } = deps; return { @@ -34,7 +42,7 @@ export function makeFileStateGateway(deps: FileStateGatewayDeps): StateGateway { // Atomic write: temp sibling + rename, so a concurrent reader never sees a // half-written state file. The temp is cleaned up on a write failure. mkdirSync(dirname(stateFile), { recursive: true }); - const tmp = join(dirname(stateFile), `.${pathStem(stateFile)}.tmp`); + const tmp = join(dirname(stateFile), `.${basename(stateFile)}.tmp`); try { writeFileSync(tmp, body); renameSync(tmp, stateFile); @@ -45,8 +53,3 @@ export function makeFileStateGateway(deps: FileStateGatewayDeps): StateGateway { }, }; } - -/** The filename (without directory) of a path — for naming the sibling temp file. */ -function pathStem(path: string): string { - return path.slice(path.lastIndexOf("/") + 1); -} diff --git a/packages/dispatcher/test/epic-store/file-state-gateway.test.ts b/packages/dispatcher/test/epic-store/file-state-gateway.test.ts index f2be59f8..4eb5f0e5 100644 --- a/packages/dispatcher/test/epic-store/file-state-gateway.test.ts +++ b/packages/dispatcher/test/epic-store/file-state-gateway.test.ts @@ -12,7 +12,6 @@ describe("fileStateGateway", () => { test("readBody returns the state file contents verbatim", async () => { const dir = tmpRepo(); const stateFile = join(dir, ".middle", "state.md"); - writeFileSync(join(dir, "x"), ""); // ensure dir exists for the write below const gw = makeFileStateGateway({ stateFile }); await gw.writeBody("o/r", 0, "# state\n\nbody\n"); expect(await gw.readBody("o/r", 0)).toBe("# state\n\nbody\n"); @@ -43,6 +42,21 @@ describe("fileStateGateway", () => { expect(readdirSync(stateDir).filter((n) => n.endsWith(".tmp"))).toEqual([]); }); + test("writeBody derives the temp sibling from the filename via `basename` (separator-safe)", async () => { + const dir = tmpRepo(); + const stateDir = join(dir, "nested"); + // A multi-dot filename in a nested dir: the temp must be `.state.snapshot.md.tmp` + // (basename of the file), a sibling inside `stateDir` — never derived by raw `/` + // slicing of the full path. + const stateFile = join(stateDir, "state.snapshot.md"); + const gw = makeFileStateGateway({ stateFile }); + await gw.writeBody("o/r", 0, "body\n"); + expect(readFileSync(stateFile, "utf8")).toBe("body\n"); + // No stray temp left, and nothing leaked outside the state dir. + expect(readdirSync(stateDir).filter((n) => n.endsWith(".tmp"))).toEqual([]); + expect(readdirSync(dir)).toEqual(["nested"]); + }); + test("writeBody overwrites an existing file", async () => { const dir = tmpRepo(); const stateFile = join(dir, "state.md"); diff --git a/packages/skills/creating-github-issues/SKILL.md b/packages/skills/creating-github-issues/SKILL.md index faa19c6f..0a4330e9 100644 --- a/packages/skills/creating-github-issues/SKILL.md +++ b/packages/skills/creating-github-issues/SKILL.md @@ -8,7 +8,7 @@ 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. +**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. ## Core principles @@ -540,7 +540,7 @@ 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.> +content you'd put in a GitHub-mode parent's Context.> ## Acceptance criteria @@ -582,7 +582,7 @@ content you'd put in a github-mode parent's Context.> ### What's the same, what's different -| Concern | github mode | file mode | +| 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 | diff --git a/packages/skills/creating-github-issues/references/file-mode-commands.md b/packages/skills/creating-github-issues/references/file-mode-commands.md index 75affbe3..d6fa5814 100644 --- a/packages/skills/creating-github-issues/references/file-mode-commands.md +++ b/packages/skills/creating-github-issues/references/file-mode-commands.md @@ -6,7 +6,7 @@ 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 +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. @@ -36,7 +36,7 @@ blocked-by: [other-epic-slug] ## Context -<1-3 paragraphs pointing to the spec section; same content as a github-mode +<1-3 paragraphs pointing to the spec section; same content as a GitHub-mode parent's Context.> ## Acceptance criteria @@ -78,7 +78,7 @@ YAML-lite, one key per line, between `<!-- middle:meta` and `-->`: (`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 +## 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. diff --git a/packages/skills/implementing-github-issues/SKILL.md b/packages/skills/implementing-github-issues/SKILL.md index cf45bcba..78339652 100644 --- a/packages/skills/implementing-github-issues/SKILL.md +++ b/packages/skills/implementing-github-issues/SKILL.md @@ -8,7 +8,7 @@ allowed-tools: Bash(gh:*), Bash(git:*), Bash(pnpm:*), Bash(mkdir:*), Read, Write End-to-end workflow for taking an Epic from "assigned" to "PR open with verification evidence, marked ready for human review." All phases of one Epic land on **one branch** and **one PR**; the PR is the long-lasting context for the workstream. -**Mode-specific commands:** the Epic's data and the agent-↔-human conversation live in one of two stores — a GitHub issue (**github mode**) or a Markdown file under `planning/epics/` (**file mode**). PRs, reviews, and CI are GitHub-native in *both* modes. This skill body is mode-agnostic: it says "fetch the Epic", "post the plan to the Epic", "close the sub-issue with evidence", "post the reviewer's brief", "mark the PR ready" — the concrete incantations live in `references/<mode>-mode-commands.md` (`github-mode-commands.md` / `file-mode-commands.md`), mirrored into your worktree at `.middle/skills/implementing-github-issues/references/` for your run's mode. Use that file for every Epic/plan/sub-issue/conversation operation; use the PR commands (identical in both modes) inline below. +**Mode-specific commands:** the Epic's data and the agent-↔-human conversation live in one of two stores — a GitHub issue (**GitHub mode**) or a Markdown file under `planning/epics/` (**file mode**). PRs, reviews, and CI are GitHub-native in *both* modes. This skill body is mode-agnostic: it says "fetch the Epic", "post the plan to the Epic", "close the sub-issue with evidence", "post the reviewer's brief", "mark the PR ready" — the concrete incantations live in `references/<mode>-mode-commands.md` (`github-mode-commands.md` / `file-mode-commands.md`), mirrored into your worktree at `.middle/skills/implementing-github-issues/references/` for your run's mode. Use that file for every Epic/plan/sub-issue/conversation operation; use the PR commands (identical in both modes) inline below. ## Dispatch brief (read first) @@ -206,7 +206,7 @@ N. ... ## Phase 4 — Post the plan to the Epic -Post the plan body to the Epic's conversation (the Epic's **plan comment**). See `references/<mode>-mode-commands.md` for the exact write — in github mode it's an issue comment; in file mode the renderer appends it to the Epic file's conversation section (never hand-edit the strict markers). +Post the plan body to the Epic's conversation (the Epic's **plan comment**). See `references/<mode>-mode-commands.md` for the exact write — in GitHub mode it's an issue comment; in file mode the renderer appends it to the Epic file's conversation section (never hand-edit the strict markers). This is non-negotiable. The plan-on-the-Epic serves three purposes: 1. The reporter / stakeholders can correct your direction before you write code @@ -391,7 +391,7 @@ If you genuinely believe an acceptance-criterion item should be deferred, ask th ### Filing a sub-issue under an existing parent -File the follow-up as a sub-issue under its parent — the body carries the parent, the context (what you saw, where), why it's a sub-issue and not in-scope, and a suggested approach if you have one. See `references/<mode>-mode-commands.md` for the exact mechanics (github mode: create the child issue then attach it under the parent via the sub-issues REST endpoint; file mode: append a new `<!-- middle:sub-issue id=N -->` block to the Epic file via the renderer). +File the follow-up as a sub-issue under its parent — the body carries the parent, the context (what you saw, where), why it's a sub-issue and not in-scope, and a suggested approach if you have one. See `references/<mode>-mode-commands.md` for the exact mechanics (GitHub mode: create the child issue then attach it under the parent via the sub-issues REST endpoint; file mode: append a new `<!-- middle:sub-issue id=N -->` block to the Epic file via the renderer). ### Creating a parent for a natural collection @@ -654,7 +654,7 @@ Everything above describes the skill running interactively. When **middle-manage ### You are pointed at an Epic — its sub-issues are your plan phases -middle dispatches **Epics**, not individual issues. The Epic you're pointed at has sub-issues, and **its open sub-issues ARE the phases of your plan**. Don't invent a phase breakdown — fetch the Epic's sub-issues (see `references/<mode>-mode-commands.md`: github mode reads the sub-issues REST graph; file mode reads the `<!-- middle:sub-issue id=N -->` blocks in the Epic file), and each one is a phase. Your `plan.md` Phases list and the PR's Status checkboxes are one-per-sub-issue. +middle dispatches **Epics**, not individual issues. The Epic you're pointed at has sub-issues, and **its open sub-issues ARE the phases of your plan**. Don't invent a phase breakdown — fetch the Epic's sub-issues (see `references/<mode>-mode-commands.md`: GitHub mode reads the sub-issues REST graph; file mode reads the `<!-- middle:sub-issue id=N -->` blocks in the Epic file), and each one is a phase. Your `plan.md` Phases list and the PR's Status checkboxes are one-per-sub-issue. One Epic → one worktree → one branch → one PR. You work *down* the sub-issues in dependency order on that single branch, ticking each Status checkbox as its sub-issue's work verifies. Do **not** open a PR per sub-issue, and do **not** wait for review between sub-issues — the whole Epic is reviewed once, as one PR, when every sub-issue is done. @@ -666,7 +666,7 @@ The dispatcher created your worktree and branch and spawned you inside it. Do ** ### Asking a question = write `.middle/blocked.json` and exit (overrides Phase 2's "comment and wait") -You cannot "comment on the Epic and wait" — headless, there is nothing to wait *in*. When you genuinely need human input (ambiguous acceptance criteria, a decision CLAUDE.md/skills/docs don't resolve and that isn't worth a fork), write `<worktree>/.middle/blocked.json` containing the question and the context a human needs to answer it, then **exit cleanly**. The `blocked.json` sentinel is mode-agnostic — you write it the same way in both modes. Middle's exit classifier detects it, parks the workflow on a `waitFor` signal, and surfaces the question on the Epic (github mode: an issue comment; file mode: a `<!-- middle:question -->` block the dispatcher appends to the Epic file via the renderer). The human answers (github mode: a reply comment; file mode: editing the `<!-- middle:answer -->` block, or running `mm resume <repo> <slug> --answer "…"`), and you're re-spawned with the answer. Do not guess past a real blocker; do not spin idle. +You cannot "comment on the Epic and wait" — headless, there is nothing to wait *in*. When you genuinely need human input (ambiguous acceptance criteria, a decision CLAUDE.md/skills/docs don't resolve and that isn't worth a fork), write `<worktree>/.middle/blocked.json` containing the question and the context a human needs to answer it, then **exit cleanly**. The `blocked.json` sentinel is mode-agnostic — you write it the same way in both modes. Middle's exit classifier detects it, parks the workflow on a `waitFor` signal, and surfaces the question on the Epic (GitHub mode: an issue comment; file mode: a `<!-- middle:question -->` block the dispatcher appends to the Epic file via the renderer). The human answers (GitHub mode: a reply comment; file mode: editing the `<!-- middle:answer -->` block, or running `mm resume <repo> <slug> --answer "…"`), and you're re-spawned with the answer. Do not guess past a real blocker; do not spin idle. ### Complexity is fork branching factor — pause the sub-issue past the ceiling @@ -683,7 +683,7 @@ When a human answers, middle re-spawns you with the answer injected into your pr ### The plan comment is mechanically gated (reinforces Phase 4) -After your plan step, the dispatcher's **plan-comment guard** verifies the plan body was posted to the Epic (github mode: a comment by your account on the issue; file mode: a plan entry in the Epic file's conversation section, written by the renderer). No plan on the Epic → the workflow fails. Phase 4 was always "non-negotiable"; under middle it is literally enforced. +After your plan step, the dispatcher's **plan-comment guard** verifies the plan body was posted to the Epic (GitHub mode: a comment by your account on the issue; file mode: a plan entry in the Epic file's conversation section, written by the renderer). No plan on the Epic → the workflow fails. Phase 4 was always "non-negotiable"; under middle it is literally enforced. ### `gh pr ready` is mechanically gated (reinforces Phase 10) diff --git a/packages/skills/implementing-github-issues/references/file-mode-commands.md b/packages/skills/implementing-github-issues/references/file-mode-commands.md index 066b8e4e..f79bff43 100644 --- a/packages/skills/implementing-github-issues/references/file-mode-commands.md +++ b/packages/skills/implementing-github-issues/references/file-mode-commands.md @@ -1,6 +1,6 @@ # implementing-github-issues — file-mode commands -The file-mode equivalents of every Epic/plan/sub-issue/conversation operation the skill body refers to mode-agnostically. In **file mode** the Epic is a Markdown file at `planning/epics/<slug>.md` (the slug is the file's stem and the canonical Epic reference), and the agent-↔-human conversation lives in that file's `<!-- middle:conversation -->` section. **PRs, reviews, and CI stay GitHub-native** — the PR/CI commands are identical to github mode (`gh pr …`). +The file-mode equivalents of every Epic/plan/sub-issue/conversation operation the skill body refers to mode-agnostically. In **file mode** the Epic is a Markdown file at `planning/epics/<slug>.md` (the slug is the file's stem and the canonical Epic reference), and the agent-↔-human conversation lives in that file's `<!-- middle:conversation -->` section. **PRs, reviews, and CI stay GitHub-native** — the PR/CI commands are identical to GitHub mode (`gh pr …`). ## The one rule that governs every write below @@ -58,7 +58,7 @@ Each `<!-- middle:sub-issue id=N -->` block is one phase. An *open* sub-issue is ## Post the plan to the Epic (Phase 4) -The plan goes into the Epic file's `<!-- middle:conversation -->` section as a conversation entry — **written by the renderer, not by hand.** Under middle's dispatch this is the plan step the dispatcher records via the renderer; the plan-comment guard then verifies a plan entry exists in the conversation section. You author the plan body (in `planning/epics/<slug>.md`'s adjacent `planning/issues/<slug>/plan.md`, same as github mode); the renderer appends it to the conversation. Do not edit the conversation markers yourself. +The plan goes into the Epic file's `<!-- middle:conversation -->` section as a conversation entry — **written by the renderer, not by hand.** Under middle's dispatch this is the plan step the dispatcher records via the renderer; the plan-comment guard then verifies a plan entry exists in the conversation section. You author the plan body (in `planning/epics/<slug>.md`'s adjacent `planning/issues/<slug>/plan.md`, same as GitHub mode); the renderer appends it to the conversation. Do not edit the conversation markers yourself. ## Close a sub-issue with evidence @@ -75,7 +75,7 @@ The recommender's "open sub-issues" count scans for unchecked boxes, so a checke ## Ask a question / surface a blocker -Identical agent action to github mode: write `<worktree>/.middle/blocked.json` and exit. The dispatcher's file-backed writer appends a `<!-- middle:question id=N status=open … -->` block to the conversation section **via the renderer** — you never write the question marker yourself. The human answers by editing the `<!-- middle:answer for=N -->` block in the file (the file-watcher fires resume when that block becomes non-empty) or by running: +Identical agent action to GitHub mode: write `<worktree>/.middle/blocked.json` and exit. The dispatcher's file-backed writer appends a `<!-- middle:question id=N status=open … -->` block to the conversation section **via the renderer** — you never write the question marker yourself. The human answers by editing the `<!-- middle:answer for=N -->` block in the file (the file-watcher fires resume when that block becomes non-empty) or by running: ```bash mm resume <repo> <slug> --answer "…" @@ -100,7 +100,7 @@ A "parent for a natural collection" is the Epic itself — file each related ite A genuinely cross-workstream item is a *new Epic file*: author `planning/epics/<other-slug>.md` with its own `<!-- middle:epic v1 -->` + `<!-- middle:meta -->` (see "creating-github-issues" file-mode addendum). A "Discovered while working on: <slug>" line in its Context is the cross-reference. Again — no `gh issue create`. -## PR / CI operations (GitHub-native — same as github mode) +## PR / CI operations (GitHub-native — same as GitHub mode) PRs, reviews, and CI are GitHub-native in file mode too. Use the same commands the skill body lists inline: diff --git a/packages/skills/implementing-github-issues/references/github-mode-commands.md b/packages/skills/implementing-github-issues/references/github-mode-commands.md index 38d8476e..cc2cb631 100644 --- a/packages/skills/implementing-github-issues/references/github-mode-commands.md +++ b/packages/skills/implementing-github-issues/references/github-mode-commands.md @@ -1,6 +1,6 @@ -# implementing-github-issues — github-mode commands +# implementing-github-issues — GitHub-mode commands -The concrete `gh` incantations for every Epic/plan/sub-issue/conversation operation the skill body refers to mode-agnostically. **github mode** is the default: the Epic is a GitHub issue, its sub-issues are native GitHub sub-issues, and the agent-↔-human conversation flows through issue comments. PRs, reviews, and CI are GitHub-native here too (and identical in file mode). +The concrete `gh` incantations for every Epic/plan/sub-issue/conversation operation the skill body refers to mode-agnostically. **GitHub mode** is the default: the Epic is a GitHub issue, its sub-issues are native GitHub sub-issues, and the agent-↔-human conversation flows through issue comments. PRs, reviews, and CI are GitHub-native here too (and identical in file mode). Throughout, `<epic>` is the Epic's issue number, `<owner>`/`<repo>` the repository. diff --git a/packages/skills/recommending-github-issues/SKILL.md b/packages/skills/recommending-github-issues/SKILL.md index c379d660..5a8695e8 100644 --- a/packages/skills/recommending-github-issues/SKILL.md +++ b/packages/skills/recommending-github-issues/SKILL.md @@ -10,7 +10,7 @@ You are the dispatch recommender for a single repository. Your only job is to rewrite ONE **state body** with a ranked plan of work to dispatch and a digest of items needing human attention. -**Mode-specific commands:** the repo runs in one of two modes. In **github mode** +**Mode-specific commands:** the repo runs in one of two modes. In **GitHub mode** the dispatch units are GitHub issues/Epics and the state body is a GitHub issue (the `agent-queue:state` issue). In **file mode** the dispatch units are Epic files under `epics_dir` and the state body is the `state_file` on disk. This @@ -65,19 +65,19 @@ the dispatcher inputs. Fetch the repo's **dispatch units** and **open PRs**, and resolve each unit's sub-issue structure. The exact reads are mode-specific — see -`references/<mode>-mode-commands.md`. In github mode you list open issues + PRs +`references/<mode>-mode-commands.md`. In GitHub mode you list open issues + PRs and read the native sub-issue graph; in file mode you scan `epics_dir` for Epic files, parse each one's `<!-- middle:meta -->` and sub-issue blocks, and still list open PRs from GitHub. Resolve the **dispatch-unit structure**: - A unit with sub-issues is an **Epic** — a dispatch unit. -- A sub-issue (an issue with a parent in github mode; a `<!-- middle:sub-issue -->` +- A sub-issue (an issue with a parent in GitHub mode; a `<!-- middle:sub-issue -->` block inside an Epic file in file mode) is **NOT** a dispatch unit. It is scope inside its Epic; never classify or rank it on its own. - A unit with neither is a **standalone issue** — a dispatch unit (a one-phase Epic). -**Exclude the state surface itself.** In github mode the issue you are rewriting +**Exclude the state surface itself.** In GitHub mode the issue you are rewriting (and any issue carrying the `agent-queue:state` label) is the dispatcher's surface, never a dispatch unit. In file mode the `state_file` is not an Epic file and never appears in `epics_dir`. Never classify or rank the state surface. @@ -167,11 +167,11 @@ Verify before writing: ### Phase 6 — Write and log Write the rendered body to the state surface — see `references/<mode>-mode-commands.md`. -In github mode it's `gh issue edit <state_issue> --body-file …`. In file mode you +In GitHub mode it's `gh issue edit <state_issue> --body-file …`. In file mode you write `state_file` **via the renderer** (`renderStateIssue`), never by hand — the renderer is the sole writer, which closes #180's class for this skill too. -Then log a single diff summary against prior_body. In github mode that's a comment +Then log a single diff summary against prior_body. In GitHub mode that's a comment on the state issue; in file mode the run summary is recorded the same way the dispatcher records it (no separate GitHub comment — the state surface is a file): @@ -222,7 +222,7 @@ the problem and stop. Dispatcher will surface to human. ## Files this skill creates -Mode-dependent (see `references/<mode>-mode-commands.md`). In github mode: none on +Mode-dependent (see `references/<mode>-mode-commands.md`). In GitHub mode: none on filesystem — output is the state issue body via `gh issue edit` plus one diff comment. In file mode: the `state_file` on disk, written via `renderStateIssue` (the renderer is the sole writer — never hand-edited). @@ -230,7 +230,7 @@ comment. In file mode: the `state_file` on disk, written via `renderStateIssue` ## Files this skill reads - Schema at the path provided by the dispatcher -- The repo's dispatch units and their sub-issue structure (github mode: open issues +- The repo's dispatch units and their sub-issue structure (GitHub mode: open issues + the sub-issue graph via `gh`; file mode: Epic files scanned from `epics_dir`) - Open PRs via `gh` (GitHub-native in both modes) - Recent git log on main diff --git a/packages/skills/recommending-github-issues/references/file-mode-commands.md b/packages/skills/recommending-github-issues/references/file-mode-commands.md index 26ca08c6..6d804283 100644 --- a/packages/skills/recommending-github-issues/references/file-mode-commands.md +++ b/packages/skills/recommending-github-issues/references/file-mode-commands.md @@ -10,7 +10,7 @@ body are file-backed. **The renderer is the sole writer of the state body.** You write `state_file` via `renderStateIssue` (the same parser + renderer + byte-identical-round-trip -invariant as github mode's state-issue flow) — **never by hand**. There is no +invariant as GitHub mode's state-issue flow) — **never by hand**. There is no recommender-agent rewriting strict sections out-of-band; this closes #180's class entirely for file mode. You compose the state model and render it; you do not hand-edit the file's markers or the dispatcher-owned sections (In-flight, Rate diff --git a/packages/skills/recommending-github-issues/references/github-mode-commands.md b/packages/skills/recommending-github-issues/references/github-mode-commands.md index 81712e42..85c30a7e 100644 --- a/packages/skills/recommending-github-issues/references/github-mode-commands.md +++ b/packages/skills/recommending-github-issues/references/github-mode-commands.md @@ -1,6 +1,6 @@ -# recommending-github-issues — github-mode commands +# recommending-github-issues — GitHub-mode commands -The concrete state-issue read/write commands for **github mode**: dispatch units +The concrete state-issue read/write commands for **GitHub mode**: dispatch units are GitHub issues/Epics, and the state body is the `agent-queue:state` issue. ## Fetch repo state and resolve the Epic graph (Phase 2) diff --git a/planning/issues/190/decisions.md b/planning/issues/190/decisions.md index 12023f40..8d912dcc 100644 --- a/planning/issues/190/decisions.md +++ b/planning/issues/190/decisions.md @@ -27,8 +27,8 @@ truth on the interface shape; the seam name `epicRef` is correct where the value **Decision:** A single `refToIssueNumber(ref)` helper converts the string ref to an integer at each `gh`-calling method; it throws a clear error when the ref is not a parseable positive -integer (github mode contract: numeric-string refs only). -**Why:** github mode keeps working unchanged — the workflow layer now speaks strings, and the +integer (GitHub mode contract: numeric-string refs only). +**Why:** GitHub mode keeps working unchanged — the workflow layer now speaks strings, and the only place that needs an int is the `gh` CLI call itself. Centralizing the parse keeps the error message uniform and the "numeric-string only" contract in one place. **Evidence:** sub-issue #191 acceptance criterion 2. @@ -56,16 +56,16 @@ that feed the epicRef seam; display SELECTs feeding numeric schemas are out of s **File(s):** `packages/dispatcher/src/workflow-record.ts`, `packages/dashboard/test/{helpers,api,sse}.*` **Date:** 2026-06-03 -**Decision:** github-mode `createWorkflowRecord` now writes BOTH `epic_number` (parsed +**Decision:** GitHub-mode `createWorkflowRecord` now writes BOTH `epic_number` (parsed from the numeric ref) and `epic_ref` (the stringified number). Two #187 dashboard tests -that asserted a github row's `epicRef` is `null` were updated to expect the stringified +that asserted a GitHub row's `epicRef` is `null` were updated to expect the stringified number; the `EpicRef` component is unaffected (it keys its `#N` render off `epic`, only consulting `epicRef` when `epic === null`). -**Why:** The spec's dual-column contract is "github mode writes both columns"; the #187 +**Why:** The spec's dual-column contract is "GitHub mode writes both columns"; the #187 tests were written against the foundation's incomplete `createWorkflowRecord` (which wrote only `epic_number`). Completing the write path makes those `epicRef: null` assertions stale — the faithful fix is to assert the new value, not to fake the old DB -state in the test helper. github-mode *rendering* is byte-for-byte unchanged. +state in the test helper. GitHub-mode *rendering* is byte-for-byte unchanged. **Evidence:** spec "Config schema" (dual-column); `EpicRef.tsx` (epicNumber-first render). ## Worktree seam is string-keyed (`epicRef`), unit path unchanged @@ -74,23 +74,23 @@ state in the test helper. github-mode *rendering* is byte-for-byte unchanged. **Decision:** `CreateWorktreeOpts.issueNumber?: number` became `epicRef?: string`; the dispatch-unit directory stays `issue-${epicRef}` (so `issue-27` is byte-identical for a -github ref). The pr-divergence reconciler parses the numeric epic from the head ref and +GitHub ref). The pr-divergence reconciler parses the numeric epic from the head ref and stringifies it at the `createWorktree` boundary. **Why:** The workflow seam now threads a string; the worktree directory must accept it so -a file-mode slug yields `issue-<slug>` without a numeric coercion. github paths are unchanged. +a file-mode slug yields `issue-<slug>` without a numeric coercion. GitHub paths are unchanged. **Evidence:** sub-issue #191 (string seam everywhere); worktree layout in root `CLAUDE.md`/spec. ## `EpicListItem` gains `ref`; `number` nullable; numeric epics cache skips file Epics **File(s):** `packages/dispatcher/src/github.ts`, `epics-cache.ts` **Date:** 2026-06-03 -**Decision:** `EpicListItem` gains a required `ref: string` (github: `String(number)`, +**Decision:** `EpicListItem` gains a required `ref: string` (GitHub: `String(number)`, file: slug) and `number` becomes `number | null` (null for a file Epic). `refreshEpics` skips rows with `number === null` — the browse cache table is numeric-keyed `(repo, number)`. **Why:** `fileEpicGateway.listOpenEpics` must return file Epics, which have only a slug. -The browse cache is github-only this phase (`refreshEpics` is always called with `ghGitHub` +The browse cache is GitHub-only this phase (`refreshEpics` is always called with `ghGitHub` in `main.ts`); a file-aware browse cache is a later phase, so skipping null-numbered rows -keeps the numeric table honest without a schema change. github rows are unaffected. +keeps the numeric table honest without a schema change. GitHub rows are unaffected. **Evidence:** `epics-cache.ts` `(repo, number)` PK; #192 integration scope (dispatch+postComment, not browse). ## File-mode PR-poll resolution (`findPrForEpic`) is a Phase-2 refinement @@ -102,7 +102,7 @@ poll comments with `authorIsBot` from the marker — the #178-class closure). `g delegates to gh. `findPrForEpic`/`findEpicPrLifecycle` delegate to gh for a numeric ref but return `null` for a file-mode slug (no PR yet / Phase-1 limitation) rather than feed a slug into gh's `Closes #<number>` search (which `refToIssueNumber` would reject). -**Why:** github's PR-finders resolve a PR by `Closes #<epicNumber>`, which a file Epic (slug, +**Why:** GitHub's PR-finders resolve a PR by `Closes #<epicNumber>`, which a file Epic (slug, no GitHub issue) can't carry; the file↔PR link is the `<!-- middle:epic <slug> -->` body marker + `meta.pr`, and `PollGateway` has no by-PR-number snapshot method to fetch through. Spec Phase 1 is "File-Epic dispatch (no watcher)"; review-resume on file mode rides Phase 2's @@ -120,13 +120,13 @@ is unaffected. (`makeRoutingEpicGateway`) that reads `repo_config` per call and delegates to the repo's file or gh backend, keyed on the method's `repo` arg. `build-deps` defaults `github`/`planCommentReader` to the router and routes `postQuestion` by mode -(file → `appendQuestion`, github → `formatPauseComment` via gh). +(file → `appendQuestion`, GitHub → `formatPauseComment` via gh). **Why:** The spec's "buildImplementationDeps picks the trio" reads as a single selection, but the daemon serves many repos through one registration — so the selection must happen per-call. Every gateway method already takes `repo` first, so -a router is the minimal, interface-preserving way to run github repo A and file repo +a router is the minimal, interface-preserving way to run GitHub repo A and file repo B under one daemon. An injected `args.github`/`args.postQuestion` still overrides -(tests). github-mode repos route to `ghGitHub`, so behavior is byte-identical. +(tests). GitHub-mode repos route to `ghGitHub`, so behavior is byte-identical. **Evidence:** spec "Architecture" ("daemon runs both modes simultaneously"); `main.ts` registers one workflow with one deps. @@ -135,12 +135,12 @@ registers one workflow with one deps. **Date:** 2026-06-03 **Decision:** The control endpoint now accepts either a non-empty string `epicRef` -(file mode) or an integer `epicNumber` ≥ 1 (github mode, stringified), building the +(file mode) or an integer `epicNumber` ≥ 1 (GitHub mode, stringified), building the string-keyed `ControlDispatchInput.epicRef` from whichever is present. **Why:** A file-mode dispatch references a slug, which the prior numeric-only validation rejected. This makes the dispatch entry mode-agnostic so #193's selector is reachable end-to-end; the CLI sends the right field in #194. Existing numeric -clients are unaffected (the github branch is unchanged). +clients are unaffected (the GitHub branch is unchanged). **Evidence:** #193 integration criterion (HTTP dispatch with a file-mode slug). ## `mm resume` is overloaded: clear-pause vs answer-a-parked-Epic @@ -166,12 +166,12 @@ looks up the parked workflow by `epic_ref`, so it works in both modes. **Date:** 2026-06-03 **Decision:** `mm dispatch <repo> <epic>` (and `--epic <ref>`) accept a file slug or -a github issue number. A digit-leading ref must be a whole number ≥ 1 (else rejected); +a GitHub issue number. A digit-leading ref must be a whole number ≥ 1 (else rejected); a non-digit-leading ref is a slug. Only a numeric ref triggers the `agent:<name>` label lookup via gh; a slug skips gh (file-mode Epics carry their adapter in the file meta, which the daemon reads). The POST body now sends `epicRef` (string). **Why:** file-mode Epics are slugs; the dispatch entry must accept them and avoid a gh -call for a non-GitHub Epic. github-mode numeric dispatch is unchanged in behavior. +call for a non-GitHub Epic. GitHub-mode numeric dispatch is unchanged in behavior. **Evidence:** sub-issue #194 (slug-or-number positional + `--epic`). ## Mode-commands mirror reads the worktree's installed skill (no dispatcher→cli import) @@ -220,12 +220,12 @@ cron (`StartPollerOptions.fileWatcher`) — same 120s cadence, no new cron. The tracks `lastWatcherTick` and scans each file-mode repo's `epics_dir`; firing the resume marks the durable wait fired and flips the question to `resolved` (structural dedup — a later tick never re-fires). -**Why:** file answers can't be detected by the github `createdAt > sinceMs` path (a +**Why:** file answers can't be detected by the GitHub `createdAt > sinceMs` path (a file answer inherits the question's ts, which predates the park) — so file mode needs the mtime + open-question-status mechanism. Sharing the cron keeps the cadence -symmetric with github comment polling and avoids a second timer. `runFileWatcherTick` +symmetric with GitHub comment polling and avoids a second timer. `runFileWatcherTick` is extracted (not inlined in main.ts) so the daemon and the integration test share one -implementation — no drift. The github resume poll stays untouched. +implementation — no drift. The GitHub resume poll stays untouched. **Evidence:** spec "filePollGateway" file-watcher paragraph; `poller.ts` `classifyNewHumanReply` (createdAt-gated); sub-issue #197 "no new cron". @@ -241,12 +241,12 @@ burning the rate-limit probe in a mixed deployment). Fixed by adding `makeRoutingPollGateway` (the poller's counterpart to `makeRoutingEpicGateway`) and wiring the poller's `github` + the orphan surface to the per-repo routers in `main.ts`. For a file-mode repo the routed poll gateway's PR-finders return null and its -comment-listing reads the Epic file, so the github resume poll is a clean no-op (the +comment-listing reads the Epic file, so the GitHub resume poll is a clean no-op (the file-watcher owns that resume). Also added a reason-guard to `runFileWatcherTick`: it only fires when the parked workflow's armed signal is the `answered-question` one (so an answer edit can't resume a workflow parked for another reason). -**Why:** "github mode unchanged" must extend to "file mode doesn't spam/contend the -github poller". Resolving the class (route every gh-facing poller/recovery seam, not +**Why:** "GitHub mode unchanged" must extend to "file mode doesn't spam/contend the +GitHub poller". Resolving the class (route every gh-facing poller/recovery seam, not just the implementation deps) within the review's blast radius, each with a test. **Evidence:** the adversarial review's BUG 1 / RISK 2 / RISK 3; tests `selector.test.ts` (routing poll gateway) + `watcher.test.ts` (reason guard). diff --git a/planning/issues/190/plan.md b/planning/issues/190/plan.md index 43e39234..cd9a9532 100644 --- a/planning/issues/190/plan.md +++ b/planning/issues/190/plan.md @@ -15,7 +15,7 @@ parallel file implementations selected per-repo at bootstrap. - The foundation (gateway rename, migrations 007/008/009, Epic-file parser/renderer + byte-identical round-trip) merged in PR #188 and is already on this branch's base. - Make the workflow seam string-keyed (`epicRef: string`) so a file slug is a - first-class Epic identifier; github mode parses `Number(epicRef)` at the `gh` boundary. + first-class Epic identifier; GitHub mode parses `Number(epicRef)` at the `gh` boundary. - Add three composite file gateways: Epic/state methods read/write local files via the existing pure parser+renderer; PR-shaped methods delegate to an injected `gh` backend. - Select the gateway trio per-repo from `repo_config.epic_store` in `build-deps.ts`. @@ -30,7 +30,7 @@ parallel file implementations selected per-repo at bootstrap. 3. **#193** feat(epic-store): bootstrap selector + `postQuestion` file-mode wiring — *blocked by #192* 4. **#194** feat(cli): `mm init/dispatch/doctor/resume` — file-mode support — *blocked by #193* 5. **#195** refactor(skills): abstract Epic-aware skills + dispatch-brief mode injection — *blocked by #193* -6. **#196** test(epic-store): parity test (github ⇔ file) + Phase 1 smoke — *blocked by #194, #195* +6. **#196** test(epic-store): parity test (GitHub ⇔ file) + Phase 1 smoke — *blocked by #194, #195* 7. **#197** feat(epic-store): Phase 2 — file-watcher Q&A loop on the poller cron — *blocked by #196* ## Files likely to change