Skill: dcprevere/org-cli (version 1.0.2)

--- name: org-cli version: 1.0.2 description: "Task capture, scheduling, and linked knowledge in org-mode files via the `org` CLI. Query, mutate, link, and search the user's org files and org-roam database." metadata: {"openclaw":{"emoji":"🦄","homepage":"https://github.com/dcprevere/org-cli","requires":{"bins":["org"],"env":["ORG_CLI_DIR","ORG_CLI_ROAM_DIR","ORG_CLI_DB","ORG_CLI_BIN","ORG_CLI_INBOX_FILE"]},"install":[{"kind":"download","label":"Download from GitHub releases: https://github.com/dcprevere/org-cli/releases"}],"scope":{"reads":["$ORG_CLI_DIR","$ORG_CLI_ROAM_DIR","$ORG_CLI_DB"],"writes":["$ORG_CLI_DIR","$ORG_CLI_ROAM_DIR","$ORG_CLI_DB"]}}} --- # org-cli Maintain structured, linked, human-readable knowledge in org-mode files via the `org` CLI. Org files combine plain text with rich structure — headlines, TODO states, tags, properties, timestamps, links — and back onto a SQLite knowledge graph via org-roam. This skill teaches the agent to manage the user's org setup on their behalf: capture tasks, schedule and close them, store knowledge in the user's roam graph, search across everything. One workspace, one store. If you also want the agent to persist *its own* memory in org, install the `org-memory` skill on top. ## The prime directive **Everything persistent goes in org.** Tasks, notes, facts, preferences, relationships, decisions — if the user says it and it has lasting value, it lives in an org file. - Never hold long-term information in chat context alone. - Never reply "got it" without a corresponding write when a write is warranted. - If a mutation fails, retry or surface the error — do not silently drop it. **Always surface the ID when you mention an item.** When you create, find, reference, or list anything in org, include its short CUSTOM_ID (or UUID for roam nodes) in your reply so the user can act on it by ID. If an item has no ID (index missing — see Stable identifiers), say so and offer to backfill. After every write, print exactly: ``` org-cli: <action> [<id>] <file-path> ``` Examples: ``` org-cli: added TODO [k4t] ~/org/inbox.org org-cli: added note [k4t] ~/org/inbox.org org-cli: marked DONE [k4t] ~/org/inbox.org org-cli: created node [3f2a-…] ~/org/roam/sarah.org ``` If the response JSON has no `custom_id` (and no `id` for roam nodes), print `[no-id]` and flag it. No silent writes. Ever. ## Shortcuts When the user types these prefixes, act immediately. | Action | Prefix | Target | Does | |---|---|---|---| | `n` note | `n:` | inbox | Create plain headline (no TODO, no date) | | `k` knowledge | `k:` | roam | Store/update a knowledge node | | `f` find | `f:` | all | Full-text search across headlines and roam nodes, return IDs | | `t` todo | `t:` | inbox | Create TODO, extract any date | | `s` schedule | `s:` | workspace | Reschedule matching TODO | | `d` done | `d:` | workspace | Mark matching TODO DONE | `t` vs `n`: `t` creates a TODO (appears in TODO lists, can be scheduled/done). `n` creates a plain captured headline — a thought parked under inbox, no TODO state. Use `n` when the user is noting something for themselves, not committing to action. A dateless TODO is still `t:` with no date in the text; it just lands without `SCHEDULED`/`DEADLINE`. ## Action details In the examples below, `$DIR` is `$ORG_CLI_DIR`, `$ROAM` is `$ORG_CLI_ROAM_DIR`, and `$DB` is `$ORG_CLI_DB`. ### `n` — create a plain note A plain headline, no TODO keyword, no date. Does not appear in TODO lists. Use for captured thoughts, ideas, observations the user wants parked. ```bash org add "$DIR/inbox.org" '<text>' --db "$DB" -f json ``` If the user later wants to promote it to a TODO, use the ID: `org todo set <id> TODO -d "$DIR" --db "$DB"`. ### `k` — knowledge in the roam graph Store in the user's roam graph. Never create duplicates. 1. Find: `org roam node find '<subject>' -d "$ROAM" --db "$DB" -f json` 2. Exists → `org append <custom_id> '<fact>' -d "$DIR" --db "$DB" -f json` 3. New → `org roam node create '<subject>' -d "$ROAM" --db "$DB" -f json` then append. Roam nodes live in `$ROAM`, never in the workspace root. Roam nodes are identified by UUID (`data.id`); headlines inside them by CUSTOM_ID (`data.custom_id`). Surface whichever applies: `Noted against Sarah [3f2a-…]: prefers morning meetings.` ### `f` — find across headlines and knowledge Search the user's org files and roam graph, return matches with IDs. No mutation. Matches any headline — TODO, done, or plain — and any roam node. 1. `org fts '<query>' -d "$DIR" --db "$DB" -f json` (full-text across titles and bodies) 2. `org roam node find '<query>' -d "$ROAM" --db "$DB" -f json` 3. Merge results, group by kind, print as `[<id>] <title>` lines. If the user is specifically asking for TODOs only, use `org todo list --search '<query>' -d "$DIR" --db "$DB" -f json` instead. If they're asking for a headline by exact title or a filter like tag/state, use `org headlines` with the relevant flags. Return the list even when empty — say "no matches" rather than retrying silently. ### `t` — create a TODO Extract any relative or absolute date from the text. Use `--deadline` for hard dates ("by Friday", "due March 1st"); `--scheduled` for softer timing ("in 3 weeks", "next month"). If no date, omit both — the TODO is still created, just without a date. ```bash org add "$DIR/inbox.org" '<title>' --todo TODO [--scheduled <date> | --deadline <date>] --db "$DB" -f json ``` - `t: submit taxes in 3 weeks` → `--scheduled 2026-05-09` - `t: renew passport by June` → `--deadline 2026-06-01` - `t: call dentist tomorrow` → `--scheduled 2026-04-19` - `t: book flights` → no date flag Read `data.custom_id` from the JSON response and include it in your reply. Example: `Added TODO [k4t]: Submit taxes, scheduled 2026-05-09.` ### `s` — reschedule Same search flow as `d`, then: ```bash org schedule <custom_id> <date> -d "$DIR" --db "$DB" -f json ``` ### `d` — mark DONE 1. Search: `org todo list --search '<text>' -d "$DIR" --db "$DB" -f json` 2. One match → `org todo set <custom_id> DONE -d "$DIR" --db "$DB" -f json` 3. Multiple → show each as `[<custom_id>] <title>` so the user can pick by ID. Ask which. 4. None → say so. If you searched with `--state TODO` and got nothing, retry without it (the match may be in another state). Reply with the ID: `Marked DONE [k4t]: Submit taxes.` ## Ambient capture When the user mentions a durable fact in passing — a preference, a relationship, a date, a constraint — offer to save it. Complete the explicit request first; then say "I'd like to note: <X>" and wait for confirmation. Bias toward capturing. Under-capture is worse than over-capture: you can always prune, but you can't recover what was never written. ## Stable identifiers Every headline added via `org add` gets a short CUSTOM_ID (e.g. `k4t`) — **but only when the index DB exists.** If you ran the first-time setup, it does. If a headline was created by hand, by `batch`, or by `add` before the index was built, it won't have one. Every roam node created via `org roam node create` gets a UUID `:ID:` unconditionally. Nodes also carry a CUSTOM_ID on their file-level headline if the index exists. Use IDs in subsequent commands — no file path needed: ```bash org todo set k4t DONE -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json org schedule k4t 2026-05-01 -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json org append k4t 'Note' -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json ``` Preference order: CUSTOM_ID > org-id (UUID) > exact title. **Never use `pos`** — it changes on every edit. For multiple mutations in one file, use `org batch`. **Always include the ID when you mention an item to the user.** Read `data.custom_id` from any JSON response; for roam nodes, also read `data.id`. Echo it in brackets: `[k4t]` or `[3f2a-…]`. This is how the user follows up — "reschedule k4t to Friday" is only possible if you told them the ID existed. If a response has no `custom_id`, tell the user: "This item has no short ID — run `org custom-id assign -d <dir> --db <db>` to backfill, or refer by title." Never silently proceed with just a title — the next lookup may match the wrong item. Backfill missing IDs: `org custom-id assign -d <dir> --db <db>`. ## Output All commands accept `-f json`. Always pass it. Envelopes: - Success: `{"ok":true,"data":...}` - Error: `{"ok":false,"error":{"type":"...","message":"..."}}` Branch on `ok`. Handle by `type`: `file_not_found`, `headline_not_found` (re-query), `parse_error` (don't retry), `invalid_args` (check `org schema`). ## Command safety **Only environment variable paths go in double quotes. User text always goes in single quotes.** Double quotes expand `$(...)`, backticks, and variables — that's shell injection when the text came from the user. ```bash # Right org add "$ORG_CLI_DIR/inbox.org" 'User provided text' --todo TODO -f json # Wrong — user text in double quotes is an injection vector org add "$ORG_CLI_DIR/inbox.org" "User provided text" --todo TODO -f json ``` Embedded single quote → `'\''`. Multi-line → pipe via stdin: ```bash printf '%s' 'Long text' | org append k4t --stdin -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json ``` (If you are calling the org-cli OpenClaw plugin's `org_*` tools instead of shelling out, pass raw text — those tools use `execFile` and do no shell interpolation.) Never bypass `org` for org-file mutations — no `echo >>`, no direct file edits, no `sed`/`awk` rewrites. The CLI maintains formatting, timestamps, index sync, and ID allocation; ad-hoc edits silently break all of that. ## Configuration Required: | Variable | Default | Purpose | |---|---|---| | `ORG_CLI_DIR` | `~/org` | User's workspace (inbox.org, tasks, projects) | | `ORG_CLI_DB` | `$ORG_CLI_DIR/.org.db` | SQLite database (roam + index) | Optional: | Variable | Default | Purpose | |---|---|---| | `ORG_CLI_ROAM_DIR` | `$ORG_CLI_DIR/roam` | Roam nodes | | `ORG_CLI_BIN` | `org` | Path to the `org` binary | | `ORG_CLI_INBOX_FILE` | `inbox.org` | Inbox filename | Always pass `--db`. Without it, the CLI defaults to `<directory>/.org.db`, which may diverge from configured locations. ## First-time setup ```bash # Sync existing files (skip if starting fresh) org roam sync -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" # Build the headline index — enables CUSTOM_IDs and file-less commands org index -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" ``` ## Discovery `org schema` dumps the full command catalog as JSON. Use it to construct commands without memorising flags. Any mutation can be previewed with `--dry-run` — the command runs its logic and reports what *would* change without touching the file. Use it when the user asks to preview, when a batch is large, or when you're uncertain which headline will match. ## Useful commands Read-only queries the shortcuts don't cover directly. All accept `-f json`. **Today / agenda** — "what do I need to do": ```bash org today -d "$DIR" --db "$DB" -f json # TODOs due today + overdue org agenda today -d "$DIR" --db "$DB" -f json # scheduled + deadlines + overdue for today org agenda week -d "$DIR" --db "$DB" -f json # next 7 days org agenda todo --state TODO -d "$DIR" --db "$DB" -f json # every open TODO ``` **TODO list filters** — narrower than `f:`: ```bash org todo list --state TODO --unscheduled -d "$DIR" --db "$DB" -f json # TODOs with no date org todo list --state TODO --overdue -d "$DIR" --db "$DB" -f json # past their scheduled/deadline org todo list --state TODO --due-before 2026-05-01 -d "$DIR" --db "$DB" -f json org todo list --state TODO --tag work -d "$DIR" --db "$DB" -f json org todo list --state TODO --priority A -d "$DIR" --db "$DB" -f json org todo list --state TODO --sort priority -d "$DIR" --db "$DB" -f json ``` **Headlines** — any headline (TODO, done, or plain), filter by tag/level/property: ```bash org headlines --todo TODO -d "$DIR" --db "$DB" -f json org headlines --tag project -d "$DIR" --db "$DB" -f json org headlines --property CATEGORY=admin -d "$DIR" --db "$DB" -f json ``` **Roam** — list, get, sync: ```bash org roam node list -d "$ROAM" --db "$DB" -f json org roam node get <uuid> -d "$ROAM" --db "$DB" -f json org roam sync -d "$DIR" --db "$DB" ``` ## References Read on demand: - **Task management** (`{baseDir}/references/task-management.md`) — for batch ops or translating natural language to queries. - **Knowledge management** (`{baseDir}/references/knowledge-management.md`) — when working with the roam graph.

