(^· ·^)⟆ gatto · an AI assistant that runs on your own PC

Extensions

An extension is a small C# script that gives gatto a new tool. These are the ones that ship with it, each read line by line by a person before it went up.


How they work

Each extension is one plain text file that lives in your own home folder, under ~\.gatto\extensions\. gatto compiles them when it starts. There is no package manager, no dependency tree, and nothing to keep updated.

gatto only ever loads them from your folder, never from the project you have open. That is the part worth understanding: opening someone else's code cannot hand gatto a new tool, because gatto is not looking there. What gatto is able to do is decided by you, on your machine, before you open anything.

To install one, save its file into that folder keeping the name it has here, and restart gatto. That is the whole thing. A one-command install is coming.

⚠ Keep the filename. gatto will also load an extension from a folder of its own, but the copy that ships inside gatto is checked by its exact content, and that check includes where the file sits. A renamed or re-nested copy still runs, but gatto treats it as a stranger's script rather than as its own, and it quietly gets less access. Save it flat, under the name shown.


What ships

Every row expands to its source. Comments about gatto's own internals are trimmed out of what is shown here: they are notes for whoever maintains gatto, not for anyone reading to learn how an extension is put together, so what you see is the code, not the commentary.

⚠ To install one, download the file; do not copy the text off this page. A bundled extension is trusted by its exact contents, and a copy that differs by so much as a comment is treated as an ordinary script: it still works, it just quietly gets less access. Each row links to the real file.

▸ask_userStops and asks you 1-4 multiple-choice questions when gatto needs a decision it cannot make itself.bundled
host API
Gatto.Register · Gatto.Repl.Term.UnicodeWidth
install
Download it and save it as ~\.gatto\extensions\ask_user.csx

Comments about gatto's own internals are trimmed here so the code is readable. The file on GitHub is the one to install.

//ask_user, bundled with gatto. it registers read-class, so it never prompts for permission.
//editing this file drops it to prompting until you revert it. your copy is your code.
using System.Text;
using System.Text.Json;
using Gatto.Core.Tools;
using Cells = Gatto.Terminal.UnicodeWidth;

Func<string, int, string> cap = (s, max) =>
{
    if (Cells.Of(s) <= max) return s;
    var sb = new StringBuilder();
    var w = 0;
    foreach (var rune in s.EnumerateRunes())
    {
        var rw = Cells.OfRune(rune);
        if (w + rw > max - 1) break;   // one cell reserved for the ellipsis
        sb.Append(rune.ToString());
        w += rw;
    }
    return sb.Append('…').ToString();
};

Gatto.Register(
    "ask_user",
    "Ask the user 1-4 multiple-choice questions (2-4 options each; user can always type a free-text answer). Use for decisions you cannot make yourself. Each option is a plain string, or an object {\"label\":string,\"description\"?:string,\"recommended\"?:bool} — description stays one short line; if you have a recommendation, mark exactly one option recommended and list it first.",
    """
    {"type":"object","properties":{"questions":{"type":"array","items":{"type":"object","properties":{"question":{"type":"string"},"header":{"type":"string"},"options":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"object","properties":{"label":{"type":"string"},"description":{"type":"string"},"recommended":{"type":"boolean"}},"required":["label"]}]}},"multi_select":{"type":"boolean"}},"required":["question","header","options"]}}},"required":["questions"]}
    """,
    async (args, ctx, ct) =>
    {
        if (!args.TryGetProperty("questions", out var qsEl) || qsEl.ValueKind != JsonValueKind.Array)
            throw new ArgumentException("missing required parameter: questions");
        var questions = new List<AskQuestion>();
        foreach (var q in qsEl.EnumerateArray())
        {
            if (q.ValueKind != JsonValueKind.Object || !q.TryGetProperty("question", out var qu) || qu.ValueKind != JsonValueKind.String)
                throw new ArgumentException("missing required parameter: question");
            var question = qu.GetString()!;
            if (q.ValueKind != JsonValueKind.Object || !q.TryGetProperty("header", out var hd) || hd.ValueKind != JsonValueKind.String)
                throw new ArgumentException("missing required parameter: header");
            var header = hd.GetString()!;
            if (header.Length == 0)
                throw new ArgumentException("header must not be empty");
            if (!q.TryGetProperty("options", out var opts) || opts.ValueKind != JsonValueKind.Array)
                throw new ArgumentException("each question needs options");
            var options = new List<AskOption>();
            foreach (var o in opts.EnumerateArray())
            {
                if (o.ValueKind == JsonValueKind.String)
                {
                    options.Add(new AskOption(o.GetString()!));
                    continue;
                }
                if (o.ValueKind == JsonValueKind.Object)
                {
                    if (!o.TryGetProperty("label", out var lb) || lb.ValueKind != JsonValueKind.String)
                        throw new ArgumentException($"question '{header}' has an option with no label");
                    var description = o.TryGetProperty("description", out var de) && de.ValueKind == JsonValueKind.String
                        ? de.GetString() : null;
                    var recommended = o.TryGetProperty("recommended", out var rc) && rc.ValueKind == JsonValueKind.True;
                    options.Add(new AskOption(lb.GetString()!, description, recommended));
                    continue;
                }
                throw new ArgumentException($"question '{header}' has a non-string option");
            }
            if (options.Count is < 2 or > 4)
                throw new ArgumentException($"question '{header}' needs 2-4 options, got {options.Count}");
            if (options.Select(o => o.Label).Distinct().Count() != options.Count)
                throw new ArgumentException($"question '{header}' has duplicate options");
            var multi = q.TryGetProperty("multi_select", out var m) && m.ValueKind == JsonValueKind.True;
            questions.Add(new AskQuestion(question, header, options, multi));
        }
        if (questions.Count is < 1 or > 4)
            throw new ArgumentException($"ask_user takes 1-4 questions, got {questions.Count}");

        var answers = await Gatto.Ui.AskAsync(questions, ct);
        var gloss = string.Join(" · ", answers.Select(a =>
        {
            var picked = string.Join(", ", a.Selected);
            return a.Header + ": " + (picked.Length == 0 ? "skipped" : cap(picked, 40));
        }));
        gloss = cap(gloss, 120);
        return new ToolResult(JsonSerializer.Serialize(
            answers.Select(a => new { header = a.Header, selected = a.Selected })), Gloss: gloss);
    },
    readClass: true);

Gatto.Policy("When a choice is the user's to make, such as a design direction, a name, or a trade-off, stop and ask with ask_user (2-4 options) instead of choosing for them.");

read from gatto-extensions@c635505 · view on GitHub

▸web_searchSearches the web and fetches pages as readable text. Providers are configured in gatto.json.bundled
install
Download it and save it as ~\.gatto\extensions\web_search.csx

Comments about gatto's own internals are trimmed here so the code is readable. The file on GitHub is the one to install.

//web_search and web_fetch, bundled with gatto. shell-class, so they are permission-gated
//per project and always prompt. editing this file changes behaviour, not that posture.
using System.Text.RegularExpressions;
using Gatto.Core.Tools;

var rxTimeout = TimeSpan.FromSeconds(2);
var anchorRx  = new Regex(@"<a[^>]+rel=[""']nofollow[""'][^>]+href=[""']([^""']+)[""'][^>]*>(.*?)</a>", RegexOptions.Singleline | RegexOptions.IgnoreCase, rxTimeout);
var snippetRx = new Regex(@"<td[^>]*class=[""']result-snippet[""'][^>]*>(.*?)</td>", RegexOptions.Singleline | RegexOptions.IgnoreCase, rxTimeout);
var tagRx     = new Regex(@"<[^>]+>", RegexOptions.Singleline, rxTimeout);
var numEntRx  = new Regex(@"&#x?[0-9a-fA-F]+;", RegexOptions.IgnoreCase, rxTimeout);

Func<string, string> decode = s =>
{
    s = numEntRx.Replace(s, m =>
    {
        var v = m.Value;                               // "&#39;" or "&#x27;"
        var inner = v.Substring(2, v.Length - 3);
        try
        {
            int code = inner.Length > 0 && (inner[0] == 'x' || inner[0] == 'X')
                ? Convert.ToInt32(inner.Substring(1), 16)
                : int.Parse(inner);
            return char.ConvertFromUtf32(code);
        }
        catch { return v; }                            // undecodable ⇒ leave verbatim, never throw
    });
    s = s.Replace("&lt;", "<").Replace("&gt;", ">").Replace("&quot;", "\"").Replace("&#39;", "'");
    return s.Replace("&amp;", "&");
};

Func<string, string> strip = s => tagRx.Replace(s, "");
Func<string, string> clean = s => decode(strip(s)).Trim();

Func<string, string> unwrap = href =>
{
    if (href.StartsWith("//duckduckgo.com/l/?uddg="))
    {
        var m = Regex.Match(href, @"[?&]uddg=([^&]+)", RegexOptions.None, rxTimeout);
        if (m.Success) return Uri.UnescapeDataString(m.Groups[1].Value);
    }
    return href;
};

var searchCfg = Gatto.Config.Section("search");
var chain = new List<string> { "ddg" };
string? tavilyKey = null;
string? searxngUrl = null;
if (searchCfg is { } cfg)
{
    if (cfg.TryGetProperty("providers", out var pv) && pv.ValueKind == JsonValueKind.Array)
    {
        chain.Clear();
        foreach (var p in pv.EnumerateArray()) chain.Add(p.GetString()!);
    }
    if (cfg.TryGetProperty("tavily", out var tv) && tv.ValueKind == JsonValueKind.Object
        && tv.TryGetProperty("apiKey", out var tk) && tk.ValueKind == JsonValueKind.String)
        tavilyKey = tk.GetString();
    if (cfg.TryGetProperty("searxng", out var sx) && sx.ValueKind == JsonValueKind.Object
        && sx.TryGetProperty("url", out var su) && su.ValueKind == JsonValueKind.String)
        searxngUrl = su.GetString()!.TrimEnd('/');
}

Func<string, string> capSnippet = s => s.Length <= 400 ? s : s.Substring(0, 400) + "…";

Func<string, int, List<(string Title, string Url, string Snippet)>> parseJsonResults = (json, count) =>
{
    var results = new List<(string, string, string)>();
    using var doc = JsonDocument.Parse(json);
    if (doc.RootElement.TryGetProperty("results", out var arr) && arr.ValueKind == JsonValueKind.Array)
        foreach (var r in arr.EnumerateArray())
        {
            if (results.Count >= count) break;
            var title = r.TryGetProperty("title", out var t) && t.ValueKind == JsonValueKind.String ? t.GetString()! : "";
            var url = r.TryGetProperty("url", out var u) && u.ValueKind == JsonValueKind.String ? u.GetString()! : "";
            var snip = r.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String ? capSnippet(c.GetString()!) : "";
            if (url != "") results.Add((title, url, snip));
        }
    return results;
};

Func<string, int, CancellationToken, Task<List<(string Title, string Url, string Snippet)>>> ddg =
    async (query, count, ct) =>
{
    var res = await Gatto.Http.FetchAsync(
        "https://lite.duckduckgo.com/lite/?q=" + Uri.EscapeDataString(query), ct);
    var html = res.Text;
    if (html.Contains("duckduckgo.com/anomaly.js") || html.Contains("anomaly-modal"))
        throw new InvalidOperationException("bot-check challenge (this network is rate-flagged, often for a while)");

    var snippets = snippetRx.Matches(html);
    var results = new List<(string, string, string)>();
    foreach (Match a in anchorRx.Matches(html))
    {
        if (results.Count >= count) break;
        var url = unwrap(a.Groups[1].Value);
        var title = clean(a.Groups[2].Value);
        var snippet = "";
        foreach (Match s in snippets)
            if (s.Index > a.Index) { snippet = clean(s.Groups[1].Value); break; }
        results.Add((title, url, snippet));
    }
    return results;
};

Func<string, int, CancellationToken, Task<List<(string Title, string Url, string Snippet)>>> tavily =
    async (query, count, ct) =>
{
    var body = JsonSerializer.Serialize(new { query = query, max_results = count, include_answer = false });
    var res = await Gatto.Http.FetchAsync(
        "https://api.tavily.com/search",
        new FetchOptions(
            Headers: new Dictionary<string, string> { ["Authorization"] = "Bearer " + tavilyKey },
            PostJson: body),
        ct);
    return parseJsonResults(res.Text, count);
};

Func<string, int, CancellationToken, Task<List<(string Title, string Url, string Snippet)>>> searxng =
    async (query, count, ct) =>
{
    var res = await Gatto.Http.FetchAsync(
        searxngUrl + "/search?q=" + Uri.EscapeDataString(query) + "&format=json", ct);
    return parseJsonResults(res.Text, count);
};

var providers = new Dictionary<string, Func<string, int, CancellationToken, Task<List<(string Title, string Url, string Snippet)>>>>
{
    ["ddg"] = ddg,
    ["tavily"] = tavily,
    ["searxng"] = searxng,
};

