Commit 508bd0115069

Vincent Demeester <vincent@sbr.pm>
2026-02-06 10:39:22
feat(pi): add org-todos extension with emacsclient backend
New Pi extension for org-mode TODO management using emacsclient to communicate with Emacs daemon. Leverages org-ql for powerful queries and existing org-batch-functions.el for operations. Features: - org_todo tool with 16 actions (list, search, done, add, etc.) - /todos command showing today's tasks - /todo-search command for searching - Full test suite (18 elisp + 14 TypeScript tests) Files: - pi-org-todos.el: Elisp wrapper with JSON output - org-todos/index.ts: Pi extension - Makefile for running tests
1 parent 1a0ac78
Changed files (5)
dots
dots/config/emacs/site-lisp/pi-org-todos-test.el
@@ -0,0 +1,351 @@
+;;; pi-org-todos-test.el --- Tests for pi-org-todos.el -*- lexical-binding: t -*-
+
+;; Copyright (C) 2026 Vincent Demeester
+
+;;; Commentary:
+
+;; Unit tests for pi-org-todos.el functions.
+;; Run with: emacs --batch -l ert -l pi-org-todos-test.el -f ert-run-tests-batch-and-exit
+;;
+;; Or from Emacs: M-x ert RET t RET
+
+;;; Code:
+
+(require 'ert)
+(require 'json)
+
+;; Load the library under test
+(add-to-list 'load-path (file-name-directory (or load-file-name buffer-file-name)))
+(require 'org-batch-functions)
+(require 'pi-org-todos)
+
+;;; Test Fixtures
+
+(defvar pi-org-todos-test-file nil
+  "Temporary test file path.")
+
+(defvar pi-org-todos-test-content
+  "#+title: Test TODOs
+#+startup: show2levels
+
+* Work
+:PROPERTIES:
+:CATEGORY: work
+:END:
+
+** TODO [#2] Review PR for pipeline
+SCHEDULED: <2026-02-06 Fri>
+:PROPERTIES:
+:CREATED: [2026-02-01 Mon]
+:END:
+
+This is a test TODO with some content.
+
+** NEXT [#1] Fix CI/CD issue :urgent:
+DEADLINE: <2026-02-05 Thu>
+:PROPERTIES:
+:CREATED: [2026-02-02 Tue]
+:END:
+
+** STRT Write documentation
+:PROPERTIES:
+:CREATED: [2026-02-03 Wed]
+:END:
+
+** WAIT Waiting for approval :blocked:
+:PROPERTIES:
+:BLOCKER: Review PR for pipeline
+:END:
+
+** DONE Completed task
+CLOSED: [2026-02-04 Thu 10:00]
+
+* Projects
+:PROPERTIES:
+:CATEGORY: projects
+:END:
+
+** TODO [#3] Homelab migration
+SCHEDULED: <2026-02-10 Wed>
+
+** TODO NixOS refactoring :nixos:homelab:
+
+* Personal
+:PROPERTIES:
+:CATEGORY: personal
+:END:
+
+** TODO Buy groceries
+SCHEDULED: <2026-02-06 Fri>
+"
+  "Test org file content.")
+
+(defun pi-org-todos-test-setup ()
+  "Create temporary test file."
+  (setq pi-org-todos-test-file (make-temp-file "pi-org-test-" nil ".org"))
+  (with-temp-file pi-org-todos-test-file
+    (insert pi-org-todos-test-content))
+  ;; Override the default file for tests
+  (setq pi/org-todo-default-file pi-org-todos-test-file))
+
+(defun pi-org-todos-test-teardown ()
+  "Clean up temporary test file."
+  (when (and pi-org-todos-test-file (file-exists-p pi-org-todos-test-file))
+    (delete-file pi-org-todos-test-file))
+  (setq pi-org-todos-test-file nil))
+
+(defun pi-org-todos-test-parse-result (json-string)
+  "Parse JSON-STRING result and return as alist."
+  (json-read-from-string json-string))
+
+;;; Test Helpers
+
+(ert-deftest pi-org-todos-test-json-response-success ()
+  "Test JSON response generation for success."
+  (let ((result (pi/org-todo--json-response t '((foo . "bar")))))
+    (should (stringp result))
+    (let ((parsed (json-read-from-string result)))
+      (should (eq t (alist-get 'success parsed)))
+      (should (equal "bar" (alist-get 'foo (alist-get 'data parsed)))))))
+
+(ert-deftest pi-org-todos-test-json-response-error ()
+  "Test JSON response generation for error."
+  (let ((result (pi/org-todo--json-response nil nil "Something went wrong")))
+    (should (stringp result))
+    (let ((parsed (json-read-from-string result)))
+      (should (eq :json-false (alist-get 'success parsed)))
+      (should (equal "Something went wrong" (alist-get 'error parsed))))))
+
+;;; Read Operation Tests
+
+(ert-deftest pi-org-todos-test-list ()
+  "Test listing TODOs."
+  (unwind-protect
+      (progn
+        (pi-org-todos-test-setup)
+        (let* ((result (pi/org-todo-list))
+               (parsed (pi-org-todos-test-parse-result result)))
+          (should (eq t (alist-get 'success parsed)))
+          (let ((data (alist-get 'data parsed)))
+            (should (> (length data) 0))
+            ;; Should include TODO, NEXT, STRT but not DONE or WAIT by default
+            (should (cl-some (lambda (todo)
+                               (equal "TODO" (alist-get 'todo todo)))
+                             data))
+            (should (cl-some (lambda (todo)
+                               (equal "NEXT" (alist-get 'todo todo)))
+                             data)))))
+    (pi-org-todos-test-teardown)))
+
+(ert-deftest pi-org-todos-test-list-all ()
+  "Test listing all TODOs including done."
+  (unwind-protect
+      (progn
+        (pi-org-todos-test-setup)
+        (let* ((result (pi/org-todo-list-all))
+               (parsed (pi-org-todos-test-parse-result result)))
+          (should (eq t (alist-get 'success parsed)))
+          (let ((data (alist-get 'data parsed)))
+            ;; Should include DONE
+            (should (cl-some (lambda (todo)
+                               (equal "DONE" (alist-get 'todo todo)))
+                             data)))))
+    (pi-org-todos-test-teardown)))
+
+(ert-deftest pi-org-todos-test-scheduled ()
+  "Test getting scheduled items."
+  (unwind-protect
+      (progn
+        (pi-org-todos-test-setup)
+        (let* ((result (pi/org-todo-scheduled nil "2026-02-06"))
+               (parsed (pi-org-todos-test-parse-result result)))
+          (should (eq t (alist-get 'success parsed)))
+          (let ((data (alist-get 'data parsed)))
+            ;; Should find items scheduled for 2026-02-06
+            (should (cl-some (lambda (todo)
+                               (string-match-p "Review PR" (alist-get 'heading todo)))
+                             data)))))
+    (pi-org-todos-test-teardown)))
+
+(ert-deftest pi-org-todos-test-overdue ()
+  "Test getting overdue items."
+  (unwind-protect
+      (progn
+        (pi-org-todos-test-setup)
+        (let* ((result (pi/org-todo-overdue))
+               (parsed (pi-org-todos-test-parse-result result)))
+          (should (eq t (alist-get 'success parsed)))
+          ;; The NEXT item has deadline 2026-02-05 which is before today (2026-02-06)
+          (let ((data (alist-get 'data parsed)))
+            (should (cl-some (lambda (todo)
+                               (string-match-p "Fix CI/CD" (alist-get 'heading todo)))
+                             data)))))
+    (pi-org-todos-test-teardown)))
+
+(ert-deftest pi-org-todos-test-search ()
+  "Test searching TODOs."
+  (unwind-protect
+      (progn
+        (pi-org-todos-test-setup)
+        (let* ((result (pi/org-todo-search "pipeline"))
+               (parsed (pi-org-todos-test-parse-result result)))
+          (should (eq t (alist-get 'success parsed)))
+          (let ((data (alist-get 'data parsed)))
+            (should (> (length data) 0))
+            (should (cl-some (lambda (todo)
+                               (string-match-p "pipeline" (alist-get 'heading todo)))
+                             data)))))
+    (pi-org-todos-test-teardown)))
+
+(ert-deftest pi-org-todos-test-get ()
+  "Test getting a specific TODO."
+  (unwind-protect
+      (progn
+        (pi-org-todos-test-setup)
+        (let* ((result (pi/org-todo-get "Review PR for pipeline"))
+               (parsed (pi-org-todos-test-parse-result result)))
+          (should (eq t (alist-get 'success parsed)))
+          (let ((data (alist-get 'data parsed)))
+            (should (equal "Review PR for pipeline" (alist-get 'heading data)))
+            (should (equal "TODO" (alist-get 'todo data)))
+            (should (equal 2 (alist-get 'priority data))))))
+    (pi-org-todos-test-teardown)))
+
+(ert-deftest pi-org-todos-test-sections ()
+  "Test getting sections."
+  (unwind-protect
+      (progn
+        (pi-org-todos-test-setup)
+        (let* ((result (pi/org-todo-sections))
+               (parsed (pi-org-todos-test-parse-result result)))
+          (should (eq t (alist-get 'success parsed)))
+          (let ((data (append (alist-get 'data parsed) nil))) ; Convert vector to list
+            (should (member "Work" data))
+            (should (member "Projects" data))
+            (should (member "Personal" data)))))
+    (pi-org-todos-test-teardown)))
+
+(ert-deftest pi-org-todos-test-by-section ()
+  "Test getting TODOs by section."
+  (unwind-protect
+      (progn
+        (pi-org-todos-test-setup)
+        (let* ((result (pi/org-todo-by-section "Projects"))
+               (parsed (pi-org-todos-test-parse-result result)))
+          (should (eq t (alist-get 'success parsed)))
+          (let ((data (alist-get 'data parsed)))
+            (should (= 2 (length data)))
+            (should (cl-some (lambda (todo)
+                               (string-match-p "Homelab" (alist-get 'heading todo)))
+                             data)))))
+    (pi-org-todos-test-teardown)))
+
+(ert-deftest pi-org-todos-test-statistics ()
+  "Test getting statistics."
+  (unwind-protect
+      (progn
+        (pi-org-todos-test-setup)
+        (let* ((result (pi/org-todo-statistics))
+               (parsed (pi-org-todos-test-parse-result result)))
+          (should (eq t (alist-get 'success parsed)))
+          (let ((data (alist-get 'data parsed)))
+            (should (> (alist-get 'total data) 0))
+            ;; by_state uses symbol keys after JSON parsing
+            (let ((by-state (alist-get 'by_state data)))
+              (should (>= (alist-get 'TODO by-state) 0))))))
+    (pi-org-todos-test-teardown)))
+
+;;; Write Operation Tests
+
+(ert-deftest pi-org-todos-test-done ()
+  "Test marking TODO as done."
+  (unwind-protect
+      (progn
+        (pi-org-todos-test-setup)
+        (let* ((result (pi/org-todo-done "Buy groceries"))
+               (parsed (pi-org-todos-test-parse-result result)))
+          (should (eq t (alist-get 'success parsed)))
+          ;; Verify the state changed
+          (let* ((get-result (pi/org-todo-get "Buy groceries"))
+                 (get-parsed (pi-org-todos-test-parse-result get-result)))
+            (should (equal "DONE" (alist-get 'todo (alist-get 'data get-parsed)))))))
+    (pi-org-todos-test-teardown)))
+
+(ert-deftest pi-org-todos-test-state ()
+  "Test changing TODO state."
+  (unwind-protect
+      (progn
+        (pi-org-todos-test-setup)
+        (let* ((result (pi/org-todo-state "Buy groceries" "NEXT"))
+               (parsed (pi-org-todos-test-parse-result result)))
+          (should (eq t (alist-get 'success parsed)))
+          ;; Verify the state changed
+          (let* ((get-result (pi/org-todo-get "Buy groceries"))
+                 (get-parsed (pi-org-todos-test-parse-result get-result)))
+            (should (equal "NEXT" (alist-get 'todo (alist-get 'data get-parsed)))))))
+    (pi-org-todos-test-teardown)))
+
+(ert-deftest pi-org-todos-test-schedule ()
+  "Test scheduling a TODO."
+  (unwind-protect
+      (progn
+        (pi-org-todos-test-setup)
+        (let* ((result (pi/org-todo-schedule "NixOS refactoring" "2026-03-01"))
+               (parsed (pi-org-todos-test-parse-result result)))
+          (should (eq t (alist-get 'success parsed)))
+          ;; Verify the schedule was set
+          (let* ((get-result (pi/org-todo-get "NixOS refactoring"))
+                 (get-parsed (pi-org-todos-test-parse-result get-result)))
+            (should (string-match-p "2026-03-01"
+                                    (alist-get 'scheduled (alist-get 'data get-parsed)))))))
+    (pi-org-todos-test-teardown)))
+
+(ert-deftest pi-org-todos-test-priority ()
+  "Test setting priority."
+  (unwind-protect
+      (progn
+        (pi-org-todos-test-setup)
+        (let* ((result (pi/org-todo-priority "NixOS refactoring" 1))
+               (parsed (pi-org-todos-test-parse-result result)))
+          (should (eq t (alist-get 'success parsed)))
+          ;; Verify the priority was set
+          (let* ((get-result (pi/org-todo-get "NixOS refactoring"))
+                 (get-parsed (pi-org-todos-test-parse-result get-result)))
+            (should (equal 1 (alist-get 'priority (alist-get 'data get-parsed)))))))
+    (pi-org-todos-test-teardown)))
+
+(ert-deftest pi-org-todos-test-add ()
+  "Test adding a new TODO."
+  (unwind-protect
+      (progn
+        (pi-org-todos-test-setup)
+        (let* ((result (pi/org-todo-add "New test task" "Work" nil "2026-02-15" 2 '("test")))
+               (parsed (pi-org-todos-test-parse-result result)))
+          (should (eq t (alist-get 'success parsed)))
+          ;; Verify the TODO was created
+          (let* ((get-result (pi/org-todo-get "New test task"))
+                 (get-parsed (pi-org-todos-test-parse-result get-result)))
+            (should (alist-get 'data get-parsed))
+            (should (equal "TODO" (alist-get 'todo (alist-get 'data get-parsed)))))))
+    (pi-org-todos-test-teardown)))
+
+(ert-deftest pi-org-todos-test-not-found ()
+  "Test error handling for non-existent heading."
+  (unwind-protect
+      (progn
+        (pi-org-todos-test-setup)
+        (let* ((result (pi/org-todo-done "This heading does not exist"))
+               (parsed (pi-org-todos-test-parse-result result)))
+          (should (eq :json-false (alist-get 'success parsed)))
+          (should (string-match-p "not found" (alist-get 'error parsed)))))
+    (pi-org-todos-test-teardown)))
+
+(ert-deftest pi-org-todos-test-invalid-file ()
+  "Test error handling for non-existent file."
+  (let ((pi/org-todo-default-file "/nonexistent/path/todos.org"))
+    (let* ((result (pi/org-todo-list))
+           (parsed (pi-org-todos-test-parse-result result)))
+      (should (eq :json-false (alist-get 'success parsed))))))
+
+(provide 'pi-org-todos-test)
+;;; pi-org-todos-test.el ends here
dots/config/emacs/site-lisp/pi-org-todos.el
@@ -0,0 +1,310 @@
+;;; pi-org-todos.el --- Pi coding agent org-mode TODO interface -*- lexical-binding: t -*-
+
+;; Copyright (C) 2026 Vincent Demeester
+
+;; Author: Vincent Demeester <vincent@sbr.pm>
+;; Keywords: org, pi, automation
+;; Version: 1.0.0
+;; Package-Requires: ((emacs "27.1") (org-ql "0.8"))
+
+;;; Commentary:
+
+;; Thin wrapper around org-batch-functions.el for use with Pi coding agent.
+;; All functions return JSON strings for easy parsing by emacsclient.
+;;
+;; Usage from Pi (TypeScript):
+;;   emacsclient --eval '(pi/org-todo-list)'
+;;   emacsclient --eval '(pi/org-todo-search "NixOS")'
+;;   emacsclient --eval '(pi/org-todo-done "Review PR")'
+;;
+;; All functions use ORG_TODO_FILE environment variable or default file.
+
+;;; Code:
+
+(require 'org-batch-functions)
+(require 'json)
+
+;;; Configuration
+
+(defvar pi/org-todo-default-file
+  (expand-file-name "~/desktop/org/todos.org")
+  "Default org file for TODO operations.")
+
+(defun pi/org-todo--get-file (&optional file)
+  "Get the org file to use. FILE overrides, then env var, then default."
+  (or file
+      (getenv "ORG_TODO_FILE")
+      pi/org-todo-default-file))
+
+(defun pi/org-todo--json-response (success data &optional error)
+  "Create JSON response string.
+SUCCESS: boolean
+DATA: data to include
+ERROR: error message if any"
+  (json-encode
+   (if success
+       `((success . t) (data . ,data))
+     `((success . :json-false) (error . ,(or error "Unknown error"))))))
+
+(defun pi/org-todo--safe-call (func &rest args)
+  "Safely call FUNC with ARGS, return JSON response."
+  (condition-case err
+      (let ((result (apply func args)))
+        (pi/org-todo--json-response t result))
+    (error
+     (pi/org-todo--json-response nil nil (error-message-string err)))))
+
+;;; Read Operations
+
+(defun pi/org-todo-list (&optional file states)
+  "List TODOs from FILE with optional STATES filter.
+STATES: comma-separated string like \"TODO,NEXT\" or nil for active tasks.
+Returns JSON string."
+  (let* ((f (pi/org-todo--get-file file))
+         (state-list (or states "TODO,NEXT,STRT")))
+    (pi/org-todo--safe-call #'org-batch-list-todos f state-list)))
+
+(defun pi/org-todo-list-all (&optional file)
+  "List all TODOs from FILE including done/cancelled.
+Returns JSON string."
+  (let ((f (pi/org-todo--get-file file)))
+    (pi/org-todo--safe-call #'org-batch-list-todos f nil)))
+
+(defun pi/org-todo-scheduled (&optional file date)
+  "Get items scheduled for DATE from FILE.
+DATE: \"YYYY-MM-DD\" or \"today\" (default).
+Returns JSON string."
+  (let ((f (pi/org-todo--get-file file))
+        (d (or date "today")))
+    (pi/org-todo--safe-call #'org-batch-scheduled-today f d)))
+
+(defun pi/org-todo-upcoming (&optional file days)
+  "Get tasks scheduled or due in next DAYS from FILE.
+DAYS defaults to 7.
+Returns JSON string."
+  (let ((f (pi/org-todo--get-file file))
+        (n (or days 7)))
+    (pi/org-todo--safe-call #'org-batch-get-upcoming f n)))
+
+(defun pi/org-todo-overdue (&optional file)
+  "Get overdue tasks from FILE.
+Returns JSON string."
+  (let ((f (pi/org-todo--get-file file)))
+    (pi/org-todo--safe-call #'org-batch-get-overdue f)))
+
+(defun pi/org-todo-search (query &optional file with-content)
+  "Search TODOs for QUERY in FILE.
+WITH-CONTENT: if non-nil, include full content.
+Returns JSON string."
+  (let ((f (pi/org-todo--get-file file)))
+    (pi/org-todo--safe-call #'org-batch-search f query with-content)))
+
+(defun pi/org-todo-get (heading &optional file)
+  "Get full content of TODO with HEADING from FILE.
+Returns JSON string."
+  (let ((f (pi/org-todo--get-file file)))
+    (pi/org-todo--safe-call #'org-batch-get-todo-content f heading)))
+
+(defun pi/org-todo-sections (&optional file)
+  "Get list of top-level sections from FILE.
+Returns JSON string."
+  (let ((f (pi/org-todo--get-file file)))
+    (pi/org-todo--safe-call #'org-batch-get-sections f)))
+
+(defun pi/org-todo-by-section (section &optional file)
+  "Get TODOs in SECTION from FILE.
+Returns JSON string."
+  (let ((f (pi/org-todo--get-file file)))
+    (pi/org-todo--safe-call #'org-batch-by-section f section)))
+
+(defun pi/org-todo--fix-numeric-keys (alist)
+  "Convert numeric keys in ALIST to strings for JSON compatibility."
+  (mapcar (lambda (pair)
+            (let ((key (car pair))
+                  (val (cdr pair)))
+              (cons (if (numberp key) (number-to-string key) key)
+                    (if (and (listp val) (not (null val)) (consp (car val)))
+                        (pi/org-todo--fix-numeric-keys val)
+                      val))))
+          alist))
+
+(defun pi/org-todo-statistics (&optional file)
+  "Get TODO statistics from FILE.
+Returns JSON string with counts by state, priority, tags."
+  (let ((f (pi/org-todo--get-file file)))
+    (condition-case err
+        (let* ((stats (org-batch-get-statistics f))
+               ;; Fix by_priority which has numeric keys
+               (fixed-stats (pi/org-todo--fix-numeric-keys stats)))
+          (pi/org-todo--json-response t fixed-stats))
+      (error
+       (pi/org-todo--json-response nil nil (error-message-string err))))))
+
+;;; Write Operations
+
+(defun pi/org-todo-done (heading &optional file)
+  "Mark TODO with HEADING as DONE in FILE.
+Returns JSON string."
+  (let ((f (pi/org-todo--get-file file)))
+    (condition-case err
+        (if (org-batch-update-state f heading "DONE")
+            (pi/org-todo--json-response t `((heading . ,heading) (state . "DONE")))
+          (pi/org-todo--json-response nil nil (format "Heading not found: %s" heading)))
+      (error
+       (pi/org-todo--json-response nil nil (error-message-string err))))))
+
+(defun pi/org-todo-state (heading state &optional file)
+  "Set TODO with HEADING to STATE in FILE.
+STATE: \"TODO\", \"NEXT\", \"STRT\", \"WAIT\", \"DONE\", \"CANX\"
+Returns JSON string."
+  (let ((f (pi/org-todo--get-file file)))
+    (condition-case err
+        (if (org-batch-update-state f heading state)
+            (pi/org-todo--json-response t `((heading . ,heading) (state . ,state)))
+          (pi/org-todo--json-response nil nil (format "Heading not found: %s" heading)))
+      (error
+       (pi/org-todo--json-response nil nil (error-message-string err))))))
+
+(defun pi/org-todo-schedule (heading date &optional file)
+  "Schedule TODO with HEADING for DATE in FILE.
+DATE: \"YYYY-MM-DD\" format.
+Returns JSON string."
+  (let ((f (pi/org-todo--get-file file)))
+    (condition-case err
+        (if (org-batch-schedule-task f heading date)
+            (pi/org-todo--json-response t `((heading . ,heading) (scheduled . ,date)))
+          (pi/org-todo--json-response nil nil (format "Heading not found: %s" heading)))
+      (error
+       (pi/org-todo--json-response nil nil (error-message-string err))))))
+
+(defun pi/org-todo-deadline (heading date &optional file)
+  "Set deadline for TODO with HEADING to DATE in FILE.
+DATE: \"YYYY-MM-DD\" format.
+Returns JSON string."
+  (let ((f (pi/org-todo--get-file file)))
+    (condition-case err
+        (if (org-batch-set-deadline f heading date)
+            (pi/org-todo--json-response t `((heading . ,heading) (deadline . ,date)))
+          (pi/org-todo--json-response nil nil (format "Heading not found: %s" heading)))
+      (error
+       (pi/org-todo--json-response nil nil (error-message-string err))))))
+
+(defun pi/org-todo-priority (heading priority &optional file)
+  "Set PRIORITY (1-5) for TODO with HEADING in FILE.
+Returns JSON string."
+  (let ((f (pi/org-todo--get-file file)))
+    (condition-case err
+        (if (org-batch-set-priority f heading priority)
+            (pi/org-todo--json-response t `((heading . ,heading) (priority . ,priority)))
+          (pi/org-todo--json-response nil nil (format "Heading not found: %s" heading)))
+      (error
+       (pi/org-todo--json-response nil nil (error-message-string err))))))
+
+(defun pi/org-todo-add (heading section &optional file scheduled priority tags)
+  "Add new TODO with HEADING in SECTION of FILE.
+SCHEDULED: \"YYYY-MM-DD\" or nil
+PRIORITY: 1-5 or nil
+TAGS: list of tag strings or nil
+Returns JSON string."
+  (let ((f (pi/org-todo--get-file file)))
+    (condition-case err
+        (if (org-batch-add-todo f section heading scheduled priority tags)
+            (pi/org-todo--json-response t `((heading . ,heading)
+                                            (section . ,section)
+                                            (scheduled . ,scheduled)
+                                            (priority . ,priority)
+                                            (tags . ,tags)))
+          (pi/org-todo--json-response nil nil (format "Section not found: %s" section)))
+      (error
+       (pi/org-todo--json-response nil nil (error-message-string err))))))
+
+(defun pi/org-todo-append (heading content &optional file)
+  "Append CONTENT to TODO with HEADING in FILE.
+CONTENT: org-mode formatted text.
+Returns JSON string."
+  (let ((f (pi/org-todo--get-file file)))
+    ;; Write content to temp file for org-batch-append-content
+    (let ((temp-file (make-temp-file "pi-org-content-")))
+      (unwind-protect
+          (progn
+            (with-temp-file temp-file
+              (insert content))
+            (condition-case err
+                (if (org-batch-append-content f heading temp-file)
+                    (pi/org-todo--json-response t `((heading . ,heading) (appended . t)))
+                  (pi/org-todo--json-response nil nil (format "Heading not found: %s" heading)))
+              (error
+               (pi/org-todo--json-response nil nil (error-message-string err)))))
+        (delete-file temp-file)))))
+
+(defun pi/org-todo-archive-done (&optional file)
+  "Archive all DONE and CANX items in FILE.
+Returns JSON string with count."
+  (let ((f (pi/org-todo--get-file file)))
+    (condition-case err
+        (let ((count (org-batch-archive-done f)))
+          (pi/org-todo--json-response t `((archived . ,count))))
+      (error
+       (pi/org-todo--json-response nil nil (error-message-string err))))))
+
+;;; Tag Operations
+
+(defun pi/org-todo-add-tags (heading tags &optional file)
+  "Add TAGS to TODO with HEADING in FILE.
+TAGS: list of tag strings.
+Returns JSON string."
+  (let ((f (pi/org-todo--get-file file)))
+    (condition-case err
+        (if (org-batch-add-tags f heading tags)
+            (pi/org-todo--json-response t `((heading . ,heading) (tags_added . ,tags)))
+          (pi/org-todo--json-response nil nil (format "Heading not found: %s" heading)))
+      (error
+       (pi/org-todo--json-response nil nil (error-message-string err))))))
+
+(defun pi/org-todo-remove-tags (heading tags &optional file)
+  "Remove TAGS from TODO with HEADING in FILE.
+TAGS: list of tag strings.
+Returns JSON string."
+  (let ((f (pi/org-todo--get-file file)))
+    (condition-case err
+        (if (org-batch-remove-tags f heading tags)
+            (pi/org-todo--json-response t `((heading . ,heading) (tags_removed . ,tags)))
+          (pi/org-todo--json-response nil nil (format "Heading not found: %s" heading)))
+      (error
+       (pi/org-todo--json-response nil nil (error-message-string err))))))
+
+(defun pi/org-todo-all-tags (&optional file)
+  "List all unique tags used in FILE.
+Returns JSON string."
+  (let ((f (pi/org-todo--get-file file)))
+    (pi/org-todo--safe-call #'org-batch-list-all-tags f)))
+
+;;; Property Operations
+
+(defun pi/org-todo-get-property (heading property &optional file)
+  "Get PROPERTY value for TODO with HEADING in FILE.
+Returns JSON string."
+  (let ((f (pi/org-todo--get-file file)))
+    (condition-case err
+        (let ((value (org-batch-get-property f heading property)))
+          (pi/org-todo--json-response t `((heading . ,heading)
+                                          (property . ,property)
+                                          (value . ,value))))
+      (error
+       (pi/org-todo--json-response nil nil (error-message-string err))))))
+
+(defun pi/org-todo-set-property (heading property value &optional file)
+  "Set PROPERTY to VALUE for TODO with HEADING in FILE.
+Returns JSON string."
+  (let ((f (pi/org-todo--get-file file)))
+    (condition-case err
+        (if (org-batch-set-property f heading property value)
+            (pi/org-todo--json-response t `((heading . ,heading)
+                                            (property . ,property)
+                                            (value . ,value)))
+          (pi/org-todo--json-response nil nil (format "Heading not found: %s" heading)))
+      (error
+       (pi/org-todo--json-response nil nil (error-message-string err))))))
+
+(provide 'pi-org-todos)
+;;; pi-org-todos.el ends here
dots/pi/agent/extensions/org-todos/index.test.ts
@@ -0,0 +1,353 @@
+/**
+ * Tests for org-todos Pi extension
+ *
+ * Run with: bun test dots/pi/agent/extensions/org-todos/index.test.ts
+ *
+ * Note: These tests require Emacs daemon running with pi-org-todos.el loaded.
+ * For unit tests that don't require Emacs, see the mock tests below.
+ */
+
+import { describe, test, expect, beforeAll, afterAll } from "bun:test";
+import { execSync } from "node:child_process";
+import { writeFileSync, unlinkSync, existsSync } from "node:fs";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+
+// Test org file content
+const TEST_ORG_CONTENT = `#+title: Test TODOs
+
+* Work
+
+** TODO [#2] Review PR for pipeline
+SCHEDULED: <2026-02-06 Fri>
+:PROPERTIES:
+:CREATED: [2026-02-01 Mon]
+:END:
+
+** NEXT [#1] Fix CI/CD issue :urgent:
+DEADLINE: <2026-02-05 Thu>
+
+** DONE Completed task
+CLOSED: [2026-02-04 Thu 10:00]
+
+* Projects
+
+** TODO [#3] Homelab migration
+SCHEDULED: <2026-02-10 Wed>
+
+** TODO NixOS refactoring :nixos:homelab:
+`;
+
+// Test file path
+const TEST_FILE = join(tmpdir(), "pi-org-todos-test.org");
+
+// Helper to check if Emacs daemon is running
+function isEmacsDaemonRunning(): boolean {
+  try {
+    execSync("emacsclient --eval '(+ 1 1)'", { stdio: "pipe" });
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+// Helper to execute elisp and parse result
+function execEmacs(elisp: string): any {
+  const escaped = elisp.replace(/'/g, "'\\''");
+  const result = execSync(`emacsclient --eval '${escaped}'`, {
+    encoding: "utf-8",
+    timeout: 10000,
+  });
+
+  let jsonStr = result.trim();
+  if (jsonStr.startsWith('"') && jsonStr.endsWith('"')) {
+    jsonStr = jsonStr.slice(1, -1);
+  }
+  jsonStr = jsonStr.replace(/\\"/g, '"').replace(/\\\\/g, "\\");
+
+  return JSON.parse(jsonStr);
+}
+
+// Check if we can run integration tests
+const canRunIntegrationTests = isEmacsDaemonRunning();
+
+describe("org-todos extension", () => {
+  // Unit tests (no Emacs required)
+  describe("unit tests", () => {
+    test("formatTodo formats basic TODO", () => {
+      const todo = {
+        todo: "TODO",
+        heading: "Test task",
+        priority: 2,
+        tags: ["work", "urgent"],
+        scheduled: "<2026-02-06 Fri>",
+      };
+
+      // Inline the format logic for testing
+      const parts: string[] = [];
+      parts.push(`[${todo.todo}]`);
+      if (todo.priority) parts.push(`[#${todo.priority}]`);
+      parts.push(todo.heading);
+      if (todo.tags?.length) parts.push(`:${todo.tags.join(":")}:`);
+      if (todo.scheduled) parts.push(`(SCHEDULED: ${todo.scheduled})`);
+
+      const formatted = parts.join(" ");
+
+      expect(formatted).toContain("[TODO]");
+      expect(formatted).toContain("[#2]");
+      expect(formatted).toContain("Test task");
+      expect(formatted).toContain(":work:urgent:");
+      expect(formatted).toContain("SCHEDULED:");
+    });
+
+    test("formatTodo handles minimal TODO", () => {
+      const todo = {
+        todo: "NEXT",
+        heading: "Simple task",
+      };
+
+      const parts: string[] = [];
+      parts.push(`[${todo.todo}]`);
+      parts.push(todo.heading);
+      const formatted = parts.join(" ");
+
+      expect(formatted).toBe("[NEXT] Simple task");
+    });
+
+    test("action validation", () => {
+      const validActions = [
+        "list",
+        "scheduled",
+        "upcoming",
+        "overdue",
+        "search",
+        "get",
+        "done",
+        "state",
+        "schedule",
+        "deadline",
+        "priority",
+        "add",
+        "append",
+        "sections",
+        "statistics",
+        "archive",
+      ];
+
+      for (const action of validActions) {
+        expect(validActions).toContain(action);
+      }
+    });
+
+    test("elisp escaping", () => {
+      const heading = 'Task with "quotes" and \'apostrophes\'';
+      const escaped = heading.replace(/"/g, '\\"');
+      expect(escaped).toBe('Task with \\"quotes\\" and \'apostrophes\'');
+    });
+  });
+
+  // Integration tests (require Emacs daemon)
+  describe("integration tests", () => {
+    beforeAll(() => {
+      if (!canRunIntegrationTests) {
+        console.log("Skipping integration tests: Emacs daemon not running");
+        return;
+      }
+
+      // Create test file
+      writeFileSync(TEST_FILE, TEST_ORG_CONTENT);
+
+      // Ensure pi-org-todos.el is loaded
+      try {
+        execSync(
+          `emacsclient --eval '(progn (add-to-list (quote load-path) "${process.cwd()}/dots/config/emacs/site-lisp") (require (quote pi-org-todos)))'`,
+          { stdio: "pipe" }
+        );
+      } catch (e) {
+        console.log("Warning: Could not load pi-org-todos.el");
+      }
+    });
+
+    afterAll(() => {
+      if (existsSync(TEST_FILE)) {
+        unlinkSync(TEST_FILE);
+      }
+    });
+
+    test("list TODOs", () => {
+      if (!canRunIntegrationTests) return;
+
+      const result = execEmacs(`(pi/org-todo-list "${TEST_FILE}")`);
+
+      expect(result.success).toBe(true);
+      expect(Array.isArray(result.data)).toBe(true);
+      expect(result.data.length).toBeGreaterThan(0);
+
+      // Should include TODO and NEXT but not DONE
+      const states = result.data.map((t: any) => t.todo);
+      expect(states).toContain("TODO");
+      expect(states).toContain("NEXT");
+    });
+
+    test("search TODOs", () => {
+      if (!canRunIntegrationTests) return;
+
+      const result = execEmacs(`(pi/org-todo-search "pipeline" "${TEST_FILE}")`);
+
+      expect(result.success).toBe(true);
+      expect(result.data.length).toBeGreaterThan(0);
+      expect(result.data[0].heading).toContain("pipeline");
+    });
+
+    test("get specific TODO", () => {
+      if (!canRunIntegrationTests) return;
+
+      const result = execEmacs(
+        `(pi/org-todo-get "Review PR for pipeline" "${TEST_FILE}")`
+      );
+
+      expect(result.success).toBe(true);
+      expect(result.data.heading).toBe("Review PR for pipeline");
+      expect(result.data.todo).toBe("TODO");
+      expect(result.data.priority).toBe(2);
+    });
+
+    test("get sections", () => {
+      if (!canRunIntegrationTests) return;
+
+      const result = execEmacs(`(pi/org-todo-sections "${TEST_FILE}")`);
+
+      expect(result.success).toBe(true);
+      const sections = Array.isArray(result.data)
+        ? result.data
+        : Object.values(result.data);
+      expect(sections).toContain("Work");
+      expect(sections).toContain("Projects");
+    });
+
+    test("get statistics", () => {
+      if (!canRunIntegrationTests) return;
+
+      const result = execEmacs(`(pi/org-todo-statistics "${TEST_FILE}")`);
+
+      expect(result.success).toBe(true);
+      expect(result.data.total).toBeGreaterThan(0);
+      expect(result.data.by_state).toBeDefined();
+    });
+
+    test("mark TODO as done", () => {
+      if (!canRunIntegrationTests) return;
+
+      // Create a fresh test file with unique name
+      const doneTestFile = join(tmpdir(), `pi-org-done-test-${Date.now()}.org`);
+      writeFileSync(
+        doneTestFile,
+        `* Work
+** TODO Task to complete
+`
+      );
+
+      try {
+        const result = execEmacs(
+          `(pi/org-todo-done "Task to complete" "${doneTestFile}")`
+        );
+
+        expect(result.success).toBe(true);
+        expect(result.data.state).toBe("DONE");
+
+        // Verify file content directly (more reliable than re-reading via elisp)
+        const { readFileSync } = require("fs");
+        const content = readFileSync(doneTestFile, "utf-8");
+        expect(content).toContain("DONE Task to complete");
+      } finally {
+        if (existsSync(doneTestFile)) {
+          unlinkSync(doneTestFile);
+        }
+      }
+    });
+
+    test("change TODO state", () => {
+      if (!canRunIntegrationTests) return;
+
+      // Create a fresh test file with unique name
+      const stateTestFile = join(tmpdir(), `pi-org-state-test-${Date.now()}.org`);
+      writeFileSync(
+        stateTestFile,
+        `* Work
+** TODO Task to change
+`
+      );
+
+      try {
+        const result = execEmacs(
+          `(pi/org-todo-state "Task to change" "NEXT" "${stateTestFile}")`
+        );
+
+        expect(result.success).toBe(true);
+        expect(result.data.state).toBe("NEXT");
+
+        // Verify file content directly
+        const { readFileSync } = require("fs");
+        const content = readFileSync(stateTestFile, "utf-8");
+        expect(content).toContain("NEXT Task to change");
+      } finally {
+        if (existsSync(stateTestFile)) {
+          unlinkSync(stateTestFile);
+        }
+      }
+    });
+
+    test("add new TODO", () => {
+      if (!canRunIntegrationTests) return;
+
+      // Create a fresh test file with unique name
+      const addTestFile = join(tmpdir(), `pi-org-add-test-${Date.now()}.org`);
+      writeFileSync(
+        addTestFile,
+        `* Work
+** TODO Existing task
+`
+      );
+
+      try {
+        const result = execEmacs(
+          `(pi/org-todo-add "New task from test" "Work" "${addTestFile}" "2026-03-01" 2 '("test"))`
+        );
+
+        expect(result.success).toBe(true);
+        expect(result.data.heading).toBe("New task from test");
+
+        // Verify file content directly
+        const { readFileSync } = require("fs");
+        const content = readFileSync(addTestFile, "utf-8");
+        expect(content).toContain("TODO");
+        expect(content).toContain("New task from test");
+      } finally {
+        if (existsSync(addTestFile)) {
+          unlinkSync(addTestFile);
+        }
+      }
+    });
+
+    test("error handling for non-existent heading", () => {
+      if (!canRunIntegrationTests) return;
+
+      const result = execEmacs(
+        `(pi/org-todo-done "This heading does not exist" "${TEST_FILE}")`
+      );
+
+      expect(result.success).toBe(false);
+      expect(result.error).toContain("not found");
+    });
+
+    test("error handling for non-existent file", () => {
+      if (!canRunIntegrationTests) return;
+
+      const result = execEmacs(
+        `(pi/org-todo-list "/nonexistent/file.org")`
+      );
+
+      expect(result.success).toBe(false);
+    });
+  });
+});
dots/pi/agent/extensions/org-todos/index.ts
@@ -0,0 +1,471 @@
+/**
+ * Pi Extension: Org-mode TODO Management
+ *
+ * Provides TODO management using org-mode files as the backend.
+ * Uses emacsclient to communicate with Emacs daemon for fast,
+ * accurate org-mode parsing via org-ql.
+ *
+ * Tool: org_todo
+ *   Actions: list, scheduled, upcoming, overdue, search, get,
+ *            done, state, schedule, deadline, priority, add, append
+ *
+ * Commands:
+ *   /todos - Show today's tasks (scheduled + overdue + NEXT)
+ *   /todo-search <query> - Search TODOs
+ *
+ * Configuration:
+ *   ORG_TODO_FILE env var or defaults to ~/desktop/org/todos.org
+ *
+ * Requirements:
+ *   - Emacs daemon running (emacs --daemon)
+ *   - org-ql and pi-org-todos.el loaded
+ */
+
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import { execSync } from "node:child_process";
+import { homedir } from "node:os";
+import { join } from "node:path";
+
+// Configuration
+const DEFAULT_ORG_FILE = join(homedir(), "desktop/org/todos.org");
+
+interface OrgTodoResult {
+  success: boolean;
+  data?: any;
+  error?: string;
+}
+
+/**
+ * Execute elisp via emacsclient and parse JSON result
+ */
+function execEmacs(elisp: string): OrgTodoResult {
+  try {
+    // Escape single quotes in elisp for shell
+    const escaped = elisp.replace(/'/g, "'\\''");
+    
+    // Try emacsclient first (fast, uses daemon)
+    const result = execSync(`emacsclient --eval '${escaped}'`, {
+      encoding: "utf-8",
+      timeout: 10000,
+      stdio: ["pipe", "pipe", "pipe"],
+    });
+
+    // emacsclient returns elisp-escaped string, need to parse it
+    // Result looks like: "{\"success\":true,\"data\":[...]}"
+    // We need to unescape the outer quotes and parse
+    let jsonStr = result.trim();
+    
+    // Remove outer quotes if present (emacsclient wraps result in quotes)
+    if (jsonStr.startsWith('"') && jsonStr.endsWith('"')) {
+      jsonStr = jsonStr.slice(1, -1);
+    }
+    
+    // Unescape escaped quotes
+    jsonStr = jsonStr.replace(/\\"/g, '"');
+    // Unescape escaped backslashes
+    jsonStr = jsonStr.replace(/\\\\/g, '\\');
+    
+    return JSON.parse(jsonStr);
+  } catch (error: any) {
+    // Check if emacsclient failed (daemon not running)
+    if (error.message?.includes("emacsclient") || error.status === 1) {
+      return {
+        success: false,
+        error: "Emacs daemon not running. Start with: emacs --daemon",
+      };
+    }
+    return {
+      success: false,
+      error: error.message || String(error),
+    };
+  }
+}
+
+/**
+ * Format TODO item for display
+ */
+function formatTodo(todo: any): string {
+  const parts: string[] = [];
+  
+  // State with color hint
+  const state = todo.todo || "TODO";
+  parts.push(`[${state}]`);
+  
+  // Priority
+  if (todo.priority) {
+    parts.push(`[#${todo.priority}]`);
+  }
+  
+  // Heading
+  parts.push(todo.heading);
+  
+  // Tags
+  if (todo.tags && todo.tags.length > 0) {
+    parts.push(`:${todo.tags.join(":")}:`);
+  }
+  
+  // Scheduled/Deadline
+  const dates: string[] = [];
+  if (todo.scheduled) {
+    dates.push(`SCHEDULED: ${todo.scheduled}`);
+  }
+  if (todo.deadline) {
+    dates.push(`DEADLINE: ${todo.deadline}`);
+  }
+  if (dates.length > 0) {
+    parts.push(`(${dates.join(", ")})`);
+  }
+  
+  return parts.join(" ");
+}
+
+export default function (pi: ExtensionAPI) {
+  // Register the org_todo tool
+  pi.registerTool({
+    name: "org_todo",
+    label: "Org TODO",
+    description: `Manage org-mode TODOs. Actions:
+- list: List active TODOs (TODO, NEXT, STRT)
+- scheduled: Get today's scheduled items
+- upcoming: Get tasks in next N days (default 7)
+- overdue: Get overdue tasks
+- search: Search TODOs by query
+- get: Get full content of a TODO
+- done: Mark TODO as DONE
+- state: Change TODO state (TODO, NEXT, STRT, WAIT, DONE, CANX)
+- schedule: Set scheduled date
+- deadline: Set deadline date
+- priority: Set priority (1-5)
+- add: Create new TODO
+- append: Append content to TODO`,
+    parameters: {
+      type: "object",
+      properties: {
+        action: {
+          type: "string",
+          enum: [
+            "list", "scheduled", "upcoming", "overdue", "search", "get",
+            "done", "state", "schedule", "deadline", "priority", "add", "append",
+            "sections", "statistics", "archive"
+          ],
+          description: "Action to perform",
+        },
+        heading: {
+          type: "string",
+          description: "TODO heading (for get, done, state, schedule, etc.)",
+        },
+        query: {
+          type: "string",
+          description: "Search query (for search action)",
+        },
+        section: {
+          type: "string",
+          description: "Section name (for add action or by-section filter)",
+        },
+        state: {
+          type: "string",
+          enum: ["TODO", "NEXT", "STRT", "WAIT", "DONE", "CANX"],
+          description: "TODO state (for state action)",
+        },
+        date: {
+          type: "string",
+          description: "Date in YYYY-MM-DD format (for schedule/deadline)",
+        },
+        days: {
+          type: "number",
+          description: "Number of days (for upcoming action, default 7)",
+        },
+        priority: {
+          type: "number",
+          description: "Priority 1-5 (1=highest)",
+        },
+        content: {
+          type: "string",
+          description: "Content to append (org-mode format)",
+        },
+        tags: {
+          type: "array",
+          items: { type: "string" },
+          description: "Tags for new TODO",
+        },
+      },
+      required: ["action"],
+    },
+    execute: async (toolCallId, params, signal, onUpdate, ctx) => {
+      const { action, heading, query, section, state, date, days, priority, content, tags } = params;
+      
+      let elisp: string;
+      
+      switch (action) {
+        case "list":
+          if (section) {
+            elisp = `(pi/org-todo-by-section "${section}")`;
+          } else {
+            elisp = "(pi/org-todo-list)";
+          }
+          break;
+          
+        case "scheduled":
+          elisp = `(pi/org-todo-scheduled nil "${date || "today"}")`;
+          break;
+          
+        case "upcoming":
+          elisp = `(pi/org-todo-upcoming nil ${days || 7})`;
+          break;
+          
+        case "overdue":
+          elisp = "(pi/org-todo-overdue)";
+          break;
+          
+        case "search":
+          if (!query) {
+            return {
+              content: [{ type: "text", text: "Error: query is required for search action" }],
+            };
+          }
+          elisp = `(pi/org-todo-search "${query.replace(/"/g, '\\"')}")`;
+          break;
+          
+        case "get":
+          if (!heading) {
+            return {
+              content: [{ type: "text", text: "Error: heading is required for get action" }],
+            };
+          }
+          elisp = `(pi/org-todo-get "${heading.replace(/"/g, '\\"')}")`;
+          break;
+          
+        case "done":
+          if (!heading) {
+            return {
+              content: [{ type: "text", text: "Error: heading is required for done action" }],
+            };
+          }
+          elisp = `(pi/org-todo-done "${heading.replace(/"/g, '\\"')}")`;
+          break;
+          
+        case "state":
+          if (!heading || !state) {
+            return {
+              content: [{ type: "text", text: "Error: heading and state are required for state action" }],
+            };
+          }
+          elisp = `(pi/org-todo-state "${heading.replace(/"/g, '\\"')}" "${state}")`;
+          break;
+          
+        case "schedule":
+          if (!heading || !date) {
+            return {
+              content: [{ type: "text", text: "Error: heading and date are required for schedule action" }],
+            };
+          }
+          elisp = `(pi/org-todo-schedule "${heading.replace(/"/g, '\\"')}" "${date}")`;
+          break;
+          
+        case "deadline":
+          if (!heading || !date) {
+            return {
+              content: [{ type: "text", text: "Error: heading and date are required for deadline action" }],
+            };
+          }
+          elisp = `(pi/org-todo-deadline "${heading.replace(/"/g, '\\"')}" "${date}")`;
+          break;
+          
+        case "priority":
+          if (!heading || priority === undefined) {
+            return {
+              content: [{ type: "text", text: "Error: heading and priority are required for priority action" }],
+            };
+          }
+          elisp = `(pi/org-todo-priority "${heading.replace(/"/g, '\\"')}" ${priority})`;
+          break;
+          
+        case "add":
+          if (!heading || !section) {
+            return {
+              content: [{ type: "text", text: "Error: heading and section are required for add action" }],
+            };
+          }
+          const schedArg = date ? `"${date}"` : "nil";
+          const prioArg = priority !== undefined ? priority : "nil";
+          const tagsArg = tags && tags.length > 0 ? `'(${tags.map(t => `"${t}"`).join(" ")})` : "nil";
+          elisp = `(pi/org-todo-add "${heading.replace(/"/g, '\\"')}" "${section.replace(/"/g, '\\"')}" nil ${schedArg} ${prioArg} ${tagsArg})`;
+          break;
+          
+        case "append":
+          if (!heading || !content) {
+            return {
+              content: [{ type: "text", text: "Error: heading and content are required for append action" }],
+            };
+          }
+          elisp = `(pi/org-todo-append "${heading.replace(/"/g, '\\"')}" "${content.replace(/"/g, '\\"').replace(/\n/g, '\\n')}")`;
+          break;
+          
+        case "sections":
+          elisp = "(pi/org-todo-sections)";
+          break;
+          
+        case "statistics":
+          elisp = "(pi/org-todo-statistics)";
+          break;
+          
+        case "archive":
+          elisp = "(pi/org-todo-archive-done)";
+          break;
+          
+        default:
+          return {
+            content: [{ type: "text", text: `Unknown action: ${action}` }],
+          };
+      }
+      
+      const result = execEmacs(elisp);
+      
+      if (!result.success) {
+        return {
+          content: [{ type: "text", text: `Error: ${result.error}` }],
+        };
+      }
+      
+      // Format output based on action
+      let text: string;
+      
+      if (Array.isArray(result.data)) {
+        // List of TODOs
+        if (result.data.length === 0) {
+          text = "No TODOs found.";
+        } else {
+          text = result.data.map(formatTodo).join("\n");
+        }
+      } else if (typeof result.data === "object") {
+        // Single result or statistics
+        text = JSON.stringify(result.data, null, 2);
+      } else {
+        text = String(result.data);
+      }
+      
+      return {
+        content: [{ type: "text", text }],
+      };
+    },
+  });
+
+  // Register /todos command
+  pi.registerCommand("todos", {
+    description: "Show today's tasks (scheduled + overdue + NEXT)",
+    handler: async (args, ctx) => {
+      const theme = ctx.ui.theme;
+      
+      // Fetch scheduled, overdue, and NEXT items
+      const scheduled = execEmacs("(pi/org-todo-scheduled)");
+      const overdue = execEmacs("(pi/org-todo-overdue)");
+      const next = execEmacs('(pi/org-todo-list nil "NEXT")');
+      
+      if (!scheduled.success && !overdue.success && !next.success) {
+        ctx.ui.notify("Failed to fetch TODOs. Is Emacs daemon running?", "error");
+        return;
+      }
+      
+      const lines: string[] = [];
+      
+      // Header
+      lines.push(theme.bold("๐Ÿ“‹ Today's Tasks"));
+      lines.push(theme.fg("dim", "โ”€".repeat(50)));
+      
+      // Overdue section
+      if (overdue.success && overdue.data && overdue.data.length > 0) {
+        lines.push("");
+        lines.push(theme.fg("error", `โš ๏ธ  Overdue (${overdue.data.length})`));
+        for (const todo of overdue.data.slice(0, 5)) {
+          lines.push(`  ${theme.fg("error", "โ€ข")} ${formatTodo(todo)}`);
+        }
+        if (overdue.data.length > 5) {
+          lines.push(theme.fg("dim", `  ... and ${overdue.data.length - 5} more`));
+        }
+      }
+      
+      // Scheduled section
+      if (scheduled.success && scheduled.data && scheduled.data.length > 0) {
+        lines.push("");
+        lines.push(theme.fg("accent", `๐Ÿ“… Scheduled Today (${scheduled.data.length})`));
+        for (const todo of scheduled.data.slice(0, 5)) {
+          lines.push(`  ${theme.fg("accent", "โ€ข")} ${formatTodo(todo)}`);
+        }
+        if (scheduled.data.length > 5) {
+          lines.push(theme.fg("dim", `  ... and ${scheduled.data.length - 5} more`));
+        }
+      }
+      
+      // NEXT section
+      if (next.success && next.data && next.data.length > 0) {
+        lines.push("");
+        lines.push(theme.fg("success", `โžก๏ธ  Next Actions (${next.data.length})`));
+        for (const todo of next.data.slice(0, 5)) {
+          lines.push(`  ${theme.fg("success", "โ€ข")} ${formatTodo(todo)}`);
+        }
+        if (next.data.length > 5) {
+          lines.push(theme.fg("dim", `  ... and ${next.data.length - 5} more`));
+        }
+      }
+      
+      // Empty state
+      if (lines.length === 2) {
+        lines.push("");
+        lines.push(theme.fg("dim", "No tasks for today. ๐ŸŽ‰"));
+      }
+      
+      // Show as widget
+      ctx.ui.setWidget("todos", lines);
+      
+      // Auto-dismiss after 15 seconds
+      setTimeout(() => {
+        ctx.ui.setWidget("todos", undefined);
+      }, 15000);
+    },
+  });
+
+  // Register /todo-search command
+  pi.registerCommand("todo-search", {
+    description: "Search TODOs. Usage: /todo-search <query>",
+    handler: async (args, ctx) => {
+      const query = (args || "").trim();
+      
+      if (!query) {
+        ctx.ui.notify("Usage: /todo-search <query>", "error");
+        return;
+      }
+      
+      const theme = ctx.ui.theme;
+      const result = execEmacs(`(pi/org-todo-search "${query.replace(/"/g, '\\"')}" nil t)`);
+      
+      if (!result.success) {
+        ctx.ui.notify(`Search failed: ${result.error}`, "error");
+        return;
+      }
+      
+      if (!result.data || result.data.length === 0) {
+        ctx.ui.notify(`No TODOs found matching "${query}"`, "info");
+        return;
+      }
+      
+      const lines: string[] = [];
+      lines.push(theme.bold(`๐Ÿ” Search: "${query}" (${result.data.length} results)`));
+      lines.push(theme.fg("dim", "โ”€".repeat(50)));
+      
+      for (const todo of result.data.slice(0, 10)) {
+        const matchedIn = todo.matched_in === "heading" ? "" : theme.fg("dim", " (in content)");
+        lines.push(`  ${theme.fg("accent", "โ€ข")} ${formatTodo(todo)}${matchedIn}`);
+      }
+      
+      if (result.data.length > 10) {
+        lines.push(theme.fg("dim", `  ... and ${result.data.length - 10} more`));
+      }
+      
+      ctx.ui.setWidget("todo-search", lines);
+      
+      setTimeout(() => {
+        ctx.ui.setWidget("todo-search", undefined);
+      }, 20000);
+    },
+  });
+}
dots/pi/agent/extensions/org-todos/Makefile
@@ -0,0 +1,115 @@
+# org-todos Pi extension Makefile
+#
+# Usage:
+#   make test          - Run all tests
+#   make test-elisp    - Run elisp tests only
+#   make test-ts       - Run TypeScript tests only
+#   make test-unit     - Run TypeScript unit tests only (no Emacs needed)
+#   make build         - Build TypeScript
+#   make check-daemon  - Check if Emacs daemon is running
+#   make clean         - Clean build artifacts
+
+SHELL := /bin/bash
+
+# Paths
+ELISP_DIR := ../../../../config/emacs/site-lisp
+ELISP_FILES := org-batch-functions.el pi-org-todos.el
+ELISP_TEST := pi-org-todos-test.el
+
+# Colors for output
+GREEN := \033[0;32m
+RED := \033[0;31m
+YELLOW := \033[0;33m
+NC := \033[0m # No Color
+
+.PHONY: all test test-elisp test-ts test-unit build check-daemon clean help
+
+all: test
+
+help:
+	@echo "org-todos Pi extension"
+	@echo ""
+	@echo "Usage:"
+	@echo "  make test          - Run all tests (elisp + TypeScript)"
+	@echo "  make test-elisp    - Run elisp tests only"
+	@echo "  make test-ts       - Run TypeScript tests only"
+	@echo "  make test-unit     - Run TypeScript unit tests only (no Emacs needed)"
+	@echo "  make build         - Build TypeScript"
+	@echo "  make check-daemon  - Check if Emacs daemon is running"
+	@echo "  make clean         - Clean build artifacts"
+
+# Run all tests
+test: test-elisp test-ts
+	@echo ""
+	@echo -e "$(GREEN)โœ“ All tests passed$(NC)"
+
+# Run elisp tests
+test-elisp:
+	@echo -e "$(YELLOW)Running elisp tests...$(NC)"
+	@cd $(ELISP_DIR) && emacs --batch \
+		-l org \
+		-l org-ql \
+		-l org-batch-functions.el \
+		-l pi-org-todos.el \
+		-l ert \
+		-l pi-org-todos-test.el \
+		-f ert-run-tests-batch-and-exit
+	@echo -e "$(GREEN)โœ“ Elisp tests passed$(NC)"
+
+# Run TypeScript tests (requires Emacs daemon for integration tests)
+test-ts: check-daemon
+	@echo -e "$(YELLOW)Running TypeScript tests...$(NC)"
+	@bun test index.test.ts
+	@echo -e "$(GREEN)โœ“ TypeScript tests passed$(NC)"
+
+# Run only TypeScript unit tests (no Emacs needed)
+test-unit:
+	@echo -e "$(YELLOW)Running TypeScript unit tests...$(NC)"
+	@bun test index.test.ts --test-name-pattern "unit tests"
+	@echo -e "$(GREEN)โœ“ TypeScript unit tests passed$(NC)"
+
+# Build TypeScript
+build:
+	@echo -e "$(YELLOW)Building TypeScript...$(NC)"
+	@bun build index.ts --target=node --outdir dist
+	@echo -e "$(GREEN)โœ“ Build complete: dist/index.js$(NC)"
+
+# Check if Emacs daemon is running
+check-daemon:
+	@if ! emacsclient --eval '(+ 1 1)' >/dev/null 2>&1; then \
+		echo -e "$(RED)Error: Emacs daemon not running$(NC)"; \
+		echo "Start with: emacs --daemon"; \
+		echo ""; \
+		echo "Or run unit tests only: make test-unit"; \
+		exit 1; \
+	fi
+	@echo -e "$(GREEN)โœ“ Emacs daemon is running$(NC)"
+
+# Ensure pi-org-todos.el is loaded in daemon
+load-elisp: check-daemon
+	@echo -e "$(YELLOW)Loading pi-org-todos.el in Emacs daemon...$(NC)"
+	@emacsclient --eval "(progn \
+		(add-to-list 'load-path \"$(shell cd $(ELISP_DIR) && pwd)\") \
+		(require 'pi-org-todos))" >/dev/null
+	@echo -e "$(GREEN)โœ“ pi-org-todos.el loaded$(NC)"
+
+# Clean build artifacts
+clean:
+	@rm -rf dist
+	@rm -f *.log
+	@echo -e "$(GREEN)โœ“ Cleaned$(NC)"
+
+# Development helpers
+.PHONY: watch dev
+
+# Watch for changes and rebuild
+watch:
+	@echo "Watching for changes..."
+	@while true; do \
+		inotifywait -q -e modify index.ts $(ELISP_DIR)/pi-org-todos.el 2>/dev/null || sleep 2; \
+		clear; \
+		make build || true; \
+	done
+
+# Quick dev cycle: build and run unit tests
+dev: build test-unit