path: plugin/index.test.ts
content: import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { join } from "node:path";
import { homedir } from "node:os";
import {
  resolveConfig,
  formatAddedTodo,
  formatAddedNote,
  formatCreatedNode,
  formatOrgError,
  buildAddTodoArgs,
  buildAddNoteArgs,
} from "./lib.ts";
import type { OrgCliConfig } from "./lib.ts";

const envKeys = [
  "ORG_CLI_DIR",
  "ORG_CLI_ROAM_DIR",
  "ORG_CLI_DB",
  "ORG_CLI_BIN",
  "ORG_CLI_INBOX_FILE",
];

let savedEnv: Record<string, string | undefined>;

beforeEach(() => {
  savedEnv = {};
  for (const k of envKeys) {
    savedEnv[k] = process.env[k];
    delete process.env[k];
  }
});

afterEach(() => {
  for (const k of envKeys) {
    if (savedEnv[k] === undefined) {
      delete process.env[k];
    } else {
      process.env[k] = savedEnv[k];
    }
  }
});

const home = homedir();

describe("resolveConfig", () => {
  describe("defaults", () => {
    it("uses default directory", () => {
      const cfg = resolveConfig();
      assert.equal(cfg.dir, join(home, "org"));
    });

    it("roam dir defaults to <dir>/roam", () => {
      const cfg = resolveConfig();
      assert.equal(cfg.roamDir, join(home, "org/roam"));
    });

    it("db defaults to <dir>/.org.db", () => {
      const cfg = resolveConfig();
      assert.equal(cfg.db, join(home, "org/.org.db"));
    });

    it("orgBin defaults to org", () => {
      const cfg = resolveConfig();
      assert.equal(cfg.orgBin, "org");
    });

    it("inboxFile defaults to inbox.org", () => {
      const cfg = resolveConfig();
      assert.equal(cfg.inboxFile, "inbox.org");
    });
  });

  describe("plugin config overrides", () => {
    it("overrides dir from plugin config", () => {
      const cfg = resolveConfig({ dir: "/custom" });
      assert.equal(cfg.dir, "/custom");
    });

    it("roam dir derives from overridden dir", () => {
      const cfg = resolveConfig({ dir: "/custom" });
      assert.equal(cfg.roamDir, "/custom/roam");
      assert.equal(cfg.db, "/custom/.org.db");
    });

    it("roam dir can be overridden independently", () => {
      const cfg = resolveConfig({ dir: "/custom", roamDir: "/custom/notes" });
      assert.equal(cfg.dir, "/custom");
      assert.equal(cfg.roamDir, "/custom/notes");
    });

    it("db can be overridden independently", () => {
      const cfg = resolveConfig({ db: "/custom.db" });
      assert.equal(cfg.db, "/custom.db");
      assert.equal(cfg.dir, join(home, "org"));
    });
  });

  describe("env var overrides", () => {
    it("env vars take precedence over plugin config", () => {
      process.env.ORG_CLI_DIR = "/env";
      const cfg = resolveConfig({ dir: "/config" });
      assert.equal(cfg.dir, "/env");
    });

    it("env roam dir overrides derived default", () => {
      process.env.ORG_CLI_ROAM_DIR = "/env/roam";
      const cfg = resolveConfig({ dir: "/config" });
      assert.equal(cfg.roamDir, "/env/roam");
    });

    it("roam dir derives from env dir when not set", () => {
      process.env.ORG_CLI_DIR = "/env";
      const cfg = resolveConfig();
      assert.equal(cfg.roamDir, "/env/roam");
      assert.equal(cfg.db, "/env/.org.db");
    });

    it("db env var overrides derived default", () => {
      process.env.ORG_CLI_DB = "/env/custom.db";
      const cfg = resolveConfig();
      assert.equal(cfg.db, "/env/custom.db");
    });
  });

  describe("roam dir is never the same as workspace dir", () => {
    it("default config has distinct dirs", () => {
      const cfg = resolveConfig();
      assert.notEqual(cfg.dir, cfg.roamDir);
    });

    it("roam dir is a subdirectory of workspace dir by default", () => {
      const cfg = resolveConfig();
      assert.ok(cfg.roamDir.startsWith(cfg.dir + "/"));
    });
  });
});