Gatto.Register(
    "web_search",
    "Search the web. Returns a numbered list of results (title, URL, snippet). Follow up with web_fetch to read a promising page.",
    """
    {"type":"object","properties":{"query":{"type":"string","description":"The search query."},"count":{"type":"integer","description":"How many results to return (default 5, max 10)."}},"required":["query"]}
    """,
    async (args, ctx, ct) =>
    {
        if (!args.TryGetProperty("query", out var qEl) || qEl.ValueKind != JsonValueKind.String)
            throw new ArgumentException("missing required parameter: query");
        var query = qEl.GetString();

        var count = 5;
        if (args.TryGetProperty("count", out var cEl) && cEl.ValueKind == JsonValueKind.Number && cEl.TryGetInt32(out var n))
            count = n;
        if (count < 1) count = 1;
        if (count > 10) count = 10;                    // clamp, never error

        var failures = new List<string>();
        var threw = false;
        foreach (var name in chain)
        {
            List<(string Title, string Url, string Snippet)> results;
            try { results = await providers[name](query, count, ct); }
            catch (Exception ex)
            {
                threw = true;
                var hint = name == "tavily" && ex.Message.Contains("HTTP 401") ? " (check search.tavily.apiKey)" : "";
                failures.Add($"{name}: {ex.Message}{hint}");
                continue;
            }
            if (results.Count == 0) { failures.Add($"{name}: no results returned"); continue; }

            var blocks = new List<string>();
            foreach (var (title, url, snippet) in results)
                blocks.Add($"{blocks.Count + 1}. {title}\n   {url}\n   {snippet}");
            var text = string.Join("\n", blocks);
            Gatto.Ledger.RecordFetch(query, text, searchOnly: true);
            return new ToolResult(text, Gloss: $"{blocks.Count} result{(blocks.Count == 1 ? "" : "s")} · {name}");
        }

        if (!threw)
            return new ToolResult($"no results for '{query}'");
        throw new InvalidOperationException(
            "web_search failed — " + string.Join("; ", failures)
            + ". Try web_fetch on a site you already know, or configure another provider in gatto.json (search.providers).");
    });

Gatto.Register(
    "web_fetch",
    "Fetch a URL and return its readable text (HTML converted to text; links become [text](url)). Use after web_search to read a promising page.",
    """
    {"type":"object","properties":{"url":{"type":"string","description":"The URL to fetch."}},"required":["url"]}
    """,
    async (args, ctx, ct) =>
    {
        if (!args.TryGetProperty("url", out var uEl) || uEl.ValueKind != JsonValueKind.String)
            throw new ArgumentException("missing required parameter: url");
        var url = uEl.GetString();

        var res = await Gatto.Http.FetchAsync(url, ct);   // ledger recording comes free from the guarded fetch
        var s = res.Text;

        s = Regex.Replace(s, @"<script[^>]*>.*?</script>", "", RegexOptions.Singleline | RegexOptions.IgnoreCase, rxTimeout);
        s = Regex.Replace(s, @"<style[^>]*>.*?</style>", "", RegexOptions.Singleline | RegexOptions.IgnoreCase, rxTimeout);
        s = Regex.Replace(s, @"<a[^>]+href=""([^""]*)""[^>]*>(.*?)</a>",
            m => "[" + m.Groups[2].Value + "](" + m.Groups[1].Value + ")",
            RegexOptions.Singleline | RegexOptions.IgnoreCase, rxTimeout);
        s = strip(s);
        s = decode(s);
        s = s.Replace("\r\n", "\n").Replace('\r', '\n');
        s = Regex.Replace(s, @"\n{3,}", "\n\n", RegexOptions.None, rxTimeout);
        s = s.Trim();

        const int cap = 64 * 1024;                     // 64 KB of UTF-16 chars
        var truncated = s.Length > cap;
        if (truncated)
            s = s.Substring(0, cap) + "\n[truncated at 64 KB]";

        var kb = Math.Max(1, (s.Length + 1023) / 1024);
        return new ToolResult(s, Gloss: $"{kb} KB text{(truncated ? " (truncated)" : "")}");
    });

read from gatto-extensions@c635505 · view on GitHub

▸undoSnapshots a file before every write the tools make, and /undo puts one back.bundled
install
Download it and save it as ~\.gatto\extensions\undo.csx

Comments about gatto's own internals are trimmed here so the code is readable. The file on GitHub is the one to install.

//undo, bundled with gatto. a snapshot of a file's bytes before every write the tools make, and /undo to put one back.
//your copy is your code. delete this file and undo is off.
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Text;
using System.Text.Json;
using Gatto.Core.Tools;

const int MaxSnapshots = 500;
const long MaxBytes = 200L * 1024 * 1024;
const long MaxFileBytes = 50L * 1024 * 1024;
const int KeepNewest = 10;
const int ListCount = 10;

var cwd = Path.GetFullPath(Gatto.Cwd).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var root = Path.Combine(Gatto.Home, "undo");
var me = Process.GetCurrentProcess();
var myFolder = Path.Combine(root, $"{me.StartTime:yyyyMMdd-HHmmss}-{me.Id}");
var myIndex = Path.Combine(myFolder, "index.jsonl");
var n = 0;
var pending = new Dictionary<string, List<int>>(StringComparer.Ordinal);
var sync = new object();
var toldOutside = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

Func<string, string> full = p =>
{
    if (p == "~" || p.StartsWith("~\\", StringComparison.Ordinal) || p.StartsWith("~/", StringComparison.Ordinal))
        p = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + p[1..];
    return Path.GetFullPath(Path.IsPathRooted(p) ? p : Path.Combine(cwd, p));
};
Func<string, bool> inside = f =>
    f.Equals(cwd, StringComparison.OrdinalIgnoreCase)
    || f.StartsWith(cwd + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);

Action<string> appendLine = line =>
{
    Directory.CreateDirectory(myFolder);
    File.AppendAllText(myIndex, line + "\n", new UTF8Encoding(false));
};

Func<Dictionary<string, object?>, string> json = o =>
{
    using var ms = new MemoryStream();
    using (var w = new Utf8JsonWriter(ms))
    {
        w.WriteStartObject();
        foreach (var (k, v) in o)
        {
            if (v is null) continue;
            switch (v)
            {
                case string s: w.WriteString(k, s); break;
                case int i: w.WriteNumber(k, i); break;
                case long l: w.WriteNumber(k, l); break;
                case bool b: w.WriteBoolean(k, b); break;
                case IEnumerable<int> list: w.WriteStartArray(k); foreach (var x in list) w.WriteNumberValue(x); w.WriteEndArray(); break;
            }
        }
        w.WriteEndObject();
    }
    return Encoding.UTF8.GetString(ms.ToArray());
};

long measuredBytes = 0;
int measuredCount = 0;
long mineBytes = 0;
int mineCount = 0;

Action<string, string, string> snapshot = (callId, tool, given) =>
{
    string f;
    try { f = full(given); }
    catch (Exception ex) { Gatto.Log($"undo: {given}: {ex.Message}"); return; }
    if (!inside(f))
    {
        bool first;
        lock (sync) first = toldOutside.Add(f);
        if (first) Gatto.Log($"undo: unsnapshotted: outside folder: {given}");
        return;
    }
    if (Directory.Exists(f)) { Gatto.Log($"undo: unsnapshotted: directory: {given}"); return; }
    lock (sync)
    {
        try
        {
            var row = new Dictionary<string, object?>
            {
                ["n"] = n + 1,
                ["ts"] = DateTimeOffset.Now.ToString("yyyy-MM-dd'T'HH:mm:sszzz", CultureInfo.InvariantCulture),
                ["tool"] = tool, ["path"] = given, ["full"] = f,
            };
            if (File.Exists(f))
            {
                var len = new FileInfo(f).Length;
                if (len > MaxFileBytes) { Gatto.Log($"undo: unsnapshotted: {given} is {len} bytes, above the cap"); return; }
                var name = $"{n + 1}-{Path.GetFileName(f)}";
                Directory.CreateDirectory(myFolder);
                File.Copy(f, Path.Combine(myFolder, name), overwrite: true);
                row["bytes"] = len;
                row["file"] = name;
            }
            else row["absent"] = true;
            n++;
            appendLine(json(row));
            if (!pending.TryGetValue(callId, out var ns)) pending[callId] = ns = new List<int>();
            ns.Add(n);
            mineCount++;
            if (row.TryGetValue("bytes", out var added) && added is long addedBytes) mineBytes += addedBytes;
        }
        catch (Exception ex) { Gatto.Log($"undo: {given}: {ex.Message}"); }
    }
    trim(false);
};

Func<JsonElement, string, string?> str = (a, key) =>
    a.ValueKind == JsonValueKind.Object && a.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;

Func<string, IReadOnlyList<(string Path, string Why)>> shellTargets = null!;

Gatto.On("tool_call", payload =>
{
    var call = payload.Call;
    if (call is null) return Task.CompletedTask;
    JsonElement args;
    try { args = JsonDocument.Parse(call.ArgumentsJson).RootElement; }
    catch { return Task.CompletedTask; }
    if (call.Name is "write_file" or "edit_file")
    {
        if (str(args, "path") is { } p) snapshot(call.Id, call.Name, p);
    }
    else if (call.Name == "shell" && str(args, "command") is { } cmd)
    {
        IReadOnlyList<(string Path, string Why)> targets;
        try { targets = shellTargets(cmd); }
        catch (Exception ex) { Gatto.Log($"undo: shell: {ex.Message}"); return Task.CompletedTask; }
        foreach (var (p, why) in targets)
        {
            if (why.Length > 0) Gatto.Log($"undo: unsnapshotted: {why}: {p}");
            else snapshot(call.Id, "shell", p);
        }
    }
    return Task.CompletedTask;
});

Gatto.On("tool_result", payload =>
{
    var call = payload.Call;
    var result = payload.Result;
    if (call is null || result is null) return Task.CompletedTask;
    lock (sync)
    {
        if (!pending.Remove(call.Id, out var ns)) return Task.CompletedTask;
        var state = result.Text.StartsWith("blocked:", StringComparison.Ordinal) ? "blocked"
            : result.IsError && call.Name != "shell" ? "failed"
            : "ran";
        try { appendLine(json(new Dictionary<string, object?> { ["mark"] = ns, ["state"] = state })); }
        catch (Exception ex) { Gatto.Log($"undo: mark: {ex.Message}"); }
    }
    return Task.CompletedTask;
});

Func<string, List<Dictionary<string, JsonElement>>> readFolder = folder =>
{
    var rows = new List<Dictionary<string, JsonElement>>();
    var marks = new Dictionary<int, string>();
    var index = Path.Combine(folder, "index.jsonl");
    if (!File.Exists(index)) return rows;
    string[] lines;
    try { lines = File.ReadAllLines(index); } catch { return rows; }
    foreach (var line in lines)
    {
        if (line.Length == 0) continue;
        try
        {
            var e = JsonDocument.Parse(line).RootElement;
            if (e.TryGetProperty("mark", out var m))
            {
                var state = e.GetProperty("state").GetString()!;
                var ns = m.EnumerateArray().Select(x => x.GetInt32()).ToList();
                foreach (var x in ns) marks[x] = state;
                continue;
            }
            var row = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
            foreach (var p in e.EnumerateObject()) row[p.Name] = p.Value;
            if (!(row.TryGetValue("n", out var rn) && rn.ValueKind == JsonValueKind.Number && rn.TryGetInt32(out _))) continue;
            if (!(row.TryGetValue("ts", out var rt) && rt.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(rt.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.None, out _))) continue;
            if (!(row.TryGetValue("tool", out var rk) && rk.ValueKind == JsonValueKind.String)) continue;
            if (!(row.TryGetValue("full", out var rf) && rf.ValueKind == JsonValueKind.String && rf.GetString() is { } fullPath
                && Path.IsPathFullyQualified(fullPath) && fullPath == Path.GetFullPath(fullPath))) continue;
            var hasFile = row.TryGetValue("file", out var rc);
            if (hasFile != row.TryGetValue("bytes", out var rb)) continue;
            if (hasFile && !(rb.ValueKind == JsonValueKind.Number && rb.TryGetInt64(out var copied) && copied >= 0)) continue;
            if (hasFile && !(rc.ValueKind == JsonValueKind.String && rc.GetString() is { } copy
                && copy == Path.GetFileName(copy) && copy.StartsWith($"{rn.GetInt32()}-", StringComparison.Ordinal))) continue;
            rows.Add(row);
        }
        catch { }
    }
    foreach (var row in rows)
        if (row.TryGetValue("n", out var rn) && marks.TryGetValue(rn.GetInt32(), out var st))
            row["state"] = JsonDocument.Parse($"\"{st}\"").RootElement;
    return rows;
};

