#+TITLE: SKILL: Event Orchestrator (system-event-orchestrator.org) #+AUTHOR: Agent #+FILETAGS: :system:orchestrator:hooks:cron: #+PROPERTY: header-args:lisp :tangle ../lisp/system-event-orchestrator.lisp * Architectural Intent The Event Orchestrator unifies three control-plane mechanisms that were previously scattered across the system: 1. **Hooks** — actions triggered when Org nodes with specific ~#+HOOK:~ properties are modified 2. **Cron** — time-based scheduled jobs using Org-mode timestamp repeat expressions 3. **Routing** — three-tier complexity classifier that decides whether a job needs the LLM at all Before the Orchestrator, each of these was handled ad-hoc. The heartbeat thread injected raw ~:heartbeat~ signals that skills had to parse themselves. Memory auto-save was a hardcoded counter in ~core-loop~. There was no way to say "when this file changes, verify its integrity" or "archive old tasks every Sunday." The Orchestrator attaches to the heartbeat as a deterministic gate (same pattern as the Dispatcher, the Archivist, and every other heartbeat-driven skill). On each tick, it checks the cron registry for due jobs and dispatches them at the appropriate tier. ** The three tiers: | Tier | LLM? | Mechanism | Example | |------|------|-----------|---------| | ~:reflex~ | No | Direct function call | "Run integrity check" | | ~:cognition~ | Light | Injected as user-input | "Summarize today's notes" | | ~:reasoning~ | Full | Injected as user-input | "Plan the project architecture" | The default classifier uses keywords in the context to determine the tier: ~rm~, ~write-file~, ~shell~ → ~:reflex~; ~summarize~, ~list~, ~find~ → ~:cognition~; everything else → ~:reasoning~. This can be overridden by setting ~*tier-classifier*~ to a custom function. * Implementation ** Package definition #+begin_src lisp (defpackage :passepartout.system-event-orchestrator (:use :cl :passepartout) (:export :orchestrator-register-hook :orchestrator-register-cron :orchestrator-classify :orchestrator-on-heartbeat :orchestrator-bootstrap :orchestrator-dispatch :default-classifier :parse-org-repeat :*hook-registry* :*cron-registry* :*tier-classifier*)) (in-package :passepartout.system-event-orchestrator) #+end_src ** Registries The hook registry maps Org-mode property names (like ~verify-integrity~ from a ~#+HOOK: verify-integrity~ headline property) to lists of gate function symbols. When a node with that hook is modified, the orchestrator calls each gate in sequence. The cron registry maps job names (keywords like ~:weekly-report~) to configuration plists. Each entry contains the repeat expression, the action function, and the dispatch tier. #+begin_src lisp (defvar *hook-registry* (make-hash-table :test 'equal) "Maps hook property string → list of gate function symbols.") (defvar *cron-registry* (make-hash-table :test 'equal) "Maps job name string → plist (:next-run :expression :repeat :action :tier).") (defvar *tier-classifier* nil "Optional function (context) → :reflex | :cognition | :reasoning.") #+end_src ** Default tier classifier Uses keyword matching on the context text to determine which tier to dispatch at. The matching is deliberately coarse — it's a heuristic, not an exact science. Users who need precise control can set ~*tier-classifier*~ to their own function. #+begin_src lisp (defun default-classifier (context) "Rule-based tier classification. :reflex — file/shell operations, deterministic checks :cognition — text processing, summarization, simple Q&A :reasoning — planning, analysis, multi-step decisions" (let* ((text (or (getf context :text) "")) (lower (string-downcase text))) (cond ((or (search "rm " lower) (search "write-file" lower) (search "shell" lower) (search "verify-" lower)) :reflex) ((or (search "summarize" lower) (search "list" lower) (search "find " lower) (search "what is" lower) (search "search" lower)) :cognition) (t :reasoning)))) #+end_src ** Parsing Org-mode repeat timestamps Org-mode timestamps use the format ~+<2026-05-02 Sat +1w>~ for repeating events. The ~+1w~ means "repeat every week," ~+1d~ means "every day," etc. This function extracts the repeat unit and value. Returns ~(UNIT VALUE)~ like ~(:W 1)~ for weekly, or ~NIL~ if there's no repeat clause. #+begin_src lisp (defun parse-org-repeat (timestamp-string) (let* ((cleaned (string-trim '(#\< #\> #\Newline #\Tab) timestamp-string)) (parts (uiop:split-string cleaned :separator '(#\space))) (repeat-part (ignore-errors (car (last parts))))) (when (and repeat-part (uiop:string-prefix-p "+" repeat-part)) (let* ((rest (subseq repeat-part 1)) (num-end (position-if (lambda (c) (not (digit-char-p c))) rest)) (num (parse-integer (subseq rest 0 num-end))) (unit-str (subseq rest num-end))) (list (intern (string-upcase unit-str) :keyword) num))))) #+end_src ** Registering a hook Called at boot or when a new ~#+HOOK:~ property is discovered. Appends the gate function to the registry entry for that hook. #+begin_src lisp (defun orchestrator-register-hook (hook-property gate-function) "Registers a deterministic gate to fire when an Org node with the #+HOOK: property matching HOOK-PROPERTY is modified." (push gate-function (gethash (string-downcase (string hook-property)) *hook-registry*)) (log-message "ORCHESTRATOR: Hook ~a → ~a" hook-property gate-function)) #+end_src ** Registering a cron job Each cron job has a name, an Org-mode timestamp with optional repeat, an action function, and a dispatch tier. The ~:next-run~ field is initialized to the current time so the job fires on the first heartbeat cycle (it will be rescheduled according to the repeat pattern after execution). #+begin_src lisp (defun orchestrator-register-cron (name expression action-function tier) "Register a cron job. NAME is a keyword, EXPRESSION is an Org-mode timestamp string with optional repeat. TIER is :reflex :cognition :reasoning." (let* ((repeat (parse-org-repeat expression)) (now (get-universal-time))) (setf (gethash (string-downcase (string name)) *cron-registry*) (list :next-run now :expression expression :repeat repeat :action action-function :tier tier)) (log-message "ORCHESTRATOR: Cron ~a (tier: ~a, repeat: ~a)" name tier repeat))) #+end_src ** Dispatch Routes an action to the appropriate executor based on its tier. Reflex actions are called directly (deterministic, no LLM overhead). Cognition and reasoning actions are injected as user-input events, which triggers the normal Perceive → Reason → Act pipeline (but at different model tiers). #+begin_src lisp (defun orchestrator-dispatch (action tier) "Execute ACTION at the specified TIER." (flet ((safe-inject (text) (when (fboundp (find-symbol "STIMULUS-INJECT" :passepartout)) (funcall (find-symbol "STIMULUS-INJECT" :passepartout) (list :type :EVENT :payload (list :sensor :user-input :text text)))))) (ecase tier (:reflex (if (functionp action) (funcall action) (when (and (symbolp action) (fboundp action)) (funcall action))) :dispatched) (:cognition (safe-inject (format nil "~a" action)) :injected) (:reasoning (safe-inject (format nil "~a" action)) :injected)))) #+end_src ** Heartbeat handler Called on each heartbeat cycle. Checks the cron registry for jobs whose ~:next-run~ time has passed, dispatches them, and reschedules repeating jobs. The rescheduling computes the next run based on the repeat unit: ~:d~ (days), ~:w~ (weeks), ~:m~ (months), defaulting to ~:h~ (hours). This is deliberately simple — full calendar-aware scheduling (skip weekends, respect business hours) can be added later. Returns ~nil~ so it doesn't block the heartbeat signal from reaching other skills. #+begin_src lisp (defun orchestrator-on-heartbeat (context) "Called on each heartbeat tick. Checks and dispatches due cron jobs." (declare (ignore context)) (let ((now (get-universal-time)) (due-jobs nil)) (maphash (lambda (name config) (let ((next-run (getf config :next-run))) (when (>= now next-run) (push (cons name config) due-jobs)))) *cron-registry*) (dolist (job due-jobs) (let* ((name (car job)) (config (cdr job)) (action (getf config :action)) (tier (getf config :tier)) (repeat (getf config :repeat)) (result (orchestrator-dispatch action tier))) (log-message "ORCHESTRATOR: Heartbeat dispatched ~a (tier: ~a) → ~a" name tier result) (when repeat (let* ((unit (first repeat)) (value (second repeat)) (interval (case unit (:d (* 86400 value)) (:w (* 604800 value)) (:m (* 2592000 value)) (t (* 3600 value))))) (setf (getf (gethash name *cron-registry*) :next-run) (+ now interval)))))) nil)) #+end_src ** Bootstrap Scans all Org files in the memex for ~#+HOOK:~ and ~#+CRON:~ properties in headline property drawers and auto-registers them. #+begin_src lisp (defun orchestrator-scan-org-file (filepath) "Scans a single Org file for HOOK and CRON properties in property drawers. Returns a list of plists (:type :hook/:cron :name :value )." (let ((results nil) (in-properties nil) (lines nil)) (handler-case (setf lines (uiop:split-string (uiop:read-file-string filepath) :separator '(#\Newline))) (error (c) (log-message "ORCHESTRATOR: Could not read ~a: ~a" filepath c) (return-from orchestrator-scan-org-file nil))) (dolist (line lines) (let ((trimmed (string-trim '(#\Space) line))) (when (string= trimmed ":PROPERTIES:") (setf in-properties t)) (when (string= trimmed ":END:") (setf in-properties nil)) (when in-properties (cond ((uiop:string-prefix-p ":HOOK:" trimmed) (let ((val (string-trim '(#\Space) (subseq trimmed 6)))) (push (list :type :hook :name val :file filepath) results) (log-message "ORCHESTRATOR: Found hook ~a in ~a" val filepath))) ((uiop:string-prefix-p ":CRON:" trimmed) (let ((val (string-trim '(#\Space) (subseq trimmed 6)))) (push (list :type :cron :name val :file filepath) results) (log-message "ORCHESTRATOR: Found cron ~a in ~a" val filepath))))))) (nreverse results))) (defun orchestrator-bootstrap () "Scans all Org files in the memex for #+HOOK: and #+CRON: properties and registers them. Scans ~/memex/projects/ and ~/memex/system/ by default." (let* ((memex-dir (or (uiop:getenv "MEMEX_DIR") (namestring (merge-pathnames "memex/" (user-homedir-pathname))))) (scan-dirs (list (merge-pathnames "projects/" memex-dir) (merge-pathnames "system/" memex-dir))) (hook-count 0) (cron-count 0)) (dolist (dir scan-dirs) (handler-case (let ((files (uiop:directory-files dir "*.org"))) (dolist (file files) (let* ((path (namestring file)) (entries (orchestrator-scan-org-file path))) (dolist (entry entries) (let ((type (getf entry :type)) (name (getf entry :name))) (cond ((eq type :hook) (orchestrator-register-hook name (lambda () (log-message "ORCHESTRATOR: Hook ~a fired" name)))) ((eq type :cron) (orchestrator-register-cron (intern (string-upcase (format nil "cron-~a" name)) :keyword) name (lambda () (log-message "ORCHESTRATOR: Cron ~a fired" name)) :cognition)))) (if (eq (getf entry :type) :hook) (incf hook-count) (incf cron-count)))))) (error (c) (log-message "ORCHESTRATOR: Could not scan ~a: ~a" dir c)))) (log-message "ORCHESTRATOR: Bootstrap complete (~d hooks, ~d cron jobs)" hook-count cron-count))) #+end_src ** Skill registration The orchestrator registers as a skill with low priority so it runs after critical skills (policy, dispatcher) but before the heartbeat processing. The trigger matches ~:heartbeat~ sensor events. #+begin_src lisp (defskill :passepartout-system-event-orchestrator :priority 80 :trigger (lambda (ctx) (eq (getf (getf ctx :payload) :sensor) :heartbeat)) :deterministic (lambda (action context) (declare (ignore action)) (orchestrator-on-heartbeat context) nil)) #+end_src