Files
passepartout/skills/org-skill-emacs-edit.org

15 KiB

SKILL: Emacs Edit (Org-Mode Structured Manipulation)

Overview

The Emacs Edit skill enables structured manipulation of org-mode files WITHOUT requiring an Emacs process. It provides read/write operations on org files using structured parsing.

Why NOT Emacs subprocess:

  • Keeps harness lightweight (no external process)
  • Simpler architecture, faster execution
  • Pure Lisp - easier to test and maintain

Capabilities:

  • Parse org files to AST (already exists via ingest-ast)
  • Write AST back to org format preserving structure
  • Create/update/delete headlines
  • Set properties with proper :ID: generation
  • Manage TODO states

Phase A: Demand (PRD)

1. Purpose

Enable structured org-file modification without breaking tangling.

2. User Needs

  • Parse org file → structured AST
  • Modify AST (add headline, set property, change TODO)
  • Write AST back → valid org file (preserves #+begin_src blocks)

3. Success Criteria

  • Read org files as structured data
  • Write back preserving PROPERTIES, drawers, code blocks
  • Generate unique IDs for new headlines
  • Change TODO states (TODO → DONE)

Phase B: Blueprint (PROTOCOL)

1. Architectural Intent

Single entry point `emacs-edit-modify` takes a file path, operation, and parameters.

2. Operations

  • `:read` - Parse file to AST
  • `:write` - Write AST back to file
  • `:add-headline` - Add new headline
  • `:set-property` - Set property on headline
  • `:set-todo` - Change TODO state

Phase D: Build (Implementation)

Package Context

(in-package :opencortex)

ID Generation

Generate unique IDs for headlines.

(defun emacs-edit-generate-id ()
  "Generates a unique ID for org-mode headlines.
Format: 8-char hex + timestamp for uniqueness."
  (let* ((data (format nil "~a-~a" (get-universal-time) (random 999999)))
         (digest (ironclad:digest-sequence :sha256 (ironclad:ascii-string-to-byte-array data)))
         (uuid (ironclad:byte-array-to-hex-string digest)))
    (subseq uuid 0 8)))

(defun emacs-edit-id-format (id)
  "Formats ID for org-mode (e.g., 'abc12345')."
  (if (search "id:" id)
      id
      (format nil "id:~a" id)))

Org Printer (AST → Org Format)

Converts AST back to org format, preserving structure.

(defun emacs-edit-print-headline (ast &key indent-level)
  "Converts a HEADLINE AST node to org text.
INDENT-LEVEL is number of leading asterisks."
  (let* ((level (or indent-level 1))
         (stars (make-string level :initial-element #\*))
         (title (or (getf (getf ast :properties) :TITLE) ""))
         (todo (getf (getf ast :properties) :TODO)))
    (format nil "~a ~a~%~a"
            stars
            (if todo (format nil "[~a] " (string-upcase todo)) "")
            title)))

(defun emacs-edit-print-properties (props)
  "Converts property list to :PROPERTIES: drawer."
  (when props
    (let ((lines (loop for (k v) on props by #'cddr
                      unless (member k '(:title :todo :created :id))
                      collect (format nil ":~a:~a" k v))))
      (when lines
        (format nil ":PROPERTIES:~%~{~a~^~%~}~%:END:~%"
                lines)))))

(defun emacs-edit-print-section (ast)
  "Prints :CONTENT: or description text."
  (let ((content (getf ast :content)))
    (when content
      content)))

(defun emacs-edit-ast-to-org (ast &key (indent-level 1))
  "Recursively converts an entire org AST back to org text.
Preserves structure including #+begin_src blocks."
  (let ((type (getf ast :type))
        (props (getf ast :properties))
        (contents (getf ast :contents))
        (elements (getf ast :elements)))
    
    (cond
      ;; Headline
      ((eq type :headline)
       (format nil "~%~a~a~%~a~{~a~}"
               (emacs-edit-print-headline ast :indent-level indent-level)
               (emacs-edit-print-properties props)
               (emacs-edit-print-section ast)
               (mapcar (lambda (child)
                         (emacs-edit-ast-to-org child :indent-level (1+ indent-level)))
                       (or contents elements))))
      
      ;; Section (body text)
      ((eq type :section)
       (emacs-edit-print-section ast))
      
      ;; Plain text / paragraph
      ((or (eq type :paragraph) (stringp ast))
       (format nil "~a~%" (if (stringp ast) ast (getf ast :raw-content))))
      
      ;; Code block (preserve exactly)
      ((eq type :src-block)
       (let ((lang (or (getf ast :language) ""))
             (code (or (getf ast :value) "")))
          (format nil "#+begin_src ~a~%~a~%#+end_src~%"
                 lang code)))
      
      ;; Unknown - return as-is
      (t (format nil "")))))

Read Operation

Parse org file to AST.

(defvar *org-parser-cache* (make-hash-table :test 'equal)
  "Cache for parsed org files.")

(defun emacs-edit-parse-file (file-path)
  "Parses an org FILE-PATH using existing ingest-ast.
Returns the parsed AST. Uses cache for performance."
  (let ((cached (gethash file-path *org-parser-cache*)))
    (when cached
      (return-from emacs-edit-parse-file cached)))
  
  (let* ((content (uiop:read-file-string file-path))
         (ast (ingest-ast (list :type :document :raw-content content))))
    (setf (gethash file-path *org-parser-cache*) ast)
    ast))

(defun emacs-edit-clear-cache (&optional file-path)
  "Clears the parser cache. If FILE-PATH provided, clears only that entry."
  (if file-path
      (remhash file-path *org-parser-cache*)
      (clrhash *org-parser-cache*)))

Write Operation

Write AST back to file preserving structure.

(defun emacs-edit-write-file (file-path ast)
  "Writes AST back to FILE-PATH, preserving org structure.
Clears cache after write."
  (opencortex::snapshot-memory)
  (let ((org-text (emacs-edit-ast-to-org ast)))
    (with-open-file (out file-path :direction :output :if-exists :supersede)
      (write-string org-text out)))
  (emacs-edit-clear-cache file-path)
  (harness-log "EMACS-EDIT: Wrote ~a" file-path))

Add Headline Operation

Add a new headline to an existing AST.

(defun emacs-edit-add-headline (ast title &key todo properties)
  "Adds a new headline to AST.
Returns modified AST."
  (let* ((new-id (emacs-edit-generate-id))
         (new-props (list :ID new-id
                         :TITLE title
                         :TODO (or todo "TODO")
                         :CREATED (format nil "[~a]" 
                                        (multiple-value-bind (s mi h d mo y)
                                            (decode-universal-time (get-universal-time))
                                          (format nil "~a-~a-~a ~a:~a"
                                                   y mo d h mi)))))
         (merged-props (loop for (k v) on properties by #'cddr
                            collect k collect v)))
    
    (setf merged-props (append merged-props new-props))
    
    (let ((new-headline (list :type :headline
                              :properties merged-props
                              :contents nil
                              :raw-content title)))
      (push new-headline (getf ast :contents))
      ast)))

Set Property Operation

Set a property on an existing headline (by ID or TITLE).

(defun emacs-edit-find-headline-by-id (ast target-id)
  "Recursively finds headline with matching :ID: property."
  (when (eq (getf ast :type) :headline)
    (let ((props (getf ast :properties)))
      (when (string= (getf props :ID) target-id)
        (return-from emacs-edit-find-headline-by-id ast))))
  
  (let ((contents (getf ast :contents)))
    (when contents
      (dolist (child contents)
        (let ((found (emacs-edit-find-headline-by-id child target-id)))
          (when found (return-from emacs-edit-find-headline-by-id found))))))
  nil)

(defun emacs-edit-find-headline-by-title (ast target-title)
  "Recursively finds headline with matching title."
  (when (eq (getf ast :type) :headline)
    (let ((props (getf ast :properties)))
      (when (string= (getf props :TITLE) target-title)
        (return-from emacs-edit-find-headline-by-title ast))))
  
  (let ((contents (getf ast :contents)))
    (when contents
      (dolist (child contents)
        (let ((found (emacs-edit-find-headline-by-title child target-title)))
          (when found (return-from emacs-edit-find-headline-by-title found))))))
  nil)

(defun emacs-edit-set-property (ast target property value)
  "Sets PROPERTY=VALUE on headline matching TARGET (ID or title).
Returns modified AST."
  (let ((headline (if (search "id:" target)
                      (emacs-edit-find-headline-by-id ast target)
                      (emacs-edit-find-headline-by-title ast target))))
    (when headline
      (setf (getf (getf headline :properties) property) value)
      (harness-log "EMACS-EDIT: Set ~a=~a on ~a" property value target)))
  ast)

Set TODO State Operation

Change TODO state (TODO → DONE → etc).

(defun emacs-edit-set-todo (ast target new-state)
  "Sets TODO state on headline matching TARGET.
NEW-STATE should be 'TODO', 'DONE', 'IN-PROGRESS', etc."
  (emacs-edit-set-property ast target :TODO new-state)
  (harness-log "EMACS-EDIT: Set TODO to ~a on ~a" new-state target))

Unified Entry Point

Main operation dispatcher.

(defun emacs-edit-modify (file-path operation &key params)
  "Main entry point for org-mode file manipulation.
OPERATIONS:
  :read - Parse file to AST, return AST
  :write - Write AST back to file (AST in params)
  :add-headline - Add headline (params: :title, :todo, :properties)
  :set-property - Set property (params: :target, :property, :value)
  :set-todo - Set TODO (params: :target, :state)"
  (let ((ast (emacs-edit-parse-file file-path)))
    
    (case operation
      (:read
       ast)
      
      (:write
       (let ((ast-to-write (getf params :ast)))
         (emacs-edit-write-file file-path ast-to-write)))
      
      (:add-headline
       (let ((title (getf params :title))
             (todo (getf params :todo))
             (properties (getf params :properties)))
         (emacs-edit-add-headline ast title :todo todo :properties properties)))
      
      (:set-property
       (let ((target (getf params :target))
             (property (getf params :property))
             (value (getf params :value)))
         (emacs-edit-set-property ast target property value)))
      
      (:set-todo
       (let ((target (getf params :target))
             (state (getf params :state)))
         (emacs-edit-set-todo ast target state)))
      
      (t
       (harness-log "EMACS-EDIT ERROR: Unknown operation ~a" operation)))))

Cognitive Tools

Exposes operations to the Probabilistic Engine.

(def-cognitive-tool :org-read
  "Reads an org-mode file and parses it to structured AST.
Use this BEFORE modifying org files to understand their structure."
  ((:file :type :string :description "Path to the org file"))
  :body (lambda (args)
          (let ((file (getf args :file)))
            (if (uiop:file-exists-p file)
                (emacs-edit-modify file :read)
                (list :status :error :reason "File not found")))))

(def-cognitive-tool :org-write
  "Writes previously parsed AST back to an org file.
Use this AFTER modifications to save changes."
  ((:file :type :string :description "Path to the org file")
   (:ast :type :list :description "The AST to write"))
  :body (lambda (args)
          (let ((file (getf args :file))
                (ast (getf args :ast)))
            (emacs-edit-modify file :write :params (list :ast ast))
            (list :status :success :message (format nil "Wrote ~a" file)))))

(def-cognitive-tool :org-add-headline
  "Adds a new headline to an org file."
  ((:file :type :string :description "Path to the org file")
   (:title :type :string :description "Headline title")
   (:todo :type :string :description "TODO state (default TODO)")
   (:properties :type :list :description "Plist of properties"))
  :body (lambda (args)
          (let ((file (getf args :file))
                (title (getf args :title))
                (todo (getf args :todo "TODO"))
                (properties (getf args :properties)))
            (emacs-edit-modify file :add-headline 
                             :params (list :title title :todo todo :properties properties))
            (list :status :success :message (format nil "Added headline: ~a" title)))))

(def-cognitive-tool :org-set-property
  "Sets a property on an existing headline (by ID or title)."
  ((:file :type :string :description "Path to the org file")
   (:target :type :string :description "Headline ID or title")
   (:property :type :string :description "Property name")
   (:value :type :string :description "Property value"))
  :body (lambda (args)
          (let ((file (getf args :file))
                (target (getf args :target))
                (property (getf args :property))
                (value (getf args :value)))
            (emacs-edit-modify file :set-property
                             :params (list :target target :property property :value value))
            (list :status :success :message (format nil "Set ~a=~a on ~a" property value target)))))

(def-cognitive-tool :org-set-todo
  "Sets the TODO state of a headline."
  ((:file :type :string :description "Path to the org file")
   (:target :type :string :description "Headline ID or title")
   (:state :type :string :description "New TODO state (TODO, DONE, etc)"))
  :body (lambda (args)
          (let ((file (getf args :file))
                (target (getf args :target))
                (state (getf args :state)))
            (emacs-edit-modify file :set-todo
                             :params (list :target target :state state))
            (list :status :success :message (format nil "Set ~a to ~a" target state)))))

Phase E: Chaos (Verification)

(defpackage :opencortex-emacs-edit-tests
  (:use :cl :fiveam :opencortex)
  (:export #:emacs-edit-suite))

(in-package :opencortex-emacs-edit-tests)

(def-suite emacs-edit-suite
  :description "Tests for Emacs Edit skill.")

(in-suite emacs-edit-suite)

(test id-generation
  (let ((id1 (emacs-edit-generate-id))
        (id2 (emacs-edit-generate-id)))
    (is (plusp (length id1)))
    (is (not (string= id1 id2))))) ;; Likely unique

(test id-format
  (let ((formatted (emacs-edit-id-format "abc12345")))
    (is (search "id:" formatted))))

(test property-setter
  (let ((ast (list :type :headline
                  :properties (list :ID "id:test123" :TITLE "Test")
                  :contents nil)))
    (emacs-edit-set-property ast "id:test123" :STATUS "ACTIVE")
    (is (string= (getf (getf ast :properties) :STATUS) "ACTIVE"))))

(test todo-setter
  (let ((ast (list :type :headline
                  :properties (list :ID "id:todo001" :TITLE "Task")
                  :contents nil)))
    (emacs-edit-set-todo ast "id:todo001" "DONE")
    (is (string= (getf (getf ast :properties) :TODO) "DONE"))))

See Also