describe("formatAddedTodo", () => {
  it("prefixes custom_id when present in JSON response", () => {
    const stdout = JSON.stringify({ ok: true, data: { custom_id: "abc", title: "Fix thing" } });
    const result = formatAddedTodo(stdout);
    assert.ok(result.startsWith("TODO created with ID: abc\n\n"));
    assert.ok(result.includes(stdout));
  });

  it("returns stdout unchanged when custom_id is absent", () => {
    const stdout = JSON.stringify({ ok: true, data: { title: "Fix thing" } });
    assert.equal(formatAddedTodo(stdout), stdout);
  });

  it("returns stdout unchanged for non-JSON", () => {
    assert.equal(formatAddedTodo("plain"), "plain");
  });

  it("returns stdout unchanged when ok is false", () => {
    const stdout = JSON.stringify({ ok: false, error: { message: "bad" } });
    assert.equal(formatAddedTodo(stdout), stdout);
  });
});

describe("formatAddedNote", () => {
  it("prefixes custom_id when present", () => {
    const stdout = JSON.stringify({ ok: true, data: { custom_id: "abc", title: "A thought" } });
    const result = formatAddedNote(stdout);
    assert.ok(result.startsWith("Note created with ID: abc\n\n"));
  });

  it("returns stdout unchanged for non-JSON", () => {
    assert.equal(formatAddedNote("plain"), "plain");
  });
});

describe("formatCreatedNode", () => {
  it("prefixes id when present", () => {
    const stdout = JSON.stringify({ ok: true, data: { id: "uuid-1234", title: "A Node" } });
    const result = formatCreatedNode(stdout);
    assert.ok(result.startsWith("Node created with ID: uuid-1234\n\n"));
  });

  it("falls back to custom_id when id is absent", () => {
    const stdout = JSON.stringify({ ok: true, data: { custom_id: "k4t", title: "A Node" } });
    const result = formatCreatedNode(stdout);
    assert.ok(result.startsWith("Node created with ID: k4t\n\n"));
  });

  it("returns stdout unchanged when neither is present", () => {
    const stdout = JSON.stringify({ ok: true, data: { title: "A Node" } });
    assert.equal(formatCreatedNode(stdout), stdout);
  });

  it("returns stdout unchanged for non-JSON", () => {
    assert.equal(formatCreatedNode("plain"), "plain");
  });
});

describe("formatOrgError", () => {
  it("extracts error.message from JSON error envelope on stdout", () => {
    const stdout = JSON.stringify({ ok: false, error: { message: "headline not found" } });
    const msg = formatOrgError(["todo", "set", "xyz", "DONE", "-f", "json"], stdout, "", "exit 1");
    assert.equal(msg, "org todo failed: headline not found");
  });

  it("falls back to stderr when JSON parse fails", () => {
    const msg = formatOrgError(["fts", "query", "-f", "json"], "not json", "fts: syntax error", "exit 1");
    assert.equal(msg, "org fts failed: fts: syntax error");
  });

  it("falls back to stdout when stderr is empty and response not JSON", () => {
    const msg = formatOrgError(["today", "-f", "json"], "raw problem output", "", "exit 1");
    assert.equal(msg, "org today failed: raw problem output");
  });

  it("falls back to the supplied fallback when stdout and stderr are empty", () => {
    const msg = formatOrgError(["read", "file", "id"], "", "", "ETIMEDOUT");
    assert.equal(msg, "org read failed: ETIMEDOUT");
  });

  it("does not attempt JSON parse when -f json is absent", () => {
    const stdout = JSON.stringify({ ok: false, error: { message: "nope" } });
    const msg = formatOrgError(["schedule", "id", "2026-05-01"], stdout, "", "exit 1");
    assert.ok(msg.startsWith("org schedule failed: "));
    assert.ok(msg.includes(stdout));
  });
});

const cfg: OrgCliConfig = {
  dir: "/ws",
  roamDir: "/ws/roam",
  db: "/ws/.org.db",
  orgBin: "org",
  inboxFile: "inbox.org",
};

describe("buildAddTodoArgs", () => {
  it("defaults to workspace inbox with TODO state and json output", () => {
    const args = buildAddTodoArgs(cfg, { title: "Buy milk" });
    assert.deepEqual(args, [
      "add",
      "/ws/inbox.org",
      "Buy milk",
      "--todo",
      "TODO",
      "--db",
      "/ws/.org.db",
      "-f",
      "json",
    ]);
  });

  it("honors custom file relative to the workspace dir", () => {
    const args = buildAddTodoArgs(cfg, { title: "Ship", file: "projects.org" });
    assert.ok(args.includes("/ws/projects.org"));
  });

  it("appends --scheduled when provided", () => {
    const args = buildAddTodoArgs(cfg, { title: "X", scheduled: "2026-05-20" });
    const idx = args.indexOf("--scheduled");
    assert.notEqual(idx, -1);
    assert.equal(args[idx + 1], "2026-05-20");
  });

  it("appends --deadline when provided", () => {
    const args = buildAddTodoArgs(cfg, { title: "X", deadline: "2026-05-20" });
    const idx = args.indexOf("--deadline");
    assert.notEqual(idx, -1);
    assert.equal(args[idx + 1], "2026-05-20");
  });

  it("passes title literally (no shell-quoting)", () => {
    const title = "Fix: double \"quoted\" 'and' $unescaped";
    const args = buildAddTodoArgs(cfg, { title });
    assert.ok(args.includes(title));
  });
});