Func<string, bool> live = folder =>
{
    var name = Path.GetFileName(folder);
    var dash = name.LastIndexOf('-');
    if (dash < 0 || !int.TryParse(name[(dash + 1)..], out var pid)) return false;
    if (!DateTime.TryParseExact(name[..dash], "yyyyMMdd-HHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var stamp)) return false;
    try
    {
        using var p = Process.GetProcessById(pid);
        try { return Math.Abs((p.StartTime - stamp).TotalSeconds) < 2; }
        catch { return true; }
    }
    catch { return false; }
};

void trim(bool force)
{
    lock (sync)
    {
        try
        {
            if (!force && measuredCount + mineCount <= MaxSnapshots && measuredBytes + mineBytes <= MaxBytes) return;
            if (!Directory.Exists(root)) return;
            var all = new List<(string Folder, Dictionary<string, JsonElement> Row, bool Mine)>();
            foreach (var folder in Directory.GetDirectories(root))
            {
                var mine = folder.Equals(myFolder, StringComparison.OrdinalIgnoreCase);
                if (!mine && live(folder)) continue;
                foreach (var row in readFolder(folder)) all.Add((folder, row, mine));
            }
            long bytes = all.Sum(a => a.Row.TryGetValue("bytes", out var b) ? b.GetInt64() : 0L);
            var count = all.Count;
            if (count <= MaxSnapshots && bytes <= MaxBytes) { measuredCount = count; measuredBytes = bytes; mineCount = 0; mineBytes = 0; return; }
            var mineLeft = all.Count(a => a.Mine);
            var ordered = all.OrderBy(a => a.Mine ? 1 : 0).ThenBy(a => a.Row["ts"].GetString(), StringComparer.Ordinal).ThenBy(a => a.Row["n"].GetInt32()).ToList();
            var drop = new List<(string Folder, Dictionary<string, JsonElement> Row, bool Mine)>();
            foreach (var a in ordered)
            {
                if (count <= MaxSnapshots && bytes <= MaxBytes) break;
                if (a.Mine && mineLeft <= KeepNewest) break;
                drop.Add(a);
                count--;
                if (a.Mine) mineLeft--;
                bytes -= a.Row.TryGetValue("bytes", out var b) ? b.GetInt64() : 0L;
            }
            foreach (var group in drop.GroupBy(d => d.Folder))
            {
                var gone = new HashSet<int>(group.Select(d => d.Row["n"].GetInt32()));
                foreach (var d in group)
                    if (d.Row.TryGetValue("file", out var f)) { try { File.Delete(Path.Combine(group.Key, f.GetString()!)); } catch { } }
                var index = Path.Combine(group.Key, "index.jsonl");
                var keptRows = new List<string>();
                var keptNs = new HashSet<int>();
                var markLines = new List<(string Line, int[] Ns)>();
                foreach (var l in File.ReadAllLines(index))
                {
                    try
                    {
                        var e = JsonDocument.Parse(l).RootElement;
                        if (e.TryGetProperty("mark", out var m)) { markLines.Add((l, m.EnumerateArray().Select(x => x.GetInt32()).ToArray())); continue; }
                        var rowN = e.GetProperty("n").GetInt32();
                        if (gone.Contains(rowN)) continue;
                        keptRows.Add(l);
                        keptNs.Add(rowN);
                    }
                    catch { }
                }
                var kept = keptRows.Concat(markLines.Where(m => m.Ns.Any(keptNs.Contains)).Select(m => m.Line)).ToList();
                if (keptRows.Count == 0 && !group.Key.Equals(myFolder, StringComparison.OrdinalIgnoreCase)) Directory.Delete(group.Key, recursive: true);
                else File.WriteAllText(index, string.Join("\n", kept) + (kept.Count > 0 ? "\n" : ""), new UTF8Encoding(false));
            }
            measuredCount = count;
            measuredBytes = bytes;
            mineCount = 0;
            mineBytes = 0;
        }
        catch (Exception ex) { Gatto.Log($"undo: retention: {ex.Message}"); }
    }
}

var pathHeads = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
    ["set-content"] = "p", ["sc"] = "p", ["add-content"] = "p", ["ac"] = "p", ["out-file"] = "p",
    ["tee-object"] = "p", ["tee"] = "p", ["new-item"] = "p", ["ni"] = "p", ["clear-content"] = "p", ["clc"] = "p",
    ["remove-item"] = "all", ["rm"] = "all", ["del"] = "all", ["erase"] = "all", ["ri"] = "all", ["rd"] = "all", ["rmdir"] = "all",
    ["rename-item"] = "p", ["rni"] = "p", ["ren"] = "p",
    ["copy-item"] = "d", ["cp"] = "d", ["copy"] = "d",
    ["move-item"] = "pd", ["mv"] = "pd", ["move"] = "pd",
};
var noValue = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
    { "-nonewline", "-force", "-append", "-passthru", "-raw", "-noclobber", "-recurse", "-r", "-whatif", "-confirm", "-container", "-verbose", "-debug" };
var gitWrites = new HashSet<string>(StringComparer.Ordinal) { "checkout", "restore", "reset", "clean" };

Func<string, List<string>> tokens = text =>
{
    var list = new List<string>();
    var sb = new StringBuilder();
    var i = 0;
    Action flush = () => { if (sb.Length > 0) { list.Add(sb.ToString()); sb.Clear(); } };
    while (i < text.Length)
    {
        var c = text[i];
        if (c == '@' && i + 1 < text.Length && (text[i + 1] == '\'' || text[i + 1] == '"'))
        {
            var q = text[i + 1];
            var end = text.IndexOf(q + "@", i + 2, StringComparison.Ordinal);
            if (end < 0) end = text.Length - 2;
            sb.Append(text, i, Math.Min(text.Length, end + 2) - i);
            i = Math.Min(text.Length, end + 2);
            continue;
        }
        if (c == '\'' || c == '"')
        {
            var end = text.IndexOf(c, i + 1);
            if (end < 0) end = text.Length - 1;
            sb.Append(text, i, end + 1 - i);
            i = end + 1;
            continue;
        }
        if (c == '(')
        {
            var depth = 0;
            var start = i;
            while (i < text.Length) { if (text[i] == '(') depth++; else if (text[i] == ')' && --depth == 0) { i++; break; } i++; }
            sb.Append(text, start, i - start);
            continue;
        }
        if (c == ';' || c == '|' || c == '\n' || c == '}' || c == ')') break;
        if (c == '&' && i + 1 < text.Length && text[i + 1] == '&') break;
        if (char.IsWhiteSpace(c)) { flush(); i++; continue; }
        sb.Append(c);
        i++;
    }
    flush();
    return list;
};

Func<string, List<string>> statements = text =>
{
    var list = new List<string>();
    var i = 0;
    while (i < text.Length)
    {
        while (i < text.Length && (char.IsWhiteSpace(text[i]) || text[i] is ';' or '|' or '&' or '{' or '(' or '}' or ')')) i++;
        if (i >= text.Length) break;
        var start = i;
        var depthQ = '\0';
        while (i < text.Length)
        {
            var c = text[i];
            if (depthQ != '\0') { if (c == depthQ) depthQ = '\0'; i++; continue; }
            if (c == '\'' || c == '"') { depthQ = c; i++; continue; }
            if (c is ';' or '|' or '\n' or '{' or '}' or ')') break;
            if (c == '&' && i + 1 < text.Length && text[i + 1] == '&') break;
            i++;
        }
        list.Add(text[start..i].Trim());
    }
    return list;
};

Func<string, IEnumerable<string>> values = tok =>
{
    if (tok.Length == 0 || tok[0] == '(' || tok[0] == '@' || tok.Contains('$')) return Array.Empty<string>();
    var parts = new List<string>();
    var sb = new StringBuilder();
    char? quote = null;
    foreach (var c in tok)
    {
        if (quote is { } q) { if (c == q) quote = null; sb.Append(c); continue; }
        if (c == '\'' || c == '"') { quote = c; sb.Append(c); continue; }
        if (c == ',') { parts.Add(sb.ToString()); sb.Clear(); continue; }
        sb.Append(c);
    }
    parts.Add(sb.ToString());
    return parts.Select(s => s.Trim()).Where(s => s.Length > 0)
        .Select(s => s.Length >= 2 && (s[0] == '\'' || s[0] == '"') && s[^1] == s[0] ? s[1..^1] : s);
};

Func<string, string?> redirectTarget = st =>
{
    for (var i = 0; i < st.Length; i++)
    {
        if (st[i] != '>') continue;
        if (i > 0 && st[i - 1] is '=' or '-' or '>') continue;
        if (i > 0 && st[i - 1] == '2' && i + 1 < st.Length && st[i + 1] == '&') continue;
        var j = i + 1;
        if (j < st.Length && st[j] == '>') j++;
        if (j < st.Length && st[j] == '=') continue;
        while (j < st.Length && char.IsWhiteSpace(st[j])) j++;
        var start = j;
        if (start < st.Length && (st[start] == '\'' || st[start] == '"'))
        {
            var end = st.IndexOf(st[start], start + 1);
            return end < 0 ? null : st[(start + 1)..end];
        }
        while (j < st.Length && !char.IsWhiteSpace(st[j]) && st[j] is not ('|' or ';' or '&' or '(' or ')' or '{' or '}' or '<' or '>')) j++;
        var target = st[start..j];
        if (target.Length == 0 || target[0] == '&' || target.Contains('$')) return null;
        if (target.Equals("NUL", StringComparison.OrdinalIgnoreCase) || target == "/dev/null") return null;
        if (!(char.IsLetterOrDigit(target[0]) || target[0] is '.' or '\\' or '/' or '~')) return null;
        return target;
    }
    return null;
};

shellTargets = command =>
{
    var found = new List<(string Path, string Why)>();
    foreach (var st in statements(command))
    {
        var toks = tokens(st);
        if (toks.Count == 0) continue;
        var head = toks[0];
        if (head.Equals("git", StringComparison.OrdinalIgnoreCase))
        {
            if (toks.Count > 1 && gitWrites.Contains(toks[1])) found.Add((st, "git"));
            else if (redirectTarget(st) is { } gr) found.Add((gr, ""));
            continue;
        }
        var bracket = head.IndexOf("::", StringComparison.Ordinal);
        if (bracket > 0 && (head.StartsWith("[IO.File]", StringComparison.OrdinalIgnoreCase) || head.StartsWith("[System.IO.File]", StringComparison.OrdinalIgnoreCase)))
        {
            var member = head[(bracket + 2)..];
            if (member.StartsWith("Write", StringComparison.OrdinalIgnoreCase) || member.StartsWith("Append", StringComparison.OrdinalIgnoreCase))
            {
                var open = st.IndexOf('(');
                if (open >= 0)
                {
                    var inner = tokens(st[(open + 1)..].TrimStart());
                    var first = inner.Count > 0 ? inner[0].TrimEnd(',') : "";
                    foreach (var v in values(first)) found.Add((v, ""));
                }
            }
            continue;
        }
        if (pathHeads.TryGetValue(head, out var kind))
        {
            var positional = new List<string>();
            var named = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
            var directory = false;
            for (var i = 1; i < toks.Count; i++)
            {
                var t = toks[i];
                if (t.StartsWith('-') && t.Length > 1 && !char.IsDigit(t[1]))
                {
                    if (noValue.Contains(t) || t.Contains(':')) { continue; }
                    if (i + 1 < toks.Count)
                    {
                        if (t.Equals("-ItemType", StringComparison.OrdinalIgnoreCase) && toks[i + 1].Equals("Directory", StringComparison.OrdinalIgnoreCase)) directory = true;
                        named[t] = toks[i + 1];
                        i++;
                    }
                    continue;
                }
                positional.Add(t);
            }
            if (directory) { found.Add((positional.Count > 0 ? positional[0] : head, "directory")); continue; }
            string? pathTok = named.TryGetValue("-Path", out var np) ? np : named.TryGetValue("-LiteralPath", out var nl) ? nl : named.TryGetValue("-FilePath", out var nf) ? nf : null;
            string? destTok = named.TryGetValue("-Destination", out var nd) ? nd : null;
            var pos = 0;
            if (pathTok is null && positional.Count > pos) pathTok = positional[pos++];
            if (destTok is null && kind is "d" or "pd" && positional.Count > pos) destTok = positional[pos++];
            if (kind == "all")
            {
                foreach (var p in positional) foreach (var v in values(p)) found.Add((v, ""));
                if (named.TryGetValue("-Path", out var ap)) foreach (var v in values(ap)) found.Add((v, ""));
                if (named.TryGetValue("-LiteralPath", out var al)) foreach (var v in values(al)) found.Add((v, ""));
                continue;
            }
            if (kind is "p" or "pd" && pathTok is not null) foreach (var v in values(pathTok)) found.Add((v, ""));
            if (kind is "d" or "pd" && destTok is not null) foreach (var v in values(destTok)) found.Add((v, ""));
            continue;
        }
        if (redirectTarget(st) is { } r) found.Add((r, ""));
    }
    return found.Select(f => f.Why.Length == 0 && f.Path.IndexOfAny(['*', '?']) >= 0 ? (f.Path, "wildcard") : f).ToList();
};

trim(true);

Func<long, string> size = b => b < 1024 ? $"{b} B" : b < 1024 * 1024 ? $"{b / 1024} KB" : $"{b / (1024 * 1024)} MB";

Func<List<(string Folder, Dictionary<string, JsonElement> Row, bool Unknown, bool Mine)>> listing = () =>
{
    var rows = new List<(string Folder, Dictionary<string, JsonElement> Row, bool Unknown, bool Mine)>();
    if (!Directory.Exists(root)) return rows;
    foreach (var folder in Directory.GetDirectories(root))
    {
        var mine = folder.Equals(myFolder, StringComparison.OrdinalIgnoreCase);
        var alive = !mine && live(folder);
        foreach (var row in readFolder(folder))
        {
            if (!row.TryGetValue("full", out var f) || !inside(f.GetString()!)) continue;
            var state = row.TryGetValue("state", out var s) ? s.GetString() : null;
            if (state == "ran") rows.Add((folder, row, false, mine));
            else if (state is null && !alive) rows.Add((folder, row, true, mine));
        }
    }
    return rows.OrderByDescending(r => r.Row["ts"].GetString(), StringComparer.Ordinal).ThenByDescending(r => r.Row["n"].GetInt32()).Take(ListCount).ToList();
};

Func<string, string> relative = f => Path.GetRelativePath(cwd, f);

Func<(string Folder, Dictionary<string, JsonElement> Row, bool Unknown, bool Mine), int, string> listLine = (r, i) =>
{
    var ts = DateTimeOffset.Parse(r.Row["ts"].GetString()!, CultureInfo.InvariantCulture);
    var when = ts.Date == DateTimeOffset.Now.Date ? ts.ToString("HH:mm:ss", CultureInfo.InvariantCulture) : ts.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
    var tool = r.Row["tool"].GetString() + (r.Unknown ? "?" : "");
    var what = r.Row.TryGetValue("bytes", out var b) ? size(b.GetInt64()) : "absent";
    var start = "";
    if (!r.Mine && DateTime.TryParseExact(Path.GetFileName(r.Folder)[..15], "yyyyMMdd-HHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var began))
        start = "  " + (began.Date == DateTime.Now.Date ? began.ToString("HH:mm:ss", CultureInfo.InvariantCulture) : began.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
    return $"{i} {when} {tool} {relative(r.Row["full"].GetString()!)} {what}{start}";
};

Func<(string Folder, Dictionary<string, JsonElement> Row, bool Unknown, bool Mine), string> restore = r =>
{
    var target = r.Row["full"].GetString()!;
    var ts = DateTimeOffset.Parse(r.Row["ts"].GetString()!, CultureInfo.InvariantCulture).ToString("HH:mm:ss", CultureInfo.InvariantCulture);
    var callId = $"undo-{Guid.NewGuid():N}";
    snapshot(callId, "undo", target);
    lock (sync)
    {
        if (pending.Remove(callId, out var ns)) appendLine(json(new Dictionary<string, object?> { ["mark"] = ns, ["state"] = "ran" }));
    }
    if (r.Row.TryGetValue("file", out var f))
    {
        Directory.CreateDirectory(Path.GetDirectoryName(target)!);
        File.Copy(Path.Combine(r.Folder, f.GetString()!), target, overwrite: true);
        return $"restored {relative(target)} to {ts} ({size(r.Row["bytes"].GetInt64())})";
    }
    if (File.Exists(target)) File.Delete(target);
    return $"deleted {relative(target)}, it did not exist before {ts}";
};

Gatto.Command("/undo", "[n|path]", "list snapshots for this folder, or restore one", (arg, ct) =>
{
    try
    {
        var rows = listing();
        var a = arg.Trim();
        if (a.Length == 0)
            return Task.FromResult(rows.Count == 0 ? "no snapshots for this folder" : string.Join("\n", rows.Select((r, i) => listLine(r, i + 1))));
        if (int.TryParse(a, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var pick))
            return Task.FromResult(pick >= 1 && pick <= rows.Count ? restore(rows[pick - 1]) : $"no snapshot {a} in the list");
        var unquoted = a.Length >= 2 && (a[0] == '"' || a[0] == '\'') && a[^1] == a[0] ? a[1..^1] : a;
        string wanted;
        try { wanted = full(unquoted); } catch { return Task.FromResult($"no snapshot of {a}"); }
        var byPath = rows.FirstOrDefault(r => r.Row["full"].GetString()!.Equals(wanted, StringComparison.OrdinalIgnoreCase));
        return Task.FromResult(byPath.Row is null ? $"no snapshot of {a}" : restore(byPath));
    }
    catch (Exception ex) { return Task.FromResult($"undo: {ex.Message}"); }
});

read from gatto-extensions@c635505 · view on GitHub

▸cardsWrites a role file from a Character Card V2 or V3, and /card <path> runs it.bundled
install
Download it and save it as ~\.gatto\extensions\cards.csx

Comments about gatto's own internals are trimmed here so the code is readable. The file on GitHub is the one to install.

//cards, bundled with gatto. /card <path> writes a role file from a character card, V2 or V3, PNG or JSON.
//your copy is your code. delete this file and /card is off.
using System.Globalization;
using System.IO;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using Gatto.Core;

var rolesDir = Path.Combine(Gatto.Home, "roles");
byte[] pngSignature = { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };

Func<string, string> full = p => Path.GetFullPath(Path.IsPathRooted(p) ? p : Path.Combine(Gatto.Cwd, p));

Func<byte[], string?> readPng = bytes =>
{
    if (bytes.Length < 8 || !bytes.AsSpan(0, 8).SequenceEqual(pngSignature)) return null;
    string? ccv3 = null, chara = null;
    var offset = 8;
    while (offset + 8 <= bytes.Length)
    {
        var length = (long)bytes[offset] << 24 | (long)bytes[offset + 1] << 16 | (long)bytes[offset + 2] << 8 | bytes[offset + 3];
        var type = Encoding.ASCII.GetString(bytes, offset + 4, 4);
        var start = offset + 8;
        if (type == "IEND" || start + length > bytes.Length) break;
        if (type == "tEXt")
        {
            var nul = Array.IndexOf(bytes, (byte)0, start, (int)length);
            if (nul >= 0)
            {
                var keyword = Encoding.Latin1.GetString(bytes, start, nul - start);
                var text = Encoding.Latin1.GetString(bytes, nul + 1, start + (int)length - nul - 1);
                if (keyword == "ccv3") ccv3 = text;
                else if (keyword == "chara") chara = text;
            }
        }
        offset = start + (int)length + 4;
    }
    return ccv3 ?? chara;
};

Func<JsonElement, string, string?> str = (data, key) =>
    data.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String && v.GetString()!.Trim() is { Length: > 0 } t ? t : null;

Func<string, string> slug = name =>
{
    var sb = new StringBuilder();
    foreach (var c in name.Trim().ToLowerInvariant())
    {
        if (c == ' ') sb.Append('-');
        else if (c is >= 'a' and <= 'z' or >= '0' and <= '9' or '-') sb.Append(c);
    }
    return sb.ToString();
};

Func<int, string, string, string> plural = (n, one, many) => $"{n} {(n == 1 ? one : many)}";

Func<string, string> card = (arg) =>
{
    var a = arg.Trim();
    if (a.Length == 0) return "usage: /card <path>";
    var unquoted = a.Length >= 2 && (a[0] == '"' || a[0] == '\'') && a[^1] == a[0] ? a[1..^1] : a;
    var path = full(unquoted);
    if (!File.Exists(path)) return $"no file at {path}";

    var ext = Path.GetExtension(path);
    var isPng = ext.Equals(".png", StringComparison.OrdinalIgnoreCase);
    if (!isPng && !ext.Equals(".json", StringComparison.OrdinalIgnoreCase)) return $"not a PNG or a JSON file: {path}";

    string json;
    if (isPng)
    {
        var chunk = readPng(File.ReadAllBytes(path));
        if (chunk is null) return $"no character card in {path}";
        try { json = Encoding.UTF8.GetString(Convert.FromBase64String(chunk)); }
        catch (FormatException) { return $"{path} is not a V2 or V3 character card"; }
    }
    else json = File.ReadAllText(path);

    JsonElement data;
    try
    {
        using var doc = JsonDocument.Parse(json);
        var root = doc.RootElement;
        if (root.ValueKind != JsonValueKind.Object
            || !root.TryGetProperty("spec", out var spec) || spec.ValueKind != JsonValueKind.String
            || spec.GetString() is not ("chara_card_v2" or "chara_card_v3")
            || !root.TryGetProperty("data", out var d) || d.ValueKind != JsonValueKind.Object)
            return $"{path} is not a V2 or V3 character card";
        data = d.Clone();
    }
    catch (JsonException) { return $"{path} is not a V2 or V3 character card"; }

    var name = str(data, "name");
    var stem = name is null ? "" : slug(name);
    if (stem.Length == 0) return "the card has no name";
    var target = Path.Combine(rolesDir, stem + ".json");
    if (File.Exists(target)) return $"{Path.Combine("roles", stem + ".json")} exists, delete it first";

    Func<string, string> fill = text => text
        .Replace("{{char}}", name, StringComparison.OrdinalIgnoreCase)
        .Replace("{{user}}", "User", StringComparison.OrdinalIgnoreCase);

    var lore = new List<(double Order, string Content)>();
    var needKeyword = 0;
    var disabled = 0;
    if (data.TryGetProperty("character_book", out var book) && book.ValueKind == JsonValueKind.Object
        && book.TryGetProperty("entries", out var entries) && entries.ValueKind == JsonValueKind.Array)
        foreach (var e in entries.EnumerateArray())
        {
            if (e.ValueKind != JsonValueKind.Object) continue;
            if (e.TryGetProperty("enabled", out var en) && en.ValueKind == JsonValueKind.False) { disabled++; continue; }
            if (!(e.TryGetProperty("constant", out var co) && co.ValueKind == JsonValueKind.True)) { needKeyword++; continue; }
            var order = e.TryGetProperty("insertion_order", out var io) && io.ValueKind == JsonValueKind.Number ? io.GetDouble() : 0;
            if (str(e, "content") is { } content) lore.Add((order, content));
        }
    var loreText = lore.OrderBy(l => l.Order).Select(l => l.Content).ToList();

    var parts = new List<string>();
    var lines = new List<string>();
    foreach (var key in new[] { "system_prompt", "description", "personality", "scenario" })
        if (str(data, key) is { } part) { parts.Add(fill(part)); lines.Add($"append: {key}"); }
    if (loreText.Count > 0)
    {
        parts.AddRange(loreText.Select(fill));
        lines.Add($"append: {plural(loreText.Count, "lorebook entry", "lorebook entries")}");
    }
    if (str(data, "mes_example") is { } example) { parts.Add(fill(example)); lines.Add("append: mes_example"); }
    var append = parts.Count > 0 ? string.Join("\n\n", parts) : null;
    if (append is not null) lines.Add($"append: {Encoding.UTF8.GetByteCount(append).ToString("N0", CultureInfo.InvariantCulture)} bytes");

    var first = str(data, "first_mes") is { } fm ? fill(fm) : null;
    var leaked = first is not null && ControlTokens.Strip(first).Count > 0;
    if (leaked) first = null;
    if (first is not null) lines.Add("first_message: first_mes");
    if (append is null && first is null) lines.Add("raw only, the card has no text");

    var greetings = data.TryGetProperty("alternate_greetings", out var ag) && ag.ValueKind == JsonValueKind.Array
        ? ag.EnumerateArray().Count(g => g.ValueKind == JsonValueKind.String && g.GetString()!.Trim().Length > 0) : 0;
    if (needKeyword > 0) lines.Add($"dropped: {plural(needKeyword, "lorebook entry that needs", "lorebook entries that need")} a keyword");
    if (disabled > 0) lines.Add($"dropped: {plural(disabled, "disabled lorebook entry", "disabled lorebook entries")}");
    if (str(data, "post_history_instructions") is not null) lines.Add("dropped: post_history_instructions");
    if (greetings > 0) lines.Add($"dropped: {plural(greetings, "alternate greeting", "alternate greetings")}");
    if (leaked) lines.Add("dropped: first_mes, it holds a control token");
    if (isPng) lines.Add("dropped: the picture");

    using var ms = new MemoryStream();
    using (var w = new Utf8JsonWriter(ms, new JsonWriterOptions { Indented = true, NewLine = "\n", Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }))
    {
        w.WriteStartObject();
        w.WriteBoolean("raw", true);
        if (append is not null) w.WriteString("append", append);
        if (first is not null) w.WriteString("first_message", first);
        w.WriteEndObject();
    }
    ms.WriteByte((byte)'\n');
    Directory.CreateDirectory(rolesDir);
    File.WriteAllBytes(target, ms.ToArray());

    return string.Join("\n", new[] { $"wrote {target}" }.Concat(lines));
};

Gatto.Command("/card", "<path>", "write a role file from a character card", (arg, ct) =>
{
    try { return Task.FromResult(card(arg)); }
    catch (Exception ex) { return Task.FromResult($"card: {ex.Message}"); }
});

read from gatto-extensions@c635505 · view on GitHub

▸tasksReads and writes the task store of the project gatto opened, through the store's own rules.opt-in, not vetted
install
Download it and save it as ~\.gatto\extensions\tasks.csx

Comments about gatto's own internals are trimmed here so the code is readable. The file on GitHub is the one to install.

//tasks: two tools over the task store of the project gatto opened.
//it does not ship with gatto, so it loads unvetted and its tools ask for permission.
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Text.Encodings.Web;
using System.Text.Json.Nodes;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Text;

public sealed record ArcFile(JsonObject? Data, List<Problem> Problems);

public static class Arc
{
    public const string Head = "const ARC = ";

    sealed class Frame(string path, bool isObject)
    {
        public readonly string At = path;
        public readonly bool IsObject = isObject;
        public readonly HashSet<string> Names = new(StringComparer.Ordinal);
        public string Pending = "";
        public int Index;
    }

    public static ArcFile Load(string root)
    {
        var path = Path.Combine(root, "arc.js");
        return File.Exists(path) ? Parse(File.ReadAllBytes(path)) : Fail("arc.js does not exist");
    }

    static ArcFile Fail(string message) => new(null, [new Problem("0", "arc.js", message)]);

    static string ChildPath(Stack<Frame> frames)
    {
        if (frames.Count == 0) return "ARC";
        var parent = frames.Peek();
        return parent.IsObject ? parent.At + "." + parent.Pending : parent.At + "[" + parent.Index++ + "]";
    }

    public static ArcFile Parse(byte[] bytes)
    {
        string text;
        try
        {
            var start = bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF ? 3 : 0;
            text = Tracker.Utf8.GetString(bytes, start, bytes.Length - start);
        }
        catch (DecoderFallbackException)
        {
            return Fail("arc.js is not valid UTF-8");
        }
        var end = text.LastIndexOf(';');
        if (!text.StartsWith(Head, StringComparison.Ordinal) || end < Head.Length || text[(end + 1)..].Trim().Length > 0)
            return Fail("arc.js is not const ARC = , a JSON document and a semicolon");
        var json = Tracker.Utf8.GetBytes(text[Head.Length..end]);
        var problems = new List<Problem>();
        var frames = new Stack<Frame>();
        try
        {
            var reader = new Utf8JsonReader(json, new JsonReaderOptions { CommentHandling = JsonCommentHandling.Disallow });
            while (reader.Read())
            {
                if (reader.TokenType is JsonTokenType.EndObject or JsonTokenType.EndArray)
                {
                    frames.Pop();
                    continue;
                }
                if (reader.TokenType == JsonTokenType.PropertyName)
                {
                    var top = frames.Peek();
                    var name = reader.GetString() ?? "";
                    if (!top.Names.Add(name)) problems.Add(new Problem("0", "arc.js", $"{top.At} holds the property '{name}' twice, and JSON keeps only the last"));
                    top.Pending = name;
                    continue;
                }
                var at = ChildPath(frames);
                if (reader.TokenType is JsonTokenType.StartObject or JsonTokenType.StartArray) frames.Push(new Frame(at, reader.TokenType == JsonTokenType.StartObject));
            }
        }
        catch (JsonException e)
        {
            var reason = e.Message.Split(" LineNumber:")[0].Trim().TrimEnd('.');
            return Fail($"arc.js is not valid JSON at line {(e.LineNumber ?? 0) + 1}: {reason}");
        }
        if (problems.Count > 0) return new ArcFile(null, problems);
        try
        {
            return JsonNode.Parse(json) is JsonObject data ? new ArcFile(data, problems) : Fail("arc.js does not hold a JSON object");
        }
        catch (Exception e) when (e is JsonException or ArgumentException or InvalidOperationException)
        {
            return Fail("arc.js cannot be read: " + e.Message);
        }
    }
}

public static class ArcRules
{
    public static readonly string[] Numbers = ["0", "1", "2", "3", "4", "5", "6", "8", "9'", "10", "11", "12"];
    static readonly string[] EraStates = ["shipped", "current", "planned"];
    static readonly string[] NodeStates = ["shipped", "inflight", "planned"];
    static readonly Regex StrictTag = new("^v[0-9]+[.][0-9]+[.][0-9]+$");
    static readonly Regex KeyShape = new("^[a-z0-9]+$");
    static readonly Regex DateShape = new("^[0-9]{4}-[0-9]{2}-[0-9]{2}$");
    static readonly JsonSerializerOptions Quote = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };

    sealed record Ctx(List<JsonObject> Eras, List<string> Tags);

    public static readonly (string Node, string Here, string Proof)[] Mapping =
    [
        ("0", "0", "an era state outside the enum, a bucket without a meaning, a property given twice, broken JSON"),
        ("0, a bucket items array", "dropped", "U2: a bucket the arc does not list"),
        ("1", "1", "a second current era"),
        ("2", "2", "a duplicated era key"),
        ("3", "3", "a shipped era after a planned one"),
        ("4", "4", "two in-flight nodes in one funnel"),
        ("5", "5", "an in-flight node under a shipped era"),
        ("6", "6", "a shipped node under a planned era"),
        ("7", "U4", "a wave that names no era"),
        ("8, a done stamp", "U2", "a date that does not exist"),
        ("8, a funnel date", "8", "a shipped node without a date, a planned node with a date"),
        ("9'", "9'", "a cut version no era claims"),
        ("10", "10", "an era carrying a positional id"),
        ("11", "11", "an era shipping a cut version while still current"),
        ("12", "12", "detail on an era that has a funnel"),
        ("13", "U5", "an open item waved at a shipped era"),
        ("14, a key twice in a row", "U2", "a duplicate key"),
        ("14, a key twice in the arc", "0", "a property given twice"),
    ];

    public static bool IsStrictTag(string tag) => StrictTag.IsMatch(tag);

    public static bool IsString(JsonNode? n) => n is not null && n.GetValueKind() == JsonValueKind.String;

    static JsonObject Obj(JsonNode? n) => n as JsonObject ?? new JsonObject();

    public static List<JsonObject> EraList(JsonObject d) => d["eras"] is JsonArray a ? a.Select(Obj).ToList() : [];

    public static List<JsonObject> Funnel(JsonObject e) => e["funnel"] is JsonArray a ? a.Select(Obj).ToList() : [];

    public static string? State(JsonObject o) => IsString(o["state"]) ? o["state"]!.GetValue<string>() : null;

    public static string Text(JsonObject o, string key)
    {
        if (!o.TryGetPropertyValue(key, out var v)) return "undefined";
        if (v is null) return "null";
        return v.GetValueKind() == JsonValueKind.String ? v.GetValue<string>() : v.ToJsonString(Quote);
    }

    static string Js(JsonObject o, string key) => !o.TryGetPropertyValue(key, out var v) ? "undefined" : v is null ? "null" : v.ToJsonString(Quote);

    static string Js(JsonNode? n) => n is null ? "null" : n.ToJsonString(Quote);

    static bool Truthy(JsonObject o, string key)
    {
        if (!o.TryGetPropertyValue(key, out var v) || v is null) return false;
        return v.GetValueKind() switch
        {
            JsonValueKind.String => v.GetValue<string>().Length > 0,
            JsonValueKind.False => false,
            JsonValueKind.Number => v.GetValue<double>() != 0,
            _ => true,
        };
    }

    static List<JsonNode?> ShipsNodes(JsonObject e)
    {
        if (!e.TryGetPropertyValue("ships", out var s) || s is null) return [];
        return s is JsonArray a ? a.ToList() : [s];
    }

    public static List<string> Ships(JsonObject e) => ShipsNodes(e).Select(n => n is null ? "null" : n.GetValueKind() == JsonValueKind.String ? n.GetValue<string>() : n.ToJsonString(Quote)).ToList();

    public static IEnumerable<string> Shape(JsonObject d)
    {
        if (d["eras"] is not JsonArray eras)
        {
            yield return "eras is not an array";
            yield break;
        }
        if (d["buckets"] is not JsonArray) yield return "buckets is not an array";
        if (!IsString(d["updated"])) yield return "updated is not a string";
        if (d.ContainsKey("note") && !IsString(d["note"])) yield return "note is present but not a string";
        for (var i = 0; i < eras.Count; i++)
        {
            var e = Obj(eras[i]);
            var at = $"eras[{i}] ({Text(e, "key")})";
            if (!IsString(e["key"])) yield return $"{at}: key is not a string";
            if (!IsString(e["title"])) yield return $"{at}: title is not a string";
            if (State(e) is not { } state || !EraStates.Contains(state)) yield return $"{at}: state {Js(e, "state")} is not shipped|current|planned";
            if (e.ContainsKey("funnel") && e["funnel"] is not JsonArray)
            {
                yield return $"{at}: funnel is not an array";
                continue;
            }
            var seen = new HashSet<string>(StringComparer.Ordinal);
            var funnel = Funnel(e);
            for (var j = 0; j < funnel.Count; j++)
            {
                var n = funnel[j];
                var nat = $"{Text(e, "key")}.funnel[{j}] ({Text(n, "id")})";
                if (!IsString(n["id"])) yield return $"{nat}: id is not a string";
                else if (!seen.Add(n["id"]!.GetValue<string>())) yield return $"{nat}: duplicate node id within the funnel";
                if (!IsString(n["title"])) yield return $"{nat}: title is not a string";
                if (!IsString(n["desc"])) yield return $"{nat}: desc is not a string";
                if (State(n) is not { } nodeState || !NodeStates.Contains(nodeState)) yield return $"{nat}: state {Js(n, "state")} is not shipped|inflight|planned";
            }
        }
        if (d["buckets"] is JsonArray buckets)
        {
            var keys = new HashSet<string>(StringComparer.Ordinal);
            for (var i = 0; i < buckets.Count; i++)
            {
                var b = Obj(buckets[i]);
                var at = $"buckets[{i}] ({Text(b, "key")})";
                if (!IsString(b["key"])) yield return $"{at}: key is not a string";
                else if (!keys.Add(b["key"]!.GetValue<string>())) yield return $"{at}: key appears more than once";
                if (!IsString(b["title"])) yield return $"{at}: title is not a string";
                if (!IsString(b["meaning"])) yield return $"{at}: meaning is not a string";
            }
        }
    }

    static IEnumerable<string> R1(Ctx c)
    {
        var current = c.Eras.Where(e => State(e) == "current").ToList();
        if (current.Count == 0) yield return "no era is current — between waves, promote the next PLANNED era to current (it simply has no funnel yet)";
        else if (current.Count > 1) yield return $"{current.Count} eras are current: {string.Join(", ", current.Select(e => Text(e, "key")))}";
    }

    static IEnumerable<string> R2(Ctx c)
    {
        var seen = new HashSet<string>(StringComparer.Ordinal);
        foreach (var e in c.Eras)
        {
            var key = Text(e, "key");
            if (!KeyShape.IsMatch(key)) yield return $"era key {Js(e, "key")} is not ^[a-z0-9]+$";
            if (!seen.Add(key)) yield return $"era key {Js(e, "key")} appears more than once";
        }
    }

    static int Rank(JsonObject e) => State(e) switch { "shipped" => 0, "current" => 1, "planned" => 2, _ => -1 };

    static IEnumerable<string> R3(Ctx c)
    {
        for (var i = 1; i < c.Eras.Count; i++)
        {
            var a = c.Eras[i - 1];
            var b = c.Eras[i];
            if (Rank(b) >= 0 && Rank(a) >= 0 && Rank(b) < Rank(a))
                yield return $"era {Text(b, "key")} ({Text(b, "state")}) sits after {Text(a, "key")} ({Text(a, "state")}) — the array runs shipped, then the one current, then planned";
        }
    }

    static IEnumerable<string> R4(Ctx c)
    {
        foreach (var e in c.Eras)
        {
            var inflight = Funnel(e).Count(n => State(n) == "inflight");
            if (inflight > 1) yield return $"era {Text(e, "key")}: {inflight} nodes are in flight; at most one may be";
        }
    }

    static IEnumerable<string> R5(Ctx c)
    {
        foreach (var e in c.Eras.Where(e => State(e) == "shipped"))
            foreach (var n in Funnel(e).Where(n => State(n) == "inflight"))
                yield return $"era {Text(e, "key")} is shipped but node {Text(n, "id")} is still in flight";
    }

    static IEnumerable<string> R6(Ctx c)
    {
        foreach (var e in c.Eras.Where(e => State(e) == "planned"))
            foreach (var n in Funnel(e).Where(n => State(n) != "planned"))
                yield return $"era {Text(e, "key")} is planned but node {Text(n, "id")} is {Text(n, "state")}";
    }

    static IEnumerable<string> R8(Ctx c)
    {
        foreach (var e in c.Eras)
            foreach (var n in Funnel(e))
            {
                if (State(n) == "shipped" && !DateShape.IsMatch(Text(n, "date")))
                    yield return $"{Text(e, "key")}.{Text(n, "id")} is shipped but its date is {Js(n, "date")}; want YYYY-MM-DD";
                if (State(n) != "shipped" && n.ContainsKey("date"))
                    yield return $"{Text(e, "key")}.{Text(n, "id")} is {Text(n, "state")} but carries a date";
            }
    }

    static IEnumerable<string> R9(Ctx c)
    {
        var claimed = new HashSet<string>(c.Eras.SelectMany(Ships), StringComparer.Ordinal);
        foreach (var tag in c.Tags.Where(tag => !claimed.Contains(tag)))
            yield return $"tag {tag} is cut in core/ but no era claims it in ships";
    }

    static IEnumerable<string> R10(Ctx c)
    {
        foreach (var e in c.Eras)
        {
            if (e.ContainsKey("id")) yield return $"era {Text(e, "key")} carries an id field — position is array order, never data";
            foreach (var s in ShipsNodes(e))
            {
                var text = s is null ? "null" : s.GetValueKind() == JsonValueKind.String ? s.GetValue<string>() : s.ToJsonString(Quote);
                if (!StrictTag.IsMatch(text)) yield return $"era {Text(e, "key")}: ships entry {Js(s)} is not vN.N.N";
            }
        }
    }

    static IEnumerable<string> R11(Ctx c)
    {
        var tagged = new HashSet<string>(c.Tags, StringComparer.Ordinal);
        foreach (var e in c.Eras)
        {
            var ships = Ships(e);
            if (ships.Count == 0) continue;
            var allTagged = ships.All(tagged.Contains);
            if (allTagged && State(e) != "shipped")
                yield return $"era {Text(e, "key")} ships {string.Join(", ", ships)} — all cut in core/ - but is {Text(e, "state")}; flip it to shipped";
            if (!allTagged && State(e) == "shipped")
                yield return $"era {Text(e, "key")} is shipped but {string.Join(", ", ships.Where(s => !tagged.Contains(s)))} was never tagged in core/";
        }
    }

    static IEnumerable<string> R12(Ctx c)
    {
        foreach (var e in c.Eras.Where(e => Funnel(e).Count > 0 && Truthy(e, "detail")))
            yield return $"era {Text(e, "key")} has a funnel and a detail — the detail renders nowhere; fold it into note or delete it";
    }

    static readonly (string Number, Func<Ctx, IEnumerable<string>> Rule)[] Table =
    [
        ("1", R1), ("2", R2), ("3", R3), ("4", R4), ("5", R5), ("6", R6), ("8", R8), ("9'", R9), ("10", R10), ("11", R11), ("12", R12),
    ];

    public static List<Problem> Run(JsonObject data, (bool Skipped, string Reason, List<string> List) tags)
    {
        var problems = Shape(data).Select(m => new Problem("0", "arc.js", m)).ToList();
        if (problems.Count > 0) return problems;
        var ctx = new Ctx(EraList(data), tags.List);
        foreach (var (number, rule) in Table)
        {
            if (tags.Skipped && (number == "9'" || number == "11")) continue;
            problems.AddRange(rule(ctx).Select(m => new Problem(number, "arc.js", m)));
        }
        return problems;
    }
}

public static class GitRun
{
    public static (string? Output, string Failure) Run(string program, string dir, string? stdin, params string[] args) =>
        RunWithin(30000, program, dir, stdin, args);

    public static (string? Output, string Failure) RunWithin(int timeoutMs, string program, string dir, string? stdin, params string[] args)
    {
        if (!Directory.Exists(dir)) return (null, $"the folder {dir} does not exist");
        var psi = new ProcessStartInfo(program) { RedirectStandardOutput = true, RedirectStandardError = true, RedirectStandardInput = stdin is not null, UseShellExecute = false, CreateNoWindow = true };
        psi.ArgumentList.Add("-C");
        psi.ArgumentList.Add(dir);
        foreach (var a in args) psi.ArgumentList.Add(a);
        Process? started;
        try
        {
            started = Process.Start(psi);
        }
        catch (Exception e) when (e is System.ComponentModel.Win32Exception or InvalidOperationException)
        {
            return (null, $"{program} could not start ({e.Message})");
        }
        if (started is null) return (null, $"{program} could not start");
        using var p = started;
        try
        {
            if (stdin is not null)
            {
                p.StandardInput.Write(stdin);
                p.StandardInput.Close();
            }
            var error = p.StandardError.ReadToEndAsync();
            var output = p.StandardOutput.ReadToEndAsync();
            if (!p.WaitForExit(timeoutMs))
            {
                p.Kill(true);
                return (null, $"{program} ran past {timeoutMs / 1000.0:0.#} seconds in {dir}");
            }
            var firstError = error.Result.Split('\n').Select(l => l.Trim()).FirstOrDefault(l => l.Length > 0) ?? "no message";
            return p.ExitCode == 0 ? (output.Result.Trim(), "") : (null, $"{program} exited {p.ExitCode} in {dir}: {firstError}");
        }
        catch (IOException e)
        {
            return (null, $"{program} broke its pipes in {dir} ({e.Message})");
        }
    }

    public static string? Output(string dir, string? stdin, params string[] args) => Run("git", dir, stdin, args).Output;
}

public sealed class Item
{
    public string Name = "";
    public string Body = "";
    public bool HeaderIntact = true;
    public readonly Dictionary<string, string> Keys = new(StringComparer.Ordinal);

    public string? this[string key] => Keys.TryGetValue(key, out var v) ? v : null;
    public string Id => this["id"] ?? Name;
    public bool IsOpen => this["state"] == "open";
    public IEnumerable<string> Tags => (this["tags"] ?? "").Split(", ", StringSplitOptions.RemoveEmptyEntries);

    public Item Clone()
    {
        var c = new Item { Name = Name, Body = Body, HeaderIntact = HeaderIntact };
        foreach (var kv in Keys) c.Keys[kv.Key] = kv.Value;
        return c;
    }
}

public static class StoreRoot
{
    public const string NotFound = "no tracker store above {0}: a store is a folder holding tasks\\ and arc.js";

    public static string? Locate(string start)
    {
        var dir = new DirectoryInfo(Path.GetFullPath(start));
        while (dir is not null)
        {
            if (Directory.Exists(Path.Combine(dir.FullName, "tasks")) && File.Exists(Path.Combine(dir.FullName, "arc.js")))
                return dir.FullName;
            dir = dir.Parent;
        }
        return null;
    }
}

public sealed record Problem(string Rule, string Where, string Message)
{
    public override string ToString() => $"rule {Rule}: {Where}: {Message}";
}

public sealed record ListFilter(string? Priority, string? Bucket, string? Wave, string? Owner, string? Tag, string State);

public static class Queries
{
    public const int RuleCount = 24;

    static StringWriter Text() => new() { NewLine = "\n" };

    static int Rank(Item i) => i["priority"] switch { "now" => 0, "next" => 1, "later" => 2, _ => 3 };

    public static string List(string root, ListFilter f)
    {
        var o = Text();
        var (items, problems) = Store.Load(root);
        IEnumerable<Item> rows = items;
        if (f.State == "open") rows = rows.Where(i => i.IsOpen);
        if (f.State == "done") rows = rows.Where(i => i["state"] == "done");
        if (f.Priority is { } priority) rows = rows.Where(i => i["priority"] == priority);
        if (f.Bucket is { } bucket) rows = rows.Where(i => i["bucket"] == bucket);
        if (f.Wave is { } wave) rows = rows.Where(i => i["wave"] is { } w && w.TrimEnd('+') == wave.TrimEnd('+'));
        if (f.Owner is { } owner) rows = rows.Where(i => (i["owner"] ?? "").Split('+').Contains(owner));
        if (f.Tag is { } tag) rows = rows.Where(i => i.Tags.Contains(tag));

        var sep = " " + Tracker.Dot + " ";
        foreach (var i in rows.OrderBy(Rank).ThenBy(i => i["created"] ?? "", StringComparer.Ordinal).ThenBy(i => i.Id, StringComparer.Ordinal))
            o.WriteLine(string.Join(sep, i.Id, i["priority"] ?? "-", i["size"] ?? "-", i["owner"] ?? "-", i["bucket"] ?? "-", i["wave"] ?? "-", i["title"] ?? "-"));
        if (problems.Count > 0) o.WriteLine($"the store holds {problems.Count} violation(s), so run check");
        return o.ToString();
    }

    public static string Mine(string root, string seat) => List(root, new ListFilter(null, null, null, seat, null, "open"));

    public static string SeatList(string root)
    {
        var o = Text();
        var (seats, present) = Seats.Load(root);
        if (!present) { o.WriteLine("no seats.md; seat add <name> \"<line>\" declares the first seat"); return o.ToString(); }
        var (items, _) = Store.Load(root);
        foreach (var s in seats)
            o.WriteLine($"{items.Count(i => i.IsOpen && (i["owner"] ?? "").Split('+').Contains(s.Name)),4} {s.Name}: {s.Line}");
        return o.ToString();
    }

    public static (string Text, int Code) Show(string root, string id)
    {
        var o = Text();
        var (path, item, problems) = Store.Find(root, id);
        if (path is null || item is null || problems.Count > 0)
        {
            foreach (var p in problems) o.WriteLine("refused: " + p);
            return (o.ToString(), 1);
        }
        return (Writer.Text(item), 0);
    }

    public static string Tags(string root)
    {
        var o = Text();
        var (items, _) = Store.Load(root);
        foreach (var g in items.SelectMany(i => i.Tags).GroupBy(t => t, StringComparer.Ordinal).OrderByDescending(g => g.Count()).ThenBy(g => g.Key, StringComparer.Ordinal))
            o.WriteLine($"{g.Count(),4} {g.Key}");
        return o.ToString();
    }

    public static string Position(string root)
    {
        var o = Text();
        var (items, problems) = Store.Load(root);
        var arc = Arc.Load(root);
        var sep = " " + Tracker.Dot + " ";
        var current = arc.Data is null ? null : ArcRules.EraList(arc.Data).FirstOrDefault(e => ArcRules.State(e) == "current");
        if (current is null) o.WriteLine("no current era: arc.js is unreadable or names none, so run check");
        else
        {
            o.WriteLine(string.Join(sep, "era " + ArcRules.Text(current, "key"), ArcRules.Text(current, "title"), "ships " + string.Join(", ", ArcRules.Ships(current))));
            foreach (var n in ArcRules.Funnel(current))
                o.WriteLine("  " + string.Join(sep, ArcRules.Text(n, "id"), ArcRules.Text(n, "state"), ArcRules.Text(n, "title")));
        }
        var open = items.Where(i => i.IsOpen).ToList();
        var listed = open.Where(i => i["priority"] is "now" or "next").OrderBy(Rank).ThenBy(i => i["created"] ?? "", StringComparer.Ordinal).ThenBy(i => i.Id, StringComparer.Ordinal).ToList();
        o.WriteLine($"open: {open.Count} items, {listed.Count} at now or next, {open.Count - listed.Count} later");
        foreach (var i in listed)
            o.WriteLine("  " + string.Join(sep, i.Id, i["priority"], i["owner"] ?? "-", i["wave"] ?? "-", i["title"] ?? "-"));
        if (problems.Count > 0) o.WriteLine($"the store holds {problems.Count} violation(s), so run check");
        return o.ToString();
    }

    public static (string Text, int Code) Check(string root, (bool Skipped, string Reason, List<string> List) tags, IEnumerable<Problem> extra)
    {
        var o = Text();
        var (items, read) = Store.Load(root);
        var arc = Arc.Load(root);
        if (tags.Skipped) o.WriteLine($"rule 9' + 11 SKIPPED — {tags.Reason}; release coverage unverified");
        var problems = arc.Data is null ? new List<Problem>() : ArcRules.Run(arc.Data, tags);
        problems.AddRange(Rules.Check(items, read, Store.Scratchpad(root), arc.Data, Seats.Load(root)));
        problems.AddRange(Seats.Lines(root));
        problems.AddRange(extra);
        problems.AddRange(Rules.Assets(root, items));
        foreach (var p in problems) o.WriteLine(p);
        var head = $"tracker: {items.Count} items, {ArcRules.EraList(arc.Data ?? new JsonObject()).Count} eras, {RuleCount} rules";
        if (problems.Count > 0)
        {
            o.WriteLine($"{head}, {problems.Count} violation(s), exit 1");
            return (o.ToString(), 1);
        }
        if (tags.Skipped)
        {
            o.WriteLine($"{head}, 0 violations, but the tag rules did not run, exit 2");
            return (o.ToString(), 2);
        }
        o.WriteLine($"{head}, 0 violations");
        return (o.ToString(), 0);
    }
}

public static class Rules
{
    public static List<Problem> Values(Item it, ISet<string> buckets)
    {
        var p = new List<Problem>();
        var f = it.Name;
        var id = it["id"];
        if (id is null) p.Add(new Problem("U1", f, "the header holds no id"));
        else if (f != id + ".md") p.Add(new Problem("U1", f, $"the file name is not its id {id}"));

        foreach (var key in Tracker.Required)
            if (key != "id" && it[key] is null) p.Add(new Problem("U2", f, $"the header has no {key}"));
        foreach (var (key, v) in it.Keys)
        {
            if (v.Length == 0) p.Add(new Problem("U2", f, $"{key} has an empty value"));
            else if (v != v.Trim()) p.Add(new Problem("U2", f, $"{key} has a space at an edge"));
            if (v.Contains('\n') || v.Contains(Tracker.CR)) p.Add(new Problem("U2", f, $"{key} holds a line break"));
        }
        if (id is not null && !Shapes.Id.IsMatch(id)) p.Add(new Problem("U2", f, $"id '{id}' is not U- and four digits"));
        Enum(p, it, "state", Tracker.States);
        Enum(p, it, "priority", Tracker.Priorities);
        Enum(p, it, "size", Tracker.Sizes);
        if (it["owner"] is { } owner && !Shapes.Owner.IsMatch(owner)) p.Add(new Problem("U2", f, $"owner '{owner}' is not ? or lowercase names joined with +"));
        if (it["bucket"] is { } bucket && !buckets.Contains(bucket)) p.Add(new Problem("U2", f, $"bucket '{bucket}' is not one of {string.Join(", ", buckets.Order(StringComparer.Ordinal))}"));
        if (it["wave"] is { } wave && !Shapes.Wave.IsMatch(wave)) p.Add(new Problem("U2", f, $"wave '{wave}' is not an era key with an optional +"));
        if (it["tags"] is { } tags)
        {
            var parts = tags.Split(", ");
            foreach (var t in parts)
                if (!Shapes.Tag.IsMatch(t)) p.Add(new Problem("U2", f, $"tag '{t}' is not lowercase letters, digits and hyphens"));
            if (parts.Distinct(StringComparer.Ordinal).Count() != parts.Length) p.Add(new Problem("U2", f, "a tag appears twice"));
        }
        foreach (var key in new[] { "created", "done" })
            if (it[key] is { } date && !Tracker.IsDate(date)) p.Add(new Problem("U2", f, $"{key} '{date}' is not a YYYY-MM-DD date"));
        return p;
    }

    static void Enum(List<Problem> p, Item it, string key, string[] allowed)
    {
        if (it[key] is { } v && Array.IndexOf(allowed, v) < 0) p.Add(new Problem("U2", it.Name, $"{key} '{v}' is not one of {string.Join(", ", allowed)}"));
    }

    public static IEnumerable<Problem> State(Item it)
    {
        if (it["state"] == "open")
        {
            if (it["priority"] is null) yield return new Problem("U3", it.Name, "an open item has no priority");
            if (it["bucket"] is null) yield return new Problem("U3", it.Name, "an open item has no bucket");
            if (it["done"] is not null) yield return new Problem("U3", it.Name, "an open item carries a done date");
        }
        else if (it["state"] == "done")
        {
            if (it["done"] is null) yield return new Problem("U3", it.Name, "a done item has no done date");
            if (it["priority"] is not null) yield return new Problem("U3", it.Name, "a done item carries a priority");
        }
    }

    public static IEnumerable<Problem> AcrossItems(List<Item> items, string? scratchpad)
    {
        foreach (var g in items.Where(i => i["id"] is not null).GroupBy(i => i["id"]!, StringComparer.Ordinal).Where(g => g.Count() > 1))
            yield return new Problem("U1", g.Key, "the id is held by " + string.Join(", ", g.Select(i => i.Name)));

        var now = items.Where(i => i.IsOpen && i["priority"] == "now").ToList();
        if (now.Count > Tracker.NowCap)
            yield return new Problem("U6", "tasks/", $"{now.Count} open items hold priority now, and the cap is {Tracker.NowCap}");

        foreach (var i in now.Where(i => i["owner"] == "?"))
            yield return new Problem("U7", i.Name, "a now item has owner ?");
        foreach (var i in now.Where(i => i["size"] == "?"))
            yield return new Problem("U7", i.Name, "a now item has size ?");

        foreach (var i in items.Where(i => i.IsOpen && i["priority"] == "next" && i["wave"] is null && i["bucket"] != "ready"))
            yield return new Problem("U8", i.Name, "a next item has no wave and is not in ready");

        if (scratchpad is not null)
        {
            var ids = new HashSet<string>(items.Select(i => i["id"]).OfType<string>(), StringComparer.Ordinal);
            foreach (var m in Shapes.Mention.Matches(scratchpad).Select(m => m.Value).Distinct(StringComparer.Ordinal))
                if (!ids.Contains(m)) yield return new Problem("U9", "scratchpad.md", $"it names {m}, and no item holds that id");
        }
    }

    public static List<Problem> Check(List<Item> items, List<Problem> read, string? scratchpad, JsonObject? arc, (List<Seat> List, bool Present) seats) =>
        read.Concat(items.SelectMany(State)).Concat(AcrossItems(items, scratchpad)).Concat(Eras(items, arc)).Concat(items.SelectMany(i => Seats.Check(i, seats.List, seats.Present))).ToList();

    public static IEnumerable<Problem> Assets(string root, List<Item> items)
    {
        var dir = Path.Combine(Store.TasksDir(root), "assets");
        foreach (var it in items)
        {
            var own = Path.Combine(dir, it.Id);
            var files = Directory.Exists(own) ? Directory.EnumerateFiles(own).Select(f => Path.GetFileName(f)).ToHashSet(StringComparer.Ordinal) : new HashSet<string>(StringComparer.Ordinal);
            var counts = new Dictionary<string, int>(StringComparer.Ordinal);
            foreach (var (id, file) in Shapes.Embeds(it.Body))
            {
                if (id == it.Id && files.Contains(file)) counts[file] = counts.GetValueOrDefault(file) + 1;
                else yield return new Problem("U11", it.Name, $"it embeds assets/{id}/{file}, and assets/{it.Id}/ holds no such file");
            }
            foreach (var file in files.Order(StringComparer.Ordinal))
                if (counts.GetValueOrDefault(file) != 1) yield return new Problem("U11", it.Name, $"its body embeds assets/{it.Id}/{file} {counts.GetValueOrDefault(file)} times, and it must embed the file once");
        }
        if (!Directory.Exists(dir)) yield break;
        var ids = new HashSet<string>(items.Select(i => i.Id), StringComparer.Ordinal);
        foreach (var folder in Directory.EnumerateDirectories(dir).Select(p => Path.GetFileName(p)).Order(StringComparer.Ordinal))
            if (!ids.Contains(folder)) yield return new Problem("U11", "tasks/assets/" + folder, "the folder names no item");
        foreach (var loose in Directory.EnumerateFiles(dir).Select(p => Path.GetFileName(p)).Order(StringComparer.Ordinal))
            yield return new Problem("U11", "tasks/assets/" + loose, "a file sits outside every item's folder, so no body can embed it");
    }

    public static IEnumerable<Problem> Eras(List<Item> items, JsonObject? arc)
    {
        if (arc is null || ArcRules.Shape(arc).Any()) yield break;
        var eras = ArcRules.EraList(arc);
        var keys = new HashSet<string>(eras.Select(e => ArcRules.Text(e, "key")), StringComparer.Ordinal);
        var shipped = new HashSet<string>(eras.Where(e => ArcRules.State(e) == "shipped").Select(e => ArcRules.Text(e, "key")), StringComparer.Ordinal);
        foreach (var i in items)
        {
            if (i["wave"] is not { } wave) continue;
            var key = wave.TrimEnd('+');
            if (!keys.Contains(key)) yield return new Problem("U4", i.Name, $"wave '{wave}' matches no era key");
            else if (i.IsOpen && shipped.Contains(key))
                yield return new Problem("U5", i.Name, wave.EndsWith('+')
                    ? $"waved '{wave}', but {key} has SHIPPED - 'or later' now means any time, which is not an allocation"
                    : $"waved at '{wave}', which has SHIPPED - stamp it done or re-point it");
        }
    }
}

public sealed record Seat(string Name, string Line);

public static class Seats
{
    public const string FileName = "seats.md";
    static readonly Regex NameShape = new("^[a-z0-9][a-z0-9.-]*$");

    public static (List<Seat> List, bool Present) Load(string root)
    {
        var path = Path.Combine(root, FileName);
        if (!File.Exists(path)) return ([], false);
        var list = new List<Seat>();
        foreach (var raw in File.ReadAllLines(path, Tracker.Utf8))
        {
            var line = raw.TrimEnd((char)13);
            var colon = line.IndexOf(": ", StringComparison.Ordinal);
            if (line.Length == 0 || colon < 1) continue;
            list.Add(new Seat(line[..colon], line[(colon + 2)..]));
        }
        return (list, true);
    }

    public static List<Problem> Lines(string root)
    {
        var path = Path.Combine(root, FileName);
        if (!File.Exists(path)) return [];
        var found = new List<Problem>();
        var lines = File.ReadAllLines(path, Tracker.Utf8);
        for (var i = 0; i < lines.Length; i++)
        {
            var line = lines[i].TrimEnd((char)13);
            if (line.Length == 0) continue;
            var colon = line.IndexOf(": ", StringComparison.Ordinal);
            if (colon < 1 || !ValidName(line[..colon]))
                found.Add(new Problem("U24", FileName, $"line {i + 1} is not a lowercase seat name, a colon and one line saying what the seat does"));
        }
        return found;
    }

    public static void Save(string root, List<Seat> list) =>
        File.WriteAllBytes(Path.Combine(root, FileName), Tracker.Utf8.GetBytes(string.Concat(list.Select(s => s.Name + ": " + s.Line + "\n"))));

    public static bool ValidName(string name) => NameShape.IsMatch(name);

    public static IEnumerable<Problem> Check(Item it, List<Seat> seats, bool present)
    {
        if (!it.IsOpen || it["owner"] is not { } owner) yield break;
        foreach (var part in owner.Split('+'))
        {
            if (part == "?" || seats.Any(s => s.Name == part)) continue;
            yield return new Problem("U24", it.Name, present
                ? $"owner '{part}' is not a declared seat"
                : $"no seats.md declares seats, so owner '{part}' cannot be checked");
        }
    }
}

public static class Shapes
{
    public static readonly Regex Id = new("^U-[0-9]{4}$");
    public static readonly Regex FileName = new("^U-([0-9]{4})[.]md$");
    public static readonly Regex Owner = new("^([?]|[a-z0-9][a-z0-9.-]*([+][a-z0-9][a-z0-9.-]*)*)$");
    public static readonly Regex Wave = new("^[a-z0-9]+[+]?$");
    public static readonly Regex Tag = new("^[a-z0-9-]+$");
    public static readonly Regex Mention = new("U-[0-9]{4}");

    public static readonly Regex Embed = new("^[!][[][^]]*[]][(]assets/(U-[0-9]{4})/([^/() ]+)[)]$");

    public static IEnumerable<(string Id, string File)> Embeds(string body) =>
        body.Split((char)10).Select(line => Embed.Match(line)).Where(m => m.Success).Select(m => (m.Groups[1].Value, m.Groups[2].Value));

    public static string DropEmbeds(string note)
    {
        var nl = ((char)10).ToString();
        var lines = note.Split((char)10);
        if (!lines.Any(line => Embed.IsMatch(line))) return note;
        var kept = string.Join(nl, lines.Where(line => !Embed.IsMatch(line)));
        while (kept.Contains(nl + nl + nl, StringComparison.Ordinal)) kept = kept.Replace(nl + nl + nl, nl + nl, StringComparison.Ordinal);
        return kept.Trim((char)10);
    }
}

public static class Store
{
    public static string TasksDir(string root) => Path.Combine(root, "tasks");

    public static string? Scratchpad(string root)
    {
        var path = Path.Combine(root, "scratchpad.md");
        return File.Exists(path) ? File.ReadAllText(path) : null;
    }

    public static HashSet<string> Buckets(string root, List<Problem> problems)
    {
        var five = new HashSet<string>(Tracker.OpenBuckets, StringComparer.Ordinal);
        var arc = Arc.Load(root);
        if (arc.Data is null)
        {
            problems.AddRange(arc.Problems);
            return five;
        }
        if (arc.Data["buckets"] is not JsonArray list) return five;
        var found = new HashSet<string>(StringComparer.Ordinal);
        foreach (var b in list)
            if (b is JsonObject o && ArcRules.IsString(o["key"]) && o["key"]!.GetValue<string>() is var key && key != "closed") found.Add(key);
        return found;
    }

    public static (List<Item> Items, List<Problem> Problems) Load(string root)
    {
        var problems = new List<Problem>();
        var buckets = Buckets(root, problems);
        var items = new List<Item>();
        var dir = TasksDir(root);
        if (!Directory.Exists(dir)) return (items, problems);
        foreach (var path in Directory.EnumerateFiles(dir, "*.md").OrderBy(p => p, StringComparer.Ordinal))
        {
            var (item, found) = Parse(Path.GetFileName(path), File.ReadAllBytes(path), buckets);
            items.Add(item);
            problems.AddRange(found);
        }
        return (items, problems);
    }

    public static (Item Item, List<Problem> Problems) Parse(string name, byte[] bytes, ISet<string> buckets)
    {
        var item = new Item { Name = name };
        var problems = new List<Problem>();
        string text;
        try
        {
            var start = bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF ? 3 : 0;
            text = Tracker.Utf8.GetString(bytes, start, bytes.Length - start).Replace(Tracker.CR + "\n", "\n");
        }
        catch (DecoderFallbackException)
        {
            item.HeaderIntact = false;
            problems.Add(new Problem("U2", name, "the file is not valid UTF-8"));
            return (item, problems);
        }
        var lines = text.Split('\n');
        var close = lines[0] == "---" ? Array.IndexOf(lines, "---", 1) : -1;
        if (close < 0)
        {
            item.HeaderIntact = false;
            problems.Add(new Problem("U2", name, lines[0] == "---" ? "the header has no closing --- line" : "the file does not open with a --- line"));
            return (item, problems);
        }
        for (var i = 1; i < close; i++)
        {
            var at = lines[i].IndexOf(": ", StringComparison.Ordinal);
            if (at <= 0)
            {
                item.HeaderIntact = false;
                problems.Add(new Problem("U2", name, $"line {i + 1} is not key: value"));
                continue;
            }
            var key = lines[i][..at];
            if (Array.IndexOf(Tracker.KeyOrder, key) < 0)
            {
                item.HeaderIntact = false;
                problems.Add(new Problem("U2", name, $"unknown key '{key}'"));
                continue;
            }
            if (!item.Keys.TryAdd(key, lines[i][(at + 2)..]))
            {
                item.HeaderIntact = false;
                problems.Add(new Problem("U2", name, $"duplicate key '{key}'"));
            }
        }
        var body = string.Join('\n', lines[(close + 1)..]);
        item.Body = body.StartsWith('\n') ? body[1..] : body;
        problems.AddRange(Rules.Values(item, buckets));
        return (item, problems);
    }

    public static (string? Path, Item? Item, List<Problem> Problems) Find(string root, string id)
    {
        var none = new List<Problem>();
        if (!Shapes.Id.IsMatch(id)) return (null, null, [new Problem("U2", id, "it is not U- and four digits")]);
        var path = Path.Combine(TasksDir(root), id + ".md");
        if (!File.Exists(path)) return (null, null, [new Problem("U1", id, $"no file tasks/{id}.md")]);
        var (item, problems) = Parse(id + ".md", File.ReadAllBytes(path), Buckets(root, none));
        return (path, item, none.Concat(problems).ToList());
    }

    static (List<string>? Names, string Failure) HistoricalNames(string tasksDir, string git)
    {
        var (output, failure) = GitRun.Run(git, tasksDir, null, "log", "--all", "--format=", "--name-only", "--", ".");
        if (output is not null) return (output.Split('\n').Select(line => Path.GetFileName(line.Trim())).ToList(), "");
        return NoHistory(failure) ? ([], "") : (null, failure);
    }

    internal static bool NoHistory(string failure) =>
        failure.Contains(" could not start", StringComparison.Ordinal) || failure.Contains("not a git repository", StringComparison.OrdinalIgnoreCase);

    public static (string? Id, string? Error) Create(string tasksDir, Item item, Action<string>? beforeCreate, string git = "git")
    {
        Directory.CreateDirectory(tasksDir);
        var (history, failure) = HistoricalNames(tasksDir, git);
        if (history is null) return (null, "git could not read the ids already recorded under tasks, so the next id is unknown: " + failure);
        for (var attempt = 0; attempt < 2; attempt++)
        {
            var number = Directory.EnumerateFiles(tasksDir, "*.md").Select(Path.GetFileName).Concat(history)
                .Select(name => Shapes.FileName.Match(name ?? ""))
                .Where(m => m.Success)
                .Select(m => int.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture))
                .DefaultIfEmpty(0).Max() + 1;
            if (number > 9999) return (null, "every id up to U-9999 is taken");
            var id = "U-" + number.ToString("D4", CultureInfo.InvariantCulture);
            var next = item.Clone();
            next.Keys["id"] = id;
            next.Name = id + ".md";
            var path = Path.Combine(tasksDir, next.Name);
            beforeCreate?.Invoke(path);
            try
            {
                using var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None);
                stream.Write(Writer.Bytes(next));
                return (id, null);
            }
            catch (IOException) when (File.Exists(path))
            {
            }
        }
        return (null, "another writer took the next id twice in a row, so run add again");
    }
}

public static class Tracker
{
    public const int NowCap = 5;
    public const char CR = (char)13;
    public static readonly string[] KeyOrder = ["id", "title", "state", "priority", "size", "owner", "bucket", "wave", "trigger", "tags", "created", "done", "source"];
    public static readonly string[] Required = ["id", "title", "state", "size", "owner", "created"];
    public static readonly string[] OpenBuckets = ["ready", "word", "trigger", "plan", "design"];
    public static readonly string[] States = ["open", "done"];
    public static readonly string[] Priorities = ["now", "next", "later"];
    public static readonly string[] Sizes = ["XS", "S", "M", "L", "?"];
    public static readonly UTF8Encoding Utf8 = new(false, true);
    public static readonly string Dot = ((char)0xB7).ToString();

    public static string Today() => DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);

    public static bool IsDate(string s) => DateTime.TryParseExact(s, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out _);
}

