summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--LICENSE18
-rw-r--r--README.md23
-rw-r--r--mise.toml83
-rw-r--r--panto-subagents-0.1.0-1.rockspec24
-rw-r--r--spec/run.lua2
-rw-r--r--spec/test_frontmatter.lua44
-rw-r--r--spec/test_init.lua6
-rw-r--r--spec/test_profiles.lua6
-rw-r--r--subagents/frontmatter.lua44
9 files changed, 195 insertions, 55 deletions
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..876f03c
--- /dev/null
+++ b/LICENSE
@@ -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.
diff --git a/README.md b/README.md
index aadf719..b462053 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/mise.toml b/mise.toml
index 97d78c5..97b5223 100644
--- a/mise.toml
+++ b/mise.toml
@@ -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