describe("buildAddNoteArgs", () => {
  it("omits --todo and date flags", () => {
    const args = buildAddNoteArgs(cfg, { text: "A thought" });
    assert.deepEqual(args, [
      "add",
      "/ws/inbox.org",
      "A thought",
      "--db",
      "/ws/.org.db",
      "-f",
      "json",
    ]);
    assert.ok(!args.includes("--todo"));
    assert.ok(!args.includes("--scheduled"));
    assert.ok(!args.includes("--deadline"));
  });

  it("honors custom file", () => {
    const args = buildAddNoteArgs(cfg, { text: "Idea", file: "ideas.org" });
    assert.ok(args.includes("/ws/ideas.org"));
  });
});


path: plugin/index.ts
content: import { Type } from "@sinclair/typebox";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import {
  DATE_PATTERN,
  DATE_OR_EMPTY_PATTERN,
  READ_TIMEOUT_MS,
  buildAddTodoArgs,
  buildAddNoteArgs,
  formatAddedTodo,
  formatAddedNote,
  formatCreatedNode,
  resolveConfig,
  runOrg,
} from "./lib.ts";

// Re-export helpers so external consumers and tests can import from the
// package root without depending on the SDK.
export {
  resolveConfig,
  formatAddedTodo,
  formatAddedNote,
  formatCreatedNode,
  formatOrgError,
  buildAddTodoArgs,
  buildAddNoteArgs,
} from "./lib.ts";
export type { OrgCliConfig } from "./lib.ts";

