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) => (