diff --git a/.agents/skills/ask-matt/PHASE-BOUNDARIES.md b/.agents/skills/ask-matt/PHASE-BOUNDARIES.md
new file mode 100644
index 0000000..cb31e6a
--- /dev/null
+++ b/.agents/skills/ask-matt/PHASE-BOUNDARIES.md
@@ -0,0 +1,55 @@
+# Phase boundaries
+
+A **phase** is a chunk of work inside a session — the grilling, the implementation, the QA. The definition is fuzzy on purpose: a phase ends when you think *"ok, we're done with that"*.
+
+The **phase boundary** is the gap between two phases, and it is the only place this decision belongs. Mid-phase there is no decision to make — continue, or split the work that's left into subagents. Compacting mid-phase makes the agent lose the thread.
+
+## The five options
+
+| Option | What it does |
+| ------------ | --------------------------------------------------------------- |
+| **Continue** | Stay in the session. No context switch at all. |
+| **`/clear`** | Empty the context window and start from nothing. |
+| **`/handoff`** | Write a portable markdown file and seed a session anywhere with it. |
+| **Subagent** | Send the task to its own context window and get a report back. |
+| **`/compact`** | Compress this context and seed a fresh session with the summary. |
+
+## The tree
+
+Work top to bottom at the boundary. The first **yes** wins.
+
+**1. Can you continue in this session?** Two things make the answer yes: the next phase needs this phase as a **primary source**, or you have enough [smart zone](https://www.aihero.dev/ai-coding-dictionary/smart-zone) left (~150k tokens) for the next phase to fit. Grilling → implementation is the standard yes: the implementation wants the reasoning verbatim, not a summary of it. Continue costs nothing and loses nothing, so rule it out before anything else.
+
+**2. Is the context irrelevant to what comes next?** Is everything in this session — the exploration, the decisions, the dead ends — disposable? If so, **`/clear`**. It is the cheapest move on the board: it takes no time and hands back the whole window. `/clear` also isn't terminal — the old session stays resumable.
+
+The cost of getting this wrong is one-way. Clear a *relevant* context and you lose the **why** behind what you built, and no amount of reading the diff back gets it returned.
+
+**3. Do you need to hand off?** `/handoff` is narrow. You need it only when you are:
+
+- swapping to a **new harness** (Claude → Codex),
+- moving to a **new directory** or repo,
+- sending the work to a **colleague**,
+- or forking a side task you found **mid-phase** without derailing what you're doing.
+
+That list is the whole clause. What `/handoff` buys is **portability** — a file that travels. If nothing is travelling, you don't need it.
+
+**4. Can the task be done AFK?** Is it scoped tightly enough to run with you away from the keyboard, no steering? Then send it to a **subagent** and leave this session untouched. Automated review is the standard case: the agent reads the diff and reports, and you aren't needed while it does.
+
+**5. Otherwise, `/compact`.** Relevant context, same harness, same directory, and you need to stay in the loop — this is where the tree lands, and it lands here often. Pass it an instruction (`/compact we're going to QA this area`) so the summary keeps what the next phase needs.
+
+`/compact` is the **default, not the first reach**. It sits at the bottom because the four questions above it are all cheaper or more precise. The failure mode when people start here is a fresh session that is confidently wrong about a decision the summary flattened.
+
+## Primary and secondary sources
+
+Every move except **Continue** turns a **primary source** into a **secondary source** — the session as it happened, replaced by a summary of it. The trade is always the same shape:
+
+| Source | Information | Noise | Room to move |
+| --------------------------------- | ----------- | ----- | ------------ |
+| Primary (Continue) | Full | Lots | Little |
+| Secondary (`/compact`, `/handoff`) | Lossy | Less | Lots |
+
+This is why question 1 comes first. You only pay the lossiness when staying costs more than it saves.
+
+## These are judgement calls
+
+The questions are not objective — each has taste in it, and the same boundary can go two ways on two days. The value is in asking them **in order**, at the boundary rather than in the middle of the work.
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..bf983e5 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,46 @@ 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, so nothing the handoff does — not even
+ // an unexpected throw — may turn this response into an error
+ // (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;
+ try {
+ 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,
+ });
+ }
+ } catch (error) {
+ // A throw that escaped the settle logic (the record write itself,
+ // the project lookup). An orphan that failed leaves the prior
+ // record standing; a settle that failed answers as a failed draft.
+ if (route === "spec") {
+ specHandoff = {
+ status: "failed",
+ repository: null,
+ issueUrl: null,
+ issueNumber: null,
+ reason: error instanceof Error ? error.message : "handoff_failed",
+ at: new Date().toISOString(),
+ orphanedAt: null,
+ };
+ }
+ }
+
return Response.json({
threadId: thread.id,
reviewedAt: thread.reviewedAt ?? null,
@@ -77,6 +120,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..b09dbb4 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,33 @@ 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" ? (
+
+ {handoff.reason === "no_credential"
+ ? "Spec recorded — the repo handoff is not wired up yet, so no issue was drafted."
+ : thread.projectId
+ ? "Spec recorded — this Project has no repository, so the handoff is not live."
+ : "Spec recorded — no Project with a repository is attached, 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) => (
`) 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.
+
+**Batching.** There is none: each settle gesture drafts its own issue
+right then, matching the desk's no-commit-gate posture (ADR 0017) — the
+receipts screen is a reading of what already happened, never a queue
+waiting to flush. The rejected alternative, collecting a Day's spec
+routings into one batched write, would reintroduce exactly the gate the
+prototype cut.
+
+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, spec routing settles as `skipped` with its own reason
+(`no_credential`) — recorded, said plainly on the Thread, and retried by
+a later routing once the token exists. A fabricated success was rejected
+outright: `drafted` is the permanent guard, so pretending would silently
+block the real issue forever. The in-memory drafter exists for tests
+only, never as a production fallback.
diff --git a/lib/desk/file-thread.ts b/lib/desk/file-thread.ts
index 4d5143c..e85598f 100644
--- a/lib/desk/file-thread.ts
+++ b/lib/desk/file-thread.ts
@@ -61,6 +61,14 @@ export async function fileThread(
filing.route === undefined
? undefined
: (asThreadRoute(result.route) ?? filing.route ?? null),
+ // The server settles what spec routing did outside the system — adopt
+ // its record whenever a route was part of this filing and the server
+ // actually answered with the field (ADR 0018); an older server's
+ // silence must not clear a record we already hold.
+ specHandoff:
+ filing.route === undefined || result.specHandoff === undefined
+ ? undefined
+ : (result.specHandoff ?? null),
});
return true;
}
diff --git a/lib/local-capture/transitions.ts b/lib/local-capture/transitions.ts
index 4bd3a7b..6699990 100644
--- a/lib/local-capture/transitions.ts
+++ b/lib/local-capture/transitions.ts
@@ -263,6 +263,10 @@ export function fileThreadTransition(
filing.route === undefined
? (thread.route ?? null)
: filing.route,
+ specHandoff:
+ filing.specHandoff === undefined
+ ? (thread.specHandoff ?? null)
+ : filing.specHandoff,
}
: thread,
);
diff --git a/lib/local-capture/types.ts b/lib/local-capture/types.ts
index 968aa25..ee3852c 100644
--- a/lib/local-capture/types.ts
+++ b/lib/local-capture/types.ts
@@ -146,6 +146,63 @@ export function verdictImpliedByRoute(
return undefined;
}
+/**
+ * What happened when this Thread was routed to Spec (ADR 0018). One record
+ * per Thread, overwritten as attempts settle: `drafted` is the permanent
+ * guard — an issue exists and is never drafted twice — while `skipped`
+ * (Project has no repository) and `failed` (the draft did not land) are
+ * visible states the next spec routing may retry past.
+ */
+export type SpecHandoffStatus = "drafted" | "skipped" | "failed";
+
+export type SpecHandoff = {
+ status: SpecHandoffStatus;
+ /** The `owner/repo` the draft targeted; null when the Project had none. */
+ repository: string | null;
+ issueUrl: string | null;
+ issueNumber: number | null;
+ /** Why the handoff is not live — set on skipped and failed records. */
+ reason: string | null;
+ at: string;
+ /**
+ * Set when the walker re-routed away after the issue was drafted: the
+ * issue stays in the repository (never delete external writes), and this
+ * marks that the Route no longer points at it.
+ */
+ orphanedAt?: string | null;
+};
+
+/**
+ * Narrow a stored value to a handoff record. Server-written JSON, so only
+ * the status is checked — an unknown status reads as no record at all.
+ */
+export function asSpecHandoff(value: unknown): SpecHandoff | null {
+ if (!value || typeof value !== "object") return null;
+ const record = value as SpecHandoff;
+ return record.status === "drafted" ||
+ record.status === "skipped" ||
+ record.status === "failed"
+ ? record
+ : null;
+}
+
+/**
+ * A Project's repository is a plain `owner/repo` — enough to address the
+ * issue drafter without carrying a whole URL through the seams. Dot-only
+ * segments are refused: `owner/..` splices into an API path as traversal.
+ */
+export function asProjectRepository(value: unknown): string | null {
+ if (typeof value !== "string") return null;
+ const trimmed = value.trim();
+ const segments = trimmed.split("/");
+ const wellFormed =
+ segments.length === 2 &&
+ segments.every(
+ (segment) => /^[A-Za-z0-9_.-]+$/.test(segment) && !/^\.+$/.test(segment),
+ );
+ return wellFormed ? trimmed : null;
+}
+
export type LocalThread = {
id: string;
title: string;
@@ -169,6 +226,8 @@ export type LocalThread = {
researchVerdict?: ResearchVerdict | null;
/** Where the walker routed this Thread; absent/null = not yet settled. */
route?: ThreadRoute | null;
+ /** What spec routing did outside the system; absent/null = nothing yet. */
+ specHandoff?: SpecHandoff | null;
};
export type CaptureSyncStatus =
@@ -325,6 +384,8 @@ export type CaptureStore = {
researchVerdict?: ResearchVerdict | null;
/** Omitted keeps the current route; null clears it. */
route?: ThreadRoute | null;
+ /** Omitted keeps the current handoff record; null clears it. */
+ specHandoff?: SpecHandoff | null;
}): Promise;
markSyncing(ids: string[]): Promise;
restoreSavedLocally(ids: string[]): Promise;
@@ -372,6 +433,7 @@ export type CaptureStore = {
projectName?: string | null;
researchVerdict?: ResearchVerdict | null;
route?: ThreadRoute | null;
+ specHandoff?: SpecHandoff | null;
captures: Array<{
id: string;
text: string;
diff --git a/lib/spec/handoff.ts b/lib/spec/handoff.ts
new file mode 100644
index 0000000..8cc76b2
--- /dev/null
+++ b/lib/spec/handoff.ts
@@ -0,0 +1,178 @@
+import type { SpecHandoff } from "@/lib/local-capture/types";
+import type { ServerThread, ThreadRepository } from "@/lib/sync/types";
+import type { IssueDrafter } from "./issue-drafter";
+
+/**
+ * Settling a spec Route's handoff (ADR 0018). Runs after the filing write,
+ * never instead of it: the Route and Reviewed are already committed, and
+ * whatever happens here — drafted, skipped, failed — lands as one record on
+ * the Thread for the receipt to read.
+ */
+
+/** The slice of the Enrichment repository the handoff needs: the reports. */
+export type SpecReportSource = {
+ listThreadEnrichments(
+ userId: string,
+ threadId: string,
+ ): Promise>;
+};
+
+export function specHandoffKey(threadId: string): string {
+ return `spec:${threadId}`;
+}
+
+/**
+ * The issue body: the Enrichment's idea-shaped report — the newest one
+ * judged an idea, falling back to the newest report at all, falling back to
+ * the walker's own words. The key line at the bottom is what the drafter
+ * searches for before ever creating a second issue.
+ */
+export async function specIssueBody(
+ userId: string,
+ thread: ServerThread,
+ reports: SpecReportSource,
+): Promise {
+ const enrichments = await reports.listThreadEnrichments(userId, thread.id);
+ const ideaShaped = [...enrichments]
+ .reverse()
+ .find((enrichment) => enrichment.kind === "idea");
+ const newest = enrichments[enrichments.length - 1];
+ const report =
+ ideaShaped?.text ??
+ newest?.text ??
+ thread.captures.map((capture) => capture.text).join("\n\n");
+ return [
+ report.trim(),
+ "",
+ "---",
+ `Drafted from a Walking Thoughts spec Thread: ${thread.title}`,
+ `Handoff key: ${specHandoffKey(thread.id)}`,
+ ].join("\n");
+}
+
+/**
+ * Route a spec Thread's handoff to its one outcome:
+ *
+ * - already `drafted` → that issue, always; re-routing back clears the
+ * orphan note but never drafts twice.
+ * - Project missing or without a repository → `skipped`, recorded, no
+ * external write; a later routing may retry past it. A *proposed*
+ * Project skips too — only the Projects the walker confirmed count,
+ * because a proposal is not a decision.
+ * - no drafter (no credential provisioned) → `skipped` as well, with its
+ * own reason: recording a fabricated draft would arm the permanent
+ * guard and silently block the real issue forever.
+ * - drafter succeeds → `drafted`, the permanent guard — recorded, and
+ * returned even when the record write itself fails, so the response
+ * never disowns an issue that exists.
+ * - drafter throws → `failed` with the reason, visible on the Thread; the
+ * filing itself has already committed and stays committed.
+ */
+export async function settleSpecHandoff(input: {
+ userId: string;
+ thread: ServerThread;
+ threads: Pick;
+ reports: SpecReportSource;
+ drafter: IssueDrafter | null;
+ now?: string;
+}): Promise {
+ const { userId, thread } = input;
+ const at = input.now ?? new Date().toISOString();
+
+ const prior = thread.specHandoff ?? null;
+ if (prior?.status === "drafted") {
+ if (!prior.orphanedAt) return prior;
+ const restored: SpecHandoff = { ...prior, orphanedAt: null };
+ await input.threads.recordSpecHandoff(userId, thread.id, restored);
+ return restored;
+ }
+
+ const skip = async (
+ reason: string,
+ repository: string | null,
+ ): Promise => {
+ const skipped: SpecHandoff = {
+ status: "skipped",
+ repository,
+ issueUrl: null,
+ issueNumber: null,
+ reason,
+ at,
+ orphanedAt: null,
+ };
+ await input.threads.recordSpecHandoff(userId, thread.id, skipped);
+ return skipped;
+ };
+
+ const project = thread.projectId
+ ? (await input.threads.listProjects(userId)).find(
+ (candidate) => candidate.id === thread.projectId,
+ )
+ : undefined;
+ const repository = project?.repository ?? null;
+
+ if (!repository) return skip("no_repository", null);
+ if (!input.drafter) return skip("no_credential", repository);
+
+ let drafted;
+ try {
+ const body = await specIssueBody(userId, thread, input.reports);
+ drafted = await input.drafter.draftIssue({
+ repository,
+ title: thread.title,
+ body,
+ idempotencyKey: specHandoffKey(thread.id),
+ });
+ } catch (error) {
+ const failed: SpecHandoff = {
+ status: "failed",
+ repository,
+ issueUrl: null,
+ issueNumber: null,
+ reason: error instanceof Error ? error.message : "draft_failed",
+ at,
+ orphanedAt: null,
+ };
+ await input.threads.recordSpecHandoff(userId, thread.id, failed);
+ return failed;
+ }
+
+ const record: SpecHandoff = {
+ status: "drafted",
+ repository: drafted.repository,
+ issueUrl: drafted.url,
+ issueNumber: drafted.number,
+ reason: null,
+ at,
+ orphanedAt: null,
+ };
+ try {
+ await input.threads.recordSpecHandoff(userId, thread.id, record);
+ } catch {
+ // The issue exists; the record write missed. Return the draft anyway —
+ // the drafter's own key check repairs the record on the next routing
+ // rather than ever drafting a second issue.
+ }
+ return record;
+}
+
+/**
+ * Un-routing once an issue exists (ADR 0018): the issue stays — external
+ * writes are never deleted — and the record notes it was orphaned so the
+ * receipt can still name what exists. No-op for anything not drafted.
+ */
+export async function orphanSpecHandoff(input: {
+ userId: string;
+ thread: ServerThread;
+ threads: Pick;
+ now?: string;
+}): Promise {
+ const prior = input.thread.specHandoff ?? null;
+ if (prior?.status !== "drafted" || prior.orphanedAt) return prior;
+ const orphaned: SpecHandoff = {
+ ...prior,
+ orphanedAt: input.now ?? new Date().toISOString(),
+ };
+ await input.threads.recordSpecHandoff(input.userId, input.thread.id, orphaned);
+ return orphaned;
+}
diff --git a/lib/spec/issue-drafter.ts b/lib/spec/issue-drafter.ts
new file mode 100644
index 0000000..04eb4ed
--- /dev/null
+++ b/lib/spec/issue-drafter.ts
@@ -0,0 +1,182 @@
+/**
+ * The seam through which spec routing leaves the system (ADR 0018): one
+ * ticket-shaped issue drafted in a Project's repository. Callers guard
+ * idempotency with the Thread's handoff record; the drafter's own key check
+ * covers the crash window where the issue landed but the record write did
+ * not, so the same key can never draft twice even across that gap.
+ */
+
+export type IssueDraft = {
+ /** `owner/repo` the issue is drafted into. */
+ repository: string;
+ title: string;
+ body: string;
+ /**
+ * Stable per-Thread key (`spec:`). It travels in the issue body
+ * so an implementation can find an existing draft before creating one.
+ */
+ idempotencyKey: string;
+};
+
+export type DraftedIssue = {
+ repository: string;
+ url: string;
+ number: number;
+ /** True when the key had already drafted this issue — nothing new landed. */
+ duplicate: boolean;
+};
+
+export type IssueDrafter = {
+ draftIssue(draft: IssueDraft): Promise;
+};
+
+type MemoryDrafterState = Map;
+
+const memoryStates = new Map();
+
+function memoryState(namespace: string): MemoryDrafterState {
+ const existing = memoryStates.get(namespace);
+ if (existing) return existing;
+ const created: MemoryDrafterState = new Map();
+ memoryStates.set(namespace, created);
+ return created;
+}
+
+export function resetMemoryIssueDrafter(namespace = "default"): void {
+ memoryStates.set(namespace, new Map());
+}
+
+/** Every issue the memory drafter holds — the tests' receipts screen. */
+export function listMemoryDraftedIssues(
+ namespace = "default",
+): Array {
+ return [...memoryState(namespace).values()];
+}
+
+/**
+ * The in-memory drafter: keyed by idempotency key, so drafting twice
+ * returns the first issue with `duplicate: true`. Backs tests and any
+ * environment without a GitHub credential.
+ */
+export function createMemoryIssueDrafter(namespace = "default"): IssueDrafter {
+ return {
+ async draftIssue(draft) {
+ const issues = memoryState(namespace);
+ const existing = issues.get(draft.idempotencyKey);
+ if (existing) return { ...existing, duplicate: true };
+ const created = {
+ repository: draft.repository,
+ url: `https://github.com/${draft.repository}/issues/${issues.size + 1}`,
+ number: issues.size + 1,
+ duplicate: false,
+ title: draft.title,
+ body: draft.body,
+ };
+ issues.set(draft.idempotencyKey, created);
+ return {
+ repository: created.repository,
+ url: created.url,
+ number: created.number,
+ duplicate: false,
+ };
+ },
+ };
+}
+
+/**
+ * The GitHub drafter, REST only. Before creating it lists the repo's
+ * issues and looks for the idempotency key in a body — the issues listing
+ * rather than the Search API, whose asynchronous indexing would miss an
+ * issue drafted seconds ago, which is exactly the half-recorded crash
+ * window this check exists to close.
+ */
+export function createGitHubIssueDrafter(options: {
+ token: string;
+ fetchImpl?: typeof fetch;
+ apiBase?: string;
+}): IssueDrafter {
+ const doFetch = options.fetchImpl ?? fetch;
+ const apiBase = options.apiBase ?? "https://api.github.com";
+ const headers = {
+ accept: "application/vnd.github+json",
+ authorization: `Bearer ${options.token}`,
+ "content-type": "application/json",
+ };
+
+ return {
+ async draftIssue(draft) {
+ const listResponse = await doFetch(
+ `${apiBase}/repos/${draft.repository}/issues?state=all&sort=created&direction=desc&per_page=100`,
+ { headers },
+ );
+ if (listResponse.ok) {
+ const issues = (await listResponse.json()) as Array<{
+ html_url: string;
+ number: number;
+ body?: string | null;
+ pull_request?: unknown;
+ }>;
+ // The listing returns PRs too; only a real issue whose body carries
+ // the exact key counts as the prior draft.
+ const prior = issues.find(
+ (issue) =>
+ !issue.pull_request &&
+ (issue.body ?? "").includes(draft.idempotencyKey),
+ );
+ if (prior) {
+ return {
+ repository: draft.repository,
+ url: prior.html_url,
+ number: prior.number,
+ duplicate: true,
+ };
+ }
+ }
+ // A failed listing is not a license to draft blind — the caller's
+ // record is the primary guard, and this path only runs when that
+ // record says nothing was drafted.
+
+ const createResponse = await doFetch(
+ `${apiBase}/repos/${draft.repository}/issues`,
+ {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ title: draft.title, body: draft.body }),
+ },
+ );
+ if (!createResponse.ok) {
+ throw new Error(`github_${createResponse.status}`);
+ }
+ const created = (await createResponse.json()) as {
+ html_url: string;
+ number: number;
+ };
+ return {
+ repository: draft.repository,
+ url: created.html_url,
+ number: created.number,
+ duplicate: false,
+ };
+ },
+ };
+}
+
+/**
+ * The drafter production code asks for — or null when the handoff cannot
+ * be live. TODO: no server-side GitHub credential is provisioned yet — add
+ * SPEC_HANDOFF_GITHUB_TOKEN to fnox and the Vercel environments to make
+ * the handoff real. Null must never fall back to the memory drafter: a
+ * fabricated `drafted` record is the permanent idempotency guard, and it
+ * would silently block the real issue forever (ADR 0018) — the settle
+ * logic records `skipped`/`no_credential` instead, which a later routing
+ * retries past once the token exists.
+ */
+export function getIssueDrafter(
+ environment: NodeJS.ProcessEnv = process.env,
+): IssueDrafter | null {
+ const token = environment.SPEC_HANDOFF_GITHUB_TOKEN;
+ if (token) {
+ return createGitHubIssueDrafter({ token });
+ }
+ return null;
+}
diff --git a/lib/sync/hydrate.ts b/lib/sync/hydrate.ts
index a7c86f0..0f8477f 100644
Binary files a/lib/sync/hydrate.ts and b/lib/sync/hydrate.ts differ
diff --git a/lib/sync/memory-repository.ts b/lib/sync/memory-repository.ts
index 9e32dce..64195b8 100644
--- a/lib/sync/memory-repository.ts
+++ b/lib/sync/memory-repository.ts
@@ -1,6 +1,6 @@
import { titleFromText } from "@/lib/local-capture/thread-destination";
import { asThreadKind } from "@/lib/local-capture/types";
-import type { ThreadRoute } from "@/lib/local-capture/types";
+import type { SpecHandoff, ThreadRoute } from "@/lib/local-capture/types";
import { expiresAtFrom, isExpired } from "./trash";
import type {
ProjectProposal,
@@ -33,6 +33,7 @@ type StoredThread = {
projectId?: string | null;
researchVerdict?: "kept" | "dismissed" | null;
route?: ThreadRoute | null;
+ specHandoff?: SpecHandoff | null;
};
type StoredProject = {
@@ -41,6 +42,7 @@ type StoredProject = {
name: string;
state: ProjectState;
createdAt: string;
+ repository?: string | null;
};
type MemoryState = {
@@ -240,6 +242,7 @@ export function createMemoryThreadRepository(
userId: string,
name: string,
initial: ProjectState,
+ repository?: string | null,
) => {
const db = state();
const trimmed = name.trim();
@@ -248,11 +251,16 @@ export function createMemoryThreadRepository(
(project) => project.userId === userId && project.name === trimmed,
);
if (existing) {
+ // A repository named on an existing Project sets it; omitted keeps.
+ if (repository !== undefined) {
+ existing.repository = repository;
+ }
return {
id: existing.id,
name: existing.name,
state: existing.state,
createdAt: existing.createdAt,
+ repository: existing.repository ?? null,
};
}
const project: StoredProject = {
@@ -261,6 +269,7 @@ export function createMemoryThreadRepository(
name: trimmed,
state: initial,
createdAt: new Date().toISOString(),
+ repository: repository ?? null,
};
db.projects.set(`${userId}:${project.id}`, project);
return {
@@ -268,6 +277,7 @@ export function createMemoryThreadRepository(
name: project.name,
state: project.state,
createdAt: project.createdAt,
+ repository: project.repository ?? null,
};
};
@@ -399,6 +409,7 @@ export function createMemoryThreadRepository(
: null,
researchVerdict: thread.researchVerdict ?? null,
route: thread.route ?? null,
+ specHandoff: thread.specHandoff ?? null,
captures,
} satisfies ServerThread;
})
@@ -528,17 +539,18 @@ export function createMemoryThreadRepository(
(project) =>
project.userId === userId && project.state === "confirmed",
)
- .map(({ id, name, state: projectState, createdAt }) => ({
+ .map(({ id, name, state: projectState, createdAt, repository }) => ({
id,
name,
state: projectState,
createdAt,
+ repository: repository ?? null,
}))
.sort((a, b) => (a.name < b.name ? -1 : 1));
},
- async createProject(userId, name) {
- return upsertProject(userId, name, "confirmed");
+ async createProject(userId, name, options) {
+ return upsertProject(userId, name, "confirmed", options?.repository);
},
async proposeProject(userId, name) {
@@ -662,6 +674,14 @@ export function createMemoryThreadRepository(
return existing?.researchVerdict ?? null;
},
+ async recordSpecHandoff(userId, threadId, handoff) {
+ const db = state();
+ const key = `${userId}:${threadId}`;
+ const existing = db.threads.get(key);
+ if (!existing) throw new Error("thread_not_found");
+ db.threads.set(key, { ...existing, specHandoff: handoff });
+ },
+
async setThreadReviewed(userId, threadId, reviewedAt) {
const db = state();
const key = `${userId}:${threadId}`;
diff --git a/lib/sync/neon-repository.ts b/lib/sync/neon-repository.ts
index c6dae67..b627395 100644
--- a/lib/sync/neon-repository.ts
+++ b/lib/sync/neon-repository.ts
@@ -2,6 +2,7 @@ import { neon } from "@neondatabase/serverless";
import { titleFromText } from "@/lib/local-capture/thread-destination";
import {
asResearchVerdict,
+ asSpecHandoff,
asThreadKind,
asThreadRoute,
} from "@/lib/local-capture/types";
@@ -28,6 +29,7 @@ type ProjectRow = {
name: string;
state: ProjectState;
created_at: string;
+ repository?: string | null;
};
function mapProject(row: ProjectRow): Project {
@@ -36,6 +38,7 @@ function mapProject(row: ProjectRow): Project {
name: row.name,
state: row.state,
createdAt: row.created_at,
+ repository: row.repository ?? null,
};
}
@@ -78,6 +81,10 @@ export function createNeonThreadRepository(databaseUrl: string): ThreadRepositor
ALTER TABLE sync_threads
ADD COLUMN IF NOT EXISTS route TEXT
`;
+ await sql`
+ ALTER TABLE sync_threads
+ ADD COLUMN IF NOT EXISTS spec_handoff JSONB
+ `;
await sql`
CREATE TABLE IF NOT EXISTS sync_projects (
id TEXT PRIMARY KEY,
@@ -97,6 +104,10 @@ export function createNeonThreadRepository(databaseUrl: string): ThreadRepositor
ALTER TABLE sync_projects
ADD COLUMN IF NOT EXISTS state TEXT NOT NULL DEFAULT 'confirmed'
`;
+ await sql`
+ ALTER TABLE sync_projects
+ ADD COLUMN IF NOT EXISTS repository TEXT
+ `;
await sql`
CREATE TABLE IF NOT EXISTS sync_captures (
id TEXT PRIMARY KEY,
@@ -299,15 +310,23 @@ export function createNeonThreadRepository(databaseUrl: string): ThreadRepositor
userId: string,
name: string,
initial: ProjectState,
+ repository?: string | null,
): Promise {
await ensure();
const trimmed = name.trim();
if (!trimmed) throw new Error("project_name_required");
+ // A repository named on an existing Project sets it; omitted keeps
+ // whatever the row already has.
const rows = (await sql`
- INSERT INTO sync_projects (id, user_id, name, state, created_at)
- VALUES (${crypto.randomUUID()}, ${userId}, ${trimmed}, ${initial}, ${new Date().toISOString()})
- ON CONFLICT (user_id, name) DO UPDATE SET name = EXCLUDED.name
- RETURNING id, name, state, created_at
+ INSERT INTO sync_projects (id, user_id, name, state, created_at, repository)
+ VALUES (${crypto.randomUUID()}, ${userId}, ${trimmed}, ${initial}, ${new Date().toISOString()}, ${repository ?? null})
+ ON CONFLICT (user_id, name) DO UPDATE SET
+ name = EXCLUDED.name,
+ repository = CASE
+ WHEN ${repository === undefined} THEN sync_projects.repository
+ ELSE EXCLUDED.repository
+ END
+ RETURNING id, name, state, created_at, repository
`) as Array;
return mapProject(rows[0]);
}
@@ -391,7 +410,7 @@ export function createNeonThreadRepository(databaseUrl: string): ThreadRepositor
const threads = (await sql`
SELECT t.id, t.title, t.revision, t.updated_at, t.reviewed_at, t.kind,
t.topics, t.ask, t.project_id, t.research_verdict, t.route,
- p.name AS project_name
+ t.spec_handoff, p.name AS project_name
FROM sync_threads t
LEFT JOIN sync_projects p ON p.id = t.project_id AND p.user_id = t.user_id
WHERE t.user_id = ${userId}
@@ -414,6 +433,7 @@ export function createNeonThreadRepository(databaseUrl: string): ThreadRepositor
project_id: string | null;
research_verdict: string | null;
route: string | null;
+ spec_handoff: unknown;
project_name: string | null;
}>;
@@ -452,6 +472,7 @@ export function createNeonThreadRepository(databaseUrl: string): ThreadRepositor
projectName: thread.project_name ?? null,
researchVerdict: asResearchVerdict(thread.research_verdict),
route: asThreadRoute(thread.route),
+ specHandoff: asSpecHandoff(thread.spec_handoff),
captures: captures.map((capture) => ({
id: capture.id,
text: capture.text,
@@ -477,15 +498,15 @@ export function createNeonThreadRepository(databaseUrl: string): ThreadRepositor
async listProjects(userId) {
await ensure();
const rows = (await sql`
- SELECT id, name, state, created_at FROM sync_projects
+ SELECT id, name, state, created_at, repository FROM sync_projects
WHERE user_id = ${userId} AND state = 'confirmed'
ORDER BY name ASC
`) as Array;
return rows.map(mapProject);
},
- async createProject(userId, name) {
- return upsertProject(userId, name, "confirmed");
+ async createProject(userId, name, options) {
+ return upsertProject(userId, name, "confirmed", options?.repository);
},
async proposeProject(userId, name) {
@@ -625,6 +646,17 @@ export function createNeonThreadRepository(databaseUrl: string): ThreadRepositor
return asResearchVerdict(rows[0]?.research_verdict);
},
+ async recordSpecHandoff(userId, threadId, handoff) {
+ await ensure();
+ const updated = (await sql`
+ UPDATE sync_threads
+ SET spec_handoff = ${handoff ? JSON.stringify(handoff) : null}
+ WHERE user_id = ${userId} AND id = ${threadId}
+ RETURNING id
+ `) as Array<{ id: string }>;
+ if (!updated[0]) throw new Error("thread_not_found");
+ },
+
async setThreadReviewed(userId, threadId, reviewedAt) {
await ensure();
const updated = (await sql`
diff --git a/lib/sync/review-client.ts b/lib/sync/review-client.ts
index bcd4abe..3b286c0 100644
--- a/lib/sync/review-client.ts
+++ b/lib/sync/review-client.ts
@@ -1,3 +1,4 @@
+import type { SpecHandoff } from "@/lib/local-capture/types";
import { trackedFetch } from "@/lib/sync/session-state";
import type { Project } from "./types";
@@ -20,6 +21,8 @@ export type FiledThread = {
projectName?: string | null;
researchVerdict?: "kept" | "dismissed" | null;
route?: string | null;
+ /** What routing to Spec did outside the system (ADR 0018). */
+ specHandoff?: SpecHandoff | null;
};
export type ReviewTransport = {
@@ -32,7 +35,10 @@ export type ReviewTransport = {
filing: ThreadFiling,
): Promise;
listProjects?(): Promise;
- createProject?(name: string): Promise;
+ createProject?(
+ name: string,
+ repository?: string | null,
+ ): Promise;
};
type ReviewGlobals = typeof globalThis & {
@@ -96,12 +102,14 @@ function defaultTransport(): ReviewTransport {
}
},
- async createProject(name) {
+ async createProject(name, repository) {
try {
const response = await trackedFetch("/api/sync/projects", {
method: "POST",
headers: headers(),
- body: JSON.stringify({ name }),
+ body: JSON.stringify(
+ repository === undefined ? { name } : { name, repository },
+ ),
});
if (!response.ok) return null;
const body = (await response.json()) as { project?: Project };
diff --git a/lib/sync/types.ts b/lib/sync/types.ts
index 943f740..1023c38 100644
--- a/lib/sync/types.ts
+++ b/lib/sync/types.ts
@@ -1,10 +1,13 @@
import type {
CaptureLocation,
MediaKind,
+ SpecHandoff,
ThreadKind,
ThreadRoute,
} from "@/lib/local-capture/types";
+export type { SpecHandoff } from "@/lib/local-capture/types";
+
export type SyncCaptureStatus =
| "saved_locally"
| "syncing"
@@ -63,6 +66,12 @@ export type Project = {
name: string;
state: ProjectState;
createdAt: string;
+ /**
+ * The `owner/repo` a spec Thread routed here drafts its issue into
+ * (docs/desk.md, D3). Optional: a Project without one records spec
+ * routings but makes no external write.
+ */
+ repository?: string | null;
};
/** A Proposed Project with the Threads that have accrued to it. */
@@ -98,6 +107,8 @@ export type ServerThread = {
researchVerdict?: "kept" | "dismissed" | null;
/** Where the walker routed this Thread (ADR 0017); null = not settled. */
route?: ThreadRoute | null;
+ /** What spec routing did outside the system (ADR 0018); null = nothing. */
+ specHandoff?: SpecHandoff | null;
captures: Array<{
id: string;
text: string;
@@ -221,13 +232,30 @@ export type ThreadRepository = {
userId: string,
threadId: string,
): Promise<"kept" | "dismissed" | null>;
+ /**
+ * Overwrite the Thread's spec handoff record (ADR 0018). One record per
+ * Thread — `drafted` is the idempotency guard the settle logic reads.
+ */
+ recordSpecHandoff(
+ userId: string,
+ threadId: string,
+ handoff: SpecHandoff | null,
+ ): Promise;
/**
* The walker's Projects — `confirmed` only. Desk surfaces call this one;
* a Proposed Project must never read as a decision the walker made.
*/
listProjects(userId: string): Promise;
- /** Idempotent by name — filing the same new Project twice makes one. */
- createProject(userId: string, name: string): Promise;
+ /**
+ * Idempotent by name — filing the same new Project twice makes one. A
+ * repository passed on an existing name sets it (the seam by which a
+ * Project gains its repo); omitted leaves whatever it already has.
+ */
+ createProject(
+ userId: string,
+ name: string,
+ options?: { repository?: string | null },
+ ): Promise;
/** Proposed Projects with the Threads that have accrued to them. */
listProposedProjects(userId: string): Promise;
/** Names the walker has already rejected, so the model stops proposing them. */
diff --git a/tests/spec-handoff.spec.ts b/tests/spec-handoff.spec.ts
new file mode 100644
index 0000000..1a78721
--- /dev/null
+++ b/tests/spec-handoff.spec.ts
@@ -0,0 +1,593 @@
+import { expect, test } from "@playwright/test";
+import { asProjectRepository } from "@/lib/local-capture/types";
+import {
+ orphanSpecHandoff,
+ settleSpecHandoff,
+ specHandoffKey,
+} from "@/lib/spec/handoff";
+import type { SpecReportSource } from "@/lib/spec/handoff";
+import {
+ createGitHubIssueDrafter,
+ createMemoryIssueDrafter,
+ getIssueDrafter,
+ listMemoryDraftedIssues,
+ resetMemoryIssueDrafter,
+} from "@/lib/spec/issue-drafter";
+import type { IssueDrafter } from "@/lib/spec/issue-drafter";
+import { mergeRemoteThreads } from "@/lib/sync/hydrate";
+import {
+ createMemoryThreadRepository,
+ resetMemoryThreadRepository,
+} from "@/lib/sync/memory-repository";
+
+const NS = "spec-handoff-tests";
+
+test.beforeEach(() => {
+ resetMemoryThreadRepository(NS);
+ resetMemoryIssueDrafter(NS);
+});
+
+async function seedThread(
+ threads: ReturnType,
+ id: string,
+ text: string,
+) {
+ await threads.upsertCaptures("user_a", [
+ {
+ id,
+ text,
+ createdAt: "2026-08-08T07:00:00.000Z",
+ location: null,
+ threadId: null,
+ sequence: 1,
+ idempotencyKey: id,
+ attachments: [],
+ },
+ ]);
+ return id;
+}
+
+function reportsWith(
+ entries: Array<{ text: string; kind?: string | null; createdAt: string }>,
+): SpecReportSource {
+ return { listThreadEnrichments: async () => entries };
+}
+
+const NO_REPORTS = reportsWith([]);
+
+async function threadById(
+ threads: ReturnType,
+ id: string,
+) {
+ const listed = await threads.listThreads("user_a");
+ const found = listed.find((thread) => thread.id === id);
+ if (!found) throw new Error(`thread ${id} missing`);
+ return found;
+}
+
+test("a Project carries a repository, and creating again by name keeps it", async () => {
+ const threads = createMemoryThreadRepository(NS);
+ const created = await threads.createProject("user_a", "Umwelten", {
+ repository: "the-focus-ai/umwelten",
+ });
+ expect(created.repository).toBe("the-focus-ai/umwelten");
+
+ // Filing the same name with no repository keeps the one it has.
+ const again = await threads.createProject("user_a", "Umwelten");
+ expect(again.id).toBe(created.id);
+ expect(again.repository).toBe("the-focus-ai/umwelten");
+
+ // Naming a repository on an existing Project is the seam that sets it.
+ const bare = await threads.createProject("user_a", "Habitats");
+ expect(bare.repository).toBeNull();
+ const upgraded = await threads.createProject("user_a", "Habitats", {
+ repository: "the-focus-ai/habitats",
+ });
+ expect(upgraded.id).toBe(bare.id);
+ expect(upgraded.repository).toBe("the-focus-ai/habitats");
+
+ const listed = await threads.listProjects("user_a");
+ expect(
+ listed.map((project) => [project.name, project.repository]),
+ ).toEqual([
+ ["Habitats", "the-focus-ai/habitats"],
+ ["Umwelten", "the-focus-ai/umwelten"],
+ ]);
+});
+
+test("a repository is a plain owner/repo, nothing else", () => {
+ expect(asProjectRepository("the-focus-ai/walking-thoughts")).toBe(
+ "the-focus-ai/walking-thoughts",
+ );
+ expect(asProjectRepository(" owner/repo ")).toBe("owner/repo");
+ expect(asProjectRepository("https://github.com/owner/repo")).toBeNull();
+ expect(asProjectRepository("owner")).toBeNull();
+ expect(asProjectRepository("owner/repo/extra")).toBeNull();
+ expect(asProjectRepository("owner /repo")).toBeNull();
+ expect(asProjectRepository(42)).toBeNull();
+});
+
+test("routing a spec Thread to a Project with a repository drafts the issue there", async () => {
+ const threads = createMemoryThreadRepository(NS);
+ const drafter = createMemoryIssueDrafter(NS);
+ const project = await threads.createProject("user_a", "Umwelten", {
+ repository: "the-focus-ai/umwelten",
+ });
+ const id = await seedThread(threads, "t-spec", "Build the umwelten reader");
+ await threads.fileThread("user_a", id, {
+ projectId: project.id,
+ reviewedAt: "2026-08-08T09:00:00.000Z",
+ route: "spec",
+ });
+
+ const handoff = await settleSpecHandoff({
+ userId: "user_a",
+ thread: await threadById(threads, id),
+ threads,
+ reports: reportsWith([
+ {
+ text: "An idea-shaped report on the reader.",
+ kind: "idea",
+ createdAt: "2026-08-08T08:00:00.000Z",
+ },
+ ]),
+ drafter,
+ now: "2026-08-08T09:00:01.000Z",
+ });
+
+ expect(handoff.status).toBe("drafted");
+ expect(handoff.repository).toBe("the-focus-ai/umwelten");
+ expect(handoff.issueUrl).toContain("the-focus-ai/umwelten/issues/");
+
+ const issues = listMemoryDraftedIssues(NS);
+ expect(issues).toHaveLength(1);
+ // Title from the Thread, body from the report, key for the dedup search.
+ expect(issues[0].title).toBe("Build the umwelten reader");
+ expect(issues[0].body).toContain("An idea-shaped report on the reader.");
+ expect(issues[0].body).toContain(specHandoffKey(id));
+
+ // The record round-trips on the Thread for receipts and other devices.
+ const stored = await threadById(threads, id);
+ expect(stored.specHandoff?.status).toBe("drafted");
+ expect(stored.specHandoff?.issueUrl).toBe(handoff.issueUrl);
+});
+
+test("the body prefers the newest idea-shaped report over a newer other one", async () => {
+ const threads = createMemoryThreadRepository(NS);
+ const project = await threads.createProject("user_a", "Umwelten", {
+ repository: "the-focus-ai/umwelten",
+ });
+ const id = await seedThread(threads, "t-body", "Reader idea");
+ await threads.fileThread("user_a", id, {
+ projectId: project.id,
+ reviewedAt: "2026-08-08T09:00:00.000Z",
+ route: "spec",
+ });
+
+ await settleSpecHandoff({
+ userId: "user_a",
+ thread: await threadById(threads, id),
+ threads,
+ reports: reportsWith([
+ { text: "Older idea report.", kind: "idea", createdAt: "1" },
+ { text: "Newer idea report.", kind: "idea", createdAt: "2" },
+ { text: "Newest, but a question.", kind: "question", createdAt: "3" },
+ ]),
+ drafter: createMemoryIssueDrafter(NS),
+ });
+
+ const [issue] = listMemoryDraftedIssues(NS);
+ expect(issue.body).toContain("Newer idea report.");
+ expect(issue.body).not.toContain("Older idea report.");
+ expect(issue.body).not.toContain("Newest, but a question.");
+});
+
+test("without any report the body falls back to the walker's own words", async () => {
+ const threads = createMemoryThreadRepository(NS);
+ const project = await threads.createProject("user_a", "Umwelten", {
+ repository: "the-focus-ai/umwelten",
+ });
+ const id = await seedThread(threads, "t-plain", "Just the capture text");
+ await threads.fileThread("user_a", id, {
+ projectId: project.id,
+ reviewedAt: "2026-08-08T09:00:00.000Z",
+ route: "spec",
+ });
+
+ await settleSpecHandoff({
+ userId: "user_a",
+ thread: await threadById(threads, id),
+ threads,
+ reports: NO_REPORTS,
+ drafter: createMemoryIssueDrafter(NS),
+ });
+
+ const [issue] = listMemoryDraftedIssues(NS);
+ expect(issue.body).toContain("Just the capture text");
+});
+
+test("routing twice never drafts two issues", async () => {
+ const threads = createMemoryThreadRepository(NS);
+ const drafter = createMemoryIssueDrafter(NS);
+ const project = await threads.createProject("user_a", "Umwelten", {
+ repository: "the-focus-ai/umwelten",
+ });
+ const id = await seedThread(threads, "t-idem", "One idea, one issue");
+ await threads.fileThread("user_a", id, {
+ projectId: project.id,
+ reviewedAt: "2026-08-08T09:00:00.000Z",
+ route: "spec",
+ });
+
+ const first = await settleSpecHandoff({
+ userId: "user_a",
+ thread: await threadById(threads, id),
+ threads,
+ reports: NO_REPORTS,
+ drafter,
+ });
+ const second = await settleSpecHandoff({
+ userId: "user_a",
+ thread: await threadById(threads, id),
+ threads,
+ reports: NO_REPORTS,
+ drafter,
+ });
+
+ expect(second.status).toBe("drafted");
+ expect(second.issueUrl).toBe(first.issueUrl);
+ expect(listMemoryDraftedIssues(NS)).toHaveLength(1);
+});
+
+test("un-routing orphans the record but the issue stays; routing back reuses it", async () => {
+ const threads = createMemoryThreadRepository(NS);
+ const drafter = createMemoryIssueDrafter(NS);
+ const project = await threads.createProject("user_a", "Umwelten", {
+ repository: "the-focus-ai/umwelten",
+ });
+ const id = await seedThread(threads, "t-orphan", "Route, regret, re-route");
+ await threads.fileThread("user_a", id, {
+ projectId: project.id,
+ reviewedAt: "2026-08-08T09:00:00.000Z",
+ route: "spec",
+ });
+ const drafted = await settleSpecHandoff({
+ userId: "user_a",
+ thread: await threadById(threads, id),
+ threads,
+ reports: NO_REPORTS,
+ drafter,
+ });
+
+ // The walker re-routes to Journal: no external delete, an orphan note.
+ await threads.fileThread("user_a", id, {
+ reviewedAt: "2026-08-08T09:05:00.000Z",
+ route: "journal",
+ });
+ const orphaned = await orphanSpecHandoff({
+ userId: "user_a",
+ thread: await threadById(threads, id),
+ threads,
+ now: "2026-08-08T09:05:01.000Z",
+ });
+ expect(orphaned?.status).toBe("drafted");
+ expect(orphaned?.orphanedAt).toBe("2026-08-08T09:05:01.000Z");
+ expect(orphaned?.issueUrl).toBe(drafted.issueUrl);
+ expect(listMemoryDraftedIssues(NS)).toHaveLength(1);
+
+ // Routing back to Spec clears the orphan note and never drafts again.
+ await threads.fileThread("user_a", id, {
+ reviewedAt: "2026-08-08T09:10:00.000Z",
+ route: "spec",
+ });
+ const restored = await settleSpecHandoff({
+ userId: "user_a",
+ thread: await threadById(threads, id),
+ threads,
+ reports: NO_REPORTS,
+ drafter,
+ });
+ expect(restored.status).toBe("drafted");
+ expect(restored.orphanedAt).toBeNull();
+ expect(restored.issueUrl).toBe(drafted.issueUrl);
+ expect(listMemoryDraftedIssues(NS)).toHaveLength(1);
+
+ // Orphaning something that was never drafted is a no-op.
+ const other = await seedThread(threads, "t-never", "Never drafted");
+ const untouched = await orphanSpecHandoff({
+ userId: "user_a",
+ thread: await threadById(threads, other),
+ threads,
+ });
+ expect(untouched).toBeNull();
+});
+
+test("a Project without a repository degrades gracefully and can go live later", async () => {
+ const threads = createMemoryThreadRepository(NS);
+ const drafter = createMemoryIssueDrafter(NS);
+ const project = await threads.createProject("user_a", "Notebook only");
+ const id = await seedThread(threads, "t-skip", "Spec with nowhere to land");
+ await threads.fileThread("user_a", id, {
+ projectId: project.id,
+ reviewedAt: "2026-08-08T09:00:00.000Z",
+ route: "spec",
+ });
+
+ const skipped = await settleSpecHandoff({
+ userId: "user_a",
+ thread: await threadById(threads, id),
+ threads,
+ reports: NO_REPORTS,
+ drafter,
+ now: "2026-08-08T09:00:01.000Z",
+ });
+
+ // Recorded, no external write, and the Thread says the handoff is not live.
+ expect(skipped.status).toBe("skipped");
+ expect(skipped.reason).toBe("no_repository");
+ expect(skipped.issueUrl).toBeNull();
+ expect(listMemoryDraftedIssues(NS)).toHaveLength(0);
+ expect((await threadById(threads, id)).specHandoff?.status).toBe("skipped");
+
+ // The Project gains a repository; the next spec routing drafts for real.
+ await threads.createProject("user_a", "Notebook only", {
+ repository: "the-focus-ai/notebook",
+ });
+ const live = await settleSpecHandoff({
+ userId: "user_a",
+ thread: await threadById(threads, id),
+ threads,
+ reports: NO_REPORTS,
+ drafter,
+ });
+ expect(live.status).toBe("drafted");
+ expect(listMemoryDraftedIssues(NS)).toHaveLength(1);
+});
+
+test("a failed draft is recorded visibly and a retry never duplicates", async () => {
+ const threads = createMemoryThreadRepository(NS);
+ const memoryDrafter = createMemoryIssueDrafter(NS);
+ const project = await threads.createProject("user_a", "Umwelten", {
+ repository: "the-focus-ai/umwelten",
+ });
+ const id = await seedThread(threads, "t-fail", "Draft into a downed repo");
+ await threads.fileThread("user_a", id, {
+ projectId: project.id,
+ reviewedAt: "2026-08-08T09:00:00.000Z",
+ route: "spec",
+ });
+
+ const downDrafter: IssueDrafter = {
+ async draftIssue() {
+ throw new Error("github_502");
+ },
+ };
+ const failed = await settleSpecHandoff({
+ userId: "user_a",
+ thread: await threadById(threads, id),
+ threads,
+ reports: NO_REPORTS,
+ drafter: downDrafter,
+ });
+ expect(failed.status).toBe("failed");
+ expect(failed.reason).toBe("github_502");
+ expect(listMemoryDraftedIssues(NS)).toHaveLength(0);
+ // Surfaced on the Thread, never silently.
+ expect((await threadById(threads, id)).specHandoff?.status).toBe("failed");
+
+ // The outage ends; the retry drafts exactly one issue.
+ const retried = await settleSpecHandoff({
+ userId: "user_a",
+ thread: await threadById(threads, id),
+ threads,
+ reports: NO_REPORTS,
+ drafter: memoryDrafter,
+ });
+ expect(retried.status).toBe("drafted");
+ expect(listMemoryDraftedIssues(NS)).toHaveLength(1);
+
+ const again = await settleSpecHandoff({
+ userId: "user_a",
+ thread: await threadById(threads, id),
+ threads,
+ reports: NO_REPORTS,
+ drafter: memoryDrafter,
+ });
+ expect(again.issueUrl).toBe(retried.issueUrl);
+ expect(listMemoryDraftedIssues(NS)).toHaveLength(1);
+});
+
+test("hydration adopts the handoff record the way it adopts the Route", () => {
+ const specHandoff = {
+ status: "drafted" as const,
+ repository: "the-focus-ai/umwelten",
+ issueUrl: "https://github.com/the-focus-ai/umwelten/issues/7",
+ issueNumber: 7,
+ reason: null,
+ at: "2026-08-08T09:00:01.000Z",
+ orphanedAt: null,
+ };
+ const merged = mergeRemoteThreads({
+ localCaptures: [],
+ localThreads: [
+ {
+ id: "t-hydrate",
+ title: "Reader idea",
+ revision: 1,
+ updatedAt: "2026-08-08T06:40:00.000Z",
+ reviewedAt: null,
+ route: null,
+ specHandoff: null,
+ },
+ ],
+ remoteThreads: [
+ {
+ id: "t-hydrate",
+ title: "Reader idea",
+ revision: 1,
+ updatedAt: "2026-08-08T06:40:00.000Z",
+ reviewedAt: "2026-08-08T09:00:00.000Z",
+ route: "spec",
+ specHandoff,
+ captures: [],
+ },
+ ],
+ });
+ const thread = merged.threads.find((t) => t.id === "t-hydrate");
+ expect(thread?.route).toBe("spec");
+ expect(thread?.specHandoff).toEqual(specHandoff);
+});
+
+test("without a credential the routing skips visibly, then goes live once wired", async () => {
+ const threads = createMemoryThreadRepository(NS);
+ const project = await threads.createProject("user_a", "Umwelten", {
+ repository: "the-focus-ai/umwelten",
+ });
+ const id = await seedThread(threads, "t-nocred", "Spec before the token");
+ await threads.fileThread("user_a", id, {
+ projectId: project.id,
+ reviewedAt: "2026-08-08T09:00:00.000Z",
+ route: "spec",
+ });
+
+ // No credential provisioned: never a fabricated draft — the fake
+ // `drafted` would arm the permanent guard and block the real issue.
+ expect(getIssueDrafter({} as NodeJS.ProcessEnv)).toBeNull();
+ const skipped = await settleSpecHandoff({
+ userId: "user_a",
+ thread: await threadById(threads, id),
+ threads,
+ reports: NO_REPORTS,
+ drafter: null,
+ });
+ expect(skipped.status).toBe("skipped");
+ expect(skipped.reason).toBe("no_credential");
+ expect(listMemoryDraftedIssues(NS)).toHaveLength(0);
+
+ // The token arrives; the next routing drafts for real, exactly once.
+ const live = await settleSpecHandoff({
+ userId: "user_a",
+ thread: await threadById(threads, id),
+ threads,
+ reports: NO_REPORTS,
+ drafter: createMemoryIssueDrafter(NS),
+ });
+ expect(live.status).toBe("drafted");
+ expect(listMemoryDraftedIssues(NS)).toHaveLength(1);
+});
+
+test("a draft that lands but fails to record still answers drafted", async () => {
+ const threads = createMemoryThreadRepository(NS);
+ const drafter = createMemoryIssueDrafter(NS);
+ const project = await threads.createProject("user_a", "Umwelten", {
+ repository: "the-focus-ai/umwelten",
+ });
+ const id = await seedThread(threads, "t-halfway", "Draft lands, record dies");
+ await threads.fileThread("user_a", id, {
+ projectId: project.id,
+ reviewedAt: "2026-08-08T09:00:00.000Z",
+ route: "spec",
+ });
+
+ const flaky = {
+ listProjects: threads.listProjects.bind(threads),
+ recordSpecHandoff: async () => {
+ throw new Error("record_write_lost");
+ },
+ };
+ const handoff = await settleSpecHandoff({
+ userId: "user_a",
+ thread: await threadById(threads, id),
+ threads: flaky,
+ reports: NO_REPORTS,
+ drafter,
+ });
+ // The response never disowns an issue that exists (ADR 0018).
+ expect(handoff.status).toBe("drafted");
+ expect(listMemoryDraftedIssues(NS)).toHaveLength(1);
+});
+
+test("the GitHub drafter finds a prior issue by its key before ever creating", async () => {
+ const calls: Array<{ url: string; method: string }> = [];
+ const priorIssue = {
+ html_url: "https://github.com/the-focus-ai/umwelten/issues/3",
+ number: 3,
+ body: "Report…\n\nHandoff key: spec:t-1",
+ };
+ const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ calls.push({ url, method: init?.method ?? "GET" });
+ if (url.includes("/repos/the-focus-ai/umwelten/issues?state=all")) {
+ return Response.json([
+ // A pull request quoting the key must never be adopted as the draft.
+ { ...priorIssue, number: 99, pull_request: { url: "pr" } },
+ priorIssue,
+ ]);
+ }
+ throw new Error("unexpected fetch");
+ }) as typeof fetch;
+
+ const drafter = createGitHubIssueDrafter({ token: "t", fetchImpl });
+ const drafted = await drafter.draftIssue({
+ repository: "the-focus-ai/umwelten",
+ title: "Reader",
+ body: `Body\n\nHandoff key: spec:t-1`,
+ idempotencyKey: "spec:t-1",
+ });
+
+ expect(drafted.duplicate).toBe(true);
+ expect(drafted.url).toBe(priorIssue.html_url);
+ expect(drafted.number).toBe(3);
+ expect(calls).toHaveLength(1);
+});
+
+test("the GitHub drafter creates when the key is unseen, and surfaces failures", async () => {
+ const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ if (url.includes("issues?state=all")) {
+ return Response.json([
+ { html_url: "u", number: 1, body: "another thread's key" },
+ ]);
+ }
+ if (url.endsWith("/repos/the-focus-ai/umwelten/issues")) {
+ expect(init?.method).toBe("POST");
+ const body = JSON.parse(String(init?.body)) as {
+ title: string;
+ body: string;
+ };
+ expect(body.title).toBe("Reader");
+ return Response.json(
+ {
+ html_url: "https://github.com/the-focus-ai/umwelten/issues/9",
+ number: 9,
+ },
+ { status: 201 },
+ );
+ }
+ throw new Error("unexpected fetch");
+ }) as typeof fetch;
+
+ const drafter = createGitHubIssueDrafter({ token: "t", fetchImpl });
+ const drafted = await drafter.draftIssue({
+ repository: "the-focus-ai/umwelten",
+ title: "Reader",
+ body: "Body",
+ idempotencyKey: "spec:t-2",
+ });
+ expect(drafted.duplicate).toBe(false);
+ expect(drafted.number).toBe(9);
+
+ const failing = createGitHubIssueDrafter({
+ token: "t",
+ fetchImpl: (async () =>
+ new Response("nope", { status: 502 })) as typeof fetch,
+ });
+ await expect(
+ failing.draftIssue({
+ repository: "the-focus-ai/umwelten",
+ title: "Reader",
+ body: "Body",
+ idempotencyKey: "spec:t-3",
+ }),
+ ).rejects.toThrow("github_502");
+});
diff --git a/tests/thread-filing-ui.spec.ts b/tests/thread-filing-ui.spec.ts
index 320722b..4963826 100644
--- a/tests/thread-filing-ui.spec.ts
+++ b/tests/thread-filing-ui.spec.ts
@@ -132,6 +132,93 @@ test("keeping the research files the Thread and reads back settled", async ({
);
});
+/**
+ * Spec routing's receipt (ADR 0018): the drafted issue reads back with its
+ * repo named and a link; a Project without a repository says plainly that
+ * the handoff is not live.
+ */
+test("routing to Spec shows the drafted issue, or that the handoff is not live", async ({
+ page,
+}) => {
+ await page.addInitScript(() => {
+ let repositoryWired = true;
+ (globalThis as typeof globalThis & { __WT_REVIEW_TRANSPORT__?: unknown }).__WT_REVIEW_TRANSPORT__ =
+ {
+ async setReviewed(threadId: string) {
+ return { threadId, reviewedAt: new Date().toISOString() };
+ },
+ async listProjects() {
+ return [];
+ },
+ async fileThread(threadId: string, filing: { route?: string | null }) {
+ // First spec routing drafts; after the walker re-routes to
+ // Journal the record keeps naming the orphaned issue. The
+ // second Thread's Project has no repository — skipped.
+ const specHandoff =
+ filing.route === undefined
+ ? null
+ : repositoryWired
+ ? {
+ status: "drafted",
+ repository: "the-focus-ai/umwelten",
+ issueUrl:
+ "https://github.com/the-focus-ai/umwelten/issues/7",
+ issueNumber: 7,
+ reason: null,
+ at: new Date().toISOString(),
+ orphanedAt: null,
+ }
+ : {
+ status: "skipped",
+ repository: null,
+ issueUrl: null,
+ issueNumber: null,
+ reason: "no_repository",
+ at: new Date().toISOString(),
+ orphanedAt: null,
+ };
+ if (filing.route === "spec") repositoryWired = false;
+ return {
+ threadId,
+ reviewedAt: new Date().toISOString(),
+ kind: null,
+ projectId: null,
+ projectName: null,
+ researchVerdict: null,
+ route: filing.route ?? null,
+ specHandoff,
+ };
+ },
+ };
+ });
+
+ await openCaptureShell(page);
+ await commitCapture(page, "Ship the umwelten reader as its own tool");
+
+ await page.goto("/days");
+ await page.locator(".desk-day-open").first().click();
+ await page.locator(".thread-file-open").first().click();
+ await expect(page.getByTestId("thread-filing")).toBeVisible();
+
+ // Route to Spec: the receipt names the repo and links the drafted issue.
+ await page.getByTestId("file-route-spec").click();
+ await page.locator(".thread-file-open").first().click();
+ const note = page.getByTestId("spec-handoff-note");
+ await expect(note).toContainText("Issue drafted in the-focus-ai/umwelten");
+ await expect(note.locator("a")).toHaveAttribute(
+ "href",
+ "https://github.com/the-focus-ai/umwelten/issues/7",
+ );
+
+ // Route to Spec again (the stub now has no repository): recorded, and the
+ // Thread says the handoff is not live.
+ await page.getByTestId("file-route-spec").click();
+ await page.locator(".thread-file-open").first().click();
+ await expect(page.getByTestId("spec-handoff-note")).toContainText(
+ "the handoff is not live",
+ );
+});
+
/**
* The Route is Filing's primary verb (ADR 0017): settling one gesture both
* reviews the Thread and records where it went; Journal implies the