export default definePluginEntry({
  id: "org-cli",
  name: "org-cli",
  description:
    "Task capture, scheduling, and linked knowledge in org-mode files via the org CLI.",

  register(api: OpenClawPluginApi) {
    const cfg = resolveConfig(api.pluginConfig);

    api.logger.info(
      `org-cli: dir=${cfg.dir}, roamDir=${cfg.roamDir}, orgBin=${cfg.orgBin}`,
    );

    // ======================================================================
    // Session-start hook: inject the shortcut grammar so the agent knows
    // how to handle quick-capture prefixes the moment a session starts.
    // ======================================================================

    api.on("before_agent_start", async () => {
      const instructions = `<org-cli-instructions>
## Quick-capture shortcuts

These shortcuts trigger org-cli actions on the user's workspace. Act on them immediately, confirm briefly.

| Prefix | Target | Action |
|---|---|---|
| \`t:\` | inbox | Create TODO in inbox.org (extract dates → --scheduled/--deadline) |
| \`n:\` | inbox | Create plain headline (no TODO, no date) |
| \`k:\` | roam | Save knowledge/info to the roam graph |
| \`d:\` | workspace | Mark matching TODO as DONE |
| \`s:\` | workspace | Reschedule a TODO to a new date |
| \`f:\` | all | Find matching headlines and roam nodes |

### Rules
- **Todo vs Note**: \`t:\` creates a TODO (dated or dateless). \`n:\` creates a plain headline, no TODO keyword — use for captured thoughts, not commitments.
- **Done**: Search for a matching TODO first. If multiple matches, ask which. If no match, say so.
- **Know**: Find an existing node first, append if found, create+append if not. Never duplicate.
- **Schedule**: \`--deadline\` for hard dates ("by Friday"); \`--scheduled\` for softer timing ("next week").
- **After every write**: print \`org-cli: <action> [<id>] <file-path>\`.
- **Argument passing**: These plugin tools invoke org via execFile — pass raw text, do not quote or escape. Use the tools' typed parameters rather than constructing shell strings.

### Workspace
- Directory: \`${cfg.dir}\` (inbox.org, tasks, projects)
- Roam nodes: \`${cfg.roamDir}\` (knowledge, people, concepts)

**Important:** Roam nodes are ONLY created in the roam directory, never in the workspace root. The plugin tools handle this automatically.

### Ambient capture
When the user mentions durable facts in passing (a preference, a date, a relationship) that have lasting value, offer to save them. Complete the explicit request first, then tell the user what you'd like to capture and confirm before writing.
</org-cli-instructions>`;

      return {
        prependContext: `<org-cli>\n${instructions}\n</org-cli>`,
      };
    });

    // ======================================================================
    // Tool: org_find — full-text search across org files + roam
    // ======================================================================

    api.registerTool(
      {
        name: "org_find",
        label: "Org Find",
        description:
          "Full-text search across the user's org files (titles + bodies). Returns matching headlines with IDs. No mutation.",
        parameters: Type.Object({
          query: Type.String({ description: "FTS5 search query" }),
        }),
        async execute(_id, params) {
          const { query } = params as { query: string };
          try {
            const { stdout } = await runOrg(
              cfg.orgBin,
              ["fts", query, "-d", cfg.dir, "--db", cfg.db, "-f", "json"],
              READ_TIMEOUT_MS,
            );
            return {
              content: [{ type: "text" as const, text: stdout }],
              details: {},
            };
          } catch (err) {
            return {
              content: [
                { type: "text" as const, text: `Find failed: ${String(err)}` },
              ],
              details: { error: true },
            };
          }
        },
      },
      { optional: true },
    );

    // ======================================================================
    // Tool: org_read_node — read a roam node by title or id
    // ======================================================================

    api.registerTool(
      {
        name: "org_read_node",
        label: "Org Read Node",
        description:
          "Read a roam node by title or ID. Returns the full node content.",
        parameters: Type.Object({
          identifier: Type.String({
            description: "Node title, org-id (UUID), or CUSTOM_ID",
          }),
        }),
        async execute(_id, params) {
          const { identifier } = params as { identifier: string };

          try {
            const { stdout: findOut } = await runOrg(
              cfg.orgBin,
              ["roam", "node", "find", identifier, "-d", cfg.roamDir, "--db", cfg.db, "-f", "json"],
              READ_TIMEOUT_MS,
            );

            const result = JSON.parse(findOut);
            if (!result.ok || !result.data) {
              return {
                content: [
                  { type: "text" as const, text: `Node not found: ${identifier}` },
                ],
                details: { found: false },
              };
            }

            // `org read` resolves the file via the workspace-level index DB,
            // not the roam subdir, so pass the workspace dir here.
            const filePath = result.data.file;
            if (filePath) {
              const { stdout: readOut } = await runOrg(
                cfg.orgBin,
                ["read", filePath, identifier, "-d", cfg.dir, "--db", cfg.db, "-f", "json"],
                READ_TIMEOUT_MS,
              );
              return {
                content: [{ type: "text" as const, text: readOut }],
                details: { node: result.data },
              };
            }

            return {
              content: [{ type: "text" as const, text: findOut }],
              details: { node: result.data },
            };
          } catch (err) {
            return {
              content: [
                { type: "text" as const, text: `Read node failed: ${String(err)}` },
              ],
              details: { error: true },
            };
          }
        },
      },
      { optional: true },
    );

    // ======================================================================
    // Tool: org_add_todo — add a TODO (t: shortcut)
    // ======================================================================

    api.registerTool(
      {
        name: "org_add_todo",
        label: "Org Add TODO",
        description:
          "Add a TODO headline to a workspace file (defaults to inbox.org). Optionally schedule or set a deadline.",
        parameters: Type.Object({
          title: Type.String({ description: "TODO title" }),
          scheduled: Type.Optional(
            Type.String({
              description: "Scheduled date (YYYY-MM-DD)",
              pattern: DATE_PATTERN,
            }),
          ),
          deadline: Type.Optional(
            Type.String({
              description: "Deadline date (YYYY-MM-DD)",
              pattern: DATE_PATTERN,
            }),
          ),
          file: Type.Optional(
            Type.String({
              description: "Filename relative to the workspace dir (default: inboxFile)",
            }),
          ),
        }),
        async execute(_id, params) {
          const typed = params as {
            title: string;
            scheduled?: string;
            deadline?: string;
            file?: string;
          };
          const args = buildAddTodoArgs(cfg, typed);
          try {
            const { stdout } = await runOrg(cfg.orgBin, args);
            return {
              content: [{ type: "text" as const, text: formatAddedTodo(stdout) }],
              details: { action: "added-todo" },
            };
          } catch (err) {
            return {
              content: [
                { type: "text" as const, text: `Failed to add TODO: ${String(err)}` },
              ],
              details: { error: true },
            };
          }
        },
      },
      { optional: true },
    );

    // ======================================================================
    // Tool: org_add_note — add a plain note (n: shortcut)
    // ======================================================================

    api.registerTool(
      {
        name: "org_add_note",
        label: "Org Add Note",
        description:
          "Add a plain headline (no TODO, no date) to a workspace file. Use for captured thoughts that aren't action items.",
        parameters: Type.Object({
          text: Type.String({ description: "Note text (becomes the headline title)" }),
          file: Type.Optional(
            Type.String({
              description: "Filename relative to the workspace dir (default: inboxFile)",
            }),
          ),
        }),
        async execute(_id, params) {
          const typed = params as { text: string; file?: string };
          const args = buildAddNoteArgs(cfg, typed);
          try {
            const { stdout } = await runOrg(cfg.orgBin, args);
            return {
              content: [{ type: "text" as const, text: formatAddedNote(stdout) }],
              details: { action: "added-note" },
            };
          } catch (err) {
            return {
              content: [
                { type: "text" as const, text: `Failed to add note: ${String(err)}` },
              ],
              details: { error: true },
            };
          }
        },
      },
      { optional: true },
    );

    // ======================================================================
    // Tool: org_mark_done — mark a TODO DONE by CUSTOM_ID
    // ======================================================================

    api.registerTool(
      {
        name: "org_mark_done",
        label: "Org Mark Done",
        description:
          "Mark a TODO as DONE by its CUSTOM_ID. For other states use org_set_state.",
        parameters: Type.Object({
          customId: Type.String({
            description: "The CUSTOM_ID of the headline to mark DONE",
          }),
        }),
        async execute(_id, params) {
          const { customId } = params as { customId: string };
          try {
            const { stdout } = await runOrg(cfg.orgBin, [
              "todo", "set", customId, "DONE",
              "-d", cfg.dir, "--db", cfg.db, "-f", "json",
            ]);
            return {
              content: [{ type: "text" as const, text: stdout }],
              details: { action: "done", customId },
            };
          } catch (err) {
            return {
              content: [
                { type: "text" as const, text: `Failed to mark DONE: ${String(err)}` },
              ],
              details: { error: true },
            };
          }
        },
      },
      { optional: true },
    );

    // ======================================================================
    // Tool: org_set_state — set any TODO state by CUSTOM_ID
    // ======================================================================

    api.registerTool(
      {
        name: "org_set_state",
        label: "Org Set State",
        description:
          "Set a TODO to any state (DONE, CANCELLED, WAITING, or custom #+TODO keywords) by its CUSTOM_ID.",
        parameters: Type.Object({
          customId: Type.String({
            description: "The CUSTOM_ID of the headline",
          }),
          state: Type.String({
            description: "Target TODO state (e.g. DONE, CANCELLED, WAITING)",
          }),
        }),
        async execute(_id, params) {
          const { customId, state } = params as { customId: string; state: string };
          try {
            const { stdout } = await runOrg(cfg.orgBin, [
              "todo", "set", customId, state,
              "-d", cfg.dir, "--db", cfg.db, "-f", "json",
            ]);
            return {
              content: [{ type: "text" as const, text: stdout }],
              details: { action: "state-set", customId, state },
            };
          } catch (err) {
            return {
              content: [
                { type: "text" as const, text: `Failed to set state: ${String(err)}` },
              ],
              details: { error: true },
            };
          }
        },
      },
      { optional: true },
    );

    // ======================================================================
    // Tool: org_reschedule — reschedule a TODO by CUSTOM_ID
    // ======================================================================

    api.registerTool(
      {
        name: "org_reschedule",
        label: "Org Reschedule",
        description:
          'Reschedule a TODO by its CUSTOM_ID. Pass a date in YYYY-MM-DD, or "" to clear the schedule.',
        parameters: Type.Object({
          customId: Type.String({
            description: "The CUSTOM_ID of the headline to reschedule",
          }),
          date: Type.String({
            description: 'New scheduled date (YYYY-MM-DD) or "" to clear',
            pattern: DATE_OR_EMPTY_PATTERN,
          }),
          repeater: Type.Optional(
            Type.String({
              description: "Repeater: +N[hdwmy], ++N[hdwmy], or .+N[hdwmy]",
            }),
          ),
          delay: Type.Optional(
            Type.String({
              description: "Warning delay: N[hdwmy] (e.g. 2d for -2d)",
            }),
          ),
        }),
        async execute(_id, params) {
          const { customId, date, repeater, delay } = params as {
            customId: string;
            date: string;
            repeater?: string;
            delay?: string;
          };
          try {
            const args = [
              "schedule", customId, date,
              "-d", cfg.dir, "--db", cfg.db, "-f", "json",
            ];
            if (repeater) args.push("--repeater", repeater);
            if (delay) args.push("--delay", delay);
            const { stdout } = await runOrg(cfg.orgBin, args);
            return {
              content: [{ type: "text" as const, text: stdout }],
              details: { action: "rescheduled", customId, date },
            };
          } catch (err) {
            return {
              content: [
                { type: "text" as const, text: `Reschedule failed: ${String(err)}` },
              ],
              details: { error: true },
            };
          }
        },
      },
      { optional: true },
    );

    // ======================================================================
    // Tool: org_list_todos — today's agenda
    // ======================================================================

    api.registerTool(
      {
        name: "org_list_todos",
        label: "Org List TODOs",
        description:
          "Show today's agenda and overdue tasks. Returns scheduled, deadline, and active TODO items.",
        parameters: Type.Object({}),
        async execute() {
          try {
            const { stdout } = await runOrg(
              cfg.orgBin,
              ["today", "-d", cfg.dir, "--db", cfg.db, "-f", "json"],
              READ_TIMEOUT_MS,
            );
            return {
              content: [{ type: "text" as const, text: stdout }],
              details: {},
            };
          } catch (err) {
            return {
              content: [
                { type: "text" as const, text: `List TODOs failed: ${String(err)}` },
              ],
              details: { error: true },
            };
          }
        },
      },
      { optional: true },
    );

    // ======================================================================
    // Tool: org_append — append text to a headline body by CUSTOM_ID
    // ======================================================================

    api.registerTool(
      {
        name: "org_append",
        label: "Org Append",
        description:
          "Append text to a headline's body by its CUSTOM_ID. Use for adding notes, observations, or content to existing items.",
        parameters: Type.Object({
          customId: Type.String({
            description: "The CUSTOM_ID of the headline to append to",
          }),
          text: Type.String({ description: "Text to append to the headline body" }),
        }),
        async execute(_id, params) {
          const { customId, text } = params as { customId: string; text: string };
          try {
            const { stdout } = await runOrg(cfg.orgBin, [
              "append", customId, text,
              "-d", cfg.dir, "--db", cfg.db, "-f", "json",
            ]);
            return {
              content: [{ type: "text" as const, text: stdout }],
              details: { action: "appended", customId },
            };
          } catch (err) {
            return {
              content: [
                { type: "text" as const, text: `Append failed: ${String(err)}` },
              ],
              details: { error: true },
            };
          }
        },
      },
      { optional: true },
    );

    // ======================================================================
    // Tool: org_roam_upsert — find-or-create a roam node, then append
    // ======================================================================

    api.registerTool(
      {
        name: "org_roam_upsert",
        label: "Org Roam Upsert",
        description:
          "Store a fact in the roam graph against a subject. If a node for the subject exists, appends to it; otherwise creates a new node and appends. This is the `k:` shortcut.",
        parameters: Type.Object({
          subject: Type.String({
            description: "Subject of the knowledge (e.g. person/concept name)",
          }),
          fact: Type.String({
            description: "The fact/note to record against the subject",
          }),
          tags: Type.Optional(
            Type.Array(Type.String(), {
              description: "Tags to apply if the node is newly created",
            }),
          ),
        }),
        async execute(_id, params) {
          const { subject, fact, tags } = params as {
            subject: string;
            fact: string;
            tags?: string[];
          };

          try {
            // 1. Find existing node
            const { stdout: findOut } = await runOrg(
              cfg.orgBin,
              ["roam", "node", "find", subject, "-d", cfg.roamDir, "--db", cfg.db, "-f", "json"],
              READ_TIMEOUT_MS,
            );
            const found = JSON.parse(findOut);

            let customId: string | undefined = found?.data?.custom_id;

            // 2. If not found, create it
            if (!found?.ok || !found?.data) {
              const createArgs = ["roam", "node", "create", subject];
              if (tags) {
                for (const tag of tags) createArgs.push("-t", tag);
              }
              createArgs.push("-d", cfg.roamDir, "--db", cfg.db, "-f", "json");
              const { stdout: createOut } = await runOrg(cfg.orgBin, createArgs);
              const created = JSON.parse(createOut);
              customId = created?.data?.custom_id;
              if (!customId) {
                return {
                  content: [{ type: "text" as const, text: formatCreatedNode(createOut) }],
                  details: { action: "created-no-id" },
                };
              }
            }

            if (!customId) {
              return {
                content: [
                  { type: "text" as const, text: `Node lookup returned no custom_id for: ${subject}` },
                ],
                details: { error: true },
              };
            }

            // 3. Append the fact
            const { stdout: appendOut } = await runOrg(cfg.orgBin, [
              "append", customId, fact,
              "-d", cfg.dir, "--db", cfg.db, "-f", "json",
            ]);
            return {
              content: [{ type: "text" as const, text: appendOut }],
              details: { action: "upserted", subject, customId },
            };
          } catch (err) {
            return {
              content: [
                { type: "text" as const, text: `Roam upsert failed: ${String(err)}` },
              ],
              details: { error: true },
            };
          }
        },
      },
      { optional: true },
    );
  },
});