public static class Writer
{
    public static string Text(Item it)
    {
        var sb = new StringBuilder("---\n");
        foreach (var key in Tracker.KeyOrder)
            if (it.Keys.TryGetValue(key, out var v)) sb.Append(key).Append(": ").Append(v).Append('\n');
        return sb.Append("---\n\n").Append(it.Body).ToString();
    }

    public static byte[] Bytes(Item it) => Tracker.Utf8.GetBytes(Text(it));
}

public static class Writes
{
    public static List<Problem> Validate(Item it, string root)
    {
        var arc = new List<Problem>();
        var buckets = Store.Buckets(root, arc);
        var (seats, present) = Seats.Load(root);
        return arc.Concat(Rules.Values(it, buckets)).Concat(Rules.State(it)).Concat(Seats.Check(it, seats, present)).ToList();
    }

    public static string Tags(string raw) => string.Join(", ", raw.Split(',').Select(t => t.Trim()));

    static (int Code, string Text) Refuse(IEnumerable<Problem> problems) => (1, string.Concat(problems.Select(p => "refused: " + p + "\n")));

    public static (int Code, string Text) Add(string root, Item item, string today)
    {
        item.Keys["created"] = today;
        var problems = Validate(item, root);
        if (problems.Count > 0) return Refuse(problems);
        var (id, error) = Store.Create(Store.TasksDir(root), item, null);
        return id is null ? (1, "refused: " + error + "\n") : (0, $"{id} tasks/{id}.md\n");
    }

