mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 08:41:32 -10:00
* feat(commands): user-defined slash commands from markdown files
Drop a markdown file at `.zero/commands/<name>.md` (project, shared with the
team) or `<userConfigDir>/zero/commands/<name>.md` (personal); typing
`/<name> args` expands its body template and submits it as a normal prompt.
Optional YAML frontmatter (`description:`, `model:`, `agent:`) feeds help text
and routing. This turns Zero's model-pulled skills into user-invokable,
repo-checked-in team workflows (`/release v1.2`, `/fix-flaky <test>`).
New internal/usercommands package: Load() scans the project + user dirs
(project shadows user on name collision, mirroring specialist scope
precedence), validates names to a safe slash-command shape, and parses
frontmatter with a self-contained parser. Expand() substitutes $ARGUMENTS,
$1..$9, and $$ (literal $); a placeholder-free template gets the raw args
appended so a trivial command still receives input.
Wiring: model loads commands at construction; the commandUnknown dispatch
checks the user set before reporting "unknown"; autocomplete lists user
commands alongside builtins. Builtins win on collision (listed first, shadow at
dispatch). Tests cover load/precedence/name-validation, the placeholder
substitution table, and the TUI dispatch + autocomplete + still-unknown paths.
* feat(init): guided /init that generates AGENTS.md seeded with repo facts
`/init` (TUI) and `zero init` (headless) investigate the repo and write a
concise AGENTS.md so future runs start with project context. Zero already has
the AGENTS.md loader and the repoinfo scanner, but the file only existed if a
user hand-authored it — this activates the machinery in one command.
New internal/agentinit.BuildPrompt seeds the bootstrap prompt with
repoinfo.Collect() output (primary language, build/test tools, CI, workspace
layout, git) formatted as a fact block, then instructs the agent to investigate
the gaps and write a tight AGENTS.md (build/test commands, conventions, things
to avoid). Collection failure is non-fatal — the agent investigates from
scratch. This is one better than a pure-prompt /init: the agent starts from
known facts instead of re-deriving them.
TUI: /init command + handleInitCommand reuse launchPrompt (standard run, tools,
AGENTS.md write path). Headless: `zero init` builds the prompt and forwards it
through the existing exec machinery, so all exec flags (--model, -w, --add-dir)
work unchanged. Tests cover FormatFacts (rendered/omitted fields), BuildPrompt,
and the TUI dispatch.
* feat(tools): lsp_navigate — semantic code navigation via the language server
A read-only, model-callable tool exposing LSP navigation: jump-to-definition,
find-all-references, find-implementations, and workspace_symbol search. The
agent can answer "where is X defined / who calls X / what implements this
interface" precisely, where grep collides on common names or misses indirect
references — cutting wasted read/grep turns and wrong-edit risk on large repos.
Zero already runs language servers for diagnostics, so this adds the
request/response navigation methods on top: Manager.Navigate syncs the file
(didOpen) then issues textDocument/definition|references|implementation or
workspace/symbol via the existing client.Call, decoding all three result shapes
(Location, []Location, []LocationLink). 1-based file:line:col in and out
(converting LSP's 0-based positions); the tool has its own lazily-started
manager (servers start on first use, reused across calls).
Opportunistic: a file type with no installed server, or a server lacking the
capability, degrades to a clear "unavailable — fall back to grep" message, not
an error. Registered in CoreReadOnlyToolsScoped. Tests cover the response-shape
decoding (incl. LocationLink + null), symbol decoding/kinds, unknown-op and
unsupported-extension degradation, and the tool's arg validation + read-only
safety.
* feat(agent): prune stale tool output before paid summarization
Long, dump-heavy sessions accumulate large read_file/grep/glob/bash tool
results the model has already acted on; their bodies are dead weight the loop
otherwise keeps paying an LLM summarizer to compress. Add a zero-cost prune
stage inside maybeCompact, before the summarizer: when over threshold, replace
the bodies of OLD, large tool results with a compact placeholder, recompute
size, and only fall through to the paid Compact() if still over.
pruneStaleToolOutput walks newest-first, protects the last preserveLast
messages plus a trailing 40k-token window of tool output (most likely still in
use), and prunes only tool results older than that and larger than 200 tokens —
gated so the whole pass is a no-op unless reclaimable output exceeds 20k tokens.
It keeps the tool message and its ToolCallID (provider replay stays valid; the
model knows what was there and can re-run the tool), never touches non-tool
messages, never re-prunes a placeholder (idempotent), and copies-on-write so it
never mutates the caller's slice. Recent turns are preserved verbatim — no
paraphrase loss, no token or latency cost.
Tests cover the reclaim gate, recent-window protection, idempotence,
non-tool-message safety, ToolCallID/role preservation, and no-input-mutation.
* feat(agent): reconnect on a transient model disconnect instead of ending the run
A long autonomous task (a big refactor, a swarm member, a headless/cron run)
should survive a single transient upstream hiccup rather than dying and
re-burning every token on a restart. When the initial StreamCompletion connect
fails with a disconnect-shaped error, streamWithReconnect re-issues the SAME
request with exponential backoff up to 2 times.
It retries only the connect — before any content is forwarded — so no
already-streamed OnText is ever duplicated. A context-limit error (the
compactor's job), an image rejection, a non-disconnect error, or a cancelled
context is returned immediately without retry, so the reconnect never fights
the existing handlers. Disconnect classification matches EOF / connection
reset|refused|closed / broken pipe / timeout / 502 / 503 / server-closed.
Reconnect attempts surface through OnReasoning ("connection lost —
reconnecting N/max"), a non-content channel that never corrupts the answer
text; silent when there's no reasoning sink.
Tests: recover-from-transient, give-up-after-max, no-retry-on-context-limit,
stop-on-context-cancel, the disconnect-vs-not classifier, backoff growth, and
the notice routing.
* fix(specialist): add lsp_navigate to knownToolNames
The new lsp_navigate tool was added to the core read-only registry but not to
specialist.knownToolNames, which mirrors the core tool set — so
TestKnownToolNamesMatchCoreRegistry failed (the list drifted from the registry).
Add it to the read-only group to match.
* fix(tools): confine lsp_navigate to the workspace (close arbitrary-file-read)
Review caught that lsp_navigate dropped the PathScope every sibling read-only
tool (read_file/glob/grep) carries, and its readFile joined or used the
model-supplied path verbatim — so `path:"/etc/passwd"` (absolute) or
`path:"../../etc/..."` (escaping) read arbitrary files off disk AND opened them
into the language-server subprocess, reachable via indirect prompt injection.
Add NewScopedLSPNavigateTool(workspaceRoot, scope) (NewLSPNavigateTool now
delegates with nil scope), resolve the path through resolveScopedReadPath BEFORE
reading or building the LSP request — the same confinement the neighbouring
tools enforce — and hand the LSP manager the resolved absolute path so it never
opens an out-of-workspace file. Errors echo only the relative path, never an
absolute one. Registered via the scoped constructor in CoreReadOnlyToolsScoped.
Regression test asserts `/etc/passwd` and `../../` are rejected for both the
position and workspace_symbol ops; verified by mutation (bypassing the scope
resolution makes the test fail).
213 lines
6.4 KiB
Go
213 lines
6.4 KiB
Go
// Package usercommands loads user-defined slash commands from markdown files.
|
|
//
|
|
// A user drops a file at `.zero/commands/<name>.md` (project, checked into the
|
|
// repo and shared with the team) or `<userConfigDir>/zero/commands/<name>.md`
|
|
// (personal), with optional YAML-style frontmatter:
|
|
//
|
|
// ---
|
|
// description: Open a PR for the current branch
|
|
// model: claude-sonnet-4.5
|
|
// ---
|
|
// Create a pull request for the current branch. Title: $1. Summarize: $ARGUMENTS
|
|
//
|
|
// Typing `/<name> some args` expands the body template ($ARGUMENTS, $1..$N) and
|
|
// submits it as a normal prompt. This turns Zero's model-pulled skills into
|
|
// user-invokable, repo-checked-in team workflows. Project commands override
|
|
// user commands of the same name (mirrors the specialist scope precedence).
|
|
package usercommands
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Command is a user-defined slash command parsed from a markdown file.
|
|
type Command struct {
|
|
Name string // the `/name` (file basename, lowercased, sans .md)
|
|
Description string // frontmatter `description:`, for help + autocomplete
|
|
Model string // optional frontmatter `model:` override
|
|
Agent string // optional frontmatter `agent:`/`mode:` routing
|
|
Template string // the markdown body, expanded on invocation
|
|
Path string // source file, for diagnostics
|
|
Project bool // true if from the project `.zero/commands` dir
|
|
}
|
|
|
|
// Paths are the directories scanned for command files, project first.
|
|
type Paths struct {
|
|
ProjectDir string
|
|
UserDir string
|
|
}
|
|
|
|
// DefaultPaths returns the project and user command directories. workspaceRoot
|
|
// is the repo root; userConfigDir is the OS config dir (os.UserConfigDir).
|
|
func DefaultPaths(workspaceRoot, userConfigDir string) Paths {
|
|
p := Paths{}
|
|
if strings.TrimSpace(workspaceRoot) != "" {
|
|
p.ProjectDir = filepath.Join(workspaceRoot, ".zero", "commands")
|
|
}
|
|
if strings.TrimSpace(userConfigDir) != "" {
|
|
p.UserDir = filepath.Join(userConfigDir, "zero", "commands")
|
|
}
|
|
return p
|
|
}
|
|
|
|
// Load reads every `*.md` command file under the given paths and returns them
|
|
// keyed by lowercased name, sorted by name. A project command shadows a user
|
|
// command of the same name. Unreadable files are skipped (best-effort);
|
|
// directories that do not exist are simply empty.
|
|
func Load(paths Paths) []Command {
|
|
byName := map[string]Command{}
|
|
// User first, then project — so the project entry overwrites on collision.
|
|
for _, dir := range []string{paths.UserDir, paths.ProjectDir} {
|
|
project := dir == paths.ProjectDir && dir != ""
|
|
for _, cmd := range loadDir(dir, project) {
|
|
byName[cmd.Name] = cmd
|
|
}
|
|
}
|
|
out := make([]Command, 0, len(byName))
|
|
for _, cmd := range byName {
|
|
out = append(out, cmd)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
return out
|
|
}
|
|
|
|
func loadDir(dir string, project bool) []Command {
|
|
if strings.TrimSpace(dir) == "" {
|
|
return nil
|
|
}
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var cmds []Command
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".md") {
|
|
continue
|
|
}
|
|
path := filepath.Join(dir, entry.Name())
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
name := strings.ToLower(strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())))
|
|
if !validCommandName(name) {
|
|
continue
|
|
}
|
|
cmds = append(cmds, parseCommand(name, path, project, string(raw)))
|
|
}
|
|
return cmds
|
|
}
|
|
|
|
// validCommandName keeps file-sourced names to a safe slash-command shape so a
|
|
// stray file can't shadow a builtin via odd characters: lowercase letters,
|
|
// digits, and hyphens, non-empty.
|
|
func validCommandName(name string) bool {
|
|
if name == "" {
|
|
return false
|
|
}
|
|
for _, r := range name {
|
|
if !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') && r != '-' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func parseCommand(name, path string, project bool, raw string) Command {
|
|
cmd := Command{Name: name, Path: path, Project: project}
|
|
body := strings.ReplaceAll(raw, "\r\n", "\n")
|
|
if fm, remainder, ok := splitFrontmatter(body); ok {
|
|
body = remainder
|
|
cmd.Description = frontmatterValue(fm, "description")
|
|
cmd.Model = frontmatterValue(fm, "model")
|
|
cmd.Agent = frontmatterValue(fm, "agent")
|
|
if cmd.Agent == "" {
|
|
cmd.Agent = frontmatterValue(fm, "mode")
|
|
}
|
|
}
|
|
cmd.Template = strings.TrimSpace(body)
|
|
if cmd.Description == "" {
|
|
cmd.Description = "User command: /" + name
|
|
}
|
|
return cmd
|
|
}
|
|
|
|
// Expand substitutes the argument placeholders in a command template:
|
|
//
|
|
// $ARGUMENTS → all args, space-joined (the whole arg string)
|
|
// $1 .. $9 → the Nth whitespace-separated positional arg ("" if absent)
|
|
// $$ → a literal "$"
|
|
//
|
|
// A template with no placeholders is returned with the raw arg string appended
|
|
// on its own line, so a trivial command still receives the user's input.
|
|
func Expand(template, args string) string {
|
|
args = strings.TrimSpace(args)
|
|
positional := strings.Fields(args)
|
|
|
|
if !strings.Contains(template, "$") {
|
|
if args == "" {
|
|
return template
|
|
}
|
|
return template + "\n\n" + args
|
|
}
|
|
|
|
var b strings.Builder
|
|
runes := []rune(template)
|
|
for i := 0; i < len(runes); i++ {
|
|
if runes[i] != '$' || i == len(runes)-1 {
|
|
b.WriteRune(runes[i])
|
|
continue
|
|
}
|
|
next := runes[i+1]
|
|
switch {
|
|
case next == '$':
|
|
b.WriteByte('$')
|
|
i++
|
|
case next >= '1' && next <= '9':
|
|
idx := int(next - '1')
|
|
if idx < len(positional) {
|
|
b.WriteString(positional[idx])
|
|
}
|
|
i++
|
|
case strings.HasPrefix(string(runes[i+1:]), "ARGUMENTS"):
|
|
b.WriteString(args)
|
|
i += len("ARGUMENTS")
|
|
default:
|
|
b.WriteRune('$')
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// --- frontmatter helpers (self-contained; mirror internal/skills) ------------
|
|
|
|
func splitFrontmatter(normalized string) (string, string, bool) {
|
|
if !strings.HasPrefix(normalized, "---\n") {
|
|
return "", "", false
|
|
}
|
|
lines := strings.Split(normalized, "\n")
|
|
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
|
|
return "", "", false
|
|
}
|
|
for index := 1; index < len(lines); index++ {
|
|
if strings.TrimSpace(lines[index]) == "---" {
|
|
return strings.Join(lines[1:index], "\n"), strings.Join(lines[index+1:], "\n"), true
|
|
}
|
|
}
|
|
return "", "", false
|
|
}
|
|
|
|
func frontmatterValue(frontmatter, key string) string {
|
|
prefix := strings.ToLower(key) + ":"
|
|
for _, line := range strings.Split(frontmatter, "\n") {
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.HasPrefix(strings.ToLower(trimmed), prefix) {
|
|
return strings.Trim(strings.TrimSpace(trimmed[len(prefix):]), `"'`)
|
|
}
|
|
}
|
|
return ""
|
|
}
|