Audience: Claude Code (or any coding agent) continuing this plugin.
Date: 2026-07-21
Repo state: working rule-engine skeleton, 17 files, ~990 lines of Lua, all
files parse under luac5.1 -p. No tests, no CI, no lint config yet.
Read this whole file before touching code. It contains invariants that are easy to break with an innocent-looking refactor and expensive to debug (async context bugs and cooldown regressions surface only at runtime, days later).
A rule-engine assistant for Neovim that nudges the user about project hygiene — commit, push, fetch/review incoming work, run tests, update tickets/docs, validate against spec, chase TODOs, ad-hoc reminders. Rules are declarative data
- two functions. The built-in rules are just the first tenants of a general engine; the engine is the product.
Target: Neovim >= 0.10 (vim.system, vim.uv). Runtime deps: git;
optional ripgrep. No Lua library dependencies (keep it that way unless a
backlog item explicitly adds one, and make it optional).
- Full evaluation engine with per-rule gating, async conditions, failure isolation.
- Triggers: repeating libuv timer (
timerrules) + autocmds (eventrules) + manual (:WorkflowAssistant check <name>). - State:
last_fired/snoozed_untilpersisted to JSON instdpath("state");last_checkedin-memory. - Actions:
vim.notify, interactivevim.ui.selectprompt (auto-appends Snooze/Dismiss), asyncrun_cmd. - Async git layer over
vim.system. - Built-in rules:
uncommitted_changes,unpushed_commits,review_incoming,count_source_writes(bookkeeping),tests_stale,open_todos(opt-in). - Commands:
check|enable|disable|toggle|list|status|snooze|reset|testedwith completion.:checkhealth workflow-assistant.
See README.md for the user-facing surface and a custom-rule example.
plugin/workflow-assistant.lua load guard only
lua/workflow-assistant/
init.lua setup() + public API (also backs the command dispatcher)
config.lua defaults + vim.tbl_deep_extend merge
engine.lua registry + evaluate() with all gating; evaluate_matching()
rule.lua normalize()/validate a rule spec
triggers.lua libuv timer + autocmd wiring; schedules onto main loop
context.lua project-root detection (cached) + per-eval snapshot (ctx)
state.lua persisted (fired/snooze) + in-memory (checked) tracking
actions.lua notify / prompt / run_cmd / open_git_status
git.lua async git wrappers (is_repo, status, ahead_behind, ...)
commands.lua :WorkflowAssistant dispatcher + completion
health.lua :checkhealth entry
rules/
init.lua build(cfg) -> concatenated list of built-in rule specs
git.lua git rule group
tests.lua tests rule group (holds a module-local write counter)
todos.lua todo rule group
timer tick (every cfg.tick_interval)
-> vim.schedule -> engine.evaluate_matching("timer")
autocmd (event) -> engine.evaluate_matching("event", args.event)
:WorkflowAssistant check -> engine.evaluate(name, force=true)
engine.evaluate(name, force):
guards (skipped when force):
cfg.enabled? rule.enabled? state.can_fire (cooldown+snooze)?
state.due_for_check (check_interval)?
ctx = context.build(cfg) -- main loop
if cfg.require_project and not ctx.is_project -> stop (unless force)
state.mark_checked(name)
rule.condition(ctx, done) -- pcall'd
done(should, payload): -- called exactly once, sync or async
if should: (mark_fired unless force) then rule.action(ctx,payload,rule) -- pcall'd
ctx = { cwd, root, is_project, bufnr, bufname, filetype, now }.
These are the load-bearing guarantees. A refactor that violates one will appear to work and fail intermittently in production.
-
Condition contract.
condition(ctx, done)must calldone(should, payload)exactly once, sync or async. The engine guards against a second call, but a condition that never callsdonewill silently wedge that rule (_evaluating[name]stays true forever). When adding async chains, ensure every branch — including error branches — reaches a singledone. -
Main-loop safety. Callbacks from
vim.uvtimers andvim.systemrun in a restricted "fast" context where mostvim.api/vim.fncalls error. Any new async helper MUSTvim.schedule(...)before touching the API and before invoking a caller-supplied callback.git.runandactions.run_cmdalready do this — new async code must follow the same pattern. Rule authors rely ongit.*/run_cmdcallbacks andctxbeing main-loop-safe; keep that true. -
Two independent throttles.
check_intervalbounds how often the condition runs;cooldownbounds how often the rule may notify. Do not collapse them. Collapsing re-introduces the bug this split fixed: a rule whose condition is false (nothing to report) would re-run its expensive/ network work every tick, e.g.review_incomingwouldgit fetchevery 60s. -
State scope split.
last_fired/snoozed_untilpersist to disk;last_checkedis memory-only (avoids disk churn every tick). Do not persistlast_checkedand do not stop persisting the other two. -
Failure isolation.
conditionandactionarepcall-wrapped inengine.evaluate. Keep that. One broken rule must not break the engine or other rules. -
Rules are declarative. A rule is data +
condition+action. Per-rule mutable runtime state lives instate.lua(persisted) or as a documented module-local (see the write counter inrules/tests.lua). Don't stash state on the rule table or in engine internals. -
Event re-arming.
triggers.wire_events()clears the augroup and rebuilds it (re-adding the DirChanged cache-buster). It is idempotent and must stay so; registering a rule at runtime calls it. If you add always-on autocmds, re-add them insidewire_eventsafter the clear, or they'll be wiped. -
No hard version pins to newer APIs without a fallback. Avoid
vim.validate(its signature changed across 0.11); use plain asserts.vim.uv or vim.loopis already the pattern. Keep 0.10 working.
- Style: 2-space indent; each module is
local M = {} ... return M; prefer small pure helpers; comments explain why, not what. - No new runtime deps unless a backlog item says so, and then make them
optional (feature-detect, degrade gracefully — mirror how
open_todosno-ops withoutripgrep). - Update
README.mdfor any user-visible change (config keys, commands, rules) andhealth.luawhen a new external dependency is introduced. - Every change ends green on the verification loop below.
# 1. Syntax — must pass for every file
find lua plugin -name '*.lua' -exec luac5.1 -p {} \; # or any Lua 5.1 parser
# 2. Lint (add in P0 task below, then this becomes required)
luacheck lua/ plugin/
# 3. Format check
stylua --check lua/ plugin/
# 4. Tests (after P0 harness lands)
nvim --headless -u tests/minimal_init.lua \
-c "PlenaryBustedDirectory tests/ {minimal_init='tests/minimal_init.lua'}"If luac5.1 isn't installed: apt-get install -y lua5.1 (or use any Lua 5.1
parser; Neovim's dialect is 5.1/LuaJIT — do not use 5.2+ syntax like goto,
integer-div //, or <close>).
Public (require("workflow-assistant")):
setup(opts), register(spec), check(name), check_all(),
enable(bool), toggle(), snooze(name, seconds), reset(name?),
mark_tested(), rule_names(), show_list(), show_status().
state: can_fire(rule), due_for_check(rule), mark_fired(name),
mark_checked(name), snooze(name,secs), is_snoozed(name), reset(name?),
last_fired/last_checked(name).
actions: notify(msg,level?), prompt(rule,msg,choices),
run_cmd(cwd,cmd,on_done), open_git_status().
choices = list of { label=string, run=function }.
git: run(cwd,args,cb), is_repo, branch, status, ahead_behind,
seconds_since_last_commit — all callback-based, all main-loop-safe.
context: build(cfg) -> ctx, find_root(start,markers), clear_cache().
engine: register(spec), evaluate(name,force?),
evaluate_matching(kind,event?,force?), get(name), list().
Work top-down; P0 first so you can self-verify everything after.
Goal: make the repo self-verifying so later work is safe.
Deliver:
.luacheckrcwithglobals = {"vim"}(and busted globalsdescribe/it/before_each/assertintests/),max_line_length = 100, ignore unusedself.stylua.toml(2-space, column width 100).Makefiletargets:check(luac parse),lint(luacheck),fmt/fmt-check(stylua),test(plenary headless),all..github/workflows/ci.yml: matrix on Neovim stable + nightly; runsmake lint fmt-check test.tests/minimal_init.luabootstrapping plenary ontoruntimepath.tests/specs (busted style via plenary) covering:config.mergedeep-merges and preserves defaults.rule.normalize: validation errors; enabled resolution (config override > spec default > true); defaultcooldown/check_interval.state:can_firerespects cooldown + snooze;due_for_checkrespectscheck_interval; persistence round-trips;reset.engine.evaluate: a fake rule with a synchronousconditionfires itsactiononly when gates allow;forcebypasses gates; a throwingcondition/actionis contained (no error propagates) and clears_evaluating.
Acceptance: make all is green locally and in CI. Tests inject fakes for
state/context rather than hitting disk/git (use package.loaded overrides
in before_each).
Gotchas: tests need the vim global, so run under nvim --headless, not
bare Lua. state writes to stdpath("state") — point cfg.state.path at a
tmp file in tests and/or set persist=false.
Goal: one-shot, user-set timed reminders, surviving restarts.
New lua/workflow-assistant/reminders.lua:
add(spec)wherespec = { in_seconds=?, at=? (epoch), message, actions?=choices }.- Persist reminders in
state(extend the persisted schema with areminderslist:{ id, due, message }). Generateid(e.g.tostring(os.time())..counter). - A single scanning timer (reuse
tick_intervalor its own 30s timer) that, on tick,vim.schedules and fires any reminder withdue <= now, then removes it and saves. Do not create one libuv timer per reminder (leaks/complexity). - On
setup, load persisted reminders and start scanning. - Parse relative durations for the command:
30m,2h,90s,1d(a smallparse_duration(str) -> secondshelper; reject unknown units clearly).
Commands: extend commands.lua + init.lua API:
:WorkflowAssistant remind <duration> <message...> and
:WorkflowAssistant reminders (list pending) and
:WorkflowAssistant unremind <id>.
Acceptance: set a reminder, restart Neovim, it still fires at the right
time; listing and cancelling work; specs cover parse_duration and the
due-scan logic (inject a fake clock).
Gotchas: keep firing on the main loop; don't fire a reminder twice if a tick
overlaps a slow vim.ui prompt (mark fired/remove before invoking the action).
Goal: rules whose condition asks an LLM to judge project state. Off by
default; zero cost unless enabled and there's something to look at.
Design:
- Config block:
ai = { enabled = false, model = "claude-haiku-4-5-20251001", api_key_env = "ANTHROPIC_API_KEY", -- never store the key in config/state max_diff_bytes = 12000, -- truncate context sent to the model min_interval = 30 * 60, -- default check_interval for AI rules }
- HTTP via
vim.system({ "curl", "-s", "-X", "POST", ... })tohttps://api.anthropic.com/v1/messages— no Lua HTTP dep. Read the key fromvim.env[cfg.ai.api_key_env]at call time. If missing → rule no-ops (andhealth.luawarns whenai.enabled). - Helper
ai.classify(prompt, cb)that builds the request, runs curl async,vim.schedules, parses JSON (vim.json.decode), and returns a structured result. Prompt the model to reply with JSON only (e.g.{ "flag": true, "reason": "..." }); strip stray fences before decoding; on any parse/HTTP error,cb(nil)and the rule stays silent. - Every AI rule must gate cheaply before calling the API: only classify when
there's a diff / relevant change, and set a long
check_intervalto bound token spend.
Ship these AI rules (all enabled=false):
spec_drift: if the working diff touches code and a spec file exists (SPEC.md/spec//configurable path), ask whether the change likely requires a spec update; fire with the reason.docs_stale: public API/signature changes in the diff but no docs touched → ask whether docs need updating.commit_quality: on staged diff, ask whether it's one logically complete commit or should be split; offer "Review (git status)".
Acceptance: with a fake ai.classify injected in tests, the rules fire on
flag=true and stay silent on nil/false; no API call happens when
ai.enabled=false, when the key is absent, or when there's no diff. The key
never appears in logs, state, or notifications.
Gotchas: never block the editor; enforce max_diff_bytes truncation; treat
all model output as untrusted (JSON-parse, don't eval, don't auto-run
suggested shell commands).
Goal: move from fire-and-forget to a queryable set of pending nudges, plus a way to see them at a glance.
Introduce a lightweight inbox in state (in-memory is fine, persistence
optional): when a rule fires, record { id, rule, message, ts, actions } as
"pending" until the user acts (run an action / dismiss / snooze). actions.prompt
should register/clear the inbox entry.
Then:
:WorkflowAssistant panel— a floating window listing pending items and all rules (name, trigger, enabled, last_fired, snoozed_until) with keymaps:<CR>act,ssnooze,ddismiss,xtoggle-enable,qclose.require("workflow-assistant").statusline()-> short string likeWA 3(count of pending) for lualine/heirline; empty when zero.- Optional backends, feature-detected: prefer
snacks.nvim/nvim-notifyforactions.notifyandfidget/snacksfor transient progress if present; fall back tovim.notify. Keepvim.ui.selectas the default prompt but allow a configured picker.
Acceptance: firing a rule adds a pending item; acting/dismissing removes it;
panel reflects live state; statusline() returns the right count; no hard
dependency on any UI plugin.
Gotchas: this touches actions.prompt and the fire path in engine — keep
invariant #5 (isolation) and #1 (single done) intact. Don't leak inbox entries
when a rule fires repeatedly (dedupe by rule name).
spec_or_docs_untouched(pure git, cheap): if working-tree changes include files under a configuredsrcglob but none underspec/docsglobs, nudge. Complements the AIspec_driftfor users without a key.shell_rulefactory in a newrules/shell.luaand exposed asrequire("workflow-assistant").shell_rule{ name, desc, cmd, cwd?="root", when = "nonzero" | "zero" | function(res)->bool, message = string|fn, choices? }. Runs an arbitrary command viarun_cmdand fires perwhen. This is the user-facing "check xyz" primitive — document it prominently.
Acceptance: shell_rule composes into custom_rules; specs cover the
when predicates with a fake run_cmd.
Today state is keyed by rule name globally, so snoozing unpushed_commits
snoozes it in every repo. Decide and implement scoping by (root, rule):
- Change
statekeys toroot .. "\0" .. name(or nested table), threadrootthroughcan_fire/mark_fired/snooze(available viactx.root). - Migrate persisted files gracefully (accept old flat schema on load).
- Update
:WorkflowAssistant snooze/resetto operate on the current root.
Write the tradeoff (global vs per-repo) at the top of the PR; default to
per-repo but keep a state.scope = "global"|"project" config escape hatch.
doc/workflow-assistant.txtvimdoc with:helptags for setup, config, commands, and the rule-authoring contract (copy invariants #1–#3 verbatim — users writing rules need them).health.lua: warn whenai.enabledbut key missing; note detected UI backends.- Config validation with actionable error messages (bad rule spec → point at
the offending
name).
Telescope/snacks picker for rules; per-rule keymaps; a "focus mode" that
suppresses all nudges for N minutes; Atlassian/Jira ticket rule (the user has an
Atlassian connector — a rule that checks the branch's linked issue status is a
strong fit, via jira CLI or a configured hook).
- All files parse;
luacheckclean;stylua --checkclean; tests pass (make all). - New user-visible surface documented in
README.md(anddoc/once it exists). - New external dep reflected in
health.luaand degrades gracefully when absent. - Invariants in section 5 preserved (call them out explicitly in the PR
description if you touched
engine,triggers,state, oractions). - No new runtime dependency unless the backlog item authorized it and it's optional.
- Land P0 (tooling + tests). Get
make allgreen in CI. This is your safety net for everything after. - Land ad-hoc reminders (P1) — self-contained, high user value, exercises the persistence + timer paths without touching the engine core.
- Land AI rules (P1) behind
ai.enabled=false, with the fake-injected tests. - Then the inbox/status surface (P1) — do it after AI rules so the panel can display AI nudges too. This is the one that refactors the fire path; land it with the invariants fresh in mind.
Start by reading engine.lua, state.lua, triggers.lua, and one rule group
(rules/git.lua) end to end, then implement P0.