path: plugin/lib.ts
content: import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { homedir } from "node:os";

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

export const DATE_PATTERN = "^\\d{4}-\\d{2}-\\d{2}$";
export const DATE_OR_EMPTY_PATTERN = "^(\\d{4}-\\d{2}-\\d{2})?$";
export const DEFAULT_TIMEOUT_MS = 10_000;
export const READ_TIMEOUT_MS = 30_000;
export const MAX_FILE_BYTES = 32_768;

// ---------------------------------------------------------------------------
// Config resolution: env vars > plugin config > defaults
// ---------------------------------------------------------------------------

export type OrgCliConfig = {
  dir: string;
  roamDir: string;
  db: string;
  orgBin: string;
  inboxFile: string;
};

export function resolveConfig(pluginConfig?: Record<string, unknown>): OrgCliConfig {
  const home = homedir();
  const cfg = pluginConfig ?? {};

  const dir =
    process.env.ORG_CLI_DIR ??
    (cfg.dir as string | undefined) ??
    join(home, "org");

  return {
    dir,
    roamDir:
      process.env.ORG_CLI_ROAM_DIR ??
      (cfg.roamDir as string | undefined) ??
      join(dir, "roam"),
    db:
      process.env.ORG_CLI_DB ??
      (cfg.db as string | undefined) ??
      join(dir, ".org.db"),
    orgBin:
      process.env.ORG_CLI_BIN ??
      (cfg.orgBin as string | undefined) ??
      "org",
    inboxFile:
      process.env.ORG_CLI_INBOX_FILE ??
      (cfg.inboxFile as string | undefined) ??
      "inbox.org",
  };
}

// ---------------------------------------------------------------------------
// Output formatting
// ---------------------------------------------------------------------------

export function formatOrgError(
  args: string[],
  stdout: string,
  stderr: string,
  fallback: string,
): string {
  const cmd = args[0] ?? "org";
  const fIdx = args.indexOf("-f");
  const wantsJson = fIdx !== -1 && args[fIdx + 1] === "json";
  if (wantsJson && stdout) {
    try {
      const parsed = JSON.parse(stdout);
      if (parsed?.ok === false && parsed.error?.message) {
        return `org ${cmd} failed: ${parsed.error.message}`;
      }
    } catch {
      // fall through
    }
  }
  const detail = stderr.trim() || stdout.trim() || fallback;
  return `org ${cmd} failed: ${detail}`;
}

export function formatAddedTodo(stdout: string): string {
  try {
    const parsed = JSON.parse(stdout);
    const customId = parsed?.data?.custom_id;
    if (customId) {
      return `TODO created with ID: ${customId}\n\n${stdout}`;
    }
  } catch {
    // non-JSON
  }
  return stdout;
}

export function formatAddedNote(stdout: string): string {
  try {
    const parsed = JSON.parse(stdout);
    const customId = parsed?.data?.custom_id;
    if (customId) {
      return `Note created with ID: ${customId}\n\n${stdout}`;
    }
  } catch {
    // non-JSON
  }
  return stdout;
}

export function formatCreatedNode(stdout: string): string {
  try {
    const parsed = JSON.parse(stdout);
    const id = parsed?.data?.id ?? parsed?.data?.custom_id;
    if (id) {
      return `Node created with ID: ${id}\n\n${stdout}`;
    }
  } catch {
    // non-JSON
  }
  return stdout;
}

