diff options
| -rw-r--r-- | LICENSE | 18 | ||||
| -rw-r--r-- | README.md | 23 | ||||
| -rw-r--r-- | mise.toml | 83 | ||||
| -rw-r--r-- | panto-subagents-0.1.0-1.rockspec | 24 | ||||
| -rw-r--r-- | spec/run.lua | 2 | ||||
| -rw-r--r-- | spec/test_frontmatter.lua | 44 | ||||
| -rw-r--r-- | spec/test_init.lua | 6 | ||||
| -rw-r--r-- | spec/test_profiles.lua | 6 | ||||
| -rw-r--r-- | subagents/frontmatter.lua | 44 |
9 files changed, 195 insertions, 55 deletions
@@ -0,0 +1,18 @@ +MIT License + +Copyright (c) 2026 Travis Parker + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. @@ -16,18 +16,26 @@ For a local checkout, use `paths = ["/path/to/panto-subagents"]` instead. A in panto's own rocks tree first: ```sh -panto lua -e 'require("luarocks.cmd").run("install", "toml2lua")' -panto lua -e 'require("luarocks.cmd").run("install", "lyaml")' +TREE="$(echo ~/.local/share/panto/rocks/lua-*)" +luarocks --lua-version 5.4 --tree "$TREE" install toml2lua +luarocks --lua-version 5.4 --tree "$TREE" install api7-lua-tinyyaml ``` +(Every rock here is pure Lua, so any LuaRocks targeting Lua 5.4 can populate +panto's tree. A `rocks` entry needs none of this — panto installs dependencies +with the rock.) + ### Dependencies Panto installs the rock's dependencies with it: -- **lyaml** parses profile frontmatter. It binds the system libyaml, which - LuaRocks does not vendor — install it first (`brew install libyaml`, or - `apt install libyaml-dev`), otherwise the rock fails to build and panto - quietly starts without the `subagents.*` tools. +- **api7-lua-tinyyaml** parses profile frontmatter. Pure Lua, nothing to + install alongside it. It covers a subset of YAML — every quoting, escaping + and block-scalar form profiles use, but not anchors/aliases (flagged with a + warning rather than silently misread) and not plain multi-line scalars + (a parse warning). Full-spec `lyaml` was the alternative, rejected because + binding the system libyaml makes the rock fail to build wherever that + library is absent. - **toml2lua** reads the layered `config.toml` at activation and TOML workflows afterwards. Pure Lua, nothing to install; without it activation fails. - **luv** backs profile and workflow discovery. Panto already ships it. @@ -151,8 +159,7 @@ mise run deps # install the rocks into ./.rocks (gitignored) mise run check # run the specs against that tree ``` -`mise run deps` passes Homebrew's libyaml prefix to lyaml when brew is -available. Without mise, install the same rocks with any LuaRocks targeting Lua +Without mise, install the same rocks with any LuaRocks targeting Lua 5.4 and run `lua spec/run.lua` from the repo root. `panto lua spec/run.lua` runs the suite inside panto's own interpreter and rocks tree, skipping whatever cases that tree has no rock for. @@ -5,21 +5,15 @@ lua = "5.4" # puts that tree on package.path and runs the specs. Both pin --lua-version 5.4 # because a stray system luarocks may default to another Lua. # -# lyaml is a C binding over libyaml and needs the system library present: -# `brew install libyaml` on macOS, `apt install libyaml-dev` on Debian. The -# YAML_DIR below points luarocks at Homebrew's prefix when brew is available and -# otherwise lets luarocks search the usual system paths. +# Every rock below is pure Lua, so there is no system library to install first +# and nothing to point luarocks at. [tasks.deps] description = "Install the Lua rocks the extension and specs need into ./.rocks" dir = "{{config_root}}" run = """ set -e -if brew --prefix libyaml >/dev/null 2>&1; then - luarocks --lua-version 5.4 --tree .rocks install lyaml YAML_DIR="$(brew --prefix libyaml)" -else - luarocks --lua-version 5.4 --tree .rocks install lyaml -fi +luarocks --lua-version 5.4 --tree .rocks install api7-lua-tinyyaml luarocks --lua-version 5.4 --tree .rocks install toml2lua luarocks --lua-version 5.4 --tree .rocks install luv luarocks --lua-version 5.4 --tree .rocks install dkjson @@ -57,3 +51,74 @@ set -e eval "$(luarocks --lua-version 5.4 --tree .rocks path)" lua spec/run.lua """ +[tasks.version] +description = "Rewrite the rockspec for a new version (then commit it)" +dir = "{{config_root}}" +run = """ +set -eu +NEW="{{arg(name='version')}}" +SPEC="$(ls -1 ./*.rockspec)" +case "$SPEC" in *" +"*) echo "expected exactly one rockspec, found:" >&2; echo "$SPEC" >&2; exit 1;; esac + +PKG="$(sed -n 's/^package = "\\(.*\\)"/\\1/p' "$SPEC")" +NEWSPEC="./$PKG-$NEW-1.rockspec" + +# Write through a temp file: without it, a re-run at the same version would +# have the redirect truncate the file sed is still reading. +TMP="$(mktemp)" +sed -e "s/^version = \\".*\\"/version = \\"$NEW-1\\"/" \\ + -e "s/^\\( *\\)tag = \\"v[^\\"]*\\"/\\1tag = \\"v$NEW\\"/" "$SPEC" > "$TMP" +mv "$TMP" "$NEWSPEC" +[ "$SPEC" = "$NEWSPEC" ] || rm -f "$SPEC" + +luarocks lint "$NEWSPEC" +echo "wrote $NEWSPEC -- commit it, then: mise run release" +""" + +[tasks.release] +description = "Tag the committed version, push the tag, upload to luarocks.org" +depends = ["check"] +dir = "{{config_root}}" +run = """ +set -eu +SPEC="$(ls -1 ./*.rockspec)" +case "$SPEC" in *" +"*) echo "expected exactly one rockspec, found:" >&2; echo "$SPEC" >&2; exit 1;; esac + +VER="$(sed -n 's/^version = "\\(.*\\)-[0-9]*"/\\1/p' "$SPEC")" +[ -n "$VER" ] || { echo "could not read version from $SPEC" >&2; exit 1; } +TAG="v$VER" + +# jj's working copy is itself a commit, so @ is normally the empty commit a +# `jj new` just made and the release content sits in @-. Override with +# REV=@ (or any revset) when that is not the case. +REV="${REV:-@-}" + +# The tag is what `luarocks upload` clones to build the .src.rock, so the +# rockspec being published has to already be in the revision being tagged. +# Uncommitted, they diverge silently: the upload would ship a rockspec that +# says $VER over sources from a commit that never had it. +if ! jj file show -r "$REV" "$SPEC" 2>/dev/null | diff -q - "$SPEC" >/dev/null 2>&1; then + echo "$SPEC at $REV differs from the working copy -- commit it first" >&2 + exit 1 +fi + +if jj tag list | grep -q "^$TAG:"; then + echo "tag $TAG already exists -- bump the version first" >&2 + exit 1 +fi + +# jj 0.42 creates tags natively and exports them to git in a colocated repo, +# but `jj git push` only pushes bookmarks -- tags still need git. +jj tag set "$TAG" -r "$REV" +jj tag list | grep "^$TAG:" +for remote in $(git remote); do + git push "$remote" "$TAG" +done + +# Reads the API key from ~/.luarocks/upload_config.lua. Packs a .src.rock by +# cloning source.url at the tag just pushed, so the push has to come first. +luarocks upload "$SPEC" +""" + diff --git a/panto-subagents-0.1.0-1.rockspec b/panto-subagents-0.1.0-1.rockspec index 5ae55ff..3a6293a 100644 --- a/panto-subagents-0.1.0-1.rockspec +++ b/panto-subagents-0.1.0-1.rockspec @@ -8,10 +8,19 @@ -- checkout gets from `paths = ["/path/to/panto-subagents"]`, so both load paths -- resolve identical requires. -- --- Dependencies. `lyaml` parses profile frontmatter and is a C binding over the --- system libyaml, which luarocks does not vendor: a machine without it needs --- `brew install libyaml` (or `apt install libyaml-dev`) first, and may need --- `YAML_DIR=...` passed to luarocks. `toml2lua` (module name `toml`) reads the +-- Dependencies. `api7-lua-tinyyaml` (module name `tinyyaml`) parses profile +-- frontmatter. It is pure Lua, so it installs anywhere without a system +-- library: the obvious alternative, `lyaml`, is a C binding over libyaml that +-- luarocks does not vendor, which would put `brew install libyaml` (or +-- `apt install libyaml-dev`, plus sometimes `YAML_DIR=...`) between a user and +-- a working extension. The cost is that tinyyaml parses a subset: it matches +-- lyaml on every quoting, escaping and block-scalar form profiles use, but it +-- does not resolve anchors/aliases (subagents/frontmatter.lua detects and +-- warns rather than accepting the raw `*alias` token) and it raises on plain +-- multi-line scalars, which surfaces as a parse warning. The api7 fork is +-- preferred over peposso's original: it fixes `'it''s here'` truncating to +-- `it`, and it ships a .src.rock instead of resolving a `git://` URL. +-- `toml2lua` (module name `toml`) reads the -- layered `config.toml` at activation and the TOML workflows afterwards, so it -- is required, not optional; it is pure Lua and needs nothing from the system. `luv` backs the recursive discovery walk and the -- wake pipes child jobs are polled on, and ships with panto as a pinned @@ -31,7 +40,8 @@ package = "panto-subagents" version = "0.1.0-1" source = { - url = "git+https://github.com/travisp/panto-subagents", + url = "git+https://code.tjp.lol/public/panto-subagents.git", + tag = "v0.1.0", } description = { @@ -44,14 +54,14 @@ description = { one-shot structured workers, and TOML dependency graphs exposed as /workflow:<name> commands. ]], - homepage = "https://github.com/travisp/panto-subagents", + homepage = "https://code.tjp.lol/public/panto-subagents", license = "MIT", labels = { "ai", "llm", "agent", "panto", "pantograph", "extension" }, } dependencies = { "lua >= 5.4, < 5.5", - "lyaml >= 6.2, < 7.0", + "api7-lua-tinyyaml >= 0.4.4, < 0.5", "toml2lua >= 3.0, < 4.0", "luv >= 1.48", } diff --git a/spec/run.lua b/spec/run.lua index ee9f432..5476e7b 100644 --- a/spec/run.lua +++ b/spec/run.lua @@ -2,7 +2,7 @@ -- -- Expected environment. Plain Lua 5.4 with the repo root on package.path, which -- this file arranges from `arg[0]`, plus the rocks the extension depends on --- (lyaml, toml2lua, luv) and dkjson for the specs' JSON decoding. `mise run +-- (api7-lua-tinyyaml, toml2lua, luv) and dkjson for the specs' JSON decoding. `mise run -- check` puts the ./.rocks tree on LUA_PATH/LUA_CPATH first and is the intended -- entry point; a bare `lua spec/run.lua` also works if those rocks are on the -- default path. `panto lua spec/run.lua` works too — panto's own rocks tree diff --git a/spec/test_frontmatter.lua b/spec/test_frontmatter.lua index 3f0c4c5..f729254 100644 --- a/spec/test_frontmatter.lua +++ b/spec/test_frontmatter.lua @@ -2,18 +2,18 @@ -- -- The body is the part a broken header must never cost the user, so every -- degraded case is checked for "body preserved verbatim" as well as for the --- warning. Cases that actually parse YAML need lyaml and skip without it. +-- warning. Cases that actually parse YAML need tinyyaml and skip without it. local frontmatter = require("subagents.frontmatter") -local function lyaml_or_skip() - return pcall(require, "lyaml") +local function yaml_or_skip() + return pcall(require, "tinyyaml") end return { { "fenced header parses and keeps the body verbatim", function() - if not lyaml_or_skip() then - return "skip", "lyaml is not installed" + if not yaml_or_skip() then + return "skip", "tinyyaml is not installed" end local body = "You are a reviewer.\n\n indented line \n" local data, parsed_body, warning = frontmatter.parse( @@ -27,8 +27,8 @@ return { end }, { "CRLF fences are tolerated", function() - if not lyaml_or_skip() then - return "skip", "lyaml is not installed" + if not yaml_or_skip() then + return "skip", "tinyyaml is not installed" end local data, body, warning = frontmatter.parse("---\r\nname: crlf\r\n---\r\nbody\r\n") assert(warning == nil, tostring(warning)) @@ -59,26 +59,32 @@ return { assert(warning == nil, tostring(warning)) end }, - { "broken YAML warns and keeps the body", function() - if not lyaml_or_skip() then - return "skip", "lyaml is not installed" + { "a header the parser rejects warns and keeps the body", function() + if not yaml_or_skip() then + return "skip", "tinyyaml is not installed" end - local data, body, warning = frontmatter.parse("---\na: [unclosed\n---\nbody\n") - assert(data == nil, "a broken header must not produce metadata") + local data, body, warning = frontmatter.parse("---\njust a string\n---\nbody\n") + assert(data == nil, "a rejected header must not produce metadata") assert(body == "body\n", string.format("%q", body)) assert(type(warning) == "string" and warning:find("did not parse", 1, true), "expected a parse warning, got " .. tostring(warning)) end }, - { "a non-mapping document warns and keeps the body", function() - if not lyaml_or_skip() then - return "skip", "lyaml is not installed" + { "an unresolved alias is dropped rather than read literally", function() + if not yaml_or_skip() then + return "skip", "tinyyaml is not installed" end - local data, body, warning = frontmatter.parse("---\njust a string\n---\nbody\n") - assert(data == nil, "a scalar header must not produce metadata") + -- tinyyaml does not resolve aliases: without the guard in parse(), the + -- `*d` token would arrive as the literal string "*d" and become this + -- profile's name. + local data, body, warning = frontmatter.parse( + "---\ndefs: &d my-agent\nname: *d\n---\nbody\n") + assert(type(data) == "table", "the mapping itself should survive") + assert(data.name == nil, "an alias must not become a value, got " .. tostring(data.name)) + assert(data.defs == nil, "an anchor must not become a value, got " .. tostring(data.defs)) assert(body == "body\n", string.format("%q", body)) - assert(type(warning) == "string" and warning:find("not a mapping", 1, true), - "expected a mapping warning, got " .. tostring(warning)) + assert(type(warning) == "string" and warning:find("anchors/aliases", 1, true), + "expected an alias warning, got " .. tostring(warning)) end }, { "empty input is empty output", function() diff --git a/spec/test_init.lua b/spec/test_init.lua index 5d2f8d9..eb8777a 100644 --- a/spec/test_init.lua +++ b/spec/test_init.lua @@ -5,7 +5,7 @@ -- lifecycle subscription is invisible until a user notices the tools are gone or -- a cancelled turn leaves children running. Discovery is pointed at a temporary -- config layer, so the assertions do not depend on this machine's ~/.config; the --- first case skips when luv or lyaml is missing because the profile it looks for +-- first case skips when luv or tinyyaml is missing because the profile it looks for -- could not be read without them. local fake = require("spec.fake_ext") @@ -55,8 +55,8 @@ return { if not ok_uv then return "skip", "luv is not installed" end - if not pcall(require, "lyaml") then - return "skip", "lyaml is not installed" + if not pcall(require, "tinyyaml") then + return "skip", "tinyyaml is not installed" end local tmp = assert(uv.fs_mkdtemp("/tmp/panto-subagents-init-XXXXXX")) diff --git a/spec/test_profiles.lua b/spec/test_profiles.lua index f2a54a2..680679f 100644 --- a/spec/test_profiles.lua +++ b/spec/test_profiles.lua @@ -4,7 +4,7 @@ -- real config, by passing explicit roots to `discover` — the same seam the -- extension uses for the user and project layers, in the same order. -- --- Needs luv (the recursive walk) and lyaml (the frontmatter); without either the +-- Needs luv (the recursive walk) and tinyyaml (the frontmatter); without either the -- whole file skips rather than asserting on a degraded parse. local profiles = require("subagents.profiles") @@ -22,8 +22,8 @@ local function discover_fixture() if not ok_uv then return nil, "luv is not installed" end - if not pcall(require, "lyaml") then - return nil, "lyaml is not installed" + if not pcall(require, "tinyyaml") then + return nil, "tinyyaml is not installed" end local tmp = assert(uv.fs_mkdtemp("/tmp/panto-subagents-profiles-XXXXXX")) diff --git a/subagents/frontmatter.lua b/subagents/frontmatter.lua index 94fe2ac..8740870 100644 --- a/subagents/frontmatter.lua +++ b/subagents/frontmatter.lua @@ -13,8 +13,9 @@ -- (a lone `---` at the top of a prose file is a horizontal rule, not a -- broken header, so this case is deliberately silent) -- * empty fenced block -> empty mapping, no warning --- * lyaml missing or erroring -> body after the fence, warning returned +-- * parser missing or erroring-> body after the fence, warning returned -- * YAML document not a map -> body after the fence, warning returned +-- * unresolved anchor/alias -> that key dropped, warning returned -- -- In the warning cases the fenced block is dropped rather than folded back -- into the body: an unparseable header is noise the child agent should not be @@ -27,6 +28,34 @@ local function trim(s) return (s:gsub("^%s+", ""):gsub("%s+$", "")) end +-- tinyyaml parses a subset of YAML: it does not resolve anchors (`&a`) or +-- aliases (`*a`). Rather than failing on them it hands back the raw token as +-- a plain string, so `name: *base` arrives here as the literal "*base" and +-- would sail through any `type(v) == "string"` check to become a profile's +-- actual name. Detect that shape and drop those keys so the caller falls back +-- to its defaults instead of adopting a bogus value. +-- +-- The match is deliberately narrow -- a whole value that is exactly `*word`, +-- or one opening with `&word ` -- so ordinary prose containing `*` or `&` +-- (`'fetch & parse'`, `'a *b* c'`) is left alone. +local function strip_unresolved_aliases(data) + local hits = {} + for k, v in pairs(data) do + if type(v) == "string" + and (v:match("^%*[%w_%-]+$") or v:match("^&[%w_%-]+%s")) then + hits[#hits + 1] = tostring(k) + end + end + if #hits == 0 then + return nil + end + table.sort(hits) + for _, k in ipairs(hits) do + data[k] = nil + end + return table.concat(hits, ", ") +end + -- Iterate lines, yielding the line plus its start offset and the offset just -- past its newline, so the caller can slice the original text exactly. local function lines(text) @@ -79,17 +108,22 @@ function M.parse(text) return {}, body, nil end - local ok_lyaml, lyaml = pcall(require, "lyaml") - if not ok_lyaml then - return nil, body, "lyaml is not installed; ignoring the YAML frontmatter" + local ok_yaml, yaml = pcall(require, "tinyyaml") + if not ok_yaml then + return nil, body, "tinyyaml is not installed; ignoring the YAML frontmatter" end - local ok, data = pcall(lyaml.load, block) + local ok, data = pcall(yaml.parse, block) if not ok then return nil, body, "YAML frontmatter did not parse: " .. tostring(data) end if type(data) ~= "table" then return nil, body, "YAML frontmatter is not a mapping; ignoring it" end + local aliased = strip_unresolved_aliases(data) + if aliased then + return data, body, + "YAML anchors/aliases are not supported; ignoring: " .. aliased + end return data, body, nil end |
