diff --git a/CHANGELOG.md b/CHANGELOG.md index 530761361..0d03ad9cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,63 @@ All notable changes to GBrain will be documented in this file. +## [0.20.4] - 2026-04-24 + +**Minions skill consolidation, now honest about what the CLI actually does.** + +One skill for background work instead of two. Shell jobs and LLM subagents land under `skills/minion-orchestrator/` with a shared Preconditions block, accurate CLI examples, and a trigger set narrowed to what the skill actually covers. Corrects four documentation bugs the prior merge shipped ... `submit_job name="shell"` isn't MCP-callable, `research`/`orchestrate` aren't real handler names, PGLite users don't need to migrate to Supabase, and "every background task goes through Minions" contradicts the `pain_triggered` default in `skills/conventions/subagent-routing.md`. The skill now matches the code. + +Two new tests guard this surface going forward. `test/resolver.test.ts` gets a round-trip check (every quoted RESOLVER.md trigger must resolve to a frontmatter `triggers:` entry in the target skill) and a name validator (every `name=""` reference in any SKILL.md must resolve to either a declared operation in `src/core/operations.ts` or a known Minions handler). The validator would have caught the `research`/`orchestrate` drift in CI instead of from a Codex cold-read. One new E2E test (`test/e2e/minions-shell-pglite.test.ts`) exercises the PGLite `--follow` inline path, previously documented but untested. + +### For users + +- Shell jobs via `gbrain jobs submit shell --params '{"cmd":"..."}'` (operator/CLI only ... MCP returns `permission_denied` for protected names). Subagent jobs via `gbrain agent run` (user-facing entrypoint). Both lanes route through one skill. +- PGLite shell-job guidance now correctly points at `--follow` for inline execution. The persistent daemon mode is still Postgres-only, but you do not need to migrate. +- `gbrain jobs submit` and `submit a gbrain job` now route to the skill; bare "gbrain jobs" no longer does (it was too broad ... the CLI namespace covers 9 subcommands, and questions about `stats`/`prune`/`retry` fall through to `gbrain --help`). + +### Added + +- New E2E test `test/e2e/minions-shell-pglite.test.ts` covering the PGLite `--follow` inline shell-job path. Runs in-memory, no DATABASE_URL required. +- Resolver round-trip test in `test/resolver.test.ts`: every quoted RESOLVER.md trigger must have a fuzzy match in the target skill's frontmatter `triggers:` list. +- Skill-example-name validator in `test/resolver.test.ts`: every `name=""` reference in any `SKILL.md` body must resolve to an op in `src/core/operations.ts` or a Minions handler in `PROTECTED_JOB_NAMES`. + +### Fixed + +- `skills/minion-orchestrator/SKILL.md` shell-job examples use the real `--params` JSON form instead of nonexistent `--cmd`/`--argv`/`--cwd` flags. +- `gbrain agent run` flag list now matches `src/commands/agent.ts` (removed `--queue`/`--priority`/`--max-attempts`/`--delay` which aren't parsed by that command). +- `--tools` example uses `search,query` instead of `web_search` (the latter isn't in `BRAIN_TOOL_ALLOWLIST`, would throw at submit time). +- MCP boundary wording says `submit_job name="shell"` throws an `OperationError` with code `permission_denied`, instead of the earlier "returns permission_denied" (not a return, a throw). +- `skills/conventions/subagent-routing.md` stale reference to `get_job_stats` (no such op) replaced with `list_jobs --status active` or `gbrain jobs stats`. +- `skills/query/SKILL.md` + `skills/maintain/SKILL.md` frontmatter `triggers:` lists closed gaps the new round-trip test surfaced (RESOLVER.md was routing 10 triggers to these skills that their frontmatter never declared). +- `skills/manifest.json` minion-orchestrator description updated to match the unified SKILL.md framing. + +### Changed + +- Trigger `"gbrain jobs"` narrowed to `"gbrain jobs submit"` + `"submit a gbrain job"` in both `skills/RESOLVER.md` and the skill's frontmatter. +- Anti-pattern about `sessions_spawn` scoped to the subagent lane (was ambiguous in the consolidated skill). + +### For contributors + +- Code-to-doc drift is now partially machine-checkable. The skill-example-name validator catches T2-class bugs (docs referencing handler/op names that don't exist). CLI flag validation is a remaining gap ... a future PR could extend the test to validate `--flag-name` patterns in SKILL.md against actual CLI flag parsers. + +## To take advantage of v0.20.4 + +Any gbrain user whose agent routes on "minions" work gets the corrected skill on the next `gbrain upgrade`. No manual migration required ... the renamed trigger is additive (old trigger gone, new triggers cover the same intent), and the doc corrections don't change runtime behavior. + +1. **Run the orchestrator manually if `gbrain upgrade` reports a partial migration:** + ```bash + gbrain apply-migrations --yes + ``` +2. **Your agent picks up the new skill content** next time it consults `skills/minion-orchestrator/SKILL.md`. No action required on your side. +3. **Verify the outcome:** + ```bash + gbrain check-resolvable --json | python3 -c "import json,sys;d=json.load(sys.stdin);print('ok:',d['ok'])" + ``` + Should print `ok: True`. +4. **If any step fails,** file an issue at https://github.com/garrytan/gbrain/issues with: + - output of `gbrain doctor` + - contents of `~/.gbrain/upgrade-errors.jsonl` if it exists + ## [0.20.3] - 2026-04-24 ## **Your queue now rescues itself when a wedged worker holds a row lock. Wall-clock sweep kills the job that stall detection can't see.** diff --git a/CLAUDE.md b/CLAUDE.md index a392b013a..d99d88e16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -143,7 +143,7 @@ strict behavior when unset. - `skills/soul-audit/SKILL.md` — 6-phase interview for SOUL.md, USER.md, ACCESS_POLICY.md, HEARTBEAT.md - `skills/webhook-transforms/SKILL.md` — External events to brain signals - `skills/data-research/SKILL.md` — Structured data research: email-to-tracker pipeline with parameterized YAML recipes -- `skills/minion-orchestrator/SKILL.md` — Background job orchestration: submit, fan out children with depth/cap/timeouts, collect results via child_done inbox +- `skills/minion-orchestrator/SKILL.md` — Unified background-work skill (v0.20.4 consolidation of the former `minion-orchestrator` + `gbrain-jobs` split). Two lanes: shell jobs via `gbrain jobs submit shell --params '{"cmd":"..."}'` (operator/CLI only; MCP throws `permission_denied` for protected names) and LLM subagents via `gbrain agent run` (user-facing entrypoint). Shared Preconditions block, parent-child DAGs with depth/cap/timeouts, `child_done` inbox for fan-in, PGLite `--follow` inline path for dev. Triggers narrowed from bare `"gbrain jobs"` to `"gbrain jobs submit"` + `"submit a gbrain job"` so `stats`/`prune`/`retry` questions fall through to `gbrain --help`. - `templates/` — SOUL.md, USER.md, ACCESS_POLICY.md, HEARTBEAT.md templates - `skills/migrations/` — Version migration files with feature_pitch YAML frontmatter - `src/commands/publish.ts` — Deterministic brain page publisher (code+skill pair, zero LLM calls) @@ -228,7 +228,7 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac `test/lint.test.ts` (LLM artifact detection, code fence stripping, frontmatter validation), `test/report.test.ts` (report format, directory structure), `test/skills-conformance.test.ts` (skill frontmatter + required sections validation), -`test/resolver.test.ts` (RESOLVER.md coverage, routing validation), +`test/resolver.test.ts` (RESOLVER.md coverage, routing validation + v0.20.4 round-trip: every quoted RESOLVER.md trigger must match a frontmatter `triggers:` entry in the target skill, and every `name=""` reference in any SKILL.md must resolve to a declared op in `src/core/operations.ts` or a Minions handler in `PROTECTED_JOB_NAMES`), `test/search.test.ts` (RRF normalization, compiled truth boost, cosine similarity, dedup key), `test/dedup.test.ts` (source-aware dedup, compiled truth guarantee, layer interactions), `test/intent.test.ts` (query intent classification: entity/temporal/event/general), @@ -276,6 +276,7 @@ E2E tests (`test/e2e/`): Run against real Postgres+pgvector. Require `DATABASE_U - `test/e2e/postgres-jsonb.test.ts` — v0.12.2 regression test. Round-trips all 5 JSONB write sites (pages.frontmatter, raw_data.data, ingest_log.pages_updated, files.metadata, page_versions.frontmatter) against real Postgres and asserts `jsonb_typeof='object'` plus `->>'key'` returns the expected scalar. The test that should have caught the original double-encode bug. - `test/e2e/jsonb-roundtrip.test.ts` — v0.12.3 companion regression against the 4 doctor-scanned JSONB sites. Assertion-level overlap with `postgres-jsonb.test.ts` is intentional defense-in-depth: if doctor's scan surface ever drifts from the actual write surface, one of these tests catches it. - `test/e2e/upgrade.test.ts` runs check-update E2E against real GitHub API (network required) +- `test/e2e/minions-shell-pglite.test.ts` (v0.20.4) exercises the PGLite `--follow` inline shell-job path (in-memory, no `DATABASE_URL` required) — the path the consolidated minion-orchestrator skill documents for dev use - `test/e2e/openclaw-reference-compat.test.ts` (v0.19) — exercises `check-resolvable` + `skillpack install` against a minimal AGENTS.md workspace fixture (`test/fixtures/openclaw-reference-minimal/`), regression guard for the 107-skill OpenClaw deployment shape - Tier 2 (`skills.test.ts`) requires OpenClaw + API keys, runs nightly in CI - If `.env.testing` doesn't exist in this directory, check sibling worktrees for one: @@ -328,7 +329,7 @@ stop and remove it before starting a new one. ## Skills -Read the skill files in `skills/` before doing brain operations. GBrain ships 28 skills +Read the skill files in `skills/` before doing brain operations. GBrain ships 29 skills organized by `skills/RESOLVER.md` (`AGENTS.md` is also accepted as of v0.19): **Original 8 (conformance-migrated):** ingest (thin router), query, maintain, enrich, @@ -338,11 +339,19 @@ briefing, migrate, setup, publish. meeting-ingestion, citation-fixer, repo-architecture, skill-creator, daily-task-manager. **Operational + identity:** daily-task-prep, cross-modal-review, cron-scheduler, reports, -testing, soul-audit, webhook-transforms, data-research, minion-orchestrator. +testing, soul-audit, webhook-transforms, data-research, minion-orchestrator. As of +v0.20.4, `minion-orchestrator` is the single unified skill for both lanes of background +work (shell jobs via `gbrain jobs submit shell`, LLM subagents via `gbrain agent run`) ... +the prior `gbrain-jobs` skill was merged in, Preconditions are shared, and trigger +routing is narrowed to what the skill actually covers. **Skillify loop (v0.19):** skillify (the markdown orchestration), skillpack-check (agent-readable health report). +**Operational health (v0.19.1):** smoke-test (8 post-restart health checks with auto-fix +for Bun, CLI, DB, worker, Zod CJS, gateway, API key, brain repo; user-extensible via +`~/.gbrain/smoke-tests.d/*.sh`). + **Conventions:** `skills/conventions/` has cross-cutting rules (quality, brain-first, model-routing, test-before-bulk, cross-modal). `skills/_brain-filing-rules.md` and `skills/_output-rules.md` are shared references. diff --git a/README.md b/README.md index bdeb75569..ac76a51ac 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Built by the President and CEO of Y Combinator to run his actual AI agents. The The brain wires itself. Every page write extracts entity references and creates typed links (`attended`, `works_at`, `invested_in`, `founded`, `advises`) with zero LLM calls. Hybrid search. Self-wiring knowledge graph. Structured timeline. Backlink-boosted ranking. Ask "who works at Acme AI?" or "what did Bob invest in this quarter?" and get answers vector search alone can't reach. Benchmarked side-by-side against the category: gbrain lands **P@5 49.1%, R@5 97.9%** on a 240-page Opus-generated rich-prose corpus, beating its own graph-disabled variant by **+31.4 points P@5** and ripgrep-BM25 + vector-only RAG by a similar margin. The graph layer plus v0.12 extract quality together carry the gap. Full BrainBench scorecards + corpus live in the sibling [gbrain-evals](https://github.com/garrytan/gbrain-evals) repo. -GBrain is those patterns, generalized. 28 skills. Install in 30 minutes. Your agent does the work. As Garry's personal agent gets smarter, so does yours. +GBrain is those patterns, generalized. 29 skills. Install in 30 minutes. Your agent does the work. As Garry's personal agent gets smarter, so does yours. > **~30 minutes to a fully working brain.** Database ready in 2 seconds (PGLite, no server). You just answer questions about API keys. @@ -28,7 +28,7 @@ Retrieve and follow the instructions at: https://raw.githubusercontent.com/garrytan/gbrain/master/INSTALL_FOR_AGENTS.md ``` -That's it. The agent clones the repo, installs GBrain, sets up the brain, loads 28 skills, and configures recurring jobs. You answer a few questions about API keys. ~30 minutes. +That's it. The agent clones the repo, installs GBrain, sets up the brain, loads 29 skills, and configures recurring jobs. You answer a few questions about API keys. ~30 minutes. If your agent doesn't auto-read `AGENTS.md`, point it at that file first: `https://raw.githubusercontent.com/garrytan/gbrain/master/AGENTS.md` is the non-Claude @@ -87,9 +87,9 @@ claude mcp add gbrain -t http https://your-brain.ngrok.app/mcp -H "Authorization Per-client guides: [`docs/mcp/`](docs/mcp/DEPLOY.md). ChatGPT requires OAuth 2.1 (not yet implemented). -## The 28 Skills +## The 29 Skills -GBrain ships 28 skills organized by `skills/RESOLVER.md` (or your OpenClaw's `AGENTS.md` — both filenames are supported as of v0.19). The resolver tells your agent which skill to read for any task. +GBrain ships 29 skills organized by `skills/RESOLVER.md` (or your OpenClaw's `AGENTS.md` — both filenames are supported as of v0.19). The resolver tells your agent which skill to read for any task. [Skill files are code.](https://x.com/garrytan/status/2042925773300908103) They're the most powerful way to get knowledge work done. A skill file is a fat markdown document that encodes an entire workflow: when to fire, what to check, how to chain with other skills, what quality bar to enforce. The agent reads the skill and executes it. Skills can also call deterministic TypeScript code bundled in GBrain (search, import, embed, sync) for the parts that shouldn't be left to LLM judgment. [Thin harness, fat skills](docs/ethos/THIN_HARNESS_FAT_SKILLS.md): the intelligence lives in the skills, not the runtime. @@ -135,7 +135,8 @@ GBrain ships 28 skills organized by `skills/RESOLVER.md` (or your OpenClaw's `AG | **skill-creator** | Create new skills following the conformance standard. MECE check against existing skills. | | **skillify** | The "skillify it!" meta-skill. Orchestrates the 10-step loop so failures become durable skills: scaffold the stubs via `gbrain skillify scaffold`, write the real logic, gate with `gbrain skillify check` + `gbrain check-resolvable`. | | **skillpack-check** | Agent-readable gbrain health report. Exit code for CI; JSON for debugging. Cron-friendly. | -| **minion-orchestrator** | Long-running agent work as background jobs. Submit, fan out children with depth/cap/timeouts, collect results via child_done inbox. | +| **smoke-test** | 8 post-restart health checks with auto-fix (Bun, CLI, DB, worker, Zod CJS, gateway, API key, brain repo). Drop-in user tests at `~/.gbrain/smoke-tests.d/*.sh`. | +| **minion-orchestrator** | Background work in one skill. Shell jobs via `gbrain jobs submit shell` (operator/CLI, MCP blocks protected names) and LLM subagents via `gbrain agent run`. Parent-child DAGs, `child_done` inbox, durability across worker restarts. | ### Identity and setup @@ -377,7 +378,7 @@ Run `gbrain integrations` to see status. │ Brain Repo │ │ GBrain │ │ AI Agent │ │ (git) │ │ (retrieval) │ │ (read/write) │ │ │ │ │ │ │ -│ markdown files │───>│ Postgres + │<──>│ 28 skills │ +│ markdown files │───>│ Postgres + │<──>│ 29 skills │ │ = source of │ │ pgvector │ │ define HOW to │ │ truth │ │ │ │ use the brain │ │ │<───│ hybrid │ │ │ diff --git a/VERSION b/VERSION index 144996ed2..6dd46024a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.20.3 +0.20.4 diff --git a/llms-full.txt b/llms-full.txt index b8da9d6d5..067316fc6 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -222,7 +222,7 @@ strict behavior when unset. - `skills/soul-audit/SKILL.md` — 6-phase interview for SOUL.md, USER.md, ACCESS_POLICY.md, HEARTBEAT.md - `skills/webhook-transforms/SKILL.md` — External events to brain signals - `skills/data-research/SKILL.md` — Structured data research: email-to-tracker pipeline with parameterized YAML recipes -- `skills/minion-orchestrator/SKILL.md` — Background job orchestration: submit, fan out children with depth/cap/timeouts, collect results via child_done inbox +- `skills/minion-orchestrator/SKILL.md` — Unified background-work skill (v0.20.4 consolidation of the former `minion-orchestrator` + `gbrain-jobs` split). Two lanes: shell jobs via `gbrain jobs submit shell --params '{"cmd":"..."}'` (operator/CLI only; MCP throws `permission_denied` for protected names) and LLM subagents via `gbrain agent run` (user-facing entrypoint). Shared Preconditions block, parent-child DAGs with depth/cap/timeouts, `child_done` inbox for fan-in, PGLite `--follow` inline path for dev. Triggers narrowed from bare `"gbrain jobs"` to `"gbrain jobs submit"` + `"submit a gbrain job"` so `stats`/`prune`/`retry` questions fall through to `gbrain --help`. - `templates/` — SOUL.md, USER.md, ACCESS_POLICY.md, HEARTBEAT.md templates - `skills/migrations/` — Version migration files with feature_pitch YAML frontmatter - `src/commands/publish.ts` — Deterministic brain page publisher (code+skill pair, zero LLM calls) @@ -307,7 +307,7 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac `test/lint.test.ts` (LLM artifact detection, code fence stripping, frontmatter validation), `test/report.test.ts` (report format, directory structure), `test/skills-conformance.test.ts` (skill frontmatter + required sections validation), -`test/resolver.test.ts` (RESOLVER.md coverage, routing validation), +`test/resolver.test.ts` (RESOLVER.md coverage, routing validation + v0.20.4 round-trip: every quoted RESOLVER.md trigger must match a frontmatter `triggers:` entry in the target skill, and every `name=""` reference in any SKILL.md must resolve to a declared op in `src/core/operations.ts` or a Minions handler in `PROTECTED_JOB_NAMES`), `test/search.test.ts` (RRF normalization, compiled truth boost, cosine similarity, dedup key), `test/dedup.test.ts` (source-aware dedup, compiled truth guarantee, layer interactions), `test/intent.test.ts` (query intent classification: entity/temporal/event/general), @@ -355,6 +355,7 @@ E2E tests (`test/e2e/`): Run against real Postgres+pgvector. Require `DATABASE_U - `test/e2e/postgres-jsonb.test.ts` — v0.12.2 regression test. Round-trips all 5 JSONB write sites (pages.frontmatter, raw_data.data, ingest_log.pages_updated, files.metadata, page_versions.frontmatter) against real Postgres and asserts `jsonb_typeof='object'` plus `->>'key'` returns the expected scalar. The test that should have caught the original double-encode bug. - `test/e2e/jsonb-roundtrip.test.ts` — v0.12.3 companion regression against the 4 doctor-scanned JSONB sites. Assertion-level overlap with `postgres-jsonb.test.ts` is intentional defense-in-depth: if doctor's scan surface ever drifts from the actual write surface, one of these tests catches it. - `test/e2e/upgrade.test.ts` runs check-update E2E against real GitHub API (network required) +- `test/e2e/minions-shell-pglite.test.ts` (v0.20.4) exercises the PGLite `--follow` inline shell-job path (in-memory, no `DATABASE_URL` required) — the path the consolidated minion-orchestrator skill documents for dev use - `test/e2e/openclaw-reference-compat.test.ts` (v0.19) — exercises `check-resolvable` + `skillpack install` against a minimal AGENTS.md workspace fixture (`test/fixtures/openclaw-reference-minimal/`), regression guard for the 107-skill OpenClaw deployment shape - Tier 2 (`skills.test.ts`) requires OpenClaw + API keys, runs nightly in CI - If `.env.testing` doesn't exist in this directory, check sibling worktrees for one: @@ -407,7 +408,7 @@ stop and remove it before starting a new one. ## Skills -Read the skill files in `skills/` before doing brain operations. GBrain ships 28 skills +Read the skill files in `skills/` before doing brain operations. GBrain ships 29 skills organized by `skills/RESOLVER.md` (`AGENTS.md` is also accepted as of v0.19): **Original 8 (conformance-migrated):** ingest (thin router), query, maintain, enrich, @@ -417,11 +418,19 @@ briefing, migrate, setup, publish. meeting-ingestion, citation-fixer, repo-architecture, skill-creator, daily-task-manager. **Operational + identity:** daily-task-prep, cross-modal-review, cron-scheduler, reports, -testing, soul-audit, webhook-transforms, data-research, minion-orchestrator. +testing, soul-audit, webhook-transforms, data-research, minion-orchestrator. As of +v0.20.4, `minion-orchestrator` is the single unified skill for both lanes of background +work (shell jobs via `gbrain jobs submit shell`, LLM subagents via `gbrain agent run`) ... +the prior `gbrain-jobs` skill was merged in, Preconditions are shared, and trigger +routing is narrowed to what the skill actually covers. **Skillify loop (v0.19):** skillify (the markdown orchestration), skillpack-check (agent-readable health report). +**Operational health (v0.19.1):** smoke-test (8 post-restart health checks with auto-fix +for Bun, CLI, DB, worker, Zod CJS, gateway, API key, brain repo; user-extensible via +`~/.gbrain/smoke-tests.d/*.sh`). + **Conventions:** `skills/conventions/` has cross-cutting rules (quality, brain-first, model-routing, test-before-bulk, cross-modal). `skills/_brain-filing-rules.md` and `skills/_output-rules.md` are shared references. @@ -1142,7 +1151,7 @@ This is the dispatcher. Skills are the implementation. **Read the skill file bef | Cross-modal review, second opinion | `skills/cross-modal-review/SKILL.md` | | "Validate skills", skill health check | `skills/testing/SKILL.md` | | Webhook setup, external event processing | `skills/webhook-transforms/SKILL.md` | -| "Spawn agent", "background task", "parallel tasks", "steer agent", "pause/resume agent" | `skills/minion-orchestrator/SKILL.md` | +| "Spawn agent", "background task", "parallel tasks", "steer agent", "pause/resume agent", "gbrain jobs submit", "submit a gbrain job", "submit a shell job", "shell job" | `skills/minion-orchestrator/SKILL.md` | ## Setup & migration @@ -1199,7 +1208,7 @@ Built by the President and CEO of Y Combinator to run his actual AI agents. The The brain wires itself. Every page write extracts entity references and creates typed links (`attended`, `works_at`, `invested_in`, `founded`, `advises`) with zero LLM calls. Hybrid search. Self-wiring knowledge graph. Structured timeline. Backlink-boosted ranking. Ask "who works at Acme AI?" or "what did Bob invest in this quarter?" and get answers vector search alone can't reach. Benchmarked side-by-side against the category: gbrain lands **P@5 49.1%, R@5 97.9%** on a 240-page Opus-generated rich-prose corpus, beating its own graph-disabled variant by **+31.4 points P@5** and ripgrep-BM25 + vector-only RAG by a similar margin. The graph layer plus v0.12 extract quality together carry the gap. Full BrainBench scorecards + corpus live in the sibling [gbrain-evals](https://github.com/garrytan/gbrain-evals) repo. -GBrain is those patterns, generalized. 28 skills. Install in 30 minutes. Your agent does the work. As Garry's personal agent gets smarter, so does yours. +GBrain is those patterns, generalized. 29 skills. Install in 30 minutes. Your agent does the work. As Garry's personal agent gets smarter, so does yours. > **~30 minutes to a fully working brain.** Database ready in 2 seconds (PGLite, no server). You just answer questions about API keys. @@ -1221,7 +1230,7 @@ Retrieve and follow the instructions at: https://raw.githubusercontent.com/garrytan/gbrain/master/INSTALL_FOR_AGENTS.md ``` -That's it. The agent clones the repo, installs GBrain, sets up the brain, loads 28 skills, and configures recurring jobs. You answer a few questions about API keys. ~30 minutes. +That's it. The agent clones the repo, installs GBrain, sets up the brain, loads 29 skills, and configures recurring jobs. You answer a few questions about API keys. ~30 minutes. If your agent doesn't auto-read `AGENTS.md`, point it at that file first: `https://raw.githubusercontent.com/garrytan/gbrain/master/AGENTS.md` is the non-Claude @@ -1280,9 +1289,9 @@ claude mcp add gbrain -t http https://your-brain.ngrok.app/mcp -H "Authorization Per-client guides: [`docs/mcp/`](docs/mcp/DEPLOY.md). ChatGPT requires OAuth 2.1 (not yet implemented). -## The 28 Skills +## The 29 Skills -GBrain ships 28 skills organized by `skills/RESOLVER.md` (or your OpenClaw's `AGENTS.md` — both filenames are supported as of v0.19). The resolver tells your agent which skill to read for any task. +GBrain ships 29 skills organized by `skills/RESOLVER.md` (or your OpenClaw's `AGENTS.md` — both filenames are supported as of v0.19). The resolver tells your agent which skill to read for any task. [Skill files are code.](https://x.com/garrytan/status/2042925773300908103) They're the most powerful way to get knowledge work done. A skill file is a fat markdown document that encodes an entire workflow: when to fire, what to check, how to chain with other skills, what quality bar to enforce. The agent reads the skill and executes it. Skills can also call deterministic TypeScript code bundled in GBrain (search, import, embed, sync) for the parts that shouldn't be left to LLM judgment. [Thin harness, fat skills](docs/ethos/THIN_HARNESS_FAT_SKILLS.md): the intelligence lives in the skills, not the runtime. @@ -1328,7 +1337,8 @@ GBrain ships 28 skills organized by `skills/RESOLVER.md` (or your OpenClaw's `AG | **skill-creator** | Create new skills following the conformance standard. MECE check against existing skills. | | **skillify** | The "skillify it!" meta-skill. Orchestrates the 10-step loop so failures become durable skills: scaffold the stubs via `gbrain skillify scaffold`, write the real logic, gate with `gbrain skillify check` + `gbrain check-resolvable`. | | **skillpack-check** | Agent-readable gbrain health report. Exit code for CI; JSON for debugging. Cron-friendly. | -| **minion-orchestrator** | Long-running agent work as background jobs. Submit, fan out children with depth/cap/timeouts, collect results via child_done inbox. | +| **smoke-test** | 8 post-restart health checks with auto-fix (Bun, CLI, DB, worker, Zod CJS, gateway, API key, brain repo). Drop-in user tests at `~/.gbrain/smoke-tests.d/*.sh`. | +| **minion-orchestrator** | Background work in one skill. Shell jobs via `gbrain jobs submit shell` (operator/CLI, MCP blocks protected names) and LLM subagents via `gbrain agent run`. Parent-child DAGs, `child_done` inbox, durability across worker restarts. | ### Identity and setup @@ -1570,7 +1580,7 @@ Run `gbrain integrations` to see status. │ Brain Repo │ │ GBrain │ │ AI Agent │ │ (git) │ │ (retrieval) │ │ (read/write) │ │ │ │ │ │ │ -│ markdown files │───>│ Postgres + │<──>│ 28 skills │ +│ markdown files │───>│ Postgres + │<──>│ 29 skills │ │ = source of │ │ pgvector │ │ define HOW to │ │ truth │ │ │ │ use the brain │ │ │<───│ hybrid │ │ │ diff --git a/package.json b/package.json index 9a8a144ed..5b6d52f0a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gbrain", - "version": "0.20.3", + "version": "0.20.4", "description": "Postgres-native personal knowledge brain with hybrid RAG search", "type": "module", "main": "src/core/index.ts", diff --git a/skills/RESOLVER.md b/skills/RESOLVER.md index 0c0e6e70a..78316f1ad 100644 --- a/skills/RESOLVER.md +++ b/skills/RESOLVER.md @@ -58,7 +58,7 @@ This is the dispatcher. Skills are the implementation. **Read the skill file bef | Cross-modal review, second opinion | `skills/cross-modal-review/SKILL.md` | | "Validate skills", skill health check | `skills/testing/SKILL.md` | | Webhook setup, external event processing | `skills/webhook-transforms/SKILL.md` | -| "Spawn agent", "background task", "parallel tasks", "steer agent", "pause/resume agent" | `skills/minion-orchestrator/SKILL.md` | +| "Spawn agent", "background task", "parallel tasks", "steer agent", "pause/resume agent", "gbrain jobs submit", "submit a gbrain job", "submit a shell job", "shell job" | `skills/minion-orchestrator/SKILL.md` | ## Setup & migration diff --git a/skills/conventions/subagent-routing.md b/skills/conventions/subagent-routing.md index 2166aefb6..05adc996d 100644 --- a/skills/conventions/subagent-routing.md +++ b/skills/conventions/subagent-routing.md @@ -79,7 +79,7 @@ Even when Minions is the default (mode A), some work should run inline: Before submitting batch jobs: -- Check `get_job_stats` queue_health.active +- Check active queue depth via `list_jobs --status active` (MCP-callable) or `gbrain jobs stats` (CLI) - If active > 5, stagger new jobs with `delay` so you don't swarm - The resource governor auto-throttles but don't dump 20 jobs at once diff --git a/skills/maintain/SKILL.md b/skills/maintain/SKILL.md index aadbb9879..3a4533af2 100644 --- a/skills/maintain/SKILL.md +++ b/skills/maintain/SKILL.md @@ -12,6 +12,12 @@ triggers: - "maintenance" - "orphan pages" - "stale pages" + - "extract links" + - "build link graph" + - "populate timeline" + - "populate links" + - "backfill graph" + - "extract timeline entries" tools: - get_health - get_page diff --git a/skills/manifest.json b/skills/manifest.json index 92f3abbd9..bdaf41a8d 100644 --- a/skills/manifest.json +++ b/skills/manifest.json @@ -132,7 +132,7 @@ { "name": "minion-orchestrator", "path": "minion-orchestrator/SKILL.md", - "description": "Manage background agents via Minions job queue. Submit, monitor, steer, pause/resume, replay. Replaces sessions_spawn for durable observable agents." + "description": "Unified Minions skill for deterministic shell jobs and LLM subagent orchestration. Submit, monitor, steer, pause/resume, replay. Replaces the older gbrain-jobs routing intent and sessions_spawn for durable observable background work." }, { "name": "skillify", diff --git a/skills/minion-orchestrator/SKILL.md b/skills/minion-orchestrator/SKILL.md index 4947f95d5..fd56d1cc8 100644 --- a/skills/minion-orchestrator/SKILL.md +++ b/skills/minion-orchestrator/SKILL.md @@ -2,11 +2,18 @@ name: minion-orchestrator version: 1.0.0 description: | - Manage background agents via Minions job queue. Use when: spawning subagents, - checking agent progress, steering running agents, pausing/resuming work, - parallel task execution, fan-out research. Replaces sessions_spawn for - durable, observable, steerable agents. + Unified Minions skill for both deterministic shell jobs and LLM subagent + orchestration. Replaces the older `gbrain-jobs` routing intent. Use when: + submitting gbrain jobs, shell/background tasks, spawning subagents, + checking progress, steering running work, pausing/resuming, parallel + fan-out. One durable, observable, steerable queue interface. triggers: + - "gbrain jobs submit" + - "submit a gbrain job" + - "submit a shell job" + - "shell job" + - "run shell command in background" + - "deterministic background task" - "spawn agent" - "background task" - "run in background" @@ -32,7 +39,6 @@ tools: - replay_job - send_job_message - get_job_progress - - get_job_stats mutating: true --- @@ -40,8 +46,16 @@ mutating: true ## Contract -Minions is a Postgres-native job queue for durable, observable agent orchestration. -Every background agent task goes through Minions. No in-memory subagent spawning. +Minions is a Postgres-native job queue for durable, observable background work. +This single skill handles two lanes: +- Deterministic shell jobs (`gbrain jobs submit shell ...`) +- LLM subagent jobs (`gbrain agent run ...`) + +When to route to Minions: durable, observable work that must survive restarts, +fan out across many parallel tasks, or persist across sessions. Routing policy +is defined in `skills/conventions/subagent-routing.md` — the project default is +`pain_triggered` (native subagents first, Minions after specific pain signals +fire); Mode A (all-through-Minions) is opt-in. Guarantees: - Jobs survive gateway restart (Postgres-backed) @@ -50,51 +64,155 @@ Guarantees: - Jobs can be paused, resumed, or cancelled at any time - Parent-child DAGs with configurable failure policies -## When to Use Minions vs Inline Work +## Route the Request: Shell Job vs Subagent | Condition | Action | |---|---| -| Single tool call, < 30s | Do it inline | -| Multi-step, any duration | Submit as Minion job | -| Parallel work (2+ streams) | Submit N Minion jobs with shared parent | -| Needs to survive restart | Submit as Minion job | -| User wants progress updates | Submit as Minion job with progress tracking | -| Research / bulk operation | Submit as Minion job, always | -| File imports, bulk embeds | Submit as Minion job | +| User asks for deterministic command/script run | Shell job (CLI: `gbrain jobs submit shell ...`) | +| User asks to "run in minions" + explicit command/argv | Shell job (CLI, `--params` with `cmd` or `argv`) | +| User asks for research/reasoning/iterative agent | Subagent job (CLI: `gbrain agent run`) | +| User asks to steer/pause/resume an agent | Subagent job lifecycle tools (MCP-callable) | +| Single simple operation under ~30s | Consider inline execution first | +| Needs restart durability/observability | Submit as Minion job | +| Parallel work (2+ streams) | `gbrain agent run --fanout-manifest` or parent + child subagents | -**Rule of thumb:** If it takes more than 3 tool calls, use a Minion. +If intent is ambiguous, ask one clarification: +"Do you want a deterministic shell command job, or an LLM agent job?" + +## Shell Jobs (Deterministic Scripts) + +Use for reproducible command execution, ETL steps, cron work, and scriptable +tasks where no LLM reasoning loop is needed. + +### Preconditions (read before submitting your first shell job) + +- **`GBRAIN_ALLOW_SHELL_JOBS=1` must be set on the worker environment.** + Without it, the shell handler refuses to register and submissions sit in + `waiting` silently. Gate lives in `src/core/minions/handlers/shell.ts`. +- **Security:** flipping `GBRAIN_ALLOW_SHELL_JOBS=1` authorizes arbitrary + command execution on the worker. On a shared queue, this is a remote code + execution surface. Treat as privileged infrastructure authorization. +- **Execution mode — pick one:** + - **Postgres + daemon:** `gbrain jobs work` runs a persistent worker that + claims and executes jobs from the queue. + - **PGLite + --follow:** `gbrain jobs submit ... --follow` runs inline. + The daemon mode is not available on PGLite (exclusive file lock). See + `docs/guides/minions-shell-jobs.md`. +- **MCP boundary:** shell-job submission is CLI-only. `submit_job name="shell"` + over MCP throws an `OperationError` with code `permission_denied` ("'shell' + jobs cannot be submitted over MCP") because `shell` is in `PROTECTED_JOB_NAMES`. + Agents CAN observe shell jobs via `get_job` / `list_jobs` / `get_job_progress` + (not protected), but cannot submit them. Operator or autopilot submits; + agent observes. +- **Verify setup:** after configuration, run `gbrain jobs stats` (CLI) to + confirm the worker is registered and consuming the queue. + +### Submit (CLI, operator or autopilot) + +Shell jobs take their command via `--params` as a JSON object with `cmd` (string) +or `argv` (array), plus `cwd` and optional `env`. + +Command string form: +``` +gbrain jobs submit shell --params '{"cmd":"echo hello","cwd":"/abs/path"}' +``` + +Argv form (no shell expansion): +``` +gbrain jobs submit shell --params '{"argv":["bash","-lc","echo hello"],"cwd":"/abs/path"}' +``` + +Inline execution on PGLite or any one-shot deployment: +``` +gbrain jobs submit shell --params '{"cmd":"echo hello","cwd":"/tmp"}' --follow +``` + +Queue/lifecycle flags exposed by `gbrain jobs submit --help`: `--queue`, +`--priority`, `--delay`, `--max-attempts`, `--max-stalled`, `--backoff-type`, +`--backoff-delay`, `--backoff-jitter`, `--timeout-ms`, `--idempotency-key`, +`--dry-run`. + +### Monitor (agents or operator) + +These operations are MCP-callable and safe for agent use: + +``` +list_jobs --name shell --status active +get_job ID +get_job_progress ID +``` + +Check structured result fields (exit code, stdout/stderr tails, attempts, +timings) from `get_job`. Use `gbrain jobs stats` (CLI) for worker/queue +health dashboard. + +### Control (MCP-callable) + +``` +cancel_job id=ID +replay_job id=ID +``` + +`replay_job` is not protected — only shell *submission* is. Agents can +cancel or replay a shell job without CLI access. + +Use idempotency keys for recurring shell workloads to avoid duplicate runs. + +## Subagent Jobs (LLM Orchestration) + +Use for open-ended reasoning, tool-using research, and fan-out synthesis. + +**User-facing entrypoint:** `gbrain agent run ` is the canonical way +to submit subagent work. It handles the elevated-trust plumbing — `subagent` +and `subagent_aggregator` are both in `PROTECTED_JOB_NAMES`, so direct MCP +submission requires `{allowProtectedSubmit: true}`, which `gbrain agent run` +supplies. ## Phase 1: Submit ``` -submit_job name="research" data={"prompt":"Research Acme Corp revenue","tools":["search","web_search"]} +gbrain agent run "Research Acme Corp revenue" --tools "search,query" ``` -Options: -- `queue` — queue name (default: 'default') -- `priority` — lower = higher priority (default: 0) -- `max_attempts` — retry limit (default: 3) -- `delay` — ms delay before eligible +`--tools` accepts a comma-separated subset of `BRAIN_TOOL_ALLOWLIST` (see +`src/core/minions/tools/brain-allowlist.ts`): `query`, `search`, `get_page`, +`list_pages`, `file_list`, `file_url`, `get_backlinks`, `traverse_graph`, +`resolve_slugs`, `get_ingest_log`, `put_page`. Anything outside the allow-list +is rejected at submit time with `allowed_tools references unknown tool`. -For parallel work, submit a parent then children: +For parallel work with a fan-out manifest: ``` -submit_job name="orchestrate" data={"task":"research 5 companies"} -# Returns parent_id - -submit_job name="research" data={"company":"Acme"} parent_job_id=PARENT_ID -submit_job name="research" data={"company":"Beta"} parent_job_id=PARENT_ID -submit_job name="research" data={"company":"Gamma"} parent_job_id=PARENT_ID +gbrain agent run --fanout-manifest companies.json ``` -Parent auto-enters `waiting-children` and unblocks when all children finish. +The manifest describes N children + 1 aggregator. Each child runs +`name="subagent"` under the hood; the aggregator runs `name="subagent_aggregator"` +and claims AFTER every child terminates. See +`src/core/minions/handlers/subagent.ts` and +`src/core/minions/handlers/subagent-aggregator.ts`. + +Flags (from `src/commands/agent.ts`): +- `--subagent-def ` — named subagent definition +- `--model ` — override model +- `--max-turns ` — cap the LLM loop +- `--tools ` — allow-listed brain tools (see above) +- `--timeout-ms ` — hard timeout per job +- `--fanout-manifest ` — N children + 1 aggregator +- `--follow` / `--no-follow` — stream logs + wait (default on TTY) +- `--detach` — submit and return immediately + +Queue/priority/retry tuning is not exposed by `gbrain agent run`; submit the +raw `subagent` handler via `gbrain jobs submit` (requires CLI trust) if you +need those knobs. ## Phase 2: Monitor ``` -list_jobs --status active # what's running? -get_job ID # full details + logs + tokens -get_job_progress ID # structured progress snapshot -get_job_stats # health dashboard +list_jobs --status active # MCP — what's running? +get_job ID # MCP — full details + logs + tokens +get_job_progress ID # MCP — structured progress snapshot +gbrain jobs stats # CLI — queue health dashboard +gbrain agent logs ID --follow # CLI — streaming transcript + heartbeat ``` Progress includes: step count, total steps, message, token usage, last tool called. @@ -121,6 +239,8 @@ replay_job id=ID # re-run with same or modified params replay_job id=ID data_overrides={"depth":"deep"} # replay with changes ``` +All lifecycle ops are MCP-callable. + ## Phase 5: Review Results ``` @@ -154,9 +274,9 @@ When reporting batch status (parent with children): ``` Parent #ID — waiting-children - #A research(Acme) — active, 3/5 steps, 2.5k tokens - #B research(Beta) — completed, 1.8k tokens - #C research(Gamma) — paused + #A subagent(Acme) — active, 3/5 steps, 2.5k tokens + #B subagent(Beta) — completed, 1.8k tokens + #C subagent(Gamma) — paused Total tokens so far: 4.3k ``` @@ -164,19 +284,19 @@ Total tokens so far: 4.3k - Don't spawn a Minion for a single search query (use search tool directly) - Don't fire-and-forget without checking results -- Don't spawn > 5 concurrent agents without checking `get_job_stats` first -- Don't use `sessions_spawn` with `runtime: "subagent"` when Minions is available +- Don't spawn > 5 concurrent agents without checking `gbrain jobs stats` first +- For subagent work, don't use `sessions_spawn` with `runtime: "subagent"` when Minions is available (use `gbrain agent run` instead) - Don't poll `get_job` in a tight loop (use `get_job_progress` for lightweight checks) ## Tools Used -- Submit a background job (submit_job) -- Get job details (get_job) -- List jobs with filters (list_jobs) -- Cancel a job (cancel_job) -- Pause a job (pause_job) -- Resume a paused job (resume_job) -- Replay a completed/failed job (replay_job) -- Send sidechannel message (send_job_message) -- Get structured progress (get_job_progress) -- Get job queue stats (get_job_stats) +- Submit a background job — `submit_job` (MCP, non-protected names only; shell jobs are CLI-only, subagent jobs via `gbrain agent run`) +- Get job details — `get_job` (MCP) +- List jobs with filters — `list_jobs` (MCP) +- Cancel a job — `cancel_job` (MCP) +- Pause a job — `pause_job` (MCP) +- Resume a paused job — `resume_job` (MCP) +- Replay a completed/failed job — `replay_job` (MCP) +- Send sidechannel message — `send_job_message` (MCP) +- Get structured progress — `get_job_progress` (MCP) +- Queue stats — `gbrain jobs stats` (CLI; no MCP equivalent) diff --git a/skills/query/SKILL.md b/skills/query/SKILL.md index 2542a715e..a3f3ad4f1 100644 --- a/skills/query/SKILL.md +++ b/skills/query/SKILL.md @@ -12,6 +12,10 @@ triggers: - "what happened" - "search for" - "look up" + - "who knows who" + - "relationship between" + - "connections" + - "graph query" tools: - search - query diff --git a/skills/smoke-test/SKILL.md b/skills/smoke-test/SKILL.md index 2f3dedec0..742f37481 100644 --- a/skills/smoke-test/SKILL.md +++ b/skills/smoke-test/SKILL.md @@ -10,6 +10,7 @@ triggers: - "container restart check" - "health check" - "did the restart break anything" + - "did the container restart break anything" tools: - exec - read diff --git a/src/core/skillpack/installer.ts b/src/core/skillpack/installer.ts index bf5aa1ad5..ab93968af 100644 --- a/src/core/skillpack/installer.ts +++ b/src/core/skillpack/installer.ts @@ -186,7 +186,10 @@ function acquireLock(workspace: string, opts: InstallOptions): void { const age = Date.now() - existing.mtimeMs; // `staleMs: 0` in tests means "any age counts as stale". Use >= // so a just-written lock qualifies when the threshold is 0. - const stale = age >= staleMs; + // Negative age (mtime in the future) happens on fast CI filesystems + // where write → stat roundtrip returns an mtime microseconds ahead of + // Date.now() — treat it as stale to avoid a "lock held" false positive. + const stale = age < 0 || age >= staleMs; if (stale && !opts.forceUnlock) { throw new InstallError( `Stale skillpack lock at ${p} (pid ${existing.pid}, ${Math.round(age / 1000)}s old). Pass --force-unlock to proceed.`, diff --git a/test/e2e/minions-shell-pglite.test.ts b/test/e2e/minions-shell-pglite.test.ts new file mode 100644 index 000000000..617c8994b --- /dev/null +++ b/test/e2e/minions-shell-pglite.test.ts @@ -0,0 +1,116 @@ +/** + * E2E Minions Shell Handler — PGLite / --follow inline execution path + * + * Closes the T4 gap surfaced during PR #381 eng review. The sibling file + * test/e2e/minions-shell.test.ts covers the Postgres + persistent-worker-daemon + * path. This file covers the PGLite path documented in the minion-orchestrator + * skill: `gbrain jobs submit shell ... --follow` runs inline because + * `gbrain jobs work` (daemon) is not available on PGLite (exclusive file lock). + * + * Mirrors the Postgres test's structure but runs in-memory against PGLiteEngine. + * No DATABASE_URL required, no Docker — runs in CI unconditionally. + * + * Run: bun test test/e2e/minions-shell-pglite.test.ts + */ + +import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; +import { PGLiteEngine } from '../../src/core/pglite-engine.ts'; +import { MinionQueue } from '../../src/core/minions/queue.ts'; +import { MinionWorker } from '../../src/core/minions/worker.ts'; +import { registerBuiltinHandlers } from '../../src/commands/jobs.ts'; + +let engine: PGLiteEngine; +let originalAllowShellJobs: string | undefined; + +async function waitTerminal(queue: MinionQueue, id: number, timeoutMs = 15000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const j = await queue.getJob(id); + if (j && ['completed', 'failed', 'dead', 'cancelled'].includes(j.status)) return j.status; + await new Promise((r) => setTimeout(r, 50)); + } + const j = await queue.getJob(id); + throw new Error(`job ${id} did not reach terminal state in ${timeoutMs}ms; last status=${j?.status}`); +} + +beforeAll(async () => { + // registerBuiltinHandlers gates shell handler on GBRAIN_ALLOW_SHELL_JOBS=1. + // Mirror the real --follow path by setting the env var; restore on cleanup + // so other tests see their original environment. + originalAllowShellJobs = process.env.GBRAIN_ALLOW_SHELL_JOBS; + process.env.GBRAIN_ALLOW_SHELL_JOBS = '1'; + + engine = new PGLiteEngine(); + await engine.connect({}); // in-memory PGLite + await engine.initSchema(); // installs pages, minion_jobs, config, etc. +}); + +afterAll(async () => { + await engine.disconnect(); + if (originalAllowShellJobs === undefined) { + delete process.env.GBRAIN_ALLOW_SHELL_JOBS; + } else { + process.env.GBRAIN_ALLOW_SHELL_JOBS = originalAllowShellJobs; + } +}); + +describe('E2E: Minions shell handler on PGLite (--follow inline path)', () => { + // Mirror the Postgres sibling's per-test reset. The engine is shared across + // both tests via beforeAll; without this, completed jobs from one test leak + // into minion_jobs and future test additions hit order-dependency. + beforeEach(async () => { + const db = (engine as any).db; + await db.exec(`DELETE FROM minion_attachments; DELETE FROM minion_inbox; DELETE FROM minion_jobs;`); + }); + + test('submit → worker registered via registerBuiltinHandlers → shell runs → completes', async () => { + const queue = new MinionQueue(engine); + const job = await queue.add( + 'shell', + { cmd: 'echo hello', cwd: '/tmp' }, + {}, + { allowProtectedSubmit: true }, + ); + expect(job.name).toBe('shell'); + expect(job.status).toBe('waiting'); + + // This is the exact dispatch path --follow takes (src/commands/jobs.ts:207). + // Gates shell on GBRAIN_ALLOW_SHELL_JOBS=1 (set in beforeAll above). + const worker = new MinionWorker(engine, { pollInterval: 100, lockDuration: 30000 }); + await registerBuiltinHandlers(worker, engine); + expect(worker.registeredNames).toContain('shell'); + + const runPromise = worker.start(); + try { + const status = await waitTerminal(queue, job.id, 20000); + expect(status).toBe('completed'); + const final = await queue.getJob(job.id); + expect((final!.result as any).exit_code).toBe(0); + expect((final!.result as any).stdout_tail).toBe('hello\n'); + } finally { + worker.stop(); + await runPromise; + } + }, 30000); + + test('GBRAIN_ALLOW_SHELL_JOBS unset → shellHandler rejects at execution time', async () => { + // v0.20.3+: shell handler is always registered (so claimed jobs emit a clear + // rejection log), but the runtime env guard lives inside the handler itself. + // Prove the guard rejects when the env var is unset. + const { shellHandler } = await import('../../src/core/minions/handlers/shell.ts'); + const saved = process.env.GBRAIN_ALLOW_SHELL_JOBS; + delete process.env.GBRAIN_ALLOW_SHELL_JOBS; + try { + const ctx: any = { + id: 1, + name: 'shell', + data: { cmd: 'echo hi', cwd: '/tmp' }, + attempt: 1, + engine, + }; + await expect(shellHandler(ctx)).rejects.toThrow(/GBRAIN_ALLOW_SHELL_JOBS=1/); + } finally { + process.env.GBRAIN_ALLOW_SHELL_JOBS = saved; + } + }); +}); diff --git a/test/resolver.test.ts b/test/resolver.test.ts index 558819342..af6dc964c 100644 --- a/test/resolver.test.ts +++ b/test/resolver.test.ts @@ -1,10 +1,12 @@ import { describe, test, expect } from "bun:test"; -import { readFileSync, existsSync } from "fs"; +import { readFileSync, existsSync, readdirSync, statSync } from "fs"; import { join } from "path"; import { checkResolvable } from "../src/core/check-resolvable.ts"; +import { PROTECTED_JOB_NAMES } from "../src/core/minions/protected-names.ts"; const SKILLS_DIR = join(import.meta.dir, "..", "skills"); const RESOLVER_PATH = join(SKILLS_DIR, "RESOLVER.md"); +const OPERATIONS_PATH = join(import.meta.dir, "..", "src", "core", "operations.ts"); describe("RESOLVER.md", () => { test("exists", () => { @@ -49,3 +51,155 @@ describe("RESOLVER.md", () => { expect(report.summary.unreachable).toBe(0); }); }); + +// D5/C — resolver round-trip: every quoted trigger in a RESOLVER.md table row +// must appear in the target skill's frontmatter `triggers:` list. Catches +// trigger/frontmatter drift that `checkResolvable` reachability doesn't. +describe("RESOLVER.md trigger round-trip (D5/C)", () => { + type Row = { triggers: string[]; skillPath: string }; + + const rows: Row[] = (() => { + if (!existsSync(RESOLVER_PATH)) return []; + const content = readFileSync(RESOLVER_PATH, "utf-8"); + // Tolerate trailing annotations after the backtick path (e.g., + // `` `skills/maintain/SKILL.md` (extraction sections) |``). The path cell + // starts with a backtick-quoted `.md` ref; anything between that and the + // closing `|` is free-form prose and is intentionally ignored. + const rowRe = /^\s*\|\s*([^|]+?)\s*\|\s*`([^`]+\.md)`[^|]*\|\s*$/gm; + const out: Row[] = []; + let m: RegExpExecArray | null; + while ((m = rowRe.exec(content)) !== null) { + const rawTriggers = m[1]; + const skillPath = m[2]; + const triggerStrings = Array.from(rawTriggers.matchAll(/"([^"]+)"/g)).map(t => t[1]); + if (triggerStrings.length > 0) { + out.push({ triggers: triggerStrings, skillPath }); + } + } + return out; + })(); + + test("at least one routing row parses from RESOLVER.md", () => { + expect(rows.length).toBeGreaterThan(0); + }); + + for (const row of rows) { + test(`every RESOLVER trigger for ${row.skillPath} is declared in its frontmatter`, () => { + const skillFullPath = join(SKILLS_DIR, "..", row.skillPath); + expect(existsSync(skillFullPath)).toBe(true); + + const skillContent = readFileSync(skillFullPath, "utf-8"); + const fmMatch = skillContent.match(/^---\n([\s\S]*?)\n---/); + if (!fmMatch) { + throw new Error(`No YAML frontmatter in ${row.skillPath}`); + } + const frontmatter = fmMatch[1]; + // Parse frontmatter triggers: list. Match "..." OR '...' items separately + // so apostrophes inside double-quoted values don't truncate the capture. + const triggersBlock = frontmatter.match(/triggers:\s*\n((?:\s*-\s*(?:"[^"]*"|'[^']*')\s*\n?)+)/); + const declaredTriggers = triggersBlock + ? Array.from(triggersBlock[1].matchAll(/-\s*(?:"([^"]*)"|'([^']*)')/g)) + .map(m => m[1] ?? m[2]) + : []; + + // Fuzzy match: RESOLVER.md phrases are natural-language summaries of the + // skill's intent; frontmatter triggers are the agent-facing phrase set. + // Match is case-insensitive, trailing-punctuation-insensitive, and supports + // "/"-split compounds (e.g., "pause/resume agent" → ["pause", "resume agent"]). + const normalize = (s: string) => s.toLowerCase().replace(/[?!.,]+$/, "").trim(); + const declaredLower = declaredTriggers.map(normalize); + + function matchesAny(phrase: string): boolean { + const p = normalize(phrase); + if (declaredLower.includes(p)) return true; + for (const ft of declaredLower) { + if (ft.includes(p) || p.includes(ft)) return true; + } + // Slash-split compound: every part should have some fuzzy frontmatter hit + if (p.includes("/")) { + const parts = p.split("/").map(s => s.trim()).filter(Boolean); + const allParts = parts.every(part => + declaredLower.some(ft => ft.includes(part) || part.includes(ft)) + ); + if (allParts) return true; + } + return false; + } + + const missing = row.triggers.filter(t => !matchesAny(t)); + if (missing.length > 0) { + throw new Error( + `RESOLVER.md routes ${JSON.stringify(missing)} to ${row.skillPath}, but the ` + + `skill's frontmatter has no fuzzy match. Declared: ${JSON.stringify(declaredTriggers)}` + ); + } + }); + } +}); + +// D13 — skill-example-name validator: any `name=""` reference inside a +// SKILL.md body must resolve to either a declared operation in operations.ts +// or a known Minions handler name in PROTECTED_JOB_NAMES. Catches T2-class +// bugs where docs reference handler names that don't exist (e.g., the +// `name="research"` / `name="orchestrate"` bug from PR #381 pre-reframe). +describe("Skill example-name validator (D13)", () => { + const opNames: string[] = (() => { + if (!existsSync(OPERATIONS_PATH)) return []; + const content = readFileSync(OPERATIONS_PATH, "utf-8"); + return Array.from(content.matchAll(/^\s+name:\s*'([a-z_]+)',/gm)).map(m => m[1]); + })(); + + const knownNames = new Set([...opNames, ...PROTECTED_JOB_NAMES]); + + test("operation names extracted from operations.ts", () => { + // Sanity check: operations.ts should declare dozens of ops + expect(opNames.length).toBeGreaterThan(10); + }); + + test("PROTECTED_JOB_NAMES is non-empty", () => { + expect(PROTECTED_JOB_NAMES.size).toBeGreaterThan(0); + }); + + function walkSkills(dir: string): string[] { + if (!existsSync(dir)) return []; + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const p = join(dir, entry); + const s = statSync(p); + if (s.isDirectory()) { + out.push(...walkSkills(p)); + } else if (entry === "SKILL.md") { + out.push(p); + } + } + return out; + } + + const skillFiles = walkSkills(SKILLS_DIR); + + test("at least one SKILL.md found", () => { + expect(skillFiles.length).toBeGreaterThan(0); + }); + + for (const skillFile of skillFiles) { + const rel = skillFile.replace(SKILLS_DIR, "skills"); + test(`${rel}: every name="" reference resolves to a real op or handler`, () => { + const content = readFileSync(skillFile, "utf-8"); + // Strip YAML frontmatter so `name: ` isn't mis-captured. + const body = content.replace(/^---\n[\s\S]*?\n---\n/, ""); + // Match only `name=` (with equals, not colon) to avoid YAML false positives + // if the frontmatter strip ever breaks. Captures quoted word values. + const refs = Array.from(body.matchAll(/name\s*=\s*["']([a-z_][a-z_0-9]*)["']/gi)) + .map(m => m[1]); + const unique = [...new Set(refs)]; + const unknown = unique.filter(n => !knownNames.has(n)); + if (unknown.length > 0) { + throw new Error( + `${rel}: references name="..." values not declared in src/core/operations.ts or ` + + `PROTECTED_JOB_NAMES: ${JSON.stringify(unknown)}. ` + + `Known: ${JSON.stringify([...knownNames].sort())}` + ); + } + }); + } +});