// ---------------------------------------------------------------------------
// Argument builders (pure — testable without spawning org)
// ---------------------------------------------------------------------------

export function buildAddTodoArgs(
  cfg: OrgCliConfig,
  params: {
    title: string;
    scheduled?: string;
    deadline?: string;
    file?: string;
  },
): string[] {
  const filePath = join(cfg.dir, params.file ?? cfg.inboxFile);
  const args = [
    "add",
    filePath,
    params.title,
    "--todo",
    "TODO",
    "--db",
    cfg.db,
    "-f",
    "json",
  ];
  if (params.scheduled) {
    args.push("--scheduled", params.scheduled);
  }
  if (params.deadline) {
    args.push("--deadline", params.deadline);
  }
  return args;
}

export function buildAddNoteArgs(
  cfg: OrgCliConfig,
  params: {
    text: string;
    file?: string;
  },
): string[] {
  const filePath = join(cfg.dir, params.file ?? cfg.inboxFile);
  return ["add", filePath, params.text, "--db", cfg.db, "-f", "json"];
}

// ---------------------------------------------------------------------------
// Runtime helpers
// ---------------------------------------------------------------------------

export function runOrg(
  bin: string,
  args: string[],
  timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<{ stdout: string; stderr: string }> {
  return new Promise((resolve, reject) => {
    execFile(bin, args, { timeout: timeoutMs }, (err, stdout, stderr) => {
      if (err) {
        reject(new Error(formatOrgError(args, stdout, stderr, err.message)));
      } else {
        resolve({ stdout, stderr });
      }
    });
  });
}

export async function readOrgFile(
  path: string,
  maxBytes = Number.POSITIVE_INFINITY,
): Promise<string | null> {
  try {
    const content = await readFile(path, "utf-8");
    if (content.length <= maxBytes) {
      return content;
    }
    return content.slice(0, maxBytes) + `\n\n... (truncated at ${maxBytes} bytes)`;
  } catch {
    return null;
  }
}

export function todayStr(): string {
  const d = new Date();
  const y = d.getFullYear();
  const m = String(d.getMonth() + 1).padStart(2, "0");
  const day = String(d.getDate()).padStart(2, "0");
  return `${y}-${m}-${day}`;
}

export function yesterdayStr(): string {
  const d = new Date();
  d.setDate(d.getDate() - 1);
  const y = d.getFullYear();
  const m = String(d.getMonth() + 1).padStart(2, "0");
  const day = String(d.getDate()).padStart(2, "0");
  return `${y}-${m}-${day}`;
}


path: plugin/openclaw.plugin.json
content: {
  "id": "org-cli",
  "name": "org-cli",
  "description": "Task capture, scheduling, and linked knowledge in org-mode files via the org CLI.",
  "version": "1.0.2",
  "contracts": {
    "tools": [
      "org_find",
      "org_read_node",
      "org_add_todo",
      "org_add_note",
      "org_mark_done",
      "org_set_state",
      "org_reschedule",
      "org_list_todos",
      "org_append",
      "org_roam_upsert"
    ]
  },
  "configSchema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "dir": {
        "type": "string",
        "description": "Workspace directory"
      },
      "roamDir": {
        "type": "string",
        "description": "Roam nodes directory (defaults to <dir>/roam)"
      },
      "db": {
        "type": "string",
        "description": "Path to the SQLite database (defaults to <dir>/.org.db)"
      },
      "orgBin": {
        "type": "string",
        "description": "Path to the org CLI binary"
      },
      "inboxFile": {
        "type": "string",
        "description": "Filename for new captures (relative to dir)"
      }
    }
  },
  "uiHints": {
    "dir": {
      "label": "Workspace Directory",
      "placeholder": "~/org",
      "help": "Your org workspace — tasks, projects, and inbox.org."
    },
    "roamDir": {
      "label": "Roam Directory",
      "placeholder": "~/org/roam",
      "help": "Where knowledge-graph nodes live. Defaults to <dir>/roam.",
      "advanced": true
    },
    "db": {
      "label": "Database",
      "placeholder": "~/org/.org.db",
      "help": "SQLite database for roam + index. Defaults to <dir>/.org.db.",
      "advanced": true
    },
    "orgBin": {
      "label": "org CLI Binary",
      "placeholder": "org",
      "help": "Path to the `org` binary. Must be on PATH or set to an absolute path.",
      "advanced": true
    },
    "inboxFile": {
      "label": "Inbox File",
      "placeholder": "inbox.org",
      "help": "Filename new captures are added to, relative to the workspace directory."
    }
  }
}


path: plugin/package-lock.json
content: {
  "name": "@dcprevere/org-cli",
  "version": "1.0.2",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "@dcprevere/org-cli",
      "version": "1.0.2",
      "license": "MIT",
      "dependencies": {
        "@sinclair/typebox": "^0.34.48"
      },
      "devDependencies": {
        "@types/node": "^22.10.0"
      }
    },
    "node_modules/@sinclair/typebox": {
      "version": "0.34.49",
      "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/@types/node": {
      "version": "22.19.17",
      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "undici-types": "~6.21.0"
      }
    },
    "node_modules/undici-types": {
      "version": "6.21.0",
      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT"
    }
  }
}


path: plugin/package.json
content: {
  "name": "@dcprevere/org-cli",
  "version": "1.0.2",
  "type": "module",
  "description": "Task capture, scheduling, and linked knowledge in org-mode files via the org CLI.",
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "https://github.com/DCPRevere/org-cli.git",
    "directory": "integrations/openclaw/org-cli/plugin"
  },
  "files": [
    "index.ts",
    "lib.ts",
    "openclaw.plugin.json"
  ],
  "openclaw": {
    "extensions": ["./index.ts"],
    "compat": {
      "pluginApi": ">=2026.3.24-beta.2",
      "minGatewayVersion": "2026.3.24-beta.2"
    },
    "build": {
      "openclawVersion": "2026.3.24-beta.2",
      "pluginSdkVersion": "2026.3.24-beta.2"
    }
  },
  "scripts": {
    "test": "node --test --experimental-strip-types index.test.ts"
  },
  "dependencies": {
    "@sinclair/typebox": "^0.34.48"
  },
  "devDependencies": {
    "@types/node": "^22.10.0"
  }
}


path: references/knowledge-management.md
content: # Knowledge management

All commands operate against `$ORG_CLI_DIR` / `$ORG_CLI_ROAM_DIR` and `$ORG_CLI_DB`.

## Always search before creating

Before creating a node or link, check if the entity already exists:

```bash
org roam node find 'Sarah' -d "$ORG_CLI_ROAM_DIR" --db "$ORG_CLI_DB" -f json
```

- If found: use the existing node's ID and file path
- If not found (`headline_not_found` error): create a new node

**Never create a node without searching first.** Duplicates fragment the knowledge graph.

## Record an entity

Only after confirming no existing node:

```bash
org roam node create 'Sarah' -d "$ORG_CLI_ROAM_DIR" --db "$ORG_CLI_DB" -t person -t work -f json
```

## Add structure to a node

Use the file path returned by create/find commands:

