From 321d05e3698b24597ccb1910bc513cf6a4d3189b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 01:08:28 +0000 Subject: [PATCH 1/5] ADR 0018: spec routing drafts one issue per Thread, recorded on the Thread (#163) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0193moUKw7KiVLfSfuDsRknd --- .../0018-spec-routing-drafts-a-repo-issue.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/adr/0018-spec-routing-drafts-a-repo-issue.md diff --git a/docs/adr/0018-spec-routing-drafts-a-repo-issue.md b/docs/adr/0018-spec-routing-drafts-a-repo-issue.md new file mode 100644 index 0000000..b6dc450 --- /dev/null +++ b/docs/adr/0018-spec-routing-drafts-a-repo-issue.md @@ -0,0 +1,46 @@ +# Spec routing drafts one issue per Thread, and the record of it lives on the Thread + +Routing a Thread to Spec is the first gesture that writes outside the +system: a ticket-shaped issue drafted in the Project's repository, title +from the Thread, body from the Enrichment's idea-shaped report. An +external write cannot be retried casually or deleted quietly, so three +questions are settled here before any seam ships. + +**Idempotency.** One Thread earns at most one drafted issue, ever. The +guard is a handoff record stored on the Thread itself (`spec_handoff`): +once it says `drafted`, every later spec routing — a re-route, a retry, a +second device replaying the same gesture — returns that record instead of +drafting again. The stable key is derived from the Thread id +(`spec:`) and travels in the issue body, so the GitHub drafter +can search for it before creating — that closes the crash window where +the issue landed but the record write did not. The alternative, keying on +the filing request or timestamp, was rejected because the same Thread +routed from the deck and from a desk row is one intent, not two. `skipped` +and `failed` records are deliberately *not* guards: they exist so the next +attempt can succeed once the Project has a repository or the outage ends. + +**Failure visibility.** A draft that does not land is recorded on the +Thread as `failed` with its reason, returned in the filing response, and +shown where the Route was settled — never swallowed, and never allowed to +fail the filing itself: the Route and Reviewed still commit, because the +walker's decision is real even when GitHub is down. Routing to Spec when +the Project has no repository is not a failure but a `skipped` record: the +Route is kept, no external write happens, and the Thread says plainly that +the handoff is not live. The rejected alternative — refusing the spec +Route until a repository exists — would make an external service's +configuration a gate on the walker's own filing. + +**Undo.** Un-routing reverses only what lives inside the system. If an +issue was already drafted, it stays in the repository — we never delete or +close external writes, because by then a coding agent may own it — and the +handoff record notes it was orphaned (`orphanedAt`), keeping the link so +the receipt can still name what exists. Routing back to Spec clears the +orphan note and reuses the same issue; it never drafts a second one. + +Accepted costs, recorded so nobody rediscovers them: an orphaned issue in +a repo is the walker's to close by hand; a Thread whose title or report +improves after drafting does not update the issue (the draft is a +handoff, not a mirror); and until a server-side GitHub credential is +provisioned, the drafter behind the seam is the in-memory one — routing +records everything faithfully but no real issue appears, which the +`skipped`/`failed` visibility above makes plain rather than silent. From 9c9a36e573825bd5505c99e04e20f9f0c4ee31be Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 01:16:13 +0000 Subject: [PATCH 2/5] Spec routing drafts a repo issue behind the drafter seam (#163) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0193moUKw7KiVLfSfuDsRknd --- app/api/sync/projects/route.ts | 20 +++- app/api/sync/review/route.ts | 27 +++++ components/thread-filing.tsx | 25 +++++ lib/desk/file-thread.ts | 4 + lib/local-capture/transitions.ts | 4 + lib/local-capture/types.ts | 56 ++++++++++ lib/spec/handoff.ts | 159 ++++++++++++++++++++++++++++ lib/spec/issue-drafter.ts | 172 +++++++++++++++++++++++++++++++ lib/sync/hydrate.ts | Bin 6032 -> 6284 bytes lib/sync/memory-repository.ts | 28 ++++- lib/sync/neon-repository.ts | 48 +++++++-- lib/sync/review-client.ts | 14 ++- lib/sync/types.ts | 32 +++++- 13 files changed, 569 insertions(+), 20 deletions(-) create mode 100644 lib/spec/handoff.ts create mode 100644 lib/spec/issue-drafter.ts diff --git a/app/api/sync/projects/route.ts b/app/api/sync/projects/route.ts index 649c17f..a5f2c8c 100644 --- a/app/api/sync/projects/route.ts +++ b/app/api/sync/projects/route.ts @@ -1,3 +1,4 @@ +import { asProjectRepository } from "@/lib/local-capture/types"; import { requireSyncAccess } from "@/lib/sync/access"; import { getThreadRepository } from "@/lib/sync/repository"; @@ -12,14 +13,19 @@ export async function GET(request: Request) { return Response.json({ projects }); } -/** Create a Project by name; filing the same name twice returns the same one. */ +/** + * Create a Project by name; filing the same name twice returns the same one. + * An optional `repository` (`owner/repo`) names where spec Threads routed to + * this Project draft their issues (ADR 0018); passing it for an existing + * name sets it, `null` clears it, omitting it keeps what the Project has. + */ export async function POST(request: Request) { const access = await requireSyncAccess(request); if ("error" in access) return access.error; - let body: { name?: string }; + let body: { name?: string; repository?: string | null }; try { - body = (await request.json()) as { name?: string }; + body = (await request.json()) as typeof body; } catch { return Response.json({ error: "invalid_json" }, { status: 400 }); } @@ -30,11 +36,19 @@ export async function POST(request: Request) { if (name.length > 60) { return Response.json({ error: "name_too_long" }, { status: 400 }); } + let repository: string | null | undefined = undefined; + if (body.repository !== undefined) { + repository = body.repository === null ? null : asProjectRepository(body.repository); + if (body.repository !== null && !repository) { + return Response.json({ error: "repository_invalid" }, { status: 400 }); + } + } try { const project = await getThreadRepository().createProject( access.userId, name, + repository === undefined ? undefined : { repository }, ); return Response.json({ project }); } catch (error) { diff --git a/app/api/sync/review/route.ts b/app/api/sync/review/route.ts index 131d954..1a1ffa7 100644 --- a/app/api/sync/review/route.ts +++ b/app/api/sync/review/route.ts @@ -1,9 +1,12 @@ +import { getEnrichmentRepository } from "@/lib/enrichment/repository"; import { asResearchVerdict, asThreadKind, asThreadRoute, verdictImpliedByRoute, } from "@/lib/local-capture/types"; +import { orphanSpecHandoff, settleSpecHandoff } from "@/lib/spec/handoff"; +import { getIssueDrafter } from "@/lib/spec/issue-drafter"; import { requireSyncAccess } from "@/lib/sync/access"; import { getThreadRepository } from "@/lib/sync/repository"; @@ -69,6 +72,29 @@ export async function POST(request: Request) { if (!thread) { return Response.json({ error: "thread_not_found" }, { status: 404 }); } + + // The handoff runs after the filing write, never instead of it: the + // Route is already committed, and a draft that cannot land records + // itself as failed on the Thread rather than failing the filing + // (ADR 0018). Un-routing a drafted spec leaves the issue and notes the + // orphan; routing back to spec reuses it and never drafts twice. + let specHandoff = thread.specHandoff ?? null; + if (route === "spec") { + specHandoff = await settleSpecHandoff({ + userId: access.userId, + thread, + threads: repository, + reports: getEnrichmentRepository(), + drafter: getIssueDrafter(), + }); + } else if (route !== undefined) { + specHandoff = await orphanSpecHandoff({ + userId: access.userId, + thread, + threads: repository, + }); + } + return Response.json({ threadId: thread.id, reviewedAt: thread.reviewedAt ?? null, @@ -77,6 +103,7 @@ export async function POST(request: Request) { projectName: thread.projectName ?? null, researchVerdict: thread.researchVerdict ?? null, route: thread.route ?? null, + specHandoff, }); } catch (error) { const reason = error instanceof Error ? error.message : "review_failed"; diff --git a/components/thread-filing.tsx b/components/thread-filing.tsx index 1219951..9560521 100644 --- a/components/thread-filing.tsx +++ b/components/thread-filing.tsx @@ -94,6 +94,7 @@ export function ThreadFiling({ // otherwise the Enrichment's proposal — defaulted from Kind until a // ROUTE: header exists. const proposedRoute = thread.route ?? routeForKind(thread.kind); + const handoff = thread.specHandoff ?? null; return (
@@ -102,6 +103,30 @@ export function ThreadFiling({ ? `Routed to ${ROUTE_LABELS[thread.route]}` : "Where does this go?"}

+ {/* The spec handoff's receipt (ADR 0018): the drafted issue with its + repo named, or plainly why the handoff is not live. */} + {handoff?.status === "drafted" && handoff.issueUrl ? ( +

+ + Issue drafted in {handoff.repository} + + {handoff.orphanedAt + ? " — re-routed since; the issue remains." + : null} +

+ ) : null} + {thread.route === "spec" && handoff?.status === "skipped" ? ( +

+ Spec recorded — this Project has no repository, so the handoff is + not live. +

+ ) : null} + {thread.route === "spec" && handoff?.status === "failed" ? ( +

+ The issue draft failed ({handoff.reason ?? "unknown"}). Routing to + Spec again retries it. +

+ ) : null}
{THREAD_ROUTES.map((route) => (