Commit 543ee1e189fd

Vincent Demeester <vincent@sbr.pm>
2025-12-23 12:02:19
feat(org): Add comprehensive TODO management to org-manager
- Enable rich TODO inspection with full content and property retrieval - Support time-based task management with overdue/upcoming queries - Provide flexible tag and property operations for task organization - Establish test infrastructure with ERT framework for reliability Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent 2f43b48
dots/.config/claude/skills/Org/tools/tests/fixtures/test-todos.org
@@ -0,0 +1,65 @@
+* Work
+** TODO Review PR #123                                             :work:code:
+SCHEDULED: <2025-12-23>
+:PROPERTIES:
+:CREATED: [2025-12-20 Fri 10:00]
+:PR_URL: https://github.com/example/repo/pull/123
+:END:
+
+- [ ] Check for security issues
+- [ ] Verify tests pass
+- [ ] Review commit messages
+
+Focus on error handling in auth module.
+
+** NEXT [#2] Implement authentication                              :work:auth:
+SCHEDULED: <2025-12-24> DEADLINE: <2025-12-30>
+:PROPERTIES:
+:CREATED: [2025-12-21 Sat 14:30]
+:END:
+
+Using OAuth2 flow.
+
+** DONE Fix bug in parser                                          :work:bug:
+CLOSED: [2025-12-22 Sun 16:45]
+:PROPERTIES:
+:CREATED: [2025-12-22 Sun 10:00]
+:END:
+
+Parser was failing on multi-line strings.
+
+* Personal
+** TODO [#1] Buy groceries                                     :personal:life:
+:PROPERTIES:
+:CREATED: [2025-12-23 Mon 09:00]
+:END:
+SCHEDULED: <2025-12-23>
+
+- Milk
+- Bread
+- Eggs
+
+** WAIT Call dentist                                           :personal:health:
+:PROPERTIES:
+:CREATED: [2025-12-20 Fri 08:00]
+:PHONE: 555-1234
+:END:
+
+Waiting for office to open after holidays.
+
+** TODO Organize photos                                        :personal:media:
+DEADLINE: <2025-12-20>
+:PROPERTIES:
+:CREATED: [2025-12-15 Mon 10:00]
+:END:
+
+This task is overdue!
+
+* Archive
+** CANX [#3] Old project                                           :work:old:
+:PROPERTIES:
+:CREATED: [2025-11-01 Fri 10:00]
+:ARCHIVE: archive.org::* Cancelled Projects
+:END:
+
+Project was cancelled due to budget constraints.
dots/.config/claude/skills/Org/tools/tests/batch-functions-test.el
@@ -0,0 +1,281 @@
+;;; batch-functions-test.el --- Tests for org-mode batch operations -*- lexical-binding: t; no-byte-compile: t -*-
+
+;; Copyright (C) 2025 Vincent Demeester
+
+;; Author: Vincent Demeester <vincent@sbr.pm>
+;; Keywords: org, batch, automation, testing
+
+;;; Commentary:
+
+;; ERT tests for batch-functions.el
+;; Run with: emacs --batch -L .. -l batch-functions.el -l batch-functions-test.el -f ert-run-tests-batch-and-exit
+
+;;; Code:
+
+(require 'ert)
+(require 'org)
+(require 'org-element)
+
+;; Load batch-functions from parent directory
+(eval-and-compile
+  (let* ((current-file (or load-file-name buffer-file-name))
+         (parent-dir (when current-file
+                       (file-name-directory (directory-file-name (file-name-directory current-file))))))
+    (when parent-dir
+      (add-to-list 'load-path parent-dir)))
+  (require 'batch-functions))
+
+;;; Test Fixtures
+
+(defvar batch-test-fixture-dir
+  (expand-file-name "fixtures" (file-name-directory load-file-name))
+  "Directory containing test fixture files.")
+
+(defvar batch-test-fixture-file
+  (expand-file-name "test-todos.org" batch-test-fixture-dir)
+  "Main test fixture file.")
+
+;;; Helper Functions
+
+(defun batch-test--count-items (items)
+  "Count number of items in ITEMS list."
+  (length items))
+
+(defun batch-test--find-item-by-heading (items heading)
+  "Find item in ITEMS with matching HEADING."
+  (seq-find (lambda (item)
+              (string= (alist-get 'heading item) heading))
+            items))
+
+(defun batch-test--with-temp-org-file (content fn)
+  "Create temp org file with CONTENT, call FN with filepath, then cleanup."
+  (let ((temp-file (make-temp-file "org-test-" nil ".org")))
+    (unwind-protect
+        (progn
+          (with-temp-file temp-file
+            (insert content))
+          (funcall fn temp-file))
+      (when (file-exists-p temp-file)
+        (delete-file temp-file)))))
+
+;;; Tests for Read Operations
+
+(ert-deftest test-org-batch-list-todos ()
+  "Test listing all TODOs from file."
+  (let ((todos (org-batch-list-todos batch-test-fixture-file)))
+    (should (> (batch-test--count-items todos) 0))
+    ;; Should find "Review PR #123"
+    (should (batch-test--find-item-by-heading todos "Review PR #123"))
+    ;; Should find "Buy groceries"
+    (should (batch-test--find-item-by-heading todos "Buy groceries"))))
+
+(ert-deftest test-org-batch-list-todos-filter-state ()
+  "Test filtering TODOs by state."
+  (let ((next-todos (org-batch-list-todos batch-test-fixture-file "NEXT")))
+    (should (> (batch-test--count-items next-todos) 0))
+    ;; All items should have NEXT state
+    (dolist (todo next-todos)
+      (should (string= (alist-get 'todo todo) "NEXT")))
+    ;; Should find "Implement authentication"
+    (should (batch-test--find-item-by-heading next-todos "Implement authentication"))))
+
+(ert-deftest test-org-batch-list-todos-filter-priority ()
+  "Test filtering TODOs by priority."
+  (let ((high-priority (org-batch-list-todos batch-test-fixture-file nil 1)))
+    (should (> (batch-test--count-items high-priority) 0))
+    ;; All items should have priority 1
+    (dolist (todo high-priority)
+      (should (= (alist-get 'priority todo) 1)))
+    ;; Should find "Buy groceries"
+    (should (batch-test--find-item-by-heading high-priority "Buy groceries"))))
+
+(ert-deftest test-org-batch-list-todos-filter-tags ()
+  "Test filtering TODOs by tags."
+  (let ((work-todos (org-batch-list-todos batch-test-fixture-file nil nil '("work"))))
+    (should (> (batch-test--count-items work-todos) 0))
+    ;; All items should have :work: tag
+    (dolist (todo work-todos)
+      (should (member "work" (alist-get 'tags todo))))
+    ;; Should find "Review PR #123"
+    (should (batch-test--find-item-by-heading work-todos "Review PR #123"))))
+
+(ert-deftest test-org-batch-scheduled-today ()
+  "Test getting scheduled items for a specific date."
+  (let ((scheduled (org-batch-scheduled-today batch-test-fixture-file "2025-12-23")))
+    (should (>= (batch-test--count-items scheduled) 1))
+    ;; Should include "Review PR #123" and "Buy groceries"
+    (should (batch-test--find-item-by-heading scheduled "Review PR #123"))))
+
+(ert-deftest test-org-batch-by-section ()
+  "Test getting TODOs by section."
+  (let ((work-section (org-batch-by-section batch-test-fixture-file "Work")))
+    (should (> (batch-test--count-items work-section) 0))
+    ;; Should find work-related tasks
+    (should (batch-test--find-item-by-heading work-section "Review PR #123")))
+
+  (let ((personal-section (org-batch-by-section batch-test-fixture-file "Personal")))
+    (should (> (batch-test--count-items personal-section) 0))
+    ;; Should find personal tasks
+    (should (batch-test--find-item-by-heading personal-section "Buy groceries"))))
+
+(ert-deftest test-org-batch-count-by-state ()
+  "Test counting TODOs by state."
+  (let ((counts (org-batch-count-by-state batch-test-fixture-file)))
+    (should (> (alist-get 'total counts) 0))
+    (should (> (alist-get 'TODO counts) 0))
+    (should (>= (alist-get 'NEXT counts) 1))
+    (should (>= (alist-get 'DONE counts) 1))
+    (should (>= (alist-get 'WAIT counts) 1))))
+
+(ert-deftest test-org-batch-search ()
+  "Test searching for content in TODOs."
+  (let ((matches (org-batch-search batch-test-fixture-file "OAuth2")))
+    (should (> (batch-test--count-items matches) 0))
+    ;; Should find "Implement authentication"
+    (should (batch-test--find-item-by-heading matches "Implement authentication"))))
+
+(ert-deftest test-org-batch-get-sections ()
+  "Test getting list of top-level sections."
+  (let ((sections (org-batch-get-sections batch-test-fixture-file)))
+    (should (>= (length sections) 3))
+    (should (member "Work" sections))
+    (should (member "Personal" sections))
+    (should (member "Archive" sections))))
+
+(ert-deftest test-org-batch-get-children ()
+  "Test getting direct children of a heading."
+  (let ((work-children (org-batch-get-children batch-test-fixture-file "Work")))
+    (should (>= (batch-test--count-items work-children) 2))
+    ;; Should find direct children only
+    (should (batch-test--find-item-by-heading work-children "Review PR #123"))
+    (should (batch-test--find-item-by-heading work-children "Implement authentication"))))
+
+;;; Tests for Write Operations
+
+(ert-deftest test-org-batch-add-todo ()
+  "Test adding a new TODO item."
+  (batch-test--with-temp-org-file
+   "* Work\n"
+   (lambda (temp-file)
+     (let ((result (org-batch-add-todo temp-file "Work" "New Task"
+                                       "2025-12-25" 2 '("test" "task"))))
+       (should result)
+       ;; Verify it was added
+       (let ((todos (org-batch-list-todos temp-file)))
+         (let ((new-task (batch-test--find-item-by-heading todos "New Task")))
+           (should new-task)
+           (should (string= (alist-get 'todo new-task) "TODO"))
+           (should (= (alist-get 'priority new-task) 2))
+           (should (member "test" (alist-get 'tags new-task)))
+           (should (member "task" (alist-get 'tags new-task)))))))))
+
+(ert-deftest test-org-batch-update-state ()
+  "Test updating TODO state."
+  (batch-test--with-temp-org-file
+   "* Work\n** TODO Test Task\n"
+   (lambda (temp-file)
+     (let ((result (org-batch-update-state temp-file "Test Task" "DONE")))
+       (should result)
+       ;; Verify state was updated
+       (let ((todos (org-batch-list-todos temp-file)))
+         (let ((task (batch-test--find-item-by-heading todos "Test Task")))
+           (should task)
+           (should (string= (alist-get 'todo task) "DONE"))))))))
+
+(ert-deftest test-org-batch-schedule-task ()
+  "Test scheduling a task."
+  (batch-test--with-temp-org-file
+   "* Work\n** TODO Test Task\n"
+   (lambda (temp-file)
+     (let ((result (org-batch-schedule-task temp-file "Test Task" "2025-12-25")))
+       (should result)
+       ;; Verify schedule was set
+       (let ((scheduled (org-batch-scheduled-today temp-file "2025-12-25")))
+         (should (batch-test--find-item-by-heading scheduled "Test Task")))))))
+
+(ert-deftest test-org-batch-set-priority ()
+  "Test setting task priority."
+  (batch-test--with-temp-org-file
+   "* Work\n** TODO Test Task\n"
+   (lambda (temp-file)
+     (let ((result (org-batch-set-priority temp-file "Test Task" 1)))
+       (should result)
+       ;; Verify priority was set
+       (let ((todos (org-batch-list-todos temp-file)))
+         (let ((task (batch-test--find-item-by-heading todos "Test Task")))
+           (should task)
+           (should (= (alist-get 'priority task) 1))))))))
+
+;;; Tests for Utility Functions
+
+(ert-deftest test-org-batch--priority-conversion ()
+  "Test priority number/character conversion."
+  (should (= (org-batch--priority-to-number ?1) 1))
+  (should (= (org-batch--priority-to-number ?5) 5))
+  (should (= (org-batch--number-to-priority 1) ?1))
+  (should (= (org-batch--number-to-priority 5) ?5)))
+
+;;; Placeholder Tests for New Features
+
+(ert-deftest test-org-batch-get-todo-content ()
+  "Test getting full TODO content with metadata and body."
+  (let ((result (org-batch-get-todo-content batch-test-fixture-file "Review PR #123")))
+    (should result)
+    ;; Check basic metadata
+    (should (string= (alist-get 'heading result) "Review PR #123"))
+    (should (string= (alist-get 'todo result) "TODO"))
+    (should (member "work" (alist-get 'tags result)))
+    (should (member "code" (alist-get 'tags result)))
+    ;; Check properties
+    (let ((props (alist-get 'properties result)))
+      (should props)
+      (should (assoc "CREATED" props))
+      (should (assoc "PR_URL" props))
+      (should (string-match "github.com" (cdr (assoc "PR_URL" props)))))
+    ;; Check content
+    (let ((content (alist-get 'content result)))
+      (should content)
+      (should (string-match "Check for security" content))
+      (should (string-match "error handling" content))))
+
+  ;; Test non-existent heading
+  (let ((result (org-batch-get-todo-content batch-test-fixture-file "NonExistent Task")))
+    (should-not result)))
+
+(ert-deftest test-org-batch-add-tags ()
+  "Test adding tags to existing TODO (to be implemented)."
+  :expected-result :failed
+  (should nil))
+
+(ert-deftest test-org-batch-remove-tags ()
+  "Test removing tags from TODO (to be implemented)."
+  :expected-result :failed
+  (should nil))
+
+(ert-deftest test-org-batch-list-all-tags ()
+  "Test listing all unique tags (to be implemented)."
+  :expected-result :failed
+  (should nil))
+
+(ert-deftest test-org-batch-get-overdue ()
+  "Test getting overdue tasks (to be implemented)."
+  :expected-result :failed
+  (should nil))
+
+(ert-deftest test-org-batch-get-upcoming ()
+  "Test getting upcoming tasks (to be implemented)."
+  :expected-result :failed
+  (should nil))
+
+(ert-deftest test-org-batch-get-property ()
+  "Test getting property value (to be implemented)."
+  :expected-result :failed
+  (should nil))
+
+(ert-deftest test-org-batch-set-property ()
+  "Test setting property value (to be implemented)."
+  :expected-result :failed
+  (should nil))
+
+(provide 'batch-functions-test)
+;;; batch-functions-test.el ends here
dots/.config/claude/skills/Org/tools/tests/run-tests.sh
@@ -0,0 +1,135 @@
+#!/usr/bin/env bash
+# run-tests.sh - Run ERT tests for org-manager batch functions
+# Copyright (C) 2025 Vincent Demeester
+# Part of Claude Code Org skill
+
+set -euo pipefail
+
+# Configuration
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+TOOLS_DIR="$(dirname "$SCRIPT_DIR")"
+TEST_FILE="$SCRIPT_DIR/batch-functions-test.el"
+EMACS="${EMACS:-emacs}"
+
+# Colors
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+usage() {
+    cat <<EOF
+Usage: $0 [options]
+
+Run ERT tests for org-manager batch functions.
+
+Options:
+  -v, --verbose     Verbose output (show all test details)
+  -s, --selector    Run specific test selector (e.g., "test-org-batch-list-todos")
+  -h, --help        Show this help message
+
+Examples:
+  # Run all tests
+  $0
+
+  # Run specific test
+  $0 -s test-org-batch-list-todos
+
+  # Verbose output
+  $0 -v
+
+Environment:
+  EMACS            Path to emacs binary (default: emacs)
+
+EOF
+    exit 0
+}
+
+# Parse arguments
+VERBOSE=0
+SELECTOR=""
+
+while [[ $# -gt 0 ]]; do
+    case "$1" in
+        -v|--verbose)
+            VERBOSE=1
+            shift
+            ;;
+        -s|--selector)
+            SELECTOR="$2"
+            shift 2
+            ;;
+        -h|--help)
+            usage
+            ;;
+        *)
+            echo -e "${RED}Unknown option: $1${NC}"
+            usage
+            ;;
+    esac
+done
+
+# Check dependencies
+if ! command -v "$EMACS" &> /dev/null; then
+    echo -e "${RED}Error: Emacs not found${NC}"
+    echo "Set EMACS environment variable or install emacs"
+    exit 1
+fi
+
+if [[ ! -f "$TEST_FILE" ]]; then
+    echo -e "${RED}Error: Test file not found: $TEST_FILE${NC}"
+    exit 1
+fi
+
+echo -e "${YELLOW}Running org-manager tests...${NC}"
+echo "Test file: $TEST_FILE"
+echo "Emacs: $EMACS"
+
+if [[ -n "$SELECTOR" ]]; then
+    echo "Selector: $SELECTOR"
+fi
+echo ""
+
+# Build emacs command
+EMACS_CMD=(
+    "$EMACS"
+    --batch
+    --no-init-file
+    -L "$TOOLS_DIR"
+    -l "$TOOLS_DIR/batch-functions.el"
+    -l "$TEST_FILE"
+)
+
+if [[ -n "$SELECTOR" ]]; then
+    # Run specific test
+    EMACS_CMD+=(--eval "(ert-run-tests-batch-and-exit '$SELECTOR)")
+else
+    # Run all tests
+    EMACS_CMD+=(-f ert-run-tests-batch-and-exit)
+fi
+
+# Run tests
+if [[ "$VERBOSE" == "1" ]]; then
+    "${EMACS_CMD[@]}" 2>&1
+    EXIT_CODE=$?
+else
+    # Capture output and show summary
+    OUTPUT=$("${EMACS_CMD[@]}" 2>&1)
+    EXIT_CODE=$?
+
+    # Show summary
+    echo "$OUTPUT" | tail -20
+fi
+
+echo ""
+if [[ $EXIT_CODE -eq 0 ]]; then
+    echo -e "${GREEN}โœ“ All tests passed!${NC}"
+else
+    echo -e "${RED}โœ— Some tests failed${NC}"
+    if [[ "$VERBOSE" == "0" ]]; then
+        echo ""
+        echo "Run with -v for detailed output"
+    fi
+fi
+
+exit $EXIT_CODE
dots/.config/claude/skills/Org/tools/batch-functions.el
@@ -212,6 +212,88 @@ Returns only immediate children (level = parent + 1), not all descendants."
               (push (org-batch--element-to-alist hl) children))))))
       (nreverse children))))
 
+(defun org-batch-get-todo-content (file heading-name)
+  "Get full content of TODO with HEADING-NAME in FILE.
+Returns alist with metadata, properties, and body content.
+Returns nil if heading not found."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (let ((found nil)
+          (result nil))
+      (org-element-map (org-element-parse-buffer) 'headline
+        (lambda (hl)
+          (when (and (not found)
+                     (string= (org-element-property :raw-value hl) heading-name))
+            (setq found t)
+            ;; Build result with metadata
+            (let* ((basic-data (org-batch--element-to-alist hl))
+                   (properties (org-batch--extract-properties hl))
+                   (content (org-batch--extract-content hl)))
+              (setq result (append basic-data
+                                   (list (cons 'properties properties)
+                                         (cons 'content content))))))))
+      result)))
+
+(defun org-batch--extract-properties (element)
+  "Extract properties drawer from ELEMENT as alist."
+  (let ((properties '())
+        (begin (org-element-property :begin element))
+        (end (org-element-property :contents-end element)))
+    (when (and begin end)
+      (save-excursion
+        (goto-char begin)
+        (forward-line 1)  ; Skip heading line
+        ;; Look for :PROPERTIES: drawer
+        (when (re-search-forward "^[ \t]*:PROPERTIES:[ \t]*$" end t)
+          (let ((drawer-start (point)))
+            (when (re-search-forward "^[ \t]*:END:[ \t]*$" end t)
+              (let ((drawer-end (match-beginning 0)))
+                (goto-char drawer-start)
+                ;; Extract each property
+                (while (re-search-forward "^[ \t]*:\\([^:\n]+\\):[ \t]*\\(.*\\)$" drawer-end t)
+                  (let ((key (match-string 1))
+                        (value (match-string 2)))
+                    (push (cons key value) properties)))))))))
+    (nreverse properties)))
+
+(defun org-batch--extract-content (element)
+  "Extract body content from ELEMENT (excluding properties drawer).
+Returns the text content without the heading line or properties."
+  (let ((end (org-element-property :contents-end element))
+        (contents-begin (org-element-property :contents-begin element)))
+    (if (and contents-begin end)
+        (save-excursion
+          (let ((content-text (buffer-substring-no-properties contents-begin end)))
+            ;; Remove properties drawer if present
+            (with-temp-buffer
+              (insert content-text)
+              (goto-char (point-min))
+              ;; Remove SCHEDULED/DEADLINE lines (they're in metadata)
+              (while (re-search-forward "^[ \t]*\\(?:SCHEDULED\\|DEADLINE\\|CLOSED\\):.*$" nil t)
+                (replace-match ""))
+              ;; Remove properties drawer
+              (goto-char (point-min))
+              (when (re-search-forward "^[ \t]*:PROPERTIES:[ \t]*$" nil t)
+                (let ((drawer-start (match-beginning 0)))
+                  (when (re-search-forward "^[ \t]*:END:[ \t]*$" nil t)
+                    (delete-region drawer-start (point))
+                    ;; Remove trailing newline
+                    (when (looking-at "\n")
+                      (delete-char 1)))))
+              ;; Trim whitespace
+              (goto-char (point-min))
+              (while (re-search-forward "^[ \t]+$" nil t)
+                (replace-match ""))
+              (goto-char (point-min))
+              (skip-chars-forward "\n")
+              (delete-region (point-min) (point))
+              (goto-char (point-max))
+              (skip-chars-backward "\n")
+              (delete-region (point) (point-max))
+              (buffer-string))))
+      "")))
+
 ;;; Write Operations
 
 (defun org-batch-update-state (file heading new-state)
@@ -319,6 +401,196 @@ Returns t on success, nil if heading not found."
         (setq found t))
       found)))
 
+(defun org-batch-add-tags (file heading new-tags)
+  "Add NEW-TAGS to task with HEADING in FILE.
+NEW-TAGS is a list of tag strings to add (existing tags are preserved).
+Returns t on success, nil if heading not found."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (goto-char (point-min))
+    (let ((found nil)
+          (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\) \\(?:\\[#[1-5]\\] \\)?"
+                                  (regexp-quote heading))))
+      (when (re-search-forward heading-regexp nil t)
+        (org-back-to-heading)
+        (let* ((current-tags (org-get-tags))
+               (combined-tags (delete-dups (append current-tags new-tags))))
+          (org-set-tags combined-tags)
+          (write-region (point-min) (point-max) file)
+          (setq found t)))
+      found)))
+
+(defun org-batch-remove-tags (file heading tags-to-remove)
+  "Remove TAGS-TO-REMOVE from task with HEADING in FILE.
+TAGS-TO-REMOVE is a list of tag strings to remove.
+Returns t on success, nil if heading not found."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (goto-char (point-min))
+    (let ((found nil)
+          (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\) \\(?:\\[#[1-5]\\] \\)?"
+                                  (regexp-quote heading))))
+      (when (re-search-forward heading-regexp nil t)
+        (org-back-to-heading)
+        (let* ((current-tags (org-get-tags))
+               (remaining-tags (seq-difference current-tags tags-to-remove)))
+          (org-set-tags remaining-tags)
+          (write-region (point-min) (point-max) file)
+          (setq found t)))
+      found)))
+
+(defun org-batch-replace-tags (file heading new-tags)
+  "Replace all tags on task with HEADING in FILE with NEW-TAGS.
+NEW-TAGS is a list of tag strings.
+Returns t on success, nil if heading not found."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (goto-char (point-min))
+    (let ((found nil)
+          (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\) \\(?:\\[#[1-5]\\] \\)?"
+                                  (regexp-quote heading))))
+      (when (re-search-forward heading-regexp nil t)
+        (org-back-to-heading)
+        (org-set-tags new-tags)
+        (write-region (point-min) (point-max) file)
+        (setq found t))
+      found)))
+
+(defun org-batch-list-all-tags (file)
+  "List all unique tags used in FILE.
+Returns sorted list of tag strings."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (let ((all-tags '()))
+      (org-element-map (org-element-parse-buffer) 'headline
+        (lambda (hl)
+          (let ((tags (org-element-property :tags hl)))
+            (when tags
+              (setq all-tags (append all-tags tags))))))
+      (sort (delete-dups all-tags) #'string<))))
+
+(defun org-batch-get-overdue (file)
+  "Get all tasks with DEADLINE before today from FILE.
+Returns list of overdue tasks with their metadata."
+  (let ((today (format-time-string "%Y-%m-%d"))
+        (overdue-items '()))
+    (with-temp-buffer
+      (insert-file-contents file)
+      (org-mode)
+      (org-element-map (org-element-parse-buffer) 'headline
+        (lambda (hl)
+          (let ((todo (org-element-property :todo-keyword hl))
+                (deadline (org-element-property :deadline hl)))
+            ;; Only include active TODOs with deadlines
+            (when (and todo
+                       (not (member todo '("DONE" "CANX")))
+                       deadline)
+              (let ((deadline-date (org-element-property :raw-value deadline)))
+                ;; Extract YYYY-MM-DD from deadline
+                (when (string-match "\\([0-9]\\{4\\}\\)-\\([0-9]\\{2\\}\\)-\\([0-9]\\{2\\}\\)" deadline-date)
+                  (let ((dl-str (match-string 0 deadline-date)))
+                    ;; Compare dates (string comparison works for YYYY-MM-DD)
+                    (when (string< dl-str today)
+                      (push (org-batch--element-to-alist hl) overdue-items)))))))))
+      (nreverse overdue-items))))
+
+(defun org-batch-get-upcoming (file &optional days)
+  "Get tasks with DEADLINE or SCHEDULED in next DAYS from FILE.
+DAYS defaults to 7. Returns list of upcoming tasks."
+  (let* ((days-count (or days 7))
+         (today (current-time))
+         (future-date (time-add today (days-to-time days-count)))
+         (today-str (format-time-string "%Y-%m-%d" today))
+         (future-str (format-time-string "%Y-%m-%d" future-date))
+         (upcoming-items '()))
+    (with-temp-buffer
+      (insert-file-contents file)
+      (org-mode)
+      (org-element-map (org-element-parse-buffer) 'headline
+        (lambda (hl)
+          (let ((todo (org-element-property :todo-keyword hl))
+                (scheduled (org-element-property :scheduled hl))
+                (deadline (org-element-property :deadline hl)))
+            ;; Only include active TODOs
+            (when (and todo (not (member todo '("DONE" "CANX"))))
+              (let ((dates-to-check '()))
+                ;; Collect scheduled and deadline dates
+                (when scheduled
+                  (push (org-element-property :raw-value scheduled) dates-to-check))
+                (when deadline
+                  (push (org-element-property :raw-value deadline) dates-to-check))
+                ;; Check if any date is in range
+                (dolist (date-str dates-to-check)
+                  (when (string-match "\\([0-9]\\{4\\}\\)-\\([0-9]\\{2\\}\\)-\\([0-9]\\{2\\}\\)" date-str)
+                    (let ((task-date (match-string 0 date-str)))
+                      ;; Date is upcoming if: today <= date <= future
+                      (when (and (not (string< task-date today-str))
+                                 (not (string< future-str task-date)))
+                        (push (org-batch--element-to-alist hl) upcoming-items)
+                        ;; Stop checking other dates for this task
+                        (setq dates-to-check nil))))))))))
+      ;; Remove duplicates and reverse
+      (delete-dups (nreverse upcoming-items)))))
+
+(defun org-batch-get-property (file heading property-name)
+  "Get value of PROPERTY-NAME for task with HEADING in FILE.
+Returns property value string or nil if not found."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (goto-char (point-min))
+    (let ((found nil)
+          (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\)? ?\\(?:\\[#[1-5]\\] \\)?"
+                                  (regexp-quote heading))))
+      (when (re-search-forward heading-regexp nil t)
+        (org-back-to-heading)
+        (setq found (org-entry-get nil property-name)))
+      found)))
+
+(defun org-batch-set-property (file heading property-name value)
+  "Set PROPERTY-NAME to VALUE for task with HEADING in FILE.
+Returns t on success, nil if heading not found."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (goto-char (point-min))
+    (let ((found nil)
+          (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\)? ?\\(?:\\[#[1-5]\\] \\)?"
+                                  (regexp-quote heading))))
+      (when (re-search-forward heading-regexp nil t)
+        (org-back-to-heading)
+        (org-set-property property-name value)
+        (write-region (point-min) (point-max) file)
+        (setq found t))
+      found)))
+
+(defun org-batch-list-properties (file heading)
+  "List all properties for task with HEADING in FILE.
+Returns alist of (property . value) pairs."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (goto-char (point-min))
+    (let ((properties '())
+          (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\)? ?\\(?:\\[#[1-5]\\] \\)?"
+                                  (regexp-quote heading))))
+      (when (re-search-forward heading-regexp nil t)
+        (org-back-to-heading)
+        ;; Get all properties using org-entry-properties
+        (let ((props (org-entry-properties nil 'standard)))
+          (dolist (prop props)
+            (let ((key (car prop))
+                  (val (cdr prop)))
+              ;; Filter out special properties we don't want to show
+              (unless (member key '("CATEGORY" "BLOCKED" "ALLTAGS" "FILE" "PRIORITY_COOKIE"
+                                   "TODO" "TAGS" "ITEM"))
+                (push (cons key val) properties))))))
+      (nreverse properties))))
+
 (defun org-batch-archive-done (file)
   "Archive all DONE and CANX items in FILE.
 Returns count of archived items."
dots/.config/claude/skills/Org/tools/org-manager
@@ -117,6 +117,18 @@ READ COMMANDS:
   children <file> <heading>
       Get direct children of a specific heading
 
+  get <file> <heading>
+      Get full content and metadata of a specific TODO
+      Returns heading, state, priority, tags, scheduled, deadline, properties, and body content
+
+  overdue <file>
+      Get all tasks with deadlines before today
+      Only shows active TODOs (excludes DONE/CANX)
+
+  upcoming <file> [--days=N]
+      Get tasks scheduled or due in next N days (default: 7)
+      Shows tasks with SCHEDULED or DEADLINE dates in range
+
 WRITE COMMANDS:
   add <file> <heading> --section=NAME [--scheduled=DATE] [--priority=N] [--tags=TAG1,TAG2]
       Add new TODO item
@@ -136,6 +148,29 @@ WRITE COMMANDS:
   archive <file>
       Archive all DONE and CANX items
 
+TAG MANAGEMENT:
+  add-tags <file> <heading> <tags>
+      Add tags to existing TODO (comma-separated, e.g., work,urgent)
+
+  remove-tags <file> <heading> <tags>
+      Remove specific tags from TODO (comma-separated)
+
+  replace-tags <file> <heading> <tags>
+      Replace all tags on TODO with new set (comma-separated)
+
+  list-tags <file>
+      List all unique tags used in file
+
+PROPERTY OPERATIONS:
+  get-property <file> <heading> <property>
+      Get value of a specific property
+
+  set-property <file> <heading> <property> <value>
+      Set property value
+
+  list-properties <file> <heading>
+      List all properties of a heading
+
 DENOTE COMMANDS:
   denote-create <title> <tags> [--signature=SIG] [--category=CAT] [--directory=DIR] [--content=FILE]
       Create a denote-formatted note with proper naming and frontmatter
@@ -183,6 +218,9 @@ EXAMPLES:
   # Get children of a heading
   org-manager children ~/desktop/org/todos.org "Migrate aion"
 
+  # Get full content of a TODO
+  org-manager get ~/desktop/org/todos.org "Review PR"
+
   # Create denote note
   org-manager denote-create "NixOS Refactoring Plan" "nixos,refactoring,plan" \\
     --category=homelab --directory=~/desktop/org/notes
@@ -356,6 +394,65 @@ cmd_children() {
     run_elisp "$elisp"
 }
 
+cmd_get() {
+    local file="$1"
+    local heading="$2"
+
+    [[ -f "$file" ]] || error "File not found: $file"
+    [[ -n "$heading" ]] || error "Heading name required"
+
+    # Escape double quotes in heading for elisp string
+    local heading_escaped="${heading//\"/\\\"}"
+
+    local elisp="(progn
+      (let ((result (org-batch-get-todo-content \"$file\" \"$heading_escaped\")))
+        (if result
+            (org-batch-output-json t result)
+          (org-batch-output-error \"Heading not found: $heading\")))
+      (kill-emacs 0))"
+
+    run_elisp "$elisp"
+}
+
+cmd_overdue() {
+    local file="$1"
+
+    [[ -f "$file" ]] || error "File not found: $file"
+
+    local elisp="(progn
+      (let ((result (org-batch-get-overdue \"$file\")))
+        (org-batch-output-json t result))
+      (kill-emacs 0))"
+
+    run_elisp "$elisp"
+}
+
+cmd_upcoming() {
+    local file="$1"; shift
+    local days=7
+
+    [[ -f "$file" ]] || error "File not found: $file"
+
+    while [[ $# -gt 0 ]]; do
+        case "$1" in
+            --days=*)
+                days=$(parse_option "$1" "--days=")
+                shift
+                ;;
+            *)
+                error "Unknown option: $1"
+                ;;
+        esac
+    done
+
+    local elisp="(progn
+      (let ((result (org-batch-get-upcoming \"$file\" $days)))
+        (org-batch-output-json t result))
+      (kill-emacs 0))"
+
+    run_elisp "$elisp"
+}
+
 cmd_add() {
     local file="$1"
     local heading="$2"; shift 2
@@ -493,6 +590,147 @@ cmd_archive() {
     run_elisp "$elisp"
 }
 
+# Tag management commands
+
+cmd_add_tags() {
+    local file="$1"
+    local heading="$2"
+    local tags="$3"
+
+    [[ -f "$file" ]] || error "File not found: $file"
+    [[ -n "$heading" ]] || error "Heading required"
+    [[ -n "$tags" ]] || error "Tags required (comma-separated)"
+
+    # Convert comma-separated tags to quoted elisp list
+    local tags_list
+    tags_list="'($(echo "$tags" | sed 's/,/ /g' | sed 's/\([^ ]*\)/"\1"/g'))"
+
+    local elisp="(progn
+      (let ((result (org-batch-add-tags \"$file\" \"$heading\" $tags_list)))
+        (if result
+            (org-batch-output-json t (list :tags-added t :heading \"$heading\"))
+          (org-batch-output-error \"Heading not found: $heading\")))
+      (kill-emacs 0))"
+
+    run_elisp "$elisp"
+}
+
+cmd_remove_tags() {
+    local file="$1"
+    local heading="$2"
+    local tags="$3"
+
+    [[ -f "$file" ]] || error "File not found: $file"
+    [[ -n "$heading" ]] || error "Heading required"
+    [[ -n "$tags" ]] || error "Tags required (comma-separated)"
+
+    # Convert comma-separated tags to quoted elisp list
+    local tags_list
+    tags_list="'($(echo "$tags" | sed 's/,/ /g' | sed 's/\([^ ]*\)/"\1"/g'))"
+
+    local elisp="(progn
+      (let ((result (org-batch-remove-tags \"$file\" \"$heading\" $tags_list)))
+        (if result
+            (org-batch-output-json t (list :tags-removed t :heading \"$heading\"))
+          (org-batch-output-error \"Heading not found: $heading\")))
+      (kill-emacs 0))"
+
+    run_elisp "$elisp"
+}
+
+cmd_replace_tags() {
+    local file="$1"
+    local heading="$2"
+    local tags="$3"
+
+    [[ -f "$file" ]] || error "File not found: $file"
+    [[ -n "$heading" ]] || error "Heading required"
+    [[ -n "$tags" ]] || error "Tags required (comma-separated)"
+
+    # Convert comma-separated tags to quoted elisp list
+    local tags_list
+    tags_list="'($(echo "$tags" | sed 's/,/ /g' | sed 's/\([^ ]*\)/"\1"/g'))"
+
+    local elisp="(progn
+      (let ((result (org-batch-replace-tags \"$file\" \"$heading\" $tags_list)))
+        (if result
+            (org-batch-output-json t (list :tags-replaced t :heading \"$heading\"))
+          (org-batch-output-error \"Heading not found: $heading\")))
+      (kill-emacs 0))"
+
+    run_elisp "$elisp"
+}
+
+cmd_list_tags() {
+    local file="$1"
+
+    [[ -f "$file" ]] || error "File not found: $file"
+
+    local elisp="(progn
+      (let ((tags (org-batch-list-all-tags \"$file\")))
+        (org-batch-output-json t tags))
+      (kill-emacs 0))"
+
+    run_elisp "$elisp"
+}
+
+# Property operations commands
+
+cmd_get_property() {
+    local file="$1"
+    local heading="$2"
+    local property="$3"
+
+    [[ -f "$file" ]] || error "File not found: $file"
+    [[ -n "$heading" ]] || error "Heading required"
+    [[ -n "$property" ]] || error "Property name required"
+
+    local elisp="(progn
+      (let ((value (org-batch-get-property \"$file\" \"$heading\" \"$property\")))
+        (if value
+            (org-batch-output-json t (list :property \"$property\" :value value))
+          (org-batch-output-error \"Property not found or heading not found\")))
+      (kill-emacs 0))"
+
+    run_elisp "$elisp"
+}
+
+cmd_set_property() {
+    local file="$1"
+    local heading="$2"
+    local property="$3"
+    local value="$4"
+
+    [[ -f "$file" ]] || error "File not found: $file"
+    [[ -n "$heading" ]] || error "Heading required"
+    [[ -n "$property" ]] || error "Property name required"
+    [[ -n "$value" ]] || error "Property value required"
+
+    local elisp="(progn
+      (let ((result (org-batch-set-property \"$file\" \"$heading\" \"$property\" \"$value\")))
+        (if result
+            (org-batch-output-json t (list :property-set t :property \"$property\" :value \"$value\"))
+          (org-batch-output-error \"Heading not found: $heading\")))
+      (kill-emacs 0))"
+
+    run_elisp "$elisp"
+}
+
+cmd_list_properties() {
+    local file="$1"
+    local heading="$2"
+
+    [[ -f "$file" ]] || error "File not found: $file"
+    [[ -n "$heading" ]] || error "Heading required"
+
+    local elisp="(progn
+      (let ((props (org-batch-list-properties \"$file\" \"$heading\")))
+        (org-batch-output-json t props))
+      (kill-emacs 0))"
+
+    run_elisp "$elisp"
+}
+
 # Denote commands
 
 cmd_denote_create() {
@@ -670,6 +908,15 @@ main() {
         children)
             cmd_children "$@"
             ;;
+        get)
+            cmd_get "$@"
+            ;;
+        overdue)
+            cmd_overdue "$@"
+            ;;
+        upcoming)
+            cmd_upcoming "$@"
+            ;;
         add)
             cmd_add "$@"
             ;;
@@ -688,6 +935,27 @@ main() {
         archive)
             cmd_archive "$@"
             ;;
+        add-tags)
+            cmd_add_tags "$@"
+            ;;
+        remove-tags)
+            cmd_remove_tags "$@"
+            ;;
+        replace-tags)
+            cmd_replace_tags "$@"
+            ;;
+        list-tags)
+            cmd_list_tags "$@"
+            ;;
+        get-property)
+            cmd_get_property "$@"
+            ;;
+        set-property)
+            cmd_set_property "$@"
+            ;;
+        list-properties)
+            cmd_list_properties "$@"
+            ;;
         denote-create)
             cmd_denote_create "$@"
             ;;
dots/.config/claude/skills/Org/SKILL.md
@@ -44,6 +44,42 @@ Provide reliable, programmatic access to org-mode files using Emacs batch mode a
 
 # Search
 ./tools/org-manager search ~/desktop/org/todos.org "term"
+
+# Get full TODO content (metadata + body)
+./tools/org-manager get ~/desktop/org/todos.org "Task name"
+
+# Get overdue tasks (deadline before today)
+./tools/org-manager overdue ~/desktop/org/todos.org
+
+# Get upcoming tasks (scheduled/deadline in next N days)
+./tools/org-manager upcoming ~/desktop/org/todos.org --days=7
+```
+
+#### Tag Management
+```bash
+# List all unique tags in file
+./tools/org-manager list-tags ~/desktop/org/todos.org
+
+# Add tags to existing TODO
+./tools/org-manager add-tags ~/desktop/org/todos.org "Task name" "urgent,review"
+
+# Remove specific tags
+./tools/org-manager remove-tags ~/desktop/org/todos.org "Task name" "urgent"
+
+# Replace all tags with new set
+./tools/org-manager replace-tags ~/desktop/org/todos.org "Task name" "done,archived"
+```
+
+#### Property Operations
+```bash
+# List all properties of a heading
+./tools/org-manager list-properties ~/desktop/org/todos.org "Task name"
+
+# Get specific property value
+./tools/org-manager get-property ~/desktop/org/todos.org "Task name" "PR_URL"
+
+# Set property value
+./tools/org-manager set-property ~/desktop/org/todos.org "Task name" "STATUS" "In Progress"
 ```
 
 #### Denote Operations
@@ -99,6 +135,11 @@ All commands return JSON:
 - `org-batch-by-section` - Filter by section
 - `org-batch-count-by-state` - Count statistics
 - `org-batch-search` - Full-text search
+- `org-batch-get-children` - Get direct children of a heading
+- `org-batch-get-sections` - List all top-level sections
+- `org-batch-get-todo-content` - Get full TODO content (metadata + body + properties)
+- `org-batch-get-overdue` - Get tasks with deadline before today
+- `org-batch-get-upcoming` - Get tasks scheduled/due in next N days
 - `org-batch-add-todo` - Add new TODO
 - `org-batch-update-state` - Change states
 - `org-batch-schedule-task` - Set SCHEDULED
@@ -106,6 +147,17 @@ All commands return JSON:
 - `org-batch-set-priority` - Set priority
 - `org-batch-archive-done` - Archive items
 
+**Tag Operations:**
+- `org-batch-add-tags` - Add tags while preserving existing
+- `org-batch-remove-tags` - Remove specific tags
+- `org-batch-replace-tags` - Replace all tags with new set
+- `org-batch-list-all-tags` - Get all unique tags in file
+
+**Property Operations:**
+- `org-batch-get-property` - Get specific property value
+- `org-batch-set-property` - Set property value
+- `org-batch-list-properties` - List all properties of a heading
+
 ### Denote Functions (denote-batch-functions.el)
 
 **Note Creation and Management:**