    public static (int Code, string Text) Close(string root, string id, string note, string today)
    {
        var (path, item, problems) = Store.Find(root, id);
        if (path is null || item is null || problems.Count > 0) return Refuse(problems);
        if (!item.IsOpen) return (1, $"refused: {item.Id} is not open\n");
        var next = item.Clone();
        next.Keys["state"] = "done";
        next.Keys.Remove("priority");
        next.Keys["done"] = today;
        next.Body = note + "\n" + (item.Body.Length > 0 ? "\n" + item.Body : "");

        var found = Validate(next, root);
        if (note.Length == 0 || note.Contains('\n') || note.Contains(Tracker.CR)) found.Add(new Problem("U3", next.Name, "the closing text is one line that is not empty"));
        if (found.Count > 0) return Refuse(found);
        File.WriteAllBytes(path, Writer.Bytes(next));
        return (0, $"{next.Id} closed\n");
    }

    public static (int Code, string Text) Set(string root, string id, string key, string value)
    {
        if (Array.IndexOf(Tracker.KeyOrder, key) < 0) return (1, $"refused: '{key}' is not a header key\n");
        if (key == "id") return (1, "refused: an id never changes\n");
        var (path, item, problems) = Store.Find(root, id);
        if (path is null || item is null || !item.HeaderIntact) return Refuse(problems);

        var next = item.Clone();
        if (value.Length == 0) next.Keys.Remove(key);
        else next.Keys[key] = key == "tags" ? Tags(value) : value;
        var found = Validate(next, root);
        if (found.Count > 0) return Refuse(found);
        File.WriteAllBytes(path, Writer.Bytes(next));
        return (0, $"{next.Id} {key} {(value.Length == 0 ? "removed" : "set")}\n");
    }
}

