From 521399efd3778c6deaaa346b7ca0107054e5de04 Mon Sep 17 00:00:00 2001 From: Chris Lettieri Date: Tue, 7 Jul 2026 08:26:30 -0400 Subject: [PATCH] M3b: Augi task-file commands; deprecate Task Dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add TaskFileService, which writes `status: pending` task files to OpenAugi/Tasks/ via the Obsidian vault API — no shell-out, no HTTP, no Node modules, so it works on mobile too. The file format mirrors the parent repo's templates/task-template.md contract consumed by task_watcher.py. New commands (all platforms): - Augi: Run review pass -> "run the review pass" - Augi: Process dashboard -> "process the dashboard" - Augi: Distill selection -> selection/active-note body becomes the ## Context; distill-lens instruction Deprecate the legacy Task Dispatch feature (it launches tmux itself, bypassing the watcher). Kept functional for community users without the Python watcher; steer new use to task files via docs banner, settings UI label, and a @deprecated marker. To be removed over a release or two. Docs: new docs/AGENT_TASKS.md; README + CODEBASE_MAP updated to lead with the task-file flow. Tests: 18 new, including a watcher-frontmatter-regex compat check and same-second collision handling. Co-Authored-By: Claude Fable 5 --- README.md | 76 ++++++------ docs/AGENT_TASKS.md | 94 +++++++++++++++ docs/CODEBASE_MAP.md | 36 ++++-- docs/TASK_DISPATCH.md | 11 ++ src/main.ts | 78 +++++++++++- src/services/task-file-service.ts | 160 +++++++++++++++++++++++++ src/types/plugin.ts | 5 +- src/ui/settings-tab.ts | 10 +- tests/task-file-service.test.ts | 189 ++++++++++++++++++++++++++++++ 9 files changed, 607 insertions(+), 52 deletions(-) create mode 100644 docs/AGENT_TASKS.md create mode 100644 src/services/task-file-service.ts create mode 100644 tests/task-file-service.test.ts diff --git a/README.md b/README.md index 6c9b2a6..ae11610 100644 --- a/README.md +++ b/README.md @@ -23,15 +23,18 @@ Gather precisely the right context from your vault — not too much, not too lit - **Character budgets** — Stay within token limits with configurable caps - **Checkbox review** — Toggle individual notes on/off before processing -### 2. Task Dispatch +### 2. Agent Tasks -Write a task note, link your context, and launch an agent session directly from Obsidian. +Queue work for your agents without leaving Obsidian. The **Augi** commands write task files to `OpenAugi/Tasks/` — the [OpenAugi task watcher](https://github.com/bitsofchris/openaugi) picks them up and runs an agent session for each. -- **tmux sessions** — Each task gets its own persistent terminal session -- **Context injection** — Task note body + all linked notes are assembled and passed to the agent -- **Named repo paths** — Map short names to directories so `working_dir: my-api` just works -- **Session management** — List, attach to, or kill running agent sessions -- **MCP integration** — Agents can search your vault and write results back to the task note +- **Augi: Run review pass** — Route new blocks, refresh views, surface Dashboard nominations +- **Augi: Process dashboard** — Execute your nomination answers only +- **Augi: Distill selection** — Distill the current selection (or active note) through the distill lens +- **File-based trigger** — Pure vault API, no shell or HTTP, works on Obsidian Mobile with a synced vault + +See [Agent Tasks docs](docs/AGENT_TASKS.md). + +The older **Task Dispatch** feature (launching tmux sessions directly from the plugin) is deprecated in favor of the task-file flow — see [Task Dispatch docs](docs/TASK_DISPATCH.md). ### 3. Note Processing @@ -50,30 +53,13 @@ Turn raw notes into organized, atomic knowledge. 1. Install from Obsidian Community Plugins (or manually) 2. Settings → OpenAugi → Enter your OpenAI API key -3. For task dispatch: install tmux (`brew install tmux`) +3. For agent tasks: install [OpenAugi](https://github.com/bitsofchris/openaugi) and run `openaugi up` (the task watcher) -### Dispatch a Task +### Queue an Agent Task -Create a task note: +Run **Augi: Run review pass** (or **Augi: Process dashboard**, **Augi: Distill selection**) from the command palette. The plugin writes a pending task file to `OpenAugi/Tasks/`; the task watcher launches an agent session that does the work and writes its results back into the task file. -```yaml ---- -task_id: fix-auth-bug -working_dir: my-repo ---- -``` - -```markdown -## Objective - -Fix the authentication bug where sessions expire after 5 minutes. - -## Context - -The auth middleware is in `src/middleware/auth.ts`. [[API Design Doc]] has the spec. -``` - -Run **Task dispatch: Launch or attach** from the command palette. A terminal opens with your agent pre-loaded with context from the note and all linked notes. +For **Augi: Distill selection**, select the text you want distilled first — the selection (or the whole active note if nothing is selected) becomes the task's context. ### Gather Context @@ -89,29 +75,34 @@ Run **Task dispatch: Launch or attach** from the command palette. A terminal ope | Command | Purpose | |---------|---------| +| **Augi: Run review pass** | Queue a full review pass for the task watcher | +| **Augi: Process dashboard** | Queue Dashboard nomination processing for the task watcher | +| **Augi: Distill selection** | Queue a distill of the current selection or active note | | **Process notes** | Gather linked notes → review → distill / publish / save | | **Process recent activity** | Same flow but discovers by recent modification date | | **Save context** | Gather and save raw context (no AI processing) | -| **Task dispatch: Launch or attach** | Launch agent session from task note | -| **Task dispatch: Kill session** | Kill tmux session for current task note | -| **Task dispatch: List active sessions** | View and manage all running agent sessions | +| **Task dispatch: Launch or attach** | Deprecated — launch agent session from task note | +| **Task dispatch: Kill session** | Deprecated — kill tmux session for current task note | +| **Task dispatch: List active sessions** | Deprecated — view and manage running agent sessions | | **Parse transcript** | Process voice transcript into atomic notes | | **Distill linked notes** | Legacy command — use Process notes instead | --- -## Task Dispatch +## Agent Tasks -Task dispatch is the agent harness. It reads a task note, assembles context from linked notes, creates a tmux session, and launches your agent CLI with everything pre-loaded. +The Augi commands are the trigger surface for the OpenAugi agent loop. Each command writes a `status: pending` task file to `OpenAugi/Tasks/`; the task watcher (`openaugi up`) hydrates it, launches a Claude agent session, and the agent writes its results back into the same file. -See [Task Dispatch docs](docs/TASK_DISPATCH.md) for the full reference. +See [Agent Tasks docs](docs/AGENT_TASKS.md) for the full reference, including the task-file format. **Key concepts:** -- `task_id` in frontmatter identifies the task (required) -- `working_dir` sets where the agent runs — supports named repos, absolute paths, or vault-relative paths -- Linked notes (`[[Design Doc]]`, `[[API Spec]]`) are automatically included in context -- Sessions persist across Obsidian restarts — use "List active sessions" to reattach -- Agents can use the OpenAugi MCP to search your vault and write results back +- The vault filesystem is the API — the plugin only writes a file, so the commands work on mobile with a synced vault +- The same contract is shared with the `openaugi review` CLI and the `zzz:` capture grammar +- The task file doubles as the record: results and status land back in it + +### Task Dispatch (deprecated) + +The older agent harness: reads a task note, assembles context from linked notes, creates a tmux session, and launches your agent CLI directly from the plugin. Deprecated in favor of the task-file flow above; it keeps working for now but will be removed over a release or two. See [Task Dispatch docs](docs/TASK_DISPATCH.md). --- @@ -146,7 +137,7 @@ Settings are in **Settings → OpenAugi**. - Include backlinks (default: on) - Journal section filtering -**Task Dispatch:** +**Task Dispatch (deprecated):** - Terminal app (iTerm2 or Terminal.app) - tmux path (auto-detected or manual) - Default working directory @@ -164,8 +155,9 @@ Settings are in **Settings → OpenAugi**. ## Requirements - **OpenAI API key** — Required for AI processing (distill, publish, parse) -- **tmux** — Required for task dispatch (`brew install tmux`) -- **macOS** — Task dispatch terminal opening uses AppleScript (iTerm2 or Terminal.app) +- **OpenAugi task watcher** — Required for the Augi agent-task commands ([openaugi](https://github.com/bitsofchris/openaugi), run `openaugi up`) +- **tmux** — Required for deprecated task dispatch (`brew install tmux`) +- **macOS** — Deprecated task dispatch terminal opening uses AppleScript (iTerm2 or Terminal.app) --- diff --git a/docs/AGENT_TASKS.md b/docs/AGENT_TASKS.md new file mode 100644 index 0000000..e42b07b --- /dev/null +++ b/docs/AGENT_TASKS.md @@ -0,0 +1,94 @@ +--- +name: Agent Tasks (Augi commands) +description: Plugin commands that queue work for the OpenAugi task watcher by writing pending task files to OpenAugi/Tasks/ via the vault API. +--- + +# Agent Tasks (Augi commands) + +**When to use:** You want an agent to run the review pass, process the +Dashboard, or distill a chunk of your writing — triggered from inside +Obsidian, on desktop or mobile. + +**How it works:** The vault filesystem is the API. Each command writes a +markdown task file with `status: pending` frontmatter into +`OpenAugi/Tasks/`. The [OpenAugi task watcher](https://github.com/bitsofchris/openaugi) +(`openaugi up`) polls that folder and launches a Claude agent session for +each pending task. The plugin never shells out and never makes an HTTP +call — it only writes a file, so the same commands work on Obsidian +Mobile against a synced vault. + +The `openaugi review` CLI, the `zzz:` capture grammar, and these commands +all converge on the same task-file contract, defined authoritatively in +the parent repo at `src/openaugi/templates/task-template.md`. + +## Requirements + +- The [OpenAugi](https://github.com/bitsofchris/openaugi) Python package + running its task watcher (`openaugi up`) against your vault. Without it, + task files sit in `OpenAugi/Tasks/` as pending until a watcher picks + them up. +- Agent skill files in your vault under `OpenAugi/AGENT/` (created by + `openaugi init`): `review-pass.md` for the review commands, + `distill-lens.md` for distill. + +## Commands + +| Command | Instruction queued | Scope | +|---------|--------------------|-------| +| **Augi: Run review pass** | `run the review pass` | Full loop: route new blocks → refresh views → Dashboard nominations | +| **Augi: Process dashboard** | `process the dashboard` | Execute Dashboard nomination answers only, no new-block routing | +| **Augi: Distill selection** | `distill this per OpenAugi/AGENT/distill-lens.md` | Current selection, or the active note's body when nothing is selected | + +For **Distill selection**, the selected text (or note body, frontmatter +stripped) is copied verbatim into the task file's `## Context` section — +the plugin acts purely as a scope selector; the agent distills exactly +that content and nothing else. + +## The task file + +Example produced by **Augi: Run review pass**: + +```markdown +--- +status: pending +source_block_id: obsidian-plugin +source_note: "[[OpenAugi plugin]]" +--- + +# run the review pass + +## Context + +Triggered via the OpenAugi plugin command "Augi: Run review pass" at 20260707-143000. + +## User instruction + +> run the review pass + +## Task + +Read OpenAugi/AGENT/review-pass.md and execute: run the review pass. + +## Human Todo + +## Results +``` + +The watcher hydrates the file (adds `task_id`, `created`, +`tmux_session`, flips `status: active`, renames to `TASK-*.md`) and +launches the agent. When the agent finishes it fills in `## Results` and +sets `status: done` — the task file doubles as the record of what +happened. + +No `repo`/`working_dir` key is written, so the watcher defaults the +agent's working directory to the vault — correct for review and distill +work. + +## Relationship to Task Dispatch (deprecated) + +The older [Task Dispatch](TASK_DISPATCH.md) feature launches tmux +sessions directly from the plugin. That is a parallel execution path +that drifts from the watcher (different session names, duplicate repo +settings) and requires desktop + tmux + macOS. It is deprecated and will +be removed over a release or two; use the Augi commands with the task +watcher instead. diff --git a/docs/CODEBASE_MAP.md b/docs/CODEBASE_MAP.md index 63af2d0..944cd51 100644 --- a/docs/CODEBASE_MAP.md +++ b/docs/CODEBASE_MAP.md @@ -13,7 +13,8 @@ A comprehensive reference for navigating and extending this Obsidian plugin. | Link traversal & aggregation | [services/distill-service.ts](../src/services/distill-service.ts) | | Unified context discovery | [services/context-gathering-service.ts](../src/services/context-gathering-service.ts) | | Settings UI | [ui/settings-tab.ts](../src/ui/settings-tab.ts) | -| Task dispatch service | [services/task-dispatch-service.ts](../src/services/task-dispatch-service.ts) | +| Agent task-file writer | [services/task-file-service.ts](../src/services/task-file-service.ts) | +| Task dispatch service (deprecated) | [services/task-dispatch-service.ts](../src/services/task-dispatch-service.ts) | | Task dispatch types | [types/task-dispatch.ts](../src/types/task-dispatch.ts) | | Context modals | [ui/context-gathering-modal.ts](../src/ui/context-gathering-modal.ts), [ui/context-selection-modal.ts](../src/ui/context-selection-modal.ts), [ui/context-preview-modal.ts](../src/ui/context-preview-modal.ts) | | Session list modal | [ui/session-list-modal.ts](../src/ui/session-list-modal.ts) | @@ -35,7 +36,8 @@ src/ │ ├── file-service.ts # File creation & output │ ├── distill-service.ts # Content aggregation, link traversal │ ├── context-gathering-service.ts # Unified discovery orchestration -│ └── task-dispatch-service.ts # tmux session & agent dispatch +│ ├── task-file-service.ts # Pending task files → OpenAugi/Tasks/ (watcher trigger) +│ └── task-dispatch-service.ts # tmux session & agent dispatch (deprecated) ├── types/ │ ├── plugin.ts # Plugin interface │ ├── settings.ts # Settings interfaces & defaults @@ -159,9 +161,26 @@ gatherContext(config: ContextGatheringConfig): Promise --- -### TaskDispatchService (`task-dispatch-service.ts`) +### TaskFileService (`task-file-service.ts`) -Manages tmux-based agent sessions dispatched from task notes. +Writes `status: pending` task files to `OpenAugi/Tasks/` via the vault API — the trigger contract consumed by the OpenAugi task watcher (`openaugi up` in the parent repo). No Node.js modules, so it works on mobile. See [AGENT_TASKS.md](AGENT_TASKS.md). + +**Key Methods:** +- `createReviewPassTask()` - Queue "run the review pass" +- `createProcessDashboardTask()` - Queue "process the dashboard" +- `createDistillTask(context, sourceNote)` - Queue a distill of the given content (selection or note body) + +**Contract:** +- File format defined in the parent repo's `src/openaugi/templates/task-template.md` +- Frontmatter: `status: pending`, `source_block_id: obsidian-plugin`, `source_note` +- Sections: `# title`, `## Context`, `## User instruction`, `## Task`, `## Human Todo`, `## Results` +- No `repo`/`working_dir` key — the watcher defaults the agent to the vault + +--- + +### TaskDispatchService (`task-dispatch-service.ts`) — DEPRECATED + +Manages tmux-based agent sessions dispatched from task notes. Deprecated: it launches tmux itself, bypassing the task watcher. Kept functional for existing users; to be removed over a release or two. Prefer `TaskFileService`. **Key Methods:** - `launchOrAttach(file)` - If tmux session exists, attach; otherwise assemble context, create session, start agent @@ -288,9 +307,12 @@ Commands are registered in `main.ts`: | `openaugi-process-notes` | Process notes | Unified flow: linked notes | | `openaugi-process-recent` | Process recent activity | Unified flow: recent activity | | `openaugi-save-context` | Save context | Save raw aggregated content | -| `task-dispatch-launch` | Task dispatch: Launch or attach | Launch or attach to agent tmux session | -| `task-dispatch-kill` | Task dispatch: Kill session | Kill active tmux session for task note | -| `task-dispatch-list` | Task dispatch: List active sessions | Show modal of all active task sessions | +| `augi-run-review-pass` | Augi: Run review pass | Write pending task file: "run the review pass" | +| `augi-process-dashboard` | Augi: Process dashboard | Write pending task file: "process the dashboard" | +| `augi-distill-selection` | Augi: Distill selection | Write pending distill task for selection/active note | +| `task-dispatch-launch` | Task dispatch: Launch or attach | Deprecated — launch or attach to agent tmux session | +| `task-dispatch-kill` | Task dispatch: Kill session | Deprecated — kill active tmux session for task note | +| `task-dispatch-list` | Task dispatch: List active sessions | Deprecated — show modal of all active task sessions | --- diff --git a/docs/TASK_DISPATCH.md b/docs/TASK_DISPATCH.md index 8194259..b6f9523 100644 --- a/docs/TASK_DISPATCH.md +++ b/docs/TASK_DISPATCH.md @@ -1,5 +1,16 @@ # Task Dispatch +> **⚠️ Deprecated.** Task Dispatch launches tmux sessions directly from the +> plugin — a parallel execution path that bypasses the OpenAugi task +> watcher and will drift from it (different session names, duplicate repo +> settings). It still works, but it will be removed over a release or two. +> New workflows should use the **Augi commands** ([Agent Tasks](AGENT_TASKS.md)), +> which write task files to `OpenAugi/Tasks/` for the task watcher to +> execute — no tmux or macOS dependency in the plugin, and they work on +> mobile. If you rely on Task Dispatch without the Python watcher, nothing +> breaks today; plan to install [OpenAugi](https://github.com/bitsofchris/openaugi) +> and run `openaugi up` before it is removed. + Task Dispatch lets you launch AI coding agents directly from Obsidian task notes. Each task gets its own tmux session with full context from your vault — the task note body, all linked notes, and access to the OpenAugi MCP server for searching your vault. ## Prerequisites diff --git a/src/main.ts b/src/main.ts index cca22ea..a253405 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,7 @@ import { OpenAIService } from './services/openai-service'; import { FileService } from './services/file-service'; import { DistillService } from './services/distill-service'; import { ContextGatheringService } from './services/context-gathering-service'; +import { TaskFileService, stripFrontmatter } from './services/task-file-service'; // Lazy-imported: TaskDispatchService uses Node.js modules (child_process, fs, path) // that are unavailable on mobile. We dynamic-import it only on desktop. import type { TaskDispatchService } from './services/task-dispatch-service'; @@ -34,6 +35,7 @@ export default class OpenAugiPlugin extends Plugin { fileService: FileService; distillService: DistillService; contextGatheringService: ContextGatheringService; + taskFileService: TaskFileService; taskDispatchService: TaskDispatchService | null; loadingIndicator: LoadingIndicator; @@ -118,7 +120,37 @@ export default class OpenAugiPlugin extends Plugin { } }); - // Task dispatch commands — desktop only + // Augi task-file commands — write a pending task to OpenAugi/Tasks/ + // for the OpenAugi task watcher to pick up. Pure vault API, works on + // mobile too. These supersede the deprecated Task Dispatch commands. + this.addCommand({ + id: 'augi-run-review-pass', + name: 'Augi: Run review pass', + callback: async () => { + await this.queueTaskFile(() => this.taskFileService.createReviewPassTask()); + } + }); + + this.addCommand({ + id: 'augi-process-dashboard', + name: 'Augi: Process dashboard', + callback: async () => { + await this.queueTaskFile(() => this.taskFileService.createProcessDashboardTask()); + } + }); + + this.addCommand({ + id: 'augi-distill-selection', + name: 'Augi: Distill selection', + callback: async () => { + await this.distillSelection(); + } + }); + + // Task dispatch commands — desktop only. + // DEPRECATED: these launch tmux directly from the plugin, bypassing the + // task watcher. Kept working for existing users; will be removed over a + // release or two. New workflows should use the Augi commands above. if (!Platform.isMobile) { this.addCommand({ id: 'task-dispatch-launch', @@ -211,6 +243,7 @@ export default class OpenAugiPlugin extends Plugin { this.distillService, this.settings ); + this.taskFileService = new TaskFileService(this.app); // TaskDispatchService uses Node.js modules (child_process, fs, path) // unavailable on mobile. Skip entirely on mobile. @@ -230,6 +263,49 @@ export default class OpenAugiPlugin extends Plugin { } } + /** + * Run a task-file writer and surface the result as a Notice. + * @param write Callback that writes the task file and returns its path + */ + private async queueTaskFile(write: () => Promise): Promise { + try { + const path = await write(); + const filename = path.split('/').pop(); + new Notice(`Task queued: ${filename}\nThe OpenAugi task watcher will pick it up.`); + } catch (error) { + console.error('Failed to write task file:', error); + new Notice('Failed to write task file. Check console for details.'); + } + } + + /** + * Queue a distill task for the current selection, or the active note's + * body when nothing is selected. The chosen text becomes the task's + * ## Context section — the plugin acts as the scope selector. + */ + private async distillSelection(): Promise { + const activeFile = this.app.workspace.getActiveFile(); + const selection = this.app.workspace.activeEditor?.editor?.getSelection()?.trim() ?? ''; + + let context = selection; + if (!context) { + if (!activeFile || activeFile.extension !== 'md') { + new Notice('Select text or open a markdown note to distill'); + return; + } + const raw = await this.app.vault.read(activeFile); + context = stripFrontmatter(raw).trim(); + } + + if (!context) { + new Notice('Nothing to distill — the note is empty'); + return; + } + + const sourceNote = activeFile?.basename ?? 'OpenAugi plugin'; + await this.queueTaskFile(() => this.taskFileService.createDistillTask(context, sourceNote)); + } + /** * Open a file in a new tab * @param filePath The path to the file to open diff --git a/src/services/task-file-service.ts b/src/services/task-file-service.ts new file mode 100644 index 0000000..cbd4f39 --- /dev/null +++ b/src/services/task-file-service.ts @@ -0,0 +1,160 @@ +import { App } from 'obsidian'; +import { createFileWithCollisionHandling } from '../utils/filename-utils'; + +/** + * TaskFileService — writes `status: pending` task files to OpenAugi/Tasks/ + * via the Obsidian vault API. No shell-out, no HTTP, mobile-safe. + * + * The task file IS the trigger contract: the OpenAugi task watcher + * (`openaugi up` in the parent repo) polls OpenAugi/Tasks/ for pending + * files and launches an agent session for each. This service, the + * `openaugi review` CLI, and the zzz capture grammar all converge on the + * same file format, defined authoritatively in the parent repo at + * `src/openaugi/templates/task-template.md`. + */ + +/** Where the task watcher looks for pending task files (vault-relative). */ +export const TASKS_FOLDER = 'OpenAugi/Tasks'; + +/** Frontmatter marker identifying tasks written by this plugin. */ +export const SOURCE_BLOCK_ID = 'obsidian-plugin'; + +export interface TaskFileOptions { + /** Human-readable task title — becomes the `# heading` and filename slug. */ + title: string; + /** Verbatim content for the `## Context` section. */ + context: string; + /** The literal user directive, block-quoted under `## User instruction`. */ + instruction: string; + /** Self-contained description for the `## Task` section (the agent's real prompt). */ + task: string; + /** Wikilink target for `source_note` frontmatter (note title, no brackets). */ + sourceNote: string; +} + +/** Strip YAML frontmatter from note content, if present. */ +export function stripFrontmatter(content: string): string { + return content.replace(/^---\n[\s\S]*?\n---\n?/, ''); +} + +/** Lowercase, filename-safe slug for the task filename. */ +export function taskFileSlug(title: string, maxLen = 50): string { + const slug = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + return slug.slice(0, maxLen).replace(/-+$/, '') || 'task'; +} + +/** YYYYMMDD-HHMMSS local timestamp, matching the `openaugi review` CLI. */ +export function taskFileTimestamp(now: Date = new Date()): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return ( + `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` + + `-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}` + ); +} + +/** + * Build the full task-file markdown per the task-template contract. + * Section order matters: the watcher hydrates around `## Results` and the + * agent treats `## Task` as its prompt. + */ +export function buildTaskFileContent(opts: TaskFileOptions): string { + return `--- +status: pending +source_block_id: ${SOURCE_BLOCK_ID} +source_note: "[[${opts.sourceNote}]]" +--- + +# ${opts.title} + +## Context + +${opts.context.trim()} + +## User instruction + +> ${opts.instruction} + +## Task + +${opts.task.trim()} + +## Human Todo + +## Results +`; +} + +export class TaskFileService { + constructor(private app: App) {} + + /** + * Queue a full review pass. The watcher-launched agent reads + * OpenAugi/AGENT/review-pass.md and runs the whole loop. + */ + async createReviewPassTask(now: Date = new Date()): Promise { + const instruction = 'run the review pass'; + return this.writeTask({ + title: instruction, + context: `Triggered via the OpenAugi plugin command "Augi: Run review pass" at ${taskFileTimestamp(now)}.`, + instruction, + task: `Read OpenAugi/AGENT/review-pass.md and execute: ${instruction}.`, + sourceNote: 'OpenAugi plugin', + }, now); + } + + /** + * Queue dashboard processing only — execute nomination answers, + * no new-block routing. + */ + async createProcessDashboardTask(now: Date = new Date()): Promise { + const instruction = 'process the dashboard'; + return this.writeTask({ + title: instruction, + context: `Triggered via the OpenAugi plugin command "Augi: Process dashboard" at ${taskFileTimestamp(now)}.`, + instruction, + task: `Read OpenAugi/AGENT/review-pass.md and execute: ${instruction}.`, + sourceNote: 'OpenAugi plugin', + }, now); + } + + /** + * Queue a distill task for the given content (current selection or + * active note body). The content goes into `## Context` verbatim; the + * agent applies the distill lens to exactly that scope. + */ + async createDistillTask(context: string, sourceNote: string, now: Date = new Date()): Promise { + return this.writeTask({ + title: `distill ${sourceNote}`, + context, + instruction: 'distill this per OpenAugi/AGENT/distill-lens.md', + task: + 'Read OpenAugi/AGENT/distill-lens.md and apply the distill lens to the ' + + 'content in the ## Context section above. That content is the entire ' + + 'scope — do not pull in additional notes.', + sourceNote, + }, now); + } + + /** Ensure OpenAugi/Tasks/ exists and write the task file into it. */ + private async writeTask(opts: TaskFileOptions, now: Date): Promise { + // Create ancestors one level at a time — createFolder is not + // guaranteed to be recursive on all platforms. + let folder = ''; + for (const segment of TASKS_FOLDER.split('/')) { + folder = folder ? `${folder}/${segment}` : segment; + if (!(await this.app.vault.adapter.exists(folder))) { + await this.app.vault.createFolder(folder); + } + } + const filename = `${taskFileSlug(opts.title)}-${taskFileTimestamp(now)}.md`; + const content = buildTaskFileContent(opts); + return createFileWithCollisionHandling( + this.app.vault, + `${TASKS_FOLDER}/${filename}`, + content + ); + } +} diff --git a/src/types/plugin.ts b/src/types/plugin.ts index 378d683..fb5787e 100644 --- a/src/types/plugin.ts +++ b/src/types/plugin.ts @@ -3,11 +3,14 @@ import { OpenAugiSettings } from './settings'; import { OpenAIService } from '../services/openai-service'; import { FileService } from '../services/file-service'; import type { TaskDispatchService } from '../services/task-dispatch-service'; +import { TaskFileService } from '../services/task-file-service'; export default interface OpenAugiPlugin extends Plugin { settings: OpenAugiSettings; openAIService: OpenAIService; fileService: FileService; + taskFileService: TaskFileService; + /** @deprecated Task Dispatch bypasses the task watcher — use TaskFileService. */ taskDispatchService: TaskDispatchService | null; saveSettings(): Promise; -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/ui/settings-tab.ts b/src/ui/settings-tab.ts index d4e8005..ccd162d 100644 --- a/src/ui/settings-tab.ts +++ b/src/ui/settings-tab.ts @@ -354,7 +354,15 @@ export class OpenAugiSettingTab extends PluginSettingTab { ); // Task Dispatch Settings Header - containerEl.createEl('h3', { text: 'Task Dispatch' }); + containerEl.createEl('h3', { text: 'Task Dispatch (deprecated)' }); + containerEl.createEl('p', { + text: 'Task Dispatch launches tmux sessions directly from the plugin and is ' + + 'deprecated — it will be removed in a future release. Prefer the ' + + '"Augi" commands (Run review pass, Process dashboard, Distill selection), ' + + 'which write task files to OpenAugi/Tasks/ for the OpenAugi task watcher ' + + 'to execute. The settings below only affect the legacy Task dispatch commands.', + cls: 'setting-item-description' + }); new Setting(containerEl) .setName('Terminal application') diff --git a/tests/task-file-service.test.ts b/tests/task-file-service.test.ts new file mode 100644 index 0000000..da2f29d --- /dev/null +++ b/tests/task-file-service.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + TaskFileService, + TASKS_FOLDER, + buildTaskFileContent, + stripFrontmatter, + taskFileSlug, + taskFileTimestamp, +} from '../src/services/task-file-service'; +import { createMockApp, MockApp } from './mocks/obsidian-mock'; + +// Mirrors FRONTMATTER_RE in the parent repo's task_watcher.py — the reader +// side of the task-file contract. If a file doesn't match this, the watcher +// never sees its frontmatter. +const WATCHER_FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---\s*\n/; + +// ─── Pure helpers ──────────────────────────────────────────────────────────── + +describe('stripFrontmatter', () => { + it('removes a leading frontmatter block', () => { + expect(stripFrontmatter('---\nfoo: bar\n---\nBody text.')).toBe('Body text.'); + }); + + it('leaves content without frontmatter untouched', () => { + expect(stripFrontmatter('Just a note.')).toBe('Just a note.'); + }); + + it('does not strip --- separators mid-document', () => { + const content = 'Intro.\n\n---\n\nMore.'; + expect(stripFrontmatter(content)).toBe(content); + }); +}); + +describe('taskFileSlug', () => { + it('lowercases and hyphenates', () => { + expect(taskFileSlug('run the review pass')).toBe('run-the-review-pass'); + }); + + it('strips special characters', () => { + expect(taskFileSlug('distill Journal 2026-07-07!')).toBe('distill-journal-2026-07-07'); + }); + + it('truncates long titles without a trailing hyphen', () => { + const slug = taskFileSlug('a'.repeat(45) + ' bcdef ghijk'); + expect(slug.length).toBeLessThanOrEqual(50); + expect(slug.endsWith('-')).toBe(false); + }); + + it('falls back to "task" for empty input', () => { + expect(taskFileSlug('!!!')).toBe('task'); + }); +}); + +describe('taskFileTimestamp', () => { + it('formats as YYYYMMDD-HHMMSS', () => { + const ts = taskFileTimestamp(new Date(2026, 6, 7, 9, 5, 3)); + expect(ts).toBe('20260707-090503'); + }); +}); + +// ─── buildTaskFileContent ──────────────────────────────────────────────────── + +describe('buildTaskFileContent', () => { + const content = buildTaskFileContent({ + title: 'run the review pass', + context: 'Triggered via the plugin.', + instruction: 'run the review pass', + task: 'Read OpenAugi/AGENT/review-pass.md and execute: run the review pass.', + sourceNote: 'OpenAugi plugin', + }); + + it('produces frontmatter the watcher can parse, with status pending', () => { + const match = content.match(WATCHER_FRONTMATTER_RE); + expect(match).toBeTruthy(); + expect(match![1]).toContain('status: pending'); + expect(match![1]).toContain('source_block_id: obsidian-plugin'); + expect(match![1]).toContain('source_note: "[[OpenAugi plugin]]"'); + }); + + it('contains all template sections in order', () => { + const sections = [ + '# run the review pass', + '## Context', + '## User instruction', + '## Task', + '## Human Todo', + '## Results', + ]; + let lastIndex = -1; + for (const section of sections) { + const idx = content.indexOf(section); + expect(idx, `missing section: ${section}`).toBeGreaterThan(lastIndex); + lastIndex = idx; + } + }); + + it('block-quotes the user instruction', () => { + expect(content).toContain('> run the review pass'); + }); + + it('keeps the context verbatim under ## Context', () => { + const multiline = buildTaskFileContent({ + title: 't', + context: 'Line one.\n\n- bullet\n- another', + instruction: 'i', + task: 'do it', + sourceNote: 'Note', + }); + expect(multiline).toContain('## Context\n\nLine one.\n\n- bullet\n- another\n\n## User instruction'); + }); +}); + +// ─── TaskFileService (fs-backed vault) ─────────────────────────────────────── + +describe('TaskFileService', () => { + let vaultRoot: string; + let app: MockApp; + let service: TaskFileService; + const now = new Date(2026, 6, 7, 14, 30, 0); + + beforeEach(() => { + vaultRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'openaugi-taskfile-')); + app = createMockApp(vaultRoot); + service = new TaskFileService(app as any); + }); + + afterEach(() => { + fs.rmSync(vaultRoot, { recursive: true, force: true }); + }); + + function readTask(relPath: string): string { + return fs.readFileSync(path.join(vaultRoot, relPath), 'utf-8'); + } + + it('creates the OpenAugi/Tasks folder if missing', async () => { + expect(fs.existsSync(path.join(vaultRoot, TASKS_FOLDER))).toBe(false); + await service.createReviewPassTask(now); + expect(fs.existsSync(path.join(vaultRoot, TASKS_FOLDER))).toBe(true); + }); + + it('writes a pending review-pass task with the expected filename', async () => { + const taskPath = await service.createReviewPassTask(now); + + expect(taskPath).toBe(`${TASKS_FOLDER}/run-the-review-pass-20260707-143000.md`); + const content = readTask(taskPath); + expect(content.match(WATCHER_FRONTMATTER_RE)![1]).toContain('status: pending'); + expect(content).toContain('> run the review pass'); + expect(content).toContain('Read OpenAugi/AGENT/review-pass.md and execute: run the review pass.'); + }); + + it('writes a process-dashboard task with its own instruction', async () => { + const taskPath = await service.createProcessDashboardTask(now); + + expect(taskPath).toBe(`${TASKS_FOLDER}/process-the-dashboard-20260707-143000.md`); + const content = readTask(taskPath); + expect(content).toContain('> process the dashboard'); + expect(content).toContain('Read OpenAugi/AGENT/review-pass.md and execute: process the dashboard.'); + }); + + it('writes a distill task with the given content as ## Context', async () => { + const selection = 'Idea one about capture.\n\nIdea two about routing.'; + const taskPath = await service.createDistillTask(selection, 'Journal 2026-07-07', now); + + expect(taskPath).toBe(`${TASKS_FOLDER}/distill-journal-2026-07-07-20260707-143000.md`); + const content = readTask(taskPath); + expect(content).toContain('source_note: "[[Journal 2026-07-07]]"'); + expect(content).toContain(`## Context\n\n${selection}\n\n## User instruction`); + expect(content).toContain('> distill this per OpenAugi/AGENT/distill-lens.md'); + }); + + it('does not overwrite when two tasks land in the same second', async () => { + const first = await service.createReviewPassTask(now); + const second = await service.createReviewPassTask(now); + + expect(second).not.toBe(first); + expect(fs.existsSync(path.join(vaultRoot, first))).toBe(true); + expect(fs.existsSync(path.join(vaultRoot, second))).toBe(true); + }); + + it('never sets a repo/working_dir key — the watcher defaults to the vault', async () => { + const taskPath = await service.createReviewPassTask(now); + const frontmatter = readTask(taskPath).match(WATCHER_FRONTMATTER_RE)![1]; + expect(frontmatter).not.toContain('repo:'); + expect(frontmatter).not.toContain('working_dir'); + }); +});