```bash
# Add a headline to the node (response includes auto-assigned custom_id)
org add <file> 'Unavailable March 2026' --tag scheduling --db "$ORG_CLI_DB" -f json
# → {"ok":true,"data":{"custom_id":"k4t","title":"Unavailable March 2026",...}}

# Use the custom_id for follow-up commands
org note k4t 'Out all of March per user.' -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json

# Append body text to an existing headline
org append k4t 'Confirmed by email on 2026-04-15.' -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json

# Append multi-line text via stdin
printf '%s' 'First paragraph.

Second paragraph.' | org append k4t --stdin -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json
```

**`org note` vs `org append`:** `note` adds a timestamped entry to the LOGBOOK drawer (metadata). `append` adds text to the headline body (visible content). Use `note` for audit trail, `append` for building up content.

**Note:** Both commands attach to *headlines*, not file-level nodes. If a roam node is file-level (no headlines yet), first add a headline with `org add`, then use `note` or `append` on it.

## Link two nodes

**Always search for both nodes first** to get their IDs:

```bash
# Find source node
org roam node find 'Bob' -d "$ORG_CLI_ROAM_DIR" --db "$ORG_CLI_DB" -f json
# → Returns {"ok":true,"data":{"id":"e5f6a7b8-...","file":"/path/to/bob.org",...}}

# Find target node
org roam node find 'Alice' -d "$ORG_CLI_ROAM_DIR" --db "$ORG_CLI_DB" -f json
# → Returns {"ok":true,"data":{"id":"a1b2c3d4-...",...}}
```

If either node doesn't exist, create it first. Then link using the IDs from the responses:

```bash
org roam link add <source-file> '<source-id>' '<target-id>' -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" --description 'manages' -f json
```

The `--description` is optional metadata about the relationship.

## Query the knowledge graph

```bash
# Find a node by name
org roam node find 'Sarah' -d "$ORG_CLI_ROAM_DIR" --db "$ORG_CLI_DB" -f json

# Read a headline's full content (body, properties, logbook)
org read <file> 'Unavailable March 2026' -d "$ORG_CLI_DIR" -f json

# Full-text search across all indexed headlines (fast, supports boolean/prefix/phrase)
org fts 'sarah AND march' -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json

# Regex search (slower, use when FTS isn't precise enough)
org search 'Sarah.*March' -d "$ORG_CLI_DIR" -f json

# Browse by graph structure
org roam backlinks 'a1b2c3d4-...' -d "$ORG_CLI_ROAM_DIR" --db "$ORG_CLI_DB" -f json
org roam tag find person -d "$ORG_CLI_ROAM_DIR" --db "$ORG_CLI_DB" -f json
```

**`org fts` vs `org search`:** Use `fts` by default — it's faster and handles natural language well. Use `search` when you need regex patterns (e.g. matching across fields or partial words that aren't prefixes).

## Aliases and refs

Aliases let a node be found by multiple names. Refs associate URLs or external identifiers.

```bash
org roam alias add <file> 'a1b2c3d4-...' 'Sarah Chen' --db "$ORG_CLI_DB"
org roam ref add <file> 'a1b2c3d4-...' 'https://github.com/sarahchen' --db "$ORG_CLI_DB"
```

## Node conventions

Use consistent tags for easy querying:

- `person` — people the user knows or works with
- `project` — software projects, initiatives
- `preference` — how the user likes things done
- `fact` — technical details, configuration, reference data


path: references/task-management.md
content: # Task management

All commands operate against `$ORG_CLI_DIR` and `$ORG_CLI_DB`.

## Read the user's state

**Start here.** `org today` is the most useful query — it returns all non-done TODOs that are scheduled for today or overdue:

```bash
org today -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json
```

For broader views:

```bash
org agenda today -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json   # all scheduled + deadlines for today
org agenda week -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json    # next 7 days
org agenda todo -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json    # all TODOs with scheduled dates
org agenda todo --tag work -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json
```

For rich filtering across all TODOs (scheduled or not):

```bash
org todo list --state TODO -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json                    # all open TODOs
org todo list --state TODO --unscheduled -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json      # unscheduled only
org todo list --state TODO --overdue -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json          # overdue items
org todo list --state TODO --due-before 2026-05-01 -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json
org todo list --search 'meeting' -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json              # search by title
org todo list --state TODO --file 'work' -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json      # filter by file
org todo list --state TODO --tag urgent -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json       # filter by tag
org todo list --state TODO --priority A -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json       # filter by priority
org todo list --state TODO --sort priority -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json    # sort by priority
org todo list --state TODO --sort scheduled --reverse -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json
```

The `todo list` command returns full data in JSON: title, todo state, priority, tags, file, pos, scheduled, deadline, level, path (parent headlines), and custom_id. All filters are combinable.

## Make changes

```bash
# Add a headline (response includes the auto-assigned custom_id)
org add "$ORG_CLI_DIR/inbox.org" 'Review PR #42' --todo TODO --tag work --deadline 2026-05-10 --db "$ORG_CLI_DB" -f json

# Subsequent commands use the custom_id — no file path needed
org todo set k4t DONE -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json
org schedule a1b 2026-05-15 -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json
org note a1b 'Pushed back per manager request' -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB"
org append a1b 'Meeting notes from standup.' -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json

# Refile still requires explicit file paths
org refile "$ORG_CLI_DIR/inbox.org" 'Review PR #42' "$ORG_CLI_DIR/work.org" 'Code reviews' --db "$ORG_CLI_DB" -f json
```

## Preview before writing

Use `--dry-run` to see what a mutation would produce without modifying the file:

```bash
org todo set tasks.org 'Buy groceries' DONE --dry-run -f json
```

## Batch operations

Apply multiple mutations atomically. Commands execute sequentially against in-memory state. Files are written only if all succeed.

```bash
echo '{"commands":[
  {"command":"todo","file":"tasks.org","identifier":"Buy groceries","args":{"state":"DONE"}},
  {"command":"tag-add","file":"tasks.org","identifier":"Write report","args":{"tag":"urgent"}},
  {"command":"schedule","file":"tasks.org","identifier":"Write report","args":{"date":"2026-05-01"}},
  {"command":"append","file":"tasks.org","identifier":"Write report","args":{"text":"Include Q1 metrics."}}
]}' | org batch -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json
```

## Query shortcuts

When the user asks about their tasks or knowledge, map natural language to the right query. Don't ask "what do you mean?" — just run the query.

| User says | Action |
|---|---|
| "What do I need to do?" / "What's on my plate?" | `org today -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json` (today + overdue) |
| "What's overdue?" | `org todo list --state TODO --overdue -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json` |
| "What's coming up this week?" | `org agenda week -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json` |
| "Show me everything tagged work" | `org todo list --state TODO --tag work -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json` |
| "What do I have unscheduled?" | `org todo list --state TODO --unscheduled -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json` |
| "Find all tasks about X" | `org todo list --search 'X' -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json` |
| "What do you know about Sarah?" | `org roam node find 'Sarah' -d "$ORG_CLI_ROAM_DIR" --db "$ORG_CLI_DB"` → `org read <file> 'Sarah'` → `org roam backlinks <id>` |
| "What's the status of project X?" | `org fts 'X' -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB"` + `org todo list --search 'X' -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB"` |
| "Search my notes for Y" | `org fts 'Y' -d "$ORG_CLI_DIR" --db "$ORG_CLI_DB" -f json` |

Present results in a clean, readable format. Don't dump raw JSON at the user — summarise it.


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)