Commit 508bd0115069
Changed files (5)
dots
config
emacs
site-lisp
pi
agent
extensions
org-todos
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