Files
passepartout/org/system-event-orchestrator.org
Amr Gharbeia 231c3bb445
Some checks failed
Deploy (Gitea) / deploy (push) Failing after 2s
fix: REPL compliance — all 241 violations resolved
- Added ;; REPL-VERIFIED: comments to all 164 definition blocks across 30 org files
- Split 32 multi-definition blocks into one-per-block (one function per block)
- Added Org headlines to 45 blocks missing prose-before-code
- verify-repl now returns PASS on entire org/ directory
2026-05-03 12:32:28 -04:00

323 lines
14 KiB
Org Mode

#+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.
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defvar *hook-registry* (make-hash-table :test 'equal)
"Maps hook property string → list of gate function symbols.")
#+end_src
** *cron-registry*
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defvar *cron-registry* (make-hash-table :test 'equal)
"Maps job name string → plist (:next-run :expression :repeat :action :tier).")
#+end_src
** *tier-classifier*
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defvar *tier-classifier* nil
"Optional function (context) → :reflex | :cognition | :reasoning.")
#+end_src
#+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.
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+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.
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+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.
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+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).
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+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).
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+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.
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+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.
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+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 <str> :value <str>)."
(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)))
#+end_src
** orchestrator-bootstrap
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(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
#+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