From ab244cbd4e4c2b65583867e48018f9b079f430cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 01:07:43 +0000 Subject: [PATCH 1/2] The To-do destination: routed tasks land on a list the walker can work (#160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit route = "todo" Threads get their real surface (docs/desk.md, D2): /todo lists them as checkable items in the walker's own words — the first Capture's text, never the Enrichment's title. Checking one off is an action on the destination (todoDoneAt on its own seam, /api/sync/todo); it never reopens or re-files the Thread, and it survives reload and hydrates across devices like the review decision does. Un-routing at the desk removes the item by construction — the list is fed by the route, not a copy. The day digest checklist now draws from that day's routed to-dos instead of re-deriving tasks: the client sends them with every ask, the prompt carries them verbatim, and the system instruction makes them the checklist when present. Closes #160 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KPox3By2txjxSy8HUAPVKk --- app/api/digest/route.ts | 31 +++- app/api/sync/todo/route.ts | 42 +++++ app/globals.css | 79 +++++++++ app/todo/page.tsx | 9 + components/daily-digest-panel.tsx | 35 +++- components/desk-workspace.tsx | 7 + components/todo-list.tsx | 130 ++++++++++++++ lib/desk/todo.ts | 76 ++++++++ lib/digest/prompt.ts | 15 +- lib/digest/types.ts | 13 ++ lib/local-capture/store.ts | 14 ++ lib/local-capture/transitions.ts | 10 ++ lib/local-capture/types.ts | 12 ++ lib/sync/hydrate.ts | Bin 6032 -> 6238 bytes lib/sync/memory-repository.ts | 11 ++ lib/sync/neon-repository.ts | 20 ++- lib/sync/review-client.ts | 25 +++ lib/sync/types.ts | 15 ++ tests/todo-destination-ui.spec.ts | 164 +++++++++++++++++ tests/todo-destination.spec.ts | 280 ++++++++++++++++++++++++++++++ 20 files changed, 983 insertions(+), 5 deletions(-) create mode 100644 app/api/sync/todo/route.ts create mode 100644 app/todo/page.tsx create mode 100644 components/todo-list.tsx create mode 100644 lib/desk/todo.ts create mode 100644 tests/todo-destination-ui.spec.ts create mode 100644 tests/todo-destination.spec.ts diff --git a/app/api/digest/route.ts b/app/api/digest/route.ts index fe0a949..0c066fd 100644 --- a/app/api/digest/route.ts +++ b/app/api/digest/route.ts @@ -1,5 +1,9 @@ import { runDayDigest } from "@/lib/digest/run"; -import type { DayChatTurn, DayCorpusEntry } from "@/lib/digest/types"; +import type { + DayChatTurn, + DayCorpusEntry, + DayRoutedTodo, +} from "@/lib/digest/types"; import { requireSyncAccess } from "@/lib/sync/access"; export const dynamic = "force-dynamic"; @@ -11,8 +15,32 @@ type DigestBody = { corpus?: DayCorpusEntry[]; walkerProfile?: string | null; history?: DayChatTurn[]; + routedTodos?: DayRoutedTodo[]; }; +/** + * Keep only well-formed routed to-dos, and keep the field's absence + * distinct from an empty list: absent = the client predates the To-do + * destination (derive as before); [] = the walker routed none that day. + */ +function sanitizeRoutedTodos(routedTodos: unknown): DayRoutedTodo[] | undefined { + if (!Array.isArray(routedTodos)) return undefined; + return routedTodos + .filter( + (todo): todo is DayRoutedTodo => + typeof todo === "object" && + todo !== null && + typeof (todo as DayRoutedTodo).threadId === "string" && + typeof (todo as DayRoutedTodo).text === "string" && + (todo as DayRoutedTodo).text.trim().length > 0, + ) + .map((todo) => ({ + threadId: todo.threadId, + text: todo.text, + done: todo.done === true, + })); +} + /** Most recent turns of the ongoing chat sent back for context. */ const HISTORY_TURN_LIMIT = 24; const HISTORY_TURN_CHARS = 4000; @@ -75,6 +103,7 @@ export async function POST(request: Request) { walkerProfile: typeof body.walkerProfile === "string" ? body.walkerProfile : null, history: sanitizeHistory(body.history), + routedTodos: sanitizeRoutedTodos(body.routedTodos), }, { userId: access.userId }, ); diff --git a/app/api/sync/todo/route.ts b/app/api/sync/todo/route.ts new file mode 100644 index 0000000..ec8d047 --- /dev/null +++ b/app/api/sync/todo/route.ts @@ -0,0 +1,42 @@ +import { requireSyncAccess } from "@/lib/sync/access"; +import { getThreadRepository } from "@/lib/sync/repository"; + +export const dynamic = "force-dynamic"; + +/** + * Check a routed to-do off (or back on) from the To-do list. The walker's + * action on the destination surface — deliberately not the filing seam: + * checking an item done never reopens or re-files the Thread, so this write + * touches todo_done_at and nothing else. + */ +export async function POST(request: Request) { + const access = await requireSyncAccess(request); + if ("error" in access) return access.error; + + let body: { threadId?: string; done?: boolean }; + try { + body = (await request.json()) as typeof body; + } catch { + return Response.json({ error: "invalid_json" }, { status: 400 }); + } + const threadId = body.threadId?.trim(); + if (!threadId) { + return Response.json({ error: "threadId_required" }, { status: 400 }); + } + + const todoDoneAt = body.done === false ? null : new Date().toISOString(); + try { + const result = await getThreadRepository().setThreadTodoDone( + access.userId, + threadId, + todoDoneAt, + ); + if (!result) { + return Response.json({ error: "thread_not_found" }, { status: 404 }); + } + return Response.json(result); + } catch (error) { + const reason = error instanceof Error ? error.message : "todo_failed"; + return Response.json({ error: reason }, { status: 500 }); + } +} diff --git a/app/globals.css b/app/globals.css index c151d4d..b4e10f5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -4619,3 +4619,82 @@ img.media-lightbox-media { color: var(--muted); padding: 0.4rem 0; } + +/* The To-do destination: routed tasks as checkable lines in the walker's + own words. Open items sit above done ones; checking off strikes the line + without moving the Thread anywhere. */ +.todo-sheet header p:not(.eyebrow) { + color: var(--muted); + margin: 0.35rem 0 0; +} + +.todo-items { + list-style: none; + display: grid; + gap: 0.35rem; + margin: 0; + padding: 0; +} + +.todo-item { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.9rem; + padding: 0.55rem 0; + border-bottom: 1px solid var(--line); +} + +.todo-check { + display: flex; + align-items: baseline; + gap: 0.65rem; + cursor: pointer; +} + +.todo-check input { + accent-color: var(--moss); + width: 1.05rem; + height: 1.05rem; + flex-shrink: 0; + transform: translateY(0.15rem); +} + +.todo-text { + overflow-wrap: anywhere; +} + +.todo-item-done .todo-text { + color: var(--muted); + text-decoration: line-through; +} + +.todo-thread-link { + color: var(--muted); + font-family: var(--font-mono); + font-weight: 700; + font-size: 0.62rem; + letter-spacing: 0.08em; + text-transform: uppercase; + text-decoration: none; + white-space: nowrap; +} + +.todo-thread-link:hover { + text-decoration: underline; +} + +.todo-empty, +.todo-tally { + color: var(--muted); + margin: 0; +} + +.todo-tally { + font-family: var(--font-mono); + font-weight: 700; + font-variant-numeric: tabular-nums; + font-size: 0.62rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} diff --git a/app/todo/page.tsx b/app/todo/page.tsx new file mode 100644 index 0000000..9d952ee --- /dev/null +++ b/app/todo/page.tsx @@ -0,0 +1,9 @@ +import { TodoList } from "@/components/todo-list"; + +export const metadata = { + title: "To-do — Walking Thoughts", +}; + +export default function TodoPage() { + return ; +} diff --git a/components/daily-digest-panel.tsx b/components/daily-digest-panel.tsx index cc43914..3d77de9 100644 --- a/components/daily-digest-panel.tsx +++ b/components/daily-digest-panel.tsx @@ -10,9 +10,14 @@ import { type DayChatMessage, } from "@/lib/digest/chat-store"; import { readCachedArtifacts } from "@/lib/artifacts/client"; +import { collectTodos } from "@/lib/desk/todo"; import { collectDayCorpus } from "@/lib/digest/corpus"; import { summarizeDay, type DaySheet } from "@/lib/digest/day-sheet"; -import type { DayCorpusEntry, DayDigestResult } from "@/lib/digest/types"; +import type { + DayCorpusEntry, + DayDigestResult, + DayRoutedTodo, +} from "@/lib/digest/types"; import { readCachedThreadEnrichments } from "@/lib/enrichment/thread-view"; import { fetchWithTimeout, @@ -105,6 +110,28 @@ export async function loadDayCorpus(dayKey: string): Promise { return collectDayCorpus(entries, dayKey); } +/** + * The day's routed to-dos, read locally the way the corpus is. Sent with + * every ask so a checklist question lists what the walker actually routed + * instead of the model re-deriving tasks (docs/desk.md, D2). + */ +export async function loadDayRoutedTodos( + dayKey: string, +): Promise { + const store = getCaptureStore(); + const [captures, threads] = await Promise.all([ + store.list(), + store.listRecentThreads(), + ]); + return collectTodos(threads, captures) + .filter((item) => item.dayKey === dayKey) + .map((item) => ({ + threadId: item.threadId, + text: item.text, + done: Boolean(item.todoDoneAt), + })); +} + type DailyDigestPanelProps = { /** Local calendar day (YYYY-MM-DD) to chat about. */ dayKey: string; @@ -258,7 +285,10 @@ export function DailyDigestPanel({ setMessages([...before, walkerTurn]); setDraft(""); try { - const corpus = await loadDayCorpus(dayKey); + const [corpus, routedTodos] = await Promise.all([ + loadDayCorpus(dayKey), + loadDayRoutedTodos(dayKey), + ]); if (corpus.length === 0) { throw new Error( isToday @@ -276,6 +306,7 @@ export function DailyDigestPanel({ dayHeading, question: trimmed, corpus, + routedTodos, history: before .slice(-HISTORY_TURN_LIMIT) .map(({ role, text }) => ({ role, text })), diff --git a/components/desk-workspace.tsx b/components/desk-workspace.tsx index 5dcc2ed..8bc7c0c 100644 --- a/components/desk-workspace.tsx +++ b/components/desk-workspace.tsx @@ -1568,6 +1568,13 @@ export function DeskWorkspace({ children }: { children?: React.ReactNode }) {
+ + To-do + Interview diff --git a/components/todo-list.tsx b/components/todo-list.tsx new file mode 100644 index 0000000..bacda42 --- /dev/null +++ b/components/todo-list.tsx @@ -0,0 +1,130 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { AppNav } from "@/components/app-nav"; +import { SyncRuntime } from "@/components/sync-runtime"; +import { collectTodos, setTodoDone, type TodoItem } from "@/lib/desk/todo"; +import { formatDayShort } from "@/lib/local-capture/calendar-day"; +import { getCaptureStore } from "@/lib/local-capture/store"; +import { SYNC_CYCLE_EVENT } from "@/lib/sync/cycle"; + +/** + * The To-do destination (docs/desk.md, D2): every Thread the walker routed + * to To-do, as checkable items in their own words. Checking one off is an + * action here, on the destination surface — the Thread stays filed exactly + * as it was routed. Un-routing at the desk removes the item, because the + * list is fed by the route itself, not a copy of it. + */ +async function readTodos(): Promise { + const store = getCaptureStore(); + const [captures, threads] = await Promise.all([ + store.list(), + store.listRecentThreads(), + ]); + return collectTodos(threads, captures); +} + +export function TodoList() { + const [items, setItems] = useState(null); + const [busyId, setBusyId] = useState(null); + const [error, setError] = useState(null); + + // Read on mount, then re-read after each sync cycle so a to-do routed or + // checked off on another device lands here — the live pile, not a snapshot. + useEffect(() => { + let active = true; + const load = async () => { + const todos = await readTodos(); + if (active) setItems(todos); + }; + void load().catch(() => undefined); + const onCycle = () => void load().catch(() => undefined); + window.addEventListener(SYNC_CYCLE_EVENT, onCycle); + return () => { + active = false; + window.removeEventListener(SYNC_CYCLE_EVENT, onCycle); + }; + }, []); + + async function toggle(item: TodoItem) { + if (busyId) return; + setBusyId(item.threadId); + setError(null); + try { + if (!(await setTodoDone(item.threadId, !item.todoDoneAt))) { + setError("Checking off needs a connection."); + return; + } + setItems(await readTodos()); + } finally { + setBusyId(null); + } + } + + const open = items?.filter((item) => !item.todoDoneAt) ?? []; + + return ( +
+ +
+

The task list

+

To-do

+

+ What you told the trail to put on the list — in your words. Checking + one off is done here; the Thread stays filed where you routed it. +

+
+ + {error ? ( +

+ {error} +

+ ) : null} + + {items === null ? ( +

Opening the list…

+ ) : items.length === 0 ? ( +

+ Nothing on the list. Route a Thread to To-do at the desk and it + lands here. +

+ ) : ( +
    + {items.map((item) => ( +
  • + + + {formatDayShort(item.dayKey)} + +
  • + ))} +
+ )} + + {items && items.length > 0 ? ( +

+ {open.length} open · {items.length - open.length} done +

+ ) : null} + + +
+ ); +} diff --git a/lib/desk/todo.ts b/lib/desk/todo.ts new file mode 100644 index 0000000..0aac0d3 --- /dev/null +++ b/lib/desk/todo.ts @@ -0,0 +1,76 @@ +import { dayKeyForThread } from "@/lib/local-capture/calendar-day"; +import { getCaptureStore } from "@/lib/local-capture/store"; +import type { LocalCapture, LocalThread } from "@/lib/local-capture/types"; +import { getReviewTransport } from "@/lib/sync/review-client"; + +/** + * One item on the To-do list: a `route = "todo"` Thread, shown in the + * walker's own words — the first Capture's text, not the Enrichment's title. + */ +export type TodoItem = { + threadId: string; + /** The walker's words: what the first Capture said on the trail. */ + text: string; + /** The day the Thread was walked (matches the day digest's grouping). */ + dayKey: string; + /** When the walker checked it off; null = still open. */ + todoDoneAt: string | null; +}; + +/** + * The To-do destination's pile: every Thread the walker routed to To-do, + * open items first, newest walk first within each half. Un-routing a Thread + * (route cleared or redirected) removes it here by construction — the list + * is fed by the route, not by a copy. + */ +export function collectTodos( + threads: LocalThread[], + captures: LocalCapture[], +): TodoItem[] { + const byThread = new Map(); + for (const capture of captures) { + if (!capture.threadId) continue; + const list = byThread.get(capture.threadId) ?? []; + list.push(capture); + byThread.set(capture.threadId, list); + } + + return threads + .filter((thread) => thread.route === "todo") + .map((thread) => { + const owned = (byThread.get(thread.id) ?? []).sort( + (a, b) => a.sequence - b.sequence, + ); + return { + threadId: thread.id, + text: owned[0]?.text || thread.title, + dayKey: dayKeyForThread(thread, owned), + todoDoneAt: thread.todoDoneAt ?? null, + }; + }) + .sort((a, b) => { + if (Boolean(a.todoDoneAt) !== Boolean(b.todoDoneAt)) { + return a.todoDoneAt ? 1 : -1; + } + return a.dayKey < b.dayKey ? 1 : a.dayKey > b.dayKey ? -1 : 0; + }); +} + +/** + * Check a to-do off (or back on) and settle the local copy from the server's + * answer — the same shape as filing, but on the destination seam: the write + * never touches reviewedAt or route. Returns false when the check-off could + * not reach the server; the caller says so in its own voice. + */ +export async function setTodoDone( + threadId: string, + done: boolean, +): Promise { + const result = await getReviewTransport().setTodoDone?.(threadId, done); + if (!result) return false; + await getCaptureStore().setThreadTodoDone( + threadId, + result.todoDoneAt ?? null, + ); + return true; +} diff --git a/lib/digest/prompt.ts b/lib/digest/prompt.ts index 1576fc9..9c2a48c 100644 --- a/lib/digest/prompt.ts +++ b/lib/digest/prompt.ts @@ -4,7 +4,8 @@ export const DAY_DIGEST_SYSTEM_INSTRUCTION = [ "You are Walking Thoughts, digesting one walker's entire day across every Thread.", "The walker may ask for a checklist, a summary, follow-ups, or any synthesis of the day's Captures and Enrichments.", "Answer only from the material provided — do not invent Captures or findings.", - "When asked for a checklist or tasks, return a markdown checklist (- [ ] …) of concrete next actions grounded in the day's reports.", + "When asked for a checklist or tasks and a routed to-dos section is provided, that section IS the checklist: list each routed to-do in the walker's own words (- [ ] open, - [x] done) and do not derive, add, or rephrase tasks; if it says none were routed, say the walker has not put anything on the list yet.", + "Only when no routed to-dos section is provided, return a markdown checklist (- [ ] …) of concrete next actions grounded in the day's reports.", "Write compact markdown: short paragraphs, bold key facts, bullets where they help.", "Speak to one reader. Stay calm and factual — no cheerleading, no urgency theater.", ].join(" "); @@ -35,6 +36,18 @@ export function buildDayDigestPrompt(input: DayDigestRequest): string { if (input.walkerProfile) { sections.push(input.walkerProfile); } + // The walker's own list, when the caller knows it: the checklist comes + // from what they routed to To-do, never re-derived from the corpus. + if (input.routedTodos) { + const lines = input.routedTodos.map( + (todo) => + `- [${todo.done ? "x" : " "}] [thread ${todo.threadId}] ${todo.text}`, + ); + sections.push( + "Routed to-dos for this day (the walker's task list — the checklist comes from these, verbatim):", + lines.join("\n") || "(none routed yet)", + ); + } sections.push( "Complete day corpus across every Thread:", historyBlock || "(empty)", diff --git a/lib/digest/types.ts b/lib/digest/types.ts index de691dc..c4d8e8b 100644 --- a/lib/digest/types.ts +++ b/lib/digest/types.ts @@ -20,6 +20,17 @@ export type DayChatTurn = { text: string; }; +/** + * One Thread the walker routed to To-do that day, in their own words. The + * digest's checklist lists these instead of re-deriving tasks from the + * corpus — the walker already said what goes on the list (docs/desk.md, D2). + */ +export type DayRoutedTodo = { + threadId: string; + text: string; + done: boolean; +}; + export type DayDigestRequest = { dayKey: string; dayHeading: string; @@ -28,6 +39,8 @@ export type DayDigestRequest = { walkerProfile?: string | null; /** Conversation so far, oldest first — the digest continues it. */ history?: DayChatTurn[]; + /** The day's routed to-dos; when present they are the checklist. */ + routedTodos?: DayRoutedTodo[]; }; export type DayDigestResult = { diff --git a/lib/local-capture/store.ts b/lib/local-capture/store.ts index 60120b4..ba85918 100644 --- a/lib/local-capture/store.ts +++ b/lib/local-capture/store.ts @@ -19,6 +19,7 @@ import { restoreFromTrashTransition, setCaptureSyncState, setThreadReviewedTransition, + setThreadTodoDoneTransition, setTrashSyncStatus, threadCaptures, trashCaptureTransition, @@ -228,6 +229,9 @@ export function createMemoryCaptureStore( async setThreadReviewed(threadId, reviewedAt) { threads = setThreadReviewedTransition(threads, threadId, reviewedAt); }, + async setThreadTodoDone(threadId, todoDoneAt) { + threads = setThreadTodoDoneTransition(threads, threadId, todoDoneAt); + }, async markSyncing(ids) { captures = setCaptureSyncState(captures, ids, { status: "syncing", @@ -620,6 +624,16 @@ export function createIdbCaptureStore(): CaptureStore { }); }, + async setThreadTodoDone(threadId, todoDoneAt) { + await withDb(async (db) => { + const threads = await readAllThreads(db); + await writeAllThreads( + db, + setThreadTodoDoneTransition(threads, threadId, todoDoneAt), + ); + }); + }, + async markSyncing(ids) { await withDb(async (db) => { const captures = await readAllCaptures(db); diff --git a/lib/local-capture/transitions.ts b/lib/local-capture/transitions.ts index 4bd3a7b..17c061f 100644 --- a/lib/local-capture/transitions.ts +++ b/lib/local-capture/transitions.ts @@ -268,6 +268,16 @@ export function fileThreadTransition( ); } +export function setThreadTodoDoneTransition( + threads: LocalThread[], + threadId: string, + todoDoneAt: string | null, +): LocalThread[] { + return threads.map((thread) => + thread.id === threadId ? { ...thread, todoDoneAt } : thread, + ); +} + export function setThreadReviewedTransition( threads: LocalThread[], threadId: string, diff --git a/lib/local-capture/types.ts b/lib/local-capture/types.ts index 968aa25..66fe71f 100644 --- a/lib/local-capture/types.ts +++ b/lib/local-capture/types.ts @@ -169,6 +169,12 @@ export type LocalThread = { researchVerdict?: ResearchVerdict | null; /** Where the walker routed this Thread; absent/null = not yet settled. */ route?: ThreadRoute | null; + /** + * When the walker checked this off on the To-do list. An action on the + * destination surface, not a filing — it never reopens or re-files the + * Thread. Absent/null = still open. + */ + todoDoneAt?: string | null; }; export type CaptureSyncStatus = @@ -314,6 +320,11 @@ export type CaptureStore = { threadId: string, reviewedAt: string | null, ): Promise; + /** Record the To-do check-off on the local Thread row (null unchecks). */ + setThreadTodoDone( + threadId: string, + todoDoneAt: string | null, + ): Promise; /** Record what the walker settled while filing a Thread at the desk. */ applyThreadFiling(filing: { threadId: string; @@ -372,6 +383,7 @@ export type CaptureStore = { projectName?: string | null; researchVerdict?: ResearchVerdict | null; route?: ThreadRoute | null; + todoDoneAt?: string | null; captures: Array<{ id: string; text: string; diff --git a/lib/sync/hydrate.ts b/lib/sync/hydrate.ts index a7c86f0ea3829338f76461d210fee7e53afc0737..94cb45f61d913e3a5e201c9e6b9920a1cede04ba 100644 GIT binary patch delta 147 zcmbQBf6rh88^64QLP>r~zDs^ys$+?jLQ!gNeo3kxLTK|Bz6Per=LC4U{yX58E@dYK6TkE;qg diff --git a/lib/sync/memory-repository.ts b/lib/sync/memory-repository.ts index 9e32dce..5d9c22e 100644 --- a/lib/sync/memory-repository.ts +++ b/lib/sync/memory-repository.ts @@ -33,6 +33,7 @@ type StoredThread = { projectId?: string | null; researchVerdict?: "kept" | "dismissed" | null; route?: ThreadRoute | null; + todoDoneAt?: string | null; }; type StoredProject = { @@ -399,6 +400,7 @@ export function createMemoryThreadRepository( : null, researchVerdict: thread.researchVerdict ?? null, route: thread.route ?? null, + todoDoneAt: thread.todoDoneAt ?? null, captures, } satisfies ServerThread; }) @@ -670,5 +672,14 @@ export function createMemoryThreadRepository( db.threads.set(key, { ...existing, reviewedAt }); return { threadId, reviewedAt }; }, + + async setThreadTodoDone(userId, threadId, todoDoneAt) { + const db = state(); + const key = `${userId}:${threadId}`; + const existing = db.threads.get(key); + if (!existing) return null; + db.threads.set(key, { ...existing, todoDoneAt }); + return { threadId, todoDoneAt }; + }, }; } diff --git a/lib/sync/neon-repository.ts b/lib/sync/neon-repository.ts index c6dae67..d125eb5 100644 --- a/lib/sync/neon-repository.ts +++ b/lib/sync/neon-repository.ts @@ -78,6 +78,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 todo_done_at TIMESTAMPTZ + `; await sql` CREATE TABLE IF NOT EXISTS sync_projects ( id TEXT PRIMARY KEY, @@ -391,7 +395,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.todo_done_at, 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 +418,7 @@ export function createNeonThreadRepository(databaseUrl: string): ThreadRepositor project_id: string | null; research_verdict: string | null; route: string | null; + todo_done_at: string | null; project_name: string | null; }>; @@ -452,6 +457,7 @@ export function createNeonThreadRepository(databaseUrl: string): ThreadRepositor projectName: thread.project_name ?? null, researchVerdict: asResearchVerdict(thread.research_verdict), route: asThreadRoute(thread.route), + todoDoneAt: thread.todo_done_at ?? null, captures: captures.map((capture) => ({ id: capture.id, text: capture.text, @@ -637,6 +643,18 @@ export function createNeonThreadRepository(databaseUrl: string): ThreadRepositor return { threadId, reviewedAt }; }, + async setThreadTodoDone(userId, threadId, todoDoneAt) { + await ensure(); + const updated = (await sql` + UPDATE sync_threads + SET todo_done_at = ${todoDoneAt} + WHERE user_id = ${userId} AND id = ${threadId} + RETURNING id, todo_done_at + `) as Array<{ id: string; todo_done_at: string | null }>; + if (!updated[0]) return null; + return { threadId, todoDoneAt: updated[0].todo_done_at ?? null }; + }, + async splitThread(userId, threadId, now = new Date().toISOString()) { await ensure(); const captures = (await sql` diff --git a/lib/sync/review-client.ts b/lib/sync/review-client.ts index bcd4abe..48451c9 100644 --- a/lib/sync/review-client.ts +++ b/lib/sync/review-client.ts @@ -31,6 +31,14 @@ export type ReviewTransport = { threadId: string, filing: ThreadFiling, ): Promise; + /** + * Check a routed to-do off (or back on) on the To-do destination surface. + * Not a filing: the server write never touches reviewedAt or route. + */ + setTodoDone?( + threadId: string, + done: boolean, + ): Promise<{ threadId: string; todoDoneAt: string | null } | null>; listProjects?(): Promise; createProject?(name: string): Promise; }; @@ -83,6 +91,23 @@ function defaultTransport(): ReviewTransport { } }, + async setTodoDone(threadId, done) { + try { + const response = await trackedFetch("/api/sync/todo", { + method: "POST", + headers: headers(), + body: JSON.stringify({ threadId, done }), + }); + if (!response.ok) return null; + return (await response.json()) as { + threadId: string; + todoDoneAt: string | null; + }; + } catch { + return null; + } + }, + async listProjects() { try { const response = await trackedFetch("/api/sync/projects", { diff --git a/lib/sync/types.ts b/lib/sync/types.ts index 943f740..cfa2539 100644 --- a/lib/sync/types.ts +++ b/lib/sync/types.ts @@ -98,6 +98,11 @@ export type ServerThread = { researchVerdict?: "kept" | "dismissed" | null; /** Where the walker routed this Thread (ADR 0017); null = not settled. */ route?: ThreadRoute | null; + /** + * When the walker checked this off on the To-do list. Destination-surface + * state, not Filing — it rides beside the route without touching it. + */ + todoDoneAt?: string | null; captures: Array<{ id: string; text: string; @@ -189,6 +194,16 @@ export type ThreadRepository = { threadId: string, now?: string, ): Promise; + /** + * Check a routed to-do off (or back on, with null) from the To-do list. + * The walker's action on the destination surface: it never touches + * reviewedAt or route, so the Thread stays settled exactly as filed. + */ + setThreadTodoDone( + userId: string, + threadId: string, + todoDoneAt: string | null, + ): Promise<{ threadId: string; todoDoneAt: string | null } | null>; /** Mark a Thread processed at the desk (null clears back to new). */ setThreadReviewed( userId: string, diff --git a/tests/todo-destination-ui.spec.ts b/tests/todo-destination-ui.spec.ts new file mode 100644 index 0000000..ab1fe58 --- /dev/null +++ b/tests/todo-destination-ui.spec.ts @@ -0,0 +1,164 @@ +import { expect, test } from "@playwright/test"; +import { + commitCapture, + newestThreadId, + openCaptureShell, +} from "./helpers/capture-shell"; + +/** + * The To-do destination (docs/desk.md, D2): routing a Thread to To-do puts + * it on the task list in the walker's own words; checking it off happens on + * the destination surface and survives a reload; un-routing removes it. + */ + +/** + * Stand-in transports: filing and check-off answer the way the server + * would, and Thread hydration reports unavailable so the locally settled + * route is what the surfaces read — the seam under test is the client's + * adoption of the server's answers. + */ +function stubTransports() { + const g = globalThis as typeof globalThis & { + __WT_REVIEW_TRANSPORT__?: unknown; + __WT_THREADS_TRANSPORT__?: unknown; + }; + g.__WT_REVIEW_TRANSPORT__ = { + async setReviewed(threadId: string) { + return { threadId, reviewedAt: new Date().toISOString() }; + }, + async listProjects() { + return []; + }, + async fileThread( + threadId: string, + filing: { kind?: string | null; route?: string | null }, + ) { + return { + threadId, + reviewedAt: new Date().toISOString(), + kind: filing.kind ?? null, + projectId: null, + projectName: null, + researchVerdict: null, + route: filing.route ?? null, + }; + }, + async setTodoDone(threadId: string, done: boolean) { + return { + threadId, + todoDoneAt: done ? new Date().toISOString() : null, + }; + }, + }; + g.__WT_THREADS_TRANSPORT__ = { + async listThreads() { + return { unavailable: true }; + }, + }; +} + +const WALKER_WORDS = "Fix the fence gate latch before the sheep find it"; + +test("a routed to-do lands on the list, checks off, and survives reload", async ({ + page, +}) => { + await page.addInitScript(stubTransports); + + await openCaptureShell(page); + await commitCapture(page, WALKER_WORDS); + const threadId = await newestThreadId(page); + + // Route it to To-do at the desk — one gesture settles it. + 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(); + await page.getByTestId("file-route-todo").click(); + await expect(page.getByTestId("thread-reviewed-chip")).toBeVisible(); + + // The task list shows it in the walker's words, unchecked. + await page.goto("/todo"); + const item = page.getByTestId(`todo-item-${threadId}`); + await expect(item).toBeVisible(); + await expect(item).toContainText(WALKER_WORDS); + const check = page.getByTestId(`todo-check-${threadId}`); + await expect(check).not.toBeChecked(); + + // Checking it off is the walker's action here — and it sticks. + await check.click(); + await expect(check).toBeChecked(); + await expect(page.getByTestId("todo-tally")).toHaveText("0 open · 1 done"); + + await page.reload(); + await expect(page.getByTestId(`todo-check-${threadId}`)).toBeChecked(); + + // The check-off did not re-open the Thread at the desk. + await page.goto("/days"); + await expect(page.locator(".desk-day-open").first()).toContainText( + "All filed", + ); + + // Un-routing (undo at the desk clears the route) removes the item. + await page.evaluate(async (id) => { + const store = ( + globalThis as typeof globalThis & { + __WT_CAPTURE_STORE__?: { + applyThreadFiling(filing: { + threadId: string; + reviewedAt: string | null; + route: string | null; + }): Promise; + }; + } + ).__WT_CAPTURE_STORE__; + await store?.applyThreadFiling({ + threadId: id, + reviewedAt: null, + route: null, + }); + }, threadId); + + await page.goto("/todo"); + await expect(page.getByTestId("todo-empty")).toBeVisible(); +}); + +test("the day digest checklist asks with that day's routed to-dos", async ({ + page, +}) => { + await page.addInitScript(stubTransports); + + await openCaptureShell(page); + await commitCapture(page, WALKER_WORDS); + const threadId = await newestThreadId(page); + + let routedTodos: unknown = null; + await page.route("**/api/digest", async (route) => { + const body = route.request().postDataJSON() as { routedTodos?: unknown }; + routedTodos = body.routedTodos ?? null; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + text: `## Checklist\n- [ ] ${WALKER_WORDS}`, + model: "test-model", + }), + }); + }); + + await page.goto("/days"); + await page.locator(".desk-day-open").first().click(); + await page.locator(".thread-file-open").first().click(); + await page.getByTestId("file-route-todo").click(); + await expect(page.getByTestId("thread-reviewed-chip")).toBeVisible(); + + await page + .getByRole("button", { name: "Create a task checklist of the day" }) + .click(); + + await expect + .poll(() => routedTodos, { timeout: 8_000 }) + .toEqual([{ threadId, text: WALKER_WORDS, done: false }]); + await expect(page.getByTestId("digest-result")).toContainText(WALKER_WORDS, { + timeout: 8_000, + }); +}); diff --git a/tests/todo-destination.spec.ts b/tests/todo-destination.spec.ts new file mode 100644 index 0000000..6f55005 --- /dev/null +++ b/tests/todo-destination.spec.ts @@ -0,0 +1,280 @@ +import { expect, test } from "@playwright/test"; +import { collectTodos } from "@/lib/desk/todo"; +import { + buildDayDigestPrompt, + DAY_DIGEST_SYSTEM_INSTRUCTION, +} from "@/lib/digest/prompt"; +import type { LocalCapture, LocalThread } from "@/lib/local-capture/types"; +import { mergeRemoteThreads } from "@/lib/sync/hydrate"; +import { + createMemoryThreadRepository, + resetMemoryThreadRepository, +} from "@/lib/sync/memory-repository"; + +const NS = "todo-destination-tests"; + +test.beforeEach(() => { + resetMemoryThreadRepository(NS); +}); + +async function seedThread( + threads: ReturnType, + id: string, + text: string, +) { + await threads.upsertCaptures("user_a", [ + { + id, + text, + createdAt: "2026-08-07T10:00:00.000Z", + location: null, + threadId: null, + sequence: 1, + idempotencyKey: id, + attachments: [], + }, + ]); + return id; +} + +function localThread(overrides: Partial & { id: string }): LocalThread { + return { + title: "Untitled", + revision: 1, + updatedAt: "2026-08-07T10:00:00.000Z", + ...overrides, + }; +} + +function localCapture( + overrides: Partial & { id: string; threadId: string }, +): LocalCapture { + return { + text: "", + createdAt: "2026-08-07T10:00:00.000Z", + location: null, + status: "complete", + sequence: 1, + attachments: [], + ...overrides, + }; +} + +test("routing a Thread to To-do lands it on the list in the walker's words", async () => { + const threads = createMemoryThreadRepository(NS); + const id = await seedThread( + threads, + "t-todo", + "Fix the fence gate latch before the sheep find it", + ); + // The Enrichment names the Thread; the list must still say what the + // walker said, not the model's title. + await threads.updateThreadTitle!("user_a", id, "Fence maintenance"); + + await threads.fileThread("user_a", id, { + reviewedAt: "2026-08-07T18:00:00.000Z", + route: "todo", + }); + + const listed = (await threads.listThreads("user_a")).find((t) => t.id === id); + expect(listed?.route).toBe("todo"); + expect(listed?.todoDoneAt ?? null).toBeNull(); + + const items = collectTodos( + [ + localThread({ + id, + title: listed!.title, + route: "todo", + todoDoneAt: null, + }), + ], + [ + localCapture({ + id, + threadId: id, + text: "Fix the fence gate latch before the sheep find it", + }), + ], + ); + expect(items).toHaveLength(1); + expect(items[0].text).toBe( + "Fix the fence gate latch before the sheep find it", + ); + expect(items[0].dayKey).toBe("2026-08-07"); +}); + +test("checking off round-trips and never re-files the Thread", async () => { + const threads = createMemoryThreadRepository(NS); + const id = await seedThread(threads, "t-check", "Order more fence wire"); + await threads.fileThread("user_a", id, { + reviewedAt: "2026-08-07T18:00:00.000Z", + route: "todo", + }); + + const done = await threads.setThreadTodoDone( + "user_a", + id, + "2026-08-08T09:00:00.000Z", + ); + expect(done?.todoDoneAt).toBe("2026-08-08T09:00:00.000Z"); + + // The destination-surface write leaves the filing exactly as settled. + const checked = (await threads.listThreads("user_a")).find((t) => t.id === id); + expect(checked?.todoDoneAt).toBe("2026-08-08T09:00:00.000Z"); + expect(checked?.reviewedAt).toBe("2026-08-07T18:00:00.000Z"); + expect(checked?.route).toBe("todo"); + + // Unchecking clears it back to open the same way. + const reopened = await threads.setThreadTodoDone("user_a", id, null); + expect(reopened?.todoDoneAt).toBeNull(); + + // A Thread that does not exist reads as null, not an error. + expect(await threads.setThreadTodoDone("user_a", "t-missing", "x")).toBeNull(); +}); + +test("check-off state hydrates to another device the way the review does", () => { + const merged = mergeRemoteThreads({ + localCaptures: [], + localThreads: [ + localThread({ + id: "t-hydrate", + title: "Order more fence wire", + reviewedAt: "2026-08-07T18:00:00.000Z", + route: "todo", + todoDoneAt: null, + }), + ], + remoteThreads: [ + { + id: "t-hydrate", + title: "Order more fence wire", + revision: 1, + updatedAt: "2026-08-07T10:00:00.000Z", + reviewedAt: "2026-08-07T18:00:00.000Z", + route: "todo", + todoDoneAt: "2026-08-08T09:00:00.000Z", + captures: [], + }, + ], + }); + expect( + merged.threads.find((t) => t.id === "t-hydrate")?.todoDoneAt, + ).toBe("2026-08-08T09:00:00.000Z"); + + // Unchecking on the other device lands here too — the server owns it. + const reopened = mergeRemoteThreads({ + localCaptures: [], + localThreads: merged.threads, + remoteThreads: [ + { + id: "t-hydrate", + title: "Order more fence wire", + revision: 1, + updatedAt: "2026-08-07T10:00:00.000Z", + reviewedAt: "2026-08-07T18:00:00.000Z", + route: "todo", + todoDoneAt: null, + captures: [], + }, + ], + }); + expect( + reopened.threads.find((t) => t.id === "t-hydrate")?.todoDoneAt ?? null, + ).toBeNull(); +}); + +test("un-routing removes the item; so does redirecting elsewhere", () => { + const capture = localCapture({ + id: "c-1", + threadId: "t-unroute", + text: "Order more fence wire", + }); + const routed = localThread({ id: "t-unroute", route: "todo" }); + + expect(collectTodos([routed], [capture])).toHaveLength(1); + // Undo at the desk clears the route — the list is fed by the route, so + // the item is gone by construction. + expect( + collectTodos([{ ...routed, route: null, reviewedAt: null }], [capture]), + ).toHaveLength(0); + // Redirecting to another destination removes it the same way. + expect( + collectTodos([{ ...routed, route: "journal" }], [capture]), + ).toHaveLength(0); +}); + +test("open items sort before done ones, newest walk first", () => { + const items = collectTodos( + [ + localThread({ + id: "t-done", + route: "todo", + todoDoneAt: "2026-08-08T09:00:00.000Z", + }), + localThread({ id: "t-old", route: "todo" }), + localThread({ id: "t-new", route: "todo" }), + ], + [ + localCapture({ id: "c-done", threadId: "t-done", text: "Done one" }), + localCapture({ + id: "c-old", + threadId: "t-old", + text: "Older open one", + createdAt: "2026-08-06T10:00:00.000Z", + }), + localCapture({ id: "c-new", threadId: "t-new", text: "Newer open one" }), + ], + ); + expect(items.map((item) => item.threadId)).toEqual([ + "t-new", + "t-old", + "t-done", + ]); +}); + +test("the day digest checklist draws from routed to-dos, not re-derivation", () => { + // The system instruction carries the rule on the DAY_DIGEST seam. + expect(DAY_DIGEST_SYSTEM_INSTRUCTION).toContain("routed to-do"); + + const base = { + dayKey: "2026-08-07", + dayHeading: "Thursday, August 7, 2026", + question: "Create a task checklist of the day", + corpus: [ + { + kind: "capture" as const, + id: "c-1", + threadId: "t-1", + threadTitle: "Fence maintenance", + text: "Fix the fence gate latch before the sheep find it", + createdAt: "2026-08-07T10:00:00.000Z", + }, + ], + }; + + const prompt = buildDayDigestPrompt({ + ...base, + routedTodos: [ + { + threadId: "t-1", + text: "Fix the fence gate latch before the sheep find it", + done: false, + }, + { threadId: "t-2", text: "Order more fence wire", done: true }, + ], + }); + expect(prompt).toContain("Routed to-dos for this day"); + expect(prompt).toContain( + "- [ ] [thread t-1] Fix the fence gate latch before the sheep find it", + ); + expect(prompt).toContain("- [x] [thread t-2] Order more fence wire"); + + // A day with none routed says so rather than inviting invention. + const empty = buildDayDigestPrompt({ ...base, routedTodos: [] }); + expect(empty).toContain("(none routed yet)"); + + // A caller that predates the To-do destination gets the old behavior. + const absent = buildDayDigestPrompt(base); + expect(absent).not.toContain("Routed to-dos for this day"); +}); From 97bed7c1821082540530acb10f94003c7d9d9c0b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 01:09:45 +0000 Subject: [PATCH 2/2] Sync vendored agent skills from upstream Mechanical refresh from the skills sync tooling at session start: updated vendored copies under .agents/skills and their computed hashes in skills-lock.json. No product code touched. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KPox3By2txjxSy8HUAPVKk --- .agents/skills/ask-matt/PHASE-BOUNDARIES.md | 55 ++++++++++++++++ .agents/skills/ask-matt/SKILL.md | 32 +++++++--- .agents/skills/code-review/SKILL.md | 8 +-- .../skills/codebase-design/DESIGN-IT-TWICE.md | 2 +- .agents/skills/diagnosing-bugs/SKILL.md | 10 ++- .../scripts/hitl-loop.template.sh | 3 + .agents/skills/find-skills/SKILL.md | 1 - .agents/skills/firecrawl-search/SKILL.md | 51 +++++++++++---- .agents/skills/grilling/SKILL.md | 18 ++++-- .agents/skills/grilling/agents/openai.yaml | 2 +- .agents/skills/implement/SKILL.md | 9 --- .../improve-codebase-architecture/SKILL.md | 2 +- .agents/skills/prototype/LOGIC.md | 64 ++++++++----------- .agents/skills/prototype/SKILL.md | 4 +- .../skills/setup-matt-pocock-skills/SKILL.md | 2 +- .../issue-tracker-github.md | 10 ++- .../issue-tracker-gitlab.md | 2 +- .../issue-tracker-local.md | 2 +- .../setup-matt-pocock-skills/triage-labels.md | 8 +-- .agents/skills/tdd/SKILL.md | 2 + .agents/skills/to-spec/SKILL.md | 2 +- .agents/skills/to-tickets/SKILL.md | 2 - .agents/skills/triage/SKILL.md | 2 +- .agents/skills/wayfinder/SKILL.md | 12 ++-- skills-lock.json | 32 +++++----- 25 files changed, 209 insertions(+), 128 deletions(-) create mode 100644 .agents/skills/ask-matt/PHASE-BOUNDARIES.md 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/.agents/skills/ask-matt/SKILL.md b/.agents/skills/ask-matt/SKILL.md index 70b807b..7f3ab78 100644 --- a/.agents/skills/ask-matt/SKILL.md +++ b/.agents/skills/ask-matt/SKILL.md @@ -14,13 +14,13 @@ A **flow** is a path through the skills. Most paths run along one **main flow**, The route most work travels. You have an idea and want it built. -1. **`/grill-with-docs`** — sharpen the idea by interview. Start here when you **have a codebase**: it's stateful, retaining what it learns in `CONTEXT.md` and ADRs. (No codebase? Use `/grill-me` — see Standalone. Both run the same `/grilling` primitive; `grill-with-docs` is the one that leaves a paper trail.) -2. **Branch — can you settle every question in conversation?** If a question needs a runnable answer (state, business logic, a UI you have to see), detour through a prototype, bridged by **`/handoff`** in both directions (see Crossing sessions): +1. **`/grill-with-docs`** — sharpen the idea by interview. Start here whenever you are **working in a working directory**: it's stateful, retaining what it learns in `CONTEXT.md` and ADRs. (No working directory? Use `/grill-me` — see Standalone. Both run the same `/grilling` primitive; `grill-with-docs` is the one that leaves a paper trail, which makes it the better of the two whenever a repo is there to leave it in.) +2. **Branch — can you settle every question in conversation?** If a question needs a runnable answer (state, business logic, a UI you have to see), detour through a prototype, bridged by **`/handoff`** in both directions (a prototype lives in its own directory, which is exactly what `/handoff` is for — see Phase boundaries): - **`/handoff`** out, then open a fresh session against that file, - **`/prototype`** to answer the question with throwaway code, - **`/handoff`** back what you learned, and reference it from the original idea thread. 3. **Branch — is this a multi-session build?** - - **Yes** → **`/to-spec`** (turn the thread into a spec), then **`/to-tickets`** to split it into tracer-bullet tickets, each declaring its **blocking edges**. On a local tracker that's one file per ticket under `.scratch//issues/`, worked blockers-first by hand; on a real tracker the edges become native blocking links, so any ticket whose blockers are done can be grabbed — kick off **`/implement`** per ticket, **clearing context between each one**. + - **Yes** → **`/to-spec`** (turn the thread into a spec), then **`/to-tickets`** to split it into tracer-bullet tickets, each declaring its **blocking edges**. On a local tracker that's one file per ticket under `.scratch//issues/`, worked blockers-first by hand; on a real tracker the edges become native blocking links, so any ticket whose blockers are done can be grabbed — kick off **`/implement`** per ticket, **`/clear`ing context between each one**. Each ticket is self-contained, so the last one's context is disposable. - **No** → **`/implement`** right here, in the same context window. Either way, **`/implement`** builds each issue by driving **`/tdd`** internally — one red-green slice at a time — then closes out by running **`/code-review`**, a two-axis review (Standards + Spec) of the diff, before committing. Reach for **`/tdd`** on its own when you just want to build a concrete behaviour test-first without a full spec, and **`/code-review`** on its own whenever you want to review a branch or PR against a fixed point. @@ -29,7 +29,7 @@ The route most work travels. You have an idea and want it built. Keep steps 1–3 in **one unbroken context window** — don't compact or clear until after `/to-tickets` — so the grilling, spec, and tickets all build on the same thinking. Each `/implement` then starts fresh, working from the ticket. -The limit on this is the **[smart zone](https://www.aihero.dev/ai-coding-dictionary/smart-zone)**: the window (~120k tokens on state-of-the-art models) within which the model still reasons sharply. If a session approaches it before `/to-tickets`, don't push on degraded — `/handoff` and continue in a fresh thread. +The limit on this is the **[smart zone](https://www.aihero.dev/ai-coding-dictionary/smart-zone)**: the window (~150k tokens on state-of-the-art models) within which the model still reasons sharply. If a session approaches it before `/to-tickets`, don't push on degraded — `/compact` at the nearest phase boundary and carry on (see Phase boundaries). ## On-ramps @@ -58,20 +58,32 @@ Two model-invoked references that run *beneath* the other skills — each the si - **`/domain-modeling`** — sharpen the project's *domain* language: challenge a fuzzy term, resolve an overloaded word ("account" doing three jobs), record a hard-to-reverse decision as an ADR. It's the active discipline `/grill-with-docs` drives to keep `CONTEXT.md` a clean glossary. - **`/codebase-design`** — the deep-module vocabulary (module, interface, depth, seam, adapter, leverage, locality) for designing a module's *shape*: a lot of behaviour behind a small interface at a clean seam. `/tdd` and `/improve-codebase-architecture` both speak it. -## Crossing sessions +## Phase boundaries -- **`/handoff`** — when a thread is full or you need to branch off (e.g. into a `/prototype` session), this compacts the conversation into a markdown file. You don't continue in place — you **open a new session and reference that file** to carry the context across. It's the bridge between context windows, in either direction. Use it when you want a **fresh session** but need the **current conversation preserved**. -- **`/compact`** (built-in) — stay in the **same conversation**, letting the earlier turns be summarized. Use it at **intentional breaks between phases**, when you don't mind losing the verbatim history. Don't compact mid-phase — the agent can lose its way. `/handoff` forks; `/compact` continues. +A **phase** is a chunk of work inside a session — the grilling, the implementation, the QA. At the **boundary** between two of them you have five options, and picking between them is the fuzziest decision in this whole map: + +- **Continue** — stay put. Costs nothing, loses nothing. +- **`/clear`** — empty the window, when nothing here matters to what's next. +- **`/handoff`** — write a portable markdown file. Narrow: only for a **new harness**, a **new directory**, a **colleague**, or forking a side task **mid-phase**. What it buys is portability. +- **Subagent** — send a tightly-scoped task to its own window and get a report back. +- **`/compact`** — compress this context and seed a fresh session with it. The **default**, at the bottom of the tree rather than the first reach. + +Read [PHASE-BOUNDARIES.md](PHASE-BOUNDARIES.md) for the ordered tree — the five questions, the reasoning behind each branch, and why the primary-source cost makes **Continue** the one to rule out first. Make the decision **at** a boundary; mid-phase, continue or split the rest into subagents. ## Standalone Off the main flow entirely. -- **`/grill-me`** — the same relentless interview as `/grill-with-docs`, but for when you have **no codebase**. Stateless: it saves nothing locally, builds no `CONTEXT.md`. Reach for it to sharpen any plan or design that doesn't live in a repo. -- **`/prototype`** — a small, throwaway program that answers one design question: does this state model feel right, or what should this UI look like. Throwaway from day one — keep the answer, delete the code. It's the detour in step 2 of the main flow, but reach for it any time a design question is hard to settle on paper. +- **`/grill-me`** — the same relentless interview as `/grill-with-docs`, but **stateless**: it saves nothing locally and builds no `CONTEXT.md`. Reach for it when you are **not working in a working directory** — sharpening a plan, a design, a piece of writing, anything with no repo under it. If you are in a working directory, use `/grill-with-docs` instead: it runs the same interview and leaves a paper trail, so it is strictly the better one. +- **`/grilling`** — the interview primitive itself: rounds, the frontier, facts are the agent's job and decisions are yours. `/grill-me` and `/grill-with-docs` are the two named ways in, and `/triage`, `/wayfinder` and `/improve-codebase-architecture` all run it internally. Reach for it directly only when you want the interview with no wrapper around it. +- **`/resolving-merge-conflicts`** — work an in-progress merge or rebase conflict hunk by hunk, resolving by **intent** traced to each side's primary source rather than by picking lines, then finish the operation. It never runs `--abort`. Standalone and off every flow: reach for it when you are already mid-conflict. +- **`/prototype`** — a small, throwaway program that answers one design question: does this state model feel right, or what should this UI look like. Throwaway is a constraint on how the code is written, not a promise to destroy it: the answer folds into the real code, and the prototype itself is kept as a **primary source** on a `prototype/` branch out of main, pointed at from the implementation issue. It's the detour in step 2 of the main flow, but reach for it any time a design question is hard to settle on paper. - **`/research`** — delegate reading legwork to a **background agent**: it investigates a question against **primary sources**, then leaves a cited Markdown file in the repo. Keep working while it reads. The file it produces is something to take *into* the main flow at `/grill-with-docs` — research feeds the thinking, it doesn't replace it. +- **`/to-questionnaire`** — when the thing blocking you isn't in your head or the codebase but in **someone else's**, this writes them a questionnaire to fill in. It's the inverse of `/grill-me`: instead of interviewing you about the subject, it interviews you about the **send** — who it's going to, what you need back — and aims the questions at the gap. What comes back is material for `/grill-with-docs` or `/to-spec`. +- **`/wizard`** — for the steps only a **human** can take: provisioning infrastructure, setting up credentials or CI secrets, clicking through an unfamiliar third-party dashboard, running a one-off migration or cutover. It generates an interactive bash script that opens each URL, captures each value, and writes it into `.env` and GitHub secrets — so the procedure stops being something you re-explain to an agent every time. Model-invoked, so the agent reaches for it the moment it hits a wall only you can pass. If the agent could just do it itself, it should; this is for where a human is genuinely in the loop. +- **`/wait-what`** — the corrective for a message that didn't land. Use it mid-conversation, inside any other skill, and the agent re-pitches what it just said with the context you were missing, in plain English, using the `CONTEXT.md` vocabulary. It works after the fact; `/grill-with-docs` is the upfront cure, because a shared language agreed early is what stops the jargon arriving at all. - **`/teach`** — learn a concept over multiple sessions, using the current directory as a stateful workspace. -- **`/writing-great-skills`** — reference for writing and editing skills well. +- **`/writing-for-agents`** — reference for writing documents agents consume: skills, AGENTS.md, pointed-at docs. ## Precondition diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md index 2a0b524..2d276fe 100644 --- a/.agents/skills/code-review/SKILL.md +++ b/.agents/skills/code-review/SKILL.md @@ -1,12 +1,12 @@ --- name: code-review -description: Review the changes since a fixed point (commit, branch, tag, or merge-base) along two axes — Standards (does the code follow this repo's documented coding standards?) and Spec (does the code match what the originating issue/PRD asked for?). Runs both reviews in parallel sub-agents and reports them side by side. Use when the user wants to review a branch, a PR, work-in-progress changes, or asks to "review since X". +description: Review the changes since a fixed point (commit, branch, tag, or merge-base) along two axes — Standards (does the code follow this repo's documented coding standards?) and Spec (does the code match what the originating issue/spec asked for?). Runs both reviews in parallel sub-agents and reports them side by side. Use when the user wants to review a branch, a PR, work-in-progress changes, or asks to "review since X". --- Two-axis review of the diff between `HEAD` and a fixed point the user supplies: - **Standards** — does the code conform to this repo's documented coding standards? -- **Spec** — does the code faithfully implement the originating issue / PRD / spec? +- **Spec** — does the code faithfully implement the originating issue / spec? Both axes run as **parallel sub-agents** so they don't pollute each other's context, then this skill aggregates their findings. @@ -28,7 +28,7 @@ Look for the originating spec, in this order: 1. Issue references in the commit messages (`#123`, `Closes #45`, GitLab `!67`, etc.) — fetch via the workflow in `docs/agents/issue-tracker.md`. 2. A path the user passed as an argument. -3. A PRD/spec file under `docs/`, `specs/`, or `.scratch/` matching the branch name or feature. +3. A spec file under `docs/`, `specs/`, or `.scratch/` matching the branch name or feature. 4. If nothing is found, ask the user where the spec is. If they say there isn't one, the **Spec** sub-agent will skip and report "no spec available". ### 3. Identify the standards sources @@ -57,8 +57,6 @@ Each smell reads *what it is* → *how to fix*; match it against the diff: ### 4. Spawn both sub-agents in parallel -Send a single message with two `Agent` tool calls. Use the `general-purpose` subagent for both. - **Standards sub-agent prompt** — include: - The full diff command and commit list. diff --git a/.agents/skills/codebase-design/DESIGN-IT-TWICE.md b/.agents/skills/codebase-design/DESIGN-IT-TWICE.md index 49a7c42..8419ad6 100644 --- a/.agents/skills/codebase-design/DESIGN-IT-TWICE.md +++ b/.agents/skills/codebase-design/DESIGN-IT-TWICE.md @@ -18,7 +18,7 @@ Show this to the user, then immediately proceed to Step 2. The user reads and th ### 2. Spawn sub-agents -Spawn 3+ sub-agents in parallel using the Agent tool. Each must produce a **radically different** interface for the deepened module. +Spawn 3+ sub-agents in parallel. Each must produce a **radically different** interface for the deepened module. Prompt each sub-agent with a separate technical brief (file paths, coupling details, dependency category from [DEEPENING.md](DEEPENING.md), what sits behind the seam). The brief is independent of the user-facing problem-space explanation in Step 1. Give each agent a different design constraint: diff --git a/.agents/skills/diagnosing-bugs/SKILL.md b/.agents/skills/diagnosing-bugs/SKILL.md index f400de7..7f8acf7 100644 --- a/.agents/skills/diagnosing-bugs/SKILL.md +++ b/.agents/skills/diagnosing-bugs/SKILL.md @@ -9,6 +9,12 @@ A discipline for hard bugs. Skip phases only when explicitly justified. When exploring the codebase, read `CONTEXT.md` (if it exists) to get a clear mental model of the relevant modules, and check ADRs in the area you're touching. +## Redact + +This skill has you show commands, outputs and captured artifacts. **Redact every secret first** — write `` in its place. Build loops against env vars, so the credential stays in the environment rather than in what you show. Captured artifacts carry auth headers: quote only the lines that carry the signal. + +If the redacted output is not enough to diagnose the bug, say so and ask the user. + ## Phase 1 — Build a feedback loop **This is the skill.** Everything else is mechanical. If you have a **tight** pass/fail signal for the bug — one that goes red on _this_ bug — you will find the cause; bisection, hypothesis-testing, and instrumentation all just consume it. If you don't have one, no amount of staring at code will save you. @@ -46,11 +52,11 @@ The goal is not a clean repro but a **higher reproduction rate**. Loop the trigg ### When you genuinely cannot build a loop -Stop and say so explicitly. List what you tried. Ask the user for: (a) access to whatever environment reproduces it, (b) a captured artifact (HAR file, log dump, core dump, screen recording with timestamps), or (c) permission to add temporary production instrumentation. Do **not** proceed to hypothesise without a loop. +Stop and say so explicitly. List what you tried. Ask the user for: (a) access to whatever environment reproduces it, (b) a redacted captured artifact (HAR file, log dump, core dump, screen recording with timestamps), or (c) permission to add temporary production instrumentation. Do **not** proceed to hypothesise without a loop. ### Completion criterion — a tight loop that goes red -Phase 1 is done when the loop is **tight** and **red-capable**: you can name **one command** — a script path, a test invocation, a curl — that you have **already run at least once** (paste the invocation and its output), and that is: +Phase 1 is done when the loop is **tight** and **red-capable**: you can name **one command** — a script path, a test invocation, a curl — that you have **already run at least once** (show the invocation and its output, redacted), and that is: - [ ] **Red-capable** — it drives the actual bug code path and asserts the **user's exact symptom**, so it can go red on this bug and green once fixed. Not "runs without erroring" — it must be able to _catch this specific bug_. - [ ] **Deterministic** — same verdict every run (flaky bugs: a pinned, high reproduction rate, per above). diff --git a/.agents/skills/diagnosing-bugs/scripts/hitl-loop.template.sh b/.agents/skills/diagnosing-bugs/scripts/hitl-loop.template.sh index 40afc46..43daedd 100644 --- a/.agents/skills/diagnosing-bugs/scripts/hitl-loop.template.sh +++ b/.agents/skills/diagnosing-bugs/scripts/hitl-loop.template.sh @@ -11,6 +11,9 @@ # capture VAR "" → show question, read response into VAR # # At the end, captured values are printed as KEY=VALUE for the agent to parse. +# +# `capture` prints its value back to the terminal, where the agent reads it — so +# capture observations, and leave signing in to the user as a `step`. set -euo pipefail diff --git a/.agents/skills/find-skills/SKILL.md b/.agents/skills/find-skills/SKILL.md index 7369d66..a41bdd0 100644 --- a/.agents/skills/find-skills/SKILL.md +++ b/.agents/skills/find-skills/SKILL.md @@ -26,7 +26,6 @@ The Skills CLI (`npx skills`) is the package manager for the open agent skills e - `npx skills find [query] [--owner ]` - Search for skills interactively or by keyword, optionally scoped to a GitHub owner - `npx skills add ` - Install a skill from GitHub or other sources -- `npx skills check` - Check for skill updates - `npx skills update` - Update all installed skills **Browse skills at:** https://skills.sh/ diff --git a/.agents/skills/firecrawl-search/SKILL.md b/.agents/skills/firecrawl-search/SKILL.md index 87b426c..c4dfee0 100644 --- a/.agents/skills/firecrawl-search/SKILL.md +++ b/.agents/skills/firecrawl-search/SKILL.md @@ -28,22 +28,49 @@ firecrawl search "your query" --scrape -o .firecrawl/scraped.json --json # News from the past day firecrawl search "your query" --sources news --tbs qdr:d -o .firecrawl/news.json --json + +# Programming question: search GitHub issues, merged PRs, READMEs, and docs +firecrawl search "your query" --categories developer -o .firecrawl/developer.json --json ``` +## Developer search + +`--categories developer` adds an index built for coding agents. It covers GitHub +issues, merged pull requests, repository READMEs, and curated documentation +sites. Use it for a programming question: an error message, an API contract, a +library behaviour, or a known bug. + +The hits arrive in their own `data.developer` group beside `data.web`. Each hit +holds `url`, `title`, and `description`, where `description` is the matched +passage. Read the passages with +`jq -r '.data.developer[] | .url, .description' .firecrawl/developer.json`. + +The dedicated `firecrawl developer` command searches only that index and keeps +the full matched passages: + +```bash +# Developer search only, with full passages +firecrawl developer "your query" --limit 10 -o .firecrawl/developer.json --json +``` + +Each result holds `id`, `type` (`issue`, `pull_request`, `readme`, `doc`), +`url`, `title`, and `passages`. Read them with +`jq -r '.results[] | .url, .passages[].text' .firecrawl/developer.json`. + ## Options -| Option | Description | -| ------------------------------------ | --------------------------------------------- | -| `--limit ` | Max number of results | -| `--sources ` | Source types to search | -| `--categories ` | Filter by category | -| `--tbs ` | Time-based search filter | -| `--location` | Location for search results | -| `--country ` | Country code for search | -| `--scrape` | Also scrape full page content for each result | -| `--scrape-formats` | Formats when scraping (default: markdown) | -| `-o, --output ` | Output file path | -| `--json` | Output as JSON | +| Option | Description | +| ---------------------------------------------- | --------------------------------------------- | +| `--limit ` | Max number of results | +| `--sources ` | Source types to search | +| `--categories ` | Filter by category | +| `--tbs ` | Time-based search filter | +| `--location` | Location for search results | +| `--country ` | Country code for search | +| `--scrape` | Also scrape full page content for each result | +| `--scrape-formats` | Formats when scraping (default: markdown) | +| `-o, --output ` | Output file path | +| `--json` | Output as JSON | ## Tips diff --git a/.agents/skills/grilling/SKILL.md b/.agents/skills/grilling/SKILL.md index 52d8eb3..95bd01e 100644 --- a/.agents/skills/grilling/SKILL.md +++ b/.agents/skills/grilling/SKILL.md @@ -3,10 +3,20 @@ name: grilling description: Grill the user relentlessly about a plan, decision, or idea. Use when the user wants to stress-test their thinking, or uses any 'grill' trigger phrases. --- -Interview me relentlessly about every aspect of this until we reach a shared understanding. Walk down each branch of the decision tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer. +Interview the user relentlessly until you reach a shared understanding. Map this as a **design tree**: every decision branches into the decisions that hang off it. -Ask the questions one at a time, waiting for feedback on each question before continuing. Asking multiple questions at once is bewildering. +Work the tree in **rounds**. The **frontier** is every decision whose prerequisites are already settled — the questions you can ask _now_ without guessing at answers you haven't heard yet. Ask the whole frontier in one round: number each question and give your recommended answer. Then wait for the user's answers before the next round. -If a *fact* can be found by exploring the environment (filesystem, tools, etc.), look it up rather than asking me. The *decisions*, though, are mine — put each one to me and wait for my answer. +Each question should be formatted like so: -Do not act on it until I confirm we have reached a shared understanding. +``` +❓ **Q1** - ****: + +➡️ +``` + +Each round the user answers reshapes the tree — settled decisions push the frontier outward and unblock questions that depended on them. Recompute the frontier and ask the next round. A question whose answer depends on another question still open in this round belongs to a _later_ round, not this one. + +Finding _facts_ is your job, never the user's. When a frontier question needs a fact from the environment (filesystem, tools, etc.), dispatch a sub-agent to find it — don't ask the user for anything you could look up yourself. Don't block on it: a running exploration is an unsettled prerequisite, so only the questions downstream of it wait for the sub-agent to report — ask the rest of the frontier now. The _decisions_ are the user's — put each to them and wait. + +The session is done when the frontier is empty: every branch of the design tree visited, nothing left silently assumed. Do not act on it until the user confirms you have reached a shared understanding. diff --git a/.agents/skills/grilling/agents/openai.yaml b/.agents/skills/grilling/agents/openai.yaml index 85b1260..ddbdb96 100644 --- a/.agents/skills/grilling/agents/openai.yaml +++ b/.agents/skills/grilling/agents/openai.yaml @@ -1,3 +1,3 @@ interface: display_name: "Grilling" - short_description: "Stress-test thinking one question at a time" + short_description: "Stress-test thinking a round of questions at a time" diff --git a/.agents/skills/implement/SKILL.md b/.agents/skills/implement/SKILL.md index db3dd33..7a0b11f 100644 --- a/.agents/skills/implement/SKILL.md +++ b/.agents/skills/implement/SKILL.md @@ -6,10 +6,6 @@ disable-model-invocation: true Implement the work described by the user in the spec or tickets. -Before coding, claim the ticket (`mise run issue:claim -- `) so it carries -`in-progress`. Do not start a ticket that is already claimed. See -`docs/agents/issue-workflow.md`. - Use /tdd where possible, at pre-agreed seams. Run typechecking regularly, single test files regularly, and the full test suite once at the end. @@ -17,8 +13,3 @@ Run typechecking regularly, single test files regularly, and the full test suite Once done, use /code-review to review the work. Commit your work to the current branch. - -When opening the PR, the **body must include** `Closes #` (e.g. -`Closes #13`). A title like `(#13)` is not enough — GitHub only auto-closes on -merge when the body uses a closing keyword. Infra/docs PRs with no product -ticket may use `No-ticket: true` instead. diff --git a/.agents/skills/improve-codebase-architecture/SKILL.md b/.agents/skills/improve-codebase-architecture/SKILL.md index b56969e..529761a 100644 --- a/.agents/skills/improve-codebase-architecture/SKILL.md +++ b/.agents/skills/improve-codebase-architecture/SKILL.md @@ -24,7 +24,7 @@ This command is _informed_ by the project's domain model and built on a shared d Read the project's domain glossary (`CONTEXT.md`) and any ADRs in the area you're touching first. -Then use the Agent tool with `subagent_type=Explore` to walk the codebase. Don't follow rigid heuristics — explore organically and note where you experience friction: +Then spawn a sub-agent to walk the codebase. Don't follow rigid heuristics — explore organically and note where you experience friction: - Where does understanding one concept require bouncing between many small modules? - Where are modules **shallow** — interface nearly as complex as the implementation? diff --git a/.agents/skills/prototype/LOGIC.md b/.agents/skills/prototype/LOGIC.md index fe9a2c2..5f5a3fd 100644 --- a/.agents/skills/prototype/LOGIC.md +++ b/.agents/skills/prototype/LOGIC.md @@ -1,13 +1,15 @@ # Logic Prototype -A tiny interactive terminal app that lets the user drive a state model by hand. Use this when the question is about **business logic, state transitions, or data shape** — the kind of thing that looks reasonable on paper but only feels wrong once you push it through real cases. +A single, self-contained HTML file — a **shareable demo** — that lets anyone drive a state model by clicking buttons. Use this when the question is about **business logic, state transitions, or data shape** — the kind of thing that looks reasonable on paper but only feels wrong once you push it through real cases. + +Because it's one file with nothing to install, you can hand it to a non-developer — a designer, a PM, a domain expert — and let them feel the model for themselves. So it speaks their language, not the code's. ## When this is the right shape - "I'm not sure if this state machine handles the edge case where X then Y." - "Does this data model actually let me represent the case where..." - "I want to feel out what the API should look like before writing it." -- Anything where the user wants to **press buttons and watch state change**. +- Anything where someone wants to **press buttons and watch state change**. If the question is "what should this look like" — wrong branch. Use [UI.md](UI.md). @@ -15,17 +17,11 @@ If the question is "what should this look like" — wrong branch. Use [UI.md](UI ### 1. State the question -Before writing code, write down what state model and what question you're prototyping. One paragraph, in the prototype's README or a comment at the top of the file. A logic prototype that answers the wrong question is pure waste — make the question explicit so it can be checked later, whether the user is watching now or returning to it AFK. - -### 2. Pick the language - -Use whatever the host project uses. If the project has no obvious runtime (e.g. a docs repo), ask. - -Match the project's existing conventions for tooling — don't add a new package manager or runtime just for the prototype. +Before writing code, write down what state model and what question you're prototyping. One paragraph, at the top of the demo (in a visible intro, not just a comment). A logic prototype that answers the wrong question is pure waste — make the question explicit so it can be checked later, whether the user is watching now or returning to it AFK. -### 3. Isolate the logic in a portable module +### 2. Isolate the logic in a portable module -Put the actual logic — the bit that's answering the question — behind a small, pure interface that could be lifted out and dropped into the real codebase later. The TUI around it is throwaway; the logic module shouldn't be. +Put the actual logic — the bit that's answering the question — in a single `