string StoreDir() => StoreRoot.Locate(Gatto.Cwd) ?? "";
string Missing() => string.Format(StoreRoot.NotFound, Gatto.Cwd);
string SessionSeat()
{
    var s = Gatto.Config.Section("tasks");
    return s is { } el && el.ValueKind == JsonValueKind.Object && el.TryGetProperty("owner", out var v) && v.ValueKind == JsonValueKind.String ? v.GetString()! : "gatto";
}
var seat = SessionSeat();
var loadRoot = StoreDir();
var seatMissing = loadRoot.Length > 0 && !Seats.Load(loadRoot).List.Any(x => x.Name == seat)
    ? $"seat '{seat}' is not declared in seats.md at {loadRoot}; seat add <name> \"<line>\" declares it"
    : null;
if (seatMissing is not null) Gatto.Log("tasks: " + seatMissing);

string? Opt(JsonElement args, string k) => args.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;

ToolResult ReadView(JsonElement args)
{
    var root = StoreDir();
    if (root.Length == 0) return new ToolResult(Missing());
    return Opt(args, "view") switch
    {
        "list" => new ToolResult(Queries.List(root, new ListFilter(Opt(args, "priority"), Opt(args, "bucket"), null, Opt(args, "owner"), Opt(args, "tag"), Opt(args, "state") ?? "open"))),
        "show" => new ToolResult(Queries.Show(root, Opt(args, "id") ?? throw new InvalidOperationException("show needs id")).Text),
        "position" => new ToolResult(Queries.Position(root)),
        "mine" => new ToolResult(seatMissing ?? Queries.Mine(root, seat)),
        "seats" => new ToolResult(Queries.SeatList(root)),
        "check" => new ToolResult(Queries.Check(root, (true, "no git reader in a script", []), []).Text),
        _ => throw new InvalidOperationException("view must be one of list, show, position, mine, seats, check"),
    };
}

