From d5d76fa8e2ed67a3f8e23a12c120bef94ce7caa9 Mon Sep 17 00:00:00 2001 From: Max Boettinger Date: Thu, 3 Sep 2026 10:48:10 +0200 Subject: [PATCH 01/14] docs: AI features design spec and implementation plan Claude-Session: https://claude.ai/code/session_01L2voqEC2eEwLsiTxoSnCGD --- .../plans/2026-09-03-ai-features.md | 206 ++++++++++++++++ .../specs/2026-09-03-ai-features-design.md | 230 ++++++++++++++++++ 2 files changed, 436 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-03-ai-features.md create mode 100644 docs/superpowers/specs/2026-09-03-ai-features-design.md diff --git a/docs/superpowers/plans/2026-09-03-ai-features.md b/docs/superpowers/plans/2026-09-03-ai-features.md new file mode 100644 index 0000000..9e8883d --- /dev/null +++ b/docs/superpowers/plans/2026-09-03-ai-features.md @@ -0,0 +1,206 @@ +# AI Features Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an injected OpenRouter-backed `AIClient` to the core layer and two task verbs on top of it: `task why` (interactive five-whys session) and `task breakdown` (structured nano-task plan → preview → revise → apply as a subtask tree). + +**Architecture:** `src/core/ai/` is a new core module (types, config, prompt loader, context renderer, plan schema/validation, lazy OpenRouter adapter). `buildProgram(client, ai)` threads the AI client through `Register = (parent, client, ai)`. Two new bridge ops (`task.context`, `task.createTree`) gather context and apply a plan in one osascript call each. Interactive input is an entity-agnostic `ui/prompt.ts` primitive. + +**Tech Stack:** Bun, TypeScript strict, Commander 13, `@openrouter/sdk` (lazy import), `bun test`, Biome (tabs, double quotes, 100 cols). `src/jxa/bridge.js` is pre-ES6 JXA. + +**Spec:** `docs/superpowers/specs/2026-09-03-ai-features-design.md` + +## Global Constraints + +- Verify with `bun run check && bun run typecheck && bun test` before every commit. +- `src/jxa/bridge.js`: only `var`, `function`, `for`; no `let`/`const`, arrows, template literals, destructuring, spread; every risky property read in `try/catch`. +- Commit messages end with `Claude-Session: https://claude.ai/code/session_01L2voqEC2eEwLsiTxoSnCGD`. +- `--json` is declared on the root only; verbs read it through `runAction`. +- The JSON interface carries zero UI chrome; the AI SDK and `yocto-spinner` are imported lazily, never at module top level. +- Verb `.description()` strings contain no parentheses, colons or semicolons. +- Use `parseIntOption`, never bare `parseInt`. +- JSON output never contains short ids; human output may. +- Never touch the real short-id cache or the real user config dir in tests (`OF_CONFIG_DIR`, `OF_PROMPTS_DIR` test seams). +- Docs updated at the end: README, CLAUDE.md, CHANGELOG, `src/core/docs.md`, `src/core/ai/docs.md` (new), `src/core/ui/docs.md`, `src/commands/docs.md`, `src/jxa/docs.md`, `test/docs.md`, `~/.agents/skills/omnifocus-cli/SKILL.md`. + +--- + +## File map + +| Path | Responsibility | +|------|----------------| +| `src/core/ai/types.ts` | `Message`, `ChatRequest`, `ChatResult`, `StructuredSchema`, `StructuredResult`, `AIClient`, `AIError` (+ `AIErrorKind`) | +| `src/core/ai/config.ts` | `resolveAIConfig({ model? })`: flag > env > config file > default; `configPath()`; `DEFAULT_MODEL` | +| `src/core/ai/prompts.ts` | `loadPrompt(name)`: user override dir → embedded Markdown | +| `src/core/ai/context.ts` | `renderTaskContext(ctx: TaskContext, today)`: Markdown block for the first user message | +| `src/core/ai/plan.ts` | `PLAN_SCHEMA`, `validatePlan(raw)`, `buildPlanTree(plan)`, `PLAN_STRUCTURED` (schema+validator bundle) | +| `src/core/ai/openrouter.ts` | the only importer of `@openrouter/sdk`; `createOpenRouterClient(config)`; error mapping; `isSdkLoaded()` | +| `src/core/ai/client.ts` | `createAIClient()`: lazy adapter that resolves config + imports the SDK on first call | +| `src/core/ai/conversation.ts` | `Conversation`: message list helper (`system`, `user`, `assistant`, `messages`) | +| `src/core/ui/prompt.ts` | `createPrompter(streams)`: `ask(question)`, `choose(question, keys)`, `close()`; Esc/Ctrl-C/Ctrl-D → `null` | +| `src/core/ui/progress.ts` | + `withSpinner(label, fn, stream?)` (extracted from the proxy) | +| `src/core/output.ts` | + `outputPlanTree(target, tree)`, `outputTreeResult(result)` | +| `src/core/types.ts` | + `TaskContext`, `ContextNode`, `TaskContextOptions`, `PlanTaskInput`, `CreateTreeOptions`, `CreateTreeResult`; `OmniFocusClient.getTaskContext`, `createTaskTree` | +| `src/core/client.ts` | + `getTaskContext`, `createTaskTree` | +| `src/jxa/bridge.js` | + `ops["task.context"]`, `ops["task.createTree"]` | +| `src/prompts/why.md`, `src/prompts/breakdown.md` | system prompts | +| `src/commands/noun.ts` | `Register = (parent, client, ai) => void` | +| `src/program.ts` | `buildProgram(client, ai)` | +| `src/index.ts` | `createAIClient()` | +| `src/commands/task/why.ts`, `breakdown.ts` | the verbs; `task/index.ts` mounts them with aliases `w`, `b` | +| `test/fixtures/fake-ai.ts` | `createFakeAI({ replies })` scripted `AIClient` | +| `test/helpers/run.ts` | `runCommand(setup, argv, client?, ai?)` | +| `test/core/ai/*.test.ts`, `test/core/ui/prompt.test.ts`, `test/integration/ai.test.ts`, `test/jxa/task-context.test.ts`, `test/jxa/task-create-tree.test.ts` | tests | + +--- + +### Task 1: AI types, config and prompt loader + +**Files:** create `src/core/ai/types.ts`, `src/core/ai/config.ts`, `src/core/ai/prompts.ts`, `src/prompts/why.md`, `src/prompts/breakdown.md`; tests `test/core/ai/config.test.ts`, `test/core/ai/prompts.test.ts`; `test/preload.ts` sets `OF_CONFIG_DIR` and `OF_PROMPTS_DIR` to temp dirs. + +**Produces:** +```ts +export type Role = "system" | "user" | "assistant"; +export interface Message { role: Role; content: string } +export interface ChatRequest { messages: Message[]; model?: string; temperature?: number; maxTokens?: number; signal?: AbortSignal } +export interface ChatResult { content: string; model: string; usage?: { prompt: number; completion: number } } +export interface ValidationFailure { errors: string[] } +export interface StructuredSchema { name: string; schema: Record; validate(raw: unknown): { value: T } | ValidationFailure } +export interface StructuredResult { value: T; raw: string; model: string; attempts: number } +export interface AIClient { chat(req): Promise; stream(req, onDelta): Promise; structured(req, schema): Promise> } +export type AIErrorKind = "missing-key" | "auth" | "credits" | "rate-limit" | "bad-request" | "invalid-response" | "network" | "aborted"; +export class AIError extends CLIError { readonly kind: AIErrorKind } +// config.ts +export interface AIConfig { apiKey: string; model: string; referer: string; title: string } +export function configDir(): string // $OF_CONFIG_DIR | $XDG_CONFIG_HOME/omnifocus-cli | ~/.config/omnifocus-cli +export function resolveAIConfig(overrides?: { model?: string }): AIConfig // throws AIError("missing-key") +export function describeAISetup(): string // help text used by the error +// prompts.ts +export type PromptName = "why" | "breakdown"; +export function loadPrompt(name: PromptName): { text: string; source: "override" | "embedded"; path?: string } +``` + +Tests: precedence (flag > `OF_AI_MODEL` > file > default), key from env vs file, missing key → `AIError` kind `missing-key` mentioning `OPENROUTER_API_KEY` and the config path, malformed config file ignored with no throw; prompt override found in `OF_PROMPTS_DIR`, embedded fallback non-empty, unknown override file ignored. + +Commit: `feat(ai): AI client types, config resolution and prompt loader`. + +### Task 2: Plan schema, validation and tree building + +**Files:** create `src/core/ai/plan.ts`; test `test/core/ai/plan.test.ts`. + +**Produces:** +```ts +export interface PlanTask { key: string; parentKey: string | null; name: string; note: string; estimateMinutes: number | null; tags: string[]; flag: boolean; sequential: boolean; due: string | null; defer: string | null } +export interface Plan { summary: string; sequential: boolean; tasks: PlanTask[]; questions: string[] } +export interface PlanNode extends PlanTask { children: PlanNode[] } +export const PLAN_SCHEMA: Record; +export function validatePlan(raw: unknown): { value: Plan } | ValidationFailure; +export function buildPlanTree(plan: Plan): PlanNode[]; +export function countPlanTasks(plan: Plan): number; +export const PLAN_STRUCTURED: StructuredSchema; +``` +Validation rules from the spec. Tests: valid plan passes; duplicate key, unknown parent, forward parent reference, empty name, non-integer estimate, non-array tags each produce a message naming the key; tree nests two levels correctly and preserves order. + +Commit: `feat(ai): breakdown plan schema, validation and tree builder`. + +### Task 3: Bridge ops `task.context` and `task.createTree` + +**Files:** modify `src/jxa/bridge.js` (after `ops["task.update"]`), `src/core/types.ts`, `src/core/client.ts`, `test/fixtures/mock-client.ts`, `test/fixtures/mock-responses.ts` (+ `MOCK_TASK_CONTEXT`); tests `test/jxa/task-context.test.ts`, `test/jxa/task-create-tree.test.ts`, `test/core/client.test.ts` (op names). + +**Produces (types.ts):** +```ts +export interface ContextNode extends OFTask { children: ContextNode[] } +export interface TaskContext { task: OFTask; ancestors: OFTask[]; project: OFProject | null; children: ContextNode[]; siblings: { id: string; name: string; completed: boolean }[]; tags: string[] } +export interface TaskContextOptions { query?: string; id?: string } +export interface PlanTaskInput { key: string; parentKey: string | null; name: string; note?: string; estimate?: number | null; tags?: string[]; flag?: boolean; sequential?: boolean; due?: string | null; defer?: string | null } +export interface CreateTreeOptions { parentId?: string; projectId?: string; sequential?: boolean; tasks: PlanTaskInput[] } +export interface CreateTreeItem { key: string; ok: boolean; id?: string; name: string; error?: string; warnings?: string[] } +export interface CreateTreeResult { parent: { id: string; name: string; project: string }; created: CreateTreeItem[] } +// OmniFocusClient +getTaskContext(opts: TaskContextOptions): Promise>; +createTaskTree(opts: CreateTreeOptions): Promise>; +``` +Bridge: `task.context` resolves by `id` (`findTaskById`) or `query` (`findTaskByQuery`), walks `parentTask()` up (max 20), reads `containingProject()` via `formatProject`, recurses `task.tasks()` with a 200-node budget (completed included), siblings via batch `parent.tasks.name()/id()/completed()` (or `project.tasks` / `doc.inboxTasks`), tags via `doc.flattenedTags.name()`. `task.createTree` validates exactly one of `parentId`/`projectId`, applies `sequential` to the parent task, iterates items: resolve container (parent target or `byKey[parentKey]`), skip with error if the ancestor failed, `of.Task({name, note})` pushed into `container.tasks`, `applyTaskProps` with `{estimate, tags, flag, sequential, due, defer}` inside try/catch, record `{key, ok, id, name, warnings}`. Timeout 120s in `client.ts`. + +Commit: `feat(bridge): task.context and task.createTree ops`. + +### Task 4: UI primitives — prompter and `withSpinner` + +**Files:** create `src/core/ui/prompt.ts`; modify `src/core/ui/progress.ts`; tests `test/core/ui/prompt.test.ts`, extend `test/core/ui/progress.test.ts`. + +**Produces:** +```ts +export interface PrompterStreams { input?: NodeJS.ReadableStream & { isTTY?: boolean; setRawMode?(m: boolean): unknown }; output?: NodeJS.WritableStream } +export interface Prompter { ask(question: string): Promise; choose(question: string, keys: string[]): Promise; close(): void } +export function createPrompter(streams?: PrompterStreams): Prompter; +export const QUIT_COMMANDS = ["/quit", "/q", "/exit"]; +// progress.ts +export async function withSpinner(label: string, fn: () => Promise, stream?: ProgressStream): Promise; +``` +`ask` creates a readline interface per question (`terminal: input.isTTY === true`), listens to a raw `data` chunk equal to `\x1b` (Esc), `SIGINT` (Ctrl-C), `close` (Ctrl-D/EOF); resolves `null` on any of them, trims answers, re-asks on empty input, and maps `QUIT_COMMANDS` to `null`. `choose` accepts the first character of the answer (case-insensitive) if it is in `keys`, otherwise re-asks. `withSpinner` respects the same two gates as `withProgress` (which now calls it). + +Tests with `PassThrough` streams: answer line, `/quit`, Esc byte, Ctrl-C, EOF, empty then answer, `choose` invalid then valid. + +Commit: `feat(ui): interactive prompter and withSpinner helper`. + +### Task 5: OpenRouter adapter and `createAIClient` + +**Files:** `package.json` (+ `@openrouter/sdk`), create `src/core/ai/openrouter.ts`, `src/core/ai/client.ts`, `src/core/ai/conversation.ts`; tests `test/core/ai/openrouter.test.ts` (against a `Bun.serve` fake on `127.0.0.1`), `test/core/ai/client.test.ts`. + +**Produces:** +```ts +export function createOpenRouterClient(config: AIConfig, opts?: { serverURL?: string }): Promise; +export function isSdkLoaded(): boolean; +export function createAIClient(overrides?: { model?: string }): AIClient; // lazy; resolves config + SDK on first call +export class Conversation { constructor(system: string); user(text): this; assistant(text): this; get messages(): Message[] } +``` +Adapter behaviour: `chat` → `client.chat.send({ model, messages, temperature, max_tokens })`, content extracted from `choices[0].message.content` (string or content-part array); `stream` → `stream: true`, concatenates `choices[0].delta.content`, calls `onDelta`; `structured` → `response_format: { type: "json_schema", json_schema: { name, strict: true, schema } }`, `provider: { require_parameters: true }`, parses JSON, validates, on failure appends assistant raw + user "fix these problems" and retries once (`attempts` = 2); throws `AIError("invalid-response")` after that. HTTP 401 → `auth`, 402 → `credits`, 429 → `rate-limit`, 400 → `bad-request`, abort → `aborted`, other → `network`. + +Fake server tests: request body shape (model, messages, response_format, headers `HTTP-Referer`/`X-Title`, `Authorization`), streaming SSE assembly, 401/402/429 mapping, structured retry then success, structured double failure → `invalid-response`. + +Commit: `feat(ai): OpenRouter adapter with chat, streaming and structured output`. + +### Task 6: Threading the AI client through the program + +**Files:** modify `src/commands/noun.ts`, `src/program.ts`, `src/index.ts`, `test/helpers/run.ts`, `test/helpers/parse.ts` (if it passes a client), create `test/fixtures/fake-ai.ts`; extend `test/integration/program.test.ts`. + +**Produces:** +```ts +export type Register = (parent: Command, client: OmniFocusClient, ai: AIClient) => void; +export function buildProgram(client: OmniFocusClient, ai: AIClient): Command; +export function createFakeAI(script?: { replies?: string[]; plans?: unknown[] }): FakeAI; // FakeAI extends AIClient with `requests: ChatRequest[]` +export function runCommand(setup, argv, client?, ai?): Promise; // RunResult gains `ai` +``` +`createFakeAI` returns queued replies for `chat`/`stream` (calling `onDelta` once with the whole text) and queued plans for `structured` (run through the schema validator so tests exercise real validation); throws `AIError("invalid-response")` when the queue is empty. Program test: after `task list --json`, `isSdkLoaded()` is false. + +Commit: `feat(program): inject the AI client alongside the OmniFocus client`. + +### Task 7: Context renderer + +**Files:** create `src/core/ai/context.ts`; test `test/core/ai/context.test.ts`. + +**Produces:** `renderTaskContext(ctx: TaskContext, opts: { today: string; extra?: string }): string` — Markdown with sections: Target task (name, id, note, dates, flag, estimate, tags, sequential, blocked), Ancestors (nearest first), Project (name, status, sequential, counts, due), Existing subtasks (indented tree, `[x]`/`[ ]`, estimates), Siblings (names with `[x]`/`[ ]`, capped at 40 with "… and N more"), Available tags, Additional context from the user. Empty sections say "none". Test: snapshot-free assertions on each section, cap behaviour, `extra` inclusion. + +Commit: `feat(ai): render OmniFocus task context for prompts`. + +### Task 8: `task breakdown` verb + renderers + +**Files:** create `src/commands/task/breakdown.ts`; modify `src/commands/task/index.ts` (mount, alias `b`), `src/core/output.ts` (+ `outputPlanTree`, `outputTreeResult`), `src/core/ui/progress.ts` labels (`getTaskContext: "Gathering task context…"`, `createTaskTree: "Creating subtasks…"`); tests `test/integration/ai.test.ts` (breakdown cases), `test/core/output.test.ts` (tree rendering). + +Flow (human): context → `structured` under `withSpinner("Thinking…")` → `outputPlanTree` → `choose("[a]pply, [r]evise or [q]uit", ["a","r","q"])` → loop. JSON: plan only unless `--apply`. Exit 1 if any created item failed. `--context` text appended to the first user message. Conversation: system = `loadPrompt("breakdown")`, user = context + "Break this task down.", assistant = previous plan JSON, user = feedback. + +Commit: `feat(task): AI breakdown into nano subtasks with preview, revise and apply`. + +### Task 9: `task why` verb + +**Files:** create `src/commands/task/why.ts`; modify `src/commands/task/index.ts` (alias `w`); tests in `test/integration/ai.test.ts`. + +Flow: refuse when not interactive (`isInteractive(process.stdin)`/stdout or format json) with `CLIError`; optional ref → context; conversation system = `loadPrompt("why")`; first user message = context + "Start the session with your first question."; loop: `stream` assistant text to stdout (prefixed line), `ask("> ")`, `null` → break; print `dim("Session ended.")`. `AbortController` per stream call, aborted on Ctrl-C via the prompter's SIGINT path (a `process.once("SIGINT")` while streaming). + +Commit: `feat(task): AI five-whys session for avoided tasks`. + +### Task 10: Completion parity, docs, changelog + +**Files:** README (new "AI features" section + command reference rows), CLAUDE.md, CHANGELOG (Unreleased), `src/core/ai/docs.md` (new Noridoc), `src/core/docs.md`, `src/core/ui/docs.md`, `src/commands/docs.md`, `src/jxa/docs.md`, `test/docs.md`, `~/.agents/skills/omnifocus-cli/SKILL.md`. + +Verify `test/integration/completion.test.ts` still passes (new verbs are picked up automatically). Commit: `docs: AI features`. diff --git a/docs/superpowers/specs/2026-09-03-ai-features-design.md b/docs/superpowers/specs/2026-09-03-ai-features-design.md new file mode 100644 index 0000000..e097ddf --- /dev/null +++ b/docs/superpowers/specs/2026-09-03-ai-features-design.md @@ -0,0 +1,230 @@ +# AI features — design + +Date: 2026-09-03 +Status: design decided autonomously (user asked for brainstorm → plan → implement in one pass); implementation follows the plan in `docs/superpowers/plans/2026-09-03-ai-features.md`. + +## Goal + +Make `of` AI-capable in a way that is "AI first": one LLM client (OpenRouter) that +lives in the core layer, is injected like the OmniFocus client, and can be used from any +verb. Ship two features on top of it: + +1. **`of task why [ref]`** — an interactive "five whys" coaching session that drills into + why a task (or anything) is being avoided. Turn by turn, adaptive, ends only when the + user quits (Esc / Ctrl-C / Ctrl-D / `/quit`). +2. **`of task breakdown `** — break a task into granular, single-step, AuDHD-friendly + nano tasks using structured output, preview them, revise with feedback as often as + wanted, then apply them to OmniFocus as a nested subtask tree in one bridge operation. + +Prompts are plain Markdown files in one folder, loaded at runtime and overridable per user. + +## Non-goals + +- A general chat REPL (`of ai chat`), inbox triage, or other AI verbs. The architecture + makes them cheap to add later; they are not part of this change. +- Provider abstraction beyond OpenRouter. OpenRouter is itself the multi-provider layer. +- Persisting `why` transcripts into OmniFocus. Deliberately left out of v1 (see Open + follow-ups) so the session stays a zero-side-effect conversation. +- Streaming for the structured breakdown call (a JSON blob is not readable mid-stream). + +## Research summary + +- **OpenRouter SDK** (verified against `@openrouter/sdk@1.2.100`, 2026-09-03): official, + Speakeasy-generated, ESM-only, sole runtime dependency `zod`; Bun ≥ 1 is a supported + runtime; adds ~0.7 MB to the compiled binary. `new OpenRouter({ apiKey, httpReferer, + appTitle, serverURL? })`; `client.chat.send({ chatRequest: { model, messages, + temperature, maxCompletionTokens, responseFormat: { type: "json_schema", jsonSchema: + { name, strict, schema } }, provider: { requireParameters: true }, stream } }, + { signal })`. Non-streaming result: `choices[0].message.content` (string or content + parts), `model`, `usage.promptTokens/completionTokens`. Streaming result is an + `EventStream` async iterable of chunks with `choices[0].delta.content`. Errors are + typed classes extending `OpenRouterError` with `statusCode` (401/402/429/400 …) plus + `RequestAbortedError`/`ConnectionError` for transport failures. Attribution headers + are `HTTP-Referer` and `X-OpenRouter-Title`. `openrouter/auto`, `:nitro`/`:floor` + suffixes and a `models: []` fallback list are supported. `anthropic/claude-sonnet-4` + does not advertise structured outputs; `openai/gpt-4.1-mini`, `google/gemini-2.5-flash` + and `anthropic/claude-sonnet-5` do. Default model: `google/gemini-2.5-flash` (fast, + cheap, strict schema support); overridable everywhere. +- **Config precedence** in mature AI CLIs (`llm`, `aichat`, `mods`, `fabric`, `sgpt`): + flag > env var > config file > built-in default; keys in env or a config file under + `$XDG_CONFIG_HOME//`; prompts ("patterns", "roles", "templates") as plain text + files in one directory with user-local overrides. +- **Preview → apply**: terraform-style plan/apply; agents get a JSON plan on stdout and an + explicit `--apply` flag; humans get a rendered preview and a confirm/revise/quit prompt. +- **Structured output**: strict JSON-schema mode is widely supported through OpenRouter, + but recursive `$ref` schemas are not portable across providers. A flat list with + `parentKey` references is portable and trivially validated, so the tree is flattened in + the schema and rebuilt in TypeScript. +- **Esc in Bun**: verified by spike — Bun's `readline.emitKeypressEvents` never flushes a + lone `ESC` (no `escapeCodeTimeout`), so Esc is detected from a raw one-byte `\x1b` chunk + on stdin; readline's `SIGINT` event covers Ctrl-C and `close` covers Ctrl-D. + +## Decisions (with rationale) + +1. **AI is a core service, injected like OmniFocus.** `src/core/ai/` exposes an + `AIClient` interface (`types.ts`) and `createAIClient()`; `src/index.ts` passes it to + `buildProgram(client, ai)`, and `Register` becomes `(parent, client, ai)`. Verbs that + don't use AI ignore the third argument. Tests inject `createFakeAI()` exactly as they + inject the mock OmniFocus client. This is the "AI first" requirement: any verb, today + or later, gets the LLM without wiring. +2. **The SDK is wrapped and lazily imported.** `AIClient` is our narrow interface + (`chat`, `stream`, `structured`); the SDK lives only in `src/core/ai/openrouter.ts` and + is `await import()`ed on first use, so `task list --json` and every non-AI run never + evaluate it (same rule as `yocto-spinner`). `test/integration/program.test.ts` guards + this. +3. **Verbs live under `task`.** `task breakdown|b ` and `task why|w [ref]` — both act + on a task, so they follow the noun-verb rule instead of an `ai` noun. Letters `b`/`w` + are free in the `task` noun. +4. **Prompts are Markdown files in `src/prompts/`,** one per feature (`why.md`, + `breakdown.md`), embedded with a Bun text import (so the compiled binary carries them, + and `bun run dev` reads the file fresh every run) and overridable at runtime by + `$OF_PROMPTS_DIR/.md` or `~/.config/omnifocus-cli/prompts/.md`. The + loader is `src/core/ai/prompts.ts`. No templating: dynamic context is sent as the + first user message, not spliced into the system prompt. +5. **Config precedence: flag > env > config file > default.** `--model` on the verb; + `OPENROUTER_API_KEY`, `OF_AI_MODEL`; `$XDG_CONFIG_HOME/omnifocus-cli/config.json` + (`{ "ai": { "apiKey"?, "model"? } }`); default model constant in `config.ts`. A + missing key throws an `AIError` with setup instructions before any network call. +6. **Structured output uses a flat plan schema.** `PlanSchema` items carry + `key`/`parentKey`; validation (`plan.ts`) checks the tree (keys unique, parent defined + earlier, no cycles) and rebuilds it. On a validation failure `structured()` retries + once, feeding the errors back to the model; a second failure is an `AIError`. +7. **Apply is one bridge op.** New `task.createTree` op creates the whole subtree in a + single osascript call (parents before children, per-item soft failures, warnings + preserved, the target's `sequential` flag applied), instead of N round-trips from + TypeScript. New `task.context` op gathers everything the prompt needs (task, ancestor + chain, project, full existing subtree incl. completed, siblings, all tag names) in one + call. +8. **Interactive means interactive.** Both verbs require an interactive stdin+stdout + in human mode; `why` refuses to run non-interactively. `breakdown` has a + non-interactive contract for agents: `--json` prints the plan and applies nothing; + `--json --apply` applies and prints the result. `--context ""` adds free-form + user context to the request in any mode. +9. **UI primitives stay entity-agnostic.** The line/keypress prompter (`ui/prompt.ts`) + and the spinner helper (`withSpinner`, extracted from `progress.ts`) go in + `src/core/ui/`; the tree renderer for plans goes in `src/core/output.ts`. + +## Architecture + +``` +src/commands/task/why.ts, breakdown.ts (verbs; need both clients) + │ + ├── src/core/ai/context.ts OmniFocus data → Markdown context block + ├── src/core/ai/plan.ts PLAN_SCHEMA, validatePlan(), buildTree() + ├── src/core/ai/prompts.ts loadPrompt(name) with user override + ├── src/core/ai/config.ts resolveAIConfig({ model? }) + ├── src/core/ai/types.ts AIClient, ChatRequest, Message, AIError kinds + ├── src/core/ai/client.ts createAIClient() → lazy OpenRouter adapter + └── src/core/ai/openrouter.ts the only file importing @openrouter/sdk +src/core/ui/prompt.ts ask()/choose() with Esc/Ctrl-C/Ctrl-D → null +src/core/ui/progress.ts + withSpinner(label, fn) +src/core/output.ts + outputPlanTree(), outputTreeResult() +src/jxa/bridge.js + ops["task.context"], ops["task.createTree"] +src/prompts/why.md, breakdown.md +``` + +### `AIClient` + +```ts +interface Message { role: "system" | "user" | "assistant"; content: string } +interface ChatRequest { + messages: Message[]; // system prompt is messages[0] + model?: string; // resolved by config when absent + temperature?: number; + maxTokens?: number; + signal?: AbortSignal; +} +interface ChatResult { content: string; model: string; usage?: { prompt: number; completion: number } } +interface AIClient { + chat(req: ChatRequest): Promise; + stream(req: ChatRequest, onDelta: (text: string) => void): Promise; + structured(req: ChatRequest, schema: StructuredSchema): Promise>; +} +interface StructuredSchema { name: string; schema: Record; validate(raw: unknown): T | ValidationFailure } +``` + +`AIError extends CLIError` with `kind: "missing-key" | "auth" | "credits" | "rate-limit" | "bad-request" | "invalid-response" | "network"`, mapped from SDK errors in `openrouter.ts`. + +### Plan schema (structured output) + +```jsonc +{ + "summary": "one sentence on the approach", + "sequential": true, // how the target's children should be ordered + "tasks": [ + { + "key": "1", "parentKey": null, // parentKey refers to an earlier item's key + "name": "Open the tax portal in the browser", + "note": "…or empty string", + "estimateMinutes": 5, // or null + "tags": ["@computer"], // only names from the provided tag list + "flag": false, + "sequential": false, // ordering of this item's own children + "due": null, "defer": null // OmniFocus-parseable text or null + } + ], + "questions": ["anything the model wants the user to clarify"] +} +``` + +All properties are required (strict mode), nullable where optional. Validation rules: +`tasks` non-empty, keys unique and non-empty, `parentKey` null or an earlier key, names +non-empty and ≤ 200 chars, `estimateMinutes` null or integer ≥ 1. + +### Bridge ops + +- `task.context { query?, id? }` → `{ task: OFTask, ancestors: OFTask[], project: OFProject | null, children: ContextNode[], siblings: { id, name, completed }[], tags: string[] }` where `ContextNode = OFTask & { children: ContextNode[] }` (completed children included, subtree capped at 200 nodes). Siblings and tags use batch reads. +- `task.createTree { parentId?, projectId?, sequential?, tasks: PlanTask[] }` → `{ parent: { id, name }, created: [{ key, ok, id?, name, error?, warnings? }] }`. Items are created in array order under `parentId`/`projectId` (or under the item named by `parentKey`); a failed item is recorded and its descendants are skipped with an error naming the failed ancestor. Exactly one of `parentId`/`projectId` is required. + +### Command contracts + +**`of task why [ref] [--model ] [--context ]`** +- Human/interactive only. Non-interactive → `CLIError("task why is an interactive session; run it in a terminal")`, exit 1. +- With a ref: `task.context` is fetched and rendered into the first user message; without: the first user message says there is no specific task. +- Loop: assistant turn is streamed to stdout; the user answers on one line; Esc / Ctrl-C / Ctrl-D / `/quit` / `/q` ends the session. Empty answers are ignored (re-prompt). +- The full history (system + every turn) is sent each time. Temperature 0.7. + +**`of task breakdown [--context ] [--model ] [--apply] [--json]`** +- Fetch `task.context`, build the request, call `structured()` with `PLAN_SCHEMA`. +- Human mode: render the tree preview; prompt `[a]pply · [r]evise · [q]uit`. `r` asks for + a feedback line, appends `{assistant: }` and `{user: }` to the + conversation and re-runs `structured()`, then re-renders. `a` applies via + `task.createTree` and prints the result tree with per-item ✓/✗ and warnings; exits 1 if + any item failed. `q` exits 0 with nothing changed. `--apply` skips the prompt. +- JSON mode: prints `{ target: { id, name, project }, plan, applied: null }` and exits 0 + without applying; with `--apply` it applies and prints `applied: { parent, created }` + (exit 1 if any item failed). Revision is not available in JSON mode (agents can re-run + with a richer `--context`). +- Temperature 0.2. + +### Error handling + +- Missing key / bad key / no credits / rate limit → `AIError` with a one-line fix hint + (env var name, config path, model id). All go through `outputError` (JSON line when + piped). +- Bridge failures (task not found, ambiguous) behave exactly like other task verbs. +- A quit at any prompt never leaves partial state: creation only happens inside one + `task.createTree` call; per-item failures inside it are reported, not hidden. +- Ctrl-C during a streaming response aborts the request via `AbortSignal` and ends the + session cleanly. + +### Testing + +- `test/fixtures/fake-ai.ts`: scripted `AIClient` (queue of text/plan responses, + records every request) — the AI counterpart of `createMockClient()`. +- `test/core/ai/`: config precedence; prompt loading + override; plan validation + + tree building (cycles, unknown parents, order); context rendering; `openrouter.ts` + error mapping and request shaping against a local `Bun.serve` fake endpoint (real SDK, + no network). +- `test/core/ui/prompt.test.ts`: lines, `/quit`, raw Esc byte, Ctrl-C, Ctrl-D, EOF. +- `test/integration/ai.test.ts`: `breakdown --json` (plan only, no client mutation), + `--json --apply`, human revise → apply loop with scripted stdin, `why` non-interactive + refusal, `why` scripted session ending on Esc. +- `test/jxa/task-context.test.ts`, `test/jxa/task-create-tree.test.ts`. +- `test/integration/program.test.ts`: AI SDK module not loaded for non-AI commands. + +## Open follow-ups (not in this change) + +- `task why --save` to append a session summary to the task note. +- `project breakdown` (the bridge op already accepts `projectId`). +- Per-prompt front matter (model/temperature per prompt file). From 0863a7f7263fe726402538aaea49999a6b7554c3 Mon Sep 17 00:00:00 2001 From: Max Boettinger Date: Thu, 3 Sep 2026 10:50:29 +0200 Subject: [PATCH 02/14] feat(ai): AI client types, config resolution and prompt loader Claude-Session: https://claude.ai/code/session_01L2voqEC2eEwLsiTxoSnCGD --- bun.lock | 5 ++ package.json | 1 + src/core/ai/config.ts | 98 ++++++++++++++++++++++++++++++++++++ src/core/ai/prompts.ts | 47 +++++++++++++++++ src/core/ai/types.ts | 94 ++++++++++++++++++++++++++++++++++ src/prompts/breakdown.md | 61 ++++++++++++++++++++++ src/prompts/why.md | 41 +++++++++++++++ src/types.d.ts | 5 ++ test/core/ai/config.test.ts | 93 ++++++++++++++++++++++++++++++++++ test/core/ai/prompts.test.ts | 55 ++++++++++++++++++++ test/preload.ts | 8 +++ 11 files changed, 508 insertions(+) create mode 100644 src/core/ai/config.ts create mode 100644 src/core/ai/prompts.ts create mode 100644 src/core/ai/types.ts create mode 100644 src/prompts/breakdown.md create mode 100644 src/prompts/why.md create mode 100644 test/core/ai/config.test.ts create mode 100644 test/core/ai/prompts.test.ts diff --git a/bun.lock b/bun.lock index 58d5815..e0eba6b 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "omnifocus-cli", "dependencies": { + "@openrouter/sdk": "1.2.100", "commander": "^13.1.0", "yocto-spinner": "^1.2.2", }, @@ -34,6 +35,8 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="], + "@openrouter/sdk": ["@openrouter/sdk@1.2.100", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-AOaom06hFT/4pFw1XE+VeyHud2PM44mbxRVpFM0+4iP505OBW3AIhkXCIn32d2YKShH0MnU5/ORwY8sM4mCBhQ=="], + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], "@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="], @@ -49,5 +52,7 @@ "yocto-spinner": ["yocto-spinner@1.2.2", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng=="], "yoctocolors": ["yoctocolors@2.2.0", "", {}, "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg=="], + + "zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], } } diff --git a/package.json b/package.json index a372cf7..4772d6e 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "build": "bun build --compile src/index.ts --outfile of" }, "dependencies": { + "@openrouter/sdk": "1.2.100", "commander": "^13.1.0", "yocto-spinner": "^1.2.2" }, diff --git a/src/core/ai/config.ts b/src/core/ai/config.ts new file mode 100644 index 0000000..7bbe71f --- /dev/null +++ b/src/core/ai/config.ts @@ -0,0 +1,98 @@ +/** + * AI configuration: which model to talk to and with which key. + * + * Precedence follows the convention of mature AI CLIs (flag > environment > + * config file > built-in default): + * + * model: --model > $OF_AI_MODEL > config.json ai.model > DEFAULT_MODEL + * key: $OPENROUTER_API_KEY > config.json ai.apiKey + * + * The config file is `$OF_CONFIG_DIR/config.json` (test seam) or + * `$XDG_CONFIG_HOME/omnifocus-cli/config.json`, defaulting to + * `~/.config/omnifocus-cli/config.json`. It is optional and read + * best-effort: a missing or malformed file is treated as empty. + */ + +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { AIError } from "./types.js"; + +/** Cheap, fast, and supports strict JSON-schema output through OpenRouter. */ +export const DEFAULT_MODEL = "google/gemini-2.5-flash"; +export const APP_REFERER = "https://github.com/maxboettinger/omnifocus-cli"; +export const APP_TITLE = "omnifocus-cli"; + +export interface AIConfig { + apiKey: string; + model: string; + /** Sent as HTTP-Referer for OpenRouter app attribution. */ + referer: string; + /** Sent as X-OpenRouter-Title for OpenRouter app attribution. */ + title: string; +} + +export interface AIConfigOverrides { + model?: string; +} + +interface FileConfig { + apiKey?: string; + model?: string; +} + +/** `$OF_CONFIG_DIR` (test seam) or `$XDG_CONFIG_HOME`/`~/.config` + `omnifocus-cli`. */ +export function configDir(): string { + const override = process.env.OF_CONFIG_DIR; + if (override) return override; + const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config"); + return join(configHome, "omnifocus-cli"); +} + +export function configPath(): string { + return join(configDir(), "config.json"); +} + +function readFileConfig(): FileConfig { + try { + const parsed: unknown = JSON.parse(readFileSync(configPath(), "utf8")); + if (typeof parsed !== "object" || parsed === null) return {}; + const ai = (parsed as { ai?: unknown }).ai; + if (typeof ai !== "object" || ai === null) return {}; + const { apiKey, model } = ai as Record; + return { + apiKey: typeof apiKey === "string" && apiKey.trim() ? apiKey.trim() : undefined, + model: typeof model === "string" && model.trim() ? model.trim() : undefined, + }; + } catch { + return {}; + } +} + +/** How to configure the AI features — used in the missing-key error and docs. */ +export function describeAISetup(): string { + return [ + "AI commands need an OpenRouter API key (https://openrouter.ai/keys).", + "Set it with `export OPENROUTER_API_KEY=sk-or-...`, or store it in", + `${configPath()} as {"ai": {"apiKey": "sk-or-...", "model": "${DEFAULT_MODEL}"}}.`, + "Pick a model per run with --model , or globally with $OF_AI_MODEL.", + ].join("\n"); +} + +/** Resolve the model without requiring a key (for help text and previews). */ +export function resolveAIModel(overrides: AIConfigOverrides = {}): string { + return overrides.model || process.env.OF_AI_MODEL || readFileConfig().model || DEFAULT_MODEL; +} + +/** Resolve the full config; throws `AIError("missing-key")` when no key is configured. */ +export function resolveAIConfig(overrides: AIConfigOverrides = {}): AIConfig { + const file = readFileConfig(); + const apiKey = process.env.OPENROUTER_API_KEY || file.apiKey; + if (!apiKey) throw new AIError("missing-key", describeAISetup()); + return { + apiKey, + model: overrides.model || process.env.OF_AI_MODEL || file.model || DEFAULT_MODEL, + referer: APP_REFERER, + title: APP_TITLE, + }; +} diff --git a/src/core/ai/prompts.ts b/src/core/ai/prompts.ts new file mode 100644 index 0000000..3b1730b --- /dev/null +++ b/src/core/ai/prompts.ts @@ -0,0 +1,47 @@ +/** + * System prompts are plain Markdown files, one per feature, in `src/prompts/`. + * + * They are embedded with Bun text imports so the compiled binary carries + * them (and `bun run dev` picks up edits on the next run), and any of them + * can be overridden at runtime without rebuilding by dropping a file of the + * same name into `$OF_PROMPTS_DIR` or `~/.config/omnifocus-cli/prompts/`. + * The override wins whenever it exists and is not blank. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import breakdownPrompt from "../../prompts/breakdown.md" with { type: "text" }; +import whyPrompt from "../../prompts/why.md" with { type: "text" }; +import { configDir } from "./config.js"; + +export type PromptName = "why" | "breakdown"; + +const EMBEDDED: Record = { + why: whyPrompt, + breakdown: breakdownPrompt, +}; + +export const PROMPT_NAMES: readonly PromptName[] = ["why", "breakdown"]; + +export interface LoadedPrompt { + text: string; + source: "override" | "embedded"; + /** The override file that was used, when `source` is "override". */ + path?: string; +} + +/** `$OF_PROMPTS_DIR` (test seam / power users) or `/prompts`. */ +export function promptsDir(): string { + return process.env.OF_PROMPTS_DIR || join(configDir(), "prompts"); +} + +export function loadPrompt(name: PromptName): LoadedPrompt { + const overridePath = join(promptsDir(), `${name}.md`); + try { + const text = readFileSync(overridePath, "utf8"); + if (text.trim()) return { text, source: "override", path: overridePath }; + } catch { + // No override — fall through to the embedded prompt. + } + return { text: EMBEDDED[name], source: "embedded" }; +} diff --git a/src/core/ai/types.ts b/src/core/ai/types.ts new file mode 100644 index 0000000..f8f4ae6 --- /dev/null +++ b/src/core/ai/types.ts @@ -0,0 +1,94 @@ +/** + * The AI seam: the narrow LLM interface every command can depend on. + * + * Mirrors the OmniFocus side of the architecture — `OmniFocusClient` is + * the seam for Apple Events, `AIClient` is the seam for the language + * model. Commands receive an `AIClient` through registration and never + * import the OpenRouter SDK; tests inject a scripted fake. + */ + +import { CLIError } from "../errors.js"; + +export type Role = "system" | "user" | "assistant"; + +export interface Message { + role: Role; + content: string; +} + +export interface ChatRequest { + /** Full conversation; the system prompt is `messages[0]`. */ + messages: Message[]; + /** Model id; resolved from config when absent. */ + model?: string; + temperature?: number; + maxTokens?: number; + signal?: AbortSignal; +} + +export interface ChatUsage { + prompt: number; + completion: number; +} + +export interface ChatResult { + content: string; + /** The model that actually answered (OpenRouter may route). */ + model: string; + usage?: ChatUsage; +} + +export interface ValidationFailure { + errors: string[]; +} + +export function isValidationFailure(value: unknown): value is ValidationFailure { + return ( + typeof value === "object" && + value !== null && + Array.isArray((value as ValidationFailure).errors) + ); +} + +/** A JSON-schema-constrained response type plus its runtime validator. */ +export interface StructuredSchema { + name: string; + schema: Record; + validate(raw: unknown): { value: T } | ValidationFailure; +} + +export interface StructuredResult { + value: T; + /** The exact JSON text the model returned (kept for conversation history). */ + raw: string; + model: string; + /** 1 on first-try success, 2 when the validation-repair retry was needed. */ + attempts: number; +} + +export interface AIClient { + chat(req: ChatRequest): Promise; + stream(req: ChatRequest, onDelta: (text: string) => void): Promise; + structured(req: ChatRequest, schema: StructuredSchema): Promise>; +} + +export type AIErrorKind = + | "missing-key" + | "auth" + | "credits" + | "rate-limit" + | "bad-request" + | "invalid-response" + | "network" + | "aborted"; + +/** Any failure talking to the model, with a kind the caller can branch on. */ +export class AIError extends CLIError { + readonly kind: AIErrorKind; + + constructor(kind: AIErrorKind, message: string) { + super(message); + this.name = "AIError"; + this.kind = kind; + } +} diff --git a/src/prompts/breakdown.md b/src/prompts/breakdown.md new file mode 100644 index 0000000..c54f9f1 --- /dev/null +++ b/src/prompts/breakdown.md @@ -0,0 +1,61 @@ +# Role + +You break one OmniFocus task into granular, single-step "nano tasks" for a person who is +AuDHD (autistic and ADHD). For them, starting is the hardest part, vague verbs are +blockers, and every hidden sub-decision is a place to stall. Your job is to remove every +reason to hesitate: each nano task must be so concrete that it can be started without +thinking. + +# Rules for nano tasks + +1. **One observable action per task.** Starts with a concrete verb and names the object, + tool, place or person: "Open the tax portal in the browser", "Find last year's + invoice PDF in ~/Documents/Taxes", "Text Anna: which Sunday works?". Never "plan", + "think about", "research", "prepare", "deal with" — convert those into the physical + actions they consist of. +2. **The first task is an ignition step**: trivially small (under 2 minutes), zero + ambiguity, ideally just opening or fetching the thing. Momentum matters more than + efficiency. +3. **Small.** Most tasks 2–10 minutes; never more than 15. If an action would take + longer, split it. Provide `estimateMinutes` for every task (integer ≥ 1). +4. **Make implicit prep explicit.** Opening apps, finding files, gathering information, + logging in, deciding between options — each is its own task if it could stall someone. +5. **Decisions become tasks with the options listed in the note**: "Choose a date: A) + Sat 10th B) Sun 11th — pick one and move on." Waiting on someone else becomes an + explicit task ("Wait for reply from …") placed sequentially after the request. +6. **"Done" must be observable.** Write names so it is obvious when the task is finished. +7. **Nest when it helps.** If a step naturally has three or more sub-steps, make it a + parent task and put the sub-steps under it (`parentKey`). Nest as deep as needed; + there is no limit. A parent's own `sequential` says whether its children must be done + in order. +8. **Set `sequential` deliberately.** Top-level `sequential` describes the order of the + tasks you create under the target. `true` when steps depend on each other (usual for + a process), `false` when they can be done in any order (a checklist). +9. **Respect what exists.** Existing subtasks and completed work are in the context: do + not recreate them, do not duplicate completed steps, and continue from where the + person actually is. Do not restate the target task itself as a nano task. +10. **Tags only from the provided list**, and only when clearly right (e.g. an existing + context tag such as "@computer" or "errand"). Never invent tags. Leave `tags` empty + when unsure. Leave `due`/`defer` `null` unless the context makes a date necessary; + when you set one, use plain text OmniFocus understands ("tomorrow", "fri 5pm"). +11. **Flag nothing** unless the user asked for it. +12. **Use the person's language** (the task may be in German or English) and their + terminology from the note. +13. If crucial information is missing, still produce the best plan you can and list the + open points in `questions` (short, concrete). Otherwise `questions` is an empty + array. +14. `summary` is one sentence describing the approach you took, in the same language. + +# Revisions + +When the person sends feedback on a previous plan, return a complete corrected plan +(not a diff), keeping everything they did not object to, and address every point of the +feedback. + +# Output + +Respond with JSON only, matching the provided schema exactly: an object with `summary`, +`sequential`, `tasks` (each with `key`, `parentKey`, `name`, `note`, `estimateMinutes`, +`tags`, `flag`, `sequential`, `due`, `defer`) and `questions`. `key` values are short +unique strings ("1", "2", "2.1"); a `parentKey` must refer to a task listed earlier in the +array or be `null` for tasks directly under the target. List parents before children. diff --git a/src/prompts/why.md b/src/prompts/why.md new file mode 100644 index 0000000..29b8123 --- /dev/null +++ b/src/prompts/why.md @@ -0,0 +1,41 @@ +# Role + +You are a calm, sharp coach helping one person understand why they are avoiding a task, +using a "five whys" style of inquiry. You are not a therapist and you do not diagnose. +You ask, listen, reflect, and dig — one question at a time. + +The person is a knowledgeable adult who is AuDHD (autistic and ADHD). Avoidance and +procrastination for them are usually not laziness but a self-regulation loop: each +deferral relieves discomfort (ambiguity, effort, fear of judgment, sensory or social +load), that relief rewards the avoidance, and shame about avoiding feeds the next round. +Task initiation, switching, planning and time estimation are neurologically hard for +them. Unclear instructions and vague next steps are common triggers. + +# How to run the session + +- Ask **exactly one question per turn**. Never ask two questions in one message. +- Keep each turn short: at most one sentence of reflection plus the question. No lists, + no lectures, no "here are five tips". Warm, direct, no fluff, no exclamation marks. +- Start with the concrete situation (the task provided in the context, or ask what they + are avoiding if there is none), then go one level deeper each turn: what specifically + feels hard, what they expect to happen, what that would mean about them, what they are + protecting themselves from, and what is actually true. +- "Why" is the spirit, not the wording. Vary the form: "What happens right before you + close the tab?", "What would it mean if that went badly?", "What is the very first + physical action?", "Which part is unclear?". +- Adapt to the answers. If an answer names ambiguity, dig into what information is + missing. If it names fear, dig into the feared outcome and its realistic likelihood. + If it names overwhelm, dig into the smallest concrete piece. If it names boredom or + low reward, dig into what would make starting worth it in the next ten minutes. +- Reflect back what you heard in a few words before the next question, so they feel + understood, but do not over-validate and do not repeat their whole answer. +- Use the task context (project, parents, existing subtasks, dates, notes) to ask + precise questions instead of generic ones. +- Whenever you sense the real blocker has surfaced (usually after three to six + exchanges), say so in one sentence, name it plainly, and propose one tiny, concrete + first action that takes under five minutes and can be done right now — then ask + whether that feels doable, and continue if they want to go deeper. +- Answer in the language the person writes in (they may switch between German and + English). Match their register. +- The person ends the session themselves (Esc). Never say goodbye or wrap up on your + own; every one of your turns ends with a question. diff --git a/src/types.d.ts b/src/types.d.ts index 396a7fd..a2a3fff 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -3,3 +3,8 @@ declare module "*/jxa/bridge.js" { const source: string; export default source; } + +declare module "*.md" { + const text: string; + export default text; +} diff --git a/test/core/ai/config.test.ts b/test/core/ai/config.test.ts new file mode 100644 index 0000000..4c94d0a --- /dev/null +++ b/test/core/ai/config.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + DEFAULT_MODEL, + configPath, + describeAISetup, + resolveAIConfig, + resolveAIModel, +} from "../../../src/core/ai/config.js"; +import { AIError } from "../../../src/core/ai/types.js"; +import { withEnv } from "../../helpers/env.js"; + +function configDirWith(content?: string): string { + const dir = mkdtempSync(join(tmpdir(), "of-ai-config-")); + if (content !== undefined) { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "config.json"), content); + } + return dir; +} + +describe("resolveAIConfig", () => { + test("reads the key from OPENROUTER_API_KEY and falls back to the default model", () => { + const dir = configDirWith(); + withEnv({ OF_CONFIG_DIR: dir, OPENROUTER_API_KEY: "sk-env", OF_AI_MODEL: undefined }, () => { + const config = resolveAIConfig(); + expect(config.apiKey).toBe("sk-env"); + expect(config.model).toBe(DEFAULT_MODEL); + expect(config.referer).toContain("github.com"); + expect(config.title).toBe("omnifocus-cli"); + }); + }); + + test("reads key and model from the config file when the env is unset", () => { + const dir = configDirWith( + JSON.stringify({ ai: { apiKey: "sk-file", model: "openai/gpt-4.1-mini" } }), + ); + withEnv({ OF_CONFIG_DIR: dir, OPENROUTER_API_KEY: undefined, OF_AI_MODEL: undefined }, () => { + const config = resolveAIConfig(); + expect(config.apiKey).toBe("sk-file"); + expect(config.model).toBe("openai/gpt-4.1-mini"); + }); + }); + + test("model precedence is flag > env > file > default", () => { + const dir = configDirWith(JSON.stringify({ ai: { apiKey: "k", model: "file/model" } })); + withEnv({ OF_CONFIG_DIR: dir, OPENROUTER_API_KEY: undefined, OF_AI_MODEL: "env/model" }, () => { + expect(resolveAIConfig({ model: "flag/model" }).model).toBe("flag/model"); + expect(resolveAIConfig().model).toBe("env/model"); + expect(resolveAIModel()).toBe("env/model"); + }); + withEnv({ OF_CONFIG_DIR: dir, OPENROUTER_API_KEY: undefined, OF_AI_MODEL: undefined }, () => { + expect(resolveAIConfig().model).toBe("file/model"); + }); + }); + + test("env key wins over the file key", () => { + const dir = configDirWith(JSON.stringify({ ai: { apiKey: "sk-file" } })); + withEnv({ OF_CONFIG_DIR: dir, OPENROUTER_API_KEY: "sk-env" }, () => { + expect(resolveAIConfig().apiKey).toBe("sk-env"); + }); + }); + + test("a missing key throws an AIError naming the env var and config path", () => { + const dir = configDirWith(); + withEnv({ OF_CONFIG_DIR: dir, OPENROUTER_API_KEY: undefined }, () => { + let caught: unknown; + try { + resolveAIConfig(); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(AIError); + expect((caught as AIError).kind).toBe("missing-key"); + expect((caught as AIError).message).toContain("OPENROUTER_API_KEY"); + expect((caught as AIError).message).toContain(join(dir, "config.json")); + expect(configPath()).toBe(join(dir, "config.json")); + expect(describeAISetup()).toContain("--model"); + }); + }); + + test("a malformed or oddly shaped config file is ignored, not fatal", () => { + for (const content of ["{not json", '{"ai": "nope"}', '{"ai": {"apiKey": 42}}', "[]"]) { + const dir = configDirWith(content); + withEnv({ OF_CONFIG_DIR: dir, OPENROUTER_API_KEY: "sk-env", OF_AI_MODEL: undefined }, () => { + expect(resolveAIConfig().model).toBe(DEFAULT_MODEL); + expect(resolveAIModel()).toBe(DEFAULT_MODEL); + }); + } + }); +}); diff --git a/test/core/ai/prompts.test.ts b/test/core/ai/prompts.test.ts new file mode 100644 index 0000000..1904304 --- /dev/null +++ b/test/core/ai/prompts.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PROMPT_NAMES, loadPrompt, promptsDir } from "../../../src/core/ai/prompts.js"; +import { withEnv } from "../../helpers/env.js"; + +describe("loadPrompt", () => { + test("every prompt has a non-empty embedded default", () => { + for (const name of PROMPT_NAMES) { + const prompt = loadPrompt(name); + expect(prompt.source).toBe("embedded"); + expect(prompt.text.length).toBeGreaterThan(200); + expect(prompt.path).toBeUndefined(); + } + }); + + test("the why prompt asks one question per turn and the breakdown prompt demands JSON", () => { + expect(loadPrompt("why").text).toMatch(/one question per turn/i); + expect(loadPrompt("breakdown").text).toMatch(/JSON only/i); + }); + + test("an override file in OF_PROMPTS_DIR replaces the embedded prompt", () => { + const dir = mkdtempSync(join(tmpdir(), "of-prompts-")); + writeFileSync(join(dir, "why.md"), "# custom why\n"); + withEnv({ OF_PROMPTS_DIR: dir }, () => { + expect(promptsDir()).toBe(dir); + const prompt = loadPrompt("why"); + expect(prompt).toEqual({ + text: "# custom why\n", + source: "override", + path: join(dir, "why.md"), + }); + // Only the overridden prompt changes. + expect(loadPrompt("breakdown").source).toBe("embedded"); + }); + }); + + test("a blank override is ignored", () => { + const dir = mkdtempSync(join(tmpdir(), "of-prompts-")); + writeFileSync(join(dir, "breakdown.md"), " \n"); + withEnv({ OF_PROMPTS_DIR: dir }, () => { + expect(loadPrompt("breakdown").source).toBe("embedded"); + }); + }); + + test("defaults to /prompts when OF_PROMPTS_DIR is unset", () => { + const dir = mkdtempSync(join(tmpdir(), "of-config-")); + mkdirSync(join(dir, "prompts")); + writeFileSync(join(dir, "prompts", "why.md"), "from config dir"); + withEnv({ OF_PROMPTS_DIR: undefined, OF_CONFIG_DIR: dir }, () => { + expect(loadPrompt("why").text).toBe("from config dir"); + }); + }); +}); diff --git a/test/preload.ts b/test/preload.ts index c460d75..90ebe81 100644 --- a/test/preload.ts +++ b/test/preload.ts @@ -9,3 +9,11 @@ process.env.OF_SHORT_ID_CACHE = join( mkdtempSync(join(tmpdir(), "of-test-short-ids-")), "short-ids.json", ); + +// Same for the AI config file and prompt overrides: tests must never read +// the user's real ~/.config/omnifocus-cli, and a developer's own +// OPENROUTER_API_KEY / OF_AI_MODEL must not leak into assertions. +process.env.OF_CONFIG_DIR = mkdtempSync(join(tmpdir(), "of-test-config-")); +process.env.OF_PROMPTS_DIR = join(process.env.OF_CONFIG_DIR, "prompts"); +Reflect.deleteProperty(process.env, "OPENROUTER_API_KEY"); +Reflect.deleteProperty(process.env, "OF_AI_MODEL"); From 42dd2246cb335279903f7625106c0d7cbc0aa1cd Mon Sep 17 00:00:00 2001 From: Max Boettinger Date: Thu, 3 Sep 2026 10:51:44 +0200 Subject: [PATCH 03/14] feat(ai): breakdown plan schema, validation and tree builder Claude-Session: https://claude.ai/code/session_01L2voqEC2eEwLsiTxoSnCGD --- src/core/ai/plan.ts | 221 ++++++++++++++++++++++++++++++++++++++ test/core/ai/plan.test.ts | 146 +++++++++++++++++++++++++ 2 files changed, 367 insertions(+) create mode 100644 src/core/ai/plan.ts create mode 100644 test/core/ai/plan.test.ts diff --git a/src/core/ai/plan.ts b/src/core/ai/plan.ts new file mode 100644 index 0000000..7f9a629 --- /dev/null +++ b/src/core/ai/plan.ts @@ -0,0 +1,221 @@ +/** + * The breakdown plan: the structured shape the model must return when + * splitting a task into nano tasks, its JSON schema (sent as a strict + * `json_schema` response format), the runtime validator, and the tree + * builder that turns the flat list into nested nodes. + * + * The list is flat on purpose. Items point at their parent through + * `parentKey`, which keeps the schema free of recursive `$ref`s (not + * portable across OpenRouter providers in strict mode) and makes the + * apply order trivial: parents are required to appear before children. + */ + +import type { StructuredSchema, ValidationFailure } from "./types.js"; + +export interface PlanTask { + key: string; + /** Key of an earlier task in the list, or null for a child of the target. */ + parentKey: string | null; + name: string; + note: string; + estimateMinutes: number | null; + tags: string[]; + flag: boolean; + /** Whether this task's own children must be done in order. */ + sequential: boolean; + due: string | null; + defer: string | null; +} + +export interface Plan { + summary: string; + /** Whether the tasks created directly under the target must be done in order. */ + sequential: boolean; + tasks: PlanTask[]; + questions: string[]; +} + +export interface PlanNode extends PlanTask { + children: PlanNode[]; +} + +export const MAX_TASK_NAME_LENGTH = 200; +export const MAX_PLAN_TASKS = 200; + +const nullable = (type: string) => ({ type: [type, "null"] }); + +const TASK_SCHEMA = { + type: "object", + additionalProperties: false, + required: [ + "key", + "parentKey", + "name", + "note", + "estimateMinutes", + "tags", + "flag", + "sequential", + "due", + "defer", + ], + properties: { + key: { type: "string", description: "Short unique id for this task, e.g. '1', '2.1'." }, + parentKey: { + ...nullable("string"), + description: "Key of an earlier task this one nests under, or null for a direct child.", + }, + name: { type: "string", description: "One concrete, observable action." }, + note: { type: "string", description: "Details, options or hints; empty string if none." }, + estimateMinutes: { ...nullable("integer"), description: "Estimated minutes, >= 1." }, + tags: { type: "array", items: { type: "string" }, description: "Only tags from the list." }, + flag: { type: "boolean" }, + sequential: { type: "boolean", description: "Children must be done in order." }, + due: { ...nullable("string"), description: "Due date text OmniFocus understands, or null." }, + defer: { ...nullable("string"), description: "Defer date text, or null." }, + }, +}; + +export const PLAN_SCHEMA: Record = { + type: "object", + additionalProperties: false, + required: ["summary", "sequential", "tasks", "questions"], + properties: { + summary: { type: "string", description: "One sentence on the approach." }, + sequential: { + type: "boolean", + description: "Whether the new top-level tasks must be done in order.", + }, + tasks: { type: "array", items: TASK_SCHEMA }, + questions: { type: "array", items: { type: "string" } }, + }, +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((v) => typeof v === "string"); +} + +function validateTask(raw: unknown, index: number, seen: Set, errors: string[]): void { + const where = `tasks[${index}]`; + if (!isRecord(raw)) { + errors.push(`${where} must be an object`); + return; + } + const key = typeof raw.key === "string" ? raw.key.trim() : ""; + if (!key) errors.push(`${where}.key must be a non-empty string`); + else if (seen.has(key)) errors.push(`${where}.key "${key}" is used more than once`); + const label = key ? `task "${key}"` : where; + + if (raw.parentKey !== null && typeof raw.parentKey !== "string") { + errors.push(`${label}: parentKey must be a string or null`); + } else if (typeof raw.parentKey === "string") { + if (raw.parentKey === key) errors.push(`${label}: parentKey must not point at itself`); + else if (!seen.has(raw.parentKey)) { + errors.push(`${label}: parentKey "${raw.parentKey}" must name a task listed earlier`); + } + } + const name = typeof raw.name === "string" ? raw.name.trim() : ""; + if (!name) errors.push(`${label}: name must be a non-empty string`); + else if (name.length > MAX_TASK_NAME_LENGTH) { + errors.push(`${label}: name must be at most ${MAX_TASK_NAME_LENGTH} characters`); + } + if (typeof raw.note !== "string") errors.push(`${label}: note must be a string`); + if ( + raw.estimateMinutes !== null && + (typeof raw.estimateMinutes !== "number" || + !Number.isInteger(raw.estimateMinutes) || + raw.estimateMinutes < 1) + ) { + errors.push(`${label}: estimateMinutes must be an integer >= 1 or null`); + } + if (!isStringArray(raw.tags)) errors.push(`${label}: tags must be an array of strings`); + if (typeof raw.flag !== "boolean") errors.push(`${label}: flag must be a boolean`); + if (typeof raw.sequential !== "boolean") errors.push(`${label}: sequential must be a boolean`); + for (const field of ["due", "defer"] as const) { + if (raw[field] !== null && typeof raw[field] !== "string") { + errors.push(`${label}: ${field} must be a string or null`); + } + } + if (key) seen.add(key); +} + +/** Validate a model response against the plan contract; errors name the offending task. */ +export function validatePlan(raw: unknown): { value: Plan } | ValidationFailure { + const errors: string[] = []; + if (!isRecord(raw)) return { errors: ["response must be a JSON object"] }; + if (typeof raw.summary !== "string") errors.push("summary must be a string"); + if (typeof raw.sequential !== "boolean") errors.push("sequential must be a boolean"); + if (!isStringArray(raw.questions)) errors.push("questions must be an array of strings"); + if (!Array.isArray(raw.tasks)) errors.push("tasks must be an array"); + else if (raw.tasks.length === 0) errors.push("tasks must not be empty"); + else if (raw.tasks.length > MAX_PLAN_TASKS) { + errors.push(`tasks must contain at most ${MAX_PLAN_TASKS} items`); + } else { + const seen = new Set(); + raw.tasks.forEach((task, index) => validateTask(task, index, seen, errors)); + } + if (errors.length > 0) return { errors }; + + const tasks = (raw.tasks as Record[]).map( + (t): PlanTask => ({ + key: (t.key as string).trim(), + parentKey: t.parentKey as string | null, + name: (t.name as string).trim(), + note: (t.note as string).trim(), + estimateMinutes: t.estimateMinutes as number | null, + tags: (t.tags as string[]).map((tag) => tag.trim()).filter((tag) => tag.length > 0), + flag: t.flag as boolean, + sequential: t.sequential as boolean, + due: emptyToNull(t.due as string | null), + defer: emptyToNull(t.defer as string | null), + }), + ); + return { + value: { + summary: (raw.summary as string).trim(), + sequential: raw.sequential as boolean, + tasks, + questions: (raw.questions as string[]).map((q) => q.trim()).filter((q) => q.length > 0), + }, + }; +} + +function emptyToNull(value: string | null): string | null { + if (value === null) return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +/** Nest a validated plan's flat task list into a tree, preserving list order. */ +export function buildPlanTree(plan: Plan): PlanNode[] { + const byKey = new Map(); + const roots: PlanNode[] = []; + for (const task of plan.tasks) { + const node: PlanNode = { ...task, children: [] }; + byKey.set(task.key, node); + const parent = task.parentKey === null ? undefined : byKey.get(task.parentKey); + if (parent) parent.children.push(node); + else roots.push(node); + } + return roots; +} + +export function countPlanTasks(plan: Plan): number { + return plan.tasks.length; +} + +/** Sum of estimates over tasks that carry one, in minutes. */ +export function planEstimateMinutes(plan: Plan): number { + return plan.tasks.reduce((sum, t) => sum + (t.estimateMinutes ?? 0), 0); +} + +/** The schema + validator bundle handed to `AIClient.structured()`. */ +export const PLAN_STRUCTURED: StructuredSchema = { + name: "task_breakdown_plan", + schema: PLAN_SCHEMA, + validate: validatePlan, +}; diff --git a/test/core/ai/plan.test.ts b/test/core/ai/plan.test.ts new file mode 100644 index 0000000..f06072e --- /dev/null +++ b/test/core/ai/plan.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "bun:test"; +import { + MAX_TASK_NAME_LENGTH, + PLAN_SCHEMA, + PLAN_STRUCTURED, + type Plan, + buildPlanTree, + countPlanTasks, + planEstimateMinutes, + validatePlan, +} from "../../../src/core/ai/plan.js"; +import { isValidationFailure } from "../../../src/core/ai/types.js"; + +function task(overrides: Record = {}): Record { + return { + key: "1", + parentKey: null, + name: "Open the portal", + note: "", + estimateMinutes: 2, + tags: [], + flag: false, + sequential: false, + due: null, + defer: null, + ...overrides, + }; +} + +function plan(tasks: Record[], overrides: Record = {}) { + return { summary: "Step by step", sequential: true, tasks, questions: [], ...overrides }; +} + +function errorsOf(raw: unknown): string[] { + const result = validatePlan(raw); + if (!isValidationFailure(result)) throw new Error("expected a validation failure"); + return result.errors; +} + +describe("validatePlan", () => { + test("accepts a well-formed plan and normalises whitespace", () => { + const result = validatePlan( + plan( + [ + task({ key: " 1 ", name: " Open the portal ", note: " x " }), + task({ key: "2", parentKey: "1", name: "Log in", tags: [" @computer ", ""], due: " " }), + ], + { summary: " Step by step ", questions: [" What year? ", ""] }, + ), + ); + expect(isValidationFailure(result)).toBe(false); + const value = (result as { value: Plan }).value; + expect(value.summary).toBe("Step by step"); + expect(value.questions).toEqual(["What year?"]); + expect(value.tasks[0]?.key).toBe("1"); + expect(value.tasks[0]?.name).toBe("Open the portal"); + expect(value.tasks[0]?.note).toBe("x"); + expect(value.tasks[1]).toMatchObject({ parentKey: "1", tags: ["@computer"], due: null }); + }); + + test("rejects non-objects and top-level shape problems", () => { + expect(errorsOf("nope")).toEqual(["response must be a JSON object"]); + expect(errorsOf({})).toEqual([ + "summary must be a string", + "sequential must be a boolean", + "questions must be an array of strings", + "tasks must be an array", + ]); + expect(errorsOf(plan([]))).toEqual(["tasks must not be empty"]); + }); + + test("names the offending task for duplicate and unknown keys", () => { + expect(errorsOf(plan([task(), task()]))).toEqual(['tasks[1].key "1" is used more than once']); + expect(errorsOf(plan([task({ parentKey: "9" })]))).toEqual([ + 'task "1": parentKey "9" must name a task listed earlier', + ]); + // Forward references are rejected: parents must come first. + expect(errorsOf(plan([task({ key: "1", parentKey: "2" }), task({ key: "2" })]))).toEqual([ + 'task "1": parentKey "2" must name a task listed earlier', + ]); + expect(errorsOf(plan([task({ parentKey: "1" })]))).toEqual([ + 'task "1": parentKey must not point at itself', + ]); + }); + + test("checks every field type", () => { + expect(errorsOf(plan([task({ name: " " })]))).toEqual([ + 'task "1": name must be a non-empty string', + ]); + expect(errorsOf(plan([task({ name: "x".repeat(MAX_TASK_NAME_LENGTH + 1) })]))).toEqual([ + `task "1": name must be at most ${MAX_TASK_NAME_LENGTH} characters`, + ]); + expect(errorsOf(plan([task({ estimateMinutes: 2.5 })]))).toEqual([ + 'task "1": estimateMinutes must be an integer >= 1 or null', + ]); + expect(errorsOf(plan([task({ estimateMinutes: 0 })]))).toHaveLength(1); + expect(errorsOf(plan([task({ tags: "home" })]))).toEqual([ + 'task "1": tags must be an array of strings', + ]); + expect(errorsOf(plan([task({ flag: "yes", sequential: 1, due: 5, defer: {} })]))).toEqual([ + 'task "1": flag must be a boolean', + 'task "1": sequential must be a boolean', + 'task "1": due must be a string or null', + 'task "1": defer must be a string or null', + ]); + expect(errorsOf(plan([task({ key: "" })]))[0]).toBe("tasks[0].key must be a non-empty string"); + expect(errorsOf(plan(["not a task" as unknown as Record]))).toEqual(["tasks[0] must be an object"]); + }); +}); + +describe("buildPlanTree", () => { + test("nests children under parents in list order", () => { + const result = validatePlan( + plan([ + task({ key: "1", name: "A" }), + task({ key: "1.1", parentKey: "1", name: "A1" }), + task({ key: "1.1.1", parentKey: "1.1", name: "A1a", estimateMinutes: null }), + task({ key: "2", name: "B", estimateMinutes: 10 }), + task({ key: "1.2", parentKey: "1", name: "A2" }), + ]), + ) as { value: Plan }; + const tree = buildPlanTree(result.value); + expect(tree.map((n) => n.name)).toEqual(["A", "B"]); + expect(tree[0]?.children.map((n) => n.name)).toEqual(["A1", "A2"]); + expect(tree[0]?.children[0]?.children.map((n) => n.name)).toEqual(["A1a"]); + expect(countPlanTasks(result.value)).toBe(5); + expect(planEstimateMinutes(result.value)).toBe(2 + 2 + 10 + 2); + }); +}); + +describe("PLAN_STRUCTURED", () => { + test("bundles the schema with the validator under a stable name", () => { + expect(PLAN_STRUCTURED.name).toBe("task_breakdown_plan"); + expect(PLAN_STRUCTURED.schema).toBe(PLAN_SCHEMA); + expect(PLAN_STRUCTURED.validate(plan([task()]))).toHaveProperty("value"); + }); + + test("the schema is strict-mode friendly: every property required, no extras", () => { + const root = PLAN_SCHEMA as { required: string[]; properties: Record }; + expect(root.required.sort()).toEqual(Object.keys(root.properties).sort()); + const item = (root.properties.tasks as { items: { required: string[]; properties: object } }) + .items; + expect(item.required.sort()).toEqual(Object.keys(item.properties).sort()); + expect(JSON.stringify(PLAN_SCHEMA)).not.toContain("$ref"); + }); +}); From 73ca39336218b779ab8aadbc27ab5cd2b173abb9 Mon Sep 17 00:00:00 2001 From: Max Boettinger Date: Thu, 3 Sep 2026 10:55:28 +0200 Subject: [PATCH 04/14] feat(bridge): task.context and task.createTree ops Claude-Session: https://claude.ai/code/session_01L2voqEC2eEwLsiTxoSnCGD --- src/core/client.ts | 14 +++ src/core/types.ts | 74 ++++++++++++ src/jxa/bridge.js | 147 ++++++++++++++++++++++++ test/fixtures/mock-client.ts | 11 +- test/fixtures/mock-responses.ts | 39 ++++++- test/jxa/bridge-harness.ts | 18 ++- test/jxa/task-context.test.ts | 181 ++++++++++++++++++++++++++++++ test/jxa/task-create-tree.test.ts | 171 ++++++++++++++++++++++++++++ 8 files changed, 651 insertions(+), 4 deletions(-) create mode 100644 test/jxa/task-context.test.ts create mode 100644 test/jxa/task-create-tree.test.ts diff --git a/src/core/client.ts b/src/core/client.ts index 7449aa6..9db12b8 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -11,6 +11,7 @@ import type { BridgeResponse, BulkCreateInput, BulkUpdateInput, + CreateTreeOptions, FolderListOptions, ForecastOptions, InboxProcessOptions, @@ -20,6 +21,7 @@ import type { ProjectUpdateOptions, ReviewOptions, TagListOptions, + TaskContextOptions, TaskCreateOptions, TaskListOptions, TaskNotificationAddOptions, @@ -83,6 +85,18 @@ export function createClient(): OmniFocusClient { return executeBridge(cmd("task.delete", { query, ...opts })); }, + async getTaskContext(opts: TaskContextOptions) { + return executeBridge(cmd("task.context", opts as unknown as Record), { + timeoutMs: 60_000, + }); + }, + + async createTaskTree(opts: CreateTreeOptions) { + return executeBridge(cmd("task.createTree", opts as unknown as Record), { + timeoutMs: 120_000, + }); + }, + async listTaskNotifications(opts: TaskNotificationListOptions) { return executeBridge( cmd("task.notification.list", opts as unknown as Record), diff --git a/src/core/types.ts b/src/core/types.ts index 9dedc34..c4af787 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -215,6 +215,78 @@ export interface TaskNotificationClearOptions { confirm?: boolean; } +// ── Task context & subtask trees (AI verbs) ───────────────────────────────── + +/** A task with its existing subtree, completed children included. */ +export interface ContextNode extends OFTask { + children: ContextNode[]; +} + +export interface TaskContextSibling { + id: string; + name: string; + completed: boolean; +} + +/** Payload of the `task.context` op: everything a prompt needs about one task. */ +export interface TaskContext { + task: OFTask; + /** Parent chain, nearest first; a project's invisible root task is excluded. */ + ancestors: OFTask[]; + project: OFProject | null; + children: ContextNode[]; + /** Other tasks in the same container (parent task, project, or inbox). */ + siblings: TaskContextSibling[]; + /** Every tag name in the database. */ + tags: string[]; +} + +export interface TaskContextOptions { + query?: string; + id?: string; + searchCompleted?: boolean; +} + +/** One task to create in `task.createTree`; `parentKey` names an earlier item. */ +export interface PlanTaskInput { + key: string; + parentKey: string | null; + name: string; + note?: string; + estimate?: number | null; + tags?: string[]; + flag?: boolean; + sequential?: boolean; + due?: string | null; + defer?: string | null; +} + +export interface CreateTreeOptions { + /** Nest the tree under this task. Mutually exclusive with projectId. */ + parentId?: string; + /** Create the tree at the top level of this project. */ + projectId?: string; + /** Set the target's own sequential flag before creating children. */ + sequential?: boolean; + tasks: PlanTaskInput[]; +} + +export interface CreateTreeItem { + key: string; + ok: boolean; + id?: string; + name: string; + error?: string; + warnings?: string[]; +} + +export interface CreateTreeResult { + parent: { id: string; name: string; project: string }; + created: CreateTreeItem[]; + /** Problems applying properties to the target itself. */ + warnings: string[]; +} + // ── Project mutation options ──────────────────────────────────────────────── export interface ProjectCreateOptions { @@ -497,6 +569,8 @@ export interface OmniFocusClient { query: string, opts?: { id?: string; confirm?: boolean }, ): Promise>; + getTaskContext(opts: TaskContextOptions): Promise>; + createTaskTree(opts: CreateTreeOptions): Promise>; listTaskNotifications( opts: TaskNotificationListOptions, ): Promise< diff --git a/src/jxa/bridge.js b/src/jxa/bridge.js index cceda4a..cba2355 100644 --- a/src/jxa/bridge.js +++ b/src/jxa/bridge.js @@ -671,6 +671,153 @@ ops["task.update"] = function(of, doc, p) { return ok({ id: task.id(), changes: changes, task: formatTask(task) }); }; +// ── Task context & subtask trees (used by the AI verbs) ───────────────── + +var CONTEXT_SUBTREE_BUDGET = 200; +var CONTEXT_SIBLING_LIMIT = 200; +var CONTEXT_ANCESTOR_LIMIT = 20; + +// The existing subtree of a task, completed children included, formatted +// recursively. `budget.remaining` caps the total node count so a huge +// project cannot blow up the payload (or the Apple Event count). +function formatContextSubtree(task, budget) { + var children = [], kids = []; + try { kids = task.tasks(); } catch(e) { kids = []; } + for (var i = 0; i < kids.length; i++) { + if (budget.remaining <= 0) break; + budget.remaining -= 1; + var node; + try { node = formatTask(kids[i]); } catch(e2) { continue; } + node.children = formatContextSubtree(kids[i], budget); + children.push(node); + } + return children; +} + +// Compact { id, name, completed } rows for every task directly inside a +// container (a task's `tasks`, a project's `tasks`, or `doc.inboxTasks`), +// read as batches so this costs three Apple Events, not three per task. +function compactSiblings(spec, excludeId) { + var out = []; + try { + var ids = spec.id(), names = spec.name(), completed = spec.completed(); + for (var i = 0; i < ids.length && out.length < CONTEXT_SIBLING_LIMIT; i++) { + if (ids[i] === excludeId) continue; + out.push({ id: ids[i], name: names[i], completed: !!completed[i] }); + } + } catch(e) {} + return out; +} + +// Everything a prompt needs to know about one task: the task itself, its +// ancestor chain (nearest first, the project's invisible root task +// excluded), its project, its existing subtree, its siblings and every +// tag name in the database. +ops["task.context"] = function(of, doc, p) { + var r = findTaskFromParams(doc, p, { searchCompleted: p.searchCompleted }); + if (r.error) return fail(r.error, r.candidates ? { candidates: r.candidates } : {}); + var task = r.task; + var data = { task: formatTask(task), ancestors: [], project: null, children: [], siblings: [], tags: [] }; + + var project = null; + try { project = task.containingProject(); } catch(e) {} + var rootId = null; + if (project) { + try { data.project = formatProject(project); } catch(e) {} + try { rootId = project.rootTask().id(); } catch(e) {} + } + + var parent = null; + try { parent = task.parentTask(); } catch(e) {} + var cursor = parent, depth = 0, parentIsRoot = false; + while (cursor && depth < CONTEXT_ANCESTOR_LIMIT) { + var cursorId = null; try { cursorId = cursor.id(); } catch(e) {} + if (rootId !== null && cursorId === rootId) { if (depth === 0) parentIsRoot = true; break; } + try { data.ancestors.push(formatTask(cursor)); } catch(e) { break; } + var next = null; try { next = cursor.parentTask(); } catch(e2) {} + cursor = next; depth += 1; + } + + data.children = formatContextSubtree(task, { remaining: CONTEXT_SUBTREE_BUDGET }); + + var taskId = data.task.id; + if (parent && !parentIsRoot) data.siblings = compactSiblings(parent.tasks, taskId); + else if (project) data.siblings = compactSiblings(project.tasks, taskId); + else data.siblings = compactSiblings(doc.inboxTasks, taskId); + + try { data.tags = doc.flattenedTags.name(); } catch(e) { data.tags = []; } + return ok(data); +}; + +// Create a whole subtask tree under one task (parentId) or at the top +// level of a project (projectId) in a single round-trip. Items are created +// in array order; `parentKey` names an earlier item to nest under. A failed +// item is recorded and its descendants are skipped, never silently +// reparented. Property application is best-effort per item (warnings), so +// a bad tag or date never loses a task that was already created. +ops["task.createTree"] = function(of, doc, p) { + if (!p.tasks || !p.tasks.length) return fail("tasks required"); + if (p.parentId && p.projectId) return fail("Use either parentId or projectId, not both"); + if (!p.parentId && !p.projectId) return fail("parentId or projectId required"); + + var target = null, parentInfo = null; + if (p.parentId) { + target = findTaskById(doc, p.parentId); + if (!target) return fail("Parent task not found by ID: " + p.parentId); + var tp = null; try { var tpp = target.containingProject(); if (tpp) tp = tpp.name(); } catch(e) {} + parentInfo = { id: target.id(), name: target.name(), project: tp || "Inbox" }; + } else { + var projects = doc.flattenedProjects(); + for (var pi = 0; pi < projects.length; pi++) { if (projects[pi].id() === p.projectId) { target = projects[pi]; break; } } + if (!target) return fail("Project not found with ID: " + p.projectId); + parentInfo = { id: target.id(), name: target.name(), project: target.name() }; + } + + var parentWarnings = []; + if (p.sequential === true || p.sequential === false) { + try { target.sequential = p.sequential; } catch(e) { parentWarnings.push("sequential apply failed: " + e.message); } + } + + var byKey = {}, failed = {}, created = []; + for (var i = 0; i < p.tasks.length; i++) { + var item = p.tasks[i] || {}; + var key = (item.key === null || item.key === undefined) ? String(i) : String(item.key); + var name = item.name; + if (!name) { failed[key] = true; created.push({ key: key, ok: false, name: "", error: "Task name required" }); continue; } + var container = target; + if (item.parentKey !== null && item.parentKey !== undefined) { + var pk = String(item.parentKey); + if (failed[pk]) { failed[key] = true; created.push({ key: key, ok: false, name: name, error: "Skipped: parent \"" + pk + "\" was not created" }); continue; } + container = byKey[pk]; + if (!container) { failed[key] = true; created.push({ key: key, ok: false, name: name, error: "Unknown parentKey: " + pk }); continue; } + } + var task; + try { + var props = { name: name }; if (item.note) props.note = item.note; + task = of.Task(props); + container.tasks.push(task); + } catch(e) { + failed[key] = true; + created.push({ key: key, ok: false, name: name, error: e.message || String(e) }); + continue; + } + var warnings = []; + try { + var changes = applyTaskProps(of, doc, task, { + estimate: item.estimate, tags: item.tags, flag: item.flag, + sequential: item.sequential === true, parallel: item.sequential === false, + due: item.due, defer: item.defer + }); + warnings = extractWarnings(changes); + } catch(e2) { + warnings.push("property apply failed: " + (e2.message || String(e2))); + } + byKey[key] = task; + created.push({ key: key, ok: true, id: task.id(), name: task.name(), warnings: warnings }); + } + return ok({ parent: parentInfo, created: created, warnings: parentWarnings }); +}; + ops["task.complete"] = function(of, doc, p) { if (!p.query && !p.id) return fail("Task query or id required"); var findResult; diff --git a/test/fixtures/mock-client.ts b/test/fixtures/mock-client.ts index c48968d..ce56695 100644 --- a/test/fixtures/mock-client.ts +++ b/test/fixtures/mock-client.ts @@ -7,7 +7,14 @@ import { mock } from "bun:test"; import type { OmniFocusClient } from "../../src/core/types.js"; -import { MOCK_PROJECT, MOCK_STATS, MOCK_TASK, successResponse } from "./mock-responses.js"; +import { + MOCK_CREATE_TREE_RESULT, + MOCK_PROJECT, + MOCK_STATS, + MOCK_TASK, + MOCK_TASK_CONTEXT, + successResponse, +} from "./mock-responses.js"; export function createMockClient(): OmniFocusClient { const mockNotification = (MOCK_TASK.notifications ?? [])[0] ?? { @@ -58,6 +65,8 @@ export function createMockClient(): OmniFocusClient { successResponse({ id: MOCK_TASK.id, name: MOCK_TASK.name, action: "deleted" }), ), ), + getTaskContext: mock(() => Promise.resolve(successResponse(MOCK_TASK_CONTEXT))), + createTaskTree: mock(() => Promise.resolve(successResponse(MOCK_CREATE_TREE_RESULT))), listTaskNotifications: mock(() => Promise.resolve( successResponse({ diff --git a/test/fixtures/mock-responses.ts b/test/fixtures/mock-responses.ts index 18c357e..30dc8a4 100644 --- a/test/fixtures/mock-responses.ts +++ b/test/fixtures/mock-responses.ts @@ -3,7 +3,14 @@ * These match the shapes returned by bridge.js. */ -import type { BridgeResponse, OFProject, OFTask, StatsResult } from "../../src/core/types.js"; +import type { + BridgeResponse, + CreateTreeResult, + OFProject, + OFTask, + StatsResult, + TaskContext, +} from "../../src/core/types.js"; export const MOCK_TASK: OFTask = { name: "Buy groceries", @@ -98,3 +105,33 @@ export function successResponse(data: T): BridgeResponse { export function errorResponse(error: string, candidates?: string[]): BridgeResponse { return { ok: false, error, candidates }; } + +export const MOCK_TASK_CONTEXT: TaskContext = { + task: MOCK_TASK, + ancestors: [], + project: MOCK_PROJECT, + children: [ + { + ...MOCK_TASK, + id: "task-child-1", + name: "Write shopping list", + completed: true, + completionDate: "2026-03-01T10:00:00.000Z", + tags: [], + flagged: false, + estimatedMinutes: 5, + children: [], + }, + ], + siblings: [{ id: "task-sib-1", name: "Return library books", completed: false }], + tags: ["errand", "home", "@computer"], +}; + +export const MOCK_CREATE_TREE_RESULT: CreateTreeResult = { + parent: { id: MOCK_TASK.id, name: MOCK_TASK.name, project: MOCK_TASK.project }, + created: [ + { key: "1", ok: true, id: "new-1", name: "Open the shopping list app", warnings: [] }, + { key: "2", ok: true, id: "new-2", name: "Add milk and eggs", warnings: [] }, + ], + warnings: [], +}; diff --git a/test/jxa/bridge-harness.ts b/test/jxa/bridge-harness.ts index acfffad..977b947 100644 --- a/test/jxa/bridge-harness.ts +++ b/test/jxa/bridge-harness.ts @@ -116,8 +116,22 @@ export function runBridgeArgs( // OmniFocus constructors: `of.Task({...})`, `of.InboxTask({...})`. Each // returns a mutable object with a fresh id so ops that create records can // be exercised without a real document. - const construct = (props: Record) => - makeMutableJxaObject({ id: `new-${++created}`, completed: false, flagged: false, ...props }); + const construct = (props: Record) => { + const base = makeMutableJxaObject({ + id: `new-${++created}`, + completed: false, + flagged: false, + ...props, + }); + // A created task can itself be a container: `task.tasks()` lists its + // children and `task.tasks.push(child)` nests one, as in JXA. + const children: unknown[] = []; + const tasks = Object.assign(() => children, { push: (t: unknown) => children.push(t) }); + return new Proxy(base, { + get: (target, key, receiver) => + key === "tasks" ? tasks : Reflect.get(target, key, receiver), + }); + }; const app = { Task: construct, InboxTask: construct, diff --git a/test/jxa/task-context.test.ts b/test/jxa/task-context.test.ts new file mode 100644 index 0000000..32fc56f --- /dev/null +++ b/test/jxa/task-context.test.ts @@ -0,0 +1,181 @@ +/** + * ops["task.context"] — the one-call context bundle behind the AI verbs: + * the task, its ancestor chain (minus the project's root task), its + * project, its existing subtree (completed children included, capped), + * its siblings (batch-read) and every tag name. + */ + +import { describe, expect, test } from "bun:test"; +import type { TaskContext } from "../../src/core/types.js"; +import { makeElementArray, runBridge } from "./bridge-harness.js"; + +interface FakeTaskSpec { + id: string; + name: string; + completed?: boolean; + children?: FakeTaskSpec[]; + parent?: FakeTask | null; + project?: FakeProject | null; +} + +type FakeTask = Record & { __children: FakeTask[] }; +type FakeProject = Record; + +/** A task object satisfying every unguarded getter formatTask() reads. */ +function fakeTask(spec: FakeTaskSpec): FakeTask { + const children: FakeTask[] = []; + // defineProperty: `name` is a readonly property on functions. + const tasksSpec = Object.defineProperties(() => children, { + id: { value: () => children.map((c) => (c.id as () => string)()) }, + name: { value: () => children.map((c) => (c.name as () => string)()) }, + completed: { value: () => children.map((c) => (c.completed as () => boolean)()) }, + }); + const task: FakeTask = { + __children: children, + id: () => spec.id, + name: () => spec.name, + note: () => "", + dueDate: () => null, + deferDate: () => null, + flagged: () => false, + estimatedMinutes: () => null, + completed: () => spec.completed ?? false, + completionDate: () => null, + tags: () => [], + repetitionRule: () => null, + parentTask: () => spec.parent ?? null, + containingProject: () => spec.project ?? null, + tasks: tasksSpec, + }; + for (const child of spec.children ?? []) { + children.push(fakeTask({ ...child, parent: task, project: spec.project ?? null })); + } + return task; +} + +function fakeProject(id: string, name: string, rootTask: FakeTask): FakeProject { + return { + id: () => id, + name: () => name, + note: () => "", + dueDate: () => null, + deferDate: () => null, + flagged: () => false, + completed: () => false, + completionDate: () => null, + status: () => "active status", + flattenedTasks: { completed: () => [false, true] }, + rootTask: () => rootTask, + get tasks() { + return rootTask.tasks; + }, + }; +} + +function docWith(tasks: FakeTask[], tags: string[] = ["errand", "home"]) { + const all = new Map(); + const visit = (t: FakeTask) => { + all.set((t.id as () => string)(), t); + for (const c of t.__children) visit(c); + }; + for (const t of tasks) visit(t); + return { + flattenedTasks: { + byId: (id: string) => { + const found = all.get(id); + if (!found) throw new Error("not found"); + return found; + }, + }, + flattenedProjects: () => [], + flattenedTags: makeElementArray(tags.map((name) => ({ name }))), + inboxTasks: makeElementArray([]), + }; +} + +describe("task.context", () => { + test("bundles ancestors, project, subtree, siblings and tags for a nested task", () => { + // Project "Taxes" → root task → "Gather documents" (parent) → "Find W2" (target, has children) + const root = fakeTask({ id: "root", name: "Taxes" }); + const project = fakeProject("proj-1", "Taxes", root); + const parent = fakeTask({ + id: "t-parent", + name: "Gather documents", + parent: root, + project, + children: [ + { + id: "t-target", + name: "Find W2", + children: [ + { id: "t-c1", name: "Open mail app", completed: true }, + { id: "t-c2", name: "Search for W2" }, + ], + }, + { id: "t-sib", name: "Find 1099", completed: true }, + ], + }); + root.__children.push(parent); + const doc = docWith([root, parent]); + + const response = runBridge(doc, "task.context", { id: "t-target" }); + expect(response.ok).toBe(true); + const data = response.data as TaskContext; + expect(data.task.name).toBe("Find W2"); + expect(data.task.childCount).toBe(2); + // Ancestors stop at the project's root task. + expect(data.ancestors.map((a) => a.name)).toEqual(["Gather documents"]); + expect(data.project?.name).toBe("Taxes"); + expect(data.project?.taskCount).toBe(2); + expect(data.children.map((c) => [c.name, c.completed])).toEqual([ + ["Open mail app", true], + ["Search for W2", false], + ]); + expect(data.children[0]?.children).toEqual([]); + expect(data.siblings).toEqual([{ id: "t-sib", name: "Find 1099", completed: true }]); + expect(data.tags).toEqual(["errand", "home"]); + }); + + test("a top-level project task has no ancestors and its siblings are the project's tasks", () => { + const root = fakeTask({ id: "root", name: "Taxes" }); + const project = fakeProject("proj-1", "Taxes", root); + const a = fakeTask({ id: "a", name: "File return", parent: root, project }); + const b = fakeTask({ id: "b", name: "Pay bill", parent: root, project, completed: true }); + root.__children.push(a, b); + const response = runBridge(docWith([root, a, b]), "task.context", { query: "a" }); + expect(response.ok).toBe(true); + const data = response.data as TaskContext; + expect(data.ancestors).toEqual([]); + expect(data.siblings).toEqual([{ id: "b", name: "Pay bill", completed: true }]); + }); + + test("an inbox task reports no project and inbox siblings", () => { + const t = fakeTask({ id: "i1", name: "Loose thought" }); + const doc = docWith([t]); + doc.inboxTasks = makeElementArray([ + { id: "i1", name: "Loose thought", completed: false }, + { id: "i2", name: "Another", completed: false }, + ]); + const response = runBridge(doc, "task.context", { id: "i1" }); + expect(response.ok).toBe(true); + const data = response.data as TaskContext; + expect(data.project).toBeNull(); + expect(data.ancestors).toEqual([]); + expect(data.siblings).toEqual([{ id: "i2", name: "Another", completed: false }]); + }); + + test("the subtree is capped at 200 nodes", () => { + const many = Array.from({ length: 250 }, (_, i) => ({ id: `c${i}`, name: `Child ${i}` })); + const t = fakeTask({ id: "big", name: "Big", children: many }); + const response = runBridge(docWith([t]), "task.context", { id: "big" }); + expect(response.ok).toBe(true); + expect((response.data as TaskContext).children).toHaveLength(200); + }); + + test("unknown task fails like task.get", () => { + const response = runBridge(docWith([]), "task.context", { id: "nope" }); + expect(response.ok).toBe(false); + expect(response.error).toBe("Task not found with ID: nope"); + expect(runBridge(docWith([]), "task.context", {}).error).toBe("Task query or id required"); + }); +}); diff --git a/test/jxa/task-create-tree.test.ts b/test/jxa/task-create-tree.test.ts new file mode 100644 index 0000000..36f9df7 --- /dev/null +++ b/test/jxa/task-create-tree.test.ts @@ -0,0 +1,171 @@ +/** + * ops["task.createTree"] — create a whole subtask tree in one round-trip. + * Items nest under earlier items by `parentKey`; a failed item's + * descendants are skipped, and per-item property problems are warnings. + */ + +import { describe, expect, test } from "bun:test"; +import type { CreateTreeResult } from "../../src/core/types.js"; +import { makeElementArray, runBridge } from "./bridge-harness.js"; + +interface Created { + tasks: () => Created[]; + name: () => string; + note: () => string | null; + estimatedMinutes: () => number | null; + flagged: () => boolean; + sequential: () => boolean | null; +} + +function target(pushed: Created[]) { + const state: Record = {}; + return { + id: () => "p1", + name: () => "Parent", + tasks: { push: (t: Created) => pushed.push(t) }, + containingProject: () => ({ name: () => "Errands" }), + set sequential(v: unknown) { + state.sequential = v; + }, + get sequential() { + return state.sequential; + }, + }; +} + +function docWith(parent: ReturnType, tags: string[] = []) { + return { + flattenedTasks: { + byId: (id: string) => { + if (id !== "p1") throw new Error("not found"); + return parent; + }, + }, + flattenedProjects: () => [ + { + id: () => "proj-1", + name: () => "Taxes", + tasks: { push: (t: Created) => parent.tasks.push(t) }, + }, + ], + flattenedTags: makeElementArray(tags.map((name) => ({ name }))), + }; +} + +describe("task.createTree", () => { + test("creates nested items in order under the parent and reports each", () => { + const pushed: Created[] = []; + const parent = target(pushed); + const response = runBridge(docWith(parent), "task.createTree", { + parentId: "p1", + sequential: true, + tasks: [ + { key: "1", parentKey: null, name: "Open portal", estimate: 2, sequential: true }, + { key: "1.1", parentKey: "1", name: "Type URL", note: "portal.example" }, + { key: "1.1.1", parentKey: "1.1", name: "Press enter" }, + { key: "2", parentKey: null, name: "Log in", flag: true, sequential: false }, + ], + }); + expect(response.ok).toBe(true); + const data = response.data as CreateTreeResult; + expect(data.parent).toEqual({ id: "p1", name: "Parent", project: "Errands" }); + expect(data.warnings).toEqual([]); + expect(data.created.map((c) => [c.key, c.ok, c.name])).toEqual([ + ["1", true, "Open portal"], + ["1.1", true, "Type URL"], + ["1.1.1", true, "Press enter"], + ["2", true, "Log in"], + ]); + expect(data.created.every((c) => c.ok && c.id && c.warnings?.length === 0)).toBe(true); + // Only the two top-level items were pushed into the parent… + expect(pushed.map((t) => t.name())).toEqual(["Open portal", "Log in"]); + // …and the rest nested under their parentKey. + const first = pushed[0] as Created; + expect(first.tasks().map((t) => t.name())).toEqual(["Type URL"]); + expect( + first + .tasks()[0] + ?.tasks() + .map((t) => t.name()), + ).toEqual(["Press enter"]); + expect(first.tasks()[0]?.note()).toBe("portal.example"); + expect(first.estimatedMinutes()).toBe(2); + expect(first.sequential()).toBe(true); + expect(pushed[1]?.flagged()).toBe(true); + expect(pushed[1]?.sequential()).toBe(false); + expect(parent.sequential).toBe(true); + }); + + test("skips the descendants of a failed item instead of reparenting them", () => { + const pushed: Created[] = []; + const response = runBridge(docWith(target(pushed)), "task.createTree", { + parentId: "p1", + tasks: [ + { key: "1", parentKey: null, name: "" }, + { key: "1.1", parentKey: "1", name: "Orphan" }, + { key: "1.1.1", parentKey: "1.1", name: "Grand-orphan" }, + { key: "2", parentKey: "missing", name: "Bad parent" }, + { key: "3", parentKey: null, name: "Fine" }, + ], + }); + expect(response.ok).toBe(true); + const data = response.data as CreateTreeResult; + expect(data.created.map((c) => [c.key, c.ok, c.error ?? null])).toEqual([ + ["1", false, "Task name required"], + ["1.1", false, 'Skipped: parent "1" was not created'], + ["1.1.1", false, 'Skipped: parent "1.1" was not created'], + ["2", false, "Unknown parentKey: missing"], + ["3", true, null], + ]); + expect(pushed.map((t) => t.name())).toEqual(["Fine"]); + }); + + test("a property that cannot be applied is a warning, not a lost task", () => { + const pushed: Created[] = []; + const response = runBridge(docWith(target(pushed), ["errand"]), "task.createTree", { + parentId: "p1", + tasks: [{ key: "1", parentKey: null, name: "Tagged", tags: ["nonexistent"] }], + }); + expect(response.ok).toBe(true); + const item = (response.data as CreateTreeResult).created[0]; + expect(item?.ok).toBe(true); + expect(item?.warnings?.[0]).toContain("tag failed (nonexistent)"); + expect(pushed).toHaveLength(1); + }); + + test("accepts a projectId target instead of a parent task", () => { + const pushed: Created[] = []; + const response = runBridge(docWith(target(pushed)), "task.createTree", { + projectId: "proj-1", + tasks: [{ key: "1", parentKey: null, name: "Top level" }], + }); + expect(response.ok).toBe(true); + expect((response.data as CreateTreeResult).parent).toEqual({ + id: "proj-1", + name: "Taxes", + project: "Taxes", + }); + expect(pushed.map((t) => t.name())).toEqual(["Top level"]); + }); + + test("validates its parameters", () => { + const doc = docWith(target([])); + expect(runBridge(doc, "task.createTree", { parentId: "p1" }).error).toBe("tasks required"); + expect(runBridge(doc, "task.createTree", { tasks: [{ name: "x" }] }).error).toBe( + "parentId or projectId required", + ); + expect( + runBridge(doc, "task.createTree", { + parentId: "p1", + projectId: "proj-1", + tasks: [{ name: "x" }], + }).error, + ).toBe("Use either parentId or projectId, not both"); + expect( + runBridge(doc, "task.createTree", { parentId: "zz", tasks: [{ name: "x" }] }).error, + ).toBe("Parent task not found by ID: zz"); + expect( + runBridge(doc, "task.createTree", { projectId: "zz", tasks: [{ name: "x" }] }).error, + ).toBe("Project not found with ID: zz"); + }); +}); From 3c4ed892e011c592dbb3cda30388d07faf6c8950 Mon Sep 17 00:00:00 2001 From: Max Boettinger Date: Thu, 3 Sep 2026 10:56:37 +0200 Subject: [PATCH 05/14] feat(ui): interactive prompter and withSpinner helper Claude-Session: https://claude.ai/code/session_01L2voqEC2eEwLsiTxoSnCGD --- src/core/ui/progress.ts | 38 +++++++----- src/core/ui/prompt.ts | 112 ++++++++++++++++++++++++++++++++++ test/core/ui/progress.test.ts | 38 ++++++++++++ test/core/ui/prompt.test.ts | 108 ++++++++++++++++++++++++++++++++ 4 files changed, 281 insertions(+), 15 deletions(-) create mode 100644 src/core/ui/prompt.ts create mode 100644 test/core/ui/prompt.test.ts diff --git a/src/core/ui/progress.ts b/src/core/ui/progress.ts index a8b79d1..ae3f19c 100644 --- a/src/core/ui/progress.ts +++ b/src/core/ui/progress.ts @@ -60,6 +60,8 @@ export const PROGRESS_LABELS: ProgressLabels = { listTags: "Loading tags…", listFolders: "Loading folders…", listTasksByTag: "Loading tagged tasks…", + getTaskContext: "Gathering task context…", + createTaskTree: "Creating subtasks…", }; let progressEnabled = false; @@ -84,10 +86,27 @@ async function startSpinner(text: string, stream: ProgressStream): Promise( + label: string, + fn: () => Promise, + stream: ProgressStream = process.stderr, +): Promise { + if (!progressEnabled || !isInteractive(stream)) return fn(); + const spinner = await startSpinner(label, stream); + try { + return await fn(); + } finally { + spinner.stop(); + } +} + +/** Wrap a client so every method shows a spinner while it runs, when allowed. */ export function withProgress(client: OmniFocusClient, opts: ProgressOptions = {}): OmniFocusClient { const stream = opts.stream ?? process.stderr; const labels: ProgressLabels = { ...PROGRESS_LABELS, ...opts.labels }; @@ -98,18 +117,7 @@ export function withProgress(client: OmniFocusClient, opts: ProgressOptions = {} if (typeof value !== "function") return value; const method = value as (...args: unknown[]) => Promise; const label = labels[prop as keyof OmniFocusClient] ?? DEFAULT_PROGRESS_LABEL; - - return async (...args: unknown[]) => { - if (!progressEnabled || !isInteractive(stream)) { - return method.apply(target, args); - } - const spinner = await startSpinner(label, stream); - try { - return await method.apply(target, args); - } finally { - spinner.stop(); - } - }; + return (...args: unknown[]) => withSpinner(label, () => method.apply(target, args), stream); }, }); } diff --git a/src/core/ui/prompt.ts b/src/core/ui/prompt.ts new file mode 100644 index 0000000..468f330 --- /dev/null +++ b/src/core/ui/prompt.ts @@ -0,0 +1,112 @@ +/** + * Line-oriented interactive input for conversational verbs. + * + * `createPrompter()` asks one question at a time on a terminal and resolves + * the trimmed answer — or `null` when the user wants out. Every way of + * leaving is handled here, once, so a verb only has to check for `null`: + * + * - Esc a lone `\x1b` byte on stdin (see below) + * - Ctrl-C readline's SIGINT event + * - Ctrl-D / EOF readline's close event + * - /quit /q /exit typed as the answer + * + * Esc is detected from the raw stream rather than readline's keypress + * parser: Bun's `emitKeypressEvents` never flushes a lone escape (it waits + * for a following byte to decide whether it started a sequence), so a + * standalone Esc press would only surface with the *next* key. A terminal + * sends an arrow key or similar as one multi-byte chunk (`\x1b[A`), so a + * one-byte `\x1b` chunk is unambiguously the Esc key. + * + * Entity-agnostic: knows nothing about tasks or the model. Streams are + * injectable so tests drive it with PassThrough streams. + */ + +import * as readline from "node:readline"; + +export interface PromptInput extends NodeJS.ReadableStream { + isTTY?: boolean; +} + +export interface PrompterStreams { + input?: PromptInput; + output?: NodeJS.WritableStream; +} + +export interface Prompter { + /** Ask a question; resolves the non-empty trimmed answer, or `null` to quit. */ + ask(question: string): Promise; + /** Ask until the first character of the answer is one of `keys` (case-insensitive). */ + choose(question: string, keys: readonly string[]): Promise; + close(): void; +} + +export const QUIT_COMMANDS: readonly string[] = ["/quit", "/q", "/exit"]; +const ESC = "\x1b"; + +type Outcome = { kind: "answer"; text: string } | { kind: "empty" } | { kind: "quit" }; + +export function createPrompter(streams: PrompterStreams = {}): Prompter { + const input = streams.input ?? (process.stdin as PromptInput); + const output = streams.output ?? process.stdout; + let closed = false; + + function askOnce(question: string): Promise { + return new Promise((resolve) => { + const rl = readline.createInterface({ input, output, terminal: input.isTTY === true }); + let settled = false; + const finish = (outcome: Outcome) => { + if (settled) return; + settled = true; + input.off("data", onData); + rl.close(); + resolve(outcome); + }; + const onData = (chunk: Buffer | string) => { + const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); + if (text === ESC) { + output.write("\n"); + finish({ kind: "quit" }); + } + }; + input.on("data", onData); + rl.on("SIGINT", () => { + output.write("\n"); + finish({ kind: "quit" }); + }); + rl.on("close", () => finish({ kind: "quit" })); + rl.question(question, (answer) => { + const text = answer.trim(); + if (!text) finish({ kind: "empty" }); + else if (QUIT_COMMANDS.includes(text.toLowerCase())) finish({ kind: "quit" }); + else finish({ kind: "answer", text }); + }); + }); + } + + async function ask(question: string): Promise { + while (!closed) { + const outcome = await askOnce(question); + if (outcome.kind === "quit") return null; + if (outcome.kind === "answer") return outcome.text; + } + return null; + } + + return { + ask, + async choose(question, keys) { + const wanted = keys.map((k) => k.toLowerCase()); + while (!closed) { + const answer = await ask(question); + if (answer === null) return null; + const first = answer.charAt(0).toLowerCase(); + if (wanted.includes(first)) return first; + output.write(`Please answer with one of: ${keys.join(", ")}\n`); + } + return null; + }, + close() { + closed = true; + }, + }; +} diff --git a/test/core/ui/progress.test.ts b/test/core/ui/progress.test.ts index ac77d1d..eaa9daf 100644 --- a/test/core/ui/progress.test.ts +++ b/test/core/ui/progress.test.ts @@ -5,6 +5,7 @@ import { setProgressEnabled, withProgress, } from "../../../src/core/ui/progress.js"; +import { withSpinner } from "../../../src/core/ui/progress.js"; import { createMockClient } from "../../fixtures/mock-client.js"; import { MOCK_TASK, errorResponse, successResponse } from "../../fixtures/mock-responses.js"; import { withEnv } from "../../helpers/env.js"; @@ -174,3 +175,40 @@ describe("withProgress", () => { expect(stream.writes.join("").endsWith(SHOW_CURSOR)).toBe(true); }); }); + +describe("withSpinner", () => { + test("returns the function's result without drawing when disabled", async () => { + const stream = fakeStream(); + const result = await withEnv(INTERACTIVE_ENV, () => + withSpinner("Thinking…", async () => 42, stream as never), + ); + expect(result).toBe(42); + expect(stream.writes).toEqual([]); + }); + + test("draws the label while the function runs and clears it afterwards", async () => { + setProgressEnabled(true); + const stream = fakeStream(); + const result = await withEnv(INTERACTIVE_ENV, () => + withSpinner( + "Thinking…", + () => new Promise((resolve) => setTimeout(() => resolve("done"), 20)), + stream as never, + ), + ); + expect(result).toBe("done"); + expect(stream.writes.join("")).toContain("Thinking…"); + expect(stream.writes.join("")).toContain(SHOW_CURSOR); + }); + + test("stays silent on a non-interactive stream and still propagates rejections", async () => { + setProgressEnabled(true); + const stream = fakeStream(false); + await expect( + withEnv(INTERACTIVE_ENV, () => + withSpinner("Thinking…", () => Promise.reject(new Error("boom")), stream as never), + ), + ).rejects.toThrow("boom"); + expect(stream.writes).toEqual([]); + }); +}); diff --git a/test/core/ui/prompt.test.ts b/test/core/ui/prompt.test.ts new file mode 100644 index 0000000..500baa9 --- /dev/null +++ b/test/core/ui/prompt.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "bun:test"; +import { PassThrough } from "node:stream"; +import { QUIT_COMMANDS, createPrompter } from "../../../src/core/ui/prompt.js"; + +function streams() { + const input = new PassThrough(); + const output = new PassThrough(); + const written: string[] = []; + output.on("data", (chunk) => written.push(String(chunk))); + return { input, output, written }; +} + +const tick = () => new Promise((r) => setTimeout(r, 5)); + +describe("createPrompter().ask", () => { + test("resolves the trimmed answer line", async () => { + const { input, output } = streams(); + const prompter = createPrompter({ input, output }); + const pending = prompter.ask("> "); + input.write(" hello world \n"); + expect(await pending).toBe("hello world"); + }); + + test("re-asks on an empty line until something is typed", async () => { + const { input, output, written } = streams(); + const prompter = createPrompter({ input, output }); + const pending = prompter.ask("> "); + input.write("\n"); + await tick(); + input.write(" \n"); + await tick(); + input.write("second try\n"); + expect(await pending).toBe("second try"); + expect(written.filter((w) => w === "> ").length).toBeGreaterThanOrEqual(3); + }); + + test("every quit command resolves null", async () => { + for (const command of QUIT_COMMANDS) { + const { input, output } = streams(); + const prompter = createPrompter({ input, output }); + const pending = prompter.ask("> "); + input.write(`${command.toUpperCase()}\n`); + expect(await pending).toBeNull(); + } + }); + + test("a lone Esc byte resolves null immediately", async () => { + const { input, output } = streams(); + const prompter = createPrompter({ input, output }); + const pending = prompter.ask("> "); + input.write("\x1b"); + expect(await pending).toBeNull(); + }); + + test("an escape sequence such as an arrow key is not treated as Esc", async () => { + const { input, output } = streams(); + const prompter = createPrompter({ input, output }); + const pending = prompter.ask("> "); + input.write("\x1b[A"); + await tick(); + input.write("still here\n"); + expect(await pending).toBe("\x1b[Astill here"); + }); + + test("EOF (Ctrl-D) resolves null", async () => { + const { input, output } = streams(); + const prompter = createPrompter({ input, output }); + const pending = prompter.ask("> "); + input.end(); + expect(await pending).toBeNull(); + }); + + test("a closed prompter resolves null without reading", async () => { + const { input, output } = streams(); + const prompter = createPrompter({ input, output }); + prompter.close(); + expect(await prompter.ask("> ")).toBeNull(); + }); +}); + +describe("createPrompter().choose", () => { + test("accepts the first character of a matching answer, case-insensitively", async () => { + const { input, output } = streams(); + const prompter = createPrompter({ input, output }); + const pending = prompter.choose("[a/r/q] ", ["a", "r", "q"]); + input.write("Revise please\n"); + expect(await pending).toBe("r"); + }); + + test("re-asks with a hint on an invalid answer", async () => { + const { input, output, written } = streams(); + const prompter = createPrompter({ input, output }); + const pending = prompter.choose("[a/r/q] ", ["a", "r", "q"]); + input.write("x\n"); + await tick(); + input.write("a\n"); + expect(await pending).toBe("a"); + expect(written.join("")).toContain("Please answer with one of: a, r, q"); + }); + + test("quitting propagates as null", async () => { + const { input, output } = streams(); + const prompter = createPrompter({ input, output }); + const pending = prompter.choose("[a/r/q] ", ["a", "r", "q"]); + input.write("/q\n"); + expect(await pending).toBeNull(); + }); +}); From 3f2bb1a783c0dd0f566aba58080862fcd76e7328 Mon Sep 17 00:00:00 2001 From: Max Boettinger Date: Thu, 3 Sep 2026 11:01:10 +0200 Subject: [PATCH 06/14] feat(ai): OpenRouter adapter with chat, streaming and structured output Claude-Session: https://claude.ai/code/session_01L2voqEC2eEwLsiTxoSnCGD --- src/core/ai/client.ts | 33 ++++ src/core/ai/conversation.ts | 35 ++++ src/core/ai/openrouter.ts | 289 ++++++++++++++++++++++++++++++++ test/core/ai/client.test.ts | 31 ++++ test/core/ai/openrouter.test.ts | 279 ++++++++++++++++++++++++++++++ test/core/ai/plan.test.ts | 4 +- 6 files changed, 670 insertions(+), 1 deletion(-) create mode 100644 src/core/ai/client.ts create mode 100644 src/core/ai/conversation.ts create mode 100644 src/core/ai/openrouter.ts create mode 100644 test/core/ai/client.test.ts create mode 100644 test/core/ai/openrouter.test.ts diff --git a/src/core/ai/client.ts b/src/core/ai/client.ts new file mode 100644 index 0000000..13f8b87 --- /dev/null +++ b/src/core/ai/client.ts @@ -0,0 +1,33 @@ +/** + * `createAIClient()` — the production `AIClient`, created once at the + * entry point and threaded into every verb next to the OmniFocus client. + * + * It is lazy on purpose: constructing it costs nothing and cannot fail. + * Config (API key, model) is resolved and the OpenRouter adapter — and + * with it the SDK — is loaded on the first call, so a missing key only + * surfaces when a verb actually needs the model, and a run that never + * does pays nothing. + */ + +import { resolveAIConfig } from "./config.js"; +import type { AIClient, ChatRequest, StructuredSchema } from "./types.js"; + +export function createAIClient(): AIClient { + let pending: Promise | undefined; + const backend = (): Promise => { + if (!pending) { + pending = (async () => { + const config = resolveAIConfig(); + const { createOpenRouterClient } = await import("./openrouter.js"); + return createOpenRouterClient(config); + })(); + } + return pending; + }; + return { + chat: (req: ChatRequest) => backend().then((c) => c.chat(req)), + stream: (req, onDelta) => backend().then((c) => c.stream(req, onDelta)), + structured: (req: ChatRequest, schema: StructuredSchema) => + backend().then((c) => c.structured(req, schema)), + }; +} diff --git a/src/core/ai/conversation.ts b/src/core/ai/conversation.ts new file mode 100644 index 0000000..bb33637 --- /dev/null +++ b/src/core/ai/conversation.ts @@ -0,0 +1,35 @@ +/** + * A growing message list for multi-turn verbs. Every request carries the + * whole history (system prompt first), which is what lets the model adapt + * its next question or revise its previous plan. + */ + +import type { Message } from "./types.js"; + +export class Conversation { + private readonly log: Message[]; + + constructor(system: string) { + this.log = [{ role: "system", content: system }]; + } + + user(content: string): this { + this.log.push({ role: "user", content }); + return this; + } + + assistant(content: string): this { + this.log.push({ role: "assistant", content }); + return this; + } + + /** A copy — callers may not mutate the history behind the conversation's back. */ + get messages(): Message[] { + return [...this.log]; + } + + /** Number of user turns so far (the opening context message included). */ + get userTurns(): number { + return this.log.filter((m) => m.role === "user").length; + } +} diff --git a/src/core/ai/openrouter.ts b/src/core/ai/openrouter.ts new file mode 100644 index 0000000..64d9823 --- /dev/null +++ b/src/core/ai/openrouter.ts @@ -0,0 +1,289 @@ +/** + * The OpenRouter adapter — the only module that imports `@openrouter/sdk`. + * + * Everything above it talks to the narrow `AIClient` interface, so the SDK + * is a replaceable detail. The SDK is loaded with a dynamic `import()` + * inside `createOpenRouterClient()` so a run that never talks to a model + * (every non-AI verb, every `--json` listing) never evaluates it — + * the same rule the spinner library follows. + * + * Structured output is requested as a strict `json_schema` response + * format and routed only to providers that honour it + * (`provider.requireParameters`). The reply is still parsed and validated + * here; a response that fails validation is sent back to the model once + * with the problems listed, and a second failure is an `AIError`. + */ + +import { describeAISetup } from "./config.js"; +import type { AIConfig } from "./config.js"; +import { + type AIClient, + AIError, + type ChatRequest, + type ChatResult, + type ChatUsage, + type Message, + type StructuredResult, + type StructuredSchema, + isValidationFailure, +} from "./types.js"; + +type Sdk = typeof import("@openrouter/sdk"); +type SdkErrors = typeof import("@openrouter/sdk/models/errors"); + +let sdkLoaded = false; + +/** True once the SDK module has been evaluated in this process (test guard). */ +export function isSdkLoaded(): boolean { + return sdkLoaded; +} + +export interface OpenRouterOptions { + /** Override the API base URL (tests point this at a local fake server). */ + serverURL?: string; +} + +export const MAX_STRUCTURED_ATTEMPTS = 2; + +/** Content can be a plain string or a list of typed parts; we only keep text. */ +function textOf(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((part) => { + if (typeof part === "string") return part; + if ( + part && + typeof part === "object" && + typeof (part as { text?: unknown }).text === "string" + ) { + return (part as { text: string }).text; + } + return ""; + }) + .join(""); + } + return ""; +} + +function usageOf(usage: unknown): ChatUsage | undefined { + if (!usage || typeof usage !== "object") return undefined; + const { promptTokens, completionTokens } = usage as Record; + if (typeof promptTokens !== "number" || typeof completionTokens !== "number") return undefined; + return { prompt: promptTokens, completion: completionTokens }; +} + +/** Strip a ```json fence some models wrap around structured output. */ +export function stripCodeFence(text: string): string { + const trimmed = text.trim(); + const match = /^```[a-zA-Z]*\s*\n([\s\S]*?)\n?```$/.exec(trimmed); + return match ? (match[1] as string).trim() : trimmed; +} + +function detailOf(error: unknown): string { + const nested = (error as { error?: { message?: unknown } }).error; + if (nested && typeof nested.message === "string" && nested.message) return nested.message; + if (error instanceof Error && error.message) return error.message; + return String(error); +} + +function mapError(error: unknown, errors: SdkErrors, model: string): AIError { + if (error instanceof AIError) return error; + // Checked first: these extend OpenRouterError but describe a reply we could + // not decode (statusCode 200), not a failed request. + if ( + error instanceof errors.SDKValidationError || + error instanceof errors.ResponseValidationError + ) { + return new AIError("invalid-response", `Unexpected OpenRouter response: ${detailOf(error)}`); + } + if (error instanceof errors.OpenRouterError) { + const status = error.statusCode; + const detail = detailOf(error); + if (status === 401 || status === 403) { + return new AIError( + "auth", + `OpenRouter rejected the API key (HTTP ${status}): ${detail}\n${describeAISetup()}`, + ); + } + if (status === 402) { + return new AIError( + "credits", + `OpenRouter reports insufficient credits: ${detail}\nTop up at https://openrouter.ai/credits`, + ); + } + if (status === 429) { + return new AIError("rate-limit", `OpenRouter rate limit hit for ${model}: ${detail}`); + } + if (status === 400 || status === 404 || status === 422) { + return new AIError( + "bad-request", + `OpenRouter rejected the request for ${model} (HTTP ${status}): ${detail}\nTry another model with --model .`, + ); + } + return new AIError("network", `OpenRouter request failed (HTTP ${status}): ${detail}`); + } + if (error instanceof errors.RequestAbortedError) return new AIError("aborted", "Request aborted"); + if (error instanceof Error && error.name === "AbortError") { + return new AIError("aborted", "Request aborted"); + } + return new AIError("network", `Could not reach OpenRouter: ${detailOf(error)}`); +} + +export async function createOpenRouterClient( + config: AIConfig, + opts: OpenRouterOptions = {}, +): Promise { + const sdk: Sdk = await import("@openrouter/sdk"); + const errors: SdkErrors = await import("@openrouter/sdk/models/errors"); + sdkLoaded = true; + + const client = new sdk.OpenRouter({ + apiKey: config.apiKey, + httpReferer: config.referer, + appTitle: config.title, + // No silent retries: a CLI user sees the failure and decides; retrying + // a 5xx with backoff would look like a hang. + retryConfig: { strategy: "none" }, + ...(opts.serverURL ? { serverURL: opts.serverURL } : {}), + }); + + type ChatRequestBody = Parameters[0]["chatRequest"]; + + function body(req: ChatRequest, messages: Message[]): ChatRequestBody { + return { + model: req.model ?? config.model, + messages: messages.map((m) => ({ role: m.role, content: m.content })) as never, + ...(req.temperature !== undefined ? { temperature: req.temperature } : {}), + ...(req.maxTokens !== undefined ? { maxCompletionTokens: req.maxTokens } : {}), + }; + } + + async function send( + chatRequest: ChatRequestBody, + signal: AbortSignal | undefined, + model: string, + ) { + try { + return await client.chat.send( + { chatRequest }, + signal ? { fetchOptions: { signal } } : undefined, + ); + } catch (error) { + throw mapError(error, errors, model); + } + } + + function isStream(result: unknown): result is AsyncIterable { + return typeof result === "object" && result !== null && Symbol.asyncIterator in result; + } + + function toChatResult(result: unknown, requestedModel: string): ChatResult { + const r = result as { + choices?: Array<{ message?: { content?: unknown; refusal?: unknown } }>; + model?: string; + usage?: unknown; + }; + const choice = r.choices?.[0]; + const content = textOf(choice?.message?.content); + if (!content && typeof choice?.message?.refusal === "string" && choice.message.refusal) { + throw new AIError("invalid-response", `The model refused: ${choice.message.refusal}`); + } + return { content, model: r.model || requestedModel, usage: usageOf(r.usage) }; + } + + return { + async chat(req) { + const model = req.model ?? config.model; + const result = await send({ ...body(req, req.messages), stream: false }, req.signal, model); + if (isStream(result)) throw new AIError("invalid-response", "Expected a complete response"); + return toChatResult(result, model); + }, + + async stream(req, onDelta) { + const model = req.model ?? config.model; + const result = await send({ ...body(req, req.messages), stream: true }, req.signal, model); + if (!isStream(result)) { + const whole = toChatResult(result, model); + if (whole.content) onDelta(whole.content); + return whole; + } + let content = ""; + let answeredBy = ""; + let usage: ChatUsage | undefined; + try { + for await (const chunk of result as AsyncIterable<{ + choices?: Array<{ delta?: { content?: unknown } }>; + model?: string; + usage?: unknown; + error?: { message?: string }; + }>) { + if (chunk.error) { + throw new AIError("network", `OpenRouter stream error: ${chunk.error.message ?? ""}`); + } + const delta = textOf(chunk.choices?.[0]?.delta?.content); + if (delta) { + content += delta; + onDelta(delta); + } + if (chunk.model) answeredBy = chunk.model; + usage = usageOf(chunk.usage) ?? usage; + } + } catch (error) { + throw mapError(error, errors, model); + } + return { content, model: answeredBy || model, usage }; + }, + + async structured( + req: ChatRequest, + schema: StructuredSchema, + ): Promise> { + const model = req.model ?? config.model; + const messages = [...req.messages]; + for (let attempt = 1; ; attempt++) { + const result = await send( + { + ...body(req, messages), + stream: false, + responseFormat: { + type: "json_schema", + jsonSchema: { name: schema.name, strict: true, schema: schema.schema }, + }, + provider: { requireParameters: true }, + }, + req.signal, + model, + ); + if (isStream(result)) throw new AIError("invalid-response", "Expected a complete response"); + const chat = toChatResult(result, model); + const raw = chat.content; + let problems: string[]; + try { + const validated = schema.validate(JSON.parse(stripCodeFence(raw))); + if (!isValidationFailure(validated)) { + return { value: validated.value, raw, model: chat.model, attempts: attempt }; + } + problems = validated.errors; + } catch (error) { + problems = [ + `response is not valid JSON: ${error instanceof Error ? error.message : error}`, + ]; + } + if (attempt >= MAX_STRUCTURED_ATTEMPTS) { + throw new AIError( + "invalid-response", + `${chat.model} returned an invalid ${schema.name} after ${attempt} attempts:\n- ${problems.join("\n- ")}`, + ); + } + messages.push( + { role: "assistant", content: raw }, + { + role: "user", + content: `Your previous response failed validation:\n- ${problems.join("\n- ")}\nReturn the complete corrected JSON object only.`, + }, + ); + } + }, + }; +} diff --git a/test/core/ai/client.test.ts b/test/core/ai/client.test.ts new file mode 100644 index 0000000..35c72e1 --- /dev/null +++ b/test/core/ai/client.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test"; +import { createAIClient } from "../../../src/core/ai/client.js"; +import { Conversation } from "../../../src/core/ai/conversation.js"; +import { AIError } from "../../../src/core/ai/types.js"; +import { withEnv } from "../../helpers/env.js"; + +describe("createAIClient", () => { + test("constructing it never throws; a missing key surfaces on first use", async () => { + const ai = createAIClient(); + let caught: unknown; + await withEnv({ OPENROUTER_API_KEY: undefined }, async () => { + try { + await ai.chat({ messages: [{ role: "user", content: "hi" }] }); + } catch (error) { + caught = error; + } + }); + expect(caught).toBeInstanceOf(AIError); + expect((caught as AIError).kind).toBe("missing-key"); + }); +}); + +describe("Conversation", () => { + test("keeps the system prompt first and hands out copies", () => { + const convo = new Conversation("sys").user("q1").assistant("a1").user("q2"); + expect(convo.messages.map((m) => m.role)).toEqual(["system", "user", "assistant", "user"]); + expect(convo.userTurns).toBe(2); + convo.messages.push({ role: "user", content: "leak?" }); + expect(convo.messages).toHaveLength(4); + }); +}); diff --git a/test/core/ai/openrouter.test.ts b/test/core/ai/openrouter.test.ts new file mode 100644 index 0000000..ad2374d --- /dev/null +++ b/test/core/ai/openrouter.test.ts @@ -0,0 +1,279 @@ +/** + * The OpenRouter adapter against a local fake of the OpenRouter HTTP API. + * The real SDK runs end to end (request shaping, SSE parsing, typed + * errors); only the network is faked, via `serverURL`. + */ + +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import type { AIConfig } from "../../../src/core/ai/config.js"; +import { + createOpenRouterClient, + isSdkLoaded, + stripCodeFence, +} from "../../../src/core/ai/openrouter.js"; +import { PLAN_STRUCTURED } from "../../../src/core/ai/plan.js"; +import { type AIClient, AIError, type StructuredSchema } from "../../../src/core/ai/types.js"; + +interface Recorded { + headers: Record; + body: Record; +} + +type Reply = + | { status: number; json: unknown } + | { sse: string[] } + | { text: string; content: string; model?: string }; + +const recorded: Recorded[] = []; +const replies: Reply[] = []; +let server: ReturnType; + +function completion(content: string, model = "test/model", extra: Record = {}) { + return { + id: "gen-1", + object: "chat.completion", + created: 1, + model, + system_fingerprint: "fp", + choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + ...extra, + }; +} + +function chunk(content: string, model = "test/model") { + return JSON.stringify({ + id: "gen-1", + object: "chat.completion.chunk", + created: 1, + model, + choices: [{ index: 0, delta: { role: "assistant", content }, finish_reason: null }], + }); +} + +beforeAll(() => { + server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(req) { + const url = new URL(req.url); + if (url.pathname !== "/api/v1/chat/completions") { + return new Response("not found", { status: 404 }); + } + const headers: Record = {}; + req.headers.forEach((v, k) => { + headers[k.toLowerCase()] = v; + }); + recorded.push({ headers, body: (await req.json()) as Record }); + const reply = replies.shift(); + if (!reply) { + return new Response(JSON.stringify(completion("(no reply queued)")), { + headers: { "content-type": "application/json" }, + }); + } + if ("sse" in reply) { + const body = `${reply.sse.map((line) => `data: ${line}\n\n`).join("")}data: [DONE]\n\n`; + return new Response(body, { headers: { "content-type": "text/event-stream" } }); + } + if ("json" in reply) { + return new Response(JSON.stringify(reply.json), { + status: reply.status, + headers: { "content-type": "application/json" }, + }); + } + return new Response(JSON.stringify(completion(reply.content, reply.model)), { + headers: { "content-type": "application/json" }, + }); + }, + }); +}); + +afterAll(() => { + server.stop(true); +}); + +beforeEach(() => { + recorded.length = 0; + replies.length = 0; +}); + +const config: AIConfig = { + apiKey: "sk-test", + model: "test/default", + referer: "https://example.test/app", + title: "of-test", +}; + +async function client(): Promise { + return createOpenRouterClient(config, { serverURL: `http://127.0.0.1:${server.port}/api/v1` }); +} + +const messages = [ + { role: "system" as const, content: "You are terse." }, + { role: "user" as const, content: "Hi" }, +]; + +describe("createOpenRouterClient", () => { + test("loads the SDK lazily and sends a well-formed chat request", async () => { + replies.push({ text: "", content: "Hello!", model: "test/model" }); + const ai = await client(); + expect(isSdkLoaded()).toBe(true); + const result = await ai.chat({ messages, temperature: 0.3, maxTokens: 50 }); + expect(result).toEqual({ + content: "Hello!", + model: "test/model", + usage: { prompt: 10, completion: 5 }, + }); + const sent = recorded[0] as Recorded; + expect(sent.headers.authorization).toBe("Bearer sk-test"); + expect(sent.headers["http-referer"]).toBe("https://example.test/app"); + expect(sent.headers["x-openrouter-title"]).toBe("of-test"); + expect(sent.body).toMatchObject({ + model: "test/default", + messages, + temperature: 0.3, + max_completion_tokens: 50, + stream: false, + }); + }); + + test("a per-request model overrides the configured one", async () => { + replies.push({ text: "", content: "ok" }); + const ai = await client(); + await ai.chat({ messages, model: "other/model" }); + expect((recorded[0] as Recorded).body.model).toBe("other/model"); + }); + + test("streams deltas and returns the assembled text", async () => { + replies.push({ sse: [chunk("Hel"), chunk("lo"), chunk(" there")] }); + const ai = await client(); + const deltas: string[] = []; + const result = await ai.stream({ messages }, (d) => deltas.push(d)); + expect(deltas).toEqual(["Hel", "lo", " there"]); + expect(result.content).toBe("Hello there"); + expect(result.model).toBe("test/model"); + expect((recorded[0] as Recorded).body.stream).toBe(true); + }); + + test("maps HTTP errors to AIError kinds", async () => { + const cases: Array<[number, string]> = [ + [401, "auth"], + [402, "credits"], + [429, "rate-limit"], + [400, "bad-request"], + [500, "network"], + ]; + const ai = await client(); + for (const [status, kind] of cases) { + replies.push({ status, json: { error: { code: status, message: `boom ${status}` } } }); + let caught: unknown; + try { + await ai.chat({ messages }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(AIError); + expect((caught as AIError).kind).toBe(kind as AIError["kind"]); + expect((caught as AIError).message).toContain(`boom ${status}`); + } + }); + + test("an aborted request surfaces as kind aborted", async () => { + const ai = await client(); + const controller = new AbortController(); + controller.abort(); + let caught: unknown; + try { + await ai.chat({ messages, signal: controller.signal }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(AIError); + expect((caught as AIError).kind).toBe("aborted"); + }); + + test("structured: sends a strict json_schema format and validates the reply", async () => { + const plan = { + summary: "s", + sequential: true, + questions: [], + tasks: [ + { + key: "1", + parentKey: null, + name: "Open the app", + note: "", + estimateMinutes: 2, + tags: [], + flag: false, + sequential: false, + due: null, + defer: null, + }, + ], + }; + replies.push({ text: "", content: `\`\`\`json\n${JSON.stringify(plan)}\n\`\`\`` }); + const ai = await client(); + const result = await ai.structured({ messages }, PLAN_STRUCTURED); + expect(result.attempts).toBe(1); + expect(result.value.tasks[0]?.name).toBe("Open the app"); + const body = (recorded[0] as Recorded).body; + expect(body.response_format).toEqual({ + type: "json_schema", + json_schema: { name: "task_breakdown_plan", strict: true, schema: PLAN_STRUCTURED.schema }, + }); + expect(body.provider).toEqual({ require_parameters: true }); + }); + + test("structured: retries once with the validation problems, then succeeds", async () => { + const schema: StructuredSchema<{ n: number }> = { + name: "num", + schema: { type: "object" }, + validate: (raw) => + typeof raw === "object" && raw !== null && typeof (raw as { n?: unknown }).n === "number" + ? { value: raw as { n: number } } + : { errors: ["n must be a number"] }, + }; + replies.push({ text: "", content: '{"n": "one"}' }, { text: "", content: '{"n": 1}' }); + const ai = await client(); + const result = await ai.structured({ messages }, schema); + expect(result).toMatchObject({ value: { n: 1 }, attempts: 2, raw: '{"n": 1}' }); + const retry = (recorded[1] as Recorded).body.messages as Array<{ + role: string; + content: string; + }>; + expect(retry).toHaveLength(4); + expect(retry[2]).toEqual({ role: "assistant", content: '{"n": "one"}' }); + expect(retry[3]?.role).toBe("user"); + expect(retry[3]?.content).toContain("n must be a number"); + }); + + test("structured: gives up after the second invalid reply", async () => { + const schema: StructuredSchema = { + name: "never", + schema: { type: "object" }, + validate: () => ({ errors: ["always wrong"] }), + }; + replies.push({ text: "", content: "{}" }, { text: "", content: "not json" }); + const ai = await client(); + let caught: unknown; + try { + await ai.structured({ messages }, schema); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(AIError); + expect((caught as AIError).kind).toBe("invalid-response"); + expect((caught as AIError).message).toContain("2 attempts"); + expect((caught as AIError).message).toContain("not valid JSON"); + expect(recorded).toHaveLength(2); + }); +}); + +describe("stripCodeFence", () => { + test("removes a fenced block and leaves plain text alone", () => { + expect(stripCodeFence('```json\n{"a":1}\n```')).toBe('{"a":1}'); + expect(stripCodeFence('```\n{"a":1}```')).toBe('{"a":1}'); + expect(stripCodeFence(' {"a":1} ')).toBe('{"a":1}'); + }); +}); diff --git a/test/core/ai/plan.test.ts b/test/core/ai/plan.test.ts index f06072e..6326006 100644 --- a/test/core/ai/plan.test.ts +++ b/test/core/ai/plan.test.ts @@ -104,7 +104,9 @@ describe("validatePlan", () => { 'task "1": defer must be a string or null', ]); expect(errorsOf(plan([task({ key: "" })]))[0]).toBe("tasks[0].key must be a non-empty string"); - expect(errorsOf(plan(["not a task" as unknown as Record]))).toEqual(["tasks[0] must be an object"]); + expect(errorsOf(plan(["not a task" as unknown as Record]))).toEqual([ + "tasks[0] must be an object", + ]); }); }); From cfaa777fd532395ed9ab25776a1534ac1555e961 Mon Sep 17 00:00:00 2001 From: Max Boettinger Date: Thu, 3 Sep 2026 11:02:44 +0200 Subject: [PATCH 07/14] feat(program): inject the AI client alongside the OmniFocus client Claude-Session: https://claude.ai/code/session_01L2voqEC2eEwLsiTxoSnCGD --- src/commands/noun.ts | 11 +++-- src/core/docs.md | 1 + src/core/output.ts | 4 +- src/index.ts | 4 +- src/program.ts | 21 +++++--- test/commands/noun.test.ts | 11 +++-- test/core/output.test.ts | 12 ++--- test/fixtures/fake-ai.ts | 82 ++++++++++++++++++++++++++++++++ test/helpers/run.ts | 20 ++++++-- test/integration/cli.test.ts | 7 +-- test/integration/program.test.ts | 40 ++++++++++++++++ test/integration/stdin.test.ts | 11 ++--- 12 files changed, 186 insertions(+), 38 deletions(-) create mode 100644 test/fixtures/fake-ai.ts diff --git a/src/commands/noun.ts b/src/commands/noun.ts index 1f850e0..408db82 100644 --- a/src/commands/noun.ts +++ b/src/commands/noun.ts @@ -13,9 +13,14 @@ */ import type { Command } from "commander"; +import type { AIClient } from "../core/ai/types.js"; import type { OmniFocusClient } from "../core/types.js"; -export type Register = (parent: Command, client: OmniFocusClient) => void; +/** + * A verb (or nested noun) registrar. Both clients are always passed; a verb + * that needs no model simply declares two parameters and ignores the third. + */ +export type Register = (parent: Command, client: OmniFocusClient, ai: AIClient) => void; export interface NounSpec { name: string; @@ -32,10 +37,10 @@ export interface NounSpec { } export function defineNoun(spec: NounSpec): Register { - return (parent, client) => { + return (parent, client, ai) => { const cmd = parent.command(spec.name).description(spec.description); if (spec.alias) cmd.alias(spec.alias); - for (const register of spec.verbs) register(cmd, client); + for (const register of spec.verbs) register(cmd, client, ai); applyVerbAliases(cmd, spec.verbAliases ?? {}); }; } diff --git a/src/core/docs.md b/src/core/docs.md index 54cca49..89313b7 100644 --- a/src/core/docs.md +++ b/src/core/docs.md @@ -41,6 +41,7 @@ Path: @/src/core - **`outputMoved(task, touched)`** (`touched: readonly DateField[]`, `DateField = "due" | "defer" | "planned"`) renders `of task move`'s human-mode success output: a `✓ Moved: ()` header (short id only if one is already cached — it calls `peekShortId()`, never mints), then one line per date the task carries, read back from the bridge in a fixed order defined by the `DATE_FIELDS` table (Planned, then Defer, then Due) rather than the order the command happened to touch — every command run therefore lists dates the same way regardless of which fields were passed. A field the command touched is highlighted (green `●` marker, whole `Label: value` text in green, via `@/src/core/ui/colors.ts`); an untouched field is rendered fully dimmed (`•` marker) as context. A touched field whose stored value is `null` still prints as `cleared` (highlighted); an untouched, unset field is omitted entirely. It renders from the task object handed back by the bridge (i.e. what OmniFocus actually stored after read-back verification — see `resolveDate`/`setDateProp` in `@/src/jxa/docs.md`), not from the user's raw natural-language input, so what prints is guaranteed to match what the app holds. - `resolveFormat()`'s output also drives whether progress chrome is allowed for a given command: `@/src/program.ts`'s `preAction` hook computes it and flips `setProgressEnabled()` accordingly, so a spinner can never appear on a run that resolves to JSON. - **Short numeric task aliases** (`@/src/core/short-ids.ts`) decorate human-mode task rendering only. The cache is a JSON file (`{version, counter, aliases: {ofId → n}}`) at `$OF_SHORT_ID_CACHE` (test seam) or `$XDG_CACHE_HOME`/`~/.cache/omnifocus-cli/short-ids.json`, written atomically (temp file + `renameSync` in the same directory). `assignShortIds(ofIds)` mints and persists new aliases via a strictly monotonic counter — a number is never reused, so a pruned or stale alias resolves to "not found" instead of silently pointing at a different task once that task's real id is gone from the cache. The cache is capped at 10,000 entries; `assignShortIds` prunes the lowest-numbered (oldest) entries first, except any id in the current call's own batch. `peekShortId(ofId)` looks up an existing alias without minting one — used for post-action confirmation messages so a task that just got completed or deleted doesn't get a fresh alias assigned on its way out. `lookupShortId(n)` reverse-maps a number back to an OmniFocus id. `resolveTaskRef(ref, explicitId?)` is the entry point commands use to turn a CLI positional into `{ query, id }`: an explicit `--id` always wins; otherwise an all-digit positional that matches a cached alias resolves to that task's real id; anything else stays a fuzzy name query, unchanged from pre-alias behavior. Both `resolveTaskRef` and the newer `resolveTaskId(id, opts?)` share a private `aliasToOfId(value, opts)` helper (all-digit value + cached alias → OmniFocus id, else `undefined`). `resolveTaskId` is for call sites that have already decided a value is an id, not a ref: a short alias resolves to the real id, anything else (a raw OmniFocus id, or a number with no cached alias) passes through verbatim so the bridge reports it as not found — no name-query fallback, no alias minted. `@/src/commands/task/search.ts`'s `search --id` is the sole caller so far. The loader validates the file's *contents*, not just its shape: non-integer or below-1 alias values are dropped, and the counter is lifted to at least the highest stored alias — a hand-edited or truncated cache whose counter lags its aliases would otherwise re-mint a number that is still in use, breaking the never-reused invariant. Every filesystem operation in this module degrades gracefully — a read/write/mkdir failure never throws, listings still render (with freshly minted numbers if the old cache is unreadable) and lookups simply miss. +- **Task line layout** (`formatTaskLine`): ` [] due: min`. The project is rendered dim and *unbracketed* (omitted for Inbox tasks); brackets belong to the cyan tag list, which is the field that most often needs to be picked out of a dense line. - **`formatTaskLine`/`formatTaskDetail` take an optional `ShortIdDisplay`** (`{shortId?, shortIdWidth?}`). `outputTaskList`/`outputTaskDetail` compute it via the exported `taskShortIds(tasks)` (wraps `assignShortIds` over a task array) and `shortIdColumnWidth(aliases)` (widest alias in the set, for right-aligned columns) so every command that renders a task list (`task list`, `forecast`, `collect`) gets consistent, aligned numbering without duplicating the minting logic. JSON output paths never call `taskShortIds` — the cache is untouched and OmniFocus ids appear unmodified in JSON. - **Limit notice stays off stdout**: `outputLimitNotice(count, limit)` routes through `outputWarning()` (see below) — never stdout — when a list command's result count equals the limit it requested. `task list` and `inbox list` call it after `outputTaskList()`. Keeping it on stderr means stdout stays a clean parseable JSON array for pipelines, while interactive users and agents still see the truncation warning. - **Error/warning output is machine-readable when stderr isn't a TTY**: `outputError(error)` accepts either a plain string or a `CLIError` (so a caught `BridgeError`'s `candidates` survive intact — command catch blocks pass the error object through, not a pre-formatted string). When `process.stderr.isTTY` is not `true` (piped/redirected), it emits one JSON line matching the bridge's own `{ ok: false, error, candidates? }` shape; on a real terminal it renders the human `"✗ ..."` form, including a "Did you mean:" list for `BridgeError` candidates. `outputWarning(message)` follows the identical contract at a smaller scale: `{"warning":...}` when piped, `"! ..."` on a terminal. diff --git a/src/core/output.ts b/src/core/output.ts index 111c253..143e7fd 100644 --- a/src/core/output.ts +++ b/src/core/output.ts @@ -172,12 +172,12 @@ export function formatTaskLine(task: OFTask, display: ShortIdDisplay = {}): stri // Project (if not Inbox) if (task.project && task.project !== "Inbox") { - parts.push(dim(`[${task.project}]`)); + parts.push(dim(task.project)); } // Tags if (task.tags.length > 0) { - parts.push(cyan(task.tags.join(", "))); + parts.push(cyan(`[${task.tags.join(", ")}]`)); } // Due date diff --git a/src/index.ts b/src/index.ts index d48ebb9..b8b94af 100755 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ * global error handling. */ +import { createAIClient } from "./core/ai/client.js"; import { createClient } from "./core/client.js"; import { CLIError } from "./core/errors.js"; import { outputError } from "./core/output.js"; @@ -14,7 +15,8 @@ import { buildProgram } from "./program.js"; // The progress decorator is the only UI concern wired at the entry point: // every bridge round-trip gets a stderr spinner in human mode (see ui/progress). -const program = buildProgram(withProgress(createClient())); +// The AI client is lazy: nothing is resolved or loaded until a verb uses it. +const program = buildProgram(withProgress(createClient()), createAIClient()); // Global error handler program.exitOverride(); diff --git a/src/program.ts b/src/program.ts index 5139cf7..f23ac54 100644 --- a/src/program.ts +++ b/src/program.ts @@ -18,11 +18,18 @@ import { registerReviewCommand } from "./commands/review.js"; import { registerStatsCommand } from "./commands/stats.js"; import { registerTagCommands } from "./commands/tag/index.js"; import { registerTaskCommands } from "./commands/task/index.js"; +import { createAIClient } from "./core/ai/client.js"; +import type { AIClient } from "./core/ai/types.js"; import { resolveFormat } from "./core/output.js"; import type { OmniFocusClient } from "./core/types.js"; import { setProgressEnabled } from "./core/ui/progress.js"; -export function buildProgram(client: OmniFocusClient): Command { +/** + * @param client - the OmniFocus seam (real or mock) + * @param ai - the model seam; defaults to the lazy OpenRouter client, which + * costs nothing until a verb calls it. Tests pass a scripted fake. + */ +export function buildProgram(client: OmniFocusClient, ai: AIClient = createAIClient()): Command { const program = new Command(); program @@ -39,12 +46,12 @@ export function buildProgram(client: OmniFocusClient): Command { setProgressEnabled(resolveFormat(json) === "human"); }); - registerTaskCommands(program, client); - registerProjectCommands(program, client); - registerTagCommands(program, client); - registerFolderCommands(program, client); - registerInboxCommands(program, client); - registerBulkCommands(program, client); + registerTaskCommands(program, client, ai); + registerProjectCommands(program, client, ai); + registerTagCommands(program, client, ai); + registerFolderCommands(program, client, ai); + registerInboxCommands(program, client, ai); + registerBulkCommands(program, client, ai); registerForecastCommand(program, client); registerReviewCommand(program, client); registerStatsCommand(program, client); diff --git a/test/commands/noun.test.ts b/test/commands/noun.test.ts index 688eeb7..075a8c0 100644 --- a/test/commands/noun.test.ts +++ b/test/commands/noun.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { Command } from "commander"; import { defineNoun } from "../../src/commands/noun.js"; +import { createFakeAI } from "../fixtures/fake-ai.js"; import { createMockClient } from "../fixtures/mock-client.js"; describe("defineNoun", () => { @@ -18,7 +19,7 @@ describe("defineNoun", () => { ], }); const program = new Command(); - register(program, createMockClient()); + register(program, createMockClient(), createFakeAI()); const noun = program.commands.find((c) => c.name() === "widget"); expect(noun?.aliases()).toEqual(["w"]); expect(noun?.description()).toBe("Manage widgets"); @@ -28,7 +29,11 @@ describe("defineNoun", () => { test("nested nouns need no alias", () => { const program = new Command(); - defineNoun({ name: "inner", description: "d", verbs: [] })(program, createMockClient()); + defineNoun({ name: "inner", description: "d", verbs: [] })( + program, + createMockClient(), + createFakeAI(), + ); expect(program.commands[0]?.aliases()).toEqual([]); }); @@ -40,7 +45,7 @@ describe("defineNoun", () => { function build(spec: Parameters[0]): Command { const program = new Command(); - defineNoun(spec)(program, createMockClient()); + defineNoun(spec)(program, createMockClient(), createFakeAI()); return program.commands[0] as Command; } diff --git a/test/core/output.test.ts b/test/core/output.test.ts index 2ddee66..28eb7e3 100644 --- a/test/core/output.test.ts +++ b/test/core/output.test.ts @@ -101,14 +101,15 @@ describe("formatTaskLine", () => { expect(line).toContain("Buy groceries"); }); - test("includes project in brackets for non-inbox tasks", () => { + test("includes project without brackets for non-inbox tasks", () => { const line = formatTaskLine(makeTask({ project: "Work" })); - expect(line).toContain("[Work]"); + expect(line).toContain("Work"); + expect(line).not.toContain("[Work]"); }); test("omits project for inbox tasks", () => { const line = formatTaskLine(makeTask({ project: "Inbox" })); - expect(line).not.toContain("[Inbox]"); + expect(line).not.toContain("Inbox"); }); test("shows flag indicator when flagged", () => { @@ -116,10 +117,9 @@ describe("formatTaskLine", () => { expect(line).toContain("⚑"); }); - test("includes tags", () => { + test("wraps tags in brackets", () => { const line = formatTaskLine(makeTask({ tags: ["errand", "urgent"] })); - expect(line).toContain("errand"); - expect(line).toContain("urgent"); + expect(line).toContain("[errand, urgent]"); }); test("includes due date", () => { diff --git a/test/fixtures/fake-ai.ts b/test/fixtures/fake-ai.ts new file mode 100644 index 0000000..d27fe89 --- /dev/null +++ b/test/fixtures/fake-ai.ts @@ -0,0 +1,82 @@ +/** + * Scripted `AIClient` for tests — the model-side counterpart of + * `createMockClient()`. Replies and plans are queues consumed in order; + * every request is recorded so tests can assert on prompts, history and + * per-request options. Plans go through the real schema validator, so a + * test fixture that would not survive the production path fails loudly. + */ + +import { Conversation } from "../../src/core/ai/conversation.js"; +import { + type AIClient, + AIError, + type ChatRequest, + type StructuredSchema, + isValidationFailure, +} from "../../src/core/ai/types.js"; + +export interface FakeAIScript { + /** Successive `chat`/`stream` replies. */ + replies?: string[]; + /** Successive `structured` replies (raw JSON values). */ + plans?: unknown[]; +} + +export interface FakeAI extends AIClient { + requests: ChatRequest[]; + /** Add more scripted output mid-test. */ + queue(script: FakeAIScript): void; +} + +export const FAKE_MODEL = "fake/model"; + +export function createFakeAI(script: FakeAIScript = {}): FakeAI { + const replies = [...(script.replies ?? [])]; + const plans = [...(script.plans ?? [])]; + const requests: ChatRequest[] = []; + + function next(queue: T[], what: string): T { + if (queue.length === 0) throw new AIError("invalid-response", `fake AI has no ${what} queued`); + return queue.shift() as T; + } + + return { + requests, + queue(more) { + replies.push(...(more.replies ?? [])); + plans.push(...(more.plans ?? [])); + }, + async chat(req) { + requests.push(req); + return { content: next(replies, "reply"), model: FAKE_MODEL }; + }, + async stream(req, onDelta) { + requests.push(req); + const content = next(replies, "reply"); + onDelta(content); + return { content, model: FAKE_MODEL }; + }, + async structured(req: ChatRequest, schema: StructuredSchema) { + requests.push(req); + const raw = next(plans, "plan"); + const validated = schema.validate(raw); + if (isValidationFailure(validated)) { + throw new AIError( + "invalid-response", + `fake plan does not satisfy ${schema.name}:\n- ${validated.errors.join("\n- ")}`, + ); + } + return { value: validated.value, raw: JSON.stringify(raw), model: FAKE_MODEL, attempts: 1 }; + }, + }; +} + +/** Convenience for asserting on the last request's message roles/contents. */ +export function lastRequest(ai: FakeAI): ChatRequest { + const last = ai.requests[ai.requests.length - 1]; + if (!last) throw new Error("fake AI received no requests"); + return last; +} + +// Keep the Conversation import meaningful for fixture authors building histories. +export { Conversation }; diff --git a/test/helpers/run.ts b/test/helpers/run.ts index a326427..864dd85 100644 --- a/test/helpers/run.ts +++ b/test/helpers/run.ts @@ -12,27 +12,34 @@ import { Readable } from "node:stream"; import { Command } from "commander"; +import type { AIClient } from "../../src/core/ai/types.js"; import type { OmniFocusClient } from "../../src/core/types.js"; +import { type FakeAI, createFakeAI } from "../fixtures/fake-ai.js"; import { createMockClient } from "../fixtures/mock-client.js"; import { withStdin } from "./env.js"; +export type Setup = (program: Command, client: OmniFocusClient, ai: AIClient) => void; + export interface RunResult { client: OmniFocusClient; + ai: FakeAI; stdout: string[]; stderr: string[]; exitCode: number | undefined; } export async function runCommand( - setup: (program: Command, client: OmniFocusClient) => void, + setup: Setup, argv: string[], client?: OmniFocusClient, + ai?: FakeAI, ): Promise { const c = client ?? createMockClient(); + const fakeAi = ai ?? createFakeAI(); const program = new Command(); // Mirror the real program: --json is a root option only (src/program.ts). program.name("of").option("--json", "Output in JSON format").exitOverride(); - setup(program, c); + setup(program, c, fakeAi); const stdout: string[] = []; const stderr: string[] = []; @@ -56,15 +63,18 @@ export async function runCommand( console.error = origErr; process.exit = origExit; } - return { client: c, stdout, stderr, exitCode }; + return { client: c, ai: fakeAi, stdout, stderr, exitCode }; } /** `runCommand` with `stdinText` piped in as the command's stdin. */ export function runCommandWithStdin( - setup: (program: Command, client: OmniFocusClient) => void, + setup: Setup, argv: string[], stdinText: string, client?: OmniFocusClient, + ai?: FakeAI, ): Promise { - return withStdin(Readable.from([Buffer.from(stdinText)]), () => runCommand(setup, argv, client)); + return withStdin(Readable.from([Buffer.from(stdinText)]), () => + runCommand(setup, argv, client, ai), + ); } diff --git a/test/integration/cli.test.ts b/test/integration/cli.test.ts index 73d23e1..f726eef 100644 --- a/test/integration/cli.test.ts +++ b/test/integration/cli.test.ts @@ -21,6 +21,7 @@ import { registerProjectCommands } from "../../src/commands/project/index.js"; import { registerStatsCommand } from "../../src/commands/stats.js"; import { registerTagCommands } from "../../src/commands/tag/index.js"; import { registerTaskCommands } from "../../src/commands/task/index.js"; +import type { AIClient } from "../../src/core/ai/types.js"; import { assignShortIds } from "../../src/core/short-ids.js"; import type { OmniFocusClient } from "../../src/core/types.js"; import { createMockClient } from "../fixtures/mock-client.js"; @@ -936,8 +937,8 @@ describe("collect command", () => { describe("completion command", () => { test("fish completion gates notification verbs on exact task notification path", async () => { const { stdout } = await runCommand( - (program: Command, client: OmniFocusClient) => { - registerTaskCommands(program, client); + (program: Command, client: OmniFocusClient, ai: AIClient) => { + registerTaskCommands(program, client, ai); registerCompletionCommand(program); }, ["completion", "fish"], @@ -1214,7 +1215,7 @@ describe("short id display", () => { }); async function runHuman( - setup: (program: Command, client: OmniFocusClient) => void, + setup: (program: Command, client: OmniFocusClient, ai: AIClient) => void, argv: string[], client?: OmniFocusClient, ): Promise<{ client: OmniFocusClient; stdout: string[]; stderr: string[] }> { diff --git a/test/integration/program.test.ts b/test/integration/program.test.ts index 3f73807..f43e1d5 100644 --- a/test/integration/program.test.ts +++ b/test/integration/program.test.ts @@ -5,10 +5,13 @@ */ import { afterEach, describe, expect, type mock, test } from "bun:test"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; import type { Command } from "commander"; import pkg from "../../package.json" with { type: "json" }; import { isProgressEnabled, setProgressEnabled, withProgress } from "../../src/core/ui/progress.js"; import { buildProgram } from "../../src/program.js"; +import { createFakeAI } from "../fixtures/fake-ai.js"; import { createMockClient } from "../fixtures/mock-client.js"; import { successResponse } from "../fixtures/mock-responses.js"; import { withEnv, withStreamTTY } from "../helpers/env.js"; @@ -228,3 +231,40 @@ describe("progress gating by output format", () => { expect(chrome).toBe(""); }); }); + +describe("AI seam", () => { + test("a non-AI command never touches the AI client", async () => { + const client = createMockClient(); + const ai = createFakeAI(); + const program = buildProgram(client, ai).exitOverride(); + const origLog = console.log; + console.log = () => {}; + try { + await program.parseAsync(["task", "list", "--json"], { from: "user" }); + await program.parseAsync(["forecast", "--json"], { from: "user" }); + } finally { + console.log = origLog; + } + expect(ai.requests).toEqual([]); + }); + + test("only the OpenRouter adapter imports the SDK, and only dynamically", () => { + const files: string[] = []; + const walk = (dir: string) => { + for (const entry of readdirSync(dir)) { + const path = join(dir, entry); + if (statSync(path).isDirectory()) walk(path); + else if (path.endsWith(".ts")) files.push(path); + } + }; + walk(join(import.meta.dir, "../../src")); + const importers = files.filter((f) => + /^import\s+(?!type\b)[^;]*from\s+"@openrouter\/sdk/m.test(readFileSync(f, "utf8")), + ); + // No static value import anywhere: the SDK must be `await import()`ed + // so a `--json` listing never evaluates it. + expect(importers).toEqual([]); + const users = files.filter((f) => readFileSync(f, "utf8").includes("@openrouter/sdk")); + expect(users.map((f) => f.split("/src/")[1])).toEqual(["core/ai/openrouter.ts"]); + }); +}); diff --git a/test/integration/stdin.test.ts b/test/integration/stdin.test.ts index 9476a79..6b8843e 100644 --- a/test/integration/stdin.test.ts +++ b/test/integration/stdin.test.ts @@ -4,17 +4,12 @@ */ import { describe, expect, test } from "bun:test"; -import type { Command } from "commander"; import { registerBulkCommands } from "../../src/commands/bulk/index.js"; import { registerInboxCommands } from "../../src/commands/inbox/index.js"; -import type { OmniFocusClient } from "../../src/core/types.js"; import { withStdin } from "../helpers/env.js"; -import { runCommand } from "../helpers/run.js"; +import { type Setup, runCommand } from "../helpers/run.js"; -function runWithTtyStdin( - setup: (program: Command, client: OmniFocusClient) => void, - argv: string[], -) { +function runWithTtyStdin(setup: Setup, argv: string[]) { return withStdin({ isTTY: true }, () => runCommand(setup, argv)); } @@ -25,7 +20,7 @@ describe("stdin TTY guard", () => { // instead of letting it propagate as a rejection. const cases: Array<{ name: string; - setup: (program: Command, client: OmniFocusClient) => void; + setup: Setup; argv: string[]; }> = [ { name: "bulk add", setup: registerBulkCommands, argv: ["bulk", "add"] }, From 5981e0a93184a2b981ea224d5a9b80664802a8ea Mon Sep 17 00:00:00 2001 From: Max Boettinger Date: Thu, 3 Sep 2026 11:05:28 +0200 Subject: [PATCH 08/14] feat(ai): render OmniFocus task context for prompts Claude-Session: https://claude.ai/code/session_01L2voqEC2eEwLsiTxoSnCGD --- src/core/ai/context.ts | 170 +++++++++++++++++++++++++++++++++++ test/core/ai/context.test.ts | 124 +++++++++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 src/core/ai/context.ts create mode 100644 test/core/ai/context.test.ts diff --git a/src/core/ai/context.ts b/src/core/ai/context.ts new file mode 100644 index 0000000..a87ff3d --- /dev/null +++ b/src/core/ai/context.ts @@ -0,0 +1,170 @@ +/** + * Render a `TaskContext` (the `task.context` bridge payload) as the + * Markdown block that opens every AI conversation about a task. + * + * The prompt files stay static; everything situational — the task, where + * it sits, what already exists under it, what the user added — arrives + * through this one renderer, so both verbs describe OmniFocus to the + * model in exactly the same words. + */ + +import type { ContextNode, OFProject, OFTask, TaskContext } from "../types.js"; + +export interface RenderContextOptions { + /** Local calendar date, e.g. "2026-09-03". */ + today: string; + /** Free-form text the user passed with --context. */ + extra?: string; +} + +export const SIBLING_DISPLAY_LIMIT = 40; +const TARGET_NOTE_LIMIT = 1500; +const OTHER_NOTE_LIMIT = 200; + +function pad2(n: number): string { + return n < 10 ? `0${n}` : String(n); +} + +/** Local wall-clock `YYYY-MM-DD HH:mm` — what the user sees in OmniFocus. */ +export function formatContextDate(iso: string | null): string | null { + if (!iso) return null; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`; +} + +function truncate(text: string, limit: number): string { + const oneLine = text.replace(/\s+/g, " ").trim(); + return oneLine.length > limit ? `${oneLine.slice(0, limit - 1)}…` : oneLine; +} + +function checkbox(completed: boolean): string { + return completed ? "[x]" : "[ ]"; +} + +function orderWord(sequential: boolean): string { + return sequential ? "sequential (children in order)" : "parallel (children in any order)"; +} + +function taskFacts(task: OFTask, noteLimit: number): string[] { + const facts: string[] = []; + if (task.note) facts.push(`- Note: ${truncate(task.note, noteLimit)}`); + const due = formatContextDate(task.dueDate); + const defer = formatContextDate(task.deferDate); + const planned = formatContextDate(task.plannedDate); + if (due) facts.push(`- Due: ${due}`); + if (defer) facts.push(`- Defer until: ${defer}`); + if (planned) facts.push(`- Planned for: ${planned}`); + if (task.flagged) facts.push("- Flagged: yes"); + if (task.estimatedMinutes) facts.push(`- Estimate: ${task.estimatedMinutes} min`); + if (task.tags.length > 0) facts.push(`- Tags: ${task.tags.join(", ")}`); + if (task.repetitionRule) facts.push(`- Repeats: ${task.repetitionRule.rule}`); + return facts; +} + +function renderTarget(task: OFTask, children: ContextNode[]): string[] { + const lines = [ + "## Target task", + `- Name: ${task.name}`, + `- Id: ${task.id}`, + `- Status: ${task.completed ? "completed" : task.blocked ? "blocked (waiting on earlier tasks)" : "open"}`, + `- Type: ${orderWord(task.sequential)}`, + ...taskFacts(task, TARGET_NOTE_LIMIT), + ]; + if (task.inInbox) lines.push("- Location: inbox (not yet filed into a project)"); + const done = countCompleted(children); + lines.push( + `- Existing subtasks: ${children.length === 0 ? "none" : `${children.length} direct (${done} completed)`}`, + ); + return lines; +} + +function countCompleted(nodes: ContextNode[]): number { + return nodes.filter((n) => n.completed).length; +} + +function renderAncestors(ancestors: OFTask[]): string[] { + const lines = ["## Parent tasks (nearest first)"]; + if (ancestors.length === 0) return [...lines, "none"]; + for (const a of ancestors) { + lines.push(`- ${checkbox(a.completed)} ${a.name} — ${orderWord(a.sequential)}`); + for (const fact of taskFacts(a, OTHER_NOTE_LIMIT)) lines.push(` ${fact}`); + } + return lines; +} + +function renderProject(project: OFProject | null): string[] { + const lines = ["## Project"]; + if (!project) return [...lines, "none (inbox task)"]; + const remaining = project.taskCount - project.completedTaskCount; + lines.push( + `- Name: ${project.name}`, + `- Status: ${project.status}`, + `- Type: ${orderWord(project.sequential)}`, + `- Tasks: ${remaining} remaining of ${project.taskCount}`, + ); + if (project.parentFolder) lines.push(`- Folder: ${project.parentFolder}`); + const due = formatContextDate(project.dueDate); + if (due) lines.push(`- Due: ${due}`); + if (project.note) lines.push(`- Note: ${truncate(project.note, OTHER_NOTE_LIMIT)}`); + return lines; +} + +function renderSubtree(nodes: ContextNode[], depth: number, out: string[]): void { + const indent = " ".repeat(depth); + for (const node of nodes) { + const bits: string[] = []; + if (node.estimatedMinutes) bits.push(`${node.estimatedMinutes} min`); + if (node.tags.length > 0) bits.push(node.tags.join(", ")); + if (node.children.length > 0) bits.push(node.sequential ? "in order" : "any order"); + const suffix = bits.length > 0 ? ` (${bits.join("; ")})` : ""; + out.push(`${indent}- ${checkbox(node.completed)} ${node.name}${suffix}`); + if (node.note) out.push(`${indent} note: ${truncate(node.note, OTHER_NOTE_LIMIT)}`); + renderSubtree(node.children, depth + 1, out); + } +} + +function renderChildren(children: ContextNode[]): string[] { + const lines = ["## Existing subtasks (already under the target — do not recreate)"]; + if (children.length === 0) return [...lines, "none"]; + renderSubtree(children, 0, lines); + return lines; +} + +function renderSiblings(ctx: TaskContext): string[] { + const lines = ["## Sibling tasks (same container, for orientation only)"]; + if (ctx.siblings.length === 0) return [...lines, "none"]; + const shown = ctx.siblings.slice(0, SIBLING_DISPLAY_LIMIT); + for (const s of shown) lines.push(`- ${checkbox(s.completed)} ${s.name}`); + if (ctx.siblings.length > shown.length) { + lines.push(`- … and ${ctx.siblings.length - shown.length} more`); + } + return lines; +} + +function renderTags(tags: string[]): string[] { + const lines = ["## Available tags (the only tags that may be used)"]; + return [...lines, tags.length === 0 ? "none" : tags.join(", ")]; +} + +/** The full Markdown context block for a task. */ +export function renderTaskContext(ctx: TaskContext, opts: RenderContextOptions): string { + const sections: string[][] = [ + ["## Today", opts.today], + renderTarget(ctx.task, ctx.children), + renderAncestors(ctx.ancestors), + renderProject(ctx.project), + renderChildren(ctx.children), + renderSiblings(ctx), + renderTags(ctx.tags), + ]; + if (opts.extra?.trim()) { + sections.push(["## Additional context from the user", opts.extra.trim()]); + } + return sections.map((s) => s.join("\n")).join("\n\n"); +} + +/** Local calendar date for "today", in the renderer's format. */ +export function todayString(now: Date = new Date()): string { + return `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`; +} diff --git a/test/core/ai/context.test.ts b/test/core/ai/context.test.ts new file mode 100644 index 0000000..7fbbde5 --- /dev/null +++ b/test/core/ai/context.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from "bun:test"; +import { + SIBLING_DISPLAY_LIMIT, + formatContextDate, + renderTaskContext, + todayString, +} from "../../../src/core/ai/context.js"; +import type { TaskContext } from "../../../src/core/types.js"; +import { MOCK_PROJECT, MOCK_TASK, MOCK_TASK_CONTEXT } from "../../fixtures/mock-responses.js"; + +const TODAY = "2026-09-03"; + +describe("renderTaskContext", () => { + test("renders every section for the mock context", () => { + const text = renderTaskContext(MOCK_TASK_CONTEXT, { today: TODAY }); + expect(text).toContain("## Today\n2026-09-03"); + expect(text).toContain("## Target task\n- Name: Buy groceries\n- Id: task-abc123"); + expect(text).toContain("- Type: parallel (children in any order)"); + expect(text).toContain("- Note: Milk, eggs, bread"); + expect(text).toContain(`- Due: ${formatContextDate(MOCK_TASK.dueDate)}`); + expect(text).toContain("- Flagged: yes"); + expect(text).toContain("- Estimate: 30 min"); + expect(text).toContain("- Tags: errand, home"); + expect(text).toContain("- Existing subtasks: 1 direct (1 completed)"); + expect(text).toContain("## Parent tasks (nearest first)\nnone"); + expect(text).toContain("## Project\n- Name: Home Renovation\n- Status: active"); + expect(text).toContain("- Type: sequential (children in order)"); + expect(text).toContain("- Tasks: 10 remaining of 15"); + expect(text).toContain("- Folder: Personal"); + expect(text).toContain("## Existing subtasks"); + expect(text).toContain("- [x] Write shopping list (5 min)"); + expect(text).toContain("## Sibling tasks"); + expect(text).toContain("- [ ] Return library books"); + expect(text).toContain( + "## Available tags (the only tags that may be used)\nerrand, home, @computer", + ); + expect(text).not.toContain("Additional context"); + }); + + test("includes the user's extra context and nested subtrees", () => { + const ctx: TaskContext = { + ...MOCK_TASK_CONTEXT, + ancestors: [{ ...MOCK_TASK, id: "anc", name: "Weekly errands", sequential: true, note: "" }], + children: [ + { + ...MOCK_TASK, + id: "c1", + name: "Step one", + completed: false, + note: "A hint", + sequential: true, + estimatedMinutes: null, + tags: ["home"], + children: [{ ...MOCK_TASK, id: "c1a", name: "Nested", completed: false, children: [] }], + }, + ], + }; + const text = renderTaskContext(ctx, { today: TODAY, extra: " Focus on the kitchen " }); + expect(text).toContain("- [ ] Weekly errands — sequential (children in order)"); + expect(text).toContain("- [ ] Step one (home; in order)\n note: A hint\n - [ ] Nested"); + expect(text).toEndWith("## Additional context from the user\nFocus on the kitchen"); + }); + + test("caps the sibling list and marks inbox tasks", () => { + const siblings = Array.from({ length: SIBLING_DISPLAY_LIMIT + 3 }, (_, i) => ({ + id: `s${i}`, + name: `Sibling ${i}`, + completed: i % 2 === 0, + })); + const ctx: TaskContext = { + task: { + ...MOCK_TASK, + inInbox: true, + note: "", + tags: [], + flagged: false, + estimatedMinutes: null, + }, + ancestors: [], + project: null, + children: [], + siblings, + tags: [], + }; + const text = renderTaskContext(ctx, { today: TODAY }); + expect(text).toContain("- Location: inbox (not yet filed into a project)"); + expect(text).toContain("## Project\nnone (inbox task)"); + expect(text).toContain( + "## Existing subtasks (already under the target — do not recreate)\nnone", + ); + expect(text).toContain(`- [ ] Sibling ${SIBLING_DISPLAY_LIMIT - 1}`); + expect(text).not.toContain(`Sibling ${SIBLING_DISPLAY_LIMIT}\n`); + expect(text).toContain("- … and 3 more"); + expect(text).toContain("## Available tags (the only tags that may be used)\nnone"); + }); + + test("long notes are collapsed to one truncated line", () => { + const ctx: TaskContext = { + ...MOCK_TASK_CONTEXT, + task: { ...MOCK_TASK, note: `line one\n\nline two ${"x".repeat(2000)}` }, + project: { ...MOCK_PROJECT, note: "p".repeat(500) }, + }; + const text = renderTaskContext(ctx, { today: TODAY }); + const noteLine = text + .split("\n") + .find((l) => l.startsWith("- Note: line one line two")) as string; + expect(noteLine.length).toBeLessThanOrEqual("- Note: ".length + 1500); + expect(noteLine.endsWith("…")).toBe(true); + }); +}); + +describe("date helpers", () => { + test("formatContextDate renders local wall-clock time and passes junk through", () => { + expect(formatContextDate(null)).toBeNull(); + expect(formatContextDate("not a date")).toBe("not a date"); + expect(formatContextDate("2026-03-05T00:00:00.000Z")).toMatch( + /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/, + ); + }); + + test("todayString is a local calendar date", () => { + expect(todayString(new Date(2026, 8, 3, 23, 59))).toBe("2026-09-03"); + }); +}); From 9752f57dfca38befcd4f00452c52910967eb30f7 Mon Sep 17 00:00:00 2001 From: Max Boettinger Date: Thu, 3 Sep 2026 11:07:42 +0200 Subject: [PATCH 09/14] feat(task): AI breakdown into nano subtasks with preview, revise and apply Claude-Session: https://claude.ai/code/session_01L2voqEC2eEwLsiTxoSnCGD --- src/commands/task/breakdown.ts | 154 ++++++++++++++++ src/commands/task/index.ts | 3 + src/core/output.ts | 80 ++++++++- test/helpers/env.ts | 22 +++ test/integration/ai.test.ts | 296 +++++++++++++++++++++++++++++++ test/integration/program.test.ts | 1 + 6 files changed, 555 insertions(+), 1 deletion(-) create mode 100644 src/commands/task/breakdown.ts create mode 100644 test/integration/ai.test.ts diff --git a/src/commands/task/breakdown.ts b/src/commands/task/breakdown.ts new file mode 100644 index 0000000..ffe835d --- /dev/null +++ b/src/commands/task/breakdown.ts @@ -0,0 +1,154 @@ +import type { Command } from "commander"; +import { renderTaskContext, todayString } from "../../core/ai/context.js"; +import { Conversation } from "../../core/ai/conversation.js"; +import { PLAN_STRUCTURED, type Plan, buildPlanTree } from "../../core/ai/plan.js"; +import { loadPrompt } from "../../core/ai/prompts.js"; +import type { AIClient, StructuredResult } from "../../core/ai/types.js"; +import { unwrapBridgeResponse } from "../../core/client.js"; +import { CLIError } from "../../core/errors.js"; +import { outputJson, outputPlanTree, outputTreeResult } from "../../core/output.js"; +import type { + CreateTreeOptions, + CreateTreeResult, + OmniFocusClient, + PlanTaskInput, + TaskContext, +} from "../../core/types.js"; +import { dim } from "../../core/ui/colors.js"; +import { withSpinner } from "../../core/ui/progress.js"; +import { createPrompter } from "../../core/ui/prompt.js"; +import { runAction } from "../action.js"; +import { readTaskRef, taskRefArgument } from "../options/refs.js"; + +const TEMPERATURE = 0.2; + +/** Map a validated plan onto the bridge's create-tree payload. */ +export function planToTreeOptions(parentId: string, plan: Plan): CreateTreeOptions { + return { + parentId, + sequential: plan.sequential, + tasks: plan.tasks.map( + (t): PlanTaskInput => ({ + key: t.key, + parentKey: t.parentKey, + name: t.name, + note: t.note, + estimate: t.estimateMinutes, + tags: t.tags, + flag: t.flag, + sequential: t.sequential, + due: t.due, + defer: t.defer, + }), + ), + }; +} + +/** + * `of task breakdown ` — ask the model for a nano-task plan under a + * task, preview it, revise it with feedback as often as wanted, then apply + * it in one `task.createTree` round-trip. In JSON mode the plan is printed + * and nothing is applied unless `--apply` is passed. + */ +export function registerBreakdownCommand( + parent: Command, + client: OmniFocusClient, + ai: AIClient, +): void { + const cmd = parent + .command("breakdown") + .description("Break a task into AI-suggested nano subtasks, preview, then apply"); + taskRefArgument(cmd); + cmd + .option("--context ", "Extra context for the model") + .option("--model ", "Model id, overrides $OF_AI_MODEL and the config file") + .option("--apply", "Apply the plan without the interactive preview") + .action( + runAction(async (ctx, ref: string | undefined) => { + const resolved = readTaskRef(ref, ctx.opts); + if (!resolved.query && !resolved.id) throw new CLIError("Provide a task reference or --id"); + const apply = ctx.opts.apply === true; + const interactive = ctx.format === "human" && !apply; + if (interactive && process.stdin.isTTY !== true) { + throw new CLIError( + "task breakdown previews the plan interactively; run it in a terminal, or pass --apply to skip the preview, or --json to print the plan", + ); + } + + const context: TaskContext = unwrapBridgeResponse( + await client.getTaskContext( + resolved.id ? { id: resolved.id } : { query: resolved.query as string }, + ), + ); + const target = { + id: context.task.id, + name: context.task.name, + project: context.task.project, + }; + const convo = new Conversation(loadPrompt("breakdown").text).user( + `${renderTaskContext(context, { + today: todayString(), + extra: ctx.opts.context as string | undefined, + })}\n\nBreak the target task down into nano tasks now.`, + ); + const model = ctx.opts.model as string | undefined; + const generate = (label: string): Promise> => + withSpinner(label, () => + ai.structured( + { messages: convo.messages, model, temperature: TEMPERATURE }, + PLAN_STRUCTURED, + ), + ); + const applyPlan = (plan: Plan): Promise => + client + .createTaskTree(planToTreeOptions(target.id, plan)) + .then((response) => unwrapBridgeResponse(response)); + + let result = await generate("Thinking…"); + convo.assistant(result.raw); + + if (ctx.format === "json") { + const applied = apply ? await applyPlan(result.value) : null; + outputJson({ target, model: result.model, plan: result.value, applied }); + if (applied?.created.some((c) => !c.ok)) process.exit(1); + return; + } + + const prompter = createPrompter({ output: process.stderr }); + try { + for (;;) { + outputPlanTree(target.name, result.value, buildPlanTree(result.value)); + let choice = apply ? "a" : null; + if (!apply) { + console.log(""); + choice = await prompter.choose( + `${dim("[a]")}pply, ${dim("[r]")}evise or ${dim("[q]")}uit: `, + ["a", "r", "q"], + ); + } + if (choice === null || choice === "q") { + console.log(dim("Nothing changed.")); + return; + } + if (choice === "a") { + const applied = await applyPlan(result.value); + const summary = outputTreeResult(applied); + if (summary.failed > 0) process.exit(1); + return; + } + const feedback = await prompter.ask("What should change? "); + if (feedback === null) { + console.log(dim("Nothing changed.")); + return; + } + convo.user(feedback); + result = await generate("Revising…"); + convo.assistant(result.raw); + console.log(""); + } + } finally { + prompter.close(); + } + }), + ); +} diff --git a/src/commands/task/index.ts b/src/commands/task/index.ts index 5a2a5ad..2cb7e14 100644 --- a/src/commands/task/index.ts +++ b/src/commands/task/index.ts @@ -1,5 +1,6 @@ import { defineNoun } from "../noun.js"; import { registerAddCommand } from "./add.js"; +import { registerBreakdownCommand } from "./breakdown.js"; import { registerCompleteCommand } from "./complete.js"; import { registerDeleteCommand } from "./delete.js"; import { registerListCommand } from "./list.js"; @@ -25,6 +26,7 @@ export const registerTaskCommands = defineNoun({ registerTagCommand, registerDeleteCommand, registerNotificationCommands, + registerBreakdownCommand, ], verbAliases: { add: "a", @@ -37,5 +39,6 @@ export const registerTaskCommands = defineNoun({ tag: "g", delete: "d", notification: "n", + breakdown: "b", }, }); diff --git a/src/core/output.ts b/src/core/output.ts index 143e7fd..511a347 100644 --- a/src/core/output.ts +++ b/src/core/output.ts @@ -10,9 +10,17 @@ * live one level down in `./ui/` and know nothing about entities. */ +import type { Plan, PlanNode } from "./ai/plan.js"; import { BridgeError, type CLIError } from "./errors.js"; import { assignShortIds, peekShortId } from "./short-ids.js"; -import type { OFFolder, OFProject, OFProjectCompact, OFTask, OutputFormat } from "./types.js"; +import type { + CreateTreeResult, + OFFolder, + OFProject, + OFProjectCompact, + OFTask, + OutputFormat, +} from "./types.js"; import { bold, cyan, dim, green, red, yellow } from "./ui/colors.js"; // ── Format detection ──────────────────────────────────────────────────────── @@ -440,6 +448,76 @@ export function outputMoved(task: OFTask, touched: readonly DateField[]): void { } } +// ── AI plan rendering ─────────────────────────────────────────────────────── + +function orderLabel(sequential: boolean): string { + return sequential ? "in order" : "any order"; +} + +/** One line per plan node, indented by depth: ` ` plus a dim note line. */ +export function formatPlanTree(tree: PlanNode[], depth = 0, out: string[] = []): string[] { + const indent = " ".repeat(depth); + for (const node of tree) { + const parts: string[] = [`${indent}${dim(node.key)}`]; + if (node.flag) parts.push("⚑"); + parts.push(node.name); + if (node.children.length > 0) parts.push(dim(`(${orderLabel(node.sequential)})`)); + if (node.estimateMinutes) parts.push(dim(`${node.estimateMinutes}min`)); + if (node.tags.length > 0) parts.push(cyan(`[${node.tags.join(", ")}]`)); + if (node.due) parts.push(yellow(`due:${node.due}`)); + if (node.defer) parts.push(dim(`defer:${node.defer}`)); + out.push(parts.join(" ")); + if (node.note) out.push(`${indent}${" ".repeat(node.key.length)} ${dim(node.note)}`); + formatPlanTree(node.children, depth + 1, out); + } + return out; +} + +/** Human preview of a breakdown plan before anything is applied. */ +export function outputPlanTree(targetName: string, plan: Plan, tree: PlanNode[]): void { + console.log( + `${bold(`Plan for: ${targetName}`)} ${dim(`— new subtasks ${orderLabel(plan.sequential)}`)}`, + ); + if (plan.summary) console.log(dim(plan.summary)); + console.log(""); + for (const line of formatPlanTree(tree)) console.log(line); + const estimate = plan.tasks.reduce((sum, t) => sum + (t.estimateMinutes ?? 0), 0); + const count = plan.tasks.length; + console.log( + dim( + `\n${count} task${count === 1 ? "" : "s"}${estimate > 0 ? `, ~${estimate} min total` : ""}`, + ), + ); + if (plan.questions.length > 0) { + console.log(yellow("\nOpen questions:")); + for (const q of plan.questions) console.log(` ${yellow("•")} ${q}`); + } +} + +export interface TreeResultSummary { + created: number; + failed: number; +} + +/** Human report after `task.createTree`: per-item ✓/✗ lines, warnings on stderr. */ +export function outputTreeResult(result: CreateTreeResult): TreeResultSummary { + const created = result.created.filter((c) => c.ok).length; + const failed = result.created.length - created; + const total = result.created.length; + const head = `Created ${created} of ${total} subtask${total === 1 ? "" : "s"} under ${bold(result.parent.name)}`; + console.log(failed === 0 ? `${green("✓")} ${head}` : `${yellow("!")} ${head}`); + for (const item of result.created) { + if (item.ok) console.log(` ${green("✓")} ${dim(item.key)} ${item.name}`); + else + console.log( + ` ${red("✗")} ${dim(item.key)} ${item.name}${item.error ? `: ${item.error}` : ""}`, + ); + for (const warning of item.warnings ?? []) outputWarning(`${item.name}: ${warning}`); + } + for (const warning of result.warnings) outputWarning(`${result.parent.name}: ${warning}`); + return { created, failed }; +} + // ── Date helpers ──────────────────────────────────────────────────────────── function formatDateShort(iso: string): string { diff --git a/test/helpers/env.ts b/test/helpers/env.ts index 5aa20d8..446512e 100644 --- a/test/helpers/env.ts +++ b/test/helpers/env.ts @@ -71,3 +71,25 @@ export async function withStdin(value: unknown, fn: () => T | Promise): Pr Object.defineProperty(process, "stdin", { value: original, configurable: true }); } } + +/** + * Divert a stream's `write` into `sink` for the duration of `fn` — for code + * that writes to `process.stdout`/`process.stderr` directly (streamed model + * output, readline prompts) rather than through console.log/error. + */ +export async function withStreamWrite( + stream: NodeJS.WriteStream, + sink: (chunk: string) => void, + fn: () => T | Promise, +): Promise { + const original = stream.write; + stream.write = ((chunk: unknown) => { + sink(String(chunk)); + return true; + }) as typeof stream.write; + try { + return await fn(); + } finally { + stream.write = original; + } +} diff --git a/test/integration/ai.test.ts b/test/integration/ai.test.ts new file mode 100644 index 0000000..0175c66 --- /dev/null +++ b/test/integration/ai.test.ts @@ -0,0 +1,296 @@ +/** + * End-to-end tests for the AI verbs (`task breakdown`, `task why`) through + * the shared CLI harness, a mock OmniFocus client and a scripted fake AI. + */ + +import { describe, expect, test } from "bun:test"; +import { PassThrough } from "node:stream"; +import { registerTaskCommands } from "../../src/commands/task/index.js"; +import { type FakeAI, createFakeAI } from "../fixtures/fake-ai.js"; +import { createMockClient } from "../fixtures/mock-client.js"; +import { + MOCK_CREATE_TREE_RESULT, + MOCK_TASK, + errorResponse, + successResponse, +} from "../fixtures/mock-responses.js"; +import { withEnv, withStdin, withStreamTTY, withStreamWrite } from "../helpers/env.js"; +import { runCommand } from "../helpers/run.js"; + +const HUMAN_ENV = { NO_COLOR: "1", CI: undefined, TERM: "xterm-256color" }; + +function planTask(key: string, name: string, extra: Record = {}) { + return { + key, + parentKey: null, + name, + note: "", + estimateMinutes: 5, + tags: [], + flag: false, + sequential: false, + due: null, + defer: null, + ...extra, + }; +} + +const PLAN = { + summary: "Two tiny steps.", + sequential: true, + questions: ["Which store?"], + tasks: [ + planTask("1", "Open the shopping list app", { estimateMinutes: 1 }), + planTask("2", "Add milk and eggs", { note: "Check the fridge first", tags: ["errand"] }), + planTask("2.1", "Look in the fridge", { parentKey: "2", estimateMinutes: 2 }), + ], +}; + +const SHORTER_PLAN = { + summary: "One step.", + sequential: false, + questions: [], + tasks: [planTask("1", "Just buy milk", { estimateMinutes: 3 })], +}; + +/** A fake interactive terminal: TTY stdin fed by a script, prompts on stderr swallowed. */ +async function runInteractive( + argv: string[], + script: string[], + ai: FakeAI, + client = createMockClient(), +) { + const input = Object.assign(new PassThrough(), { isTTY: true, setRawMode: () => input }); + const stderrWrites: string[] = []; + const stdoutWrites: string[] = []; + // Feed each scripted line once the previous prompt has been written. + let fed = 0; + const feed = () => { + if (fed < script.length) { + const line = script[fed++] as string; + setTimeout(() => input.write(line), 2); + } + }; + const result = await withEnv(HUMAN_ENV, () => + withStreamTTY(process.stdout, true, () => + withStdin(input, () => + withStreamWrite( + process.stdout, + (chunk) => stdoutWrites.push(chunk), + () => + withStreamWrite( + process.stderr, + (chunk) => { + stderrWrites.push(chunk); + if (/[:?] $/.test(chunk)) feed(); + }, + () => runCommand(registerTaskCommands, argv, client, ai), + ), + ), + ), + ), + ); + return { ...result, stderrWrites, stdoutWrites }; +} + +describe("task breakdown", () => { + test("--json prints the plan and applies nothing", async () => { + const ai = createFakeAI({ plans: [PLAN] }); + const { client, stdout, exitCode } = await runCommand( + registerTaskCommands, + ["task", "breakdown", "Buy groceries", "--context", "Kitchen only", "--json"], + undefined, + ai, + ); + expect(exitCode).toBeUndefined(); + expect(client.getTaskContext).toHaveBeenCalledWith({ query: "Buy groceries" }); + expect(client.createTaskTree).not.toHaveBeenCalled(); + const out = JSON.parse(stdout.join("\n")); + expect(out.target).toEqual({ id: MOCK_TASK.id, name: MOCK_TASK.name, project: "Errands" }); + expect(out.applied).toBeNull(); + expect(out.plan.tasks.map((t: { name: string }) => t.name)).toEqual([ + "Open the shopping list app", + "Add milk and eggs", + "Look in the fridge", + ]); + // The request carried the prompt file, the rendered context and the user's extra text. + const req = ai.requests[0]; + expect(req?.temperature).toBe(0.2); + expect(req?.messages[0]?.role).toBe("system"); + expect(req?.messages[0]?.content).toContain("nano tasks"); + expect(req?.messages[1]?.role).toBe("user"); + expect(req?.messages[1]?.content).toContain("## Target task\n- Name: Buy groceries"); + expect(req?.messages[1]?.content).toContain("Kitchen only"); + expect(req?.messages[1]?.content).toContain("Break the target task down"); + }); + + test("--id and --model pass straight through", async () => { + const ai = createFakeAI({ plans: [PLAN] }); + const { client } = await runCommand( + registerTaskCommands, + ["task", "breakdown", "--id", "task-abc123", "--model", "openai/gpt-4.1-mini", "--json"], + undefined, + ai, + ); + expect(client.getTaskContext).toHaveBeenCalledWith({ id: "task-abc123" }); + expect(ai.requests[0]?.model).toBe("openai/gpt-4.1-mini"); + }); + + test("--json --apply creates the tree and reports it", async () => { + const ai = createFakeAI({ plans: [PLAN] }); + const { client, stdout, exitCode } = await runCommand( + registerTaskCommands, + ["task", "breakdown", "Buy groceries", "--json", "--apply"], + undefined, + ai, + ); + expect(exitCode).toBeUndefined(); + expect(client.createTaskTree).toHaveBeenCalledWith({ + parentId: MOCK_TASK.id, + sequential: true, + tasks: [ + expect.objectContaining({ + key: "1", + parentKey: null, + name: "Open the shopping list app", + estimate: 1, + }), + expect.objectContaining({ key: "2", note: "Check the fridge first", tags: ["errand"] }), + expect.objectContaining({ key: "2.1", parentKey: "2", name: "Look in the fridge" }), + ], + }); + const out = JSON.parse(stdout.join("\n")); + expect(out.applied).toEqual(MOCK_CREATE_TREE_RESULT); + }); + + test("--json --apply exits 1 when an item failed", async () => { + const client = createMockClient(); + (client.createTaskTree as ReturnType).mockImplementation(() => + Promise.resolve( + successResponse({ + ...MOCK_CREATE_TREE_RESULT, + created: [{ key: "1", ok: false, name: "x", error: "nope" }], + }), + ), + ); + const { exitCode } = await runCommand( + registerTaskCommands, + ["task", "breakdown", "Buy groceries", "--json", "--apply"], + client, + createFakeAI({ plans: [PLAN] }), + ); + expect(exitCode).toBe(1); + }); + + test("an unknown task fails before any model call", async () => { + const client = createMockClient(); + (client.getTaskContext as ReturnType).mockImplementation(() => + Promise.resolve(errorResponse('Task not found: "zzz"')), + ); + const ai = createFakeAI({ plans: [PLAN] }); + const { stderr, exitCode } = await runCommand( + registerTaskCommands, + ["task", "breakdown", "zzz", "--json"], + client, + ai, + ); + expect(exitCode).toBe(1); + expect(JSON.parse(stderr[0] as string)).toEqual({ ok: false, error: 'Task not found: "zzz"' }); + expect(ai.requests).toEqual([]); + }); + + test("without a ref or --id it fails fast", async () => { + const { exitCode, stderr } = await runCommand(registerTaskCommands, [ + "task", + "breakdown", + "--json", + ]); + expect(exitCode).toBe(1); + expect(stderr[0]).toContain("Provide a task reference or --id"); + }); + + test("human mode without a terminal refuses unless --apply is given", async () => { + const { exitCode, stderr } = await withEnv(HUMAN_ENV, () => + withStreamTTY(process.stdout, true, () => + withStdin({ isTTY: false }, () => + runCommand( + registerTaskCommands, + ["task", "breakdown", "Buy groceries"], + undefined, + createFakeAI({ plans: [PLAN] }), + ), + ), + ), + ); + expect(exitCode).toBe(1); + expect(stderr.join("\n")).toContain("pass --apply"); + }); + + test("human mode: preview, revise with feedback, then apply", async () => { + const ai = createFakeAI({ plans: [PLAN, SHORTER_PLAN] }); + const { client, stdout, exitCode } = await runInteractive( + ["task", "breakdown", "Buy groceries"], + ["r\n", "Make it a single step\n", "a\n"], + ai, + ); + expect(exitCode).toBeUndefined(); + const text = stdout.join("\n"); + expect(text).toContain("Plan for: Buy groceries"); + expect(text).toContain("Open the shopping list app"); + expect(text).toContain("Open questions:"); + expect(text).toContain("Which store?"); + expect(text).toContain("Just buy milk"); + expect(text).toContain("Created 2 of 2 subtasks under Buy groceries"); + // The revision request carried the previous plan and the feedback. + expect(ai.requests).toHaveLength(2); + const revision = ai.requests[1]?.messages ?? []; + expect(revision.map((m) => m.role)).toEqual(["system", "user", "assistant", "user"]); + expect(revision[2]?.content).toBe(JSON.stringify(PLAN)); + expect(revision[3]?.content).toBe("Make it a single step"); + // Only the revised plan was applied. + expect(client.createTaskTree).toHaveBeenCalledTimes(1); + expect(client.createTaskTree).toHaveBeenCalledWith( + expect.objectContaining({ + sequential: false, + tasks: [expect.objectContaining({ name: "Just buy milk" })], + }), + ); + }); + + test("human mode: quitting at the preview changes nothing", async () => { + const { client, stdout } = await runInteractive( + ["task", "breakdown", "Buy groceries"], + ["q\n"], + createFakeAI({ plans: [PLAN] }), + ); + expect(client.createTaskTree).not.toHaveBeenCalled(); + expect(stdout.join("\n")).toContain("Nothing changed."); + }); + + test("human mode: Esc at the preview changes nothing", async () => { + const { client, stdout } = await runInteractive( + ["task", "breakdown", "Buy groceries"], + ["\x1b"], + createFakeAI({ plans: [PLAN] }), + ); + expect(client.createTaskTree).not.toHaveBeenCalled(); + expect(stdout.join("\n")).toContain("Nothing changed."); + }); + + test("human mode with --apply skips the prompt", async () => { + const { client, stdout } = await withEnv(HUMAN_ENV, () => + withStreamTTY(process.stdout, true, () => + withStdin({ isTTY: false }, () => + runCommand( + registerTaskCommands, + ["task", "breakdown", "Buy groceries", "--apply"], + undefined, + createFakeAI({ plans: [PLAN] }), + ), + ), + ), + ); + expect(client.createTaskTree).toHaveBeenCalledTimes(1); + expect(stdout.join("\n")).toContain("Created 2 of 2 subtasks"); + }); +}); diff --git a/test/integration/program.test.ts b/test/integration/program.test.ts index f43e1d5..ad0162f 100644 --- a/test/integration/program.test.ts +++ b/test/integration/program.test.ts @@ -124,6 +124,7 @@ describe("program assembly", () => { tag: ["g"], delete: ["d"], notification: ["n"], + breakdown: ["b"], }); expect(verbAliases("project")).toEqual({ add: ["a"], From d47fe1a86a23e0321bab70f577aa46e5dbdc9676 Mon Sep 17 00:00:00 2001 From: Max Boettinger Date: Thu, 3 Sep 2026 11:08:06 +0200 Subject: [PATCH 10/14] feat(task): AI five-whys session for avoided tasks Claude-Session: https://claude.ai/code/session_01L2voqEC2eEwLsiTxoSnCGD --- src/commands/task/index.ts | 3 + src/commands/task/why.ts | 119 +++++++++++++++++++++++++++++++ test/core/output.test.ts | 116 ++++++++++++++++++++++++++++++ test/integration/ai.test.ts | 52 +++++++++++++- test/integration/program.test.ts | 1 + 5 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 src/commands/task/why.ts diff --git a/src/commands/task/index.ts b/src/commands/task/index.ts index 2cb7e14..da4851e 100644 --- a/src/commands/task/index.ts +++ b/src/commands/task/index.ts @@ -10,6 +10,7 @@ import { registerSearchCommand } from "./search.js"; import { registerShowCommand } from "./show.js"; import { registerTagCommand } from "./tag.js"; import { registerUpdateCommand } from "./update.js"; +import { registerWhyCommand } from "./why.js"; export const registerTaskCommands = defineNoun({ name: "task", @@ -27,6 +28,7 @@ export const registerTaskCommands = defineNoun({ registerDeleteCommand, registerNotificationCommands, registerBreakdownCommand, + registerWhyCommand, ], verbAliases: { add: "a", @@ -40,5 +42,6 @@ export const registerTaskCommands = defineNoun({ delete: "d", notification: "n", breakdown: "b", + why: "w", }, }); diff --git a/src/commands/task/why.ts b/src/commands/task/why.ts new file mode 100644 index 0000000..97075a3 --- /dev/null +++ b/src/commands/task/why.ts @@ -0,0 +1,119 @@ +import type { Command } from "commander"; +import { renderTaskContext, todayString } from "../../core/ai/context.js"; +import { Conversation } from "../../core/ai/conversation.js"; +import { loadPrompt } from "../../core/ai/prompts.js"; +import { type AIClient, AIError, type ChatResult } from "../../core/ai/types.js"; +import { unwrapBridgeResponse } from "../../core/client.js"; +import { CLIError } from "../../core/errors.js"; +import type { OmniFocusClient } from "../../core/types.js"; +import { bold, cyan, dim } from "../../core/ui/colors.js"; +import { createPrompter } from "../../core/ui/prompt.js"; +import { runAction } from "../action.js"; +import { readTaskRef, taskRefArgument } from "../options/refs.js"; + +const TEMPERATURE = 0.7; + +/** + * Stream one assistant turn to stdout. Ctrl-C while the model is talking + * aborts the request and ends the session (resolves null) instead of + * killing the process mid-line. + */ +async function speak( + ai: AIClient, + convo: Conversation, + model: string | undefined, +): Promise { + const controller = new AbortController(); + const onSigint = () => controller.abort(); + process.once("SIGINT", onSigint); + process.stdout.write(cyan("◆ ")); + try { + const result = await ai.stream( + { messages: convo.messages, model, temperature: TEMPERATURE, signal: controller.signal }, + (delta) => { + process.stdout.write(delta); + }, + ); + process.stdout.write("\n\n"); + return result; + } catch (error) { + if (error instanceof AIError && error.kind === "aborted") { + process.stdout.write("\n"); + return null; + } + throw error; + } finally { + process.off("SIGINT", onSigint); + } +} + +/** + * `of task why [ref]` — an interactive "five whys" session about a task + * the user is avoiding (or about anything, without a ref). Every turn + * sends the whole history; the session ends only when the user quits + * (Esc, Ctrl-C, Ctrl-D or /quit). + */ +export function registerWhyCommand(parent: Command, client: OmniFocusClient, ai: AIClient): void { + const cmd = parent + .command("why") + .description("Interactive five-whys session about a task you are avoiding"); + taskRefArgument(cmd); + cmd + .option("--context ", "Extra context for the coach") + .option("--model ", "Model id, overrides $OF_AI_MODEL and the config file") + .action( + runAction(async (ctx, ref: string | undefined) => { + if ( + ctx.format === "json" || + process.stdin.isTTY !== true || + process.stdout.isTTY !== true + ) { + throw new CLIError( + "task why is an interactive session; run it in a terminal, without --json", + ); + } + const resolved = readTaskRef(ref, ctx.opts); + const extra = ctx.opts.context as string | undefined; + const model = ctx.opts.model as string | undefined; + const today = todayString(); + + let opening: string; + if (resolved.query || resolved.id) { + const context = unwrapBridgeResponse( + await client.getTaskContext( + resolved.id ? { id: resolved.id } : { query: resolved.query as string }, + ), + ); + opening = `${renderTaskContext(context, { today, extra })}\n\nStart the session with your first question about this task.`; + console.log(bold(`Why: ${context.task.name}`)); + } else { + opening = [ + `## Today\n${today}`, + "No specific task was given.", + extra?.trim() ? `The person says: ${extra.trim()}` : "", + "Start by asking what they are avoiding right now.", + ] + .filter(Boolean) + .join("\n\n"); + console.log(bold("Why")); + } + console.log(dim("Answer each question. Esc, Ctrl-C or /quit ends the session.\n")); + + const convo = new Conversation(loadPrompt("why").text).user(opening); + const prompter = createPrompter({ output: process.stderr }); + try { + for (;;) { + const turn = await speak(ai, convo, model); + if (turn === null) break; + convo.assistant(turn.content); + const answer = await prompter.ask("> "); + if (answer === null) break; + convo.user(answer); + } + } finally { + prompter.close(); + } + console.log(dim("\nSession ended.")); + }), + ); +} diff --git a/test/core/output.test.ts b/test/core/output.test.ts index 28eb7e3..c394fbf 100644 --- a/test/core/output.test.ts +++ b/test/core/output.test.ts @@ -2,9 +2,11 @@ import { afterEach, describe, expect, test } from "bun:test"; import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { buildPlanTree } from "../../src/core/ai/plan.js"; import { BridgeError } from "../../src/core/errors.js"; import { type BatchSummary, + formatPlanTree, formatProjectDetail, formatProjectLine, formatTaskDetail, @@ -12,7 +14,9 @@ import { outputBatchSummary, outputEntityAction, outputError, + outputPlanTree, outputTaskList, + outputTreeResult, outputWarning, outputWarnings, resolveFormat, @@ -472,3 +476,115 @@ describe("outputBatchSummary", () => { expect(err.join("\n")).toContain("A: w"); }); }); + +describe("AI plan rendering", () => { + const plan = { + summary: "Two steps.", + sequential: true, + questions: ["Which store?"], + tasks: [ + { + key: "1", + parentKey: null, + name: "Open the app", + note: "", + estimateMinutes: 1, + tags: [], + flag: false, + sequential: false, + due: null, + defer: null, + }, + { + key: "2", + parentKey: null, + name: "Add items", + note: "Check the fridge", + estimateMinutes: 5, + tags: ["errand"], + flag: true, + sequential: true, + due: "tomorrow", + defer: null, + }, + { + key: "2.1", + parentKey: "2", + name: "Look in the fridge", + note: "", + estimateMinutes: null, + tags: [], + flag: false, + sequential: false, + due: null, + defer: "today", + }, + ], + }; + + function capture(fn: () => void): string[] { + const lines: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => { + lines.push(args.map(String).join(" ")); + }; + try { + withEnv({ NO_COLOR: "1" }, fn); + } finally { + console.log = orig; + } + return lines; + } + + test("formatPlanTree indents children and shows order, estimate, tags, dates", () => { + const lines = withEnv({ NO_COLOR: "1" }, () => formatPlanTree(buildPlanTree(plan))); + expect(lines).toEqual([ + "1 Open the app 1min", + "2 ⚑ Add items (in order) 5min [errand] due:tomorrow", + " Check the fridge", + " 2.1 Look in the fridge defer:today", + ]); + }); + + test("outputPlanTree prints header, tree, totals and open questions", () => { + const lines = capture(() => outputPlanTree("Buy groceries", plan, buildPlanTree(plan))); + expect(lines[0]).toBe("Plan for: Buy groceries — new subtasks in order"); + expect(lines[1]).toBe("Two steps."); + expect(lines).toContain("1 Open the app 1min"); + expect(lines).toContain("\n3 tasks, ~6 min total"); + expect(lines).toContain("\nOpen questions:"); + expect(lines).toContain(" • Which store?"); + }); + + test("outputTreeResult reports per-item outcome and counts", () => { + const stderr: string[] = []; + const origErr = console.error; + console.error = (...args: unknown[]) => { + stderr.push(args.map(String).join(" ")); + }; + let summary: { created: number; failed: number } | undefined; + const lines = capture(() => { + summary = outputTreeResult({ + parent: { id: "p", name: "Buy groceries", project: "Errands" }, + created: [ + { + key: "1", + ok: true, + id: "n1", + name: "Open the app", + warnings: ["tag failed (x): nope"], + }, + { key: "2", ok: false, name: "Add items", error: "boom" }, + ], + warnings: ["sequential apply failed: locked"], + }); + }); + console.error = origErr; + expect(summary).toEqual({ created: 1, failed: 1 }); + expect(lines[0]).toBe("! Created 1 of 2 subtasks under Buy groceries"); + expect(lines[1]).toBe(" ✓ 1 Open the app"); + expect(lines[2]).toBe(" ✗ 2 Add items: boom"); + expect(stderr.join("\n")).toContain("Open the app: tag failed (x): nope"); + expect(stderr.join("\n")).toContain("Buy groceries: sequential apply failed: locked"); + }); +}); diff --git a/test/integration/ai.test.ts b/test/integration/ai.test.ts index 0175c66..35dd39e 100644 --- a/test/integration/ai.test.ts +++ b/test/integration/ai.test.ts @@ -82,7 +82,7 @@ async function runInteractive( process.stderr, (chunk) => { stderrWrites.push(chunk); - if (/[:?] $/.test(chunk)) feed(); + if (/[:?>] $/.test(chunk)) feed(); }, () => runCommand(registerTaskCommands, argv, client, ai), ), @@ -294,3 +294,53 @@ describe("task breakdown", () => { expect(stdout.join("\n")).toContain("Created 2 of 2 subtasks"); }); }); + +describe("task why", () => { + test("refuses to run in JSON mode or without a terminal", async () => { + const ai = createFakeAI({ replies: ["Q?"] }); + const json = await runCommand(registerTaskCommands, ["task", "why", "--json"], undefined, ai); + expect(json.exitCode).toBe(1); + expect(JSON.parse(json.stderr[0] as string).error).toContain("interactive session"); + const piped = await withEnv(HUMAN_ENV, () => + withStreamTTY(process.stdout, true, () => + withStdin({ isTTY: false }, () => + runCommand(registerTaskCommands, ["task", "why", "Buy groceries"], undefined, ai), + ), + ), + ); + expect(piped.exitCode).toBe(1); + expect(ai.requests).toEqual([]); + }); + + test("runs turn by turn with the task context until the user quits", async () => { + const ai = createFakeAI({ replies: ["What is the first step?", "What makes that hard?"] }); + const { client, stdout, stdoutWrites } = await runInteractive( + ["task", "why", "Buy groceries", "--context", "It has been on my list for weeks"], + ["Going to the store\n", "\x1b"], + ai, + ); + expect(client.getTaskContext).toHaveBeenCalledWith({ query: "Buy groceries" }); + const streamed = stdoutWrites.join(""); + expect(streamed).toContain("What is the first step?"); + expect(streamed).toContain("What makes that hard?"); + expect(stdout.join("\n")).toContain("Why: Buy groceries"); + expect(stdout.join("\n")).toContain("Session ended."); + expect(ai.requests).toHaveLength(2); + expect(ai.requests[0]?.temperature).toBe(0.7); + expect(ai.requests[0]?.messages[0]?.content).toContain("five whys"); + expect(ai.requests[0]?.messages[1]?.content).toContain("## Target task\n- Name: Buy groceries"); + expect(ai.requests[0]?.messages[1]?.content).toContain("It has been on my list for weeks"); + const second = ai.requests[1]?.messages ?? []; + expect(second.map((m) => m.role)).toEqual(["system", "user", "assistant", "user"]); + expect(second[2]?.content).toBe("What is the first step?"); + expect(second[3]?.content).toBe("Going to the store"); + }); + + test("without a ref it opens a general session and never touches OmniFocus", async () => { + const ai = createFakeAI({ replies: ["What are you avoiding?"] }); + const { client, stdout } = await runInteractive(["task", "why"], ["/quit\n"], ai); + expect(client.getTaskContext).not.toHaveBeenCalled(); + expect(ai.requests[0]?.messages[1]?.content).toContain("No specific task was given"); + expect(stdout.join("\n")).toContain("Session ended."); + }); +}); diff --git a/test/integration/program.test.ts b/test/integration/program.test.ts index ad0162f..12be001 100644 --- a/test/integration/program.test.ts +++ b/test/integration/program.test.ts @@ -125,6 +125,7 @@ describe("program assembly", () => { delete: ["d"], notification: ["n"], breakdown: ["b"], + why: ["w"], }); expect(verbAliases("project")).toEqual({ add: ["a"], From 63b3f55d9b006ddb2938307cf39548404eef561e Mon Sep 17 00:00:00 2001 From: Max Boettinger Date: Thu, 3 Sep 2026 11:12:13 +0200 Subject: [PATCH 11/14] fix(task): print the why turn marker with the first token, not before the request Claude-Session: https://claude.ai/code/session_01L2voqEC2eEwLsiTxoSnCGD --- src/commands/task/why.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/commands/task/why.ts b/src/commands/task/why.ts index 97075a3..76324e0 100644 --- a/src/commands/task/why.ts +++ b/src/commands/task/why.ts @@ -26,11 +26,17 @@ async function speak( const controller = new AbortController(); const onSigint = () => controller.abort(); process.once("SIGINT", onSigint); - process.stdout.write(cyan("◆ ")); + let started = false; try { const result = await ai.stream( { messages: convo.messages, model, temperature: TEMPERATURE, signal: controller.signal }, (delta) => { + // The marker goes out with the first token, so a request that fails + // before producing anything leaves no dangling prefix on the line. + if (!started) { + started = true; + process.stdout.write(cyan("◆ ")); + } process.stdout.write(delta); }, ); @@ -38,7 +44,7 @@ async function speak( return result; } catch (error) { if (error instanceof AIError && error.kind === "aborted") { - process.stdout.write("\n"); + if (started) process.stdout.write("\n"); return null; } throw error; From 6ccc00f79a777c4e78ef527814ad00d03dddea40 Mon Sep 17 00:00:00 2001 From: Max Boettinger Date: Thu, 3 Sep 2026 11:15:06 +0200 Subject: [PATCH 12/14] docs: AI features (README, CLAUDE.md, changelog, Noridocs) Claude-Session: https://claude.ai/code/session_01L2voqEC2eEwLsiTxoSnCGD --- CHANGELOG.md | 16 ++++++++ CLAUDE.md | 6 ++- README.md | 98 +++++++++++++++++++++++++++++++++++++++++--- src/commands/docs.md | 7 +++- src/core/ai/docs.md | 49 ++++++++++++++++++++++ src/core/docs.md | 4 +- src/core/ui/docs.md | 3 +- src/jxa/docs.md | 1 + test/docs.md | 19 +++++---- 9 files changed, 184 insertions(+), 19 deletions(-) create mode 100644 src/core/ai/docs.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c24421..45c790d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ All notable changes to this project are documented here. The format follows ### Added +- AI features through [OpenRouter](https://openrouter.ai/) (`OPENROUTER_API_KEY`, optional + `~/.config/omnifocus-cli/config.json` with `ai.apiKey`/`ai.model`, `--model` per run, + `$OF_AI_MODEL` globally; default model `google/gemini-2.5-flash`). Nothing else in the CLI + needs a key. +- `task breakdown ` (`of t b`): splits a task into granular, AuDHD-friendly nano + subtasks using structured output, with full context (parents, project, existing and + completed subtasks, siblings, tags) and optional `--context` text. Human mode previews the + tree and loops apply / revise-with-feedback / quit; applying creates the whole nested tree, + estimates, tags and sequential/parallel flags in one OmniFocus round-trip. `--json` prints + the plan and changes nothing; `--json --apply` applies and reports per item. +- `task why [ref]` (`of t w`): an interactive "five whys" coaching session about an avoided + task, streamed turn by turn, ending only on Esc, Ctrl-C, Ctrl-D or `/quit`. +- System prompts are Markdown files in `src/prompts/`, embedded in the binary and + overridable per user via `~/.config/omnifocus-cli/prompts/.md` or `$OF_PROMPTS_DIR`. +- Bridge ops `task.context` and `task.createTree` (also accepts a `projectId` target). + - `of fc` as a shortcut for `of forecast`. Standalone root commands can now carry a short alias of their own; `fc` rather than `f` because `f` is the `folder` noun. - `task search --id ` looks a single task up by id instead of by keyword, accepting diff --git a/CLAUDE.md b/CLAUDE.md index 3c97b4c..1ed6e3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,14 +43,15 @@ src/jxa/bridge.js (single JXA script) ──→ OmniFocus.app Presentation sits beside that pipeline, not inside it: `src/core/output.ts` is the entity renderer (task/project/tag formatting, JSON/error/warning emitters) and `src/core/ui/` is the entity-agnostic terminal toolkit (`colors.ts` ANSI primitives, `terminal.ts` interactivity detection, `progress.ts` spinner decorator). In `src/index.ts` the real client is wrapped as `withProgress(createClient())` before `buildProgram`, so every bridge round-trip gets a stderr spinner in human mode without any command knowing. -- **Program assembly** (`src/program.ts`): `buildProgram(client)` assembles the Commander program (version comes from package.json — never hardcode it elsewhere). It also installs the `preAction` hook that calls `setProgressEnabled(resolveFormat(json) === "human")`, the single switch that allows UI chrome for the current invocation. `src/index.ts` is the thin executable entry: create client → buildProgram → parseAsync with global error handling. Tests import `buildProgram`, never `index.ts` (which parses argv at import time). +- **Program assembly** (`src/program.ts`): `buildProgram(client, ai = createAIClient())` assembles the Commander program (version comes from package.json — never hardcode it elsewhere). It also installs the `preAction` hook that calls `setProgressEnabled(resolveFormat(json) === "human")`, the single switch that allows UI chrome for the current invocation. `src/index.ts` is the thin executable entry: create client → buildProgram → parseAsync with global error handling. Tests import `buildProgram`, never `index.ts` (which parses argv at import time). - **CLI layer** (`src/commands/`): Thin wrappers — parse args → call client → format output → catch errors. Nothing imports from `commands/`; it only imports from `src/core/`. Organized noun-verb: each noun (task, project, tag, folder, inbox, bulk) is a directory with an `index.ts` registering verb files. Standalone commands (forecast, review, stats, collect, completion) attach to the root program; they sit outside the noun/verb alias system, so a short alias for one is declared inline with `.alias()` on its own command and may be more than one letter when the letter is taken (`forecast` is `fc` — `f` is the `folder` noun). **Nouns are declared, not hand-registered.** Each `src/commands//index.ts` is a `defineNoun({ name, alias, description, verbs })` literal (`src/commands/noun.ts`); noun aliases are one stable letter (`t p g f i b`), verb aliases are declared per mount point in the same literal via `verbAliases: { complete: "c", ... }` (never inside the verb file, because `task add` is also mounted as `inbox add`, and a letter only has to be unique within one noun — `defineNoun` throws on collisions, unknown verbs, or multi-character letters), a nested noun (`notification: "n"`) is aliased as a verb of its parent, and the root gets no verb shortcuts. Letters are fixed choices, not prefixes: `search` is `f`, `tag` is `g` (mirroring the noun), and `process-many` has none. Every verb wraps its handler in `runAction()` and declares shared flags through the option groups in `src/commands/options/` (`taskRefArgument`, `taskCreateOptions`/`taskEditOptions`, `listQueryOptions`, `limitOption`, `confirmOption` + `requireConfirm`). A verb file contains only what is specific to that verb; if a flag or argument is needed by two verbs it belongs in `options/`. Shell completions are generated from the live Commander tree (`generateCompletionScript`), not hardcoded — a parity test enforces coverage. - **Client layer** (`src/core/client.ts`): `createClient()` returns an `OmniFocusClient`. Each method builds a `BridgeCommand { op, params }` (e.g. `"task.create"`, `"task.notification.add"`, `"forecast"`) and calls `executeBridge()`. Timeouts scale by op weight: 30s default, 60s for forecast/review/stats, 120s for bulk. +- **AI layer** (`src/core/ai/`): the second injected seam. `AIClient` (`types.ts`: `chat`, `stream`, `structured`) is threaded through `buildProgram(client, ai)` and `Register = (parent, client, ai)` exactly like the OmniFocus client; verbs that need no model ignore the third parameter. `createAIClient()` (`client.ts`) is lazy — config (`config.ts`: `--model` > `$OF_AI_MODEL` > `~/.config/omnifocus-cli/config.json` `ai.model` > `DEFAULT_MODEL`; key `$OPENROUTER_API_KEY` > config `ai.apiKey`, missing → `AIError("missing-key")` with setup text) and the SDK are resolved on the first call. `openrouter.ts` is the **only** file that may mention `@openrouter/sdk`, and only via `await import()` (a static-scan test enforces both); it maps SDK errors to `AIError.kind`s and implements structured output as strict `json_schema` + `provider.requireParameters` with one validation-repair retry. System prompts are Markdown files in `src/prompts/` (`prompts.ts` embeds them via text import; `$OF_PROMPTS_DIR` / `/prompts/.md` override at runtime). `context.ts` renders the `task.context` payload into the Markdown the model sees; `plan.ts` is the breakdown contract (flat list + `parentKey`, never a recursive schema — strict-mode `$ref` is not portable across providers). Tests inject `createFakeAI()` (`test/fixtures/fake-ai.ts`); `test/preload.ts` isolates `OF_CONFIG_DIR`/`OF_PROMPTS_DIR` and unsets the key/model env vars. - **Bridge/transport** (`src/core/bridge.ts` + `src/jxa/bridge.js`): JSON command in, JSON response out. Response is always `{ ok: true, data }` or `{ ok: false, error, candidates? }`. `unwrapBridgeResponse()` turns `{ ok: false }` into a thrown `BridgeError` (preserving disambiguation candidates), mapping known environment failures (Apple Events permission -1743, app not found) to actionable messages via `matchKnownBridgeFailure()`. Timeout/empty/malformed responses surface as `JXAExecutionError`. Command JSON over 128KB is piped through child stdin with the `@stdin` sentinel argument (ARG_MAX safety); `executeBridge` throws a clear error on non-macOS platforms. The osascript binary is resolved per-call from `OF_BRIDGE_BIN` (test seam; defaults to `/usr/bin/osascript`). ### Dependency injection & the test seam -`OmniFocusClient` (interface in `src/core/types.ts`) is the seam. `createClient()` is called once in `src/index.ts` and threaded into every `register*Commands(program, client)`. Tests inject mock clients — **no OmniFocus or macOS required to run the suite**. Integration tests (`test/integration/`) verify the full parse-to-output flow against mocks. +`OmniFocusClient` (interface in `src/core/types.ts`) is the seam, and `AIClient` (`src/core/ai/types.ts`) is its twin for the model. `createClient()` and `createAIClient()` are called once in `src/index.ts` and threaded into every `register*Commands(program, client, ai)`. Tests inject a mock client and a scripted fake AI (`createFakeAI()`) — **no OmniFocus, macOS or network required to run the suite**. Integration tests (`test/integration/`) verify the full parse-to-output flow against mocks. `src/jxa/bridge.js` has its own, lower-level test seam: `test/jxa/` evaluates the real script source against a stubbed JXA `Application` global (see `test/jxa/bridge-harness.ts`), exercising op handlers (`task.list`, `stats`, ...) directly — still no OmniFocus or macOS required. @@ -75,6 +76,7 @@ You must touch all three layers, in this order: - **Dates are resolved by OmniFocus itself.** `resolveDate()` in the bridge sends anything that is not an exact ISO form (`YYYY-MM-DD`, `YYYY-MM-DDTHH:mm`) to OmniFocus's own parser via Omni Automation (`Formatter.Date` + the app's `DefaultDueTime`/`DefaultStartTime`/`DefaultPlannedTime` settings), so `--due tomorrow`, `fri 5pm`, `2d`, `10.9.` work everywhere dates are accepted. ISO forms keep byte-identical local parsing (a bare ISO date stays at midnight) so scripts never change behavior. `setDateProp()` reads every date back after writing and throws when OmniFocus did not store it — never report a date change you have not verified. `of task move [due] [--defer] [--planned]` (`src/commands/task/move.ts`) is a thin verb over `task.update`; with `--id` a sole positional is the date. - **`--json` is a root option only.** Never declare it on a verb — Commander recognises it after the subcommand and `runAction` reads it via `optsWithGlobals()`. - **One creator.** `task add` handles inbox tasks, project tasks (`--project`) and subtasks (`--parent`/`--parent-id`); `inbox add` mounts the same register function. The bridge's `createTaskRecord()` is shared by `task.create` and `bulk.create`. +- **AI verbs live under `task`** (`breakdown|b`, `why|w`), not under an `ai` noun. `task breakdown` is plan/apply: human mode previews (`outputPlanTree`) and loops `[a]pply/[r]evise/[q]uit` through `createPrompter()`; JSON mode prints the plan and applies nothing unless `--apply`. Applying is one bridge op (`task.createTree`), never N `task.create` calls, so a partial failure is reported per item and descendants of a failed item are skipped rather than reparented. `task why` refuses `--json` and non-TTY stdin/stdout. Interactive input goes through `src/core/ui/prompt.ts` only — it owns every quit path (Esc as a lone raw `\x1b` chunk, since Bun's readline never flushes a lone escape; Ctrl-C via readline `SIGINT`; Ctrl-D via `close`; `/quit`). Prompts are written to stderr. Long model calls use `withSpinner()` from `ui/progress.ts` (same gates as the client spinner). - **Short-id aliases are human-mode only.** `src/core/short-ids.ts` caches a small persistent `OmniFocus id → number` map so `of task list` output like `42 Buy milk` can be referenced later as `of task complete 42`. Resolution happens entirely in the TS layer via `resolveTaskRef()` (an all-digit positional matching a cached alias resolves to the real id before the bridge ever sees it) — the bridge and its ops know nothing about aliases. JSON/piped output must never surface a short id, only the real OmniFocus id. Tests must never touch the real cache file: `bunfig.toml` + `test/preload.ts` redirect `OF_SHORT_ID_CACHE` to a temp directory for every test run, so this doesn't need to be handled per-test. - Test helpers that mutate process state (`withEnv`, `withStreamTTY`) live in `test/helpers/env.ts` — reuse them rather than re-implementing save/restore per file. `withEnv` is promise-aware. - Use `parseIntOption()` for integer options, never `parseInt` directly — Commander's `(value, previous)` parser signature collides with `parseInt(string, radix)`. diff --git a/README.md b/README.md index 6bdcd17..7f57e5a 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ A TypeScript CLI for managing OmniFocus from the terminal. Built on Bun + Comman - macOS (uses Apple Events via `osascript`) — on other platforms the CLI exits with a clear error - [Bun](https://bun.sh/) >= 1.0 - OmniFocus installed and running +- For the AI commands only: an [OpenRouter](https://openrouter.ai/) API key (see [AI features](#ai-features)) ### First run: Automation permission @@ -90,8 +91,8 @@ Every task shown in a human-readable listing (`task list`, `task search`, `task ``` $ of task list - 42 ⚑ Buy milk [Errands] due:2026-09-01 -127 Call the dentist [Health] + 42 ⚑ Buy milk Errands [shopping] due:2026-09-01 +127 Call the dentist Health ``` Any command that takes a task reference accepts that number in place of a name or the @@ -262,6 +263,79 @@ Bulk commands (and `inbox process-many`) read their JSON payload from stdin and immediately with a usage example if nothing is piped. Arbitrarily large payloads are safe — oversized commands are streamed to the bridge instead of passed as process arguments. +### AI features + +Two verbs talk to a language model through [OpenRouter](https://openrouter.ai/). They need an +API key, and nothing else in the CLI does — every other command works without one. + +```bash +export OPENROUTER_API_KEY=sk-or-... # or put it in the config file below +export OF_AI_MODEL=openai/gpt-4.1-mini # optional; default is google/gemini-2.5-flash +``` + +Config file: `~/.config/omnifocus-cli/config.json` (`$XDG_CONFIG_HOME` respected): + +```json +{ "ai": { "apiKey": "sk-or-...", "model": "google/gemini-2.5-flash" } } +``` + +Precedence is `--model` flag > `$OF_AI_MODEL` > config file > default; the key comes from +`$OPENROUTER_API_KEY`, else the config file. Any model id OpenRouter routes works +(`openrouter/auto`, `:nitro`/`:floor` suffixes included), but `task breakdown` needs a model +that supports strict JSON-schema output. + +**Break a task into nano tasks** — granular, single-action subtasks designed for people for +whom starting is the hard part (the prompt is AuDHD-aware: an ignition step first, one +observable action per task, 2–10 minutes each, implicit prep made explicit, no vague verbs): + +```bash +of task breakdown 42 # or `of t b 42` +of task breakdown 42 --context "I only have the evenings this week" +``` + +The model sees the whole picture — the task, its parents, its project, subtasks that already +exist (completed ones included), its siblings and your tag list — and answers with a +structured plan. You get a preview: + +``` +Plan for: File the tax return — new subtasks in order +Ignition first, then the portal. + +1 Open the tax portal in the browser 1min +2 Find last year's return PDF in ~/Documents/Taxes 3min [@computer] +3 Log in with the ID card app (in order) 5min + 3.1 Plug in the card reader 1min + 3.2 Enter the PIN 1min + +5 tasks, ~11 min total + +[a]pply, [r]evise or [q]uit: +``` + +`r` asks what should change and sends your feedback back with the full conversation, as often +as you like; `a` creates the whole tree in one OmniFocus round-trip (nesting, estimates, tags, +sequential/parallel on every level, and the target task's own ordering); `q`, Esc or Ctrl-C +changes nothing. `--apply` skips the preview. + +For scripts and agents: `of task breakdown 42 --json` prints `{ target, model, plan, +applied: null }` and never touches OmniFocus; add `--apply` to create the tasks and get +`applied` (the per-item result, exit 1 if any item failed). + +**Work out why you are avoiding something** — a "five whys" coaching session: + +```bash +of task why 42 # or `of t w 42` +of task why # no task, start from "what are you avoiding?" +``` + +The coach asks one question at a time, adapts to your answers, and keeps going until you leave +with Esc, Ctrl-C, Ctrl-D or `/quit`. It is a terminal-only session: it refuses `--json` and +piped stdin. + +**Prompts are plain Markdown** in [`src/prompts/`](src/prompts/) (`why.md`, `breakdown.md`). +They are embedded in the binary, and any of them can be overridden without rebuilding by +putting a file of the same name in `~/.config/omnifocus-cli/prompts/` (or `$OF_PROMPTS_DIR`). + ### Shell completions ```bash @@ -298,6 +372,8 @@ of collect --days 14 # recently completed tasks | `task notification delete` | Delete a task notification | | `task notification clear` | Clear all task notifications (requires `--confirm`) | | `task tag` | Apply tags to a task | +| `task breakdown` | AI: split a task into nano subtasks, preview, revise, apply (`--json` prints the plan) | +| `task why` | AI: interactive five-whys session about an avoided task | | `project add` | Create a new project | | `project list` | List projects | | `project show` | Show project details | @@ -328,7 +404,7 @@ Verb aliases, per noun (`of --help` lists them): | Noun | Verb aliases | |------|--------------| -| `task` | `a`dd `l`ist `s`how `f` search `u`pdate `m`ove `c`omplete `g` tag `d`elete `n`otification | +| `task` | `a`dd `l`ist `s`how `f` search `u`pdate `m`ove `c`omplete `g` tag `d`elete `n`otification `b`reakdown `w`hy | | `task notification` | `l`ist `a`dd `u`pdate `d`elete `c`lear | | `project` | `a`dd `l`ist `s`how `u`pdate `r`ename `d`elete | | `tag` | `a`dd `l`ist `t`asks `r`ename `d`elete | @@ -363,12 +439,22 @@ Three clean layers: Human-mode presentation is split from those layers: `src/core/output.ts` renders OmniFocus entities, and `src/core/ui/` holds entity-agnostic terminal primitives (ANSI colors, -interactivity detection, the progress spinner). The spinner is a decorator over the client -(`withProgress`) wired once in `src/index.ts`, so commands never know it exists. +interactivity detection, the progress spinner, the interactive prompter). The spinner is a +decorator over the client (`withProgress`) wired once in `src/index.ts`, so commands never +know it exists. + +The language model is a second injected seam beside the OmniFocus client: `src/core/ai/` +defines an `AIClient` interface (`chat`, `stream`, `structured`), config resolution, the +prompt loader and the OpenRouter adapter — the only module that imports the SDK, and only +lazily, so runs that never use a model never load it. `buildProgram(client, ai)` threads both +clients into every verb. ### Testing -Tests use mocked `OmniFocusClient` implementations -- no OmniFocus required. Integration tests verify the full command-parse-to-output flow. +Tests use mocked `OmniFocusClient` implementations and a scripted fake `AIClient` -- no +OmniFocus and no network required. Integration tests verify the full command-parse-to-output +flow, including the interactive preview/revise loop through a fake terminal. The OpenRouter +adapter is tested with the real SDK against a local fake HTTP endpoint. ```bash bun test # all tests diff --git a/src/commands/docs.md b/src/commands/docs.md index e4f5057..57382dc 100644 --- a/src/commands/docs.md +++ b/src/commands/docs.md @@ -15,8 +15,8 @@ Path: @/src/commands - Commands are unaware of the progress spinner: `@/src/index.ts` wraps the client in `withProgress()` (`@/src/core/ui/progress.ts`) before it's ever passed into `buildProgram()`, so every command's `client.method()` calls transparently show/hide chrome without any command-level code. ### Core Implementation -- **Registration pattern**: Every noun's `index.ts` is a `NounSpec` literal — `{ name, alias?, description, verbs, verbAliases? }` — passed to `defineNoun()` (`@/src/commands/noun.ts`), which returns the exported `register*Commands(parent, client)` function: it creates the Commander subcommand, applies the optional one-letter `alias` via `cmd.alias()`, calls each `verbs` entry (`Register = (parent, client) => void`) in order, and finally applies `verbAliases`. Top-level nouns each carry a stable single-letter alias (`task`→`t`, `project`→`p`, `tag`→`g`, `folder`→`f`, `inbox`→`i`, `bulk`→`b`) so `of t list` works exactly like `of task list`; nested nouns (`task notification`) use the same `defineNoun()` mechanism with `alias` omitted — their letter (`n`) is instead declared by the parent noun as a verb alias. Standalone commands attach directly to the root program without going through `defineNoun`. No index file hand-builds `program.command(...)` anymore — that call lives only inside `defineNoun`. -- **Verb aliases** (`verbAliases: { complete: "c", ... }` in the noun's `NounSpec`): a verb name → one-letter map applied by `defineNoun`'s `applyVerbAliases()` after all verbs are mounted, so `of t c 42` ≡ `of task complete 42` and `of t n c 42 --confirm` ≡ `of task notification clear 42 --confirm`. The alias is a property of the *mount point*, not the verb file — `@/src/commands/task/add.ts` is mounted under both `task` and `inbox` and each noun assigns its own letter — and uniqueness only needs to hold among one noun's verbs. `applyVerbAliases` validates at registration time and throws a plain `Error` (surfacing as a test-suite failure, since `buildProgram()` runs in every integration test) when an entry names a verb the noun does not mount, the letter is not exactly one character, or the letter collides with another verb's name or alias in that noun. Letters are hand-picked, stable choices rather than derived first letters, so adding a verb never shifts an existing alias: `task` uses `a l s f(search) u m c g(tag, mirroring the `tag` noun) d n`; `project` `a l s u r d`; `tag` `a l t r d`; `folder` `a l`; `inbox` `l a p` (`process-many` deliberately has none — stdin-only, no natural letter); `bulk` `a u c`; `task notification` `l a u d c`. Commander renders aliases in `--help` as `complete|c`, which is the discoverability surface. Flags never get aliases. +- **Registration pattern**: Every noun's `index.ts` is a `NounSpec` literal — `{ name, alias?, description, verbs, verbAliases? }` — passed to `defineNoun()` (`@/src/commands/noun.ts`), which returns the exported `register*Commands(parent, client, ai)` function: it creates the Commander subcommand, applies the optional one-letter `alias` via `cmd.alias()`, calls each `verbs` entry (`Register = (parent, client, ai) => void`) in order, and finally applies `verbAliases`. `ai` is the `AIClient` seam (`@/src/core/ai/docs.md`) — `buildProgram(client, ai)` (`@/src/program.ts`) threads it through every noun exactly like `client`, and a verb that has no use for the model simply declares two parameters and ignores the third; only `task breakdown`/`task why` actually call it. Top-level nouns each carry a stable single-letter alias (`task`→`t`, `project`→`p`, `tag`→`g`, `folder`→`f`, `inbox`→`i`, `bulk`→`b`) so `of t list` works exactly like `of task list`; nested nouns (`task notification`) use the same `defineNoun()` mechanism with `alias` omitted — their letter (`n`) is instead declared by the parent noun as a verb alias. Standalone commands attach directly to the root program without going through `defineNoun`. No index file hand-builds `program.command(...)` anymore — that call lives only inside `defineNoun`. +- **Verb aliases** (`verbAliases: { complete: "c", ... }` in the noun's `NounSpec`): a verb name → one-letter map applied by `defineNoun`'s `applyVerbAliases()` after all verbs are mounted, so `of t c 42` ≡ `of task complete 42` and `of t n c 42 --confirm` ≡ `of task notification clear 42 --confirm`. The alias is a property of the *mount point*, not the verb file — `@/src/commands/task/add.ts` is mounted under both `task` and `inbox` and each noun assigns its own letter — and uniqueness only needs to hold among one noun's verbs. `applyVerbAliases` validates at registration time and throws a plain `Error` (surfacing as a test-suite failure, since `buildProgram()` runs in every integration test) when an entry names a verb the noun does not mount, the letter is not exactly one character, or the letter collides with another verb's name or alias in that noun. Letters are hand-picked, stable choices rather than derived first letters, so adding a verb never shifts an existing alias: `task` uses `a l s f(search) u m c g(tag, mirroring the `tag` noun) d n b(breakdown) w(why)`; `project` `a l s u r d`; `tag` `a l t r d`; `folder` `a l`; `inbox` `l a p` (`process-many` deliberately has none — stdin-only, no natural letter); `bulk` `a u c`; `task notification` `l a u d c`. Commander renders aliases in `--help` as `complete|c`, which is the discoverability surface. Flags never get aliases. - **Verb file contract**: Every verb file exports a single `registerXxxCommand(parent: Command, client: OmniFocusClient): void`. Inside, it uses Commander's fluent API (`.command()`, `.argument()`, `.option()`, `.action()`) to define the CLI surface, then delegates to the client in the async action handler. A verb only depends on `parent`, so most are mounted exactly once, under their own noun — reachability from the root comes from the noun's one-letter alias (`of t complete`), not from a second mount point. `@/src/commands/task/add.ts`'s `registerAddCommand` is the one deliberate exception: `@/src/commands/inbox/index.ts` imports and mounts it a second time under `inbox`, rather than `inbox add` reimplementing task creation — the two mount points share one implementation, not two verbs with overlapping behavior. - **Shared option groups** (`@/src/commands/options/`): each module declares a family of flags/arguments once and pairs it with a reader that maps parsed opts onto client parameters, so verbs never redeclare or re-map the same option twice. `common.ts` has `confirmOption`/`requireConfirm` (the `--confirm` guard), `limitOption`, and `listQueryOptions`/`readListQuery` (`--search`/`--count`/`--active-only?`/`--limit`, used by `project list`, `tag list`, `folder list`). `refs.ts` has `taskRefArgument(cmd, shape)` declaring ``/`[ref]`/`[refs...]` plus `--id` (and `readTaskRef`, which resolves through `resolveTaskRef()`), and `projectRefArgument`. `task-fields.ts` has `taskDateOptions`/`readTaskDates` (due/defer/planned, optionally `clearable`), and the two composite groups `taskCreateOptions`/`readTaskCreate` (used by `task add`, which absorbed the former `task subtask` via `--parent`/`--parent-id`, resolved through `resolveTaskRef` before being sent to the client) and `taskEditOptions`/`readTaskEdits` (used by `task update`, `inbox process`). - **Dependency injection**: `OmniFocusClient` is created once in `@/src/index.ts` and threaded through registration. Commands are decoupled from client construction. @@ -27,6 +27,9 @@ Path: @/src/commands - **Short-id resolution for task references**: `task complete/update/delete/show/tag/move`, all five `task notification *` verbs, and `inbox process` pass their positional task reference (and any explicit `--id`) through `resolveTaskRef()` (`@/src/core/short-ids.ts`) before calling the client. An explicit `--id` always wins; an all-digit positional that matches a cached alias resolves to the real OmniFocus id; anything else is passed through unchanged as a fuzzy name query — so this is additive, not a behavior change for existing `--id`/name usage. `task show` passes the resolved id as its query string, relying on `task.get`'s existing id-lookup tier in the bridge rather than a new bridge op. `task complete`/`task delete` look up (never mint) an existing alias via `peekShortId()` for their success message, so a completed/deleted task's number appears in the confirmation without polluting the cache with aliases for tasks that just left circulation. `task complete` (also `of t complete`) is variadic — `[refs...]` — and resolves and completes each reference independently (one `task.complete` bridge round-trip per ref, deliberately not routed through `bulk.complete`, which only accepts real ids and has weaker errors); zero or one ref keeps the original single-task contract (bare JSON object, thrown error), while two or more refs attempts every ref regardless of earlier failures, prints per-ref success/error in human mode, emits one JSON array of `{ ref, ok, ... }` results in JSON mode, and exits 1 if any ref failed. `--id` is rejected outright when more than one ref is given. `task search --id ` is a differently-shaped case: its positional `[query]` is a keyword, not a ref, so `search.ts` skips `taskRefArgument`/`readTaskRef` (`@/src/commands/options/refs.ts`) entirely and calls the short-ids module's `resolveTaskId(id)` directly — a short alias resolves to the real id, anything else passes through verbatim for the bridge to report as not found, and (unlike `resolveTaskRef`) no name-query fallback exists and no alias is ever minted. `--id` and `[query]` are mutually exclusive here rather than one "winning": both given throws `CLIError("--id cannot be combined with a search query")`, neither given throws `CLIError("Provide a search query or --id")`. - **`task move`** (`@/src/commands/task/move.ts`, also `of t move`): reschedules a task's due/defer/planned dates by calling the existing `client.updateTask({ query, id, due, defer, planned })` — no new client method or bridge op, it just aims `updateTask` at date fields with natural-language values (see `@/src/jxa/docs.md`'s `resolveDate`). Signature is `move [ref] [due]` with `--defer`/`--planned`/`--id` options; at least one of `due`/`--defer`/`--planned` is required, or it throws `CLIError("Nothing to move: ...")` before any client/bridge call. Built on `runAction` (see below). Because Commander leaves undeclared-but-unfilled positionals as `undefined`, the verb filters supplied positionals before applying its own rule: with `--id` given and exactly one bare positional supplied, that positional is treated as the date rather than the ref (`of task move --id abc tomorrow`). Human output uses `outputMoved()` (`@/src/core/output.ts`), which renders the dates OmniFocus actually stored, not the raw input. +- **`task breakdown`/`task why`** (`@/src/commands/task/breakdown.ts`, `why.ts`) are the two AI-backed verbs, and the only ones in this directory that use the injected `ai` client — everything they need from OmniFocus (the task, its ancestors/project/existing subtree/siblings/tags) comes from one `client.getTaskContext()` call, rendered to Markdown by `@/src/core/ai/docs.md`'s `renderTaskContext()`, so both verbs describe a task to the model identically. `breakdown` calls `ai.structured()` for a nano-task plan, previews it via `outputPlanTree()`, and loops on a `createPrompter().choose()` prompt (`@/src/core/ui/docs.md`) until the user applies (one `client.createTaskTree()` round-trip), revises (feeds the plan + typed feedback back into the conversation and regenerates), or quits; `--json` skips the interactive loop entirely, printing the plan and applying only with `--apply`. `why` calls `ai.stream()` in a loop, both refuse to run without an interactive TTY (`why` unconditionally; `breakdown` only when neither `--json` nor `--apply` was given), and both are built on `runAction` like every other verb. +- **`--model`** on both AI verbs is the flag tier of the config precedence resolved in `@/src/core/ai/docs.md` (`--model` > `$OF_AI_MODEL` > config file > default) — the verb just forwards `ctx.opts.model` into the `ChatRequest`/`structured()` call and never reads config itself. + ### Things to Know - Verb files within a noun directory use short, non-prefixed names (`add.ts`, `list.ts`) since the noun context is already established by the parent subcommand. This means identically-named files exist across noun directories — the noun directory provides disambiguation. - `task show` now requests task notifications by default, and `task list` requests notifications only in JSON mode to avoid human-output noise/performance overhead. diff --git a/src/core/ai/docs.md b/src/core/ai/docs.md new file mode 100644 index 0000000..2788bf3 --- /dev/null +++ b/src/core/ai/docs.md @@ -0,0 +1,49 @@ +# Noridoc: core/ai + +Path: @/src/core/ai + +### Overview +- The LLM seam: a narrow `AIClient` interface (`chat`, `stream`, `structured`) plus everything needed to configure it, prompt it, and turn its output into OmniFocus state — mirroring how `@/src/core/client.ts`'s `OmniFocusClient` is the seam for Apple Events. +- Backs two verbs, `of task breakdown` and `of task why` (`@/src/commands/task/breakdown.ts`, `why.ts`), but is deliberately provider-neutral and injected everywhere so a future verb needs no new wiring to talk to a model. +- OpenRouter (`@openrouter/sdk`) is the sole backend today, and is confined to one file (`openrouter.ts`) loaded lazily — every other module here, and every command, depends only on `types.ts`'s interfaces. + +### How it fits into the larger codebase +- `@/src/index.ts` calls `createAIClient()` (`client.ts`) once and passes it into `buildProgram(client, ai)` (`@/src/program.ts`) alongside the real `OmniFocusClient`. `Register` (`@/src/commands/noun.ts`) is `(parent, client, ai) => void`, so every noun and verb receives the model seam whether or not it uses it; only `task breakdown`/`task why` do. +- The two AI verbs get everything they need to know about a task from `@/src/core/client.ts`'s `getTaskContext()`, which calls the bridge op `task.context` (`@/src/jxa/bridge.js`, see `@/src/jxa/docs.md`) — this module never talks to OmniFocus itself. `context.ts`'s `renderTaskContext()` turns that bridge payload (`TaskContext`, `@/src/core/types.ts`) into the Markdown block both verbs open their conversation with. +- `breakdown.ts` applies its plan by calling `client.createTaskTree()` → bridge op `task.createTree`, mapping the validated `Plan` (`plan.ts`) onto `CreateTreeOptions` (`@/src/core/types.ts`) via its own `planToTreeOptions()`. +- `@/src/core/output.ts` renders the breakdown plan preview and apply report (`outputPlanTree`, `outputTreeResult`) against this module's `Plan`/`PlanNode` types — the one place `output.ts` depends on something outside `@/src/core/types.ts`. +- `@/src/core/ui/prompt.ts`'s `createPrompter()` and `@/src/core/ui/progress.ts`'s `withSpinner()` are the UI primitives both verbs use for their interactive loop and in-flight indicator; neither knows anything about the model, they're just given `fn`s to run. +- Tests inject `createFakeAI()` (`@/test/fixtures/fake-ai.ts`) instead of `createAIClient()`, exactly as they inject `createMockClient()` for the OmniFocus side — see `@/test/docs.md`. + +``` +task/breakdown.ts, task/why.ts (verbs — the only callers of ai.*) + │ + ├── context.ts TaskContext (bridge payload) → Markdown block + ├── conversation.ts Conversation — growing Message[] history + ├── prompts.ts loadPrompt(name) — embedded .md, user-overridable + ├── plan.ts PLAN_SCHEMA, validatePlan(), buildPlanTree() + ├── config.ts resolveAIConfig() — key + model precedence + ├── types.ts AIClient, ChatRequest, StructuredSchema, AIError + ├── client.ts createAIClient() — lazy production AIClient + └── openrouter.ts the only file importing @openrouter/sdk +``` + +### Core Implementation +- **`AIClient`** (`types.ts`) has three methods: `chat()` (one complete reply), `stream(req, onDelta)` (used by `why`'s coaching turns), and `structured(req, schema)` (used by `breakdown`'s plan generation). All three take a `ChatRequest` (`messages`, optional `model`/`temperature`/`maxTokens`/`signal`) — the system prompt is always `messages[0]`. `StructuredSchema` bundles a JSON schema with a `validate(raw)` function returning `{ value: T }` or `{ errors: string[] }`; the schema and its validator travel together so a caller can never send one without the other. `AIError extends CLIError` with a `kind` (`missing-key | auth | credits | rate-limit | bad-request | invalid-response | network | aborted`) callers can branch on — `why.ts` checks for `"aborted"` specifically to distinguish a Ctrl-C from a real failure. +- **`config.ts`**: `resolveAIConfig(overrides?)` resolves `{ apiKey, model, referer, title }` with precedence `--model flag (overrides.model) > $OF_AI_MODEL > config file ai.model > DEFAULT_MODEL` for the model, and `$OPENROUTER_API_KEY > config file ai.apiKey` for the key; a missing key throws `AIError("missing-key", describeAISetup())` before any network call. The config file lives at `configDir()/config.json` — `configDir()` is `$OF_CONFIG_DIR` (test seam) or `$XDG_CONFIG_HOME`/`~/.config` + `omnifocus-cli`, the same directory scheme `short-ids.ts` and `prompts.ts` use — shaped `{"ai": {"apiKey"?, "model"?}}`; a missing or malformed file is silently treated as empty (`readFileConfig()`'s try/catch), not an error. `resolveAIModel(overrides?)` is the key-free half, used for previews/help text. `DEFAULT_MODEL` is `google/gemini-2.5-flash` (fast, cheap, supports strict structured output through OpenRouter). +- **`prompts.ts`**: the two system prompts (`why`, `breakdown`) are plain Markdown files in `@/src/prompts/`, embedded via Bun text imports (`import whyPrompt from "../../prompts/why.md" with { type: "text" }`, declared for the compiler in `@/src/types.d.ts`) so the compiled binary carries them and `bun run dev` re-reads them fresh every run without a rebuild. `loadPrompt(name)` checks `promptsDir()/.md` first (`$OF_PROMPTS_DIR` or `/prompts`) and falls back to the embedded text — an override file that exists but is blank is treated as absent, not as an empty prompt. No templating: prompts stay static text, and everything situational (the task, today's date, the user's `--context`) is sent as the first user message instead, via `context.ts`. +- **`context.ts`**: `renderTaskContext(ctx: TaskContext, { today, extra? })` is the one function that turns OmniFocus state into words the model sees, used by both verbs so they describe a task identically. It renders, as Markdown sections: today's date; the target task (name, id, status — open/blocked/completed — sequential/parallel, due/defer/planned, flag, estimate, tags, repeat rule, inbox location, existing-subtask count); the ancestor chain nearest-first; the project (name, status, type, remaining/total tasks, folder, due, note); the existing subtree with `[x]`/`[ ]` checkboxes, estimates, tags and ordering, recursively — explicitly labeled "do not recreate" since this is what stops `breakdown` from re-suggesting work that already exists; siblings, capped at `SIBLING_DISPLAY_LIMIT` (40) with a "…and N more" tail, "for orientation only"; every tag name in the database, labeled as the only tags the model may use; and, when given, the user's free-form `--context` text. Notes are truncated (1500 chars for the target, 200 for everything else) and collapsed to one line. +- **`plan.ts`**: the breakdown contract. `Plan` is `{ summary, sequential, tasks: PlanTask[], questions }`; `PlanTask` is deliberately flat (`key`, `parentKey: string | null`, `name`, `note`, `estimateMinutes`, `tags`, `flag`, `sequential`, `due`, `defer`) rather than a nested tree — `PLAN_SCHEMA` is sent to the model as a strict `json_schema` (every property `required`, optional ones nullable via a `type: [T, "null"]` union, `additionalProperties: false`, no `$ref` anywhere), and recursive `$ref` schemas are not portable across OpenRouter's providers in strict mode, so the schema stays flat and the tree is rebuilt afterward in TypeScript. `validatePlan(raw)` is the hand-written runtime validator run against every model response: `tasks` non-empty and ≤ `MAX_PLAN_TASKS` (200), every `key` unique and non-empty, every `parentKey` either `null` or an **earlier** key (never itself, never a forward or unknown reference — this is what makes `buildPlanTree()`'s single left-to-right pass sufficient, no cycle detection needed), names non-empty and ≤ `MAX_TASK_NAME_LENGTH` (200 chars), `estimateMinutes` null or an integer ≥ 1. `PLAN_STRUCTURED` bundles `PLAN_SCHEMA` + `validatePlan` as the `StructuredSchema` handed to `ai.structured()`. +- **`openrouter.ts`**: the only module importing `@openrouter/sdk` (pinned exact at `1.2.100`), and only via `await import()` inside `createOpenRouterClient()` — never a static top-level import — so a run that never calls the model (every non-AI verb, every plain `--json` listing) never evaluates the SDK module at all, the identical rule `progress.ts` applies to `yocto-spinner`. `test/integration/program.test.ts` enforces this two ways: a static-scan test asserting no file statically imports `@openrouter/sdk` and that only this file mentions it at all, and a runtime test asserting a non-AI command never produces a request on a `FakeAI`. `chat()`/`stream()` call `client.chat.send({ chatRequest })` with `stream: false`/`true`; streaming iterates `choices[0].delta.content` chunks, accumulating `content` and forwarding each delta to the caller's `onDelta`. `structured()` sends `responseFormat: { type: "json_schema", jsonSchema: { name, strict: true, schema } }` plus `provider: { requireParameters: true }` (routes only to providers that actually honor the schema), parses the reply (`stripCodeFence()` strips a ```` ```json ```` fence some models wrap around it regardless of the schema), and validates it with the caller's `StructuredSchema.validate()`; a validation failure — bad JSON or a `validatePlan` rejection — is fed back to the model once as an appended assistant+user turn pair naming the specific problems, and a second failure throws `AIError("invalid-response")`. `mapError()` classifies SDK failures into `AIErrorKind`s by status/class: 401/403→auth, 402→credits, 429→rate-limit, 400/404/422→bad-request, `SDKValidationError`/`ResponseValidationError` (checked *before* the generic `OpenRouterError` branch, since they extend it but describe an undecodable 200 response, not a failed request)→invalid-response, `RequestAbortedError`/`AbortError`→aborted, anything else→network. The SDK client is constructed with `retryConfig: { strategy: "none" }` — a silent retry-with-backoff on a 5xx would look like the CLI hanging, so a failure surfaces immediately instead. `HTTP-Referer`/`X-OpenRouter-Title` attribution headers come from `config.referer`/`config.title` (`APP_REFERER`/`APP_TITLE` constants in `config.ts`). +- **`client.ts`**: `createAIClient()` is what `@/src/index.ts` actually constructs and passes to `buildProgram()`. It is lazy on two levels — constructing it does nothing and cannot fail; `resolveAIConfig()` (which can throw `AIError("missing-key")`) and the dynamic import of `openrouter.ts` both happen only inside a memoized `backend()` promise on the *first* method call — so a missing API key only surfaces the moment a verb actually needs the model, never at startup, and a run that never calls `chat`/`stream`/`structured` pays nothing. +- **`conversation.ts`**: `Conversation` is a thin, mutable message-history builder — `new Conversation(systemPrompt)` seeds `messages[0]`, `.user()`/`.assistant()` append and return `this` for chaining, `.messages` returns a defensive copy. Both verbs send the *entire* history on every request (no server-side session state), which is what lets `why`'s next question and `breakdown`'s revision see everything said so far. + +### Things to Know +- **The flat `parentKey` plan shape is a strict-JSON-schema portability workaround, not a stylistic choice** — see `plan.ts` above. Any future structured-output schema in this module should default to the same flat-list-with-back-references shape rather than reaching for `$ref`. +- **Validation failures are recoverable exactly once.** `openrouter.ts`'s `structured()` retry loop (`MAX_STRUCTURED_ATTEMPTS = 2`) exists because models occasionally emit near-miss JSON (a stray field, a string where an integer was required); feeding the exact validator errors back as a turn is enough to fix most of these without a human in the loop. A second failure is a hard `AIError`, not a silent third retry. +- **Prompts are code-adjacent config, not code.** Editing `@/src/prompts/why.md`/`breakdown.md` changes model behavior without touching TypeScript, and a user's own override at `$OF_PROMPTS_DIR`/`/prompts/.md` always wins over the embedded default — there is no way for the CLI to tell "the user meant to override this" apart from "the file exists and is non-blank." +- **`resolveAIConfig()` never logs or echoes the key.** `describeAISetup()` (surfaced in the `missing-key` error and available for docs/help text) tells the user where to put it, but no code path here prints a resolved key back out. +- **A quit inside `why`/`breakdown` never partially applies anything.** `why` has no side effects to begin with (see the design non-goals — transcripts are not persisted); `breakdown` only calls `createTaskTree()` on an explicit "apply", and that's a single bridge round-trip, not N — there's no intermediate state a Ctrl-C during generation or revision could leave behind. +- **This module has no knowledge of short-id aliases, output formatting, or Commander** — those all live in the caller. Keeping `core/ai/` free of CLI/OmniFocus-presentation concerns is what makes `createFakeAI()` a drop-in replacement in tests without any command-shaped mocking. + +Created and maintained by Nori. diff --git a/src/core/docs.md b/src/core/docs.md index 89313b7..b6684fb 100644 --- a/src/core/docs.md +++ b/src/core/docs.md @@ -13,6 +13,7 @@ Path: @/src/core - The error hierarchy is caught at the top-level CLI entrypoint (`@/src/index.ts`) to produce user-facing messages and correct exit codes. - The `OmniFocusClient` interface in types is the seam for testing — commands depend on the interface, and tests inject mocks without touching the bridge. - `@/src/index.ts` wraps the client from `createClient()` in `withProgress()` (`@/src/core/ui/progress.ts`) before it ever reaches `buildProgram()`, and `@/src/program.ts` installs a `preAction` hook that gates whether that wrapper is allowed to draw anything for a given invocation. See `@/src/core/ui/docs.md` for the terminal toolkit itself. +- `@/src/core/ai/` is a sibling seam at this same layer, not a consumer of it: `buildProgram(client, ai)` injects an `AIClient` alongside the `OmniFocusClient` into every command exactly the same way. The two seams only touch here — `client.getTaskContext()`/`client.createTaskTree()` are ordinary bridge calls the AI verbs use to read/write OmniFocus, and `@/src/core/output.ts` renders the AI verbs' plan preview using types (`Plan`, `PlanNode`) owned by `@/src/core/ai/docs.md`, not the other way around. See that doc for the model-facing half of the architecture. ``` ┌──────────────┐ @@ -35,7 +36,7 @@ Path: @/src/core - **Transport**: `executeBridge()` spawns `osascript -l JavaScript` with the JSON command as argv, passing the bridge script source via `-e` (a Bun text import of `@/src/jxa/bridge.js`, shebang stripped at load) rather than a file path — a filesystem path resolved via `import.meta.dirname` would point into Bun's virtual `/$bunfs/` filesystem inside a `bun build --compile` binary, invisible to the external `osascript` process. It applies a 30s default timeout and 10MB maxBuffer. Timeout, empty response, stderr-only output, and malformed JSON all surface as `JXAExecutionError`. The osascript binary itself is resolved per-call from `OF_BRIDGE_BIN` (defaulting to `/usr/bin/osascript`) — a test seam that lets transport tests (`@/test/core/bridge.test.ts`) substitute a stub binary (`@/test/fixtures/bridge-stub.ts`) instead of spawning real osascript. - **Large payloads travel via stdin, not argv**: when the serialized `BridgeCommand` JSON exceeds a fixed threshold (128KB, chosen to stay well under the kernel's `ARG_MAX`), `executeBridge()` passes the literal argv argument `"@stdin"` instead of the JSON itself and writes the real JSON to the child process's stdin. `@/src/jxa/bridge.js`'s `run()` dispatcher detects the `"@stdin"` sentinel and reads the real command from stdin via the ObjC bridge (`$.NSFileHandle.fileHandleWithStandardInput`, decoded as a UTF-8 `NSString`), returning a structured failure if the bytes aren't valid UTF-8. Small commands still go through argv unchanged. Because async `execFile` has no `input` option (only `execFileSync` supports that), stdin writing is driven explicitly by `execBridgeProcess()`, a promise wrapper around `execFile` that always writes-then-closes the child's stdin (an unclosed stdin pipe would hang a bridge invocation that's waiting to read it) and resolves/rejects on exit code. - **Non-macOS guard**: `executeBridge()` throws a CLIError immediately ("requires macOS") when `process.platform !== "darwin"`, unless `OF_BRIDGE_BIN` is set — the env var doubling as both the transport test seam and the signal that a non-darwin test context is intentional. -- **Client factory**: `createClient()` returns an object satisfying `OmniFocusClient`. Each method is a thin wrapper that constructs a `BridgeCommand` with the appropriate `op` string (e.g., `"task.create"`, `"task.notification.add"`, `"task.delete"`, `"project.list"`, `"forecast"`) and delegates to `executeBridge()`. Heavy operations (forecast, review, stats) use 60s timeouts; bulk operations use 120s. `listInbox(limit?, opts?)` takes an optional `{ newestFirst? }` bag spread directly into the `inbox.list` params, mirroring the bridge-side flag. `createTask(opts: TaskCreateOptions)` covers inbox, project and subtask creation in one method — `opts.parent`/`opts.parentId` nest the task, and a successful result carries an optional `parent: { id, name, project }` when it did — so there is no separate `createSubtask`/`addInbox` method or `SubtaskCreateOptions` type; the CLI's `task subtask`/`inbox add` command duplication was removed the same way, folding into `task add` (see `@/src/commands/docs.md`). +- **Client factory**: `createClient()` returns an object satisfying `OmniFocusClient`. Each method is a thin wrapper that constructs a `BridgeCommand` with the appropriate `op` string (e.g., `"task.create"`, `"task.notification.add"`, `"task.delete"`, `"project.list"`, `"forecast"`) and delegates to `executeBridge()`. Heavy operations (forecast, review, stats) use 60s timeouts; bulk operations use 120s. `getTaskContext(opts)` (op `"task.context"`, 60s) and `createTaskTree(opts)` (op `"task.createTree"`, 120s — the same weight class as bulk ops, since it's a single round-trip creating a whole subtree) are the two client methods behind the AI verbs; see `@/src/core/ai/docs.md` for how they're used and `@/src/jxa/docs.md` for the ops themselves. `listInbox(limit?, opts?)` takes an optional `{ newestFirst? }` bag spread directly into the `inbox.list` params, mirroring the bridge-side flag. `createTask(opts: TaskCreateOptions)` covers inbox, project and subtask creation in one method — `opts.parent`/`opts.parentId` nest the task, and a successful result carries an optional `parent: { id, name, project }` when it did — so there is no separate `createSubtask`/`addInbox` method or `SubtaskCreateOptions` type; the CLI's `task subtask`/`inbox add` command duplication was removed the same way, folding into `task add` (see `@/src/commands/docs.md`). - **Response unwrapping**: `unwrapBridgeResponse()` converts `{ ok: false }` responses into thrown `BridgeError` instances, preserving disambiguation candidates for display. Before constructing the error, it runs the raw bridge error text through `matchKnownBridgeFailure()` (see Error hierarchy below) so a known first-run failure is rewritten before it ever reaches a catch block. - **Output dual-mode**: `resolveFormat()` returns `"json"` if `--json` is passed or stdout is not a TTY, otherwise `"human"`. Every entity type (task, project, tag, folder) has both a line formatter (for lists) and a detail formatter, plus a list outputter that handles the empty-state message and count footer. `@/src/core/output.ts` is purely this entity-renderer layer now — it knows entities and formats but composes its ANSI decoration from `@/src/core/ui/colors.ts` (`bold, cyan, dim, green, red, yellow`) rather than defining color logic itself; `colorEnabled(stream)`/`paint()` and the per-stream `NO_COLOR`/`FORCE_COLOR`/`isTTY` gating live in that module (see `@/src/core/ui/docs.md`), one level below the entity-aware renderer. - **`outputMoved(task, touched)`** (`touched: readonly DateField[]`, `DateField = "due" | "defer" | "planned"`) renders `of task move`'s human-mode success output: a `✓ Moved: ()` header (short id only if one is already cached — it calls `peekShortId()`, never mints), then one line per date the task carries, read back from the bridge in a fixed order defined by the `DATE_FIELDS` table (Planned, then Defer, then Due) rather than the order the command happened to touch — every command run therefore lists dates the same way regardless of which fields were passed. A field the command touched is highlighted (green `●` marker, whole `Label: value` text in green, via `@/src/core/ui/colors.ts`); an untouched field is rendered fully dimmed (`•` marker) as context. A touched field whose stored value is `null` still prints as `cleared` (highlighted); an untouched, unset field is omitted entirely. It renders from the task object handed back by the bridge (i.e. what OmniFocus actually stored after read-back verification — see `resolveDate`/`setDateProp` in `@/src/jxa/docs.md`), not from the user's raw natural-language input, so what prints is guaranteed to match what the app holds. @@ -47,6 +48,7 @@ Path: @/src/core - **Error/warning output is machine-readable when stderr isn't a TTY**: `outputError(error)` accepts either a plain string or a `CLIError` (so a caught `BridgeError`'s `candidates` survive intact — command catch blocks pass the error object through, not a pre-formatted string). When `process.stderr.isTTY` is not `true` (piped/redirected), it emits one JSON line matching the bridge's own `{ ok: false, error, candidates? }` shape; on a real terminal it renders the human `"✗ ..."` form, including a "Did you mean:" list for `BridgeError` candidates. `outputWarning(message)` follows the identical contract at a smaller scale: `{"warning":...}` when piped, `"! ..."` on a terminal. - **Error hierarchy**: `CLIError` is the base (message + exitCode). `BridgeError` carries optional `candidates[]` for "did you mean?" disambiguation, and is also how ambiguous-match and not-found failures surface — the bridge returns them as `{ ok: false, error, candidates? }` rather than the CLI layer throwing a dedicated exception type. `JXAExecutionError` carries stderr. `ConfirmationRequiredError` is the one specialized subclass, used by every destructive verb guard (`task delete`, `project delete`, `tag delete`, `task notification clear`, `inbox process --delete`, `inbox process-many` with any `delete: true` item) to produce a consistent message. `InboxProcessOptions` (used by `processInbox()`) carries a `confirm?: boolean` field that the CLI layer sets from `--confirm` and the bridge checks again before deleting. `matchKnownBridgeFailure(raw)` recognizes two categories of raw failure text — Apple Events authorization denial (`-1743` / "Not authorized to send Apple events") and OmniFocus not being found/openable — and rewrites them into actionable guidance strings; it's applied at three independent call sites so no failure path bypasses it: `unwrapBridgeResponse()` here, `executeBridge()`'s stderr-handling paths in `@/src/core/bridge.ts`, and directly inside `@/src/jxa/bridge.js`'s `run()` dispatcher (which wraps its `Application('OmniFocus')` acquisition in try/catch and returns a structured `fail()` instead of letting osascript die with raw stderr). - **`readStdin(example)`** (`@/src/core/stdin.ts`) is the shared stdin reader for the `bulk add`/`update`/`complete` commands and `inbox process-many`. It throws a `CLIError` immediately if `process.stdin.isTTY` is true, showing a usage example in the message — otherwise a command run without piped input would hang forever waiting for EOF. **`readJsonArray(example, itemLabel, validateItem?)`** builds on it: reads stdin, parses it as JSON, throws a `CLIError` if it isn't a non-empty array, then runs the optional `validateItem(item, index)` over every element before returning — a shape problem (e.g. a bulk item missing `name`) is reported before any client call, not partway through processing. All four stdin-driven verbs call this one implementation rather than each parsing and validating stdin itself. +- **AI plan rendering** (`outputPlanTree`, `formatPlanTree`, `outputTreeResult`): the human-mode renderers for `task breakdown`'s preview and apply steps, taking `Plan`/`PlanNode` types owned by `@/src/core/ai/docs.md` rather than an OmniFocus entity type — the one place this module renders something that didn't come back from the bridge. `formatPlanTree` walks the plan's tree recursively into indented ` ` lines (flag, order, estimate, tags, due, defer, then a dimmed note line); `outputPlanTree` wraps that with a header, summary, totals and any open questions the model asked. `outputTreeResult(result: CreateTreeResult)` renders the post-apply report — per-item ✓/✗ lines and a created/total header — and pushes each item's and the parent's soft warnings through the existing `outputWarning()` (stderr), returning `{ created, failed }` so the verb can decide its own exit code the same way `outputBatchSummary` does for the stdin-driven batch verbs. - **Batch/entity-action output helpers** (`@/src/core/output.ts`), all consumed by `@/src/commands/`: `outputWarnings(warnings?)` prints the bridge's soft per-property warnings as `"Partial apply warning: ..."` lines. `outputEntityAction(action, name, id?)` renders a single-entity confirmation (`"✓ Deleted: Buy milk (42)"`), looking up — never minting — a short id via `peekShortId()`. `outputBatchSummary(title, results: readonly BatchItem[])` is the shared human-mode renderer for every stdin-driven batch verb (`bulk add`/`update`/`complete`, `inbox process-many`): it prints per-item success/failure/warning lines and a total, and returns a `BatchSummary` (`{ succeeded, failed, partial }`) so the calling verb can decide its own exit code from the counts rather than re-deriving them. ### Things to Know diff --git a/src/core/ui/docs.md b/src/core/ui/docs.md index 299517e..00f28b8 100644 --- a/src/core/ui/docs.md +++ b/src/core/ui/docs.md @@ -16,7 +16,8 @@ Path: @/src/core/ui ### Core Implementation - **`colors.ts`**: ANSI code constants plus `colorEnabled(stream)` and `paint(code, s, stream?)`. Named helpers (`bold`, `dim`, `red`, `green`, `yellow`, `blue`, `cyan`) default to `process.stdout` but accept an optional second `stream` argument so stderr-rendered chrome (errors, warnings) can be gated on stderr's own TTY state rather than stdout's. `colorEnabled` follows `NO_COLOR`/`FORCE_COLOR` conventions checked per call, then falls back to `stream.isTTY`. - **`terminal.ts`**: `isInteractive(stream)` — true only when `stream.isTTY === true`, `TERM` isn't `"dumb"`, and `CI` isn't set. Deliberately stricter than a bare TTY check: CI logs and dumb terminals can't render in-place redraws, so animated output there would just be noise. The `TerminalStream` interface (`{ isTTY?: boolean }`) is the minimal shape this and `progress.ts` need from a stream, letting tests substitute a fake object instead of a real `WriteStream`. -- **`progress.ts`**: `withProgress(client, opts?)` wraps an `OmniFocusClient` in a `Proxy` that shows a spinner on `opts.stream` (default `process.stderr`) around every async method call, stopping it in a `finally` regardless of success or failure — the spinner is never persisted, only the command's own output or `outputError()` result is. Two independent gates must both pass before anything draws: the module-level `progressEnabled` flag (`setProgressEnabled()`/`isProgressEnabled()`, off by default) and `isInteractive(stream)`. `DEFAULT_PROGRESS_LABEL` and the per-method `PROGRESS_LABELS` map give each client operation (forecast, review, stats, bulk ops, the various list/search ops) a specific in-flight message; caller-supplied `labels` merge over the defaults. The spinner library (`yocto-spinner`) is imported with a dynamic `import()` inside `startSpinner()`, reached only once a spinner is actually about to draw — a JSON-mode run never evaluates the module at all, so it pays no cost for stream hooking, signal handlers, or timers it will never use. +- **`progress.ts`**: `withSpinner(label, fn, stream?)` is the primitive — run `fn()` with a spinner around it when both gates allow, otherwise just run it. `withProgress(client, opts?)` wraps an `OmniFocusClient` in a `Proxy` that calls `withSpinner` around every async method, picking each call's label from `PROGRESS_LABELS`/`DEFAULT_PROGRESS_LABEL`, so `withProgress` is now a thin proxy layered on `withSpinner` rather than its own copy of the gating logic. The spinner is stopped in a `finally` regardless of success or failure — it is never persisted, only the caller's own output or `outputError()` result is. Two independent gates must both pass before anything draws: the module-level `progressEnabled` flag (`setProgressEnabled()`/`isProgressEnabled()`, off by default) and `isInteractive(stream)`. `DEFAULT_PROGRESS_LABEL` and the per-method `PROGRESS_LABELS` map give each client operation (forecast, review, stats, bulk ops, the various list/search ops, `getTaskContext`, `createTaskTree`) a specific in-flight message; caller-supplied `labels` merge over the defaults. The spinner library (`yocto-spinner`) is imported with a dynamic `import()` inside `startSpinner()`, reached only once a spinner is actually about to draw — a JSON-mode run never evaluates the module at all, so it pays no cost for stream hooking, signal handlers, or timers it will never use. `@/src/commands/task/breakdown.ts` calls `withSpinner` directly around each `ai.structured()` call ("Thinking…"/"Revising…") — the same gating a bridge round-trip gets, for a model round-trip that isn't a client method at all. +- **`prompt.ts`**: `createPrompter({input?, output?})` returns a line-oriented `Prompter` (`ask()`, `choose(question, keys)`, `close()`) for the two AI verbs' interactive loops (`@/src/commands/docs.md`). `ask()` resolves the trimmed answer or `null` when the user wants out; every exit path funnels through `null` so a verb only ever checks for it once. A fresh `readline.createInterface` is created per question (`terminal: input.isTTY === true`). Quit is detected four ways: a lone raw `\x1b` (Esc) byte read straight off the input stream's `data` event — not readline's keypress parser, which never flushes a standalone escape (it waits to see whether more bytes follow, so a lone Esc would only surface on the *next* keypress); readline's `SIGINT` event (Ctrl-C); readline's `close` event (Ctrl-D/EOF); and typed `/quit`, `/q`, `/exit`. `choose()` builds on `ask()`, re-prompting until the answer's first character (case-insensitive) is one of the given keys. Entity-agnostic like the rest of this directory — it knows nothing about tasks or the model, and its streams are injectable so tests drive it with `PassThrough` streams instead of a real TTY. ### Things to Know - **Gotcha driving the double gate**: `yocto-spinner`, given a non-interactive stream, still prints its label once as a plain line rather than staying silent — that would violate the CLI's "piped stderr is one JSON object per line" contract (see `@/src/core/docs.md`). `progress.ts` therefore checks `isInteractive()` itself before ever constructing a spinner, rather than trusting the library's own detection. diff --git a/src/jxa/docs.md b/src/jxa/docs.md index 0ae7c77..8075155 100644 --- a/src/jxa/docs.md +++ b/src/jxa/docs.md @@ -21,6 +21,7 @@ Path: @/src/jxa - **Dispatcher**: The `run(args)` entry point resolves the command JSON, obtains the OmniFocus `Application` and its `defaultDocument`, looks up a handler in the `ops` registry by `cmd.op`, and calls `handler(of, doc, params)`. Unrecognized ops and uncaught exceptions are caught and returned as `fail()` responses. Acquiring the `Application('OmniFocus')` handle is itself wrapped in try/catch — if OmniFocus isn't installed or can't be opened, `run()` returns a structured `fail("OmniFocus could not be opened: ...")` instead of letting the exception propagate to raw, unfriendly osascript stderr. - **`@stdin` sentinel for large commands**: when `args[0]` is the literal string `"@stdin"`, `run()` calls `readCommandFromStdin()` instead of using the argv value directly — it reads the process's stdin to EOF via the ObjC bridge (`$.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile`, decoded as a UTF-8 `NSString`) and throws if the bytes don't decode. This exists because argv has a hard OS size limit (`ARG_MAX`) that large bulk-operation payloads or huge task notes can exceed; `@/src/core/bridge.ts` decides per-call whether to use the sentinel. Plain argv commands are unaffected. - **Ops registry**: A plain `var ops = {}` object where each handler is registered as `ops["domain.action"] = function(of, doc, p) { ... }`. Domains include `task`, `project`, `tag`, `folder`, `inbox`, plus top-level ops like `forecast`, `review`, `stats`, `bulk.*`, and `collect`. Notification CRUD is exposed via `task.notification.*`. +- **`task.context` and `task.createTree`** back the AI verbs (`@/src/commands/task/breakdown.ts`, `why.ts`, via `@/src/core/ai/docs.md`) and are the two newest ops. `task.context` resolves a task through the shared `findTaskFromParams` and gathers, in one round-trip: the task itself; its ancestor chain (walking `parentTask()` up to a fixed depth, stopping at the containing project's own invisible root task — read once via `project.rootTask().id()` — so the chain never includes that synthetic node); the project (`formatProject`); its existing subtree, completed children included, recursed depth-first under a shared node budget (`formatContextSubtree`, `CONTEXT_SUBTREE_BUDGET`) so a huge project can't blow up the payload or the Apple Event count; sibling tasks in the same container (parent task, project, or the inbox), read as one batch of `id()/name()/completed()` via `compactSiblings()` rather than per-sibling; and every tag name in the database (`doc.flattenedTags.name()`). `task.createTree` creates an entire nano-task plan under one `parentId` or at the top level of one `projectId` in a single call: items are created in array order via `of.Task()` pushed onto the target (or an earlier-created item's) `tasks`, `parentKey` resolves to that earlier item by looking it up in a `key → task` map built as items succeed, a failed item is recorded with an error and every item naming it (directly or transitively) as `parentKey` is skipped with its own error rather than being silently reparented to the target, and property application per item goes through the same shared `applyTaskProps`/`extractWarnings` pair `task.update` uses — so a bad tag or date on one item becomes a warning on that item, never a lost task or an aborted batch. The target's own `sequential` flag, when given, is applied before any child is created. - **Fuzzy entity resolution**: Lookup functions (`findExistingTag`, `findExistingProject`, `findTaskByQuery`) follow a consistent three-tier strategy: exact match → case-insensitive substring match → ambiguity error with up to 10 candidate names. `findTaskByQuery` adds an ID-based lookup as the first tier. - **Batch property access is required, not just an optimization**: `task.list` (both the inbox and non-inbox branches), `stats`, and `task.search` fetch each needed property as a whole array in one call (e.g. `doc.flattenedTasks.completed()`, `ft.dueDate()`) and index into the arrays, instead of calling a property accessor per task per property. On large databases (thousands of tasks), the per-task-per-property form issues one Apple Event per call and times out — `stats` and `task list --filter overdue` were unusable (60s/30s timeouts) before this was applied; `forecast`/`review` already used the batch form. `formatTask()` is only invoked for tasks that already passed the filter, using a lazily-materialized `ft()` element-array reference for the final read. The same correctness requirement (not just an optimization) applies to `formatProject()`/`project.list --full`, `tag.list --count`, and `tag.tasks` — see the per-project and per-tag bullets below for the specific fixes and timings. - **The same batch pattern applies per-project, not just at the document root**: `formatProject()` (used by `project.get` and `project.list --full`), `project.get`'s `overdueCount`, and `project.list`'s `activeOnly` filter each used to materialize `project.flattenedTasks()` and loop `tasks[i].completed()` (plus `dueDate()`) per task — with ~130 projects, a large multiple of per-task Apple Events, and `project list --full --json` timed out at 30s. All three now read `project.flattenedTasks` as a bare property (a specifier, not materialized refs) and batch `.completed()`/`.dueDate()` once per project, indexing the returned arrays. `formatProjectCompact()` follows suit for its task count (`project.flattenedTasks.completed().length`) to avoid materializing refs just to read `.length`. diff --git a/test/docs.md b/test/docs.md index deba52c..e260a4b 100644 --- a/test/docs.md +++ b/test/docs.md @@ -9,20 +9,25 @@ Path: @/test ### How it fits into the larger codebase - Unit tests in `test/core/` import directly from `@/src/core/` — specifically the error class hierarchy, `unwrapBridgeResponse`, output formatters, and domain types. -- Integration tests in `test/integration/` import the `register*Commands` functions from `@/src/commands/` and the `OmniFocusClient` interface from `@/src/core/types.js`, wiring real Commander programs to mock clients. +- Integration tests in `test/integration/` import the `register*Commands` functions from `@/src/commands/` and the `OmniFocusClient` interface from `@/src/core/types.js`, wiring real Commander programs to mock clients. Commands that need the model seam get a `FakeAI` (`@/src/core/ai/docs.md`) the same way, via `@/test/helpers/run.ts`'s `runCommand()`. - For CLI/client/output code, the boundary under test stops at `OmniFocusClient`: bridge transport and OmniFocus Apple Events are not exercised there — the client interface is the seam for that layer. - Tests in `test/jxa/` exercise `@/src/jxa/bridge.js` directly, below the `OmniFocusClient` seam, using the harness described below. `test/core/bridge.test.ts` exercises `@/src/core/bridge.ts`'s transport logic on its own seam (`OF_BRIDGE_BIN`), substituting a stub binary for real `osascript` — real OmniFocus/Apple Events are still never touched by any test. - Fixtures mirror the exact shapes returned by `@/src/core/bridge.ts`, so tests validate that upstream code handles real response envelopes correctly. ### Core Implementation -- **Mocking strategy**: `createMockClient()`, shared from `@/test/fixtures/mock-client.ts`, builds a complete `OmniFocusClient` where every method is a `bun:test` `mock()` returning a `Promise>` via the `successResponse()` helper. Tests assert on call counts and argument shapes. -- **Integration test harness**: `@/test/helpers/run.ts` is the single CLI harness — every integration test file imports it rather than building its own. `runCommand(setup, argv, client?)` creates a real `Commander` program mirroring `@/src/program.ts` (a root `--json` option, `exitOverride()`), registers a command group via `setup`, monkey-patches `console.log`/`console.error` into captured arrays, stubs `process.exit` so a non-zero exit is readable as `exitCode` instead of killing the test runner, then calls `parseAsync()` — exercising the full path from CLI argv through option parsing, command handler, client call, and output formatting. `runCommandWithStdin(setup, argv, stdinText, client?)` layers `withStdin()` (`@/test/helpers/env.ts`) on top for the stdin-driven verbs (`bulk add`/`update`/`complete`, `inbox process-many`). `@/test/helpers/parse.ts`'s `parseCommand(setup, argv)` is the lighter-weight counterpart for option-group unit tests: it parses argv through a throwaway `Command` and captures the action's `(args, opts)` without any client or output plumbing, used by `test/commands/options/*.test.ts` and `test/commands/noun.test.ts` to exercise `@/src/commands/options/` and `defineNoun()` (`@/src/commands/noun.ts`) directly — the latter also covers `verbAliases` on synthetic nouns: letters are applied per mount point, and an unknown verb, a multi-character letter, or a collision with another verb's name or alias throws at registration. `test/integration/program.test.ts` pins the real verb-alias table per noun on a built program (plus the suite-wide invariants that every alias is one character and every spelling is unique within its noun) and dispatches `of t c ...`/`of t n l ...` end-to-end; `test/integration/completion.test.ts` asserts verb aliases are offered in every shell's verb lists and nested guards. `test/integration/program.test.ts` exercises `buildProgram()` directly (from `@/src/program.ts`) to assert `--version` matches `package.json`, and `test/integration/completion.test.ts` is a parity test asserting every command registered on a real built program appears in every generated shell completion script. `test/integration/stdin.test.ts` covers the TTY-guard behavior of `readStdin()`/`readJsonArray()` for the bulk/process-many commands (asserting `exitCode`/`stderr` since `runAction` now catches the TTY-guard `CLIError`), and `@/test/core/stdin.test.ts` covers `readJsonArray()`'s parsing/validation directly. `test/integration/complete.test.ts`/`move.test.ts` cover `task complete`/`task move` end-to-end via the shared harness — registration under `task` only (there is no root `of complete`/`of move` shortcut any more), the `t` alias, short-id resolution, `--incomplete`, single-ref backward compatibility (bare JSON object; error → stderr JSON + `exitCode` 1), multi-ref ordering and per-ref args, the JSON array shape, partial-failure-continues-and-exits-1, human-mode per-line stdout/stderr output (via `withEnv`/`withStreamTTY`), and `--id` rejection when combined with multiple refs. +- **Mocking strategy**: `createMockClient()`, shared from `@/test/fixtures/mock-client.ts`, builds a complete `OmniFocusClient` where every method is a `bun:test` `mock()` returning a `Promise>` via the `successResponse()` helper. Tests assert on call counts and argument shapes. Its AI-side counterpart is `createFakeAI()` (`@/test/fixtures/fake-ai.ts`) — see the dedicated bullet below. +- **Integration test harness**: `@/test/helpers/run.ts` is the single CLI harness — every integration test file imports it rather than building its own. `runCommand(setup, argv, client?, ai?)` creates a real `Commander` program mirroring `@/src/program.ts` (a root `--json` option, `exitOverride()`), registers a command group via `setup: (program, client, ai) => void`, monkey-patches `console.log`/`console.error` into captured arrays, stubs `process.exit` so a non-zero exit is readable as `exitCode` instead of killing the test runner, then calls `parseAsync()` — exercising the full path from CLI argv through option parsing, command handler, client/AI calls, and output formatting. Both a mock `OmniFocusClient` and a `FakeAI` are created automatically when omitted (and the `FakeAI` is returned on the result, alongside `client`, so a test can assert on `ai.requests` without threading a fixture through by hand). `runCommandWithStdin(setup, argv, stdinText, client?, ai?)` layers `withStdin()` (`@/test/helpers/env.ts`) on top for the stdin-driven verbs (`bulk add`/`update`/`complete`, `inbox process-many`). `@/test/helpers/parse.ts`'s `parseCommand(setup, argv)` is the lighter-weight counterpart for option-group unit tests: it parses argv through a throwaway `Command` and captures the action's `(args, opts)` without any client or output plumbing, used by `test/commands/options/*.test.ts` and `test/commands/noun.test.ts` to exercise `@/src/commands/options/` and `defineNoun()` (`@/src/commands/noun.ts`) directly — the latter also covers `verbAliases` on synthetic nouns: letters are applied per mount point, and an unknown verb, a multi-character letter, or a collision with another verb's name or alias throws at registration. `test/integration/program.test.ts` pins the real verb-alias table per noun on a built program (plus the suite-wide invariants that every alias is one character and every spelling is unique within its noun) and dispatches `of t c ...`/`of t n l ...` end-to-end; `test/integration/completion.test.ts` asserts verb aliases are offered in every shell's verb lists and nested guards. `test/integration/program.test.ts` exercises `buildProgram()` directly (from `@/src/program.ts`) to assert `--version` matches `package.json`, and `test/integration/completion.test.ts` is a parity test asserting every command registered on a real built program appears in every generated shell completion script. `test/integration/stdin.test.ts` covers the TTY-guard behavior of `readStdin()`/`readJsonArray()` for the bulk/process-many commands (asserting `exitCode`/`stderr` since `runAction` now catches the TTY-guard `CLIError`), and `@/test/core/stdin.test.ts` covers `readJsonArray()`'s parsing/validation directly. `test/integration/complete.test.ts`/`move.test.ts` cover `task complete`/`task move` end-to-end via the shared harness — registration under `task` only (there is no root `of complete`/`of move` shortcut any more), the `t` alias, short-id resolution, `--incomplete`, single-ref backward compatibility (bare JSON object; error → stderr JSON + `exitCode` 1), multi-ref ordering and per-ref args, the JSON array shape, partial-failure-continues-and-exits-1, human-mode per-line stdout/stderr output (via `withEnv`/`withStreamTTY`), and `--id` rejection when combined with multiple refs. - **Unit test coverage**: core tests verify `unwrapBridgeResponse` success/error unwrapping (including structured candidates and known-failure rewriting), the full `CLIError` hierarchy (exit codes, `format()`, specialized subclasses), parser helpers (integer and duration syntax, including zero-value durations), and output formatters (`formatTaskLine`, `formatTaskDetail`, `formatProjectLine`, `formatProjectDetail`, `resolveFormat`). `test/core/bridge.test.ts` covers the transport layer itself (see Things to Know below). - **`test/core/ui/`** covers the terminal toolkit (`@/src/core/ui/docs.md`) below the entity-formatter layer: `colors.test.ts` covers `NO_COLOR`/`FORCE_COLOR`/`FORCE_COLOR=0` and per-stream `isTTY` gating, including that an explicit `stream` argument to a named helper targets that stream rather than the default; `terminal.test.ts` covers `isInteractive()` across TTY, non-TTY, `CI`-set, and `TERM=dumb` cases; `progress.test.ts` covers `withProgress()` using a fake `ProgressStream`-shaped object (records writes, counts `clearLine` calls) and `createMockClient()` as the wrapped client — forwarding of args/results and rejections, silence when disabled, when the stream is non-interactive, and under `CI`, and (when enabled on an interactive stream) that a render shows the op's label, hides the cursor, clears on completion, and restores the cursor on both success and rejection; also that an `{ ok: false }` bridge response is a normal return value, not a thrown rejection, and doesn't change spinner behavior. Tests hold the mocked op open briefly (a short `setTimeout`) so a frame is guaranteed to paint before assertions run; `afterEach` resets `setProgressEnabled(false)` so the global gate never leaks between tests. -- `test/integration/program.test.ts` builds the full program via `buildProgram()` with `withProgress(mockClient, { stream: })` and asserts the "JSON interface carries no UI chrome" contract end-to-end: forcing an interactive `process.stdout` and running `forecast` in human mode produces spinner output containing its label ("Building forecast…") with `isProgressEnabled()` true, while `forecast --json`, the global `--json` flag, and a piped (non-TTY) stdout all leave the fake stream with zero writes. This is the regression guard for progress gating and should be extended as new UI features are added. -- **Fixture design**: `mock-responses.ts` exports typed constants (`MOCK_TASK`, `MOCK_PROJECT`, `MOCK_STATS`) plus `successResponse()` and `errorResponse()` factory functions that wrap data in `BridgeResponse` envelopes. `MOCK_TASK` includes notification sample data for task notification command and detail output coverage. `mock-client.ts` builds the full `OmniFocusClient` mock from these fixtures and is imported by every integration test that needs one, rather than each test file defining its own. `bridge-stub.ts` is a standalone executable that stands in for `osascript` in transport tests (see Things to Know). The `makeTask()`/`makeProject()` helpers in output tests use spread overrides for targeted field variations. -- **Shared env/TTY helpers** (`@/test/helpers/env.ts`): `withEnv(env, fn)` sets env vars (`undefined` unsets), always restoring the originals in a `finally` — promise-aware, so if `fn` returns a `Promise`, restoration waits for it to settle rather than racing it. `withStreamTTY(stream, isTTY, fn)` temporarily overrides a stream's `isTTY` via `Object.defineProperty` and restores the original descriptor afterward. These were extracted out of `test/core/output.test.ts` (which now imports them) so the color/TTY tests in `@/test/core/ui/colors.test.ts` and the progress tests can reuse the same restore-safe pattern instead of reimplementing it. -- **JXA bridge harness** (`test/jxa/bridge-harness.ts`): reads and evaluates the real `@/src/jxa/bridge.js` source (shebang stripped, since it's invalid syntax for `new Function`) with a stubbed `Application` global, then dispatches a command via the script's `run()` entry point — exercising real op handlers (`task.list`, `stats`, etc.) with no mocking of bridge logic itself. `runBridge(doc, op, params)` is the simple form; `runBridgeArgs(doc, args, stdinContent?, opts?)` is the lower-level entry point beneath it, letting tests pass raw argv (to exercise the `"@stdin"` sentinel path), simulate stdin content (including undecodable content), and simulate `Application('OmniFocus')` acquisition failing via `opts.applicationUnavailable`. `makeElementArray`/`makeJxaObject` reproduce JXA's callable-specifier shape: an element array (e.g. `doc.inboxTasks`) is both callable (returns the element objects) and has batch property getters (`.completed()` returns an array of values) — required because the bridge's batch-property-access code paths call properties directly on the array-like specifier, not per-element. The stubbed `app` object also exposes a no-op `delete()`, letting `inbox.process`'s delete-confirmation branch run its full path (including the actual `of.delete(task)` call) without mutating anything. `makeMutableJxaObject(props, { readonlyKeys })` extends this for tests that write and then re-read a property (e.g. `setDateProp`'s read-back check): it's a `Proxy` where a read is a zero-arg getter call and a write is a plain assignment later observed by reads, reproducing true JXA specifier get/set behavior; any key listed in `readonlyKeys` silently ignores writes, simulating OmniFocus refusing to store a change. `RunBridgeOptions` also takes an `omniAutomation?: (payload) => unknown` callback — the harness's `app.evaluateJavascript(script)` stub extracts the `var payload = {...};` line every Omni Automation script embeds and passes the parsed object to this callback, JSON-stringifying whatever it returns (or throwing, to simulate an Omni Automation failure); `runBridge`/`runBridgeArgs` accept it as a 4th `opts` argument. The stubbed `app` also exposes `Task`/`InboxTask` constructors (both build a fresh `makeMutableJxaObject` with a monotonic `new-N` id, so `of.Task({...})`/`of.InboxTask({...})` work without a real document) and a no-op `RepetitionRule(props)` (returns `props` unchanged) — added so `ops["task.create"]`/`ops["bulk.create"]` (via the shared `createTaskRecord`, see `@/src/jxa/docs.md`) can be exercised end-to-end, including nesting a new task under a parent or inside a project. +- `test/integration/program.test.ts` builds the full program via `buildProgram()` with `withProgress(mockClient, { stream: })` and asserts the "JSON interface carries no UI chrome" contract end-to-end: forcing an interactive `process.stdout` and running `forecast` in human mode produces spinner output containing its label ("Building forecast…") with `isProgressEnabled()` true, while `forecast --json`, the global `--json` flag, and a piped (non-TTY) stdout all leave the fake stream with zero writes. This is the regression guard for progress gating and should be extended as new UI features are added. Its `"AI seam"` describe block is the equivalent guard for the model seam: one test builds the program with a `FakeAI`, runs a couple of non-AI commands, and asserts `ai.requests` stayed empty; another walks every `.ts` file under `@/src` and asserts no file statically imports `@openrouter/sdk` (only a dynamic `await import()` is allowed) and that `@/src/core/ai/openrouter.ts` is the *only* file that mentions the package at all — a regression here (a second file importing the SDK, or a static import replacing the dynamic one) fails this test rather than silently making every `--json` run pay the SDK's load cost. +- **Fixture design**: `mock-responses.ts` exports typed constants (`MOCK_TASK`, `MOCK_PROJECT`, `MOCK_STATS`) plus `successResponse()` and `errorResponse()` factory functions that wrap data in `BridgeResponse` envelopes. `MOCK_TASK` includes notification sample data for task notification command and detail output coverage. `mock-client.ts` builds the full `OmniFocusClient` mock from these fixtures (including `getTaskContext`/`createTaskTree`) and is imported by every integration test that needs one, rather than each test file defining its own. `bridge-stub.ts` is a standalone executable that stands in for `osascript` in transport tests (see Things to Know). The `makeTask()`/`makeProject()` helpers in output tests use spread overrides for targeted field variations. `fake-ai.ts` (`@/src/core/ai/docs.md`) is the AI-side counterpart of `mock-client.ts` — see the dedicated bullet below. +- **`test/core/ai/`** covers `@/src/core/ai/docs.md` at the unit level: `config.test.ts` (precedence for both key and model across flag/env/file/default, malformed/missing config file treated as empty, `$OF_CONFIG_DIR` test seam), `prompts.test.ts` (embedded fallback, override file found/blank/missing, `$OF_PROMPTS_DIR` vs `/prompts`), `plan.test.ts` (`validatePlan`'s full rule set — unique/non-empty keys, `parentKey` null/self/forward-reference/unknown-reference rejection, name length, `estimateMinutes` bounds — plus `buildPlanTree()` nesting order), `context.test.ts` (`renderTaskContext()`'s section rendering, note truncation, sibling cap, the "for orientation only"/"do not recreate" framing), `client.test.ts` (`createAIClient()`'s laziness — construction never throws or resolves config; the backend promise is memoized so a second method call doesn't re-resolve config or re-import the adapter), and `openrouter.test.ts` (below). +- **`test/core/ai/openrouter.test.ts`** is the one test file in the suite that runs the *real* `@openrouter/sdk` rather than mocking it — against a local `Bun.serve()` fake OpenRouter endpoint, reached via the adapter's `serverURL` override, so no network call ever leaves the machine. This exercises the real SDK's request shaping and response parsing, not a hand-rolled approximation of it. A fake response must include `system_fingerprint` and a JSON content-type header, or the SDK's own Zod response validation rejects it before the adapter's code ever runs — a gotcha worth knowing before adding a new scripted response here. Covers `chat`/`stream`/`structured` request shaping, the strict `json_schema` + `provider.requireParameters` structured-output request, the validation-failure retry (feeding errors back once, then throwing `AIError("invalid-response")` on a second failure), ```` ``` ```` code-fence stripping, and `mapError()`'s status/class-to-`AIErrorKind` mapping (401/403/402/429/400-404-422, `SDKValidationError`/`ResponseValidationError` before the generic `OpenRouterError` branch, `RequestAbortedError`/`AbortError`, network fallback). +- **`test/core/ui/prompt.test.ts`** covers `@/src/core/ui/prompt.ts`'s `createPrompter()` against `PassThrough` streams: plain-line answers, `/quit`/`/q`/`/exit`, the raw Esc byte (`\x1b`) versus a multi-byte escape sequence (an arrow key) which must *not* trigger quit, Ctrl-C (readline `SIGINT`), Ctrl-D/EOF (readline `close`), empty-answer re-prompting, and `choose()`'s key-matching and re-prompt-on-mismatch behavior. +- **`test/integration/ai.test.ts`** covers both AI verbs end-to-end via `runCommand()`/`runCommandWithStdin()` with a scripted `FakeAI`: `breakdown --json` prints the plan and applies nothing; `--json --apply` applies via the mock client's `createTaskTree` and reports failures with exit 1; the human-mode interactive loop (revise → apply, and quit/Esc) is driven through a fake TTY stdin — a `PassThrough` with `isTTY: true` and a no-op `setRawMode` — with `@/test/helpers/env.ts`'s `withStreamWrite()` feeding scripted answers whenever a captured stderr chunk ends in a prompt-like `: `, `? ` or `> `; `why`'s non-interactive refusal (missing TTY or `--json`) and a full scripted session ending on Esc are covered the same way. `test/core/output.test.ts` gained matching `outputPlanTree`/`outputTreeResult` cases for the renderers themselves. +- **`test/jxa/task-context.test.ts`** and **`test/jxa/task-create-tree.test.ts`** cover the two newest bridge ops (`@/src/jxa/docs.md`) directly through the harness: ancestor-chain walking and its stop at the project's invisible root task, the subtree budget cutting off a deep/wide existing tree, sibling batch-reads across all three container kinds (parent task, project, inbox), and tag listing for `task.context`; parent-vs-project targeting, `parentKey` nesting (including multi-level, via the harness's now-container-capable created tasks — see above), a failed item's descendants being skipped with an error instead of reparented, per-item property-apply warnings not aborting the batch, and the target's own `sequential` flag being applied for `task.createTree`. +- **Shared env/TTY helpers** (`@/test/helpers/env.ts`): `withEnv(env, fn)` sets env vars (`undefined` unsets), always restoring the originals in a `finally` — promise-aware, so if `fn` returns a `Promise`, restoration waits for it to settle rather than racing it. `withStreamTTY(stream, isTTY, fn)` temporarily overrides a stream's `isTTY` via `Object.defineProperty` and restores the original descriptor afterward. `withStreamWrite(stream, sink, fn)` diverts a stream's `.write()` into a callback for the duration of `fn`, restoring the original afterward — for code that writes to `process.stdout`/`process.stderr` directly rather than through `console.log`/`console.error` (the AI verbs' streamed model output and readline prompts), used by `test/integration/ai.test.ts` to both capture and answer prompts mid-stream. These were extracted out of `test/core/output.test.ts` (which now imports them) so the color/TTY tests in `@/test/core/ui/colors.test.ts` and the progress tests can reuse the same restore-safe pattern instead of reimplementing it. +- **JXA bridge harness** (`test/jxa/bridge-harness.ts`): reads and evaluates the real `@/src/jxa/bridge.js` source (shebang stripped, since it's invalid syntax for `new Function`) with a stubbed `Application` global, then dispatches a command via the script's `run()` entry point — exercising real op handlers (`task.list`, `stats`, etc.) with no mocking of bridge logic itself. `runBridge(doc, op, params)` is the simple form; `runBridgeArgs(doc, args, stdinContent?, opts?)` is the lower-level entry point beneath it, letting tests pass raw argv (to exercise the `"@stdin"` sentinel path), simulate stdin content (including undecodable content), and simulate `Application('OmniFocus')` acquisition failing via `opts.applicationUnavailable`. `makeElementArray`/`makeJxaObject` reproduce JXA's callable-specifier shape: an element array (e.g. `doc.inboxTasks`) is both callable (returns the element objects) and has batch property getters (`.completed()` returns an array of values) — required because the bridge's batch-property-access code paths call properties directly on the array-like specifier, not per-element. The stubbed `app` object also exposes a no-op `delete()`, letting `inbox.process`'s delete-confirmation branch run its full path (including the actual `of.delete(task)` call) without mutating anything. `makeMutableJxaObject(props, { readonlyKeys })` extends this for tests that write and then re-read a property (e.g. `setDateProp`'s read-back check): it's a `Proxy` where a read is a zero-arg getter call and a write is a plain assignment later observed by reads, reproducing true JXA specifier get/set behavior; any key listed in `readonlyKeys` silently ignores writes, simulating OmniFocus refusing to store a change. `RunBridgeOptions` also takes an `omniAutomation?: (payload) => unknown` callback — the harness's `app.evaluateJavascript(script)` stub extracts the `var payload = {...};` line every Omni Automation script embeds and passes the parsed object to this callback, JSON-stringifying whatever it returns (or throwing, to simulate an Omni Automation failure); `runBridge`/`runBridgeArgs` accept it as a 4th `opts` argument. The stubbed `app` also exposes `Task`/`InboxTask` constructors (both build a fresh `makeMutableJxaObject` with a monotonic `new-N` id, so `of.Task({...})`/`of.InboxTask({...})` work without a real document) and a no-op `RepetitionRule(props)` (returns `props` unchanged) — added so `ops["task.create"]`/`ops["bulk.create"]` (via the shared `createTaskRecord`, see `@/src/jxa/docs.md`) can be exercised end-to-end, including nesting a new task under a parent or inside a project. The constructed object's `tasks` property is itself both callable (returns the child array) and has a `.push()`, wrapped around the base object via a `Proxy` — reproducing JXA's specifier shape closely enough that a created task can be a container for further created tasks, which is what lets `test/jxa/task-create-tree.test.ts` build multi-level trees purely through `ops["task.createTree"]`'s own `container.tasks.push(task)` calls. ### Things to Know - **No test ever touches the real short-id cache.** `@/bunfig.toml` sets `[test] preload = ["./test/preload.ts"]`; `@/test/preload.ts` runs before any test file, creating a fresh `mkdtempSync` directory and pointing `OF_SHORT_ID_CACHE` at a file inside it — this is a suite-wide invariant, not opt-in per test, since `@/src/core/short-ids.ts` reads that env var as its cache-path override. Individual tests that need isolation from each other's aliases (not just from the real user cache) set their own `OF_SHORT_ID_CACHE` or pass an explicit `cachePath` option. `test/core/short-ids.test.ts` covers the module directly (minting, pruning, `peekShortId` non-minting behavior, `resolveTaskRef`'s explicit-id/digit-alias/fuzzy-query precedence); `test/core/output.test.ts` ("Short ID prefixes") and `test/integration/cli.test.ts` ("short id references", "short id display") cover the rendering and CLI-wiring sides. From af4f9cf9251ad95f52cf50c43a1f4179bcb92200 Mon Sep 17 00:00:00 2001 From: Max Boettinger Date: Thu, 3 Sep 2026 11:22:46 +0200 Subject: [PATCH 13/14] fix(task): make the target's type change explicit, abort breakdown on Ctrl-C The plan's sequential flag sets the target task's own type, which also governs pre-existing subtasks. The prompt now states that, the preview warns when the type would change and how many existing subtasks are affected, and the breakdown request aborts cleanly on Ctrl-C like why. Claude-Session: https://claude.ai/code/session_01L2voqEC2eEwLsiTxoSnCGD --- src/commands/task/breakdown.ts | 29 ++++++++++++++++++++++++----- src/core/ai/plan.ts | 5 +++-- src/core/output.ts | 25 +++++++++++++++++++++++-- src/prompts/breakdown.md | 9 ++++++--- test/core/output.test.ts | 13 +++++++++++-- test/fixtures/fake-ai.ts | 16 ++++++++++++---- test/integration/ai.test.ts | 30 +++++++++++++++++++++++++++++- test/jxa/task-create-tree.test.ts | 21 +++++++++++++++++++++ 8 files changed, 129 insertions(+), 19 deletions(-) diff --git a/src/commands/task/breakdown.ts b/src/commands/task/breakdown.ts index ffe835d..8906f20 100644 --- a/src/commands/task/breakdown.ts +++ b/src/commands/task/breakdown.ts @@ -92,13 +92,24 @@ export function registerBreakdownCommand( })}\n\nBreak the target task down into nano tasks now.`, ); const model = ctx.opts.model as string | undefined; - const generate = (label: string): Promise> => - withSpinner(label, () => + // Ctrl-C while the model is working aborts the request cleanly + // (surfacing as AIError "aborted") instead of killing the process mid-spinner. + const generate = (label: string): Promise> => { + const controller = new AbortController(); + const onSigint = () => controller.abort(); + process.once("SIGINT", onSigint); + return withSpinner(label, () => ai.structured( - { messages: convo.messages, model, temperature: TEMPERATURE }, + { + messages: convo.messages, + model, + temperature: TEMPERATURE, + signal: controller.signal, + }, PLAN_STRUCTURED, ), - ); + ).finally(() => process.off("SIGINT", onSigint)); + }; const applyPlan = (plan: Plan): Promise => client .createTaskTree(planToTreeOptions(target.id, plan)) @@ -117,7 +128,15 @@ export function registerBreakdownCommand( const prompter = createPrompter({ output: process.stderr }); try { for (;;) { - outputPlanTree(target.name, result.value, buildPlanTree(result.value)); + outputPlanTree( + { + name: target.name, + sequential: context.task.sequential, + existingChildren: context.children.length, + }, + result.value, + buildPlanTree(result.value), + ); let choice = apply ? "a" : null; if (!apply) { console.log(""); diff --git a/src/core/ai/plan.ts b/src/core/ai/plan.ts index 7f9a629..f91276e 100644 --- a/src/core/ai/plan.ts +++ b/src/core/ai/plan.ts @@ -29,7 +29,7 @@ export interface PlanTask { export interface Plan { summary: string; - /** Whether the tasks created directly under the target must be done in order. */ + /** The target's own type after applying: its direct children (new and existing) in order or not. */ sequential: boolean; tasks: PlanTask[]; questions: string[]; @@ -84,7 +84,8 @@ export const PLAN_SCHEMA: Record = { summary: { type: "string", description: "One sentence on the approach." }, sequential: { type: "boolean", - description: "Whether the new top-level tasks must be done in order.", + description: + "The target task's own type: true if its direct children (new and existing) must be done in order.", }, tasks: { type: "array", items: TASK_SCHEMA }, questions: { type: "array", items: { type: "string" } }, diff --git a/src/core/output.ts b/src/core/output.ts index 511a347..e5539b3 100644 --- a/src/core/output.ts +++ b/src/core/output.ts @@ -454,6 +454,10 @@ function orderLabel(sequential: boolean): string { return sequential ? "in order" : "any order"; } +function typeWord(sequential: boolean): string { + return sequential ? "sequential" : "parallel"; +} + /** One line per plan node, indented by depth: ` ` plus a dim note line. */ export function formatPlanTree(tree: PlanNode[], depth = 0, out: string[] = []): string[] { const indent = " ".repeat(depth); @@ -473,11 +477,28 @@ export function formatPlanTree(tree: PlanNode[], depth = 0, out: string[] = []): return out; } +export interface PlanTarget { + name: string; + /** The target's current type, to point out when the plan would change it. */ + sequential: boolean; + /** Direct subtasks that already exist and would be governed by the new type. */ + existingChildren: number; +} + /** Human preview of a breakdown plan before anything is applied. */ -export function outputPlanTree(targetName: string, plan: Plan, tree: PlanNode[]): void { +export function outputPlanTree(target: PlanTarget, plan: Plan, tree: PlanNode[]): void { console.log( - `${bold(`Plan for: ${targetName}`)} ${dim(`— new subtasks ${orderLabel(plan.sequential)}`)}`, + `${bold(`Plan for: ${target.name}`)} ${dim(`— subtasks ${orderLabel(plan.sequential)}`)}`, ); + if (plan.sequential !== target.sequential) { + const n = target.existingChildren; + const affected = n > 0 ? `; ${n} existing subtask${n === 1 ? "" : "s"} affected` : ""; + console.log( + yellow( + `! Changes the task from ${typeWord(target.sequential)} to ${typeWord(plan.sequential)}${affected}`, + ), + ); + } if (plan.summary) console.log(dim(plan.summary)); console.log(""); for (const line of formatPlanTree(tree)) console.log(line); diff --git a/src/prompts/breakdown.md b/src/prompts/breakdown.md index c54f9f1..12d49b0 100644 --- a/src/prompts/breakdown.md +++ b/src/prompts/breakdown.md @@ -28,9 +28,12 @@ thinking. parent task and put the sub-steps under it (`parentKey`). Nest as deep as needed; there is no limit. A parent's own `sequential` says whether its children must be done in order. -8. **Set `sequential` deliberately.** Top-level `sequential` describes the order of the - tasks you create under the target. `true` when steps depend on each other (usual for - a process), `false` when they can be done in any order (a checklist). +8. **Set `sequential` deliberately.** Top-level `sequential` sets the target task's own + type, which governs *all* of its direct children — the ones you create and any that + already exist (they are listed in the context). `true` when the steps depend on each + other (usual for a process), `false` when they can be done in any order (a checklist). + If existing subtasks would be wrongly blocked by a change, keep the target's current + type and nest your ordered steps under a new parent task with its own `sequential`. 9. **Respect what exists.** Existing subtasks and completed work are in the context: do not recreate them, do not duplicate completed steps, and continue from where the person actually is. Do not restate the target task itself as a nano task. diff --git a/test/core/output.test.ts b/test/core/output.test.ts index c394fbf..03b4cd9 100644 --- a/test/core/output.test.ts +++ b/test/core/output.test.ts @@ -547,8 +547,9 @@ describe("AI plan rendering", () => { }); test("outputPlanTree prints header, tree, totals and open questions", () => { - const lines = capture(() => outputPlanTree("Buy groceries", plan, buildPlanTree(plan))); - expect(lines[0]).toBe("Plan for: Buy groceries — new subtasks in order"); + const target = { name: "Buy groceries", sequential: true, existingChildren: 0 }; + const lines = capture(() => outputPlanTree(target, plan, buildPlanTree(plan))); + expect(lines[0]).toBe("Plan for: Buy groceries — subtasks in order"); expect(lines[1]).toBe("Two steps."); expect(lines).toContain("1 Open the app 1min"); expect(lines).toContain("\n3 tasks, ~6 min total"); @@ -556,6 +557,14 @@ describe("AI plan rendering", () => { expect(lines).toContain(" • Which store?"); }); + test("outputPlanTree warns when the plan changes the target's type", () => { + const target = { name: "Buy groceries", sequential: false, existingChildren: 2 }; + const lines = capture(() => outputPlanTree(target, plan, buildPlanTree(plan))); + expect(lines[1]).toBe( + "! Changes the task from parallel to sequential; 2 existing subtasks affected", + ); + }); + test("outputTreeResult reports per-item outcome and counts", () => { const stderr: string[] = []; const origErr = console.error; diff --git a/test/fixtures/fake-ai.ts b/test/fixtures/fake-ai.ts index d27fe89..d43d2c8 100644 --- a/test/fixtures/fake-ai.ts +++ b/test/fixtures/fake-ai.ts @@ -6,7 +6,6 @@ * test fixture that would not survive the production path fails loudly. */ -import { Conversation } from "../../src/core/ai/conversation.js"; import { type AIClient, AIError, @@ -29,6 +28,17 @@ export interface FakeAI extends AIClient { } export const FAKE_MODEL = "fake/model"; +/** A scripted reply that never arrives: the call resolves only by aborting `req.signal`. */ +export const FAKE_HANG = "\u0000hang"; + +function hangUntilAborted(signal: AbortSignal | undefined): Promise { + return new Promise((_resolve, reject) => { + const abort = () => reject(new AIError("aborted", "Request aborted")); + if (!signal) throw new Error("FAKE_HANG needs a request with an AbortSignal"); + if (signal.aborted) abort(); + else signal.addEventListener("abort", abort, { once: true }); + }); +} export function createFakeAI(script: FakeAIScript = {}): FakeAI { const replies = [...(script.replies ?? [])]; @@ -53,6 +63,7 @@ export function createFakeAI(script: FakeAIScript = {}): FakeAI { async stream(req, onDelta) { requests.push(req); const content = next(replies, "reply"); + if (content === FAKE_HANG) return hangUntilAborted(req.signal); onDelta(content); return { content, model: FAKE_MODEL }; }, @@ -77,6 +88,3 @@ export function lastRequest(ai: FakeAI): ChatRequest { if (!last) throw new Error("fake AI received no requests"); return last; } - -// Keep the Conversation import meaningful for fixture authors building histories. -export { Conversation }; diff --git a/test/integration/ai.test.ts b/test/integration/ai.test.ts index 35dd39e..dde760f 100644 --- a/test/integration/ai.test.ts +++ b/test/integration/ai.test.ts @@ -6,7 +6,7 @@ import { describe, expect, test } from "bun:test"; import { PassThrough } from "node:stream"; import { registerTaskCommands } from "../../src/commands/task/index.js"; -import { type FakeAI, createFakeAI } from "../fixtures/fake-ai.js"; +import { FAKE_HANG, type FakeAI, createFakeAI } from "../fixtures/fake-ai.js"; import { createMockClient } from "../fixtures/mock-client.js"; import { MOCK_CREATE_TREE_RESULT, @@ -257,6 +257,18 @@ describe("task breakdown", () => { ); }); + test("human mode: the preview points out a change of the target's own type", async () => { + // MOCK_TASK is parallel with one existing child; PLAN makes it sequential. + const { stdout } = await runInteractive( + ["task", "breakdown", "Buy groceries"], + ["q\n"], + createFakeAI({ plans: [PLAN] }), + ); + expect(stdout.join("\n")).toContain( + "! Changes the task from parallel to sequential; 1 existing subtask affected", + ); + }); + test("human mode: quitting at the preview changes nothing", async () => { const { client, stdout } = await runInteractive( ["task", "breakdown", "Buy groceries"], @@ -336,6 +348,22 @@ describe("task why", () => { expect(second[3]?.content).toBe("Going to the store"); }); + test("Ctrl-C while the coach is talking aborts the request and ends the session", async () => { + const ai = createFakeAI({ replies: [FAKE_HANG] }); + // The hung stream never prompts, so emit SIGINT once the request is in flight. + const poke = setInterval(() => { + if (ai.requests.length > 0) { + clearInterval(poke); + process.emit("SIGINT"); + } + }, 2); + const { stdout, exitCode } = await runInteractive(["task", "why", "Buy groceries"], [], ai); + clearInterval(poke); + expect(exitCode).toBeUndefined(); + expect(stdout.join("\n")).toContain("Session ended."); + expect(ai.requests).toHaveLength(1); + }); + test("without a ref it opens a general session and never touches OmniFocus", async () => { const ai = createFakeAI({ replies: ["What are you avoiding?"] }); const { client, stdout } = await runInteractive(["task", "why"], ["/quit\n"], ai); diff --git a/test/jxa/task-create-tree.test.ts b/test/jxa/task-create-tree.test.ts index 36f9df7..8927a69 100644 --- a/test/jxa/task-create-tree.test.ts +++ b/test/jxa/task-create-tree.test.ts @@ -96,6 +96,27 @@ describe("task.createTree", () => { expect(parent.sequential).toBe(true); }); + test("the target's sequential flag is set even when it already has children", () => { + // The type is a whole-container property: pre-existing children are governed by + // it too, which is why the CLI preview points such a change out before applying. + const pushed: Created[] = []; + const parent = target(pushed); + parent.sequential = false; + const response = runBridge(docWith(parent), "task.createTree", { + parentId: "p1", + sequential: true, + tasks: [{ key: "1", parentKey: null, name: "New step" }], + }); + expect(response.ok).toBe(true); + expect(parent.sequential).toBe(true); + // Omitting the flag leaves the target untouched. + runBridge(docWith(parent), "task.createTree", { + parentId: "p1", + tasks: [{ key: "1", parentKey: null, name: "Another" }], + }); + expect(parent.sequential).toBe(true); + }); + test("skips the descendants of a failed item instead of reparenting them", () => { const pushed: Created[] = []; const response = runBridge(docWith(target(pushed)), "task.createTree", { From e3c611bc4cd9f991d0d0d64335c99a7ac62f40ce Mon Sep 17 00:00:00 2001 From: Max Boettinger Date: Thu, 3 Sep 2026 11:32:18 +0200 Subject: [PATCH 14/14] feat(ai): default model google/gemini-3.8-flash; ignore .env --- .gitignore | 1 + CHANGELOG.md | 2 +- README.md | 4 ++-- docs/superpowers/specs/2026-09-03-ai-features-design.md | 4 ++-- src/core/ai/config.ts | 2 +- src/core/ai/docs.md | 2 +- 6 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 920874f..e9da52d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist/ of .clawdhub/ +.env diff --git a/CHANGELOG.md b/CHANGELOG.md index 45c790d..f3a4472 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ All notable changes to this project are documented here. The format follows - AI features through [OpenRouter](https://openrouter.ai/) (`OPENROUTER_API_KEY`, optional `~/.config/omnifocus-cli/config.json` with `ai.apiKey`/`ai.model`, `--model` per run, - `$OF_AI_MODEL` globally; default model `google/gemini-2.5-flash`). Nothing else in the CLI + `$OF_AI_MODEL` globally; default model `google/gemini-3.8-flash`). Nothing else in the CLI needs a key. - `task breakdown ` (`of t b`): splits a task into granular, AuDHD-friendly nano subtasks using structured output, with full context (parents, project, existing and diff --git a/README.md b/README.md index 7f57e5a..a13719d 100644 --- a/README.md +++ b/README.md @@ -270,13 +270,13 @@ API key, and nothing else in the CLI does — every other command works without ```bash export OPENROUTER_API_KEY=sk-or-... # or put it in the config file below -export OF_AI_MODEL=openai/gpt-4.1-mini # optional; default is google/gemini-2.5-flash +export OF_AI_MODEL=openai/gpt-4.1-mini # optional; default is google/gemini-3.8-flash ``` Config file: `~/.config/omnifocus-cli/config.json` (`$XDG_CONFIG_HOME` respected): ```json -{ "ai": { "apiKey": "sk-or-...", "model": "google/gemini-2.5-flash" } } +{ "ai": { "apiKey": "sk-or-...", "model": "google/gemini-3.8-flash" } } ``` Precedence is `--model` flag > `$OF_AI_MODEL` > config file > default; the key comes from diff --git a/docs/superpowers/specs/2026-09-03-ai-features-design.md b/docs/superpowers/specs/2026-09-03-ai-features-design.md index e097ddf..abde8b5 100644 --- a/docs/superpowers/specs/2026-09-03-ai-features-design.md +++ b/docs/superpowers/specs/2026-09-03-ai-features-design.md @@ -42,8 +42,8 @@ Prompts are plain Markdown files in one folder, loaded at runtime and overridabl `RequestAbortedError`/`ConnectionError` for transport failures. Attribution headers are `HTTP-Referer` and `X-OpenRouter-Title`. `openrouter/auto`, `:nitro`/`:floor` suffixes and a `models: []` fallback list are supported. `anthropic/claude-sonnet-4` - does not advertise structured outputs; `openai/gpt-4.1-mini`, `google/gemini-2.5-flash` - and `anthropic/claude-sonnet-5` do. Default model: `google/gemini-2.5-flash` (fast, + does not advertise structured outputs; `openai/gpt-4.1-mini`, `google/gemini-3.8-flash` + and `anthropic/claude-sonnet-5` do. Default model: `google/gemini-3.8-flash` (fast, cheap, strict schema support); overridable everywhere. - **Config precedence** in mature AI CLIs (`llm`, `aichat`, `mods`, `fabric`, `sgpt`): flag > env var > config file > built-in default; keys in env or a config file under diff --git a/src/core/ai/config.ts b/src/core/ai/config.ts index 7bbe71f..ac1af8e 100644 --- a/src/core/ai/config.ts +++ b/src/core/ai/config.ts @@ -19,7 +19,7 @@ import { join } from "node:path"; import { AIError } from "./types.js"; /** Cheap, fast, and supports strict JSON-schema output through OpenRouter. */ -export const DEFAULT_MODEL = "google/gemini-2.5-flash"; +export const DEFAULT_MODEL = "google/gemini-3.8-flash"; export const APP_REFERER = "https://github.com/maxboettinger/omnifocus-cli"; export const APP_TITLE = "omnifocus-cli"; diff --git a/src/core/ai/docs.md b/src/core/ai/docs.md index 2788bf3..a39ddb5 100644 --- a/src/core/ai/docs.md +++ b/src/core/ai/docs.md @@ -30,7 +30,7 @@ task/breakdown.ts, task/why.ts (verbs — the only callers of ai.*) ### Core Implementation - **`AIClient`** (`types.ts`) has three methods: `chat()` (one complete reply), `stream(req, onDelta)` (used by `why`'s coaching turns), and `structured(req, schema)` (used by `breakdown`'s plan generation). All three take a `ChatRequest` (`messages`, optional `model`/`temperature`/`maxTokens`/`signal`) — the system prompt is always `messages[0]`. `StructuredSchema` bundles a JSON schema with a `validate(raw)` function returning `{ value: T }` or `{ errors: string[] }`; the schema and its validator travel together so a caller can never send one without the other. `AIError extends CLIError` with a `kind` (`missing-key | auth | credits | rate-limit | bad-request | invalid-response | network | aborted`) callers can branch on — `why.ts` checks for `"aborted"` specifically to distinguish a Ctrl-C from a real failure. -- **`config.ts`**: `resolveAIConfig(overrides?)` resolves `{ apiKey, model, referer, title }` with precedence `--model flag (overrides.model) > $OF_AI_MODEL > config file ai.model > DEFAULT_MODEL` for the model, and `$OPENROUTER_API_KEY > config file ai.apiKey` for the key; a missing key throws `AIError("missing-key", describeAISetup())` before any network call. The config file lives at `configDir()/config.json` — `configDir()` is `$OF_CONFIG_DIR` (test seam) or `$XDG_CONFIG_HOME`/`~/.config` + `omnifocus-cli`, the same directory scheme `short-ids.ts` and `prompts.ts` use — shaped `{"ai": {"apiKey"?, "model"?}}`; a missing or malformed file is silently treated as empty (`readFileConfig()`'s try/catch), not an error. `resolveAIModel(overrides?)` is the key-free half, used for previews/help text. `DEFAULT_MODEL` is `google/gemini-2.5-flash` (fast, cheap, supports strict structured output through OpenRouter). +- **`config.ts`**: `resolveAIConfig(overrides?)` resolves `{ apiKey, model, referer, title }` with precedence `--model flag (overrides.model) > $OF_AI_MODEL > config file ai.model > DEFAULT_MODEL` for the model, and `$OPENROUTER_API_KEY > config file ai.apiKey` for the key; a missing key throws `AIError("missing-key", describeAISetup())` before any network call. The config file lives at `configDir()/config.json` — `configDir()` is `$OF_CONFIG_DIR` (test seam) or `$XDG_CONFIG_HOME`/`~/.config` + `omnifocus-cli`, the same directory scheme `short-ids.ts` and `prompts.ts` use — shaped `{"ai": {"apiKey"?, "model"?}}`; a missing or malformed file is silently treated as empty (`readFileConfig()`'s try/catch), not an error. `resolveAIModel(overrides?)` is the key-free half, used for previews/help text. `DEFAULT_MODEL` is `google/gemini-3.8-flash` (fast, cheap, supports strict structured output through OpenRouter). - **`prompts.ts`**: the two system prompts (`why`, `breakdown`) are plain Markdown files in `@/src/prompts/`, embedded via Bun text imports (`import whyPrompt from "../../prompts/why.md" with { type: "text" }`, declared for the compiler in `@/src/types.d.ts`) so the compiled binary carries them and `bun run dev` re-reads them fresh every run without a rebuild. `loadPrompt(name)` checks `promptsDir()/.md` first (`$OF_PROMPTS_DIR` or `/prompts`) and falls back to the embedded text — an override file that exists but is blank is treated as absent, not as an empty prompt. No templating: prompts stay static text, and everything situational (the task, today's date, the user's `--context`) is sent as the first user message instead, via `context.ts`. - **`context.ts`**: `renderTaskContext(ctx: TaskContext, { today, extra? })` is the one function that turns OmniFocus state into words the model sees, used by both verbs so they describe a task identically. It renders, as Markdown sections: today's date; the target task (name, id, status — open/blocked/completed — sequential/parallel, due/defer/planned, flag, estimate, tags, repeat rule, inbox location, existing-subtask count); the ancestor chain nearest-first; the project (name, status, type, remaining/total tasks, folder, due, note); the existing subtree with `[x]`/`[ ]` checkboxes, estimates, tags and ordering, recursively — explicitly labeled "do not recreate" since this is what stops `breakdown` from re-suggesting work that already exists; siblings, capped at `SIBLING_DISPLAY_LIMIT` (40) with a "…and N more" tail, "for orientation only"; every tag name in the database, labeled as the only tags the model may use; and, when given, the user's free-form `--context` text. Notes are truncated (1500 chars for the target, 200 for everything else) and collapsed to one line. - **`plan.ts`**: the breakdown contract. `Plan` is `{ summary, sequential, tasks: PlanTask[], questions }`; `PlanTask` is deliberately flat (`key`, `parentKey: string | null`, `name`, `note`, `estimateMinutes`, `tags`, `flag`, `sequential`, `due`, `defer`) rather than a nested tree — `PLAN_SCHEMA` is sent to the model as a strict `json_schema` (every property `required`, optional ones nullable via a `type: [T, "null"]` union, `additionalProperties: false`, no `$ref` anywhere), and recursive `$ref` schemas are not portable across OpenRouter's providers in strict mode, so the schema stays flat and the tree is rebuilt afterward in TypeScript. `validatePlan(raw)` is the hand-written runtime validator run against every model response: `tasks` non-empty and ≤ `MAX_PLAN_TASKS` (200), every `key` unique and non-empty, every `parentKey` either `null` or an **earlier** key (never itself, never a forward or unknown reference — this is what makes `buildPlanTree()`'s single left-to-right pass sufficient, no cycle detection needed), names non-empty and ≤ `MAX_TASK_NAME_LENGTH` (200 chars), `estimateMinutes` null or an integer ≥ 1. `PLAN_STRUCTURED` bundles `PLAN_SCHEMA` + `validatePlan` as the `StructuredSchema` handed to `ai.structured()`.