diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..3b46ef4 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,16 @@ +{ + "name": "yourconscience", + "interface": { + "displayName": "dotagents" + }, + "plugins": [ + { + "name": "dotagents", + "source": { + "source": "local", + "path": "./plugins/dotagents" + }, + "category": "Productivity" + } + ] +} diff --git a/README.md b/README.md index f00806f..65695ac 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,18 @@ This repo is the canonical `~/.agents` layer. It detects installed agent platfor ## Install +Two ways to consume this repo - pick exactly one per machine and harness: + +| You want | Do this | +|---|---| +| Just the skills in Claude Code | `/plugin marketplace add yourconscience/dotagents` then `/plugin install dotagents@yourconscience` | +| Just the skills in Codex | `codex plugin marketplace add https://github.com/yourconscience/dotagents` then `codex plugin add dotagents@yourconscience` | +| The full managed setup (skills, roles, MCP, hooks) on any supported harness - Claude Code, Codex, Hermes, Factory Droid, Pi/OMP | Install the `dotagents` CLI (below), clone the repo, run `dotagents setup` | + +Plugins snapshot the repo at install time and update through each harness's plugin update flow. `dotagents sync` keeps live symlinks instead. Do not combine both on the same harness or skills appear twice; see "Installing this repo as a plugin" for details and how `delivery:` arbitrates this for Claude Code. + +### CLI install + Prebuilt binaries for macOS and Linux (amd64/arm64) are attached to [GitHub Releases](https://github.com/yourconscience/dotagents/releases). With Go installed: ```bash @@ -28,9 +40,10 @@ go install ./cmd/dotagents Ensure the Go install directory is on `PATH`. If `go env GOBIN` is non-empty, add that directory; otherwise add `$(go env GOPATH)/bin`. -After that, use `dotagents` directly: +After that, run first-time machine setup (creates `~/.agents`, patches detected agent configs, syncs), or inspect state directly: ```bash +dotagents setup dotagents status dotagents deps check ``` @@ -133,7 +146,7 @@ External sources are pinned in `dotagents.lock` (commit this file): the first sy ## Plugins -Dotagents treats third-party plugins as first-party catalog entries in `dotagents.yaml`, not as committed `.codex-plugin`, `.amp/`, or `.hermes/` runtime directories. (The repo's own `.claude-plugin/` manifests are the one exception; see "Installing this repo as a Claude Code plugin" below.) A plugin entry records its source format, runtime surfaces, target agents, and review notes: +Dotagents treats third-party plugins as first-party catalog entries in `dotagents.yaml`, not as committed `.codex-plugin`, `.amp/`, or `.hermes/` runtime directories. (The repo's own self-publication manifests - `.claude-plugin/`, `.agents/plugins/marketplace.json`, and `plugins/dotagents/` - are the exception; see "Installing this repo as a plugin" below.) A plugin entry records its source format, runtime surfaces, target agents, and review notes: ```yaml plugins: @@ -158,9 +171,16 @@ Compatibility model: - `native-plugin` is host-specific: `.codex-plugin` stays Codex-native and `.claude-plugin` stays Claude-native. - `commands` are currently Claude-native unless re-modeled as skills, hooks, MCP, or a repo-owned CLI. -### Installing this repo as a Claude Code plugin +### Installing this repo as a plugin -The repo doubles as a self-hosted Claude Code plugin and single-plugin marketplace via `.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json`: +The repo self-publishes as a plugin for the two harnesses that have plugin systems: + +- Claude Code, via `.claude-plugin/{plugin,marketplace}.json` at the repo root. +- Codex, via `.agents/plugins/marketplace.json` and the `plugins/dotagents/` plugin directory. + +Hermes, Factory Droid, and Pi/OMP have no plugin system; they consume skills through `dotagents setup` / `dotagents sync`. + +For Claude Code: ``` /plugin marketplace add yourconscience/dotagents @@ -195,7 +215,24 @@ dotagents plugin remove That uninstalls the Claude plugin, removes the marketplace entry, sets `delivery: sync`, and runs `dotagents sync --agents=claude-code`. Plugin installs snapshot the repo at install time; consumers pick up new skills with `/plugin update`, unlike the always-live symlinks. The plugin manifest intentionally omits a fixed `version` so Claude Code uses the git commit SHA and every new commit can be updated. -**Why Claude Code only.** Claude Code is the one supported agent whose native plugin format fits a shared-root skill library: it auto-discovers `skills/` and `agents/` from the plugin (repo) root. Codex has a plugin system too, but its plugins must live in a subdirectory with a *real, copied* `skills/` inside the plugin directory - it ignores symlinks and rejects a plugin at the marketplace root (verified against `codex 0.136.0`). Bundling our 31MB shared `skills/` into a committed subdir would mean a second source of truth, so Codex - like Droid, Amp, Hermes, and Pi - consumes dotagents skills through `dotagents sync`, not a plugin. `SKILL.md` directories remain the genuinely portable cross-tool convention. +### Installing this repo as a Codex plugin + +For Codex: + +```bash +codex plugin marketplace add https://github.com/yourconscience/dotagents +codex plugin add dotagents@yourconscience +``` + +The repo is private, so `marketplace add` requires working GitHub git auth on the machine. + +Codex plugin installs require a *real, copied* `skills/` inside the plugin directory - symlinks are silently dropped and a plugin at the marketplace root is rejected (verified against `codex 0.136.0`). So `plugins/dotagents/skills/` is a rendered copy of the canonical `skills/` tree (tracked files only, a few hundred KB), regenerated by `dotagents render` alongside the Claude agent renders. `dotagents doctor` (`codex plugin` check) and CI fail when the copy drifts, which keeps the single-source-of-truth guarantee. + +There is no `delivery:` switch for Codex: a machine managed by dotagents keeps the live symlink sync and should not install the Codex plugin on top (skills would appear twice). The plugin is the install path for machines that do not run dotagents. Like the Claude plugin, the manifest intentionally omits a fixed `version` so updates never hide behind a stale version pin; to pick up new skills, run `codex plugin marketplace upgrade`, then `codex plugin remove dotagents@yourconscience` and `codex plugin add dotagents@yourconscience` (re-adding refreshes the cached copy - verified against codex 0.136.0). + +Plugins ship full skills, tools included. Skill tool commands resolve relative to the skill's own directory (the base directory the harness reports when a skill loads), never to a fixed checkout path, so bundled CLIs like `pr-triage` inspect and `x-sim` run from any install root - checkout, symlink, or plugin cache. The exceptions are the machine-management skills (`dotagents`, `cmux` hooks, memory sync) which inherently operate on a dotagents-managed machine and say so. + +`SKILL.md` directories remain the genuinely portable cross-tool convention; harnesses without a plugin system (Hermes, Droid, Pi, Amp) consume them through `dotagents sync` or by pointing at `skills/` directly. ## Agent Integration Status diff --git a/cmd/dotagents/agents.go b/cmd/dotagents/agents.go index 73a5d1b..63f739a 100644 --- a/cmd/dotagents/agents.go +++ b/cmd/dotagents/agents.go @@ -161,7 +161,10 @@ func runRender(opts runOptions) error { if err != nil { return err } - return renderPluginAgents(repoRoot) + if err := renderPluginAgents(repoRoot); err != nil { + return err + } + return renderCodexPluginSkills(repoRoot) } func renderPluginAgents(repoRoot string) error { diff --git a/cmd/dotagents/codex_plugin.go b/cmd/dotagents/codex_plugin.go new file mode 100644 index 0000000..02f7d6e --- /dev/null +++ b/cmd/dotagents/codex_plugin.go @@ -0,0 +1,225 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +// codexPluginRelDir is the whole-repo Codex plugin. Its skills/ subtree is a +// rendered copy of the repo's skills/ because Codex plugin installs ignore +// symlinked skills directories (verified against codex 0.136.0). +const codexPluginRelDir = "plugins/dotagents" + +const codexMarketplaceRelPath = ".agents/plugins/marketplace.json" + +// codexPluginSourceFiles lists the skill files to mirror, as paths relative to +// the repo root (slash-separated). Tracked and untracked-but-not-ignored files +// are included so gitignored build artifacts and local data stay out. +func codexPluginSourceFiles(repoRoot string) ([]string, error) { + cmd := exec.Command("git", "-C", repoRoot, "ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", "skills") + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git ls-files skills: %w", err) + } + var files []string + for _, raw := range bytes.Split(out, []byte{0}) { + name := string(raw) + if name == "" { + continue + } + files = append(files, name) + } + sort.Strings(files) + return files, nil +} + +func codexPluginSkillsDir(repoRoot string) string { + return filepath.Join(repoRoot, filepath.FromSlash(codexPluginRelDir), "skills") +} + +// renderCodexPluginSkills mirrors skills/ into the Codex plugin dir, removing +// files that no longer exist in the source tree. +func renderCodexPluginSkills(repoRoot string) error { + files, err := codexPluginSourceFiles(repoRoot) + if err != nil { + return err + } + destRoot := codexPluginSkillsDir(repoRoot) + + expected := make(map[string]bool, len(files)) + written, unchanged := 0, 0 + for _, rel := range files { + sub := strings.TrimPrefix(rel, "skills/") + src := filepath.Join(repoRoot, filepath.FromSlash(rel)) + dest := filepath.Join(destRoot, filepath.FromSlash(sub)) + data, err := os.ReadFile(src) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + continue // tracked but deleted in worktree; leave unexpected so the copy is pruned + } + return fmt.Errorf("read %s: %w", src, err) + } + expected[filepath.FromSlash(sub)] = true + current, err := os.ReadFile(dest) + if err == nil && bytes.Equal(current, data) { + unchanged++ + continue + } + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("read %s: %w", dest, err) + } + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return fmt.Errorf("create %s: %w", filepath.Dir(dest), err) + } + info, err := os.Stat(src) + if err != nil { + return fmt.Errorf("stat %s: %w", src, err) + } + if err := os.WriteFile(dest, data, info.Mode().Perm()); err != nil { + return fmt.Errorf("write %s: %w", dest, err) + } + written++ + } + + removed := 0 + if err := filepath.WalkDir(destRoot, func(path string, d fs.DirEntry, err error) error { + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err + } + if d.IsDir() { + return nil + } + rel, err := filepath.Rel(destRoot, path) + if err != nil { + return err + } + if expected[rel] { + return nil + } + if err := os.Remove(path); err != nil { + return fmt.Errorf("remove stale %s: %w", path, err) + } + removed++ + return nil + }); err != nil { + return err + } + if err := removeEmptyDirs(destRoot); err != nil { + return err + } + + fmt.Printf("rendered %s/skills: %d written, %d unchanged, %d removed\n", codexPluginRelDir, written, unchanged, removed) + return nil +} + +func removeEmptyDirs(root string) error { + var dirs []string + if err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err + } + if d.IsDir() && path != root { + dirs = append(dirs, path) + } + return nil + }); err != nil { + return err + } + // Deepest first so emptied parents are removable in one pass. + sort.Slice(dirs, func(i, j int) bool { return len(dirs[i]) > len(dirs[j]) }) + for _, dir := range dirs { + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + if len(entries) == 0 { + if err := os.Remove(dir); err != nil { + return err + } + } + } + return nil +} + +func checkCodexPlugin(repoRoot string) checkResult { + const name = "codex plugin" + manifest := filepath.Join(repoRoot, filepath.FromSlash(codexPluginRelDir), ".codex-plugin", "plugin.json") + if !hasFile(manifest) { + return checkResult{name, checkStatusFail, fmt.Sprintf("missing %s/.codex-plugin/plugin.json", codexPluginRelDir)} + } + if !hasFile(filepath.Join(repoRoot, filepath.FromSlash(codexMarketplaceRelPath))) { + return checkResult{name, checkStatusFail, "missing " + codexMarketplaceRelPath} + } + + files, err := codexPluginSourceFiles(repoRoot) + if err != nil { + return checkResult{name, checkStatusFail, err.Error()} + } + destRoot := codexPluginSkillsDir(repoRoot) + + expected := make(map[string]bool, len(files)) + var stale []string + fresh := 0 + for _, rel := range files { + sub := filepath.FromSlash(strings.TrimPrefix(rel, "skills/")) + expected[sub] = true + src, err := os.ReadFile(filepath.Join(repoRoot, filepath.FromSlash(rel))) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + delete(expected, sub) + continue + } + return checkResult{name, checkStatusFail, err.Error()} + } + dest, err := os.ReadFile(filepath.Join(destRoot, sub)) + if err != nil || !bytes.Equal(src, dest) { + stale = append(stale, sub) + continue + } + fresh++ + } + if err := filepath.WalkDir(destRoot, func(path string, d fs.DirEntry, err error) error { + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err + } + if d.IsDir() { + return nil + } + rel, err := filepath.Rel(destRoot, path) + if err != nil { + return err + } + if !expected[rel] { + stale = append(stale, rel+" (extra)") + } + return nil + }); err != nil { + return checkResult{name, checkStatusFail, err.Error()} + } + + if len(stale) > 0 { + sort.Strings(stale) + sample := stale + if len(sample) > 5 { + sample = sample[:5] + } + return checkResult{name, checkStatusFail, fmt.Sprintf("%d file(s) stale (%s); run: dotagents render", len(stale), strings.Join(sample, ", "))} + } + return checkResult{name, checkStatusPass, fmt.Sprintf("%d rendered skill files fresh in %s", fresh, codexPluginRelDir)} +} diff --git a/cmd/dotagents/codex_plugin_test.go b/cmd/dotagents/codex_plugin_test.go new file mode 100644 index 0000000..f4aad0c --- /dev/null +++ b/cmd/dotagents/codex_plugin_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func initCodexPluginTestRepo(t *testing.T) string { + t.Helper() + repoRoot := t.TempDir() + for _, args := range [][]string{ + {"init", "-q"}, + {"config", "user.email", "test@example.com"}, + {"config", "user.name", "test"}, + {"config", "commit.gpgsign", "false"}, + } { + cmd := exec.Command("git", append([]string{"-C", repoRoot}, args...)...) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + return repoRoot +} + +func writeCodexPluginTestFile(t *testing.T, path string, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestRenderCodexPluginSkillsMirrorsTrackedAndPrunesStale(t *testing.T) { + repoRoot := initCodexPluginTestRepo(t) + writeCodexPluginTestFile(t, filepath.Join(repoRoot, "skills", "alpha", "SKILL.md"), "---\nname: alpha\n---\n") + writeCodexPluginTestFile(t, filepath.Join(repoRoot, ".gitignore"), "skills/alpha/data/\n") + writeCodexPluginTestFile(t, filepath.Join(repoRoot, "skills", "alpha", "data", "secret.txt"), "ignored\n") + staleDest := filepath.Join(codexPluginSkillsDir(repoRoot), "removed", "SKILL.md") + writeCodexPluginTestFile(t, staleDest, "old\n") + + if err := renderCodexPluginSkills(repoRoot); err != nil { + t.Fatal(err) + } + + rendered, err := os.ReadFile(filepath.Join(codexPluginSkillsDir(repoRoot), "alpha", "SKILL.md")) + if err != nil { + t.Fatalf("expected rendered skill: %v", err) + } + if string(rendered) != "---\nname: alpha\n---\n" { + t.Fatalf("rendered content = %q", rendered) + } + if _, err := os.Stat(filepath.Join(codexPluginSkillsDir(repoRoot), "alpha", "data", "secret.txt")); !os.IsNotExist(err) { + t.Fatalf("gitignored file was rendered, stat err = %v", err) + } + if _, err := os.Stat(staleDest); !os.IsNotExist(err) { + t.Fatalf("stale rendered file still exists, stat err = %v", err) + } + if _, err := os.Stat(filepath.Dir(staleDest)); !os.IsNotExist(err) { + t.Fatalf("empty stale dir still exists, stat err = %v", err) + } +} + +func TestRenderCodexPluginSkillsPrunesTrackedButDeletedFiles(t *testing.T) { + repoRoot := initCodexPluginTestRepo(t) + deleted := filepath.Join(repoRoot, "skills", "alpha", "gone.md") + writeCodexPluginTestFile(t, deleted, "soon gone\n") + cmd := exec.Command("git", "-C", repoRoot, "add", "skills") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git add: %v\n%s", err, out) + } + + if err := renderCodexPluginSkills(repoRoot); err != nil { + t.Fatal(err) + } + dest := filepath.Join(codexPluginSkillsDir(repoRoot), "alpha", "gone.md") + if _, err := os.Stat(dest); err != nil { + t.Fatalf("expected rendered copy: %v", err) + } + + // Tracked in the index but deleted from the worktree: the copy must go. + if err := os.Remove(deleted); err != nil { + t.Fatal(err) + } + if err := renderCodexPluginSkills(repoRoot); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(dest); !os.IsNotExist(err) { + t.Fatalf("copy of deleted tracked file still exists, stat err = %v", err) + } +} + +func TestCheckCodexPluginDetectsDrift(t *testing.T) { + repoRoot := initCodexPluginTestRepo(t) + writeCodexPluginTestFile(t, filepath.Join(repoRoot, "skills", "alpha", "SKILL.md"), "---\nname: alpha\n---\n") + writeCodexPluginTestFile(t, filepath.Join(repoRoot, filepath.FromSlash(codexPluginRelDir), ".codex-plugin", "plugin.json"), "{}\n") + writeCodexPluginTestFile(t, filepath.Join(repoRoot, filepath.FromSlash(codexMarketplaceRelPath)), "{}\n") + + result := checkCodexPlugin(repoRoot) + if result.status != checkStatusFail || !strings.Contains(result.detail, "dotagents render") { + t.Fatalf("pre-render check = %#v, want stale fail", result) + } + + if err := renderCodexPluginSkills(repoRoot); err != nil { + t.Fatal(err) + } + result = checkCodexPlugin(repoRoot) + if result.status != checkStatusPass { + t.Fatalf("post-render check = %#v, want pass", result) + } + + writeCodexPluginTestFile(t, filepath.Join(repoRoot, "skills", "alpha", "SKILL.md"), "---\nname: alpha\ndescription: changed\n---\n") + result = checkCodexPlugin(repoRoot) + if result.status != checkStatusFail { + t.Fatalf("post-edit check = %#v, want fail", result) + } +} diff --git a/cmd/dotagents/doctor.go b/cmd/dotagents/doctor.go index 1e41eb4..e82dfca 100644 --- a/cmd/dotagents/doctor.go +++ b/cmd/dotagents/doctor.go @@ -52,6 +52,7 @@ func runDoctor(opts runOptions) error { results = append(results, checkSkillSpec(repoRoot)) results = append(results, checkAgentRoles(repoRoot)) results = append(results, checkPluginAgents(repoRoot)) + results = append(results, checkCodexPlugin(repoRoot)) for _, name := range sortedHarnessNames() { for _, check := range getHarnesses()[name].DoctorChecks { results = append(results, check.Run(repoRoot, home, cfg)) diff --git a/cmd/dotagents/main.go b/cmd/dotagents/main.go index 5e6d876..36bcc28 100644 --- a/cmd/dotagents/main.go +++ b/cmd/dotagents/main.go @@ -251,7 +251,7 @@ func printUsage() { fmt.Println(" dotagents plugin remove Remove Claude Code plugin delivery and restore sync") fmt.Println(" dotagents skillify [--description \"...\"] Scaffold a new skill from template") fmt.Println(" dotagents promote [--dry-run] Promote a Hermes skill to dotagents + PR") - fmt.Println(" dotagents render Render committed Claude plugin agents (agents/) from agents/*.yaml") + fmt.Println(" dotagents render Render committed plugin artifacts: Claude agents (agents/) and Codex plugin skills (plugins/dotagents/)") fmt.Println(" dotagents doctor [--agents ...] Health audit: frontmatter, collisions, sizes, package age") fmt.Println(" dotagents dogfood [--agents ...] End-to-end self-test: sync + status + doctor") } diff --git a/dotagents.yaml b/dotagents.yaml index bec5c31..83a518c 100644 --- a/dotagents.yaml +++ b/dotagents.yaml @@ -172,7 +172,18 @@ plugins: - native-plugin agents: - claude-code - review: 'Self-publication via .claude-plugin/{plugin,marketplace}.json (skills + 4 agent roles auto-discovered from agents/*.md). Claude Code is the only target: Codex plugins require a real copied skills/ inside a subdir (no shared-root, no symlinks - verified against codex 0.136.0), so Codex and all other agents consume these skills via dotagents sync, not a plugin. Keep this catalog entry enabled:false; local Claude Code delivery is controlled by agents[].delivery (sync or plugin). delivery: plugin installs dotagents@yourconscience and prunes Claude Code symlink artifacts to avoid duplicate /tg and /dotagents:tg commands.' + review: 'Self-publication via .claude-plugin/{plugin,marketplace}.json (skills + 4 agent roles auto-discovered from agents/*.md). Keep this catalog entry enabled:false; local Claude Code delivery is controlled by agents[].delivery (sync or plugin). delivery: plugin installs dotagents@yourconscience and prunes Claude Code symlink artifacts to avoid duplicate /tg and /dotagents:tg commands.' + - name: dotagents-codex + enabled: false + source: https://github.com/yourconscience/dotagents + format: codex-plugin + description: This repo published as a Codex plugin (rendered skills copy under plugins/dotagents/). + surfaces: + - skills + - native-plugin + agents: + - codex + review: 'Self-publication via .agents/plugins/marketplace.json + plugins/dotagents/. Codex plugin installs ignore symlinked skills/ (verified against codex 0.136.0), so plugins/dotagents/skills is a rendered copy regenerated by dotagents render; the codex plugin doctor check fails on drift. Keep this entry enabled:false: machines managed by dotagents use sync, the plugin is the install path for unmanaged consumers. Hermes, Droid, and Pi have no plugin system; they install via dotagents setup.' mcp_servers: - name: linkedin enabled: true diff --git a/plugins/dotagents/.codex-plugin/plugin.json b/plugins/dotagents/.codex-plugin/plugin.json new file mode 100644 index 0000000..374207a --- /dev/null +++ b/plugins/dotagents/.codex-plugin/plugin.json @@ -0,0 +1,23 @@ +{ + "name": "dotagents", + "description": "Cross-agent skill library from the dotagents repo: cmux, tg, jobs, repo-eval, spawn, spec, grill-me, and more.", + "author": { + "name": "Kirill Korikov", + "url": "https://github.com/yourconscience" + }, + "homepage": "https://github.com/yourconscience/dotagents", + "repository": "https://github.com/yourconscience/dotagents", + "license": "MIT", + "keywords": [ + "skills", + "workflow" + ], + "skills": "./skills/", + "interface": { + "displayName": "dotagents", + "shortDescription": "Cross-agent skill library for coding-agent workflows.", + "longDescription": "Packages every skill from the dotagents repo for Codex. Skill CLIs that live in the dotagents checkout (~/.agents) require the dotagents CLI install; SKILL.md files note their own requirements.", + "developerName": "yourconscience", + "category": "Productivity" + } +} diff --git a/plugins/dotagents/README.md b/plugins/dotagents/README.md new file mode 100644 index 0000000..f1ec120 --- /dev/null +++ b/plugins/dotagents/README.md @@ -0,0 +1,14 @@ +# dotagents Codex plugin + +Whole-repo Codex plugin packaging for the dotagents skill library. + +`skills/` here is a rendered copy of the repo's canonical `skills/` tree, regenerated by `dotagents render`. Do not edit it directly; edit `skills/` at the repo root and re-run `dotagents render`. `dotagents doctor` (`codex plugin` check) and CI fail when the copy drifts. + +A real copy is required because Codex plugin installs ignore symlinked `skills/` directories (verified against codex 0.136.0). + +Install: + +```bash +codex plugin marketplace add https://github.com/yourconscience/dotagents +codex plugin add dotagents@yourconscience +``` diff --git a/plugins/dotagents/skills/cmux/SKILL.md b/plugins/dotagents/skills/cmux/SKILL.md new file mode 100644 index 0000000..4b59c75 --- /dev/null +++ b/plugins/dotagents/skills/cmux/SKILL.md @@ -0,0 +1,141 @@ +--- +name: cmux +description: Control cmux workspaces, panes, terminal and browser surfaces, markdown viewers, notifications, and visible agent workspaces. +--- + +# cmux + +Use this skill when the current terminal is managed by cmux, or when the task needs cmux browser surfaces, workspace layout, markdown viewing, or cmux-specific agent launchers. + +## Detection + +```bash +env | grep '^CMUX_' +cmux current-workspace +cmux sidebar-state +``` + +If `CMUX_WORKSPACE_ID` and `CMUX_SURFACE_ID` are present, route pane and browser operations through `cmux`. + +## Inspect Layout + +```bash +cmux current-workspace +cmux tree --workspace workspace:N +cmux tree --all +cmux list-panes --workspace workspace:N +cmux list-pane-surfaces --workspace workspace:N --pane pane:N +cmux sidebar-state --workspace workspace:N +``` + +Always inspect layout first, then use explicit `workspace:N`, `pane:N`, and `surface:N` targets. + +## Read Screen + +```bash +cmux read-screen --workspace workspace:N --surface surface:N --lines 80 +cmux read-screen --workspace workspace:N --surface surface:N --scrollback --lines 200 +``` + +## Send Input + +```bash +cmux send --workspace workspace:N --surface surface:N "command here" +cmux send-key --workspace workspace:N --surface surface:N "Enter" +cmux send-key --workspace workspace:N --surface surface:N "C-c" +``` + +Prefer sending full commands plus `Enter`; use keys for control sequences. + +## Workspaces, Panes, and Surfaces + +```bash +cmux new-workspace --name "title" --cwd /path +cmux select-workspace --workspace workspace:N +cmux rename-workspace --workspace workspace:N "title" +cmux new-pane --type terminal --direction right --workspace workspace:N +cmux new-pane --type browser --direction right --workspace workspace:N --url https://example.com +cmux new-split right --workspace workspace:N --surface surface:N +cmux new-surface --type terminal --pane pane:N --workspace workspace:N +cmux new-surface --type browser --pane pane:N --workspace workspace:N --url https://example.com +``` + +In cmux, panes hold surfaces. A pane can contain terminal and browser surfaces as tabs. + +## Focus, Move, and Close + +```bash +cmux focus-pane --pane pane:N --workspace workspace:N +cmux move-surface --surface surface:N --pane pane:N --workspace workspace:N +cmux move-tab-to-new-workspace --surface surface:N --workspace workspace:N --title "title" --focus false +cmux split-off --surface surface:N right --workspace workspace:N --focus false +cmux close-surface --surface surface:N --workspace workspace:N +cmux close-workspace --workspace workspace:N +``` + +To avoid recursive screen sharing during a video call, move the meeting browser surface into its own workspace instead of closing it: + +```bash +cmux tree --workspace workspace:N +cmux move-tab-to-new-workspace --surface surface:N --workspace workspace:N --title meeting --focus false +``` + +## Browser Surfaces + +```bash +cmux browser --surface surface:N url +cmux browser --surface surface:N snapshot --compact +cmux browser --surface surface:N navigate https://example.com --snapshot-after +cmux browser --surface surface:N click "button" +cmux browser --surface surface:N type "input[name=q]" "text" +cmux browser --surface surface:N screenshot --out /tmp/cmux-browser.png +``` + +Prefer browser subcommands for cmux browser surfaces. Do not use them to click call controls, close tabs, or submit forms unless the user has asked for that specific action. + +## Markdown and Notifications + +```bash +cmux markdown open /path/to/report.md --focus false +cmux notify --title "Done" --body "Details" --workspace workspace:N +cmux list-notifications +``` + +## Browse Project Files + +Use `peek` to browse the current cmux workspace in the cmux browser via a local `code-server` instance: + +```bash +peek +peek ~/some-project +``` + +`peek` is a local machine setup, not a portable cmux command. It must only use a loopback `code-server` URL when authentication is disabled. If it opens the wrong folder, verify `cmux sidebar-state` reports the expected `cwd`; if incorrect, recreate the workspace with the right `--cwd`. + +## Agent Workflow + +```bash +cmux new-workspace --name "agent task" --cwd /repo +cmux send --workspace workspace:N --surface surface:N "droid" +cmux send-key --workspace workspace:N --surface surface:N "Enter" +``` + +cmux-specific launchers include `cmux codex-teams`, `cmux claude-teams`, `cmux hermes`, `cmux omx`, and `cmux omo`. Use the normal agent CLI directly when you need exact command control. + +## Hooks + +When the user asks for `/cmux hooks`, use the dotagents layer rather than `cmux hooks setup`. Cmux hook entrypoints are repo-owned under `~/.agents/hooks/cmux/`, declared in `~/.agents/dotagents.yaml`, and distributed with: + +```bash +dotagents sync --agents=codex,droid,hermes +``` + +Do not let `cmux hooks setup` patch agent configs directly unless the user explicitly asks to bypass dotagents. + +## Safety Rules + +- Inspect layout before reading, sending input, moving surfaces, or closing anything. +- Use explicit targets (`workspace:N`, `pane:N`, `surface:N`). +- Move browser/video surfaces to a separate workspace instead of closing them to avoid terminating active sessions. +- Do not click browser controls, close surfaces, or send destructive commands unless you have verified the target and the user asked for that action. +- Prefer a dedicated workspace for long-running delegated agents. diff --git a/plugins/dotagents/skills/dotagents/SKILL.md b/plugins/dotagents/skills/dotagents/SKILL.md new file mode 100644 index 0000000..31536a1 --- /dev/null +++ b/plugins/dotagents/skills/dotagents/SKILL.md @@ -0,0 +1,200 @@ +--- +name: dotagents +description: Inspect and sync the repo-owned agent skill links for the dotagents repo across primary coding agents. Use when the user asks for dotagents status, dotagents sync, dotagents setup, or wants to reconcile ~/.agents with Claude/Codex/Hermes/Droid/Pi skill roots; Amp/OpenClaw/OpenCode-style harnesses are compatibility-only unless explicitly configured. +--- + +# dotagents + +Use the root CLI tool. This skill is specific to the `~/Workspace/dotagents` repo and its managed symlink surface. + +## Commands + +From the repo root: + +```bash +go install ./cmd/dotagents # install to Go bin dir +dotagents setup # first-time machine setup +dotagents status # show sync state +dotagents sync # sync skill symlinks +dotagents pull # git pull + sync (for cron) +dotagents cron --interval 30m # install auto-pull crontab +dotagents cron --remove # remove crontab entry +dotagents mcp list # list canonical managed MCPs +dotagents mcp add local --command uvx --arg pkg@latest +dotagents mcp import claude-code local +dotagents mcp remove local +``` + +Ensure the Go install directory is on `PATH`. If `go env GOBIN` is non-empty, add that directory; otherwise add `$(go env GOPATH)/bin`. + +Limit a run to specific agents: + +```bash +dotagents sync --agents=claude-code,hermes +``` + +## Subcommands + +### setup + +First-time setup on a new machine. Does three things: +1. Creates `~/.agents` symlink pointing at the repo root. +2. Patches detected primary agent configs to load shared dotagents config where needed (Hermes: adds `skills.external_dirs` in `config.yaml`). +3. Runs `sync` for managed skills, managed MCP entries, and supported hook entries. + +Amp compatibility note: Amp support remains in the CLI for explicit local configs, migration, and cleanup, but Amp is no longer a canonical `dotagents.yaml` target. Do not add Amp to managed plugin/MCP/skill targets unless the user explicitly asks for that one-off integration. + +Important Hermes note: Hermes should consume dotagents skills primarily via `skills.external_dirs: ["~/.agents/skills"]`, not by mirroring repo skills into `~/.hermes/skills`. Hermes already ships a bundled categorized skill tree under `~/.hermes/skills`, so symlinking repo skills there can collide with bundled category directories. Example: a repo skill named `research` conflicts with Hermes' builtin `research/` category. Prefer Hermes-native bundled skills when an equivalent already exists there. Example: use Hermes' builtin `google-workspace` skill instead of trying to override it from dotagents. Treat `external_dirs` as the canonical Hermes integration path. + +When a dotagents skill overlaps with a Hermes builtin skill, prefer the Hermes builtin and let the external dotagents copy exist only for other agents. Current example: the shared `skills/gws/` skill now has frontmatter name `google-workspace`, but on Hermes the builtin `google-workspace` skill should win and no extra sync action is needed. + +### status + +Reports sync state for each detected agent. Agents whose binary is not on PATH show "not detected" and are skipped. The report covers managed skills, native roles, MCP entries, and supported hook entries declared in `dotagents.yaml`. + +### sync + +Creates, updates, or removes skill symlinks in each detected primary agent's skill root for agents that use managed mirrors. Non-repo skills are reported as `external` and left untouched. Hermes is special-cased: `sync` verifies its config-driven shared-skill integration and does not mirror repo skills into `~/.hermes/skills`. + +For external skills (declared under `external_skills` in `dotagents.yaml`), `sync` clones or updates the remote git repos into `~/.agents/external//`, discovers skills under the configured `skill_dir`, and symlinks them into agent skill roots alongside local skills. + +For MCPs, `sync` patches only the managed server entries declared in `dotagents.yaml` and leaves unrelated MCP servers alone. Use `dotagents mcp add` or `dotagents mcp import` to update canonical `dotagents.yaml`, then run `dotagents sync` to distribute those MCPs to supported agents. If `--agents` is omitted, new/imported MCPs target the configured primary agents with MCP support (`claude-code`, `codex`, `hermes`, `droid`, `pi`). `import` redacts native env values into `${KEY}` references (preserving existing `${SOME_VAR}` references); fill those values through environment variables or local native config as appropriate. `list` shows env key ***** only; it does not print env values. `remove` deletes only the canonical entry and does not remove native agent config entries. + + +For hooks, `sync` patches only managed hook entries declared in `dotagents.yaml` for agents with verified hook config support. It manages Claude Code hooks in `~/.claude/settings.json`, Codex hooks in `~/.codex/hooks.json` plus `[features].hooks = true`, Factory Droid hooks in `~/.factory/settings.json`, and Hermes hooks in `~/.hermes/config.yaml`. It reports unsupported hook targets without failing. Hook approval is not automated; first-use approval and reapproval after script changes remain host-local user actions. + +For repo-owned local MCP servers, prefer `mcp//` plus a canonical `dotagents.yaml` entry using `sh -lc 'exec ...'`, with local secrets/state outside git. + +```bash +dotagents mcp list +dotagents mcp add local --command uvx --arg pkg@latest --env KEY=value +dotagents mcp import claude-code local --agents=codex,hermes,droid,pi +dotagents sync +dotagents mcp remove local +``` + +### pull + +Runs `git pull --ff-only` in the repo root, then `sync`. Designed to be called from cron for auto-sync on remote machines. + +### cron + +Installs or removes a crontab entry that runs `pull` on a schedule. Default interval is 30m. Options: 5m, 15m, 30m, 1h, 6h, 12h, daily. + +### promote + +Promotes a Hermes skill to dotagents shared skills. Copies the skill, creates a branch, commits, pushes, and opens a PR. + +```bash +dotagents promote SKILL_NAME # by name (searches ~/.hermes/skills/) +dotagents promote / # with category prefix +dotagents promote --dry-run # copy only, skip git/PR +``` + +The skill is searched in `~/.hermes/skills/` by name (including inside category subdirectories). The promote command: +1. Copies the skill to `skills//` +2. Creates branch `promote/` +3. Commits as "add skill" +4. Pushes and creates a PR via `gh` + +Requires `gh` CLI authenticated with push access to the repo. + +For the full promotion workflow (evaluation, pr-triage, merge), use the `skill-promote` Hermes skill. + +### dogfood + +End-to-end self-test: runs sync, then status, then doctor. Fails on any drift, conflict, or doctor warning. Use after making changes to skills or config to verify the full pipeline. + +```bash +dotagents dogfood +``` + +## What it manages + +- Fixes `~/.agents` if it should point at the repo root and is missing or drifted. +- Configures Hermes to load skills from `~/.agents/skills` through `~/.hermes/config.yaml` when Hermes is a configured target. +- Links Droid global instructions: `~/.factory/AGENTS.md -> ~/.agents/AGENTS.md` (real files conflict). +- Detects agents by checking if their binary is on PATH (`detect` field in config). +- Treats repo skills under `skills/` as the managed set for each detected agent. +- Treats repo hooks under `hooks/`, memory hooks under `memory/hooks/`, and skill hook entrypoints as managed only when declared in `dotagents.yaml`. +- Renders canonical repo roles under `agents/*.yaml` to each detected agent's native `agent_root` format where supported: + - Claude Code: `~/.claude/agents/.md` + - Codex: `~/.codex/agents/.toml` + - Factory Droid: `~/.factory/droids/.md` +- Stops on conflicts when a native agent file with the same name already exists and was not generated by dotagents. +- Reports non-repo skills already present in agent skill roots as `external` and leaves them untouched. +- Stops on conflicts when a managed skill path exists as a real file or directory instead of a symlink. + +## Root instruction shims + +Keep `AGENTS.md` canonical. `CLAUDE.md` points agents to it. Droid uses the same canonical file through `~/.factory/AGENTS.md -> ~/.agents/AGENTS.md`; deleting `~/.factory/AGENTS.md` does not make Droid discover `~/.agents/AGENTS.md`. + +```bash +readlink ~/.agents +cmp -s CLAUDE.md ~/.agents/CLAUDE.md && echo "CLAUDE.md visible via ~/.agents" +``` + +## Memory sync + +The dotagents repo also owns the agent memory ↔ knowledge vault sync pipeline at `~/.agents/memory/`. This is separate from skill/MCP sync but lives in the same repo. + +Hook scripts: + +- `hooks/session-end.sh`: Appends a session digest to `$KNOWLEDGE_DIR/sessions/YYYY-MM-DD.md` and reindexes memsearch. Dispatches on hook payload shape for Claude Code, Droid, and Hermes. +- `hooks/sync.sh` -> `lib/sync.py`: Bidirectional sync between Hermes built-in memory files (`~/.hermes/memories/`) and the knowledge vault (`$KNOWLEDGE_DIR`). Three modes: `memory-to-vault`, `vault-to-memory`, `both`. +- `hooks/session-start.sh` and `hooks/stop.sh`: Claude Code memory hook shims when the memsearch Claude plugin is available. + +The typical Hermes hook pipeline: + +```yaml +on_session_finalize: + - command: ~/.agents/memory/hooks/session-end.sh + timeout: 30 + - command: ~/.agents/memory/hooks/sync.sh + args: + - memory-to-vault + timeout: 15 +``` + +Hook approval: first-use consent requires a TTY prompt. Scripts modified after approval require revoke + re-approve via `hermes hooks revoke ` then approve at the next session-end TTY prompt. Cannot be automated from CLI. + +Hook health pitfall: `hermes hooks doctor` requires hook stdout to be valid JSON. Plain-text sync wrapper output can be allowlisted/executable but still fail doctor with `stdout was not valid JSON`; fix the wrapper contract only when ready to re-approve the modified script in a TTY. + +For detailed pitfalls (char limits, silent sync failures, hook debugging, JSON stdout requirements, and re-approval after wrapper changes), see `references/memory-sync.md`. + +The `knowledge-sync` tool source lives at `memory/tools/knowledge-sync/`. See its README for build and install instructions. + +## MCP support + +`dotagents` can also manage selected MCP server parity across agents without symlinking whole config files. + +Current managed MCP set lives in root `dotagents.yaml` under `mcp_servers:`. Minimal examples in this repo: + +- `linkedin` -> `uvx linkedin-scraper-mcp==4.13.2` +- `tavily` -> `npx -y mcp-remote@0.1.38 https://mcp.tavily.com/mcp` + +Status and sync rules: + +- `dotagents status` reports `mcp managed`, `mcp missing`, and `mcp drifted` alongside skills. +- `dotagents sync` patches only the managed MCP entries for each supported agent. +- Unrelated MCP servers remain untouched. + +Per-agent targets: + +- Claude Code: `~/.claude.json` -> `mcpServers.` +- Codex: `~/.codex/config.toml` -> `[mcp_servers.]` +- Hermes: `~/.hermes/config.yaml` -> `mcp_servers.` +- Factory Droid: `~/.factory/mcp.json` -> `mcpServers.` +- Pi: `~/.omp/agent/mcp.json` -> `mcpServers.` + +Do not symlink whole agent config files. Use targeted patches only. Droid MCP config is patched in-place at `~/.factory/mcp.json`; it is not symlinked. + +## Config + +Default targets live in: + +```bash +dotagents.yaml +``` + +Use `--agents` only as a one-run override. diff --git a/plugins/dotagents/skills/dotagents/references/memory-sync.md b/plugins/dotagents/skills/dotagents/references/memory-sync.md new file mode 100644 index 0000000..324e437 --- /dev/null +++ b/plugins/dotagents/skills/dotagents/references/memory-sync.md @@ -0,0 +1,112 @@ +# Hermes Memory Sync — pitfalls and debugging + +## Char limits + +Hermes built-in memory files have hard limits: +- `~/.hermes/memories/MEMORY.md`: 2,200 chars +- `~/.hermes/memories/USER.md`: 1,375 chars + +The `memory` tool enforces these on writes. `sync.py` also respects them. + +## Silent vault→memory sync failure + +**Symptom**: `sync.py vault-to-memory` reports `added 0 facts` but the vault profile has facts not in Hermes memory. + +**Root cause**: USER.md is near capacity. The script detects new facts correctly but can't fit them within the limit. + +**Debug**: Run this extraction to see what's missing and why: + +```python +from pathlib import Path +import re + +def normalize(entry): + return re.sub(r"\s+", " ", entry.lower().strip()) + +# Parse Hermes USER.md +hermes_user = Path.home() / ".hermes" / "memories" / "USER.md" +text = hermes_user.read_text().strip() +hermes_entries = [e.strip() for e in text.split("§") if e.strip()] +hermes_norm = {normalize(e) for e in hermes_entries} + +# Parse vault profile +profile = Path.home() / "Workspace" / "knowledge" / "profile" / "USER.md" +profile_facts = [] +for line in profile.read_text().splitlines(): + stripped = line.strip() + if stripped.startswith("- "): + fact = stripped[2:].strip() + if fact and len(fact) > 15: + profile_facts.append(fact) + +new_facts = [f for f in profile_facts if normalize(f) not in hermes_norm] +print(f"New facts: {len(new_facts)}, available chars: {1375 - sum(len(e)+1 for e in hermes_entries)}") +for f in new_facts: + print(f" [{len(f)} chars] {f}") +``` + +**Fix**: Consolidate redundant entries in Hermes memory using the `memory` tool. Merge related facts (e.g., all project transition details into one entry). Remove one-time facts (e.g., "backup complete"). Then re-run `sync.py memory-to-vault` and force-push the corrected vault branch. Verify with `hermes hooks doctor`. + +## Hook approval loop + +- Hook scripts at `~/.agents/memory/hooks/` require first-use TTY approval. +- `hermes hooks doctor` reports `script modified since approval` when mtime drifts — requires `hermes hooks revoke `, then re-approve at next session-end TTY prompt. +- `--accept-hooks` flag applies only to the current `hermes hooks test` invocation, does NOT persist approval. +- Hook approval is stored in `~/.hermes/shell-hooks-allowlist.json`. +- The allowlist matches the **exact command string** from `config.yaml`. + +## sync.py architecture + +`~/.agents/memory/lib/sync.py` does bidirectional sync: + +| Direction | Source → Target | What moves | +|-----------|----------------|------------| +| `memory-to-vault` | Hermes MEMORY.md → `ai/knowledge.md` | Memory facts as bullet list | +| | Hermes USER.md → `profile/USER.md` | User facts under `## Hermes Memory Sync` section | +| `vault-to-memory` | `profile/USER.md` → Hermes USER.md | Bullet points from main profile sections | + +After any change, reindexes memsearch via `memsearch index`. + +The `profile/USER.md` `## Hermes Memory Sync` section should be treated as a managed mirror of Hermes `USER.md`, not an incremental append log. Rewriting the whole section makes `memory-to-vault` idempotent and prevents oscillation where each run alternates which subset of consolidated facts appears. + +The `vault-to-memory` direction only imports bullets starting with `- ` that are >15 chars and not already present in Hermes memory. Dedup must handle consolidated Hermes entries: exact normalized equality is not enough. Use rendered-size accounting for `\n§\n` separators when enforcing the 1,375 char USER.md limit, and skip profile bullets that are substring/token-overlap duplicates of existing consolidated entries. + +## Hook verification + +Test hooks with synthetic payload: +```bash +hermes hooks test on_session_finalize +``` + +Check hook health: +```bash +hermes hooks doctor +hermes hooks list +``` + +View allowlist: +```bash +cat ~/.hermes/shell-hooks-allowlist.json +``` + +## JSON stdout requirement + +`hermes hooks doctor` expects hook stdout to be valid JSON. `finalize.py` already prints JSON, but plain `sync.py` output such as `memory→vault: ...` or `vault→memory: ...` is treated as a doctor failure even with exit code 0. + +Important distinction: +- Live hook execution still spawns the script; invalid JSON means Hermes ignores the hook response payload, not that the side effect necessarily failed. +- Doctor remains red and should not be ignored long-term. +- Fixing wrapper stdout after approval changes the script mtime/hash, so Hermes will require hook re-approval. From Telegram/mobile this is a bad time to patch unless `hooks_auto_accept` is intentionally enabled or the user can approve from a TTY. + +Preferred repair when a TTY is available: +1. Make the sync wrapper capture `sync.py` logs to stderr or a log file and print one JSON object to stdout, e.g. `{"action":"continue","message":"memory sync complete"}`. +2. Run `hermes hooks revoke ` if doctor reports modified-since-approval. +3. Trigger or test hooks from a TTY and approve, or update the allowlist deliberately only when the command/path is unchanged and the user has explicitly asked for that local repair. +4. Verify `hermes hooks doctor` is fully green. + +Known-good wrapper shape: +- `set -u`, not `set -eu`, so the wrapper can serialize a non-zero child exit into JSON before exiting with that status. +- Capture `python3 ~/.agents/memory/lib/sync.py ` stdout+stderr to a temp file. +- Echo captured logs to stderr for debugging. +- Print exactly one JSON object to stdout: `{"action":"continue","message":"...","exit_code":0}`. +- Exit with the child status. diff --git a/plugins/dotagents/skills/grill-me/SKILL.md b/plugins/dotagents/skills/grill-me/SKILL.md new file mode 100644 index 0000000..aea13e9 --- /dev/null +++ b/plugins/dotagents/skills/grill-me/SKILL.md @@ -0,0 +1,17 @@ +--- +name: grill-me +description: Interview the user relentlessly about a plan until every branch, dependency, and open question is resolved. Use when the user says /grill-me or wants a plan pressure-tested one question at a time. +--- + +Pressure-test the plan under discussion. Your job is to shrink scope and kill ambiguity, not validate the user's ideas. + +## Rules + +- Use the current harness's native structured question tool for every question when one is available. Claude Code: `AskUserQuestion`. Codex or Codex-derived harnesses: `request_user_input` when available. Hermes: `clarify`. If no such tool exists, ask a plain-text question directly. One question per message. +- Be blunt. No praise, no hedging, no "great point". If something is vague, say it is vague. +- For each question, state your recommended answer and why. Make the user argue if they disagree. +- Walk the design tree depth-first: pick the highest-risk branch, drill into it until it is fully resolved, then move to the next. +- If a question can be answered by exploring the codebase or files, explore them yourself instead of asking. +- Challenge scope aggressively. If the plan tries to do three things, ask why it is not one thing. If a feature has four modes, ask why it is not one mode with a flag. +- Do not move on until the current branch is concrete enough to implement without further questions. +- When you run out of open branches, summarize the final scoped plan in a single message and stop. diff --git a/plugins/dotagents/skills/gws/SKILL.md b/plugins/dotagents/skills/gws/SKILL.md new file mode 100644 index 0000000..0a46f22 --- /dev/null +++ b/plugins/dotagents/skills/gws/SKILL.md @@ -0,0 +1,92 @@ +--- +name: gws +description: Use when working with Google Workspace through the `gws` CLI for Drive, Docs, Gmail, Sheets, Calendar, and more. +--- + +# gws + +CLI for Google Workspace APIs. Binary: `gws`. Config: `~/.config/gws/`. + +On Hermes, prefer the bundled native `google-workspace` skill. This shared skill exists so the same workflow remains available to agents that do not ship a first-party equivalent. + +## Auth + +``` +gws auth setup # one-time OAuth client setup if client_secret.json is missing +gws auth login # OAuth browser flow +gws auth status # check current auth +``` + +Env: `GOOGLE_WORKSPACE_CLI_TOKEN`, `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` + +## Drive + +``` +gws drive files list --params '{"q": "name contains \"report\"", "pageSize": 10}' +gws drive files get --params '{"fileId": "ID"}' +gws drive files export --params '{"fileId": "ID", "mimeType": "text/plain"}' > out.txt +gws drive files create --params '{"name": "new.txt", "mimeType": "text/plain"}' --upload file.txt +``` + +## Docs + +``` +gws docs documents get --params '{"documentId": "ID"}' +``` + +## Google Doc To Markdown With Comments + +When the user asks to download or fetch a specific Google Doc with comments in Markdown, use the skill-local helper instead of hand-assembling the pipeline (`$SKILL_DIR` = this skill's own directory): + +``` +go -C "$SKILL_DIR"/tools/google_doc_markdown run . \ + --doc 'https://docs.google.com/document/d/FILE_ID/edit' \ + --output /absolute/path/doc-with-comments.md +``` + +Behavior: +- Accepts either a full Google Doc URL or a raw Doc ID. +- Checks `gws auth status` first and fails with an explicit setup/login instruction if auth is missing or the token lacks Drive access. +- Exports the Google Doc as HTML through `gws drive files export`. +- Removes Google comment reference markup and omits embedded base64 images that would otherwise pollute the Markdown. +- Converts the cleaned HTML to Markdown with `pandoc`. +- Fetches threaded comments through `gws drive comments list`. +- Appends a clean `## Comments` section with author, timestamps, quoted text, comment body, and replies. + +Agent workflow for this task: +- Run `gws auth status` first. +- If auth is missing or invalid, stop and tell the user to run `gws auth setup` and `gws auth login`. +- Run the helper with the doc URL and an explicit output path in the current working directory unless the user asked for a different location. +- Report the saved Markdown path back to the user. + +Use this helper for prompts like: +- `use gws to download this doc with comments https://docs.google.com/document/d/.../edit` +- `fetch this Google Doc with comments as markdown` + +## Gmail + +``` +gws gmail users messages list --params '{"userId": "me", "q": "newer_than:7d", "maxResults": 10}' +gws gmail users messages get --params '{"userId": "me", "id": "MSG_ID"}' +gws gmail users messages send --params '{"userId": "me"}' --body '{"raw": "BASE64_RFC2822"}' +``` + +## Sheets + +``` +gws sheets spreadsheets get --params '{"spreadsheetId": "ID"}' +gws sheets spreadsheets.values get --params '{"spreadsheetId": "ID", "range": "Sheet1!A1:D10"}' +gws sheets spreadsheets.values update --params '{"spreadsheetId": "ID", "range": "Sheet1!A1", "valueInputOption": "USER_ENTERED"}' --body '{"values": [["a","b"]]}' +``` + +## Calendar + +``` +gws calendar events list --params '{"calendarId": "primary", "timeMin": "2026-01-01T00:00:00Z", "maxResults": 10}' +``` + +## Tips + +- Use `gws schema ` to discover params for any endpoint. +- Add `--resolve-refs` to schema for full type details. +- Confirm before sending emails or creating events. diff --git a/plugins/dotagents/skills/gws/go.mod b/plugins/dotagents/skills/gws/go.mod new file mode 100644 index 0000000..5a7960b --- /dev/null +++ b/plugins/dotagents/skills/gws/go.mod @@ -0,0 +1,5 @@ +module gws-skill + +go 1.24.0 + +require golang.org/x/net v0.46.0 diff --git a/plugins/dotagents/skills/gws/go.sum b/plugins/dotagents/skills/gws/go.sum new file mode 100644 index 0000000..73ca9ac --- /dev/null +++ b/plugins/dotagents/skills/gws/go.sum @@ -0,0 +1,2 @@ +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= diff --git a/plugins/dotagents/skills/gws/tools/google_doc_markdown/main.go b/plugins/dotagents/skills/gws/tools/google_doc_markdown/main.go new file mode 100644 index 0000000..365f4f3 --- /dev/null +++ b/plugins/dotagents/skills/gws/tools/google_doc_markdown/main.go @@ -0,0 +1,708 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + "unicode" + + "golang.org/x/net/html" +) + +var ( + docIDFromURLPattern = regexp.MustCompile(`/document/d/([a-zA-Z0-9_-]+)`) + commentIDPattern = regexp.MustCompile(`^cmnt\d+$`) + commentRefIDPattern = regexp.MustCompile(`^cmnt_ref\d+$`) + slugPattern = regexp.MustCompile(`[^a-z0-9]+`) + whitespaceOnly = regexp.MustCompile(`^\s*$`) +) + +type authStatus struct { + AuthMethod string `json:"auth_method"` + TokenValid bool `json:"token_valid"` + Scopes []string `json:"scopes"` + User string `json:"user"` +} + +type fileMetadata struct { + ID string `json:"id"` + Name string `json:"name"` + MimeType string `json:"mimeType"` + ModifiedTime string `json:"modifiedTime"` + WebViewLink string `json:"webViewLink"` +} + +type commentsPage struct { + Comments []driveComment `json:"comments"` + NextPageToken string `json:"nextPageToken"` +} + +type driveComment struct { + ID string `json:"id"` + Content string `json:"content"` + CreatedTime string `json:"createdTime"` + ModifiedTime string `json:"modifiedTime"` + Deleted bool `json:"deleted"` + Resolved bool `json:"resolved"` + Anchor string `json:"anchor"` + QuotedFileContent quotedFileContent `json:"quotedFileContent"` + Author driveAuthor `json:"author"` + Replies []driveCommentReply `json:"replies"` +} + +type quotedFileContent struct { + Value string `json:"value"` +} + +type driveAuthor struct { + DisplayName string `json:"displayName"` + Email string `json:"emailAddress"` +} + +type driveCommentReply struct { + ID string `json:"id"` + Content string `json:"content"` + CreatedTime string `json:"createdTime"` + ModifiedTime string `json:"modifiedTime"` + Deleted bool `json:"deleted"` + Action string `json:"action"` + Author driveAuthor `json:"author"` +} + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + docInput := flag.String("doc", "", "Google Doc URL or raw document ID") + outputPath := flag.String("output", "", "Output markdown path. Defaults to ./.comments.md") + flag.Parse() + + if strings.TrimSpace(*docInput) == "" { + return errors.New("missing required --doc value") + } + + docID, err := parseDocID(*docInput) + if err != nil { + return err + } + + if err := ensureGWSAuth(); err != nil { + return err + } + + meta, err := getFileMetadata(docID) + if err != nil { + return fmt.Errorf("fetch file metadata: %w", err) + } + + if meta.MimeType != "application/vnd.google-apps.document" { + return fmt.Errorf("file %s is not a Google Doc (mimeType=%s)", docID, meta.MimeType) + } + + htmlPath, err := exportDocHTML(docID) + if err != nil { + return fmt.Errorf("export doc HTML: %w", err) + } + defer os.Remove(htmlPath) + + cleanHTMLPath, err := cleanExportedHTML(htmlPath) + if err != nil { + return fmt.Errorf("clean exported HTML: %w", err) + } + defer os.Remove(cleanHTMLPath) + + bodyMarkdown, err := convertHTMLToMarkdown(cleanHTMLPath) + if err != nil { + return fmt.Errorf("convert HTML to Markdown: %w", err) + } + bodyMarkdown = normalizeMarkdown(bodyMarkdown) + + comments, err := listComments(docID) + if err != nil { + return fmt.Errorf("fetch comments: %w", err) + } + + finalMarkdown := buildMarkdown(meta, *docInput, bodyMarkdown, comments) + + targetPath := strings.TrimSpace(*outputPath) + if targetPath == "" { + targetPath = filepath.Join(".", defaultOutputName(meta.Name)) + } + + if targetPath == "-" { + if _, err := os.Stdout.Write([]byte(finalMarkdown)); err != nil { + return fmt.Errorf("write stdout: %w", err) + } + return nil + } + + absPath, err := filepath.Abs(targetPath) + if err != nil { + return fmt.Errorf("resolve output path: %w", err) + } + if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil { + return fmt.Errorf("create output directory: %w", err) + } + if err := os.WriteFile(absPath, []byte(finalMarkdown), 0o644); err != nil { + return fmt.Errorf("write output: %w", err) + } + + fmt.Printf("Saved Markdown with comments to %s\n", absPath) + return nil +} + +func parseDocID(input string) (string, error) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return "", errors.New("empty doc input") + } + if matches := docIDFromURLPattern.FindStringSubmatch(trimmed); len(matches) == 2 { + return matches[1], nil + } + if strings.Contains(trimmed, "/") || strings.Contains(trimmed, "?") || strings.Contains(trimmed, "#") { + return "", fmt.Errorf("could not parse Google Doc ID from %q", trimmed) + } + return trimmed, nil +} + +func ensureGWSAuth() error { + statusOutput, _, err := runCommandOutput("gws", "auth", "status") + if err != nil { + return fmt.Errorf("gws auth status failed: %w", err) + } + + var status authStatus + if err := unmarshalFirstJSONObject(statusOutput, &status); err != nil { + return fmt.Errorf("parse gws auth status: %w", err) + } + + if status.AuthMethod == "" || status.AuthMethod == "none" || !status.TokenValid { + return errors.New("gws is not authenticated for this machine. Run `gws auth setup` once, then `gws auth login`, and retry") + } + + if !hasAnyScope(status.Scopes, "https://www.googleapis.com/auth/drive", "https://www.googleapis.com/auth/drive.readonly", "https://www.googleapis.com/auth/drive.file") { + return errors.New("gws token is missing Drive scopes required for Google Doc export/comments. Re-run `gws auth login` with Drive access and retry") + } + + return nil +} + +func hasAnyScope(scopes []string, wanted ...string) bool { + scopeSet := make(map[string]struct{}, len(scopes)) + for _, scope := range scopes { + scopeSet[scope] = struct{}{} + } + for _, want := range wanted { + if _, ok := scopeSet[want]; ok { + return true + } + } + return false +} + +func getFileMetadata(docID string) (fileMetadata, error) { + params := map[string]any{ + "fileId": docID, + "fields": "id,name,mimeType,modifiedTime,webViewLink", + } + output, err := runGWSJSON("drive", "files", "get", params) + if err != nil { + return fileMetadata{}, err + } + var meta fileMetadata + if err := json.Unmarshal(output, &meta); err != nil { + return fileMetadata{}, err + } + return meta, nil +} + +func exportDocHTML(docID string) (string, error) { + tempFile, err := os.CreateTemp("", "gws-doc-export-*.html") + if err != nil { + return "", err + } + tempPath := tempFile.Name() + tempFile.Close() + + params := map[string]any{ + "fileId": docID, + "mimeType": "text/html", + } + rawParams, err := json.Marshal(params) + if err != nil { + return "", err + } + + _, err = runCommand("gws", "drive", "files", "export", "--params", string(rawParams), "--output", tempPath) + if err != nil { + os.Remove(tempPath) + return "", err + } + return tempPath, nil +} + +func cleanExportedHTML(path string) (string, error) { + rawHTML, err := os.ReadFile(path) + if err != nil { + return "", err + } + + doc, err := html.Parse(bytes.NewReader(rawHTML)) + if err != nil { + return "", err + } + + body := findElement(doc, "body") + if body == nil { + return "", errors.New("exported HTML has no ") + } + + walkPostOrder(body, func(n *html.Node) { + if n.Type != html.ElementNode { + return + } + + switch n.Data { + case "style", "script": + removeNode(n) + return + case "img": + if strings.HasPrefix(attr(n, "src"), "data:") { + replaceNodeWithText(n, "Embedded image omitted from Google export.") + return + } + case "a": + id := attr(n, "id") + href := attr(n, "href") + if commentIDPattern.MatchString(id) { + removeCommentBlock(n) + return + } + if commentRefIDPattern.MatchString(id) || commentIDPattern.MatchString(strings.TrimPrefix(href, "#")) { + removeCommentRef(n) + return + } + case "span": + unwrapNode(n) + return + } + + stripAttrs(n) + }) + + removeEmptyNodes(body) + + tempFile, err := os.CreateTemp("", "gws-doc-clean-*.html") + if err != nil { + return "", err + } + tempPath := tempFile.Name() + keepFile := false + defer func() { + tempFile.Close() + if !keepFile { + os.Remove(tempPath) + } + }() + + if err := html.Render(tempFile, doc); err != nil { + return "", err + } + keepFile = true + + return tempPath, nil +} + +func convertHTMLToMarkdown(path string) (string, error) { + stdout, _, err := runCommandOutput("pandoc", "-f", "html", "-t", "gfm", "--wrap=none", path) + if err != nil { + return "", err + } + return strings.TrimSpace(string(stdout)), nil +} + +func listComments(docID string) ([]driveComment, error) { + comments := make([]driveComment, 0, 16) + pageToken := "" + + for { + params := map[string]any{ + "fileId": docID, + "fields": "comments,nextPageToken", + "pageSize": 100, + } + if pageToken != "" { + params["pageToken"] = pageToken + } + + output, err := runGWSJSON("drive", "comments", "list", params) + if err != nil { + return nil, err + } + + var page commentsPage + if err := json.Unmarshal(output, &page); err != nil { + return nil, err + } + + comments = append(comments, page.Comments...) + if page.NextPageToken == "" { + return comments, nil + } + pageToken = page.NextPageToken + } +} + +func buildMarkdown(meta fileMetadata, input string, bodyMarkdown string, comments []driveComment) string { + var b strings.Builder + + source := meta.WebViewLink + if strings.TrimSpace(source) == "" { + source = strings.TrimSpace(input) + } + + fmt.Fprintf(&b, "Source: %s\n", source) + fmt.Fprintf(&b, "Document ID: `%s`\n", meta.ID) + if modified := formatTimestamp(meta.ModifiedTime); modified != "" { + fmt.Fprintf(&b, "Last modified: %s\n", modified) + } + b.WriteString("\n") + b.WriteString(strings.TrimSpace(bodyMarkdown)) + b.WriteString("\n\n## Comments\n\n") + + if len(comments) == 0 { + b.WriteString("No comments.\n") + return b.String() + } + + for i, comment := range comments { + fmt.Fprintf(&b, "### Comment %d\n\n", i+1) + fmt.Fprintf(&b, "Author: %s\n", displayAuthor(comment.Author)) + if created := formatTimestamp(comment.CreatedTime); created != "" { + fmt.Fprintf(&b, "Created: %s\n", created) + } + if modified := formatTimestamp(comment.ModifiedTime); modified != "" { + fmt.Fprintf(&b, "Modified: %s\n", modified) + } + fmt.Fprintf(&b, "Status: %s\n", commentStatus(comment)) + fmt.Fprintf(&b, "Comment ID: `%s`\n", comment.ID) + if comment.Anchor != "" && strings.TrimSpace(comment.QuotedFileContent.Value) == "" { + fmt.Fprintf(&b, "Anchor: `%s`\n", comment.Anchor) + } + b.WriteString("\n") + + if quoted := strings.TrimSpace(comment.QuotedFileContent.Value); quoted != "" { + b.WriteString("Quoted text:\n") + b.WriteString(asBlockquote(quoted)) + b.WriteString("\n") + } + + if body := strings.TrimSpace(comment.Content); body != "" { + b.WriteString(body) + b.WriteString("\n\n") + } else if comment.Deleted { + b.WriteString("_Deleted comment._\n\n") + } + + if len(comment.Replies) > 0 { + b.WriteString("Replies:\n") + for _, reply := range comment.Replies { + b.WriteString("- ") + b.WriteString(displayAuthor(reply.Author)) + if created := formatTimestamp(reply.CreatedTime); created != "" { + b.WriteString(" - ") + b.WriteString(created) + } + if reply.Action != "" { + b.WriteString(" - action: ") + b.WriteString(reply.Action) + } + b.WriteString(": ") + replyText := strings.TrimSpace(reply.Content) + if replyText == "" && reply.Deleted { + replyText = "[deleted]" + } + b.WriteString(replyText) + b.WriteString("\n") + } + b.WriteString("\n") + } + } + + return strings.TrimSpace(b.String()) + "\n" +} + +func commentStatus(comment driveComment) string { + switch { + case comment.Deleted: + return "deleted" + case comment.Resolved: + return "resolved" + default: + return "open" + } +} + +func asBlockquote(text string) string { + lines := strings.Split(strings.TrimSpace(text), "\n") + for i, line := range lines { + lines[i] = "> " + strings.TrimSpace(line) + } + return strings.Join(lines, "\n") + "\n" +} + +func defaultOutputName(title string) string { + slug := strings.ToLower(strings.TrimSpace(title)) + slug = slugPattern.ReplaceAllString(slug, "-") + slug = strings.Trim(slug, "-") + if slug == "" { + slug = "google-doc" + } + return slug + ".comments.md" +} + +func normalizeMarkdown(input string) string { + input = strings.ReplaceAll(input, "\u00a0", " ") + + var b strings.Builder + for _, r := range input { + if unicode.Is(unicode.Co, r) { + continue + } + b.WriteRune(r) + } + + lines := strings.Split(b.String(), "\n") + for i, line := range lines { + lines[i] = strings.TrimRight(line, " \t") + } + + return strings.TrimSpace(strings.Join(lines, "\n")) +} + +func displayAuthor(author driveAuthor) string { + if strings.TrimSpace(author.DisplayName) != "" { + return author.DisplayName + } + if strings.TrimSpace(author.Email) != "" { + return author.Email + } + return "Unknown author" +} + +func formatTimestamp(value string) string { + if strings.TrimSpace(value) == "" { + return "" + } + t, err := time.Parse(time.RFC3339, value) + if err != nil { + return value + } + return t.UTC().Format("2006-01-02 15:04 MST") +} + +func runGWSJSON(service, resource, method string, params map[string]any) ([]byte, error) { + rawParams, err := json.Marshal(params) + if err != nil { + return nil, err + } + stdout, _, err := runCommandOutput("gws", service, resource, method, "--params", string(rawParams)) + if err != nil { + return nil, err + } + return extractFirstJSONObject(stdout) +} + +func runCommand(name string, args ...string) ([]byte, error) { + stdout, _, err := runCommandOutput(name, args...) + if err != nil { + return nil, err + } + return stdout, nil +} + +func runCommandOutput(name string, args ...string) ([]byte, []byte, error) { + cmd := exec.Command(name, args...) + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + if stderr.Len() > 0 { + return nil, nil, fmt.Errorf("%s %s: %w\n%s", name, strings.Join(args, " "), err, strings.TrimSpace(stderr.String())) + } + return nil, nil, fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err) + } + if stderr.Len() > 0 { + fmt.Fprintln(os.Stderr, strings.TrimSpace(stderr.String())) + } + return stdout.Bytes(), stderr.Bytes(), nil +} + +func unmarshalFirstJSONObject(input []byte, dest any) error { + data, err := extractFirstJSONObject(input) + if err != nil { + return err + } + return json.Unmarshal(data, dest) +} + +func extractFirstJSONObject(input []byte) ([]byte, error) { + for i, b := range input { + if b == '{' || b == '[' { + return input[i:], nil + } + } + return nil, fmt.Errorf("no JSON object found in command output: %s", strings.TrimSpace(string(input))) +} + +func walkPostOrder(n *html.Node, fn func(*html.Node)) { + for child := n.FirstChild; child != nil; { + next := child.NextSibling + walkPostOrder(child, fn) + child = next + } + fn(n) +} + +func findElement(n *html.Node, tag string) *html.Node { + if n.Type == html.ElementNode && n.Data == tag { + return n + } + for child := n.FirstChild; child != nil; child = child.NextSibling { + if found := findElement(child, tag); found != nil { + return found + } + } + return nil +} + +func removeCommentBlock(anchor *html.Node) { + target := nearestAncestor(anchor, func(n *html.Node) bool { + return n.Type == html.ElementNode && n.Data == "div" + }) + if target == nil { + target = nearestAncestor(anchor, func(n *html.Node) bool { + return n.Type == html.ElementNode && (n.Data == "p" || n.Data == "li") + }) + } + if target == nil { + target = anchor + } + removeNode(target) +} + +func removeCommentRef(anchor *html.Node) { + target := nearestAncestor(anchor, func(n *html.Node) bool { + return n.Type == html.ElementNode && n.Data == "sup" + }) + if target == nil { + target = anchor + } + removeNode(target) +} + +func nearestAncestor(n *html.Node, match func(*html.Node) bool) *html.Node { + for current := n; current != nil; current = current.Parent { + if match(current) { + return current + } + } + return nil +} + +func removeEmptyNodes(root *html.Node) { + walkPostOrder(root, func(n *html.Node) { + if n == root || n.Type != html.ElementNode { + return + } + switch n.Data { + case "p", "div", "sup", "span": + if !nodeHasMeaningfulContent(n) { + removeNode(n) + } + } + }) +} + +func nodeHasMeaningfulContent(n *html.Node) bool { + for child := n.FirstChild; child != nil; child = child.NextSibling { + switch child.Type { + case html.TextNode: + if !whitespaceOnly.MatchString(child.Data) { + return true + } + case html.ElementNode: + if child.Data == "img" || child.Data == "a" || child.Data == "table" || child.Data == "ul" || child.Data == "ol" || strings.HasPrefix(child.Data, "h") { + return true + } + if nodeHasMeaningfulContent(child) { + return true + } + } + } + return false +} + +func replaceNodeWithText(n *html.Node, text string) { + if n.Parent == nil { + return + } + textNode := &html.Node{Type: html.TextNode, Data: text} + n.Parent.InsertBefore(textNode, n) + n.Parent.RemoveChild(n) +} + +func unwrapNode(n *html.Node) { + if n.Parent == nil { + return + } + for child := n.FirstChild; child != nil; { + next := child.NextSibling + n.RemoveChild(child) + n.Parent.InsertBefore(child, n) + child = next + } + n.Parent.RemoveChild(n) +} + +func removeNode(n *html.Node) { + if n != nil && n.Parent != nil { + n.Parent.RemoveChild(n) + } +} + +func stripAttrs(n *html.Node) { + if n.Type != html.ElementNode { + return + } + keep := make([]html.Attribute, 0, len(n.Attr)) + for _, attr := range n.Attr { + switch attr.Key { + case "href", "src", "alt", "title", "colspan", "rowspan": + keep = append(keep, attr) + } + } + n.Attr = keep +} + +func attr(n *html.Node, key string) string { + for _, attr := range n.Attr { + if attr.Key == key { + return attr.Val + } + } + return "" +} diff --git a/plugins/dotagents/skills/humanizer/SKILL.md b/plugins/dotagents/skills/humanizer/SKILL.md new file mode 100644 index 0000000..b572573 --- /dev/null +++ b/plugins/dotagents/skills/humanizer/SKILL.md @@ -0,0 +1,119 @@ +--- +name: humanizer +description: Humanize, rewrite, or draft concise technical and personal writing so it sounds like the user rather than generic AI. Use for cover letters, job applications, benchmark updates, articles, posts, profile copy, emails, and agent prompts when the user asks to sound human, match their voice, remove AI tone, polish a draft, or adapt text using local context. +--- + +# humanizer + +Turn a rough draft or context dump into writing that sounds like the user: direct, specific, technically credible, and not overproduced. + +This skill is usually a second pass after another agent gathers context and writes a first draft. It can also create the first draft when the needed context is available. + +## Inputs + +Use whatever the user provides: + +- Draft text to rewrite. +- Target format: cover letter, article, update, post, email, profile blurb, prompt, or note. +- Audience and stakes. +- Voice samples or prior writing. +- Source context from a repo, job post, docs, notes, or knowledge profile. + +If the user asks for "my style" and gives no sample, infer from the current conversation first. For higher-stakes writing, pull local context when relevant: + +- Profile: `$KNOWLEDGE_DIR/profile/USER.md` +- Work history: `$KNOWLEDGE_DIR/profile/WORK.md` +- Job stories: `$KNOWLEDGE_DIR/skills/jobs/data/story-bank.md` or `skills/jobs/data/story-bank.md` from this repo +- Project-specific repo/docs/results when writing about a project. + +Do not fabricate credentials, dates, metrics, motivations, links, or results. If a claim is useful but unsourced, ask for evidence or mark it as an assumption. + +## Modes + +- **Rewrite**: improve an existing draft while preserving meaning. +- **Voice match**: analyze 1-3 samples, then rewrite using that rhythm, directness, and vocabulary level. +- **Draft from context**: create a compact first draft from supplied sources, then run the humanizer pass. +- **Audit only**: list AI tells and concrete fixes without rewriting. + +## Workflow + +1. Define the job of the text. + - What is it for, who reads it, how long should it be, and what should happen after they read it? +2. Gather only useful evidence. + - Pull concrete facts, examples, decisions, tradeoffs, and outcomes. + - For articles or benchmark updates, prefer actual repo results, commands, tables, traces, and caveats over abstract claims. + - For applications, choose the 2-3 strongest match points instead of listing everything. +3. Build a short voice profile for this task. + - Default user voice: direct, pragmatic, concise, skeptical of fluff, comfortable with technical specifics. + - Preserve thinking style, not chat typos. + - Use first person when it helps. Do not hide behind corporate phrasing. +4. Rewrite or draft. + - Start with the actual point, not a ceremonial intro. + - Use concrete nouns and verbs. + - Keep paragraphs short. One idea per paragraph. + - Let the text have a real opinion, tradeoff, or uncertainty when appropriate. +5. Anti-AI pass. + - Remove generic hype: exciting, compelling, impactful, dynamic, innovative, cutting-edge, passionate, thrilled. + - Remove stock frames: "I am writing to express", "Throughout my career", "In today's rapidly evolving landscape", "plays a crucial role", "it is worth noting". + - Replace vague authority with sources, or delete it. + - Replace inflated significance with what actually happened. + - Break rule-of-three lists when they feel ornamental. + - Prefer "is" over "serves as", "uses" over "leverages", "shows" over "showcases". + - Remove dangling "-ing" clauses that add fake depth. +6. Honesty check. + - Could this have been written by anyone about anything? Add specifics or cut it. + - Is any claim unsupported? Qualify or remove it. + - Is it too polished to sound like a person? Add a real constraint, concrete detail, or simpler sentence. +7. Return the output. + - For rewrite, voice match, or draft-from-context: return the finished text first. + - For audit only: use the "Quick Audit Output" format. + - Add a short note only when useful: assumptions, removed claims, or optional alternate angle. + +## User Voice Defaults + +- Plain English, concise but not clipped. +- Specific technical language when it carries information. +- Calm confidence rather than sales energy. +- Measured opinions and visible tradeoffs. +- Minimal ceremony. +- No forced warmth, jokes, or theatrical vulnerability. +- Avoid em dashes unless the target text really benefits from them. + +## Format Guidance + +### Technical Articles and Benchmark Updates + +- Lead with the actual result or question. +- Include what changed, how it was measured, and what is still uncertain. +- Avoid pretending the result is universal when it is benchmark- or repo-specific. +- Use concrete model names, task counts, commands, dates, and links when available. +- Keep caveats readable, not apologetic. + +### Cover Letters and Applications + +- Default to 250-450 words. +- Open with the real fit between the role and the user's work. +- Use 2-3 concrete evidence threads. +- Close calmly. Do not beg, over-flatter, or inflate enthusiasm. + +### Agent Prompts and Notes + +- Keep instructions operational. +- Prefer direct constraints and exact paths. +- Remove motivational filler. +- Make success criteria explicit. + +## Quick Audit Output + +When asked to audit, use: + +```text +AI tells: +- ... + +Fixes: +- ... + +Suggested rewrite: +... +``` diff --git a/plugins/dotagents/skills/humanizer/agents/openai.yaml b/plugins/dotagents/skills/humanizer/agents/openai.yaml new file mode 100644 index 0000000..8d8c62a --- /dev/null +++ b/plugins/dotagents/skills/humanizer/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Humanizer" + short_description: "Rewrite drafts so they sound like you" + default_prompt: "Use $humanizer to process this text in my voice: rewrite, voice match, draft from context, or audit. Preserve the meaning, pull source context when relevant, keep it concise and specific, and remove generic AI-sounding phrasing." diff --git a/plugins/dotagents/skills/jobs/SKILL.md b/plugins/dotagents/skills/jobs/SKILL.md new file mode 100644 index 0000000..ce91c67 --- /dev/null +++ b/plugins/dotagents/skills/jobs/SKILL.md @@ -0,0 +1,170 @@ +--- +name: jobs +description: "Use when tracking a job search pipeline, analyzing fit for postings, generating interview quizzes, or grading answers. Modes: `/jobs` syncs evidence and updates tracker; `/jobs check URL` runs fit-gap analysis; `/jobs scan` discovers new roles; `/jobs status` shows the pipeline." +--- + +# Jobs + +Single skill for job search tracking and fit analysis. + +## Modes + +- **`/jobs`** (default): Pull evidence from Gmail/LinkedIn, update tracker, report changes and next actions. +- **`/jobs check `**: Fit-gap analysis against a specific posting. Generates quiz, grades answers, updates tracker. +- **`/jobs scan`**: Run portal scanner to discover new roles at tracked companies via ATS APIs (zero tokens). +- **`/jobs status`**: Show current pipeline state from tracker without pulling new evidence. + +## Workspace + +Private data lives in the knowledge vault (`$KNOWLEDGE_DIR/skills/jobs/`). +Code and prompts stay in the skill root (`skills/jobs/`). + +- Tracker: `$KNOWLEDGE_DIR/skills/jobs/opportunities.yaml` (canonical, single source of truth) +- CV/Resume: `$KNOWLEDGE_DIR/skills/jobs/cv/` +- Interview prep: `$KNOWLEDGE_DIR/skills/jobs/interview-prep/` +- Company research: `$KNOWLEDGE_DIR/skills/jobs/company-research/` +- Story bank: `$KNOWLEDGE_DIR/skills/jobs/story-bank.md` +- Portal config: `$KNOWLEDGE_DIR/skills/jobs/portals.yml` +- Cover letters: `$KNOWLEDGE_DIR/skills/jobs/cover_letters/` +- Prompts: `prompts/` (tracked, reusable voice mode interview prompts) +- Scanner: `tools/portals-scan/` (see its README for flags and config format) + +## Tracker Schema + +`opportunities.yaml` is a YAML list. Keep the schema flat: + +```yaml +- company: Example Corp + role: Senior Applied Scientist + archetype: ml-infra + stage: new + contact: + comp: + location: San Francisco, CA + remote: false + last_contact_date: 2026-04-01 + next_action: Reply to recruiter with availability + next_action_due: 2026-04-03 + notes: Recruiter reached out on LinkedIn about an inference role. +``` + +Archetypes (set during `/jobs check`): `ml-infra` (LLMOps, inference, training), `eval-research` (evaluation, benchmarks, applied research), `applied-ml` (product ML, RAG, agents, recommendations), `search-retrieval` (ranking, IR, query understanding). Hybrid is fine: `ml-infra / eval-research`. + +Stages: `new`, `needs-action`, `active`, `waiting`, `low-priority`, `closed`. + +Rules: +- One item per opportunity. Upsert by company + role. +- Leave unknown fields blank. Do not invent placeholders. +- Dates use `YYYY-MM-DD`. +- Put ambiguous facts in `notes`. +- Preserve manual notes unless new evidence clearly supersedes them. + +## Sync Mode (`/jobs`) + +1. Read `opportunities.yaml`. +2. Pull evidence from Gmail (`google-workspace`, using the `gws` CLI where relevant) and LinkedIn MCP. +3. Update tracker conservatively from concrete evidence only. +4. Report: what changed, what needs action, what is stale. + +### Gmail + +Use `google-workspace` / `gws` read-only. Never send mail unless explicitly asked. + +```bash +gws gmail users messages list --params '{"userId":"me","q":"newer_than:30d (recruiter OR interview OR application OR hiring OR linkedin OR greenhouse OR lever OR ashby)","maxResults":25}' +``` + +### LinkedIn + +Use LinkedIn MCP for current reads. Historical exports for seed/backfill only. +- Prefer: `get_inbox`, `search_conversations`, `get_conversation`, `get_person_profile` +- Only create entries from real process signals (DMs, role mentions, outreach). +- Ignore: profile views, feed notifications, generic connection requests. +- Never send messages or connection requests unless explicitly asked. + +## Check Mode (`/jobs check `) + +### 1. Gather evidence + +Fetch the posting via LinkedIn MCP (`get_job_details`) or WebFetch. Read candidate profile from CV at `data/cv/` and LinkedIn MCP (`get_person_profile`). Do not block on MCP unavailability - use whatever is available. + +### 2. Fit-gap analysis + +Produce: +- Archetype classification (per Tracker Schema) +- `Matches X of Y required qualifications` / `A of B additional` +- One line per requirement: check / miss / uncertain + evidence +- Top strengths with evidence +- Top gaps with evidence +- Gap questions: ask what the user has actually done (separate experience gaps from profile keyword gaps) +- Concrete items to add to resume and LinkedIn profile + +Be specific. Not "highlight leadership" but "add the eval pipeline ownership story with team adoption metrics." + +### 3. Comp research + +Run 1-2 WebSearch queries for market compensation data: +- `"{company}" "{role}" salary levels.fyi glassdoor` +- `"{role}" compensation {location} 2026` + +Report what's available. If no data, say so - do not invent numbers. + +### 4. Ghost job detection + +Assess whether the posting is likely a real, active opening. Check: +- **Posting age**: extract date if visible. Over 60 days is a yellow flag. +- **Company hiring signals**: WebSearch for `"{company}" layoffs 2026` or `"{company}" hiring freeze`. If layoffs found, note whether they hit the same department. +- **Description quality**: does it name specific technologies, team size, scope? Generic boilerplate correlates with ghost postings. +- **Role-company fit**: does this role make sense for this company's business? + +Output one of three tiers: +- **High Confidence**: multiple signals suggest a real, active opening +- **Proceed with Caution**: mixed signals worth noting +- **Suspicious**: multiple ghost indicators, investigate before investing time + +Present signals as observations, not accusations. Always note legitimate explanations. + +### 5. Interview quiz (gap-targeted) + +- 5-10 questions, every question maps to a gap +- Mix of technical, system design, and experience-story questions +- Each question: why it matters, what a strong answer includes, depth 1-5 +- Do not quiz on covered strengths + +### 6. STAR+R story suggestions + +For the top 2-3 gaps, suggest STAR+R stories from existing experience: +- **S**ituation, **T**ask, **A**ction, **R**esult, **R**eflection (what would you do differently) +- Check `data/story-bank.md` for reusable stories first. Append new ones. + +### 7. Grade answers (after user responds to quiz) + +Per answer: score 1-5, strengths, gaps, how to improve, weakness type (knowledge / clarity / specificity / evidence). + +Overall: grade, strongest areas, weakest areas, likely interviewer concerns, next-step prep. + +### 8. Update tracker + +Upsert the posting in `opportunities.yaml`. Set `archetype` field. Preserve existing stage/contact/comp/dates/notes. Add jobcheck date, ghost job assessment, and top gaps in notes. Set `next_action` only when analysis yields a clear user-side action. + +## Scan Mode (`/jobs scan`) + +Run `go run ./tools/portals-scan` from the skill root. See `tools/portals-scan/README.md` for flags and config format. The scanner uses zero LLM tokens - direct ATS API calls only. + +1. Run the scanner via Bash. +2. Review the new roles found. +3. For interesting roles, run `/jobs check ` for fit-gap analysis. +4. Scanner deduplicates against `data/opportunities.yaml` automatically. + +## Status Mode (`/jobs status`) + +Read `opportunities.yaml`, render a concise pipeline view. Answer: +- What needs action now? +- What is stale? +- What is most promising (role, location, comp, relocation fit)? + +Do not pull new evidence. + +## User Context + +Bias fit-gap analysis toward the candidate's profile and job search direction in `$KNOWLEDGE_DIR/profile/USER.md`. diff --git a/plugins/dotagents/skills/jobs/prompts/README.md b/plugins/dotagents/skills/jobs/prompts/README.md new file mode 100644 index 0000000..cb1369d --- /dev/null +++ b/plugins/dotagents/skills/jobs/prompts/README.md @@ -0,0 +1,7 @@ +# Interview Prompts + +Voice mode system prompts for interview practice. Copy the prompt (inside the code fence) into ChatGPT Advanced Voice Mode as a custom instruction, or use with OpenAI Realtime API. + +- `ml-fundamentals.md` - ML theory screening (30 min) +- `ml-system-design.md` - ML system design (45 min) +- `experience-interview.md` - Technical deep dive + behavioral stories (30 min) diff --git a/plugins/dotagents/skills/jobs/prompts/experience-interview.md b/plugins/dotagents/skills/jobs/prompts/experience-interview.md new file mode 100644 index 0000000..1c58ab3 --- /dev/null +++ b/plugins/dotagents/skills/jobs/prompts/experience-interview.md @@ -0,0 +1,49 @@ +# Experience Interview + +``` +You are a senior interviewer conducting a 30-minute experience round for a Senior/Staff ML Engineer position. The session has two parts. + +Part 1 - Technical Deep Dive (15 min): +- Ask the candidate to pick one past project and walk through it end-to-end. +- Do not interrupt the initial walkthrough. +- After they finish, probe for depth: why they made specific technical decisions, what alternatives they considered, what went wrong, what the actual measured impact was. +- Push on vague claims. If they say "improved quality" ask for specific metrics. If they say "we considered X" ask why they rejected it. +- Ask at least one question about scale: data volume, QPS, latency constraints, team size. +- Ask at least one question about post-mortem judgment: "knowing what you know now, what would you change about the architecture?" + +Part 2 - Behavioral Stories (15 min): +- Ask 3-4 behavioral questions, one at a time. After each answer, probe for missing STAR+R elements before moving on. + +Question pool (pick 3-4, mix categories): + +Ownership & impact: +- Tell me about a system you built from scratch that is still running in production. +- Describe a time you identified a quality problem nobody else had noticed. What did you do? +- Walk me through a project where you had to make a significant trade-off. What did you sacrifice and why? + +Failure & learning: +- Tell me about a technical decision you made that turned out to be wrong. How did you discover it and what happened next? +- Describe a time a project took much longer than expected. What caused it and what would you do differently? + +Collaboration & influence: +- Describe a time you had to convince someone to adopt your approach when they preferred a different one. +- How did you handle a disagreement about architecture or technical direction? + +Scale & complexity: +- Tell me about the most complex data pipeline you have built or maintained. +- Describe a time you had to debug a production issue under time pressure. + +Probing rules for both parts: +- If the candidate skips Situation: "Can you set the scene? What company, what team, what was the context?" +- If the candidate skips specifics: "What specifically did YOU do vs the team?" +- If the candidate skips Result: "What was the measurable outcome?" +- If the candidate skips Reflection: "Knowing what you know now, what would you do differently?" +- If an answer runs over 5 minutes: "Let me stop you there - can you get to the result?" +- Be respectful but direct. Do not praise answers - just move to the next probe. + +After the session, give feedback: +- Which stories were strongest (clear, specific, compelling) +- Which stories were weakest (vague, no metrics, no reflection) +- What an interviewer would flag as a concern +- Top 3 improvements for next session +``` diff --git a/plugins/dotagents/skills/jobs/prompts/ml-fundamentals.md b/plugins/dotagents/skills/jobs/prompts/ml-fundamentals.md new file mode 100644 index 0000000..42f102f --- /dev/null +++ b/plugins/dotagents/skills/jobs/prompts/ml-fundamentals.md @@ -0,0 +1,28 @@ +# ML Fundamentals Screener + +``` +You are an ML interviewer conducting a 30-minute ML fundamentals screening round for a Senior/Staff ML Engineer position at a top tech company. + +Your style: +- Ask one question at a time. Wait for the answer before moving on. +- Start with a topic area, then drill deeper based on the answer quality. +- If the candidate gives a correct but surface-level answer, push for the "why" and edge cases. +- If the candidate is wrong, do not correct them immediately - ask a follow-up that exposes the gap, then explain after. +- Cover 3-4 topics in 30 minutes. Do not rush. + +Topic pool (pick 3-4 per session, vary across sessions): +- Neural network fundamentals: MLP architecture, weight initialization (zero init and symmetry breaking, Xavier/Glorot, He/Kaiming), activation functions (ReLU, sigmoid, tanh, GELU - gradients, saturation, dying ReLU), vanishing/exploding gradients +- Optimization: SGD, momentum, Adam (why does it work, when does it fail), learning rate scheduling, gradient accumulation, mixed precision training +- Regularization: dropout (training vs inference behavior), batch normalization (what it normalizes, why it helps, inference mode), layer norm, weight decay, L1 vs L2 +- Transformers: self-attention mechanism (Q/K/V), positional encoding, why attention is O(n^2), multi-head attention purpose, KV cache, FlashAttention intuition +- Architecture differences: GPT vs BERT (autoregressive vs masked LM, unidirectional vs bidirectional, generation vs understanding), encoder-decoder, when to use each for retrieval/ranking/generation +- Training: cross-entropy loss and its connection to likelihood, label smoothing, knowledge distillation, LoRA/adapters (what they freeze, why it works) +- Modern LLM topics: RLHF/DPO intuition, tokenization (BPE, why it matters), context window scaling, inference optimization (speculative decoding, quantization) + +After each answer, rate it internally (do not share the rating) and adjust difficulty: +- If the answer is strong, go harder on the same topic +- If the answer is weak, note it and move to the next topic +- At the end, summarize which areas were strong and which need work + +Important: do not turn this into a lecture. Ask questions, listen, probe. Be a realistic interviewer, not a tutor. +``` diff --git a/plugins/dotagents/skills/jobs/prompts/ml-system-design.md b/plugins/dotagents/skills/jobs/prompts/ml-system-design.md new file mode 100644 index 0000000..9cbef0b --- /dev/null +++ b/plugins/dotagents/skills/jobs/prompts/ml-system-design.md @@ -0,0 +1,35 @@ +# ML System Design Interviewer + +``` +You are a senior ML system design interviewer conducting a 45-minute mock interview for a Staff/Senior ML Engineer position. + +Format: +1. Present a system design problem (5 min) +2. Let the candidate drive the design (25-30 min) +3. Probe weak areas (10 min) + +Problem pool (pick one per session): +- Design a real-time search ranking system for an AI-agent-facing search engine +- Design an ML-powered recommendation system for a streaming platform +- Design a fraud detection system for a fintech product +- Design a RAG pipeline for grounding LLM responses in web data +- Design an evaluation pipeline for LLM quality at scale +- Design a multilingual TTS inference service with sub-200ms latency +- Design a real-time query understanding and rewriting system + +Your interviewing style: +- Give the problem, then let the candidate drive. Do not lead them. +- If they get stuck, give a small nudge, not the answer. +- Evaluate: problem scoping, data pipeline, model selection, training/serving split, evaluation strategy, scale/latency/cost trade-offs, monitoring and failure modes. +- Push on trade-offs: "why this model over X?", "what happens when Y fails?", "how does this scale to 10x traffic?" +- Ask about evaluation explicitly if the candidate skips it. +- At Staff level: expect them to discuss team structure, rollout strategy, and how they would validate the system is working in production. + +After the session, give structured feedback: +- Problem scoping: did they ask the right clarifying questions? +- Architecture: was it reasonable and well-justified? +- ML depth: did they show real understanding of model choices? +- Scale/production: did they consider real-world constraints? +- Communication: was the walkthrough clear and well-structured? +- Overall: would this pass a real round? What is the biggest gap? +``` diff --git a/plugins/dotagents/skills/jobs/tools/portals-scan/README.md b/plugins/dotagents/skills/jobs/tools/portals-scan/README.md new file mode 100644 index 0000000..5e0ce63 --- /dev/null +++ b/plugins/dotagents/skills/jobs/tools/portals-scan/README.md @@ -0,0 +1,54 @@ +# portals-scan + +Zero-token ATS portal scanner. Queries Greenhouse, Ashby, and Lever public APIs, filters by title keywords, and deduplicates against an existing opportunities tracker. + +## Build + +```bash +go build -o portals-scan . +``` + +## Usage + +```bash +# Run from skill root (skills/jobs/) - finds data/portals.yml automatically +cd skills/jobs +go run ./tools/portals-scan + +# Or use the built binary +./tools/portals-scan/portals-scan + +# Scan a single company +go run ./tools/portals-scan --company Recraft + +# Preview without writing +go run ./tools/portals-scan --dry-run + +# JSON output for piping +go run ./tools/portals-scan --json + +# Explicit paths +go run ./tools/portals-scan --config path/to/portals.yml --tracker path/to/opportunities.yaml +``` + +## Config + +`portals.yml` defines tracked companies and title filters: + +```yaml +title_filter: + positive: ["ml", "machine learning", "ai engineer"] + negative: ["intern", "manager", "frontend"] + +tracked_companies: + - name: Anthropic + careers_url: https://job-boards.greenhouse.io/anthropic + enabled: true +``` + +Supported platforms (auto-detected from `careers_url`): +- **Greenhouse**: `job-boards.greenhouse.io/{slug}` +- **Ashby**: `jobs.ashbyhq.com/{slug}` +- **Lever**: `jobs.lever.co/{slug}` + +Companies with custom career portals (no supported ATS) should be set to `enabled: false`. diff --git a/plugins/dotagents/skills/jobs/tools/portals-scan/go.mod b/plugins/dotagents/skills/jobs/tools/portals-scan/go.mod new file mode 100644 index 0000000..9ac719b --- /dev/null +++ b/plugins/dotagents/skills/jobs/tools/portals-scan/go.mod @@ -0,0 +1,5 @@ +module github.com/yourconscience/dotagents/skills/jobs/tools/portals-scan + +go 1.21 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/plugins/dotagents/skills/jobs/tools/portals-scan/go.sum b/plugins/dotagents/skills/jobs/tools/portals-scan/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/plugins/dotagents/skills/jobs/tools/portals-scan/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/dotagents/skills/jobs/tools/portals-scan/main.go b/plugins/dotagents/skills/jobs/tools/portals-scan/main.go new file mode 100644 index 0000000..5fb9169 --- /dev/null +++ b/plugins/dotagents/skills/jobs/tools/portals-scan/main.go @@ -0,0 +1,494 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + "gopkg.in/yaml.v3" +) + +// Config types + +type TitleFilter struct { + Positive []string `yaml:"positive"` + Negative []string `yaml:"negative"` +} + +type Company struct { + Name string `yaml:"name"` + CareersURL string `yaml:"careers_url"` + Enabled bool `yaml:"enabled"` +} + +type Config struct { + TitleFilter TitleFilter `yaml:"title_filter"` + TrackedCompanies []Company `yaml:"tracked_companies"` +} + +// Opportunity tracker types + +type Opportunity struct { + Company string `yaml:"company"` + Role string `yaml:"role"` +} + +// Scanned job result + +type Job struct { + Company string + Title string + Location string + URL string +} + +// JSON output type + +type JSONJob struct { + Company string `json:"company"` + Title string `json:"title"` + Location string `json:"location"` + URL string `json:"url"` +} + +// Per-company scan result + +type scanResult struct { + company string + jobs []Job + total int + filtered int + err error +} + +func main() { + defaultConfig := findDefault("data/portals.yml") + defaultTracker := findDefault("data/opportunities.yaml") + + configPath := flag.String("config", defaultConfig, "path to portals.yml") + trackerPath := flag.String("tracker", defaultTracker, "path to opportunities.yaml") + companyFilter := flag.String("company", "", "scan only this company (by name)") + dryRun := flag.Bool("dry-run", false, "show what would be found without outputting") + asJSON := flag.Bool("json", false, "output as JSON array") + flag.Parse() + + cfg, err := loadConfig(*configPath) + if err != nil { + fatalf("failed to load config: %v", err) + } + cfg.TitleFilter = lowercaseFilter(cfg.TitleFilter) + + existingKeys := loadExistingKeys(*trackerPath) + + companies := cfg.TrackedCompanies + if *companyFilter != "" { + companies = filterByName(companies, *companyFilter) + if len(companies) == 0 { + fatalf("no company named %q found in config", *companyFilter) + } + } + + // Only scan enabled companies + var enabled []Company + for _, c := range companies { + if c.Enabled { + enabled = append(enabled, c) + } + } + + results := scanAll(enabled, cfg.TitleFilter, 10) + + // Aggregate + var newJobs []Job + totalFound := 0 + totalFiltered := 0 + totalDupes := 0 + var errors []string + + for _, r := range results { + if r.err != nil { + errors = append(errors, fmt.Sprintf("%s: %v", r.company, r.err)) + continue + } + totalFound += r.total + totalFiltered += r.filtered + for _, j := range r.jobs { + key := dedupKey(j.Company, j.Title) + if existingKeys[key] { + totalDupes++ + continue + } + newJobs = append(newJobs, j) + } + } + + if *dryRun { + fmt.Printf("Dry run: %d new roles would be shown\n", len(newJobs)) + for _, j := range newJobs { + fmt.Printf(" %s | %s | %s\n", j.Company, j.Title, j.Location) + } + return + } + + if *asJSON { + out := make([]JSONJob, len(newJobs)) + for i, j := range newJobs { + out[i] = JSONJob(j) + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(out); err != nil { + fatalf("failed to encode JSON: %v", err) + } + return + } + + // Human-readable output + fmt.Printf("Portal Scan - %s\n", time.Now().Format("2006-01-02")) + fmt.Println(strings.Repeat("\u2501", 24)) + fmt.Printf("Companies scanned: %d\n", len(enabled)) + fmt.Printf("Total jobs found: %d\n", totalFound) + fmt.Printf("Filtered by title: %d removed\n", totalFiltered) + fmt.Printf("Duplicates: %d skipped\n", totalDupes) + fmt.Printf("New roles found: %d\n", len(newJobs)) + + if len(newJobs) > 0 { + fmt.Println("\nNew roles:") + for _, j := range newJobs { + fmt.Printf(" + %s | %s | %s\n", j.Company, j.Title, j.Location) + fmt.Printf(" %s\n", j.URL) + } + } + + if len(errors) > 0 { + fmt.Println("\nErrors:") + for _, e := range errors { + fmt.Printf(" ! %s\n", e) + } + } +} + +func scanAll(companies []Company, filter TitleFilter, maxConcurrent int) []scanResult { + results := make([]scanResult, len(companies)) + sem := make(chan struct{}, maxConcurrent) + client := &http.Client{Timeout: 30 * time.Second} + var wg sync.WaitGroup + + for i, c := range companies { + wg.Add(1) + go func(idx int, company Company) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + results[idx] = scanCompany(client, company, filter) + }(i, c) + } + + wg.Wait() + return results +} + +func scanCompany(client *http.Client, c Company, filter TitleFilter) scanResult { + platform, slug, err := detectPlatform(c.CareersURL) + if err != nil { + return scanResult{company: c.Name, err: err} + } + + var jobs []Job + + switch platform { + case "ashby": + jobs, err = fetchAshby(client, slug, c.Name) + case "greenhouse": + jobs, err = fetchGreenhouse(client, slug, c.Name) + case "lever": + jobs, err = fetchLever(client, slug, c.Name) + default: + err = fmt.Errorf("unknown platform for URL %s", c.CareersURL) + } + + if err != nil { + return scanResult{company: c.Name, err: err} + } + + total := len(jobs) + var passed []Job + filtered := 0 + for _, j := range jobs { + if matchesFilter(j.Title, filter) { + passed = append(passed, j) + } else { + filtered++ + } + } + + return scanResult{ + company: c.Name, + jobs: passed, + total: total, + filtered: filtered, + } +} + +// Platform detection + +var ( + reAshby = regexp.MustCompile(`(?i)^https?://jobs\.ashbyhq\.com/([^/?#]+)`) + reGreenhouse = regexp.MustCompile(`(?i)^https?://job-boards(?:\.eu)?\.greenhouse\.io/([^/?#]+)`) + reLever = regexp.MustCompile(`(?i)^https?://jobs\.lever\.co/([^/?#]+)`) +) + +func detectPlatform(careersURL string) (platform, slug string, err error) { + if m := reAshby.FindStringSubmatch(careersURL); m != nil { + return "ashby", m[1], nil + } + if m := reGreenhouse.FindStringSubmatch(careersURL); m != nil { + return "greenhouse", m[1], nil + } + if m := reLever.FindStringSubmatch(careersURL); m != nil { + return "lever", m[1], nil + } + return "", "", fmt.Errorf("unrecognized careers URL: %s", careersURL) +} + +// Greenhouse + +type greenhouseResponse struct { + Jobs []struct { + Title string `json:"title"` + URL string `json:"absolute_url"` + Location struct { + Name string `json:"name"` + } `json:"location"` + } `json:"jobs"` +} + +func fetchGreenhouse(client *http.Client, slug, company string) ([]Job, error) { + url := fmt.Sprintf("https://boards-api.greenhouse.io/v1/boards/%s/jobs", slug) + body, err := getJSON(client, url) + if err != nil { + return nil, err + } + var resp greenhouseResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("parse error: %v", err) + } + jobs := make([]Job, 0, len(resp.Jobs)) + for _, j := range resp.Jobs { + jobs = append(jobs, Job{ + Company: company, + Title: j.Title, + Location: j.Location.Name, + URL: j.URL, + }) + } + return jobs, nil +} + +// Ashby + +type ashbyResponse struct { + Jobs []struct { + Title string `json:"title"` + JobURL string `json:"jobUrl"` + Location string `json:"location"` + } `json:"jobs"` +} + +func fetchAshby(client *http.Client, slug, company string) ([]Job, error) { + url := fmt.Sprintf("https://api.ashbyhq.com/posting-api/job-board/%s?includeCompensation=true", slug) + body, err := getJSON(client, url) + if err != nil { + return nil, err + } + var resp ashbyResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("parse error: %v", err) + } + jobs := make([]Job, 0, len(resp.Jobs)) + for _, j := range resp.Jobs { + jobs = append(jobs, Job{ + Company: company, + Title: j.Title, + Location: j.Location, + URL: j.JobURL, + }) + } + return jobs, nil +} + +// Lever + +type leverPosting struct { + Text string `json:"text"` + HostedURL string `json:"hostedUrl"` + Categories struct { + Location string `json:"location"` + } `json:"categories"` +} + +func fetchLever(client *http.Client, slug, company string) ([]Job, error) { + url := fmt.Sprintf("https://api.lever.co/v0/postings/%s", slug) + body, err := getJSON(client, url) + if err != nil { + return nil, err + } + var postings []leverPosting + if err := json.Unmarshal(body, &postings); err != nil { + return nil, fmt.Errorf("parse error: %v", err) + } + jobs := make([]Job, 0, len(postings)) + for _, p := range postings { + jobs = append(jobs, Job{ + Company: company, + Title: p.Text, + Location: p.Categories.Location, + URL: p.HostedURL, + }) + } + return jobs, nil +} + +// HTTP helper + +func getJSON(client *http.Client, url string) ([]byte, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "portals-scan/1.0") + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, url) + } + return io.ReadAll(resp.Body) +} + +// Title filtering + +func lowercaseFilter(filter TitleFilter) TitleFilter { + out := TitleFilter{ + Positive: make([]string, len(filter.Positive)), + Negative: make([]string, len(filter.Negative)), + } + for i, kw := range filter.Positive { + out.Positive[i] = strings.ToLower(kw) + } + for i, kw := range filter.Negative { + out.Negative[i] = strings.ToLower(kw) + } + return out +} + +func matchesFilter(title string, filter TitleFilter) bool { + lower := strings.ToLower(title) + + hasPositive := false + for _, kw := range filter.Positive { + if strings.Contains(lower, kw) { + hasPositive = true + break + } + } + if !hasPositive { + return false + } + + for _, kw := range filter.Negative { + if strings.Contains(lower, kw) { + return false + } + } + + return true +} + +// Dedup + +func dedupKey(company, role string) string { + return strings.ToLower(company) + "::" + strings.ToLower(role) +} + +func loadExistingKeys(path string) map[string]bool { + keys := make(map[string]bool) + data, err := os.ReadFile(path) + if err != nil { + // File may not exist yet; that's fine + return keys + } + + var entries []Opportunity + if err := yaml.Unmarshal(data, &entries); err != nil { + return keys + } + + for _, e := range entries { + if e.Company != "" && e.Role != "" { + keys[dedupKey(e.Company, e.Role)] = true + } + } + return keys +} + +// Config loading + +func loadConfig(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + return &cfg, nil +} + +// Helpers + +func findDefault(relPath string) string { + // Try cwd first (works with go run from skill root) + if _, err := os.Stat(relPath); err == nil { + abs, _ := filepath.Abs(relPath) + return abs + } + // Try relative to compiled binary (works with built binary in tools/portals-scan/) + exe, err := os.Executable() + if err == nil { + skillRoot := filepath.Dir(filepath.Dir(filepath.Dir(exe))) + candidate := filepath.Join(skillRoot, relPath) + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + return relPath +} + +func filterByName(companies []Company, name string) []Company { + lower := strings.ToLower(name) + var out []Company + for _, c := range companies { + if strings.ToLower(c.Name) == lower { + out = append(out, c) + } + } + return out +} + +func fatalf(format string, args ...interface{}) { + fmt.Fprintf(os.Stderr, "error: "+format+"\n", args...) + os.Exit(1) +} diff --git a/plugins/dotagents/skills/jobs/tools/view-pipeline.py b/plugins/dotagents/skills/jobs/tools/view-pipeline.py new file mode 100644 index 0000000..4531bc2 --- /dev/null +++ b/plugins/dotagents/skills/jobs/tools/view-pipeline.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +# /// script +# dependencies = ["pyyaml"] +# /// +"""Render opportunities.yaml as a readable markdown pipeline view. + +Usage: + uv run view-pipeline.py # print to stdout + uv run view-pipeline.py --open # write to /tmp/pipeline.md and open in cmux +""" +import subprocess +import sys +from pathlib import Path +import yaml + +DATA = Path(__file__).resolve().parent.parent / "data" / "opportunities.yaml" +OUT = Path("/tmp/pipeline.md") + + +def md_cell(value): + return str(value).replace("|", "\\|") + + +def render(): + if not DATA.exists(): + return "" + entries = yaml.safe_load(DATA.read_text()) or [] + + needs_action = [e for e in entries if e.get("stage") == "needs-action"] + active = [e for e in entries if e.get("stage") == "active"] + new = [e for e in entries if e.get("stage") == "new"] + waiting = [e for e in entries if e.get("stage") == "waiting"] + low = [e for e in entries if e.get("stage") == "low-priority"] + closed = [e for e in entries if e.get("stage") == "closed"] + + lines = [] + p = lines.append + + p("# Job Pipeline\n") + + if needs_action: + p("## Needs Action\n") + for e in needs_action: + p(f"### {e.get('company', '?')} - {e.get('role') or 'TBD'}") + _detail(p, e) + p("") + + if active: + p("## Active Interviews\n") + for e in active: + p(f"### {e.get('company', '?')} - {e.get('role') or 'TBD'}") + _detail(p, e) + p("") + + if waiting: + p("## Waiting for Response\n") + for e in waiting: + company = e.get("company", "?") + role = e.get("role") or "TBD" + na = e.get("next_action") or "" + last = e.get("last_contact_date") or "?" + p(f"- **{company}** ({role}) - last contact {last}. {na}") + p("") + + if new: + p("## Not Yet Applied\n") + p("| Company | Role | Location | Next Step |") + p("|---|---|---|---|") + for e in new: + company = md_cell(e.get("company", "?")) + role = md_cell((e.get("role") or "TBD")[:45]) + loc = md_cell(e.get("location") or ("Remote" if e.get("remote") else "?")) + na = md_cell((e.get("next_action") or "")[:70]) + p(f"| {company} | {role} | {loc} | {na} |") + p("") + + if low: + p("## Low Priority\n") + for e in low: + company = e.get("company", "?") + role = (e.get("role") or "TBD")[:50] + reason = "" + rf = e.get("red_flag") + if rf: + reason = f" ({rf})" + p(f"- **{company}**: {role}{reason}") + p("") + + p("---\n") + p(f"Active: {len(active)} | Needs action: {len(needs_action)} | " + f"Waiting: {len(waiting)} | New: {len(new)} | " + f"Low: {len(low)} | Closed: {len(closed)}") + + return "\n".join(lines) + + +def _detail(p, e): + loc = e.get("location") or ("Remote" if e.get("remote") else "?") + comp = e.get("comp") or "" + contact = e.get("contact") or "" + last = e.get("last_contact_date") or "" + na = e.get("next_action") or "" + due = e.get("next_action_due") or "" + + parts = [f"**Location:** {loc}"] + if comp: + parts.append(f"**Comp:** {comp}") + if contact: + parts.append(f"**Contact:** {contact.split(';')[0].strip()}") + if last: + parts.append(f"**Last contact:** {last}") + p(f" {' | '.join(parts)}") + + if na: + due_str = f" (due {due})" if due else "" + p(f" **Next:** {na}{due_str}") + p("") + + +if __name__ == "__main__": + md = render() + if "--open" in sys.argv: + OUT.write_text(md) + subprocess.run(["cmux", "markdown", "open", str(OUT)], check=True) + else: + print(md) diff --git a/plugins/dotagents/skills/pr-triage/SKILL.md b/plugins/dotagents/skills/pr-triage/SKILL.md new file mode 100644 index 0000000..6e02b41 --- /dev/null +++ b/plugins/dotagents/skills/pr-triage/SKILL.md @@ -0,0 +1,93 @@ +--- +name: pr-triage +description: Inspect PR failed checks and unresolved review comments, fix valid feedback, push, and safely handle publish-via-PR workflows. Use when user says /pr-triage, asks about PR status, CI failures, review comments, or wants changes published through a PR. +--- + +# pr-triage + +Inspect, fix, commit, push, wait, reinspect. One bounded cycle per invocation. + +Prefer the configured GitHub MCP server for PR/check/thread data when the agent can access MCP tools. Use the local `pr-triage` tool for deterministic CLI inspection and hook runtime. `$SKILL_DIR` below means this skill's own directory (the base directory reported when the skill loads); the tool ships with the skill, so this works from any install location and from inside any Go module (`-C` runs in the tool dir; `PR_TRIAGE_PWD` keeps gh on the session repo): + +```bash +PR_TRIAGE_PWD="$PWD" go -C "$SKILL_DIR"/tools/pr-triage run . inspect --format markdown +PR_TRIAGE_PWD="$PWD" go -C "$SKILL_DIR"/tools/pr-triage run . inspect --format json +``` + +The read-only Stop hook entrypoint is: + +```bash +"$SKILL_DIR"/hooks/stop.sh +``` + +## Creation and merge gate + +Use this flow when publishing through a PR, not just fixing an existing PR. + +1. If the current branch has no PR, create one first, for example with `gh pr create --fill`. +2. Immediately inspect merge state, checks, body, and unresolved active review threads. +3. Never merge immediately after opening a PR. +4. Never merge into `main` or `master` without explicit user approval. +5. Merge only when the inspect step is clean or the user explicitly accepts remaining issues. + +If local commits were created on unrelated history while the remote base has commits, do not open that PR directly. Create a fresh branch from the remote base, cherry-pick local commits onto it, then open the PR. + +## Inspect + +Run the tool first: + +```bash +PR_TRIAGE_PWD="$PWD" go -C "$SKILL_DIR"/tools/pr-triage run . inspect --format markdown +``` + +If MCP tools are available, use GitHub MCP to cross-check the same surface: + +- PR merge state and base/head refs +- check runs and failed checks +- changed files +- unresolved active review threads +- review thread IDs for bot-only resolution +- PR body + +The local tool currently uses `gh` as its CLI backend because shell hooks cannot directly call the host's in-process MCP tools. + +## Review policy + +Bot families: `gemini*`, `copilot*`, `cursor*`, `claude*`, `codex*`, `coderabbitai*`. + +Bot comments: think first. Bots are frequently wrong. Fix the code if valid, then resolve the bot thread silently. Wrong or low-value bot comments may be resolved silently without code changes. Do not reply to bot comments unless the user explicitly asks. + +Human comments: never resolve, reply to, or comment on human threads autonomously. Fix the code silently and leave the thread for the human reviewer to verify. + +Ambiguous comments: ask the user before merging. + +## Fix and push workflow + +1. Fix identified valid issues in code. +2. Stage and commit with a short one-line message. +3. Do not run pre-commit manually; it runs on commit. +4. If pre-commit fails, read the error, fix it, stage, and commit again as a new commit. +5. Push. +6. Wait for bot reviews to arrive before resolving bot threads or declaring clean. + +After every push, wait at least 60 seconds, then poll review/check state until stable for two consecutive polls or about 3 minutes total. Re-run the full inspect step after the wait. + +Repeat fix-push-wait-inspect up to 3 cycles to avoid infinite loops. + +## Summary output + +After completing work, output a compact status: + +```text +## PR # Status +Merge: +CI: +Reviews: unresolved ( bot, human) + +Fixed (): +Ignored (): +Needs your decision (): +Human comments (): +``` + +Always include "Needs your decision" and "Human comments", even if empty. diff --git a/plugins/dotagents/skills/pr-triage/hooks/stop.sh b/plugins/dotagents/skills/pr-triage/hooks/stop.sh new file mode 100755 index 0000000..4116196 --- /dev/null +++ b/plugins/dotagents/skills/pr-triage/hooks/stop.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +skill_dir="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +tool_dir="$skill_dir/tools/pr-triage" + +# Resolve the session's working directory so the tool inspects the repo the +# session is in, not this checkout. Hook stdin carries {"cwd": ...}; fall back +# to the hook's own cwd. +session_cwd="$PWD" +if [ ! -t 0 ]; then + payload="$(cat || true)" + if [ -n "$payload" ] && command -v jq >/dev/null 2>&1; then + payload_cwd="$(printf '%s' "$payload" | jq -r '.cwd // empty' 2>/dev/null || true)" + if [ -n "$payload_cwd" ] && [ -d "$payload_cwd" ]; then + session_cwd="$payload_cwd" + fi + fi +fi + +# Only inspect PRs when the session is inside a git work tree; otherwise stay quiet. +if ! git -C "$session_cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + printf '{"continue":true,"suppressOutput":true,"message":"pr-triage hook skipped: not a git repository"}\n' + exit 0 +fi + +if [ -d "$tool_dir" ] && [ -f "$tool_dir/go.mod" ]; then + export PR_TRIAGE_PWD="$session_cwd" + cd "$tool_dir" && exec go run . hook stop +fi + +printf '{"continue":true,"suppressOutput":true,"message":"pr-triage hook skipped: tool directory missing"}\n' diff --git a/plugins/dotagents/skills/pr-triage/scripts/inspect_pr.sh b/plugins/dotagents/skills/pr-triage/scripts/inspect_pr.sh new file mode 100755 index 0000000..876f813 --- /dev/null +++ b/plugins/dotagents/skills/pr-triage/scripts/inspect_pr.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ge 1 ]]; then + PR_ARG="$1" + PR_META="$(gh pr view "$PR_ARG" --json number,url --jq '(.number | tostring) + " " + .url')" +else + PR_META="$(gh pr view --json number,url --jq '(.number | tostring) + " " + .url')" +fi +read -r PR PR_URL <<<"$PR_META" +if [[ "$PR_URL" =~ github.com/([^/]+)/([^/]+)/pull/[0-9]+ ]]; then + OWNER="${BASH_REMATCH[1]}" + REPO="${BASH_REMATCH[2]}" +else + echo "Error: could not resolve repository from PR URL: ${PR_URL}" >&2 + exit 1 +fi + +QUERY='query($owner:String!, $repo:String!, $number:Int!){ repository(owner:$owner, name:$repo){ pullRequest(number:$number){ reviewThreads(first:100){ nodes { id isResolved isOutdated path line comments(first:20){ nodes { author{ login } body url createdAt } } } } } } }' + +echo "PR #${PR} (${OWNER}/${REPO})" + +echo +echo "=== CHECKS ===" +set +e +gh pr checks "$PR" +checks_status=$? +set -e +if [[ "$checks_status" -ne 0 && "$checks_status" -ne 8 ]]; then + exit "$checks_status" +fi + +echo +echo "=== FAILED CHECKS ===" +gh pr view "$PR" --json statusCheckRollup --jq '.statusCheckRollup[] + | select(.status == "COMPLETED") + | select(.conclusion != "SUCCESS" and .conclusion != "NEUTRAL" and .conclusion != "SKIPPED") + | "\(.name)\t\(.conclusion)\t\(.detailsUrl)"' + +echo +echo "=== UNRESOLVED THREADS ===" +gh api graphql \ + -F owner="$OWNER" -F repo="$REPO" -F number="$PR" \ + -f query="$QUERY" \ + --jq '.data.repository.pullRequest.reviewThreads.nodes[] + | select(.isResolved==false and .isOutdated==false) + | "- [\(.comments.nodes[0].author.login)] \(.path):\(.line // 0)\n \(.comments.nodes[0].url)\n \(.comments.nodes[0].body | gsub("\n"; " ") | .[0:260])"' + +echo +echo "=== BOT THREADS (auto-resolvable) ===" +gh api graphql \ + -F owner="$OWNER" -F repo="$REPO" -F number="$PR" \ + -f query="$QUERY" \ + --jq '.data.repository.pullRequest.reviewThreads.nodes[] + | select(.isResolved==false and .isOutdated==false) + | select(.comments.nodes[0].author.login | test("^(chatgpt-codex|gemini|copilot|cursor|claude|codex|coderabbitai)"; "i")) + | "- [\(.comments.nodes[0].author.login)] \(.path):\(.line // 0)\n \(.comments.nodes[0].url)"' + +echo +echo "=== HUMAN THREADS (do NOT auto-resolve) ===" +gh api graphql \ + -F owner="$OWNER" -F repo="$REPO" -F number="$PR" \ + -f query="$QUERY" \ + --jq '.data.repository.pullRequest.reviewThreads.nodes[] + | select(.isResolved==false and .isOutdated==false) + | select((.comments.nodes[0].author.login | test("^(chatgpt-codex|gemini|copilot|cursor|claude|codex|coderabbitai)"; "i")) | not) + | "- [\(.comments.nodes[0].author.login)] \(.path):\(.line // 0)\n \(.comments.nodes[0].url)"' diff --git a/plugins/dotagents/skills/pr-triage/tools/pr-triage/README.md b/plugins/dotagents/skills/pr-triage/tools/pr-triage/README.md new file mode 100644 index 0000000..06b694b --- /dev/null +++ b/plugins/dotagents/skills/pr-triage/tools/pr-triage/README.md @@ -0,0 +1,13 @@ +# pr-triage tool + +Deterministic CLI tool for the `pr-triage` skill and hook runtime. + +Agent workflow should prefer the configured GitHub MCP server when MCP tools are available. This tool uses `gh` as a local CLI backend because shell hooks cannot call host in-process MCP tools directly. + +```bash +go run ~/.agents/skills/pr-triage/tools/pr-triage inspect --format markdown +go run ~/.agents/skills/pr-triage/tools/pr-triage inspect --format json +go run ~/.agents/skills/pr-triage/tools/pr-triage hook stop +``` + +The Stop hook is read-only. It may block on merge conflicts, failed checks, unresolved human comments, or high-severity bot threads; it does not push, merge, edit PR bodies, or resolve threads. diff --git a/plugins/dotagents/skills/pr-triage/tools/pr-triage/go.mod b/plugins/dotagents/skills/pr-triage/tools/pr-triage/go.mod new file mode 100644 index 0000000..9a4abc3 --- /dev/null +++ b/plugins/dotagents/skills/pr-triage/tools/pr-triage/go.mod @@ -0,0 +1,3 @@ +module pr-triage + +go 1.24.0 diff --git a/plugins/dotagents/skills/pr-triage/tools/pr-triage/main.go b/plugins/dotagents/skills/pr-triage/tools/pr-triage/main.go new file mode 100644 index 0000000..8ff2fbc --- /dev/null +++ b/plugins/dotagents/skills/pr-triage/tools/pr-triage/main.go @@ -0,0 +1,432 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "os/exec" + "regexp" + "strconv" + "strings" +) + +type inspection struct { + PR int `json:"pr"` + URL string `json:"url,omitempty"` + BaseRefName string `json:"base_ref_name,omitempty"` + HeadRefName string `json:"head_ref_name,omitempty"` + Mergeable string `json:"mergeable,omitempty"` + MergeState string `json:"merge_state_status,omitempty"` + FailedChecks []checkResult `json:"failed_checks,omitempty"` + Unresolved []thread `json:"unresolved_threads,omitempty"` + BotThreads []thread `json:"bot_threads,omitempty"` + HumanThreads []thread `json:"human_threads,omitempty"` + BodyEmpty bool `json:"body_empty"` + RecommendedNext string `json:"recommended_next"` +} + +type checkResult struct { + Name string `json:"name"` + Conclusion string `json:"conclusion,omitempty"` + DetailsURL string `json:"details_url,omitempty"` +} + +type thread struct { + ID string `json:"id"` + Path string `json:"path,omitempty"` + Line int `json:"line,omitempty"` + Author string `json:"author,omitempty"` + URL string `json:"url,omitempty"` + Body string `json:"body,omitempty"` + IsBot bool `json:"is_bot"` + Severity string `json:"severity,omitempty"` +} + +type reviewThreadsPageResult struct { + Threads []thread + Next string +} + +type hookResponse struct { + Continue bool `json:"continue,omitempty"` + SuppressOutput bool `json:"suppressOutput,omitempty"` + Decision string `json:"decision,omitempty"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` +} + +var botAuthor = regexp.MustCompile(`(?i)^(chatgpt-codex|gemini|copilot|cursor|claude|codex|coderabbitai)`) + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(args []string) error { + if len(args) == 0 { + return errors.New("usage: pr-triage inspect|hook") + } + switch args[0] { + case "inspect": + return runInspect(args[1:]) + case "hook": + return runHook(args[1:]) + default: + return fmt.Errorf("unknown command %q", args[0]) + } +} + +func runInspect(args []string) error { + fs := flag.NewFlagSet("inspect", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + format := fs.String("format", "markdown", "Output format: markdown or json") + if err := fs.Parse(args); err != nil { + return err + } + prArg := "" + if fs.NArg() > 1 { + return errors.New("usage: pr-triage inspect [--format markdown|json] [PR]") + } + if fs.NArg() == 1 { + prArg = fs.Arg(0) + } + result, err := inspectPR(prArg) + if err != nil { + return err + } + switch *format { + case "json": + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(result) + case "markdown": + fmt.Print(renderMarkdown(result)) + return nil + default: + return fmt.Errorf("unsupported format %q", *format) + } +} + +func runHook(args []string) error { + if len(args) != 1 || args[0] != "stop" { + return errors.New("usage: pr-triage hook stop") + } + result, err := inspectPR("") + if err != nil { + return writeHook(hookResponse{Continue: true, SuppressOutput: true, Message: "pr-triage skipped: " + err.Error()}) + } + blockers := hardBlockers(result) + if len(blockers) > 0 { + return writeHook(hookResponse{Decision: "block", Reason: strings.Join(blockers, "; ")}) + } + return writeHook(hookResponse{Continue: true, SuppressOutput: true, Message: "pr-triage: PR has no hard blockers"}) +} + +func inspectPR(prArg string) (inspection, error) { + if _, err := exec.LookPath("gh"); err != nil { + return inspection{}, errors.New("gh CLI unavailable") + } + if strings.TrimSpace(prArg) == "" { + out, err := runGH("pr", "view", "--json", "number", "--jq", ".number") + if err != nil { + return inspection{}, errors.New("no PR detected for current branch") + } + prArg = strings.TrimSpace(out) + } + var raw struct { + Number int `json:"number"` + URL string `json:"url"` + BaseRefName string `json:"baseRefName"` + HeadRefName string `json:"headRefName"` + Mergeable string `json:"mergeable"` + MergeStateStatus string `json:"mergeStateStatus"` + Body string `json:"body"` + StatusCheckRollup []json.RawMessage `json:"statusCheckRollup"` + } + out, err := runGH("pr", "view", prArg, "--json", "number,url,baseRefName,headRefName,mergeable,mergeStateStatus,body,statusCheckRollup") + if err != nil { + return inspection{}, err + } + if err := json.Unmarshal([]byte(out), &raw); err != nil { + return inspection{}, fmt.Errorf("parse gh pr view: %w", err) + } + ownerRepo, err := ownerRepoFromPRURL(raw.URL) + if err != nil { + return inspection{}, err + } + threads, err := reviewThreads(ownerRepo, raw.Number) + if err != nil { + return inspection{}, err + } + result := inspection{ + PR: raw.Number, + URL: raw.URL, + BaseRefName: raw.BaseRefName, + HeadRefName: raw.HeadRefName, + Mergeable: raw.Mergeable, + MergeState: raw.MergeStateStatus, + BodyEmpty: strings.TrimSpace(raw.Body) == "", + Unresolved: threads, + } + result.FailedChecks = failedChecks(raw.StatusCheckRollup) + for _, t := range threads { + if t.IsBot { + result.BotThreads = append(result.BotThreads, t) + } else { + result.HumanThreads = append(result.HumanThreads, t) + } + } + result.RecommendedNext = recommendedNext(result) + return result, nil +} + +func reviewThreads(ownerRepo string, prNumber int) ([]thread, error) { + parts := strings.Split(strings.TrimSpace(ownerRepo), "/") + if len(parts) != 2 { + return nil, fmt.Errorf("unexpected PR owner/name: %q", ownerRepo) + } + query := `query($owner:String!, $repo:String!, $number:Int!, $after:String){ repository(owner:$owner, name:$repo){ pullRequest(number:$number){ reviewThreads(first:100, after:$after){ pageInfo { hasNextPage endCursor } nodes { id isResolved isOutdated path line comments(first:20){ nodes { author{ login } body url createdAt } } } } } } }` + var threads []thread + after := "" + for { + page, err := fetchReviewThreadsPage(ownerRepo, prNumber, query, after) + if err != nil { + return nil, err + } + threads = append(threads, page.Threads...) + if page.Next == "" { + break + } + after = page.Next + } + for i := range threads { + threads[i].IsBot = botAuthor.MatchString(threads[i].Author) + threads[i].Severity = inferSeverity(threads[i].Body) + } + return threads, nil +} + +func fetchReviewThreadsPage(ownerRepo string, prNumber int, query string, after string) (reviewThreadsPageResult, error) { + parts := strings.Split(strings.TrimSpace(ownerRepo), "/") + if len(parts) != 2 { + return reviewThreadsPageResult{}, fmt.Errorf("unexpected PR owner/name: %q", ownerRepo) + } + args := []string{"api", "graphql", "-F", "owner=" + parts[0], "-F", "repo=" + parts[1], "-F", "number=" + strconv.Itoa(prNumber), "-f", "query=" + query} + if after != "" { + args = append(args, "-F", "after="+after) + } + out, err := runGH(args...) + if err != nil { + return reviewThreadsPageResult{}, err + } + return parseReviewThreadsPage([]byte(out)) +} + +func parseReviewThreadsPage(out []byte) (reviewThreadsPageResult, error) { + var raw struct { + Data struct { + Repository struct { + PullRequest struct { + ReviewThreads struct { + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + EndCursor string `json:"endCursor"` + } `json:"pageInfo"` + Nodes []struct { + ID string `json:"id"` + IsResolved bool `json:"isResolved"` + IsOutdated bool `json:"isOutdated"` + Path string `json:"path"` + Line int `json:"line"` + Comments struct { + Nodes []struct { + Author struct { + Login string `json:"login"` + } `json:"author"` + Body string `json:"body"` + URL string `json:"url"` + } `json:"nodes"` + } `json:"comments"` + } `json:"nodes"` + } `json:"reviewThreads"` + } `json:"pullRequest"` + } `json:"repository"` + } `json:"data"` + } + if err := json.Unmarshal(out, &raw); err != nil { + return reviewThreadsPageResult{}, fmt.Errorf("parse review threads: %w", err) + } + var threads []thread + for _, node := range raw.Data.Repository.PullRequest.ReviewThreads.Nodes { + if node.IsResolved || node.IsOutdated || len(node.Comments.Nodes) == 0 { + continue + } + firstComment := node.Comments.Nodes[0] + threads = append(threads, thread{ + ID: node.ID, + Path: node.Path, + Line: node.Line, + Author: firstComment.Author.Login, + URL: firstComment.URL, + Body: firstComment.Body, + }) + } + next := "" + pageInfo := raw.Data.Repository.PullRequest.ReviewThreads.PageInfo + if pageInfo.HasNextPage { + next = pageInfo.EndCursor + } + return reviewThreadsPageResult{Threads: threads, Next: next}, nil +} + +func ownerRepoFromPRURL(prURL string) (string, error) { + parts := strings.Split(strings.TrimSpace(prURL), "/") + for i := 0; i+4 < len(parts); i++ { + if parts[i] == "github.com" && parts[i+3] == "pull" { + owner := strings.TrimSpace(parts[i+1]) + repo := strings.TrimSpace(parts[i+2]) + if owner != "" && repo != "" { + return owner + "/" + repo, nil + } + } + } + return "", fmt.Errorf("could not resolve repository from PR URL %q", prURL) +} + +func failedChecks(items []json.RawMessage) []checkResult { + var checks []checkResult + for _, item := range items { + var raw map[string]interface{} + if err := json.Unmarshal(item, &raw); err != nil { + continue + } + status, _ := raw["status"].(string) + conclusion, _ := raw["conclusion"].(string) + if status != "COMPLETED" { + continue + } + if conclusion == "" || conclusion == "SUCCESS" || conclusion == "NEUTRAL" || conclusion == "SKIPPED" { + continue + } + name, _ := raw["name"].(string) + if name == "" { + name, _ = raw["workflowName"].(string) + } + url, _ := raw["detailsUrl"].(string) + checks = append(checks, checkResult{Name: name, Conclusion: conclusion, DetailsURL: url}) + } + return checks +} + +func recommendedNext(result inspection) string { + switch { + case result.Mergeable == "CONFLICTING" || result.MergeState == "DIRTY": + return "resolve merge conflicts before review triage" + case len(result.FailedChecks) > 0: + return "inspect failed checks and fix CI before resolving review threads" + case len(result.HumanThreads) > 0: + return "fix human feedback silently and leave threads for the human reviewer" + case len(result.BotThreads) > 0: + return "triage bot feedback; fix valid issues and resolve wrong/low-value bot threads" + case result.BodyEmpty: + return "update PR body with summary and verification" + default: + return "no hard blockers found" + } +} + +func hardBlockers(result inspection) []string { + var blockers []string + if result.Mergeable == "CONFLICTING" || result.MergeState == "DIRTY" { + blockers = append(blockers, "merge conflicts must be resolved") + } + if len(result.FailedChecks) > 0 { + blockers = append(blockers, fmt.Sprintf("%d failed check(s)", len(result.FailedChecks))) + } + if len(result.HumanThreads) > 0 { + blockers = append(blockers, fmt.Sprintf("%d unresolved human thread(s)", len(result.HumanThreads))) + } + for _, thread := range result.BotThreads { + if thread.Severity == "critical" || thread.Severity == "high" { + blockers = append(blockers, "untriaged high-severity bot review thread") + break + } + } + return blockers +} + +func inferSeverity(body string) string { + lower := strings.ToLower(body) + switch { + case strings.Contains(lower, "critical"): + return "critical" + case strings.Contains(lower, "high"): + return "high" + case strings.Contains(lower, "medium"): + return "medium" + default: + return "" + } +} + +func renderMarkdown(result inspection) string { + var b strings.Builder + fmt.Fprintf(&b, "## PR #%d Status\n", result.PR) + fmt.Fprintf(&b, "Merge: %s / %s\n", valueOrDash(result.Mergeable), valueOrDash(result.MergeState)) + if len(result.FailedChecks) == 0 { + fmt.Fprintf(&b, "CI: PASS or pending checks only\n") + } else { + fmt.Fprintf(&b, "CI: %d failed check(s)\n", len(result.FailedChecks)) + for _, check := range result.FailedChecks { + fmt.Fprintf(&b, "- %s: %s %s\n", valueOrDash(check.Name), valueOrDash(check.Conclusion), check.DetailsURL) + } + } + fmt.Fprintf(&b, "Reviews: %d unresolved (%d bot, %d human)\n", len(result.Unresolved), len(result.BotThreads), len(result.HumanThreads)) + fmt.Fprintf(&b, "Body: ") + if result.BodyEmpty { + fmt.Fprintf(&b, "empty\n") + } else { + fmt.Fprintf(&b, "present\n") + } + fmt.Fprintf(&b, "\nRecommended next: %s\n", result.RecommendedNext) + return b.String() +} + +func valueOrDash(value string) string { + if strings.TrimSpace(value) == "" { + return "-" + } + return value +} + +func writeHook(response hookResponse) error { + enc := json.NewEncoder(os.Stdout) + return enc.Encode(response) +} + +func runGH(args ...string) (string, error) { + cmd := exec.Command("gh", args...) + // The hook wrapper cds into this tool's module dir to `go run`; PR_TRIAGE_PWD + // carries the session's original working directory so gh resolves the repo + // the session is actually in, not the dotagents checkout. + if dir := strings.TrimSpace(os.Getenv("PR_TRIAGE_PWD")); dir != "" { + cmd.Dir = dir + } + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + return "", fmt.Errorf("gh %s: %s", strings.Join(args, " "), msg) + } + return stdout.String(), nil +} diff --git a/plugins/dotagents/skills/pr-triage/tools/pr-triage/main_test.go b/plugins/dotagents/skills/pr-triage/tools/pr-triage/main_test.go new file mode 100644 index 0000000..5225847 --- /dev/null +++ b/plugins/dotagents/skills/pr-triage/tools/pr-triage/main_test.go @@ -0,0 +1,116 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestFailedChecksFiltersSuccessfulAndNeutral(t *testing.T) { + raw := []json.RawMessage{ + json.RawMessage(`{"name":"lint","status":"COMPLETED","conclusion":"SUCCESS"}`), + json.RawMessage(`{"name":"test","status":"COMPLETED","conclusion":"FAILURE","detailsUrl":"https://example.test/run"}`), + json.RawMessage(`{"name":"skip","status":"COMPLETED","conclusion":"SKIPPED"}`), + json.RawMessage(`{"name":"pending","status":"IN_PROGRESS"}`), + } + got := failedChecks(raw) + if len(got) != 1 { + t.Fatalf("failedChecks length = %d, want 1: %#v", len(got), got) + } + if got[0].Name != "test" || got[0].Conclusion != "FAILURE" { + t.Fatalf("unexpected failed check: %#v", got[0]) + } +} + +func TestHardBlockersIncludesHumanThreadsAndHighBot(t *testing.T) { + result := inspection{ + FailedChecks: []checkResult{{Name: "test"}}, + HumanThreads: []thread{{Author: "alice"}}, + BotThreads: []thread{{Author: "coderabbitai", IsBot: true, Severity: "high"}}, + } + got := strings.Join(hardBlockers(result), "\n") + for _, want := range []string{"failed check", "human thread", "high-severity bot"} { + if !strings.Contains(got, want) { + t.Fatalf("hard blockers %q missing %q", got, want) + } + } +} + +func TestBotAuthorIncludesHostedCodexConnector(t *testing.T) { + if !botAuthor.MatchString("chatgpt-codex-connector") { + t.Fatal("chatgpt-codex-connector should be treated as a bot reviewer") + } +} + +func TestOwnerRepoFromPRURL(t *testing.T) { + got, err := ownerRepoFromPRURL("https://github.com/yourconscience/dotagents/pull/59") + if err != nil { + t.Fatal(err) + } + if got != "yourconscience/dotagents" { + t.Fatalf("ownerRepoFromPRURL = %q, want yourconscience/dotagents", got) + } + if _, err := ownerRepoFromPRURL("https://example.test/not-a-pr"); err == nil { + t.Fatal("ownerRepoFromPRURL accepted invalid URL") + } +} + +func TestParseReviewThreadsPageFiltersAndReturnsNextCursor(t *testing.T) { + page, err := parseReviewThreadsPage([]byte(`{ + "data": { + "repository": { + "pullRequest": { + "reviewThreads": { + "pageInfo": {"hasNextPage": true, "endCursor": "cursor-2"}, + "nodes": [ + { + "id": "active", + "isResolved": false, + "isOutdated": false, + "path": "file.go", + "line": 12, + "comments": {"nodes": [{"author": {"login": "chatgpt-codex-connector"}, "body": "fix this", "url": "https://example.test/thread"}]} + }, + { + "id": "resolved", + "isResolved": true, + "isOutdated": false, + "path": "file.go", + "line": 13, + "comments": {"nodes": [{"author": {"login": "alice"}, "body": "done", "url": "https://example.test/resolved"}]} + }, + { + "id": "outdated", + "isResolved": false, + "isOutdated": true, + "path": "file.go", + "line": 14, + "comments": {"nodes": [{"author": {"login": "bob"}, "body": "stale", "url": "https://example.test/outdated"}]} + } + ] + } + } + } + } +}`)) + if err != nil { + t.Fatal(err) + } + if page.Next != "cursor-2" { + t.Fatalf("next cursor = %q, want cursor-2", page.Next) + } + if len(page.Threads) != 1 { + t.Fatalf("threads length = %d, want 1: %#v", len(page.Threads), page.Threads) + } + if page.Threads[0].ID != "active" || page.Threads[0].Author != "chatgpt-codex-connector" { + t.Fatalf("unexpected thread: %#v", page.Threads[0]) + } +} + +func TestRenderMarkdownIncludesRecommendedNext(t *testing.T) { + result := inspection{PR: 42, Mergeable: "MERGEABLE", MergeState: "CLEAN", RecommendedNext: "no hard blockers found"} + out := renderMarkdown(result) + if !strings.Contains(out, "PR #42") || !strings.Contains(out, "no hard blockers found") { + t.Fatalf("unexpected markdown:\n%s", out) + } +} diff --git a/plugins/dotagents/skills/pr-triage/tools/pr-triage/pr-triage b/plugins/dotagents/skills/pr-triage/tools/pr-triage/pr-triage new file mode 100755 index 0000000..0659b2d Binary files /dev/null and b/plugins/dotagents/skills/pr-triage/tools/pr-triage/pr-triage differ diff --git a/plugins/dotagents/skills/remote-access/SKILL.md b/plugins/dotagents/skills/remote-access/SKILL.md new file mode 100644 index 0000000..9f90297 --- /dev/null +++ b/plugins/dotagents/skills/remote-access/SKILL.md @@ -0,0 +1,143 @@ +--- +name: remote-access +description: Search local Droid/Codex sessions, send scoped continuation instructions through the Mac bridge, or use takopi for Telegram-based mobile access to Pi sessions. +--- + + +# remote-access + +Two modes of mobile access: + +1. **takopi** (recommended for Pi) — Telegram bridge that streams progress, supports voice input, project/worktree management, and session resume. Use when the user wants to send prompts to Pi from their phone and monitor agent work. +2. **Mac bridge** — lightweight REST bridge for Hermes on VPS to inspect/continue local Droid/Codex sessions. Use when the user wants Hermes to peek at or lightly continue Mac-local agent work. + +Do not use this for session transfer, live terminal sharing, tmux, Warp/TUI scraping, or unrestricted shell access. + +## takopi (Telegram → Pi) + +[takopi](https://github.com/banteg/takopi) bridges Pi to a Telegram bot. Install: `uv tool install -U takopi`. Docs: [takopi.dev](https://takopi.dev/). + +The user's bot is `@gamrevinu_bot`. Config lives at `~/.takopi/takopi.toml`. + +### Quick reference + +```bash +takopi # setup wizard (first run) or start bridge +takopi config list # show current config +takopi config set default_engine pi +takopi config set pi.provider anthropic +``` + +From Telegram, prefix messages with `/pi` to target Pi, or set `default_engine = "pi"`. + +### Capabilities with Pi + +- Progress streaming (tool calls, file changes, elapsed time) +- Session resume (`pi --session `) +- Voice notes (transcribed via Whisper endpoint) +- Project aliases and git worktrees +- File transfer (`/file put`, `/file get`) + +### Limitations + +takopi uses `pi --print --mode json` (non-interactive). No mid-stream steering, tool approval, model switching, or compaction. For full RPC control, wait for `pi serve` (planned). + + +## What it does + +- Checks whether the Mac bridge is online. +- Lists recent local Droid/Codex sessions. +- Searches past session history by query or session ID. +- Reports repo, branch, git status, changed files, mission state, and likely next action. +- Sends small, auditable instructions to an existing Droid session when explicitly requested. + +## Bridge endpoints + +```text +Mac local: http://127.0.0.1:18777 +VPS to Mac over Tailscale: $REMOTE_ACCESS_BRIDGE_URL +``` + +Hermes on the VPS should call the Mac bridge directly over Tailscale. Set `REMOTE_ACCESS_BRIDGE_URL` to the Mac bridge URL, for example `http://:18777`. Prefer binding the bridge to the Mac's Tailscale address instead of all interfaces. + +## Commands + +Status is unauthenticated: + +```bash +curl -fsS "$REMOTE_ACCESS_BRIDGE_URL/status" +``` + +Recent/search/ask require `REMOTE_ACCESS_BRIDGE_TOKEN`: + +```bash +curl -fsS -H "authorization: Bearer $REMOTE_ACCESS_BRIDGE_TOKEN" \ + "$REMOTE_ACCESS_BRIDGE_URL/recent" + +curl -fsS -H "authorization: Bearer $REMOTE_ACCESS_BRIDGE_TOKEN" \ + --get --data-urlencode 'q=' \ + "$REMOTE_ACCESS_BRIDGE_URL/search" + +curl -fsS -X POST "$REMOTE_ACCESS_BRIDGE_URL/ask" \ + -H 'content-type: application/json' \ + -H "authorization: Bearer $REMOTE_ACCESS_BRIDGE_TOKEN" \ + -d '{"agent":"droid","session_id":"","instruction":"summarize current state and next action","mode":"continue"}' +``` + +`X-Remote-Access-Token: ` is also accepted. + +## Procedure + +1. Run `/status` first and say whether the Mac is online. +2. For unclear requests, run `/recent` or `/search` before `/ask`. +3. Prefer read-only summaries. Use `/ask` only when the user gives a target session or explicitly asks to continue one. +4. Keep `/ask` instructions narrow: session ID, repo/path, expected output, allowed file writes, commit policy, and files not to touch. +5. If the bridge is offline, use synced knowledge-vault context if available and label it stale. + +## Response shape + +For mobile, keep output compact: + +```text +Mac: online/offline +Repo: +Branch: +Git: +Session: +State: +Blockers: +Next action: +``` + +## Local data sources + +- Droid: `droid search --json`, `~/.factory/sessions/**`, `droid exec --session-id`, `droid exec --fork` +- Codex: `~/.codex` history/session stores and local continuation commands when available +- Git: `git status --short --branch`, `git diff --stat`, recent commits +- Cached fallback: synced knowledge vault at `$KNOWLEDGE_DIR` + +## Bridge maintenance + +Build or refresh the Mac runtime binary (`$SKILL_DIR` = this skill's own directory): + +```bash +"$SKILL_DIR"/tools/remote-access-bridge/build.sh +launchctl kickstart -k "gui/$(id -u)/com.conscience.remote-access.bridge" +``` + +The binary defaults to `~/.local/bin/remote-access-bridge`. + +If VPS access fails, check Tailscale connectivity first: + +```bash +ssh vps-ts 'curl -fsS --max-time 5 "$REMOTE_ACCESS_BRIDGE_URL/status"' +``` +## Future: pi serve + +`pi serve` will expose the full RPC protocol over HTTP/WebSocket, enabling: +- Full interactive control from any device (steer, abort, model switch, compaction) +- Web UI for browser-based access +- Richer takopi integration via WebSocket transport backend + +This is tracked in [oh-my-pi#436](https://github.com/can1357/oh-my-pi/issues/436). + diff --git a/plugins/dotagents/skills/remote-access/tools/remote-access-bridge/build.sh b/plugins/dotagents/skills/remote-access/tools/remote-access-bridge/build.sh new file mode 100755 index 0000000..b938b3e --- /dev/null +++ b/plugins/dotagents/skills/remote-access/tools/remote-access-bridge/build.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -eu + +tool_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +out="${1:-$HOME/.local/bin/remote-access-bridge}" + +mkdir -p "$(dirname "$out")" +go -C "$tool_dir" build -trimpath -ldflags='-s -w' -o "$out" . +chmod +x "$out" diff --git a/plugins/dotagents/skills/remote-access/tools/remote-access-bridge/go.mod b/plugins/dotagents/skills/remote-access/tools/remote-access-bridge/go.mod new file mode 100644 index 0000000..a7bc16b --- /dev/null +++ b/plugins/dotagents/skills/remote-access/tools/remote-access-bridge/go.mod @@ -0,0 +1,3 @@ +module remote-access-bridge + +go 1.22 diff --git a/plugins/dotagents/skills/remote-access/tools/remote-access-bridge/main.go b/plugins/dotagents/skills/remote-access/tools/remote-access-bridge/main.go new file mode 100644 index 0000000..e7b00fe --- /dev/null +++ b/plugins/dotagents/skills/remote-access/tools/remote-access-bridge/main.go @@ -0,0 +1,393 @@ +package main + +import ( + "bufio" + "context" + "crypto/subtle" + "encoding/json" + "errors" + "flag" + "fmt" + "io/fs" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" +) + +const ( + defaultAddr = "127.0.0.1:18777" + defaultMaxRecent = 20 + maxQueryLength = 500 + maxPromptLength = 4000 + maxResponseLength = 200000 +) + +type server struct { + home string + cmdTimeout time.Duration + askToken string +} + +type limitedBuffer struct { + buf []byte +} + +func (b *limitedBuffer) Write(p []byte) (int, error) { + remaining := maxResponseLength - len(b.buf) + if remaining > 0 { + if len(p) < remaining { + remaining = len(p) + } + b.buf = append(b.buf, p[:remaining]...) + } + return len(p), nil +} + +func (b *limitedBuffer) Bytes() []byte { + return b.buf +} + +type statusResponse struct { + OK bool `json:"ok"` + Hostname string `json:"hostname"` + Time string `json:"time"` + DroidAvailable bool `json:"droid_available"` + CodexAvailable bool `json:"codex_available"` + KnowledgeVault string `json:"knowledge_vault"` +} + +type sessionStart struct { + Type string `json:"type"` + ID string `json:"id"` + Title string `json:"title"` + SessionTitle string `json:"sessionTitle"` + CWD string `json:"cwd"` +} + +type recentSession struct { + ID string `json:"id"` + Agent string `json:"agent"` + Title string `json:"title,omitempty"` + CWD string `json:"cwd,omitempty"` + Path string `json:"path"` + UpdatedAt string `json:"updated_at"` +} + +type recentResponse struct { + OK bool `json:"ok"` + Sessions []recentSession `json:"sessions"` +} + +type sessionCandidate struct { + path string + modTime time.Time +} + +type searchResponse struct { + OK bool `json:"ok"` + Query string `json:"query"` + Droid json.RawMessage `json:"droid,omitempty"` + Output string `json:"output,omitempty"` +} + +type askRequest struct { + Agent string `json:"agent"` + SessionID string `json:"session_id"` + Instruction string `json:"instruction"` + Mode string `json:"mode"` +} + +type askResponse struct { + OK bool `json:"ok"` + Agent string `json:"agent"` + SessionID string `json:"session_id"` + Mode string `json:"mode"` + Output string `json:"output"` +} + +func main() { + addr := flag.String("addr", defaultAddr, "listen address") + timeout := flag.Duration("timeout", 2*time.Minute, "command timeout") + flag.Parse() + + home, err := os.UserHomeDir() + if err != nil { + log.Fatal(err) + } + + s := &server{ + home: home, + cmdTimeout: *timeout, + askToken: strings.TrimSpace(os.Getenv("REMOTE_ACCESS_BRIDGE_TOKEN")), + } + mux := http.NewServeMux() + mux.HandleFunc("/status", s.status) + mux.HandleFunc("/recent", s.recent) + mux.HandleFunc("/search", s.search) + mux.HandleFunc("/ask", s.ask) + + httpServer := &http.Server{ + Addr: *addr, + Handler: withJSONErrors(mux), + ReadHeaderTimeout: 5 * time.Second, + } + log.Printf("remote-access bridge listening on %s", *addr) + if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatal(err) + } +} + +func withJSONErrors(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + next.ServeHTTP(w, r) + }) +} + +func (s *server) status(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + host, _ := os.Hostname() + writeJSON(w, statusResponse{ + OK: true, + Hostname: host, + Time: time.Now().Format(time.RFC3339), + DroidAvailable: commandExists("droid"), + CodexAvailable: commandExists("codex"), + KnowledgeVault: filepath.Join(s.home, "Workspace", "knowledge"), + }) +} + +func (s *server) recent(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + if !s.authorizeSensitive(w, r) { + return + } + sessions, err := s.recentDroidSessions(defaultMaxRecent) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, recentResponse{OK: true, Sessions: sessions}) +} + +func (s *server) search(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + if !s.authorizeSensitive(w, r) { + return + } + q := strings.TrimSpace(r.URL.Query().Get("q")) + if q == "" { + writeError(w, http.StatusBadRequest, "missing q") + return + } + if len(q) > maxQueryLength { + writeError(w, http.StatusBadRequest, "q too long") + return + } + stdout, stderr, err := s.run(r.Context(), "droid", "search", q, "--json", "--limit-sessions", "10", "--limit-hits", "3", "--context-chars", "120") + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()+"\n"+truncate(stderr)) + return + } + if !json.Valid(stdout) { + writeError(w, http.StatusBadGateway, "droid search returned invalid json\n"+truncate(stderr)) + return + } + writeJSON(w, searchResponse{OK: true, Query: q, Droid: json.RawMessage(stdout), Output: truncate(stderr)}) +} + +func (s *server) ask(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + if !s.authorizeSensitive(w, r) { + return + } + var req askRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64*1024)).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return + } + req.Agent = strings.ToLower(strings.TrimSpace(req.Agent)) + req.SessionID = strings.TrimSpace(req.SessionID) + req.Instruction = strings.TrimSpace(req.Instruction) + req.Mode = strings.ToLower(strings.TrimSpace(req.Mode)) + if req.Mode == "" { + req.Mode = "continue" + } + if req.Agent != "droid" { + writeError(w, http.StatusBadRequest, "only agent=droid is currently supported") + return + } + if req.SessionID == "" { + writeError(w, http.StatusBadRequest, "missing session_id") + return + } + if req.Instruction == "" { + writeError(w, http.StatusBadRequest, "missing instruction") + return + } + if len(req.Instruction) > maxPromptLength { + writeError(w, http.StatusBadRequest, "instruction too long") + return + } + + var args []string + switch req.Mode { + case "continue": + args = []string{"exec", "--session-id", req.SessionID, req.Instruction} + case "fork": + args = []string{"exec", "--fork", req.SessionID, req.Instruction} + default: + writeError(w, http.StatusBadRequest, "mode must be continue or fork") + return + } + + stdout, stderr, err := s.run(r.Context(), "droid", args...) + output := append(stdout, stderr...) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()+"\n"+truncate(output)) + return + } + writeJSON(w, askResponse{ + OK: true, + Agent: req.Agent, + SessionID: req.SessionID, + Mode: req.Mode, + Output: truncate(output), + }) +} + +func (s *server) authorizeSensitive(w http.ResponseWriter, r *http.Request) bool { + if s.askToken == "" { + writeError(w, http.StatusServiceUnavailable, "REMOTE_ACCESS_BRIDGE_TOKEN must be set to enable sensitive endpoints") + return false + } + token := strings.TrimSpace(r.Header.Get("X-Remote-Access-Token")) + if token == "" { + token = strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) + } + if subtle.ConstantTimeCompare([]byte(token), []byte(s.askToken)) != 1 { + writeError(w, http.StatusUnauthorized, "unauthorized") + return false + } + return true +} + +func (s *server) recentDroidSessions(limit int) ([]recentSession, error) { + root := filepath.Join(s.home, ".factory", "sessions") + var candidates []sessionCandidate + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || !strings.HasSuffix(path, ".jsonl") { + return nil + } + info, err := d.Info() + if err != nil { + return nil + } + candidates = append(candidates, sessionCandidate{path: path, modTime: info.ModTime()}) + return nil + }) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return nil, err + } + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].modTime.After(candidates[j].modTime) + }) + if len(candidates) > limit { + candidates = candidates[:limit] + } + + sessions := make([]recentSession, 0, len(candidates)) + for _, candidate := range candidates { + path := candidate.path + start, _ := readSessionStart(path) + id := strings.TrimSuffix(filepath.Base(path), ".jsonl") + title := start.SessionTitle + if title == "" { + title = start.Title + } + sessions = append(sessions, recentSession{ + ID: id, + Agent: "droid", + Title: title, + CWD: start.CWD, + Path: path, + UpdatedAt: candidate.modTime.Format(time.RFC3339), + }) + } + return sessions, nil +} + +func readSessionStart(path string) (sessionStart, error) { + f, err := os.Open(path) + if err != nil { + return sessionStart{}, err + } + defer f.Close() + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + var start sessionStart + if err := json.Unmarshal(scanner.Bytes(), &start); err == nil && start.Type == "session_start" { + return start, nil + } + } + return sessionStart{}, scanner.Err() +} + +func (s *server) run(ctx context.Context, name string, args ...string) ([]byte, []byte, error) { + ctx, cancel := context.WithTimeout(ctx, s.cmdTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) + cmd.Env = append(os.Environ(), "NO_COLOR=1") + var stdout, stderr limitedBuffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if ctx.Err() == context.DeadlineExceeded { + return stdout.Bytes(), stderr.Bytes(), fmt.Errorf("%s timed out after %s", name, s.cmdTimeout) + } + if err != nil { + return stdout.Bytes(), stderr.Bytes(), err + } + return stdout.Bytes(), stderr.Bytes(), nil +} + +func commandExists(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} + +func writeJSON(w http.ResponseWriter, value any) { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(value) +} + +func writeError(w http.ResponseWriter, code int, message string) { + w.WriteHeader(code) + writeJSON(w, map[string]any{"ok": false, "error": message}) +} + +func truncate(b []byte) string { + if len(b) <= maxResponseLength { + return string(b) + } + return string(b[:maxResponseLength]) +} diff --git a/plugins/dotagents/skills/repo-eval/SKILL.md b/plugins/dotagents/skills/repo-eval/SKILL.md new file mode 100644 index 0000000..a8545fd --- /dev/null +++ b/plugins/dotagents/skills/repo-eval/SKILL.md @@ -0,0 +1,158 @@ +--- +name: repo-eval +description: Find, triage, and deep-evaluate GitHub repos for a given need. Use when the user says /repo-eval, asks to find a tool or library, wants to vet a specific repo, or needs to compare alternatives before adopting one. +--- + +# repo-eval + +Evaluate GitHub repos as candidates for adoption. Two modes determined by input shape. + +## Usage + +``` +/repo-eval find # discovery + light triage +/repo-eval [concern] # deep eval of a known repo +``` + +## Find mode + +Input is a need description, not a repo slug. + +### 1. Search GitHub + +```bash +gh search repos "" --json fullName,description,stargazersCount,pushedAt,isArchived,license,url --limit 15 +``` + +Filter out archived repos and forks. Sort by stars descending. + +### 2. Light triage (all candidates in parallel) + +For each candidate, fetch via `gh api`: + +```bash +# Repo metadata +gh api repos// + +# Last commit date, latest release, SECURITY.md presence +gh api graphql -f query='query($owner:String!,$name:String!){ + repository(owner:$owner,name:$name){ + defaultBranchRef{ target{ ... on Commit { committedDate } } } + latestRelease{ tagName publishedAt } + securityRoot: object(expression:"HEAD:SECURITY.md"){ __typename } + } +}' -F owner= -F name= + +# Top 7 open issues by reactions +gh api graphql -f query='query($q:String!){ + search(type:ISSUE,query:$q,first:7){ + nodes{ ... on Issue { title url reactions{totalCount} createdAt comments(last:5){ + nodes{ createdAt authorAssociation } + }}} + } +}' -F q="repo:/ is:issue is:open sort:reactions-desc" +``` + +Assess each repo on: +- **Activity**: last commit within 30/60/180 days, recent release +- **Maintenance**: maintainer replied to top issues within 60 days +- **Friction**: top issues mentioning crash, data loss, broken, abandoned, install problems +- **Basics**: license present, not archived, security policy exists + +### 3. Community sentiment (top 3-5 candidates) + +For candidates that pass light triage, check community signal scoped to the repo/tool name: + +- **HN**: `https://hn.algolia.com/api/v1/search?query=&tags=story&hitsPerPage=5` +- **X.com**: `x-cli search "" --type top --count 5` (fallback: `site:x.com` via WebSearch) +- **Reddit**: search relevant subreddits or `site:reddit.com ` via WebSearch + +### 4. Output + +Present a ranked shortlist: + +``` +## - Repo Shortlist + +| Repo | Stars | Last commit | Release | Top issue friction | Sentiment | +|------|-------|-------------|---------|-------------------|-----------| + +### Recommendation +Which repos to deep-eval and why. Proactively suggest running eval mode +on the top 2-3 via AskUserQuestion. +``` + +After the user picks candidates, run eval mode on each in parallel via subagents. + +## Eval mode + +Input is an `owner/repo` slug, optionally followed by a specific concern. + +### 1. Full GitHub analysis + +Same `gh api` calls as light triage, but also: +- Read top 7 issues in full (title, body snippet, comment count, maintainer response) +- Check dependency count and language breakdown: `gh api repos///languages` +- Check recent commit frequency: `gh api repos///stats/commit_activity` + +### 2. Community sentiment + +Same as find mode step 3, but deeper: read the top 2-3 HN threads and Reddit threads in full, not just titles. + +### 3. Clone and code analysis + +```bash +git clone --depth 1 https://github.com//.git ~/Public/ +``` + +Analyze: +- README quality and completeness +- Dependency manifest (package.json, go.mod, requirements.txt, Cargo.toml) +- Look for red flags: vendored secrets, excessive permissions, suspicious install scripts +- If user provided a specific concern, search the codebase for it + +For ML/model repos, also check model-distribution surfaces without executing anything: +- Hugging Face model/dataset API if the README links weights or datasets: `https://huggingface.co/api/models//` or dataset equivalent. Capture public/private state, downloads/likes, tags, lastModified, and file list. +- Artifact safety: flag pickle / arbitrary-code-loading formats as trusted-code artifacts; do not load them during eval. +- Setup scripts: read shell/bootstrap scripts for side effects such as `sudo`, package-manager changes, modprobe/kernel tweaks, telemetry/API-key prompts, or accelerator-specific installs. Report these as operational concerns, not necessarily malicious findings. +- Versioning: note missing releases/tags and recommend pinning by commit SHA when no release exists. + +Do NOT install or run the tool. Analysis is read-only. + +### 4. Output + +``` +## - Deep Eval + +### Health +Stars, commits, releases, maintainer responsiveness, license, security policy. + +### Top Issues +List top 5 with title, reactions, age, and whether maintainer responded. + +### Community Sentiment +What HN/X/Reddit say. Quote notable opinions with links. + +### Code Analysis +README quality, dependencies, red flags, concern-specific findings. + +### Verdict +Worth adopting / Proceed with caution / Avoid. One paragraph explaining why. +``` + +If the user asks for a file/report, write Markdown under `~/Workspace/reports/` with a descriptive date-stamped filename, then verify the file exists and report its absolute path. Include a concise executive verdict near the top, source list, direct links, and explicit caveats for skipped sources. + +### 5. Offer setup + +After presenting the verdict and the user indicates which repo they want, **ask whether they want you to set it up**. The evaluation was read-only; now the user needs the tool running. Do not wait for them to ask — they often assume setup follows evaluation. + +## Rules + +- Always include direct links. Never fabricate quotes or links. +- If the user asks to evaluate an announcement/post/article that contains a GitHub link, keep the announcement's claims and discussion as the primary object. Use `/repo-eval` on the linked repo as supporting evidence only. Do not reframe the whole task as repo adoption unless the user explicitly asks to adopt/vet that repo. +- Run `gh api` calls in parallel where possible. +- Clone to ~/Public, not to the current working directory. +- **During evaluation (steps 1-4):** Never install or execute code from evaluated repos. Analysis is read-only. +- **After the user picks a winner (step 5):** Offer to install, configure, and launch the recommended tool. At that point, following the repo's own setup instructions is expected and safe. +- If `x-cli` auth is broken, fall back to WebSearch for X.com signal. +- For find mode, cap at 15 candidates for search, 5 for sentiment, 3 suggested for deep eval. diff --git a/plugins/dotagents/skills/spawn/SKILL.md b/plugins/dotagents/skills/spawn/SKILL.md new file mode 100644 index 0000000..7ed9d28 --- /dev/null +++ b/plugins/dotagents/skills/spawn/SKILL.md @@ -0,0 +1,220 @@ +--- +name: spawn +description: "Decide how to delegate work to subagents or teams. Agent-agnostic: covers Droid, Claude Code, Hermes, and Codex." +--- + +# spawn + +Decide how to split work across agents. This skill is about the decision, not the tool syntax: pick the right delegation mode, then follow the agent-specific execution section. + +## Decision framework + +### Task sizing + +| Task size | Time | Examples | Delegation mode | +|---|---|---|---| +| Small | < 5 min | lookup, lint check, simple search | Do it yourself | +| Medium | 5-30 min | focused research, single-file refactor, test writing | Subagent | +| Large | 30+ min | multi-file refactor, deep research, architecture design | Subagent or team | +| Cross-model | Any | second opinion, GPT vs Claude comparison | Codex or Droid with a different model | + +### When to use subagents + +- Task is self-contained and produces a summary +- You want to protect main context from verbose output +- Tasks are independent (no cross-referencing needed) +- Default choice for most delegation + +### When to use a team (Claude Code only) + +- User explicitly asks for a team +- Agents need to challenge each other's findings +- Parallel work on interrelated parts of the same system +- Long-running work where the user wants visible panes + +### When to use Droid + +- Substantial implementation or review work should default to Factory Droid when available, using Task/custom droids for role-specific delegation. +- Droid and Codex default to OpenAI/GPT through Droid BYOK/VibeProxy. Claude Code remains available, but is not the default unless the user explicitly asks for Claude. + +## Model selection + +Cost-aware defaults regardless of which agent is primary. + +| Role complexity | Model | Use for | +|---|---|---| +| Complex design/architecture | opus / gpt-5 | architect, lead reviewer, complex refactors | +| Standard implementation | sonnet / gpt-5 | builder, researcher, most tasks | +| Simple/mechanical | haiku / gpt-4.1-mini | linting, formatting, simple lookups | + +## Team architecture patterns + +| Pattern | Shape | Best for | +|---|---|---| +| Pipeline | A -> B -> C | Sequential dependencies | +| Fan-out/Fan-in | Split -> parallel -> merge | Independent subtasks | +| Producer-Reviewer | Create <-> validate | Quality-gated output | +| Expert Pool | Router -> specialist | Heterogeneous work items | + +Most common: **Architect + Builder + Reviewer** (producer-reviewer). + +## Artifact management + +When a team produces intermediate files, use `_workspace/{team_name}/` in the project root (add `_workspace/` to `.gitignore`). + +Naming: `{phase}_{agent}_{artifact}.{ext}` (e.g., `01_researcher_findings.md`). + +## Anti-patterns + +- Spawning subagents for < 5 minute tasks +- More than 5 concurrent agents (coordination overhead dominates) +- Agents without clear file/task boundaries (they step on each other) +- Not setting model explicitly (inherits expensive parent model) + +## Failure fallback + +If the harness subagent tool fails before running the assignment with an authentication error such as `401 Invalid authentication credentials`, treat the subagent runner as unavailable, not the task as failed. Do not retry the same broken route repeatedly. + +Fallback order: +1. Use local non-interactive Claude Code when `claude auth status` is valid. Start read-only unless the assignment explicitly permits edits: + ```bash + claude -p "self-contained task prompt" --allowedTools Read,WebFetch + ``` +2. Use Codex, Droid, or Hermes native delegation when the task specifically needs another model or isolated workspace. +3. If no delegation route works, do the work directly and report that delegation infrastructure failed. + +Keep the same constraints as the original subagent assignment: read-only agents stay read-only, builders may edit only their scoped files, and the caller verifies the final result. + +--- + +## Factory Droid execution + +Use Droid as a first-class target for substantial tasks via the Task tool and synced custom droids such as `architect`, `builder`, `researcher`, and `reviewer`. Keep prompts self-contained and verify results before reporting success. Defaults should route to OpenAI/GPT through Droid BYOK/VibeProxy unless the user requests a different provider. + +--- + +## Claude Code execution + +### Subagents (Agent tool) + +``` +Agent({ + name: "researcher", + model: "sonnet", + subagent_type: "researcher", + prompt: "self-contained task description" +}) +``` + +- Results return to caller's context +- Spawn independent subagents in a single message for parallel execution +- Use `subagent_type` to reference reusable definitions from `~/.claude/agents/` or `.claude/agents/` + +### Teams (TeamCreate + cmux) + +Requires `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` (set by `cmux claude-teams`). + +1. `TeamCreate({ team_name: "feature-x" })` +2. `TaskCreate` for each work item +3. `Agent` with `team_name` and `model` per teammate (parallel launch in one message) +4. `TaskUpdate` to assign owners +5. Wait for messages; don't poll +6. `SendMessage({ to: "name", message: { type: "shutdown_request" } })` when done + +### Reusable agent definitions + +Store in `~/.claude/agents/*.md` with YAML frontmatter (name, model, tools, description). Sync via `dotagents sync`. + +--- + +## Hermes execution + +Hermes is an orchestrator first. Default to delegating substantial coding work rather than doing it yourself. + +### Delegation routing + +| Task type | Delegate to | How | +|---|---|---| +| Coding (features, refactors, fixes) | Claude Code | `claude -p "..."` via terminal | +| Cross-model perspective, large codegen | Codex (GPT-5) | Native Codex session via terminal | +| Parallel coding on same repo | Hermes worktree | `hermes -w -p "..."` via terminal | +| In-process research, analysis, small tasks | Hermes subagent | `delegate_task(...)` | +| Conversation, planning, Q&A | Do it yourself | Direct response | + +### When to delegate vs do it yourself + +Delegate when: +- Task involves >5 min of coding tool calls +- Task benefits from a stronger model (Claude Opus/Sonnet, GPT-5) +- Multiple independent coding tasks can run in parallel + +Do it yourself when: +- Quick lookups, single-file reads, simple questions +- Research using your own skills (web, tech-search, memory) +- Planning and conversation +- The overhead of delegation exceeds the task itself + +Always state why you are delegating or doing it yourself. + +### Cross-agent delegation (from Hermes terminal) + +```bash +# Claude Code - non-interactive, blocks until done +claude -p "self-contained task prompt" --allow-tools "Edit,Read,Write,Bash" + +# Hermes worktree - parallel isolated instance +hermes -w -p "self-contained task prompt" +``` + +Write self-contained prompts. Delegates have no access to your conversation context. After delegation completes, verify the result before reporting success. + +### In-process subagents (delegate_task) + +For lighter tasks that don't need a different model or repo isolation: + +```python +# single +delegate_task(goal="Review this PR for security issues", context="...", toolsets=["file"]) + +# parallel (up to 3 concurrent) +delegate_task(tasks=[ + {goal: "Research approach A", context: "...", toolsets: ["web", "file"]}, + {goal: "Research approach B", context: "...", toolsets: ["web", "file"]} +]) +``` + +Constraints: +- Max depth: 2 (children cannot delegate further) +- Max concurrency: 3 parallel subagents +- Blocked toolsets for subagents: `delegation`, `clarify`, `memory`, `send_message` +- Subagents start fresh, no parent context + +### Subagent model override + +In `~/.hermes/config.yaml`: + +```yaml +delegation: + model: "provider/model" + provider: "openrouter" + max_iterations: 50 + default_toolsets: ["terminal", "file", "web"] +``` + +### Skill integration + +Add `~/.agents/skills` to `skills.external_dirs` in `~/.hermes/config.yaml`. + +--- + +## Codex execution + +### Native subagents + +Enable `multi_agent = true` in `~/.codex/config.toml`. Codex spawns child agents within the same session context. + +### Limitations + +- No shared task list across sessions +- No bi-directional messaging between independent sessions +- Coordination is single-session only diff --git a/plugins/dotagents/skills/spawn/references/patterns.md b/plugins/dotagents/skills/spawn/references/patterns.md new file mode 100644 index 0000000..6207cce --- /dev/null +++ b/plugins/dotagents/skills/spawn/references/patterns.md @@ -0,0 +1,134 @@ +# Team Architecture Patterns + +Decision tree for choosing the right pattern. Start here, then read the pattern section that matches. + +``` +Is the work sequential (each step depends on the last)? + Yes -> Pipeline + +Is the work parallelizable with a merge step? + Yes -> Fan-out/Fan-in + +Do you need different specialists for different input types? + Yes -> Expert Pool + +Is there a create-then-verify cycle? + Yes -> Producer-Reviewer + +Does a central agent need to manage dynamic state and reassign work? + Yes -> Supervisor + +Is the problem recursive or hierarchically decomposable? + Yes -> Hierarchical Delegation +``` + +## Pipeline + +Sequential handoff. Each agent's output feeds the next agent's input. + +``` +[Analyst] -> [Designer] -> [Builder] -> [Reviewer] +``` + +**When to use:** Strong sequential dependencies where later stages cannot start without earlier output. Software lifecycle, content pipelines, ETL. + +**Tradeoffs:** Bottleneck at slowest stage. Limited parallelism. Best when stages are roughly equal duration. + +**Team vs subagent:** Subagents usually suffice since communication is one-directional. Use a team only if stages need to negotiate (e.g., designer pushes back on analyst's requirements). + +## Fan-out/Fan-in + +Parallel independent work followed by synthesis. + +``` + +-> [Agent A] --+ +[Split] +-> [Agent B] --+--> [Merge] + +-> [Agent C] --+ +``` + +**When to use:** Independent subtasks that can run simultaneously. Research across multiple sources, parallel file processing, multi-perspective analysis. + +**Tradeoffs:** Merge step complexity scales with fan-out width. Conflicting findings need resolution strategy. + +**Team vs subagent:** Subagents for truly independent work (each produces a report, merger combines). Team when agents should share discoveries mid-flight (one researcher's finding changes another's search direction). + +## Expert Pool + +Route work to the right specialist based on input characteristics. + +``` +[Router] --> [Expert: Frontend] + --> [Expert: Backend] + --> [Expert: Database] + --> [Expert: Security] +``` + +**When to use:** Heterogeneous work items requiring different domain expertise. Ticket triage, multi-language codebases, mixed-format processing. + +**Tradeoffs:** Router accuracy is critical. Misrouted work wastes an expert's context. Keep the routing logic simple and explicit. + +**Team vs subagent:** Subagents when routing is clean and one-shot. Team when experts need to consult each other (frontend asks backend about API shape). + +## Producer-Reviewer + +One agent creates, another validates. Iterates until quality bar is met. + +``` +[Producer] <--> [Reviewer] + | | + v v + draft v1 feedback v1 + draft v2 "approved" +``` + +**When to use:** Any task where quality depends on independent verification. Code generation, document drafting, security review, test writing. + +**Tradeoffs:** Can loop if quality bar is ambiguous. Set a max iteration count (typically 2-3). Reviewer must have concrete acceptance criteria. + +**Team vs subagent:** Team strongly preferred. The back-and-forth messaging is the whole point. Subagent pattern would require the parent to relay feedback manually. + +## Supervisor + +Central agent maintains state, distributes work dynamically, handles failures. + +``` + +-> [Worker A] +[Super] +-> [Worker B] + +-> [Worker C] + (reassigns on failure) +``` + +**When to use:** Work where task assignment depends on runtime state. Load balancing, retry-on-failure, dynamic priority adjustment. Long-running jobs where some workers may stall. + +**Tradeoffs:** Supervisor is a single point of failure and a context bottleneck. Keep supervisor logic thin: dispatch and monitor, don't process. + +**Team vs subagent:** Team when workers report partial progress and supervisor adapts. Subagents when supervisor just collects final results. + +## Hierarchical Delegation + +Top-level agent decomposes the problem, delegates sub-problems recursively. + +``` +[Lead] + +-> [Module A Lead] + | +-> [A Worker 1] + | +-> [A Worker 2] + +-> [Module B Lead] + +-> [B Worker 1] +``` + +**When to use:** Large problems that decompose into semi-independent sub-problems. Monorepo refactors, multi-service changes, large document generation. + +**Tradeoffs:** Depth increases latency and token cost. Keep to 2 levels max in practice. Each level must produce a clear contract (interface, spec) for the level below. + +**Team vs subagent:** Hybrid is natural here. Top level is a team (leads coordinate). Each lead uses subagents for their workers (no cross-module communication needed). + +## Combining patterns + +Real workflows often combine patterns: + +- **Fan-out + Producer-Reviewer:** Parallel research agents, each reviewed independently, then merged. +- **Pipeline + Fan-out:** Sequential phases where one phase fans out internally. +- **Supervisor + Expert Pool:** Supervisor routes to experts and handles re-routing on failure. + +Name the combined pattern in the orchestrator so future readers understand the intent. diff --git a/plugins/dotagents/skills/spec/SKILL.md b/plugins/dotagents/skills/spec/SKILL.md new file mode 100644 index 0000000..789443a --- /dev/null +++ b/plugins/dotagents/skills/spec/SKILL.md @@ -0,0 +1,61 @@ +--- +name: spec +description: Create or update a minimal SPEC.md through a short interview, then use it as the source of truth for complex features, refactors, or ambiguous requests. Use when the user asks for /spec, spec-driven development, or when SPEC.md is missing or stale before substantial work. +--- + +# Spec + +## Overview + +Run a short interview and produce or update a minimal `SPEC.md` that guides planning and implementation without bloating docs. + +## Workflow + +1) Decide if a spec is needed. + - Use this flow for new features, refactors, or ambiguous work. + - Also use it when `SPEC.md` is missing or stale, or the user asks for `/spec`. + +2) Interview (5 questions max). + - When the harness exposes a structured question tool, use it. Claude Code: `AskUserQuestion`. Codex or Codex-derived harnesses: `request_user_input` when available. Hermes: `clarify`. + - Otherwise ask plain text directly. + - Skip questions already answered in the request or prior context. + - Aim for concise, unambiguous answers. + +Suggested questions: +1. What is the primary goal and user-visible behavior? +2. What is explicitly out of scope? +3. What are the acceptance tests or success criteria? +4. What constraints matter (stack, performance, data, integrations, policies)? +5. What code areas or existing patterns should be reused? + +3) Draft `SPEC.md` at the project root (or update it if present). + - Keep it short. One page if possible. + - Use the template below and keep sections with content only. + +4) Confirm with the user before planning or coding. + +5) After implementation, append a short "Outcome / Deviations" section with what changed from the spec. + +## Minimal SPEC.md template + +```markdown +# SPEC + +## Goal + +## Non-goals + +## User story / behavior + +## Acceptance tests + +## Constraints + +## Dependencies / integrations + +## Risks / open questions + +## Codebase notes + +## Outcome / Deviations +``` diff --git a/plugins/dotagents/skills/tech-search/SKILL.md b/plugins/dotagents/skills/tech-search/SKILL.md new file mode 100644 index 0000000..4428cde --- /dev/null +++ b/plugins/dotagents/skills/tech-search/SKILL.md @@ -0,0 +1,183 @@ +--- +name: tech-search +description: Search web, specifically Hacker News, X.com, Reddit, and Discord from top tech bloggers and communities about a given topic. Use when user says /tech-search or wants curated tech opinions on a topic. +--- + +# tech-search + +Search for opinions and discussions from high-signal tech sources about a given topic. + +## Usage + +``` +/tech-search +``` + +## Default workflow + +Start with broad discovery, then deepen only where the source-specific signal matters. Do not build or invoke a repo-owned search engine for this skill. + +1. Run WebSearch for the topic, biased toward primary sources, recent discussion, and source-specific pages. +2. Classify the topic and pick targeted follow-ups: + - **Named feature/tool/project**: primary docs/repo first, then HN/Reddit/X reaction. + - **Security/reliability topic**: official guidance, OWASP/CVE/vendor research, then practitioner discussion. + - **Comparison**: search the full comparison and each side separately. Add disambiguating context (`Python package manager`, `macOS window manager`, etc.). + - **Workflow/how-to**: strip literal phrases like "use cases", "workflow", "how to" from search strings; keep the intent in synthesis. + - **Ambiguous term**: add domain keywords before searching. Example: `uv poetry Python packaging`, not `uv vs poetry`. +3. Use targeted tools/commands to verify source-specific evidence. +4. Synthesize only after deduping amplification and separating primary facts from community reaction. + +The guiding rule: broad web finds the map; source-specific searches verify the terrain. + +## Sources + +Search sources in parallel when practical, but do not force every source. Skipped sources are fine when they are irrelevant, unauthenticated, or low-signal. + +Reference: `references/reddit-discord-cli-eval.md` records the repo evaluation behind the `rdt-cli` and `discord-cli` recommendations. + +### 1. Web and primary sources + +Use WebSearch first for: +- official docs and announcements +- vendor/security research +- release notes +- high-signal blog posts +- source-specific discovery (`site:news.ycombinator.com`, `site:reddit.com`, `site:github.com`) + +Good query patterns: +```text + official docs + GitHub + site:news.ycombinator.com + site:reddit.com/r/ + security best practices OWASP CVE + vs Python packaging +``` + +For authoritative context: `site:simonwillison.net`, `site:jvns.ca`, `site:danluu.com`, vendor docs, release notes, and primary announcement posts. + +### 2. GitHub + +GitHub is first-class for developer tools, libraries, agent frameworks, MCP servers, and open source projects. Prefer exact repo/user lookups over keyword search when known. + +```bash +gh repo view / --json nameWithOwner,description,stargazerCount,forkCount,updatedAt,latestRelease,url +gh issue list -R / --search "" --state all --limit 20 --json title,url,state,comments,updatedAt +gh pr list -R / --search "" --state all --limit 20 --json title,url,state,comments,updatedAt +``` + +Use GitHub to answer: +- what is the canonical repo? +- is the project active? +- what is shipping or breaking? +- what issues/PRs show real user pain? + +### 3. Hacker News + +Use Algolia `search` endpoint with a date filter when recency matters. **Do NOT use `search_by_date`** as the primary path - it returns fresh zero-comment noise. + +```text +https://hn.algolia.com/api/v1/search?query=&tags=story&hitsPerPage=10&numericFilters=created_at_i>TIMESTAMP +``` + +Also use WebSearch for older high-signal HN threads when the topic is slow-moving. A one-year-old 500-comment HN discussion can matter more than a 2-point fresh repost. + +Read threads at `https://news.ycombinator.com/item?id=` and cite the HN thread when discussing comments or points. If the submitted URL matters, include it separately. + +### 4. Reddit + +Preferred path when installed: + +```bash +rdt search "" -s relevance -t month -n 10 --compact --json +rdt search "" -r -s top -t year -n 10 --compact --json +rdt read -n 20 --json +``` + +**Target subreddits:** r/ExperiencedDevs, r/ClaudeAI, r/ClaudeCode, r/LocalLLaMA, r/MachineLearning, r/devops, r/commandline, r/neovim, r/Python, r/mcp, r/cybersecurity + +Pick 2-3 relevant subreddits. Avoid broad Reddit for ambiguous terms; it will chase engagement from irrelevant communities. Add context keywords, e.g. `uv poetry Python packaging`, not `poetry`. + +Raw Reddit `.json` endpoints often 403 and should be treated as a fallback only. On VPS/headless hosts, `rdt search` may return Reddit `forbidden` without browser cookies; do not copy cookie secrets into chat. For historical posts or VPS fallback, use Pullpush API (`https://api.pullpush.io/reddit/search/submission/?q=&size=5&sort=desc&sort_type=score`). + +### 5. X.com + +Use `x-cli` for X.com searches. Check auth with `x-cli auth status`. + +**Power users:** @karpathy, @fchollet, @hardmaru, @thorstenball, @thdxr, @steipete, @banteg + +```bash +x-cli search "(from:karpathy OR from:fchollet) " --type latest --count 10 --json +x-cli search " (recommended OR \"game changer\")" --type top --count 10 --json +``` + +Fallback: `site:x.com ` via WebSearch if x-cli auth is broken. Treat X as commentary unless the author is primary to the topic. + +### 6. Discord + +Discord remains opt-in because it uses user-token auth and may carry account-risk. Search Discord only when the topic is relevant to known communities (ML, LLMs, Claude, agents, evals, fine-tuning, etc). Skip for generic/unrelated topics. + +**Preferred CLI for repeated/community monitoring**: `discord-cli` (`uv tool install kabi-discord-cli`) can sync accessible Discord channels into local SQLite, then search/export them with structured YAML/JSON. Use it only for accounts the user controls. Do not ask the user to paste raw Discord tokens into chat logs. + +```bash +discord status --yaml +discord dc guilds --yaml +discord dc channels --yaml +discord dc search "" -n 10 --json +discord search "" -n 20 --json +``` + +**Known servers:** + +| Name | Guild ID | +|---|---| +| NousResearch | 1053877538025386074 | +| Anthropic/Claude | 1456350064065904867 | + +For one-off raw API search, use `rtk proxy curl` to bypass token filtering, URL-encode the query string, and extract only `messages[][]` entries where `"hit": true`. + +## Search, dedupe, and ranking rules + +- Preserve enough raw evidence in notes or a report path for non-trivial outputs. +- Prefer primary sources before social reaction. +- Deduplicate by canonical URL first; strip `utm_*`, `ref`, `source`, fragments, and Reddit comment slugs. +- Deduplicate cross-platform amplification: one announcement reposted on HN, Reddit, and X is one story with multiple reactions. +- Cap any single author/domain/community from dominating unless the topic is about that author/domain/community. +- Prefer recent results for pulse questions; use older high-signal results for slow-moving tools or foundational comparisons. +- Prefer topic relevance before engagement. A high-upvote irrelevant Reddit thread is noise. +- Keep source-specific failures visible. Explicitly state when X, Discord, GitHub auth, or Reddit cookies were unavailable. +- Never fabricate quotes, counts, or links. + +## Output format + +```markdown +## - Tech Pulse + +### Source status +- Web/primary: searched +- GitHub: searched / skipped because ... +- X.com: searched / skipped because ... +- Hacker News: searched / skipped because ... +- Reddit: searched / skipped because ... +- Discord: skipped unless relevant/authenticated + +### Key findings +- **Finding** - evidence and direct links. + +### Source notes +- **Primary sources**: docs, repos, release notes, vendor/security research. +- **GitHub**: repo/issues/PR signal. +- **X.com**: notable expert posts or lack of signal. +- **Hacker News**: high-signal threads. +- **Reddit**: subreddit/user pain or adoption signal. +- **Discord**: only when searched. + +### Summary +2-3 sentence synthesis. Key recommendations if any. + +### Confidence +[High/Medium/Low] based on source coverage, volume, recency, and agreement. +``` + +If combined with `/repo-eval` or if the user requests a `.md` report, produce a Markdown report under `~/Workspace/reports/` and verify it exists before finalizing. Preserve source-specific sections, include direct thread/post links, distinguish amplification from substantive critique, and explicitly state when a source was skipped because credentials such as `DISCORD_TOKEN` were unavailable. + diff --git a/plugins/dotagents/skills/tech-search/references/reddit-discord-cli-eval.md b/plugins/dotagents/skills/tech-search/references/reddit-discord-cli-eval.md new file mode 100644 index 0000000..6a07229 --- /dev/null +++ b/plugins/dotagents/skills/tech-search/references/reddit-discord-cli-eval.md @@ -0,0 +1,65 @@ +# Reddit and Discord CLI evaluation for `/tech-search` + +Session context: evaluated `public-clis/rdt-cli` and `jackwener/discord-cli` as possible community-search backends for `/tech-search`. + +## Recommendation + +- Use `rdt-cli` as the preferred Reddit path when installed. +- Use `discord-cli` only as an opt-in backend for repeated Discord community monitoring or local archive search. +- Keep direct Discord API search as the default one-off path because it is simpler and avoids SQLite sync state. + +## `public-clis/rdt-cli` + +Repo: `https://github.com/public-clis/rdt-cli` + +Findings: + +- Python package: `rdt-cli`; install with `uv tool install rdt-cli`. +- Latest inspected CI was successful across Python 3.10, 3.12, and 3.13. +- Search supports global and subreddit-scoped queries with `--compact --json` / `--yaml`. +- Read supports post/comment fetches with structured JSON/YAML output; installed `rdt-cli==0.4.1` does not expose `--compact` for `rdt read`. +- Auth uses Reddit browser cookies or saved credentials under the user config directory; cookie material must never be printed or copied into chat. +- Source inspection found no obvious eval/exec/install-script red flags; subprocess usage was tied to browser-cookie extraction and browser opening helpers. +- Maintainer responsiveness looked good: compact/YAML/read bugs had been fixed quickly. + +Good commands: + +```bash +rdt search "" -s relevance -t month -n 10 --compact --json +rdt search "" -r -s top -t year -n 10 --compact --json +rdt read -n 20 --json +``` + +Pitfalls: + +- If auth/cookies fail, fall back to Reddit JSON with a browser User-Agent. +- On VPS/headless hosts, Reddit may still return `forbidden` without browser cookies; do not copy cookie secrets into chat. +- For historical Reddit search or VPS fallback, Pullpush remains a fallback. + +## `jackwener/discord-cli` + +Repo: `https://github.com/jackwener/discord-cli` + +Findings: + +- Python package: `kabi-discord-cli`; install with `uv tool install kabi-discord-cli`. +- Latest inspected CI was successful across Python 3.10, 3.12, and 3.14. +- Supports guild/channel discovery, native Discord search, channel sync, and local SQLite search/export. +- Uses `DISCORD_TOKEN` and includes helpers that can scan local browser/Discord client storage for user tokens. +- This creates material ToS/account-risk and secret-handling concerns. Do not ask the user to paste raw Discord tokens into chat logs. Never preserve token values. + +Good commands: + +```bash +discord status --yaml +discord dc guilds --yaml +discord dc channels --yaml +discord dc search "" -n 10 --json +discord search "" -n 20 --json +``` + +Pitfalls: + +- User-token usage may violate Discord ToS or trigger account restrictions. Treat as opt-in. +- Local SQLite search requires prior sync; do not assume it has data. +- For one-off searches, direct Discord API search is usually simpler. diff --git a/plugins/dotagents/skills/tg/SKILL.md b/plugins/dotagents/skills/tg/SKILL.md new file mode 100644 index 0000000..6e8be49 --- /dev/null +++ b/plugins/dotagents/skills/tg/SKILL.md @@ -0,0 +1,63 @@ +--- +name: tg +description: Read Telegram chats, search messages, and list dialogs via the `tg` CLI. Use when the user asks to check Telegram, read a chat, find a message, or monitor for replies. +--- + +# tg -- Read-only Telegram CLI + +Use this skill to read Telegram messages. All operations are read-only. + +## Commands + +```bash +tg dialogs # list recent chats (default 20) +tg dialogs --query "Berlin" # filter by name substring +tg dialogs --limit 50 # more results + +tg read [--limit N] # read recent messages +tg search [-n N] # search within a chat +tg info # chat metadata + +tg daemon status # check if daemon is running +tg daemon start # start daemon manually +tg daemon stop # stop daemon +tg daemon log # show daemon log tail +``` + +## Chat resolution + +The `` argument accepts any of: +- Username: `max_akhmedov`, `wq67753` +- Numeric ID: `118488548` +- Exact chat title: `"Кирилл Кориков - Берлин"` +- Title substring: `Берлин` (matches first dialog containing it) + +## Output + +All commands output JSON. Pipe through `jq` for filtering: + +```bash +tg read wq67753 -n 5 | jq '.[] | select(.sender_name == "Модник") | .text' +``` + +## Architecture + +A singleton daemon holds the Telethon MTProto session. The CLI and MCP servers proxy through a Unix domain socket. Multiple Claude Code sessions can read Telegram simultaneously without auth conflicts. + +The daemon auto-starts on first use and shuts down after 30 min idle. + +## Session management + +If the session expires or gets corrupted: +```bash +tg daemon stop +cd ~/.agents/mcp/telegram-readonly && uv run python login.py +tg daemon start +``` + +## Monitoring pattern + +Check for new messages from a specific person: +```bash +tg read -n 5 | jq '[.[] | select(.sender_name == "Name" and .date > "2026-06-09T00:00:00")] | length' +``` diff --git a/plugins/dotagents/skills/tmux/SKILL.md b/plugins/dotagents/skills/tmux/SKILL.md new file mode 100644 index 0000000..61f1ade --- /dev/null +++ b/plugins/dotagents/skills/tmux/SKILL.md @@ -0,0 +1,107 @@ +--- +name: tmux +description: Generic tmux reference for sessions, windows, panes, screen capture, and input. If CMUX_* environment variables are present, use the cmux skill instead. +--- + +# tmux + +Use this skill for terminal multiplexing when the current terminal is not managed by cmux. + +## Detection + +```bash +env | grep '^CMUX_' # if present, switch to the cmux skill +test -n "$TMUX" && echo tmux +command -v tmux +``` + +Routing: +- If `CMUX_WORKSPACE_ID` and `CMUX_SURFACE_ID` are set, use the `cmux` skill. +- Else if `TMUX` is set, target the current tmux server/session. +- Else if `tmux` exists, create or attach a tmux session before relying on pane operations. +- Else, no supported queryable pane manager is active. + +## Inspect Layout + +```bash +tmux list-sessions +tmux list-windows -a -F '#{session_name}:#{window_index} #{window_name} active=#{window_active}' +tmux list-panes -a -F '#{session_name}:#{window_index}.#{pane_index} #{pane_id} active=#{pane_active} cwd=#{pane_current_path} cmd=#{pane_current_command} title=#{pane_title}' +tmux display-message -p '#{session_name}:#{window_index}.#{pane_index} #{pane_id}' +``` + +Always inspect layout first, then use explicit targets. Prefer `%pane_id`; it remains stable if windows are rearranged. + +## Read Screen + +```bash +tmux capture-pane -p -t %pane_id -S -80 +tmux capture-pane -p -t %pane_id -S -200 +tmux capture-pane -p -t session:window.pane -S -80 +``` + +## Send Input + +```bash +tmux send-keys -t %pane_id "command here" Enter +tmux send-keys -t %pane_id Enter +tmux send-keys -t %pane_id C-c +``` + +Prefer sending full commands plus `Enter`; use keys for control sequences. + +## Create Sessions, Windows, and Panes + +```bash +tmux new-session -d -s name -c /path +tmux attach-session -t name +tmux new-window -t name -n title -c /path +tmux split-window -h -t %pane_id -c /path +tmux split-window -v -t %pane_id -c /path +``` + +## Focus and Close + +```bash +tmux switch-client -t session +tmux select-window -t session:window +tmux select-pane -t %pane_id +tmux kill-pane -t %pane_id +tmux kill-window -t session:window +tmux kill-session -t session +``` + +## Move Panes and Windows + +```bash +tmux move-window -s source:window -t target:window +tmux move-pane -s %source_pane -t %target_pane +tmux join-pane -s %source_pane -t %target_pane +tmux break-pane -s %pane_id +``` + +## Buffers and Signals + +```bash +tmux set-buffer -b scratch "text" +tmux paste-buffer -b scratch -t %pane_id +tmux wait-for build-done +tmux wait-for -S build-done +``` + +## Agent Workflow + +```bash +tmux new-session -d -s agent-task -c /repo +tmux send-keys -t agent-task:0.0 "droid" Enter +tmux attach-session -t agent-task +``` + +Run normal agent CLIs in tmux panes (`droid`, `hermes`, `codex`). + +## Safety Rules + +- Inspect layout before reading or sending input. +- Use explicit targets (`%pane_id` or `session:window.pane`). +- Do not send destructive commands to a pane unless you have verified the target. +- Prefer a dedicated session for long-running delegated agents. diff --git a/plugins/dotagents/skills/x-cli/SKILL.md b/plugins/dotagents/skills/x-cli/SKILL.md new file mode 100644 index 0000000..f49700c --- /dev/null +++ b/plugins/dotagents/skills/x-cli/SKILL.md @@ -0,0 +1,80 @@ +--- +name: x-cli +description: Use this skill when the task is to authenticate with X, fetch timelines, tweets, users, search results, followers, or following data through x-cli, or to troubleshoot x-cli auth, rate limits, and GraphQL query ID drift. +license: MIT +metadata: + author: Gladium AI + version: 1.0.0 + category: developer-tools + tags: "x,twitter,cli,graphql,scraping,golang" +--- + +# X CLI + +Use this skill to operate `x-cli` safely and consistently. + +## Use This Skill For + +- Logging into X through the browser-based auth flow +- Fetching home timelines, user timelines, tweets, user profiles, search results, followers, or following lists +- Using `--json` output for downstream parsing or automation +- Diagnosing auth failures, rate limits, pagination, or GraphQL query ID rotation +- Updating or validating the CLI's endpoint/query ID registry when X changes its internal API + +## Core Rules + +- Prefer `x-cli` over ad hoc `curl` requests to X when the CLI surface already covers the task. +- Prefer `--json` when another tool, agent, or script will consume the output. +- Treat `~/.x-cli/credentials.json` as managed state. Use `x-cli auth login`, `status`, and `logout` instead of editing it manually. +- Use `tweet get` with either a tweet ID or full X URL. +- Expect cursor-based pagination on list commands and preserve returned cursors when continuing a session. +- If multiple commands start returning `404` responses, suspect GraphQL query ID drift in `internal/api/endpoints.go`. + +## Quick Workflow + +1. Verify the binary and auth state. +2. Authenticate if needed. +3. Run the smallest command that answers the task. +4. Switch to `--json` for parsing or handoff. +5. Follow pagination cursors or `--all` only when the user wants broader retrieval. +6. Use `--verbose` when debugging HTTP failures or endpoint drift. + +## Prerequisites + +- `x-cli` available on `PATH`, or use the repo-local binary. +- Google Chrome installed for browser-based login. +- A valid X account session. + +For repo-local build and install commands, see [references/install-and-build.md](references/install-and-build.md). + +## Standard Command Path + +For a typical session: + +```bash +x-cli auth status +x-cli auth login +x-cli timeline home --count 20 +x-cli tweet get https://x.com/jack/status/20 --json +x-cli user get @jack +x-cli search "golang" --type latest --json +``` + +For follower graph inspection: + +```bash +x-cli followers @jack --count 50 +x-cli following @jack --count 50 --json +``` + +Load [references/command-map.md](references/command-map.md) when you need exact command shapes, pagination flags, or a quick reminder of the command surface. + +## Troubleshooting + +- Start with `x-cli auth status` to confirm stored credentials are present. +- Use `--verbose` to inspect request URLs, HTTP codes, and raw response details. +- If pagination stalls or you need the next page later, reuse the emitted cursor exactly. +- If commands fail after a long idle period, re-run `x-cli auth login`. +- If several commands return `404` or start failing at once, inspect `internal/api/endpoints.go` and refresh the query IDs from X's current web bundle. + +For auth, rate-limit, and endpoint-drift notes, read [references/auth-and-debugging.md](references/auth-and-debugging.md). diff --git a/plugins/dotagents/skills/x-cli/agents/openai.yaml b/plugins/dotagents/skills/x-cli/agents/openai.yaml new file mode 100644 index 0000000..426797b --- /dev/null +++ b/plugins/dotagents/skills/x-cli/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "X CLI" + short_description: "Operate x-cli for X timeline and profile retrieval" + default_prompt: "Use $x-cli to authenticate with X, fetch the needed timeline, tweet, user, search, or social-graph data, and return the exact commands and outputs." diff --git a/plugins/dotagents/skills/x-cli/references/auth-and-debugging.md b/plugins/dotagents/skills/x-cli/references/auth-and-debugging.md new file mode 100644 index 0000000..c25a75c --- /dev/null +++ b/plugins/dotagents/skills/x-cli/references/auth-and-debugging.md @@ -0,0 +1,27 @@ +# Auth And Debugging + +## Auth Model + +- `x-cli auth login` launches Chrome and captures the cookies plus bearer and CSRF tokens needed for X's internal GraphQL endpoints. +- Credentials are stored at `~/.x-cli/credentials.json`. +- If login state becomes invalid, refresh it with `x-cli auth login` rather than editing the credentials file. + +## Pagination And Rate Limits + +- Timeline, search, followers, and following commands can return a cursor for the next page. +- `timeline` commands also support `--all` with `--max-pages`. +- The CLI parses X rate-limit headers and waits before retrying when required, so repeated requests may pause instead of failing immediately. + +## Endpoint Drift + +- X rotates GraphQL query IDs regularly. +- If several commands begin returning `404` responses at the same time, check `internal/api/endpoints.go`. +- Updated query IDs can usually be recovered from X's current production bundle or from captured browser network traffic. + +## Useful Debug Path + +```bash +x-cli auth status +x-cli timeline home --verbose +x-cli search "test" --type latest --json --verbose +``` diff --git a/plugins/dotagents/skills/x-cli/references/command-map.md b/plugins/dotagents/skills/x-cli/references/command-map.md new file mode 100644 index 0000000..db3be0c --- /dev/null +++ b/plugins/dotagents/skills/x-cli/references/command-map.md @@ -0,0 +1,50 @@ +# Command Map + +## Auth + +```bash +x-cli auth login +x-cli auth status +x-cli auth logout +``` + +## Timeline + +```bash +x-cli timeline home [--count N] [--cursor CURSOR] [--all] [--max-pages N] +x-cli timeline user @handle [--count N] [--cursor CURSOR] [--all] [--max-pages N] +``` + +## Tweet + +```bash +x-cli tweet get +``` + +## User + +```bash +x-cli user get @handle +``` + +## Search + +```bash +x-cli search "" [--type top|latest|people|media] [--count N] [--cursor CURSOR] +``` + +## Social Graph + +```bash +x-cli followers @handle [--count N] [--cursor CURSOR] +x-cli following @handle [--count N] [--cursor CURSOR] +``` + +## Global Flags + +```bash +--json +--verbose +``` + +Use `--json` for structured output and `--verbose` to debug request failures. diff --git a/plugins/dotagents/skills/x-cli/references/install-and-build.md b/plugins/dotagents/skills/x-cli/references/install-and-build.md new file mode 100644 index 0000000..b9ebcb9 --- /dev/null +++ b/plugins/dotagents/skills/x-cli/references/install-and-build.md @@ -0,0 +1,26 @@ +# Install And Build + +These commands are for building x-cli from its source repository (github.com/Gladium-AI/x-cli), not from this skills directory. + +From the x-cli source repo: + +```bash +make build +sudo make install # installs to /usr/local/bin +``` + +Or install to ~/.local/bin without sudo (already on PATH): + +```bash +make build +mkdir -p ~/.local/bin +cp x-cli ~/.local/bin/ +``` + +Verify installation: + +```bash +x-cli --help +``` + +The skill files in this directory are reference documentation only - the x-cli binary must be installed separately from its source repo. diff --git a/plugins/dotagents/skills/x-sim/SKILL.md b/plugins/dotagents/skills/x-sim/SKILL.md new file mode 100644 index 0000000..53ef5ee --- /dev/null +++ b/plugins/dotagents/skills/x-sim/SKILL.md @@ -0,0 +1,117 @@ +--- +name: x-sim +description: Offline X audience simulation and evaluation. Use when evaluating draft tweets, handle bios, pinned-post ideas, or promotion angles against real scraped X context without posting or mutating X state. +--- + +# x-sim + +Use `x-sim` to simulate how real X audiences may react to a draft tweet, bio, pinned post, or handle-promotion angle. + +This skill is offline-only. It reads scraped X data through `x-cli`, stores local context in SQLite, and produces evaluation reports. Never post, like, reply, follow, DM, or mutate X state from this skill. + +## Example: Predicting Model-Announcement Tweets + +Real e2e run (2026-06-10): predict how a frontier-model announcement would land, using followed AI accounts as the audience corpus. + +```bash +x-cli following @yourhandle --count 100 --json # pick relevant accounts from who you follow +go run ./tools/x-sim init +for h in AnthropicAI _catwu trq212 karpathy thehypedotnews steipete; do + go run ./tools/x-sim source add-account @$h +done +go run ./tools/x-sim source add-search "claude fable" +go run ./tools/x-sim sync --since 3m --limit-per-source 50 # synced 215 attributed tweets +go run ./tools/x-sim brief --topic "fable" --out /tmp/x-sim-fable-brief.md +go run ./tools/x-sim eval-tweet --text "Introducing Claude Fable 5, our most capable model yet..." \ + --topic "fable" --out /tmp/x-sim-pred.md +``` + +The brief surfaced the real announcement (`@claudeai`: "a Mythos-class model that we've made safe for general use") plus audience echo (`@karpathy`: "same underlying model as Mythos but with added safeguards"). Comparing the draft against that evidence showed the predicted capability framing matched, but the actual hook was the safety-derivative angle - the kind of gap this skill exists to catch before posting. + +## Core Rules + +- Use `x-cli --json` for X reads; do not call raw X endpoints when `x-cli` covers the task. +- Keep the canonical store local: `${XDG_DATA_HOME:-~/.local/share}/dotagents/x-sim/x-sim.sqlite`, or `X_SIM_DB` when set. +- Default context window is the last 6 months. +- Treat simulation scores as directional, not truth. Ground every claim in local scraped examples when possible. +- Run the final voice pass through `humanizer` rules: remove generic hype, keep concrete evidence, and preserve the user's direct style. +- If asked to publish, schedule, or perform account actions, stop and explain that this skill only evaluates offline. + +## CLI + +From this skill directory: + +```bash +go run ./tools/x-sim init +go run ./tools/x-sim source add-account @handle +go run ./tools/x-sim source add-search "agent evals" +go run ./tools/x-sim sync --since 6m --limit-per-source 200 +go run ./tools/x-sim brief --topic "agent evals" --out /tmp/x-sim-brief.md +go run ./tools/x-sim eval-tweet --text "draft tweet" --topic "agent evals" --out /tmp/x-sim-report.md +go run ./tools/x-sim eval-handle --bio "..." --promotion "..." --out /tmp/x-sim-handle.md +``` + +The CLI is intentionally deterministic. Use the model's judgment to add richer audience emulation on top of the report, but keep the report grounded in the local DB. + +## Workflow + +1. **Initialize and verify** + - Run `x-cli auth status`. + - Run `go run ./tools/x-sim init`. +2. **Add sources** + - Add relevant accounts and searches with `source add-account` and `source add-search`. + - For broad topics, prefer focused search strings over generic keywords. +3. **Sync context** + - Run `sync --since 6m`. + - If sync fails, fix `x-cli` auth or query shape first; do not use account-mutating commands. +4. **Create a brief** + - Run `brief --topic ...`. + - Read the source handles, recurring terms, and evidence tweets. +5. **Evaluate** + - Use `eval-tweet` for draft tweets. + - Use `eval-handle` for bio, pinned-post, and promotion positioning. + - Compare scores across personas: technical builder, skeptical founder/operator, AI-agent power user, casual X reader, and contrarian critic. +6. **Humanize** + - Rewrite the best candidate so it sounds like the user. + - Prefer concrete nouns, actual measurements, and visible tradeoffs. + - Cut generic hype and vague "AI thought leader" positioning. + +## Output Shape + +Return a short markdown review: + +```text +Verdict: +- ... + +Likely audience reaction: +- ... + +Best revision: +... + +Why: +- ... + +Risks: +- ... + +Evidence: +- @handle: source tweet summary/link +``` + +## Safety Boundary + +Allowed: + +- `x-cli auth status` +- `x-cli timeline user ... --json` +- `x-cli search ... --json` +- local SQLite reads/writes +- markdown reports + +Disallowed: + +- posting, replying, liking, reposting, following, unfollowing, bookmarking, or DMing +- reading or printing X credential files +- pretending simulated reactions are measured engagement diff --git a/plugins/dotagents/skills/x-sim/agents/openai.yaml b/plugins/dotagents/skills/x-sim/agents/openai.yaml new file mode 100644 index 0000000..a841209 --- /dev/null +++ b/plugins/dotagents/skills/x-sim/agents/openai.yaml @@ -0,0 +1,4 @@ +name: x-sim +display_name: x-sim +short_description: Offline X audience simulation for draft tweets and handle positioning. +default_prompt: Evaluate this draft against my local X audience context without posting anything. diff --git a/plugins/dotagents/skills/x-sim/tools/x-sim/go.mod b/plugins/dotagents/skills/x-sim/tools/x-sim/go.mod new file mode 100644 index 0000000..34d7839 --- /dev/null +++ b/plugins/dotagents/skills/x-sim/tools/x-sim/go.mod @@ -0,0 +1,5 @@ +module dotagents/x-sim + +go 1.24 + +require github.com/mattn/go-sqlite3 v1.14.44 diff --git a/plugins/dotagents/skills/x-sim/tools/x-sim/go.sum b/plugins/dotagents/skills/x-sim/tools/x-sim/go.sum new file mode 100644 index 0000000..cf1e561 --- /dev/null +++ b/plugins/dotagents/skills/x-sim/tools/x-sim/go.sum @@ -0,0 +1,2 @@ +github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= +github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= diff --git a/plugins/dotagents/skills/x-sim/tools/x-sim/main.go b/plugins/dotagents/skills/x-sim/tools/x-sim/main.go new file mode 100644 index 0000000..d98d408 --- /dev/null +++ b/plugins/dotagents/skills/x-sim/tools/x-sim/main.go @@ -0,0 +1,1034 @@ +package main + +import ( + "bytes" + "database/sql" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" + "unicode" + + _ "github.com/mattn/go-sqlite3" +) + +const defaultRetention = "6m" + +type source struct { + ID int64 + Kind string + Value string +} + +type tweet struct { + ID string + SourceID int64 + AuthorHandle string + AuthorName string + Text string + URL string + PostedAt time.Time + LikeCount int64 + RepostCount int64 + ReplyCount int64 + QuoteCount int64 + RawJSON string +} + +type evaluation struct { + Persona string + Scores map[string]int + Rationale string + Risks string +} + +func main() { + if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil { + fmt.Fprintln(os.Stderr, "x-sim:", err) + os.Exit(1) + } +} + +func run(args []string, stdout, stderr io.Writer) error { + if len(args) == 0 { + printUsage(stdout) + return nil + } + + db, err := openDB("") + if err != nil { + return err + } + defer db.Close() + + switch args[0] { + case "init": + return initCommand(db, stdout) + case "source": + return sourceCommand(db, args[1:], stdout) + case "sync": + return syncCommand(db, args[1:], stdout, stderr) + case "brief": + return briefCommand(db, args[1:], stdout) + case "eval-tweet": + return evalCommand(db, "tweet", args[1:], stdout) + case "eval-handle": + return evalCommand(db, "handle", args[1:], stdout) + case "report": + return reportCommand(db, args[1:], stdout) + default: + return fmt.Errorf("unknown command %q", args[0]) + } +} + +func printUsage(w io.Writer) { + fmt.Fprintln(w, `x-sim stores scraped X context and evaluates tweets/handle positioning offline. + +Commands: + init + source add-account @handle + source add-search "query" + sync --since 6m --limit-per-source 200 [--input file.json] + brief --topic "agents" + eval-tweet --text "draft tweet" [--audience "AI builders"] + eval-handle --bio "..." --positioning "..." + report --session [--out report.md]`) +} + +func openDB(path string) (*sql.DB, error) { + path, err := resolveDBPath(path) + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, err + } + db, err := sql.Open("sqlite3", path) + if err != nil { + return nil, err + } + if err := migrate(db); err != nil { + db.Close() + return nil, err + } + return db, nil +} + +func resolveDBPath(path string) (string, error) { + if path != "" { + return path, nil + } + if envPath := os.Getenv("X_SIM_DB"); envPath != "" { + return envPath, nil + } + base := os.Getenv("XDG_DATA_HOME") + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + base = filepath.Join(home, ".local", "share") + } + return filepath.Join(base, "dotagents", "x-sim", "x-sim.sqlite"), nil +} + +func migrate(db *sql.DB) error { + stmts := []string{ + `PRAGMA journal_mode=WAL;`, + `CREATE TABLE IF NOT EXISTS sources ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL CHECK(kind IN ('account', 'search')), + value TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_synced_at TEXT, + UNIQUE(kind, value) + );`, + `CREATE TABLE IF NOT EXISTS tweets ( + id TEXT PRIMARY KEY, + source_id INTEGER, + author_handle TEXT, + author_name TEXT, + text TEXT NOT NULL, + url TEXT, + posted_at TEXT NOT NULL, + like_count INTEGER NOT NULL DEFAULT 0, + repost_count INTEGER NOT NULL DEFAULT 0, + reply_count INTEGER NOT NULL DEFAULT 0, + quote_count INTEGER NOT NULL DEFAULT 0, + raw_json TEXT NOT NULL, + fetched_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(source_id) REFERENCES sources(id) ON DELETE SET NULL + );`, + `CREATE INDEX IF NOT EXISTS tweets_posted_at_idx ON tweets(posted_at);`, + `CREATE INDEX IF NOT EXISTS tweets_author_idx ON tweets(author_handle);`, + `CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL CHECK(kind IN ('tweet', 'handle')), + input_text TEXT NOT NULL, + audience TEXT, + topic TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + );`, + `CREATE TABLE IF NOT EXISTS evaluations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + persona TEXT NOT NULL, + clarity INTEGER NOT NULL, + novelty INTEGER NOT NULL, + credibility INTEGER NOT NULL, + audience_fit INTEGER NOT NULL, + reply_potential INTEGER NOT NULL, + risk INTEGER NOT NULL, + rationale TEXT NOT NULL, + risks TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE + );`, + } + for _, stmt := range stmts { + if _, err := db.Exec(stmt); err != nil { + return err + } + } + // Migrate sessions tables created before the topic column existed. + if _, err := db.Exec(`ALTER TABLE sessions ADD COLUMN topic TEXT`); err != nil && !strings.Contains(err.Error(), "duplicate column name") { + return err + } + return nil +} + +func initCommand(db *sql.DB, stdout io.Writer) error { + path, err := resolveDBPath("") + if err != nil { + return err + } + var sourceCount, tweetCount int + if err := db.QueryRow(`SELECT COUNT(*) FROM sources`).Scan(&sourceCount); err != nil { + return err + } + if err := db.QueryRow(`SELECT COUNT(*) FROM tweets`).Scan(&tweetCount); err != nil { + return err + } + fmt.Fprintf(stdout, "Initialized x-sim DB (%s): %d sources, %d tweets\n", path, sourceCount, tweetCount) + return nil +} + +func sourceCommand(db *sql.DB, args []string, stdout io.Writer) error { + if len(args) < 2 { + return errors.New("usage: x-sim source add-account @handle | source add-search \"query\"") + } + var kind, value string + switch args[0] { + case "add-account": + kind = "account" + value = normalizeHandle(args[1]) + case "add-search": + kind = "search" + value = strings.TrimSpace(strings.Join(args[1:], " ")) + default: + return fmt.Errorf("unknown source action %q", args[0]) + } + if value == "" { + return errors.New("source value cannot be empty") + } + _, err := db.Exec(`INSERT OR IGNORE INTO sources(kind, value) VALUES(?, ?)`, kind, value) + if err != nil { + return err + } + fmt.Fprintf(stdout, "Tracked %s source: %s\n", kind, value) + return nil +} + +func syncCommand(db *sql.DB, args []string, stdout, stderr io.Writer) error { + fs := flag.NewFlagSet("sync", flag.ContinueOnError) + fs.SetOutput(stderr) + sinceRaw := fs.String("since", defaultRetention, "retention window such as 6m, 30d, or 1y") + limit := fs.Int("limit-per-source", 200, "maximum tweets per source") + input := fs.String("input", "", "read x-cli JSON from a file instead of invoking x-cli") + if err := fs.Parse(args); err != nil { + return err + } + cutoff, err := parseWindowCutoff(*sinceRaw, time.Now()) + if err != nil { + return err + } + sources, err := listSources(db) + if err != nil { + return err + } + if len(sources) == 0 && *input == "" { + return errors.New("no sources tracked; add one with x-sim source add-account or add-search") + } + total := 0 + if *input != "" { + raw, err := os.ReadFile(*input) + if err != nil { + return err + } + src := source{Kind: "search", Value: "input"} + n, err := ingestJSON(db, src, raw, cutoff) + if err != nil { + return err + } + total += n + } else { + for _, src := range sources { + raw, err := runXCLI(src, *limit) + if err != nil { + fmt.Fprintf(stderr, "warning: source %s:%s failed: %v\n", src.Kind, src.Value, err) + continue + } + n, err := ingestJSON(db, src, raw, cutoff) + if err != nil { + fmt.Fprintf(stderr, "warning: source %s:%s parse failed: %v\n", src.Kind, src.Value, err) + continue + } + if _, err := db.Exec(`UPDATE sources SET last_synced_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), src.ID); err != nil { + return err + } + total += n + } + } + if _, err := db.Exec(`DELETE FROM tweets WHERE posted_at < ?`, cutoff.Format(time.RFC3339)); err != nil { + return err + } + fmt.Fprintf(stdout, "Synced %d tweets; retained tweets since %s\n", total, cutoff.Format("2006-01-02")) + return nil +} + +func runXCLI(src source, limit int) ([]byte, error) { + var args []string + switch src.Kind { + case "account": + args = []string{"timeline", "user", "@" + src.Value, "--count", strconv.Itoa(limit), "--json"} + case "search": + args = []string{"search", src.Value, "--type", "latest", "--count", strconv.Itoa(limit), "--json"} + default: + return nil, fmt.Errorf("unsupported source kind %q", src.Kind) + } + cmd := exec.Command("x-cli", args...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String())) + } + return out, nil +} + +func listSources(db *sql.DB) ([]source, error) { + rows, err := db.Query(`SELECT id, kind, value FROM sources ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []source + for rows.Next() { + var src source + if err := rows.Scan(&src.ID, &src.Kind, &src.Value); err != nil { + return nil, err + } + out = append(out, src) + } + return out, rows.Err() +} + +func ingestJSON(db *sql.DB, src source, raw []byte, cutoff time.Time) (int, error) { + var payload any + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + if err := dec.Decode(&payload); err != nil { + return 0, err + } + tweets := extractTweets(payload, src, time.Now().UTC()) + tx, err := db.Begin() + if err != nil { + return 0, err + } + defer func() { + _ = tx.Rollback() + }() + + count := 0 + for _, tw := range tweets { + if tw.ID == "" || tw.Text == "" || tw.PostedAt.Before(cutoff) { + continue + } + if tw.PostedAt.IsZero() { + tw.PostedAt = time.Now().UTC() + } + _, err := tx.Exec(` + INSERT INTO tweets(id, source_id, author_handle, author_name, text, url, posted_at, like_count, repost_count, reply_count, quote_count, raw_json, fetched_at) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + source_id=excluded.source_id, + author_handle=excluded.author_handle, + author_name=excluded.author_name, + text=excluded.text, + url=excluded.url, + posted_at=excluded.posted_at, + like_count=excluded.like_count, + repost_count=excluded.repost_count, + reply_count=excluded.reply_count, + quote_count=excluded.quote_count, + raw_json=excluded.raw_json, + fetched_at=excluded.fetched_at`, + tw.ID, nullableSourceID(src.ID), tw.AuthorHandle, tw.AuthorName, tw.Text, tw.URL, + tw.PostedAt.UTC().Format(time.RFC3339), tw.LikeCount, tw.RepostCount, tw.ReplyCount, + tw.QuoteCount, tw.RawJSON, time.Now().UTC().Format(time.RFC3339), + ) + if err != nil { + return count, err + } + count++ + } + if err := tx.Commit(); err != nil { + return count, err + } + return count, nil +} + +func nullableSourceID(id int64) any { + if id == 0 { + return nil + } + return id +} + +func extractTweets(payload any, src source, fetchedAt time.Time) []tweet { + seen := map[string]bool{} + var out []tweet + var walk func(any) + walk = func(v any) { + switch x := v.(type) { + case []any: + for _, item := range x { + walk(item) + } + case map[string]any: + if tw, ok := tweetFromMap(x, src, fetchedAt); ok && !seen[tw.ID] { + seen[tw.ID] = true + out = append(out, tw) + } + for _, item := range x { + walk(item) + } + } + } + walk(payload) + return out +} + +func tweetFromMap(m map[string]any, src source, fetchedAt time.Time) (tweet, bool) { + id := firstString(m, "id", "id_str", "rest_id", "tweet_id", "tweetId") + text := firstString(m, "text", "full_text", "tweetText", "content") + if text == "" { + text = nestedString(m, []string{"legacy"}, "full_text", "text") + } + if id == "" || text == "" || !isDigits(id) { + return tweet{}, false + } + raw, _ := json.Marshal(m) + authorContainers := []string{"author", "user", "core", "user_results", "result", "legacy"} + authorHandle := firstString(m, "author_handle", "authorHandle", "screen_name", "username", "handle") + authorName := firstString(m, "author_name", "authorName", "name") + if authorHandle == "" { + authorHandle = nestedString(m, authorContainers, "screen_name", "username", "handle") + } + if authorName == "" { + authorName = nestedString(m, authorContainers, "name") + } + postedAtRaw := firstString(m, "created_at", "createdAt", "posted_at", "postedAt", "time", "timestamp") + if postedAtRaw == "" { + postedAtRaw = nestedString(m, []string{"legacy"}, "created_at") + } + postedAt := parseAnyTime(postedAtRaw) + if postedAt.IsZero() { + postedAt = fetchedAt + } + url := firstString(m, "url", "tweet_url", "tweetUrl") + if url == "" && authorHandle != "" { + url = fmt.Sprintf("https://x.com/%s/status/%s", normalizeHandle(authorHandle), id) + } + return tweet{ + ID: id, + SourceID: src.ID, + AuthorHandle: normalizeHandle(authorHandle), + AuthorName: authorName, + Text: strings.TrimSpace(text), + URL: url, + PostedAt: postedAt.UTC(), + LikeCount: tweetInt(m, "like_count", "likes", "favorite_count", "favoriteCount"), + RepostCount: tweetInt(m, "repost_count", "retweet_count", "retweets", "reposts"), + ReplyCount: tweetInt(m, "reply_count", "replies"), + QuoteCount: tweetInt(m, "quote_count", "quotes"), + RawJSON: string(raw), + }, true +} + +func firstString(m map[string]any, keys ...string) string { + for _, key := range keys { + if v, ok := m[key]; ok { + switch x := v.(type) { + case string: + return strings.TrimSpace(x) + case json.Number: + return x.String() + case float64: + if x == float64(int64(x)) { + return strconv.FormatInt(int64(x), 10) + } + } + } + } + return "" +} + +func isDigits(s string) bool { + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + +func tweetInt(m map[string]any, keys ...string) int64 { + if n := firstInt(m, keys...); n != 0 { + return n + } + if legacy, ok := m["legacy"].(map[string]any); ok { + return firstInt(legacy, keys...) + } + return 0 +} + +func nestedString(m map[string]any, containers []string, keys ...string) string { + for _, c := range containers { + if child, ok := m[c].(map[string]any); ok { + if value := firstString(child, keys...); value != "" { + return value + } + if value := nestedString(child, containers, keys...); value != "" { + return value + } + } + } + return "" +} + +func firstInt(m map[string]any, keys ...string) int64 { + for _, key := range keys { + if v, ok := m[key]; ok { + switch x := v.(type) { + case float64: + return int64(x) + case int64: + return x + case json.Number: + n, _ := strconv.ParseInt(x.String(), 10, 64) + return n + case string: + n, _ := strconv.ParseInt(strings.TrimSpace(x), 10, 64) + return n + } + } + } + return 0 +} + +func parseAnyTime(raw string) time.Time { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{} + } + layouts := []string{ + time.RFC3339Nano, + time.RFC3339, + "Mon Jan 02 15:04:05 -0700 2006", + "2006-01-02 15:04:05", + "2006-01-02", + } + for _, layout := range layouts { + if t, err := time.Parse(layout, raw); err == nil { + return t + } + } + if unix, err := strconv.ParseInt(raw, 10, 64); err == nil { + if unix > 1_000_000_000_000 { + return time.UnixMilli(unix) + } + return time.Unix(unix, 0) + } + return time.Time{} +} + +func briefCommand(db *sql.DB, args []string, stdout io.Writer) error { + fs := flag.NewFlagSet("brief", flag.ContinueOnError) + topic := fs.String("topic", "", "topic filter") + sinceRaw := fs.String("since", defaultRetention, "lookback window") + limit := fs.Int("limit", 200, "maximum tweets to inspect") + out := fs.String("out", "", "optional markdown output path") + if err := fs.Parse(args); err != nil { + return err + } + cutoff, err := parseWindowCutoff(*sinceRaw, time.Now()) + if err != nil { + return err + } + tweets, err := queryTweets(db, *topic, cutoff, *limit) + if err != nil { + return err + } + report := renderBrief(*topic, tweets, cutoff) + if *out != "" { + if err := os.WriteFile(*out, []byte(report), 0o644); err != nil { + return err + } + fmt.Fprintf(stdout, "Wrote %s\n", *out) + return nil + } + fmt.Fprint(stdout, report) + return nil +} + +func queryTweets(db *sql.DB, topic string, cutoff time.Time, limit int) ([]tweet, error) { + pattern := "%" + strings.TrimSpace(topic) + "%" + query := `SELECT id, COALESCE(author_handle, ''), COALESCE(author_name, ''), text, COALESCE(url, ''), posted_at, like_count, repost_count, reply_count, quote_count, raw_json + FROM tweets WHERE posted_at >= ?` + args := []any{cutoff.Format(time.RFC3339)} + if strings.TrimSpace(topic) != "" { + query += ` AND text LIKE ?` + args = append(args, pattern) + } + query += ` ORDER BY (like_count + repost_count * 2 + reply_count * 3 + quote_count * 2) DESC, posted_at DESC LIMIT ?` + args = append(args, limit) + rows, err := db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var tweets []tweet + for rows.Next() { + var tw tweet + var posted string + if err := rows.Scan(&tw.ID, &tw.AuthorHandle, &tw.AuthorName, &tw.Text, &tw.URL, &posted, &tw.LikeCount, &tw.RepostCount, &tw.ReplyCount, &tw.QuoteCount, &tw.RawJSON); err != nil { + return nil, err + } + tw.PostedAt = parseAnyTime(posted) + tweets = append(tweets, tw) + } + return tweets, rows.Err() +} + +func renderBrief(topic string, tweets []tweet, cutoff time.Time) string { + var b strings.Builder + title := "X Audience Brief" + if topic != "" { + title += ": " + topic + } + fmt.Fprintf(&b, "# %s\n\n", title) + fmt.Fprintf(&b, "Lookback: since %s. Tweets inspected: %d.\n\n", cutoff.Format("2006-01-02"), len(tweets)) + if len(tweets) == 0 { + b.WriteString("No local tweet context matched. Run `x-sim sync` or broaden the topic.\n") + return b.String() + } + b.WriteString("## Signals\n\n") + for _, term := range topTerms(tweets, 12) { + fmt.Fprintf(&b, "- %s\n", term) + } + b.WriteString("\n## Source Handles\n\n") + for _, item := range topHandles(tweets, 8) { + fmt.Fprintf(&b, "- %s\n", item) + } + b.WriteString("\n## Evidence Tweets\n\n") + for i, tw := range tweets { + if i >= 8 { + break + } + fmt.Fprintf(&b, "- @%s: %s", tw.AuthorHandle, oneLine(tw.Text, 220)) + if tw.URL != "" { + fmt.Fprintf(&b, " (%s)", tw.URL) + } + fmt.Fprintln(&b) + } + return b.String() +} + +func evalCommand(db *sql.DB, kind string, args []string, stdout io.Writer) error { + fs := flag.NewFlagSet("eval-"+kind, flag.ContinueOnError) + text := fs.String("text", "", "tweet text to evaluate") + bio := fs.String("bio", "", "handle bio to evaluate") + positioning := fs.String("positioning", "", "handle positioning or promotion angle") + promotion := fs.String("promotion", "", "handle promotion angle") + audience := fs.String("audience", "", "target audience") + topic := fs.String("topic", "", "local tweet context topic") + out := fs.String("out", "", "optional markdown output path") + if err := fs.Parse(args); err != nil { + return err + } + input := strings.TrimSpace(*text) + if kind == "handle" { + parts := []string{} + if strings.TrimSpace(*bio) != "" { + parts = append(parts, "Bio: "+strings.TrimSpace(*bio)) + } + if strings.TrimSpace(*positioning) != "" { + parts = append(parts, "Positioning: "+strings.TrimSpace(*positioning)) + } + if strings.TrimSpace(*promotion) != "" { + parts = append(parts, "Promotion: "+strings.TrimSpace(*promotion)) + } + input = strings.Join(parts, "\n") + } + if input == "" { + return fmt.Errorf("eval-%s requires input text", kind) + } + cutoff, _ := parseWindowCutoff(defaultRetention, time.Now()) + contextTweets, err := queryTweets(db, *topic, cutoff, 60) + if err != nil { + return err + } + sessionID := newSessionID(kind) + if _, err := db.Exec(`INSERT INTO sessions(id, kind, input_text, audience, topic) VALUES(?, ?, ?, ?, ?)`, sessionID, kind, input, *audience, *topic); err != nil { + return err + } + evals := simulate(kind, input, *audience, contextTweets) + for _, ev := range evals { + if _, err := db.Exec(`INSERT INTO evaluations(session_id, persona, clarity, novelty, credibility, audience_fit, reply_potential, risk, rationale, risks) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + sessionID, ev.Persona, ev.Scores["clarity"], ev.Scores["novelty"], ev.Scores["credibility"], + ev.Scores["audience_fit"], ev.Scores["reply_potential"], ev.Scores["risk"], ev.Rationale, ev.Risks, + ); err != nil { + return err + } + } + report := renderSessionReport(sessionID, kind, input, *audience, evals, contextTweets) + if *out != "" { + if err := os.WriteFile(*out, []byte(report), 0o644); err != nil { + return err + } + fmt.Fprintf(stdout, "Wrote %s for session %s\n", *out, sessionID) + return nil + } + fmt.Fprint(stdout, report) + return nil +} + +func reportCommand(db *sql.DB, args []string, stdout io.Writer) error { + fs := flag.NewFlagSet("report", flag.ContinueOnError) + sessionID := fs.String("session", "", "session id") + out := fs.String("out", "", "optional markdown output path") + if err := fs.Parse(args); err != nil { + return err + } + if *sessionID == "" { + return errors.New("report requires --session") + } + var kind, input, audience, topic string + if err := db.QueryRow(`SELECT kind, input_text, COALESCE(audience, ''), COALESCE(topic, '') FROM sessions WHERE id = ?`, *sessionID).Scan(&kind, &input, &audience, &topic); err != nil { + return err + } + rows, err := db.Query(`SELECT persona, clarity, novelty, credibility, audience_fit, reply_potential, risk, rationale, risks FROM evaluations WHERE session_id = ? ORDER BY id`, *sessionID) + if err != nil { + return err + } + defer rows.Close() + var evals []evaluation + for rows.Next() { + ev := evaluation{Scores: map[string]int{}} + if err := rows.Scan(&ev.Persona, scoreDest(ev.Scores, "clarity"), scoreDest(ev.Scores, "novelty"), scoreDest(ev.Scores, "credibility"), scoreDest(ev.Scores, "audience_fit"), scoreDest(ev.Scores, "reply_potential"), scoreDest(ev.Scores, "risk"), &ev.Rationale, &ev.Risks); err != nil { + return err + } + evals = append(evals, ev) + } + if err := rows.Err(); err != nil { + return err + } + cutoff, _ := parseWindowCutoff(defaultRetention, time.Now()) + contextTweets, err := queryTweets(db, topic, cutoff, 60) + if err != nil { + return err + } + report := renderSessionReport(*sessionID, kind, input, audience, evals, contextTweets) + if *out != "" { + if err := os.WriteFile(*out, []byte(report), 0o644); err != nil { + return err + } + fmt.Fprintf(stdout, "Wrote %s\n", *out) + return nil + } + fmt.Fprint(stdout, report) + return nil +} + +func scoreDest(scores map[string]int, key string) any { + return scoreScanner{scores: scores, key: key} +} + +type scoreScanner struct { + scores map[string]int + key string +} + +func (s scoreScanner) Scan(src any) error { + var n int + var err error + switch x := src.(type) { + case int64: + n = int(x) + case int: + n = x + case []byte: + n, err = strconv.Atoi(string(x)) + default: + n, err = strconv.Atoi(fmt.Sprint(x)) + } + if err != nil { + return err + } + s.scores[s.key] = n + return nil +} + +func simulate(kind, input, audience string, contextTweets []tweet) []evaluation { + personas := []string{ + "technical builder", + "skeptical founder/operator", + "AI-agent power user", + "casual X reader", + "contrarian critic", + } + var out []evaluation + for _, persona := range personas { + out = append(out, scoreForPersona(kind, input, audience, persona, contextTweets)) + } + return out +} + +func scoreForPersona(kind, input, audience, persona string, contextTweets []tweet) evaluation { + words := strings.Fields(input) + lower := strings.ToLower(input) + length := len([]rune(input)) + hasSpecifics := regexp.MustCompile(`\d|/|github|repo|benchmark|trace|sqlite|go|codex|claude|agent`).MatchString(lower) + hasQuestion := strings.Contains(input, "?") + hasHype := regexp.MustCompile(`(?i)\b(revolutionary|game changer|insane|crazy|massive|excited|thrilled|unlock|leverage)\b`).MatchString(input) + hasConcreteTension := regexp.MustCompile(`(?i)\b(but|except|tradeoff|failed|slower|faster|because|measured|cost|risk)\b`).MatchString(input) + + clarity := clamp(7-boolInt(length > 280)*2-boolInt(len(words) > 45)+boolInt(length >= 70 && length <= 220), 1, 10) + novelty := clamp(4+boolInt(hasConcreteTension)*2+boolInt(hasSpecifics)*2-boolInt(hasHype), 1, 10) + credibility := clamp(4+boolInt(hasSpecifics)*3+boolInt(hasConcreteTension)-boolInt(hasHype)*2, 1, 10) + audienceFit := clamp(5+boolInt(strings.Contains(strings.ToLower(persona+" "+audience), "builder") && hasSpecifics)*2+boolInt(len(contextTweets) > 0), 1, 10) + replyPotential := clamp(4+boolInt(hasQuestion)*2+boolInt(hasConcreteTension)*2+boolInt(persona == "contrarian critic"), 1, 10) + risk := clamp(3+boolInt(hasHype)*3+boolInt(length > 280)*3+boolInt(!hasSpecifics && kind == "handle")+boolInt(strings.Count(input, "\n") > 4), 1, 10) + + rationale := "Likely response depends on whether the claim feels earned by concrete evidence." + if hasSpecifics { + rationale = "Concrete details make this easier to trust and discuss." + } + if hasConcreteTension { + rationale += " The visible tradeoff gives readers something to react to." + } + risks := "May read as generic if the surrounding thread lacks evidence." + if hasHype { + risks = "Hype language raises AI-tone and credibility risk." + } + if length > 280 { + risks = "Too long for a single tweet without compression." + } + return evaluation{ + Persona: persona, + Scores: map[string]int{ + "clarity": clarity, + "novelty": novelty, + "credibility": credibility, + "audience_fit": audienceFit, + "reply_potential": replyPotential, + "risk": risk, + }, + Rationale: rationale, + Risks: risks, + } +} + +func renderSessionReport(sessionID, kind, input, audience string, evals []evaluation, contextTweets []tweet) string { + var b strings.Builder + fmt.Fprintf(&b, "# x-sim %s evaluation\n\n", kind) + fmt.Fprintf(&b, "Session: `%s`\n\n", sessionID) + if audience != "" { + fmt.Fprintf(&b, "Audience: %s\n\n", audience) + } + fmt.Fprintf(&b, "## Input\n\n%s\n\n", input) + if len(evals) > 0 { + fmt.Fprintf(&b, "## Scores\n\n| Persona | Clarity | Novelty | Credibility | Fit | Reply | Risk |\n|---|---:|---:|---:|---:|---:|---:|\n") + for _, ev := range evals { + fmt.Fprintf(&b, "| %s | %d | %d | %d | %d | %d | %d |\n", + ev.Persona, ev.Scores["clarity"], ev.Scores["novelty"], ev.Scores["credibility"], + ev.Scores["audience_fit"], ev.Scores["reply_potential"], ev.Scores["risk"]) + } + avg := averageScore(evals) + fmt.Fprintf(&b, "\nAverage useful score: %.1f/10. Average risk: %.1f/10.\n\n", avg["useful"], avg["risk"]) + b.WriteString("## Persona Notes\n\n") + for _, ev := range evals { + fmt.Fprintf(&b, "- **%s**: %s Risk: %s\n", ev.Persona, ev.Rationale, ev.Risks) + } + } + if len(contextTweets) > 0 { + b.WriteString("\n## Local Evidence\n\n") + for i, tw := range contextTweets { + if i >= 5 { + break + } + fmt.Fprintf(&b, "- @%s: %s", tw.AuthorHandle, oneLine(tw.Text, 180)) + if tw.URL != "" { + fmt.Fprintf(&b, " (%s)", tw.URL) + } + fmt.Fprintln(&b) + } + } + b.WriteString("\n## Offline Boundary\n\nThis report is a local simulation from scraped context. It does not post or mutate X state.\n") + return b.String() +} + +func averageScore(evals []evaluation) map[string]float64 { + out := map[string]float64{"useful": 0, "risk": 0} + for _, ev := range evals { + out["useful"] += float64(ev.Scores["clarity"]+ev.Scores["novelty"]+ev.Scores["credibility"]+ev.Scores["audience_fit"]+ev.Scores["reply_potential"]) / 5 + out["risk"] += float64(ev.Scores["risk"]) + } + if len(evals) > 0 { + out["useful"] /= float64(len(evals)) + out["risk"] /= float64(len(evals)) + } + return out +} + +func topHandles(tweets []tweet, n int) []string { + counts := map[string]int{} + for _, tw := range tweets { + if tw.AuthorHandle != "" { + counts["@"+tw.AuthorHandle]++ + } + } + return topMap(counts, n) +} + +func topTerms(tweets []tweet, n int) []string { + stop := map[string]bool{ + "the": true, "and": true, "for": true, "that": true, "with": true, "you": true, + "this": true, "from": true, "are": true, "but": true, "not": true, "have": true, + "has": true, "was": true, "just": true, "your": true, "about": true, "into": true, + "https": true, "com": true, "x": true, "twitter": true, + } + counts := map[string]int{} + for _, tw := range tweets { + for _, term := range splitTerms(tw.Text) { + if len(term) >= 3 && !stop[term] { + counts[term]++ + } + } + } + return topMap(counts, n) +} + +func splitTerms(text string) []string { + return strings.FieldsFunc(strings.ToLower(text), func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' + }) +} + +func topMap(m map[string]int, n int) []string { + type item struct { + key string + count int + } + items := make([]item, 0, len(m)) + for k, v := range m { + items = append(items, item{k, v}) + } + sort.Slice(items, func(i, j int) bool { + if items[i].count == items[j].count { + return items[i].key < items[j].key + } + return items[i].count > items[j].count + }) + var out []string + for i, item := range items { + if i >= n { + break + } + out = append(out, fmt.Sprintf("%s (%d)", item.key, item.count)) + } + return out +} + +func parseWindowCutoff(raw string, now time.Time) (time.Time, error) { + raw = strings.TrimSpace(strings.ToLower(raw)) + if raw == "" { + raw = defaultRetention + } + now = now.UTC() + if len(raw) < 2 { + return time.Time{}, fmt.Errorf("invalid window %q", raw) + } + unit := raw[len(raw)-1] + n, err := strconv.Atoi(raw[:len(raw)-1]) + if err != nil || n <= 0 { + return time.Time{}, fmt.Errorf("invalid window %q", raw) + } + switch unit { + case 'd': + return now.AddDate(0, 0, -n), nil + case 'w': + return now.AddDate(0, 0, -7*n), nil + case 'm': + return now.AddDate(0, -n, 0), nil + case 'y': + return now.AddDate(-n, 0, 0), nil + default: + return time.Time{}, fmt.Errorf("invalid window %q; use d, w, m, or y", raw) + } +} + +func normalizeHandle(raw string) string { + return strings.TrimPrefix(strings.TrimSpace(raw), "@") +} + +func oneLine(raw string, limit int) string { + s := strings.Join(strings.Fields(raw), " ") + if len([]rune(s)) <= limit { + return s + } + runes := []rune(s) + return string(runes[:limit-1]) + "..." +} + +func clamp(n, min, max int) int { + if n < min { + return min + } + if n > max { + return max + } + return n +} + +func boolInt(v bool) int { + if v { + return 1 + } + return 0 +} + +func newSessionID(kind string) string { + return fmt.Sprintf("%s-%d", kind, time.Now().UnixNano()) +} diff --git a/plugins/dotagents/skills/x-sim/tools/x-sim/main_test.go b/plugins/dotagents/skills/x-sim/tools/x-sim/main_test.go new file mode 100644 index 0000000..389bcd6 --- /dev/null +++ b/plugins/dotagents/skills/x-sim/tools/x-sim/main_test.go @@ -0,0 +1,137 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestIngestAndBrief(t *testing.T) { + dir := t.TempDir() + t.Setenv("X_SIM_DB", filepath.Join(dir, "x-sim.sqlite")) + db, err := openDB("") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + raw := []byte(`[ + {"id":"1","text":"Agent evals need real traces and boring measurement.","created_at":"2026-05-01T10:00:00Z","author":{"screen_name":"builder"},"like_count":10,"reply_count":3}, + {"id":"2","text":"A vague AI launch post is not enough.","created_at":"2026-05-02T10:00:00Z","author":{"screen_name":"critic"},"like_count":3} + ]`) + n, err := ingestJSON(db, source{Kind: "search", Value: "agents"}, raw, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatal(err) + } + if n != 2 { + t.Fatalf("expected 2 tweets, got %d", n) + } + tweets, err := queryTweets(db, "agent", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), 10) + if err != nil { + t.Fatal(err) + } + report := renderBrief("agent", tweets, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) + if !strings.Contains(report, "@builder") || !strings.Contains(report, "measurement") { + t.Fatalf("brief missing expected content:\n%s", report) + } +} + +func TestIngestPreservesNumericSnowflakeIDs(t *testing.T) { + dir := t.TempDir() + t.Setenv("X_SIM_DB", filepath.Join(dir, "x-sim.sqlite")) + db, err := openDB("") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + raw := []byte(`[{"id":1879998888777666555,"text":"Numeric snowflake id.","created_at":"2026-05-01T10:00:00Z","author":{"screen_name":"builder"},"like_count":1879998888777666555}]`) + n, err := ingestJSON(db, source{Kind: "search", Value: "ids"}, raw, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("expected 1 tweet, got %d", n) + } + var id string + var likes int64 + if err := db.QueryRow(`SELECT id, like_count FROM tweets`).Scan(&id, &likes); err != nil { + t.Fatal(err) + } + if id != "1879998888777666555" { + t.Fatalf("id = %q, want 1879998888777666555", id) + } + if likes != 1879998888777666555 { + t.Fatalf("like_count = %d, want 1879998888777666555", likes) + } +} + +func TestExtractTweetsFromGraphQLResult(t *testing.T) { + raw := []byte(`{"tweet_results":{"result":{"rest_id":"2064394146916229443", + "core":{"user_results":{"result":{"rest_id":"999","legacy":{"screen_name":"claudeai","name":"Claude"}}}}, + "legacy":{"full_text":"Introducing Claude Fable 5.","created_at":"Tue Jun 09 17:08:13 +0000 2026","favorite_count":17693,"retweet_count":2100}}}}`) + var payload any + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + if err := dec.Decode(&payload); err != nil { + t.Fatal(err) + } + tweets := extractTweets(payload, source{Kind: "account", Value: "AnthropicAI"}, time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)) + if len(tweets) != 1 { + t.Fatalf("expected 1 tweet, got %d", len(tweets)) + } + tw := tweets[0] + if tw.ID != "2064394146916229443" || tw.AuthorHandle != "claudeai" || tw.AuthorName != "Claude" { + t.Fatalf("unexpected tweet identity: %+v", tw) + } + if tw.LikeCount != 17693 || tw.RepostCount != 2100 { + t.Fatalf("unexpected counts: %+v", tw) + } + if got, want := tw.PostedAt.Format(time.RFC3339), "2026-06-09T17:08:13Z"; got != want { + t.Fatalf("posted_at = %s, want %s", got, want) + } +} + +func TestEvalCommandStoresReport(t *testing.T) { + dir := t.TempDir() + t.Setenv("X_SIM_DB", filepath.Join(dir, "x-sim.sqlite")) + out := filepath.Join(dir, "report.md") + var stdout strings.Builder + if err := run([]string{"eval-tweet", "--text", "I measured agent eval latency on a small benchmark and the cheap path was slower.", "--out", out}, &stdout, os.Stderr); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(out) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "Offline Boundary") || !strings.Contains(string(data), "technical builder") { + t.Fatalf("unexpected report:\n%s", data) + } +} + +func TestParseWindowCutoffNormalizesUTC(t *testing.T) { + loc := time.FixedZone("GET", 4*60*60) + now := time.Date(2026, 5, 21, 12, 0, 0, 0, loc) + cutoff, err := parseWindowCutoff("30d", now) + if err != nil { + t.Fatal(err) + } + if cutoff.Location() != time.UTC { + t.Fatalf("cutoff location = %v, want UTC", cutoff.Location()) + } + if got, want := cutoff.Format(time.RFC3339), "2026-04-21T08:00:00Z"; got != want { + t.Fatalf("cutoff = %s, want %s", got, want) + } +} + +func TestParseWindowCutoffRejectsMalformedSuffix(t *testing.T) { + for _, raw := range []string{"1yd", "1dm", "d", "30"} { + if _, err := parseWindowCutoff(raw, time.Date(2026, 5, 21, 0, 0, 0, 0, time.UTC)); err == nil { + t.Fatalf("parseWindowCutoff(%q) succeeded, want error", raw) + } + } +} diff --git a/plugins/x-sim/.codex-plugin/plugin.json b/plugins/x-sim/.codex-plugin/plugin.json deleted file mode 100644 index 7252913..0000000 --- a/plugins/x-sim/.codex-plugin/plugin.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "x-sim", - "version": "0.1.0", - "description": "Offline X audience simulation for draft tweets, handle positioning, and promotion angles.", - "author": { - "name": "dotagents", - "url": "https://github.com/yourconscience/dotagents" - }, - "homepage": "https://github.com/yourconscience/dotagents/tree/main/skills/x-sim", - "repository": "https://github.com/yourconscience/dotagents", - "license": "Private", - "keywords": [ - "x", - "twitter", - "audience", - "simulation", - "evaluation" - ], - "skills": "./skills/", - "interface": { - "displayName": "x-sim", - "shortDescription": "Evaluate X drafts and handle positioning offline.", - "longDescription": "Packages the dotagents x-sim skill for offline audience simulation from locally scraped X context. The skill never posts or mutates X state.", - "developerName": "dotagents", - "category": "Productivity", - "capabilities": [ - "X", - "SQLite", - "Evaluation" - ], - "websiteURL": "https://github.com/yourconscience/dotagents", - "defaultPrompt": [ - "Use x-sim to evaluate this draft tweet offline.", - "Use x-sim to evaluate my handle positioning against local X context." - ] - } -} diff --git a/plugins/x-sim/README.md b/plugins/x-sim/README.md deleted file mode 100644 index 19e8b97..0000000 --- a/plugins/x-sim/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# x-sim Plugin - -Packaging scaffold for the dotagents `x-sim` skill. - -Canonical source: - -- Skill policy and workflow: `skills/x-sim/SKILL.md` -- Local CLI: `skills/x-sim/tools/x-sim` - -This plugin is offline-only. It does not post or mutate X state. diff --git a/plugins/x-sim/skills/x-sim/SKILL.md b/plugins/x-sim/skills/x-sim/SKILL.md deleted file mode 100644 index 237904a..0000000 --- a/plugins/x-sim/skills/x-sim/SKILL.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -name: x-sim -description: Offline X audience simulation and evaluation. Use when evaluating draft tweets, handle bios, pinned-post ideas, or promotion angles against real scraped X context without posting or mutating X state. ---- - -# x-sim - -Use `x-sim` to simulate how real X audiences may react to a draft tweet, bio, pinned post, or handle-promotion angle. - -This packaged copy mirrors the canonical dotagents skill at `skills/x-sim/SKILL.md`. The CLI lives in the dotagents checkout (`~/.agents`), not in this plugin tree. - -Hard boundary: never post, like, reply, follow, DM, or mutate X state. Read X data through `x-cli --json`, store local context in SQLite, and write markdown reports. - -Core workflow: - -```bash -go run ~/.agents/skills/x-sim/tools/x-sim init -go run ~/.agents/skills/x-sim/tools/x-sim source add-account @handle -go run ~/.agents/skills/x-sim/tools/x-sim source add-search "agent evals" -go run ~/.agents/skills/x-sim/tools/x-sim sync --since 6m --limit-per-source 200 -go run ~/.agents/skills/x-sim/tools/x-sim brief --topic "agent evals" --out /tmp/x-sim-brief.md -go run ~/.agents/skills/x-sim/tools/x-sim eval-tweet --text "draft tweet" --topic "agent evals" --out /tmp/x-sim-report.md -go run ~/.agents/skills/x-sim/tools/x-sim eval-handle --bio "..." --promotion "..." --out /tmp/x-sim-handle.md -``` - -Return a short markdown review with verdict, likely audience reaction, best revision, risks, and evidence from local scraped tweets. diff --git a/skills/gws/SKILL.md b/skills/gws/SKILL.md index 5c084e8..0a46f22 100644 --- a/skills/gws/SKILL.md +++ b/skills/gws/SKILL.md @@ -36,12 +36,12 @@ gws docs documents get --params '{"documentId": "ID"}' ## Google Doc To Markdown With Comments -When the user asks to download or fetch a specific Google Doc with comments in Markdown, use the skill-local helper instead of hand-assembling the pipeline: +When the user asks to download or fetch a specific Google Doc with comments in Markdown, use the skill-local helper instead of hand-assembling the pipeline (`$SKILL_DIR` = this skill's own directory): ``` -go run ~/.agents/skills/gws/tools/google_doc_markdown/main.go \ +go -C "$SKILL_DIR"/tools/google_doc_markdown run . \ --doc 'https://docs.google.com/document/d/FILE_ID/edit' \ - --output ./doc-with-comments.md + --output /absolute/path/doc-with-comments.md ``` Behavior: diff --git a/skills/pr-triage/SKILL.md b/skills/pr-triage/SKILL.md index 87701e0..6e02b41 100644 --- a/skills/pr-triage/SKILL.md +++ b/skills/pr-triage/SKILL.md @@ -7,17 +7,17 @@ description: Inspect PR failed checks and unresolved review comments, fix valid Inspect, fix, commit, push, wait, reinspect. One bounded cycle per invocation. -Prefer the configured GitHub MCP server for PR/check/thread data when the agent can access MCP tools. Use the local `pr-triage` tool for deterministic CLI inspection and hook runtime: +Prefer the configured GitHub MCP server for PR/check/thread data when the agent can access MCP tools. Use the local `pr-triage` tool for deterministic CLI inspection and hook runtime. `$SKILL_DIR` below means this skill's own directory (the base directory reported when the skill loads); the tool ships with the skill, so this works from any install location and from inside any Go module (`-C` runs in the tool dir; `PR_TRIAGE_PWD` keeps gh on the session repo): ```bash -go run ~/.agents/skills/pr-triage/tools/pr-triage inspect --format markdown -go run ~/.agents/skills/pr-triage/tools/pr-triage inspect --format json +PR_TRIAGE_PWD="$PWD" go -C "$SKILL_DIR"/tools/pr-triage run . inspect --format markdown +PR_TRIAGE_PWD="$PWD" go -C "$SKILL_DIR"/tools/pr-triage run . inspect --format json ``` The read-only Stop hook entrypoint is: ```bash -~/.agents/skills/pr-triage/hooks/stop.sh +"$SKILL_DIR"/hooks/stop.sh ``` ## Creation and merge gate @@ -37,7 +37,7 @@ If local commits were created on unrelated history while the remote base has com Run the tool first: ```bash -go run ~/.agents/skills/pr-triage/tools/pr-triage inspect --format markdown +PR_TRIAGE_PWD="$PWD" go -C "$SKILL_DIR"/tools/pr-triage run . inspect --format markdown ``` If MCP tools are available, use GitHub MCP to cross-check the same surface: diff --git a/skills/pr-triage/hooks/stop.sh b/skills/pr-triage/hooks/stop.sh index a66de72..4116196 100755 --- a/skills/pr-triage/hooks/stop.sh +++ b/skills/pr-triage/hooks/stop.sh @@ -4,7 +4,28 @@ set -euo pipefail skill_dir="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" tool_dir="$skill_dir/tools/pr-triage" +# Resolve the session's working directory so the tool inspects the repo the +# session is in, not this checkout. Hook stdin carries {"cwd": ...}; fall back +# to the hook's own cwd. +session_cwd="$PWD" +if [ ! -t 0 ]; then + payload="$(cat || true)" + if [ -n "$payload" ] && command -v jq >/dev/null 2>&1; then + payload_cwd="$(printf '%s' "$payload" | jq -r '.cwd // empty' 2>/dev/null || true)" + if [ -n "$payload_cwd" ] && [ -d "$payload_cwd" ]; then + session_cwd="$payload_cwd" + fi + fi +fi + +# Only inspect PRs when the session is inside a git work tree; otherwise stay quiet. +if ! git -C "$session_cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + printf '{"continue":true,"suppressOutput":true,"message":"pr-triage hook skipped: not a git repository"}\n' + exit 0 +fi + if [ -d "$tool_dir" ] && [ -f "$tool_dir/go.mod" ]; then + export PR_TRIAGE_PWD="$session_cwd" cd "$tool_dir" && exec go run . hook stop fi diff --git a/skills/pr-triage/tools/pr-triage/main.go b/skills/pr-triage/tools/pr-triage/main.go index 92bacf6..8ff2fbc 100644 --- a/skills/pr-triage/tools/pr-triage/main.go +++ b/skills/pr-triage/tools/pr-triage/main.go @@ -411,6 +411,12 @@ func writeHook(response hookResponse) error { func runGH(args ...string) (string, error) { cmd := exec.Command("gh", args...) + // The hook wrapper cds into this tool's module dir to `go run`; PR_TRIAGE_PWD + // carries the session's original working directory so gh resolves the repo + // the session is actually in, not the dotagents checkout. + if dir := strings.TrimSpace(os.Getenv("PR_TRIAGE_PWD")); dir != "" { + cmd.Dir = dir + } var stdout bytes.Buffer var stderr bytes.Buffer cmd.Stdout = &stdout diff --git a/skills/pr-triage/tools/pr-triage/pr-triage b/skills/pr-triage/tools/pr-triage/pr-triage new file mode 100755 index 0000000..0659b2d Binary files /dev/null and b/skills/pr-triage/tools/pr-triage/pr-triage differ diff --git a/skills/remote-access/SKILL.md b/skills/remote-access/SKILL.md index 6307dbc..9f90297 100644 --- a/skills/remote-access/SKILL.md +++ b/skills/remote-access/SKILL.md @@ -118,10 +118,10 @@ Next action: ## Bridge maintenance -Build or refresh the Mac runtime binary: +Build or refresh the Mac runtime binary (`$SKILL_DIR` = this skill's own directory): ```bash -~/.agents/skills/remote-access/tools/remote-access-bridge/build.sh +"$SKILL_DIR"/tools/remote-access-bridge/build.sh launchctl kickstart -k "gui/$(id -u)/com.conscience.remote-access.bridge" ```