ToolResult WriteAction(JsonElement args)
{
    var root = StoreDir();
    if (root.Length == 0) return new ToolResult(Missing());
    var action = Opt(args, "action");
    string Need(string k) => Opt(args, k) ?? throw new InvalidOperationException($"{action} needs {k}");
    (int Code, string Text) r;
    switch (action)
    {
        case "add":
            var item = new Item { Name = "U-0000.md" };
            item.Keys["id"] = "U-0000";
            item.Keys["title"] = Need("title");
            item.Keys["state"] = "open";
            foreach (var k in new[] { "priority", "size", "owner", "bucket" }) item.Keys[k] = Need(k);
            foreach (var k in new[] { "wave", "trigger", "source" }) if (Opt(args, k) is { } v) item.Keys[k] = v;
            if (Opt(args, "tags") is { } tags) item.Keys["tags"] = Writes.Tags(tags);
            r = Writes.Add(root, item, Tracker.Today());
            break;
        case "set": r = Writes.Set(root, Need("id"), Need("key"), Opt(args, "value") ?? ""); break;
        case "close": r = Writes.Close(root, Need("id"), Need("note"), Tracker.Today()); break;
        default: throw new InvalidOperationException("action must be one of add, set, close");
    }
    if (r.Code != 0) throw new InvalidOperationException(r.Text.TrimEnd());
    return new ToolResult(r.Text.TrimEnd());
}

