summaryrefslogtreecommitdiff
path: root/_selfcheck.lua
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-07-03 09:49:13 -0600
committert <t@tjp.lol>2026-07-03 10:53:10 -0600
commit94568d9f70b12fa348040a7998dac7b0808b0258 (patch)
treee177f28e534c44525ec9828edc3cab18e7c6756e /_selfcheck.lua
Initial changes: agent.rules and agent.skill extensionsHEADmain
panto-agent is a luarocks rock providing the agent.* extension suite for pantograph's panto CLI: - agent.rules: injects AGENTS.md/CLAUDE.md (AGENTS.md wins, never both) from the base agent dir, user config dir, and project root into the system prompt at session start. Also hooks std.read tool results to append rules files found between a read location and the project root, each injected at most once per session. - agent.skill: discovers Agent Skills (SKILL.md with YAML frontmatter) across data/config/home/project skill dirs with later dirs shadowing earlier ones, and exposes them via an agent.skill tool whose enum and description are built from the surviving skills. The tool returns the skill's instructions with frontmatter stripped. Not registered when no skills are found. Includes a rockspec, README, and a panto-runnable self-check (_selfcheck.lua, underscore-prefixed so panto's directory scan skips it). Sources live under panto-agent/ so require works the same installed or from a checkout.
Diffstat (limited to '_selfcheck.lua')
-rw-r--r--_selfcheck.lua236
1 files changed, 236 insertions, 0 deletions
diff --git a/_selfcheck.lua b/_selfcheck.lua
new file mode 100644
index 0000000..adf02f1
--- /dev/null
+++ b/_selfcheck.lua
@@ -0,0 +1,236 @@
+-- Self-check for the panto-agent suite. Run with:
+--
+-- panto lua panto-agent/_selfcheck.lua
+--
+-- Uses a stub `panto` module, so it exercises the pure extension logic
+-- (file selection, symlink resolution, message format) without a session.
+
+local uv = require("luv")
+
+-- Stub panto module: capture agent:add_system_message calls and `on`
+-- subscriptions (ext.agent stands in for the session agent). Must be
+-- seeded before rules.lua is required.
+local captured = {}
+local handlers = {}
+local registered = {}
+package.loaded["panto"] = {
+ ext = {
+ agent = {
+ add_system_message = function(_, text) captured[#captured + 1] = text end,
+ },
+ on = function(event, handler) handlers[event] = handler end,
+ register_tool = function(t) registered[#registered + 1] = t end,
+ json = {
+ -- Just enough JSON for the inputs this selfcheck fabricates
+ -- (a flat object with a "path" string, no escapes).
+ decode = function(s) return { path = s:match('"path":"(.-)"') } end,
+ },
+ },
+}
+
+-- Make the suite requirable relative to this file, wherever cwd is.
+local here = debug.getinfo(1, "S").source:match("^@(.*)/[^/]+$") or "."
+package.path = here .. "/panto-agent/?.lua;" .. here .. "/panto-agent/?/init.lua;" .. package.path
+
+local rules = require("rules")
+assert(rules.name == "agent.rules", "entry name")
+
+-- Harness pump: panto runs entrypoints inside a coroutine with the uv
+-- loop driven; this standalone script has to provide the same
+-- environment itself.
+local function run(fn)
+ local results
+ local co = coroutine.create(function() results = table.pack(fn()) end)
+ local ok, err = coroutine.resume(co)
+ if not ok then error(err, 0) end
+ while coroutine.status(co) ~= "dead" do
+ uv.run("once")
+ end
+ return table.unpack(results, 1, results.n)
+end
+
+local function write(path, text)
+ local f = assert(io.open(path, "w"))
+ f:write(text)
+ f:close()
+end
+
+local tmp = assert(uv.fs_mkdtemp("/tmp/panto-agent-selfcheck-XXXXXX"))
+local function dir(name)
+ local d = tmp .. "/" .. name
+ assert(uv.fs_mkdir(d, 493)) -- 0755
+ return d
+end
+
+-- both: AGENTS.md wins, CLAUDE.md ignored
+local both = dir("both")
+write(both .. "/AGENTS.md", "agents rules")
+write(both .. "/CLAUDE.md", "claude rules")
+-- claude-only: falls back to CLAUDE.md
+local claude_only = dir("claude-only")
+write(claude_only .. "/CLAUDE.md", "claude only")
+-- empty: contributes nothing
+local empty = dir("empty")
+-- linked: AGENTS.md is a symlink; resolved path reported
+local target_dir = dir("target")
+write(target_dir .. "/real-rules.md", "linked rules")
+local linked = dir("linked")
+assert(uv.fs_symlink(target_dir .. "/real-rules.md", linked .. "/AGENTS.md"))
+
+-- activate(): subscribes to session_start; each firing resets the
+-- session-global `seen` set and injects one message per file, using the
+-- event's cwd as the project root. Activate first so firing session_start
+-- doubles as the seen-reset between the scenarios below.
+rules.layer_dirs = function(cwd) return { cwd } end
+rules.activate()
+local on_session_start = handlers["session_start"]
+assert(type(on_session_start) == "function", "session_start handler registered")
+assert(#captured == 0, "nothing injected before session_start fires")
+
+-- collect() awaits libuv callbacks, so it runs under the harness pump.
+local got = run(function() return rules.collect({ both, claude_only, empty, linked }) end)
+assert(#got == 3, "expected 3 collected, got " .. #got)
+assert(got[1].path == uv.fs_realpath(both .. "/AGENTS.md"), "AGENTS.md over CLAUDE.md")
+assert(got[1].text == "agents rules")
+assert(got[2].path == uv.fs_realpath(claude_only .. "/CLAUDE.md"), "CLAUDE.md fallback")
+assert(got[2].text == "claude only")
+assert(got[3].path == uv.fs_realpath(target_dir .. "/real-rules.md"), "symlink resolved")
+assert(got[3].text == "linked rules")
+
+-- Session-global dedupe: a repeat collect of already-seen files is empty.
+local again = run(function() return rules.collect({ both }) end)
+assert(#again == 0, "seen files are not collected twice in a session")
+
+-- A session boundary resets `seen` (empty dir: injects nothing).
+run(function() on_session_start({ cwd = empty }) end)
+assert(#captured == 0, "empty root injects nothing")
+-- Two dirs reaching the same real file within one collect: used once.
+local dedup = run(function() return rules.collect({ linked, target_dir }) end)
+assert(#dedup == 1, "same real file deduped")
+
+run(function() on_session_start({ cwd = both }) end)
+assert(#captured == 1, "one system message")
+local want = "context from " .. uv.fs_realpath(both .. "/AGENTS.md") .. ":\n\nagents rules"
+assert(captured[1] == want, "message format:\n" .. captured[1])
+-- A second session boundary (e.g. /new) re-injects for the new cwd.
+run(function() on_session_start({ cwd = claude_only }) end)
+assert(#captured == 2 and captured[2]:find("claude only"), "re-injects on next session")
+
+-- tool_result handler: rules files between a read and the session root
+-- ride on the result via the writable `output` field, and the component
+-- wrapper lists them. Tree: proj/ (root, covered by the system prompt),
+-- proj/sub (AGENTS.md), proj/sub/deep (CLAUDE.md only).
+local proj = dir("proj")
+write(proj .. "/AGENTS.md", "root rules")
+assert(uv.fs_mkdir(proj .. "/sub", 493))
+write(proj .. "/sub/AGENTS.md", "sub rules")
+assert(uv.fs_mkdir(proj .. "/sub/deep", 493))
+write(proj .. "/sub/deep/CLAUDE.md", "deep rules")
+write(proj .. "/sub/deep/file.txt", "hello")
+
+run(function() on_session_start({ cwd = proj }) end)
+local on_tool_result = handlers["tool_result"]
+assert(type(on_tool_result) == "function", "tool_result handler registered")
+
+-- Fabricate a `tool_result` event: a plain table stands in for the real
+-- event userdata (assignment and method calls look the same).
+local fake_comp = { render = function(_, _) return { "read line" } end }
+local function read_event(path)
+ local ev = {
+ tool_name = "std.read",
+ input = '{"path":"' .. path .. '"}',
+ output = "FILE",
+ }
+ ev.get_component = function() return fake_comp end
+ ev.set_component = function(_, t) ev.component = t end
+ return ev
+end
+
+local ev = read_event(proj .. "/sub/deep/file.txt")
+run(function() on_tool_result(ev) end)
+local sub_real = uv.fs_realpath(proj .. "/sub/AGENTS.md")
+local deep_real = uv.fs_realpath(proj .. "/sub/deep/CLAUDE.md")
+assert(ev.output == "FILE"
+ .. "\n\n---\ncontext from " .. sub_real .. ":\n\nsub rules"
+ .. "\n\n---\ncontext from " .. deep_real .. ":\n\ndeep rules",
+ "walk order + format:\n" .. ev.output)
+-- The component wrapper passes the original lines through and lists the
+-- attached files below them.
+local lines = ev.component.render(ev.component, 80)
+assert(lines[1] == "read line", "wrapper renders through the original")
+assert(lines[2] == "+ rules: " .. sub_real and lines[3] == "+ rules: " .. deep_real,
+ "wrapper lists attached files")
+
+-- Same read again: everything already seen, result and component untouched.
+local ev2 = read_event(proj .. "/sub/deep/file.txt")
+run(function() on_tool_result(ev2) end)
+assert(ev2.output == "FILE" and ev2.component == nil, "already-seen rules are not re-injected")
+
+-- Reading a directory directly starts the walk at that directory.
+assert(uv.fs_mkdir(proj .. "/other", 493))
+write(proj .. "/other/AGENTS.md", "other rules")
+local ev3 = read_event(proj .. "/other")
+run(function() on_tool_result(ev3) end)
+assert(ev3.output:find("other rules", 1, true), "directory read includes its own rules")
+
+-- Reads outside the session root add nothing; other tools are ignored.
+local ev4 = read_event(both .. "/AGENTS.md")
+run(function() on_tool_result(ev4) end)
+assert(ev4.output == "FILE", "outside-root reads are untouched")
+local ev5 = read_event(proj .. "/sub/deep/file.txt")
+ev5.tool_name = "std.shell"
+run(function() on_tool_result(ev5) end)
+assert(ev5.output == "FILE", "non-read tools are ignored")
+
+-- agent.skill: layered discovery, frontmatter-name shadowing, enum/desc
+-- registration, frontmatter-stripped load with a path preamble.
+local skill = require("skill")
+assert(skill.name == "agent.skill", "skill entry name")
+
+local function write_skill(layer, dirname, name, desc, body)
+ local d = layer .. "/" .. dirname
+ assert(uv.fs_mkdir(d, 493))
+ write(d .. "/SKILL.md", "---\nname: " .. name .. "\ndescription: " .. desc
+ .. "\nmetadata:\n author: selfcheck\n---\n" .. body)
+ return d
+end
+
+local sk_low = dir("skills-low")
+local sk_high = dir("skills-high")
+-- alpha in both layers; the high layer's directory name differs from the
+-- frontmatter name, which is the identity that shadows.
+write_skill(sk_low, "alpha", "alpha", "low alpha", "low alpha body\n")
+local alpha_high = write_skill(sk_high, "alpha-dir", "alpha", '"high alpha"', "high alpha body\n")
+write_skill(sk_low, "beta", "beta", "beta desc", "beta body\n")
+-- Not skills: no SKILL.md; SKILL.md without frontmatter.
+assert(uv.fs_mkdir(sk_low .. "/noskill", 493))
+assert(uv.fs_mkdir(sk_low .. "/badfm", 493))
+write(sk_low .. "/badfm/SKILL.md", "just markdown, no frontmatter\n")
+
+skill.skill_dirs = function() return { sk_low, sk_high } end
+run(function() skill.activate() end)
+assert(#registered == 1, "one tool registered")
+local tool = registered[1]
+assert(tool.name == "agent.skill", "tool name")
+local enum = tool.schema.properties.skill.enum
+assert(#enum == 2 and enum[1] == "alpha" and enum[2] == "beta", "enum is the surviving names, sorted")
+assert(tool.description:find("- alpha: high alpha", 1, true), "shadowing layer's description wins")
+assert(tool.description:find("- beta: beta desc", 1, true), "description lists every skill")
+
+local alpha_real = uv.fs_realpath(alpha_high)
+local out = run(function() return tool.handler({ skill = "alpha" }) end)
+assert(out:find("loaded from " .. alpha_real .. "/SKILL.md", 1, true), "preamble names the real path")
+assert(out:find("resolve it against " .. alpha_real .. "/", 1, true), "preamble covers relative paths")
+assert(out:find("high alpha body", 1, true), "higher layer shadows lower")
+assert(not out:find("name: alpha", 1, true), "frontmatter stripped")
+
+local miss = run(function() return tool.handler({ skill = "nope" }) end)
+assert(miss:find("unknown skill: nope", 1, true) and miss:find("alpha, beta", 1, true),
+ "unknown skill lists the available ones")
+
+-- No skills anywhere: the tool is not registered at all.
+skill.skill_dirs = function() return { empty } end
+run(function() skill.activate() end)
+assert(#registered == 1, "no skills, no tool")
+
+print("panto-agent selfcheck: OK")