M3b: Augi task-file commands; deprecate Task Dispatch

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 <noreply@anthropic.com>
This commit is contained in:
Chris Lettieri 2026-07-07 08:26:30 -04:00
parent 6e9ed65d40
commit 521399efd3
9 changed files with 607 additions and 52 deletions

View file

@ -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)
---

94
docs/AGENT_TASKS.md Normal file
View file

@ -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.

View file

@ -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<GatheredContext>
---
### 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 |
---

View file

@ -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

View file

@ -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<string>): Promise<void> {
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<void> {
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

View file

@ -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<string> {
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<string> {
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<string> {
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<string> {
// 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
);
}
}

View file

@ -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<void>;
}
}

View file

@ -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')

View file

@ -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');
});
});