Gatto.Register(
    "task_read",
    "Read the project's tracker store. view: list (items; filter by priority, owner, tag, bucket, state open|done|all), show (one item by id), position (the current era and every now or next item), mine (open items owned by this session's seat), seats (declared seats with open counts), check (rule violations, as text).",
    """
    {"type":"object","properties":{"view":{"type":"string","enum":["list","show","position","mine","seats","check"]},"id":{"type":"string"},"priority":{"type":"string","enum":["now","next","later"]},"owner":{"type":"string"},"tag":{"type":"string"},"bucket":{"type":"string"},"state":{"type":"string","enum":["open","done","all"]}},"required":["view"]}
    """,
    (args, ctx, ct) => Task.FromResult(ReadView(args)),
    readClass: true);

Gatto.Register(
    "task_write",
    "Write to the project's tracker store through its rules; a refusal comes back as an error naming the rule. action: add (title, priority, size, owner, bucket; optional wave, trigger, tags, source; starts one git process to read the id history), set (id, key, value; empty value removes an optional key), close (id, note).",
    """
    {"type":"object","properties":{"action":{"type":"string","enum":["add","set","close"]},"id":{"type":"string"},"key":{"type":"string"},"value":{"type":"string"},"title":{"type":"string"},"priority":{"type":"string"},"size":{"type":"string"},"owner":{"type":"string"},"bucket":{"type":"string"},"wave":{"type":"string"},"trigger":{"type":"string"},"tags":{"type":"string"},"source":{"type":"string"},"note":{"type":"string"}},"required":["action"]}
    """,
    (args, ctx, ct) => Task.FromResult(WriteAction(args)));

read from gatto-extensions@c635505 · view on GitHub


Writing your own

The host API is small: one call registers a tool, and gatto handles the rest. Write an extension documents it with a worked example, and there is a plain-text version if you would rather hand the whole thing to gatto and ask it to build you something.


Other sources

gatto will read extension sources the way a package manager does. The shelf above is the source that ships turned on. You will be able to add someone else's repository yourself, and everything in it becomes installable, with the plain warning that nobody here has read it. Coming soon.

GGitHub XX BBluesky MMastodon RReddit Windows 10 / 11 GPL-3.0
(^· ·^)⟆   zzz

nothing here yet: a game goes in this space

Close gatto.computer
(^· ·^)⟆

Are you sure you want to close this window?

It's now safe to close
this tab.
…or come back.