mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
Merge remote-tracking branch 'origin/master' into garrytan/cathedral-2
# Conflicts: # CHANGELOG.md # TODOS.md # VERSION # package.json
This commit is contained in:
@@ -38,6 +38,44 @@ Every scoreboard row carries a `seam` label: the `openclaw` row exercises the sh
|
||||
- CLI exit codes for the new command route through the shared write-fence + aliveness-grace exit seam, so PGLite's WASM exit-code stomping and Bun's exit-time stdout discard can't corrupt the CI contract.
|
||||
|
||||
To take advantage of v0.44.0.0: run `gbrain eval brainbench` — no setup, no keys, no brain required. If it ever reports something broken after an upgrade, `bun evals/brainbench/generator/gen.ts` rebuilds the corpus byte-identically and `gbrain eval brainbench --update-baseline` re-derives the baseline from an actual run; both are safe to re-run any time.
|
||||
## [0.42.47.0] - 2026-06-16
|
||||
|
||||
**A brain now travels with its own operating manual, and gbrain finally tells you how to run it better (gbrain#2180).** Two long-standing gaps closed. First: a brain repo can carry its own skillpack — skills authored for and versioned with that specific brain — and any harness that connects is offered it. Connect a fresh Claude Code or a thin client to a mature brain and it learns, on the spot, which meeting-ingestion or diligence protocol the brain expects, instead of starting blind. Second: gbrain stops being purely passive. `gbrain advisor` reads the brain's own state and hands back a ranked, read-only list of high-leverage actions — pending migrations, version drift, stalled backfills, low embedding coverage, setup smells — each with the exact command to fix it. It never acts on its own; it shows you and asks.
|
||||
|
||||
Discovery works on both connection topologies. Add a federated source that ships a brain-resident pack and gbrain prints what's in it and how to install it (with bounded, escalate-then-suppress nagging while it stays uninstalled — it never nags off a cron or an MCP call, only a real CLI prompt). Over MCP, a connecting agent calls `list_brain_skillpack` (source-scoped, so a multi-source brain attributes each pack to its source) and `get_skill --source_id` to fetch a specific pack skill. The advisor is also available over MCP behind its own gate, read-only, so a thin client can coach you in its own voice without ever exposing a fix it could run itself.
|
||||
|
||||
Nothing here forks the manifest, the installer, or the trust model — brain packs go through the same TOFU gate and SSRF allowlist as any third-party pack. Thin-client *binary* install (download-and-unpack) remains the separate, still-deferred PR2 work; today a thin client resolves a pack from its git source on its own machine.
|
||||
|
||||
### Added
|
||||
- **Brain-resident skillpacks** — optional `brain_resident` + `schema_pack` fields on the v1 manifest (additive, forward-compatible); `gbrain skillpack init-brain-pack` scaffolds one (with a machine-parseable README a connecting harness can scan) pinned to the exact serving version so a pack can't install on a binary that lacks its ops.
|
||||
- **Connect-time discovery** — `gbrain sources add` surfaces a brain-resident pack and offers to install it; a new `list_brain_skillpack` MCP tool (source-scoped, `mcp.publish_skills`-gated) plus `get_skill --source_id` let a thin client discover and fetch per-source pack skills. `gbrain connect` now teaches agents to call it.
|
||||
- **`gbrain advisor`** — ranked, read-only "what to do next" for this brain (version drift, pending migrations, schema-pack issues, stalled jobs/sync, embedding coverage, setup smells, uninstalled skills). `--json` with severity-based exit codes for CI/cron; `--apply <id>` runs one fix locally behind an explicit confirm (structured argv, never a shell). Exposed over MCP behind `mcp.publish_advisor` (default off, read-only).
|
||||
- **`gbrain-advisor` bundled skill + weekly cron recipe** — teaches a harness to run the advisor on a cadence and ping you with what's new since last run.
|
||||
- **Brain-pack version-skew lint** — `init-brain-pack` validates each pack skill's declared `tools:` against the serving op set, so a pack fails loud on drift instead of silently half-working.
|
||||
|
||||
### Changed
|
||||
- **The post-install/upgrade advisory is now state-aware and current.** It reads a single current-state recommended set (not a version-pinned constant) and the install ledger, and it speaks `gbrain skillpack scaffold` (the removed `install` verb is gone from the copy).
|
||||
|
||||
### To take advantage of v0.42.47.0
|
||||
`gbrain upgrade`, then run `gbrain advisor` to see the top things worth doing on your brain right now. To publish a brain's skills to anyone who connects, run `gbrain skillpack init-brain-pack <name>` in the brain repo, fill in the README's five sections, and commit it. To let connecting agents discover packs over MCP, `gbrain config set mcp.publish_skills true`; to let a thin client run the advisor, `gbrain config set mcp.publish_advisor true` (both default off). Install the `gbrain-advisor` skill and its weekly cron recipe for a standing brain checkup.
|
||||
## [0.42.46.0] - 2026-06-16
|
||||
|
||||
**Federated read scope now reaches every by-slug read, not just search and query (gbrain#2200).** A client that mounts several sources (a `federated_read` grant) could find a page through search and query, but the by-slug reads — `get_page`'s tags, plus `get_tags`, `get_links`, `get_backlinks`, and `get_timeline` — didn't honor the same grant. For a page living outside the default source that meant two wrong outcomes: the read came back empty for content the client was authorized to see, or it resolved against the wrong source. This release routes all of those reads through the same source-scope ladder that already governs search/query, so a federated client reads exactly the sources it's granted — no more, no less. Thanks to @mlobo2012 for the report and the proposed fix.
|
||||
|
||||
Link reads are scoped on every endpoint. A link connects up to three pages (the source page, the target, and the page that authored the edge); a federated read now constrains all three to the grant, so a link that crosses out of your granted sources doesn't surface a foreign page's slug. Untrusted remote callers carrying a single-source token get the same all-endpoint scoping; trusted local CLI keeps its cross-source view for link reconciliation and validators.
|
||||
|
||||
The semantic query cache was already corrected in v0.42.34.0 (cache rows key on the full source set, so a federated result can't be served to a caller with a different scope); this release closes the read-path half of the same theme.
|
||||
|
||||
### Fixed
|
||||
- **By-slug reads honor the federated read grant.** `get_page` resolves a page's tags against that page's own source, and `get_tags` / `get_links` / `get_backlinks` / `get_timeline` route through the federated source scope. A multi-source client reads tags, links, backlinks, and timeline across exactly its granted sources (union), instead of falling back to a single source.
|
||||
- **Link reads are scoped on all three endpoints** (source, target, and authoring page) under a federated grant, and untrusted remote single-source callers are scoped the same way — so a cross-source link can't disclose a foreign slug. Trusted local reads keep the full cross-source view.
|
||||
|
||||
### Changed
|
||||
- The engine's `getTags` / `getLinks` / `getBacklinks` / `getTimeline` and `TimelineOpts` accept a `sourceIds[]` federated scope (precedence over the scalar source), mirroring `getPage` from v0.42.37.0. Write-side operations are unchanged — a read grant never widens writes.
|
||||
|
||||
### To take advantage of v0.42.46.0
|
||||
`gbrain upgrade`. No configuration needed — federated clients immediately read tags, links, backlinks, and timeline across their full granted source set, and cross-source link reads stop surfacing foreign slugs. Single-source brains are unaffected.
|
||||
|
||||
## [0.42.45.0] - 2026-06-13
|
||||
|
||||
**The daily sync cron stops wedging on cost, and the embedding-spend estimate finally matches what a sync actually does (gbrain#2139).** On an active brain the inline-embed cost gate priced the *entire* corpus every time the working tree was dirty — which is always, since agents and crons write to it constantly — so a routine daily sync estimated ~158M tokens / ~$8 when the real delta was a few hundred files / ~$0.04, then blocked the cron with a confirmation it could never answer. Embeds silently stalled until someone noticed. The estimate now mirrors execution: it fetches first and prices only the files this run will pull and import, through the same diff machinery the sync itself uses. A brain whose commits are caught up but whose tree is dirty estimates $0, because an attached-HEAD sync imports only the committed diff.
|
||||
|
||||
@@ -254,7 +254,7 @@ audit trail lives in the source repo's git history.
|
||||
|
||||
## Skills
|
||||
|
||||
Read the skill files in `skills/` before doing brain operations. GBrain ships 29 skills
|
||||
Read the skill files in `skills/` before doing brain operations. GBrain ships 30 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,
|
||||
@@ -273,6 +273,17 @@ routing is narrowed to what the skill actually covers.
|
||||
**Skillify loop (v0.19):** skillify (the markdown orchestration), skillpack-check
|
||||
(agent-readable health report).
|
||||
|
||||
**Brain-resident skillpacks + advisor (v0.42.47.0, #2180):** A brain repo can carry its
|
||||
own publishable skillpack (`brain_resident: true` in `skillpack.json` + `schema_pack`);
|
||||
`gbrain skillpack init-brain-pack` scaffolds one with a 5-section machine-parseable README.
|
||||
Connecting harnesses discover it on `gbrain sources add` (Topology A advisory, bounded nag
|
||||
via `nag-state.ts`) and over MCP via the source-scoped `list_brain_skillpack` op +
|
||||
`get_skill --source_id` (gated by `mcp.publish_skills`). The bundled `gbrain-advisor` skill
|
||||
+ `gbrain advisor` op compute a ranked, read-only list of high-leverage actions from brain
|
||||
state (8 collectors in `src/core/advisor/`); `--json`+exit codes for CI/cron, local-only
|
||||
`--apply <id>` behind confirm, exposed over MCP behind `mcp.publish_advisor` (default off,
|
||||
read-only on remote). Thin-client binary install stays deferred to PR2 `build_skillpack`.
|
||||
|
||||
**Routing-table compression (v0.32.3.0):** `skills/functional-area-resolver/` —
|
||||
two-layer dispatch pattern for shrinking large AGENTS.md / RESOLVER.md files
|
||||
(>=12KB) without losing routing accuracy. Replaces one row per skill with one
|
||||
|
||||
@@ -16,6 +16,26 @@ Deferred from the BrainBench wave (eng-reviewed; plan + GSTACK REVIEW REPORT at
|
||||
- [ ] **Periodic re-baselining (the ratchet doesn't auto-tighten).** Improvements aren't banked into master's baseline until a PR updates it, so a regression back to a stale baseline level passes. Documented as an accepted residual in `docs/eval/BRAINBENCH.md`; the fix is an operator habit or a scheduled job that re-runs `--update-baseline` after metric-improving merges. Priority: P3.
|
||||
|
||||
- [ ] **Hermetic-ize the 7 env-sensitive LLM-availability tests.** `test/think-gateway-adapter.test.ts`, `test/conversation-parser/llm-base.test.ts`/`llm-fallback.test.ts`, `test/doctor-ze-checks.test.ts` assert behavior "when ANTHROPIC_API_KEY is unset" by reading the live process env — they fail on any dev shell that exports provider keys (verified failing on clean master in such a shell; green in keyless CI). Stub/save-restore the env per test so local runs match CI. Priority: P2.
|
||||
## gbrain#2200 federated-read follow-ups (filed v0.42.46.0)
|
||||
|
||||
- [ ] **P1 — Close the federated-read scope on the remaining same-class by-slug read ops.**
|
||||
v0.42.46.0 (#2200) routed `get_page` tags + `get_tags` / `get_links` / `get_backlinks` /
|
||||
`get_timeline` through the federated source scope and taught the engine methods to honor
|
||||
`sourceIds[]`. The adversarial review (Codex + Claude) flagged sibling read ops in the
|
||||
SAME class that still use scalar-only `ctx.sourceId ? {sourceId} : {}` and never thread
|
||||
`ctx.auth.allowedSources`: `get_chunks`, `get_raw_data`, `get_versions`, and `resolve_slugs`
|
||||
(the standalone op — `resolve_slugs` passes NO scope at all). A remote federated client
|
||||
(grant set, dispatch-default `ctx.sourceId='default'`) reads these against `default` or
|
||||
unscoped, not its grant.
|
||||
- **Why:** same cross-source correctness/isolation class #2200 targets; a federated client
|
||||
can't read chunks/raw-data/versions for an authorized non-default source, and `resolve_slugs`
|
||||
can fuzzy-resolve across all sources.
|
||||
- **How to start:** mirror the #2200 pattern — route each handler through `sourceScopeOpts(ctx)`
|
||||
(or `linkReadScopeOpts` if a far endpoint exists), add `sourceIds?: string[]` to the engine
|
||||
methods (`getChunks` / `getRawData` / `getVersions` / `resolveSlugs`) with `source_id = ANY($::text[])`
|
||||
precedence, and add federated/isolation tests + engine-parity arms.
|
||||
- **Depends on:** nothing; #2200 established the pattern and the `linkReadScopeOpts` helper.
|
||||
|
||||
## Spend-controls wave follow-ups (filed v0.42.45.0, #2139)
|
||||
|
||||
Deferred from the #2139 delta-estimator wave. See plan + GSTACK REVIEW REPORT at
|
||||
@@ -692,6 +712,19 @@ PR1 shipped the read-only catalog; PR2 is the download-and-install surface,
|
||||
deferred per the plan's D1 + D8 because it stands up new HTTP/binary/token
|
||||
infra and reaches into third-party packs that live outside the host skills dir.
|
||||
|
||||
> **#2180 update (v0.43+ brain-resident skillpacks + advisor):** brain-resident
|
||||
> pack DISCOVERY over MCP shipped as a dedicated, source-scoped
|
||||
> `list_brain_skillpack` op (NOT folded into `list_skills` — the host catalog is
|
||||
> host-global and ignores `ctx.sourceId`, so per-source packs needed their own
|
||||
> tenancy-correct surface). `get_skill` gained an optional `source_id` for
|
||||
> per-source fetch disambiguation. The `tools:` version-skew lint below is now
|
||||
> implemented (`src/core/skillpack/brain-pack-lint.ts`, run by
|
||||
> `gbrain skillpack init-brain-pack`). STILL DEFERRED to this PR2: thin-client
|
||||
> BINARY install (`build_skillpack` download) — a thin client today gets the
|
||||
> pack's git scaffold spec and `resolveSource`s it on its own machine. The
|
||||
> `include_skillpacks` host-global merge below is intentionally still open
|
||||
> (separate concern from per-source brain packs).
|
||||
|
||||
- [ ] **v0.41.37+: `build_skillpack` op + `GET /skillpack/download/:token` endpoint.** Build a deterministic `.tgz` on demand (named skillpack, ad-hoc skill subset, or whole repo) and deliver it both base64-inline (universal/stdio) and via an authenticated short-lived download URL when running under `gbrain serve --http`. **What:** new admin-or-write-scoped op + a token-store + cache-dir GC; reuse `packTarball` from `src/core/skillpack/tarball.ts` (already deterministic + symlink-rejecting + size-capped) and the magic-link nonce pattern in `serve-http.ts`. The tarball ships source CODE, so it needs its own trust decision separate from PR1's prose-only catalog. **Why:** lets a thin client install a skillpack into its own setup, not just follow one live. **Depends on:** PR1 (landed in v0.41.36.0). Priority: P2.
|
||||
- [ ] **v0.41.37+: `include_skillpacks` merge in `list_skills`.** Fold pinned third-party packs (from `~/.gbrain/skillpack-state.json`) into the catalog. Deferred from PR1 (D8) because packs live OUTSIDE the host skills dir and need (a) a per-pack trusted-root realpath confinement and (b) `{name, skillpack_name?}` disambiguation when a pack skill and a host skill share a name. Lands naturally with PR2's pack machinery. Priority: P2.
|
||||
- [ ] **v0.41.37+: TTL+mtime cache for the skill-catalog walk.** PR1 reads fresh every call (cold path, ~ms). If telemetry shows repeated `list_skills` calls, add a TTL+mtime-keyed cache shared by `list_skills` + `get_skill`. Priority: P3 (do-nothing was the deliberate PR1 call).
|
||||
|
||||
File diff suppressed because one or more lines are too long
+13
-1
@@ -403,7 +403,7 @@ audit trail lives in the source repo's git history.
|
||||
|
||||
## Skills
|
||||
|
||||
Read the skill files in `skills/` before doing brain operations. GBrain ships 29 skills
|
||||
Read the skill files in `skills/` before doing brain operations. GBrain ships 30 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,
|
||||
@@ -422,6 +422,17 @@ routing is narrowed to what the skill actually covers.
|
||||
**Skillify loop (v0.19):** skillify (the markdown orchestration), skillpack-check
|
||||
(agent-readable health report).
|
||||
|
||||
**Brain-resident skillpacks + advisor (v0.42.47.0, #2180):** A brain repo can carry its
|
||||
own publishable skillpack (`brain_resident: true` in `skillpack.json` + `schema_pack`);
|
||||
`gbrain skillpack init-brain-pack` scaffolds one with a 5-section machine-parseable README.
|
||||
Connecting harnesses discover it on `gbrain sources add` (Topology A advisory, bounded nag
|
||||
via `nag-state.ts`) and over MCP via the source-scoped `list_brain_skillpack` op +
|
||||
`get_skill --source_id` (gated by `mcp.publish_skills`). The bundled `gbrain-advisor` skill
|
||||
+ `gbrain advisor` op compute a ranked, read-only list of high-leverage actions from brain
|
||||
state (8 collectors in `src/core/advisor/`); `--json`+exit codes for CI/cron, local-only
|
||||
`--apply <id>` behind confirm, exposed over MCP behind `mcp.publish_advisor` (default off,
|
||||
read-only on remote). Thin-client binary install stays deferred to PR2 `build_skillpack`.
|
||||
|
||||
**Routing-table compression (v0.32.3.0):** `skills/functional-area-resolver/` —
|
||||
two-layer dispatch pattern for shrinking large AGENTS.md / RESOLVER.md files
|
||||
(>=12KB) without losing routing accuracy. Replaces one row per skill with one
|
||||
@@ -1340,6 +1351,7 @@ This is the dispatcher. Skills are the implementation. **Read the skill file bef
|
||||
| Morning prep, meeting context, day planning | `skills/daily-task-prep/SKILL.md` |
|
||||
| Daily briefing, "what's happening today" | `skills/briefing/SKILL.md` |
|
||||
| Cron scheduling, quiet hours, job staggering | `skills/cron-scheduler/SKILL.md` |
|
||||
| "get more out of gbrain", "is my brain set up right", "weekly brain checkup", "advise me on my brain", "gbrain advisor" | `skills/gbrain-advisor/SKILL.md` |
|
||||
| Save or load reports | `skills/reports/SKILL.md` |
|
||||
| "Create a skill", "improve this skill" | `skills/skill-creator/SKILL.md` |
|
||||
| "Skillify this", "is this a skill?", "make this proper" | `skills/skillify/SKILL.md` |
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"skills/data-research",
|
||||
"skills/enrich",
|
||||
"skills/functional-area-resolver",
|
||||
"skills/gbrain-advisor",
|
||||
"skills/idea-ingest",
|
||||
"skills/idea-lineage",
|
||||
"skills/ingest",
|
||||
|
||||
@@ -57,6 +57,7 @@ This is the dispatcher. Skills are the implementation. **Read the skill file bef
|
||||
| Morning prep, meeting context, day planning | `skills/daily-task-prep/SKILL.md` |
|
||||
| Daily briefing, "what's happening today" | `skills/briefing/SKILL.md` |
|
||||
| Cron scheduling, quiet hours, job staggering | `skills/cron-scheduler/SKILL.md` |
|
||||
| "get more out of gbrain", "is my brain set up right", "weekly brain checkup", "advise me on my brain", "gbrain advisor" | `skills/gbrain-advisor/SKILL.md` |
|
||||
| Save or load reports | `skills/reports/SKILL.md` |
|
||||
| "Create a skill", "improve this skill" | `skills/skill-creator/SKILL.md` |
|
||||
| "Skillify this", "is this a skill?", "make this proper" | `skills/skillify/SKILL.md` |
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
---
|
||||
name: gbrain-advisor
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Proactive "make the most of gbrain" coaching. Runs `gbrain advisor` on a
|
||||
cadence and pings the user with the top high-leverage actions for their brain:
|
||||
version drift, pending migrations, stalled jobs, low embed coverage, setup
|
||||
smells, and uninstalled brain skills. Read-only; always asks before fixing.
|
||||
triggers:
|
||||
- "what should I do to get more out of gbrain"
|
||||
- "is my brain set up right"
|
||||
- "gbrain advisor"
|
||||
- "advise me on my brain"
|
||||
- "weekly brain checkup"
|
||||
tools:
|
||||
- advisor
|
||||
mutating: false
|
||||
---
|
||||
|
||||
# gbrain Advisor
|
||||
|
||||
> **Convention:** See `skills/conventions/brain-first.md`. This skill is the
|
||||
> proactive voice of the brain — it tells the owner how to run it better.
|
||||
|
||||
## Contract
|
||||
|
||||
This skill guarantees:
|
||||
- **Read-only.** `gbrain advisor` never mutates. It computes a ranked list of
|
||||
actions from existing brain state.
|
||||
- **Print, never execute.** You SHOW the user the findings and ASK before running
|
||||
any fix. The user owns every decision.
|
||||
- **Bounded nagging.** On a cadence, surface only what changed or what's
|
||||
critical; don't repeat an ignored low-severity item every run.
|
||||
|
||||
## When to run
|
||||
|
||||
- On demand when the user asks "how do I get more out of this brain?"
|
||||
- On a **weekly** cadence via the cron recipe below (even idle brains get a
|
||||
"here's how to run this better" ping).
|
||||
|
||||
## How to run it
|
||||
|
||||
```bash
|
||||
gbrain advisor --json
|
||||
```
|
||||
|
||||
Exit code is the severity gate (E2): `0` clean, `1` warn, `2` critical. The JSON
|
||||
payload is `{ version, generated_at, worst, findings: [...] }`. Each finding has:
|
||||
|
||||
- `severity` — `critical` | `warn` | `info`
|
||||
- `title` — one-line why-it-matters
|
||||
- `fix.command_argv` — the exact command to fix it (a structured argv)
|
||||
- `fix.dispatch_id` — present when the fix is safe to run via `--apply`
|
||||
|
||||
## What to do with the findings
|
||||
|
||||
1. Read the findings, highest severity first.
|
||||
2. Summarize the top 1-3 to the user in their own channel/voice. Lead with any
|
||||
`critical` item (e.g. pending migrations).
|
||||
3. For each, show the `fix.command_argv` and **ask** whether to run it.
|
||||
4. If they say yes and the finding has a `fix.dispatch_id`, you may run it
|
||||
locally with an explicit confirm:
|
||||
|
||||
```bash
|
||||
gbrain advisor --apply <dispatch_id>
|
||||
```
|
||||
|
||||
`--apply` is local-only, runs the fix as a structured argv (no shell), and
|
||||
confirms first. Findings without a `dispatch_id` are not auto-runnable — run
|
||||
their `fix.command_argv` yourself after the user agrees.
|
||||
5. Never run a fix the user didn't approve.
|
||||
|
||||
## Cron recipe (weekly checkup)
|
||||
|
||||
Install a weekly job via the `cron-scheduler` skill. Keep the prompt THIN — the
|
||||
job just reads this skill and runs the advisor:
|
||||
|
||||
- **Schedule:** weekly, one quiet-hours-respecting slot (e.g. Monday 09:00 local).
|
||||
- **Job prompt:** `Read skills/gbrain-advisor/SKILL.md and run gbrain advisor --json. If anything is critical or new since last run, ping me with the top items and the exact fix commands. Ask before fixing.`
|
||||
- **Idempotent:** the advisor is read-only, so a double-fire is harmless.
|
||||
|
||||
The advisor records a local run history, so on each fire you can tell the user
|
||||
what is **new since last run** rather than re-listing everything.
|
||||
|
||||
## Output Format
|
||||
|
||||
When you surface advisor findings to the user, lead with severity and keep it
|
||||
scannable:
|
||||
|
||||
```
|
||||
🧠 gbrain checkup — 2 things worth your attention
|
||||
|
||||
CRITICAL Schema migrations are pending.
|
||||
Fix: gbrain apply-migrations --yes (want me to run it?)
|
||||
|
||||
WARN gbrain 0.44 is available (you're on 0.43).
|
||||
Fix: gbrain upgrade
|
||||
```
|
||||
|
||||
- One block per finding, highest severity first.
|
||||
- Always show the exact `fix` command and ASK before running it.
|
||||
- If nothing is pressing, say so in one line ("brain looks healthy") — don't
|
||||
manufacture work.
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- **Running a fix without asking.** The advisor is read-only by contract. Never
|
||||
run `--apply` (or any `fix` command) without the user's explicit yes.
|
||||
- **Dumping the raw JSON at the user.** Translate findings into their voice; lead
|
||||
with what matters.
|
||||
- **Re-nagging ignored low-severity items every run.** Use the "new since last
|
||||
run" delta; respect the user's prior non-action.
|
||||
- **Treating `info` like `critical`.** Only block/insist on `critical` findings
|
||||
(pending migrations). `info` is a gentle nudge.
|
||||
- **Calling the MCP `advisor` op for workspace install state.** Over MCP the
|
||||
advisor returns brain-state signals only; uninstalled-skill findings are a
|
||||
local-CLI concern.
|
||||
@@ -239,6 +239,11 @@
|
||||
"path": "functional-area-resolver/SKILL.md",
|
||||
"description": "Compress an agent's routing file (RESOLVER.md or AGENTS.md) by replacing skill-per-row tables with functional-area dispatcher entries. Two-layer dispatch keeps every sub-skill reachable at ~50% of the file size."
|
||||
},
|
||||
{
|
||||
"name": "gbrain-advisor",
|
||||
"path": "gbrain-advisor/SKILL.md",
|
||||
"description": "Proactive 'make the most of gbrain' coaching. Runs gbrain advisor on a cadence and pings the user with the top high-leverage actions for their brain. Read-only; always asks before fixing."
|
||||
},
|
||||
{
|
||||
"name": "brain-taxonomist",
|
||||
"path": "brain-taxonomist/SKILL.md",
|
||||
|
||||
+10
-1
@@ -44,7 +44,7 @@ for (const op of operations) {
|
||||
}
|
||||
|
||||
// CLI-only commands that bypass the operation layer
|
||||
const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'enrich', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status', 'connect', 'skillopt', 'quarantine', 'self-upgrade', 'watch']);
|
||||
const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'enrich', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status', 'connect', 'skillopt', 'quarantine', 'self-upgrade', 'advisor', 'watch']);
|
||||
// CLI-only commands whose handlers print their own --help text. These are
|
||||
// excluded from the generic short-circuit so detailed per-command and
|
||||
// per-subcommand usage stays reachable.
|
||||
@@ -1774,6 +1774,15 @@ async function handleCliOnly(command: string, args: string[]) {
|
||||
setCliExitVerdict(result.exitCode);
|
||||
break;
|
||||
}
|
||||
// v0.43 (#2180) — `gbrain advisor`: ranked, read-only "what to do next".
|
||||
// CLI surface; the same signals are exposed over MCP via the `advisor` op.
|
||||
case 'advisor': {
|
||||
const { runAdvisorCli } = await import('./commands/advisor.ts');
|
||||
const result = await runAdvisorCli(engine, args);
|
||||
process.exit(result.exitCode);
|
||||
// eslint-disable-next-line no-unreachable
|
||||
break;
|
||||
}
|
||||
// v0.38 — Capture: single human-facing entrypoint for ingestion.
|
||||
case 'capture': {
|
||||
const { runCapture } = await import('./commands/capture.ts');
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* commands/advisor.ts — `gbrain advisor` CLI surface.
|
||||
*
|
||||
* gbrain advisor # ranked, agent-readable action list (human render)
|
||||
* gbrain advisor --json # structured findings; exit non-zero on critical (E2)
|
||||
* gbrain advisor --apply ID # run ONE finding's fix, local-only, after confirm (E5)
|
||||
*
|
||||
* The advisor itself never mutates. `--apply` is the only path that runs a fix,
|
||||
* and it: refuses over MCP (CLI is always local), only acts on allowlisted
|
||||
* findings (those carrying a dispatch_id), executes the fix as STRUCTURED ARGV
|
||||
* via a child process (never a shell — no injection), and confirms first.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'child_process';
|
||||
import { createInterface } from 'readline';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import { VERSION } from '../version.ts';
|
||||
import { loadConfig } from '../core/config.ts';
|
||||
import { autoDetectSkillsDir } from '../core/repo-root.ts';
|
||||
import { runAdvisor } from '../core/advisor/run.ts';
|
||||
import { renderAdvisorReport } from '../core/advisor/render.ts';
|
||||
import { appendAdvisorRun, summarizeDeltas } from '../core/advisor/history.ts';
|
||||
import { resolveApplyTarget } from '../core/advisor/apply.ts';
|
||||
import type { AdvisorContext, AdvisorReport } from '../core/advisor/types.ts';
|
||||
|
||||
export interface AdvisorCliResult {
|
||||
exitCode: 0 | 1 | 2;
|
||||
}
|
||||
|
||||
function buildContext(engine: BrainEngine): AdvisorContext {
|
||||
const det = autoDetectSkillsDir();
|
||||
const skillsDir = det.dir;
|
||||
const workspace = skillsDir ? resolvePath(skillsDir, '..') : null;
|
||||
return {
|
||||
engine,
|
||||
config: loadConfig() ?? ({} as AdvisorContext['config']),
|
||||
version: VERSION,
|
||||
workspace,
|
||||
skillsDir,
|
||||
now: new Date(),
|
||||
remote: false, // CLI is always the trusted local owner
|
||||
};
|
||||
}
|
||||
|
||||
/** Exit-code contract (E2): 0 clean / 1 warn / 2 critical. */
|
||||
function exitFor(report: AdvisorReport): 0 | 1 | 2 {
|
||||
if (report.worst === 'critical') return 2;
|
||||
if (report.worst === 'warn') return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function runAdvisorCli(engine: BrainEngine, args: string[]): Promise<AdvisorCliResult> {
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(
|
||||
'gbrain advisor [--json] [--apply <finding-id>]\n\n' +
|
||||
' (no flags) Ranked, agent-readable list of high-leverage actions for this brain.\n' +
|
||||
' --json Structured findings. Exit code: 0 clean / 1 warn / 2 critical.\n' +
|
||||
' --apply <id> Run ONE finding\'s fix (local-only, confirms first). Only findings\n' +
|
||||
' that report an apply id are runnable.\n\n' +
|
||||
'Read-only by default; never mutates without --apply + your confirmation.',
|
||||
);
|
||||
return { exitCode: 0 };
|
||||
}
|
||||
|
||||
const json = args.includes('--json');
|
||||
const applyIdx = args.indexOf('--apply');
|
||||
const applyId = applyIdx >= 0 ? args[applyIdx + 1] : undefined;
|
||||
|
||||
const ctx = buildContext(engine);
|
||||
const report = await runAdvisor(ctx);
|
||||
|
||||
if (applyId) {
|
||||
return applyFinding(report, applyId);
|
||||
}
|
||||
|
||||
// Record run history (local-only) for "since last run" deltas.
|
||||
let deltaNote = '';
|
||||
try {
|
||||
const prior = appendAdvisorRun(report);
|
||||
deltaNote = summarizeDeltas(prior, report);
|
||||
} catch {
|
||||
/* history is best-effort; never block the report */
|
||||
}
|
||||
|
||||
if (json) {
|
||||
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
||||
} else {
|
||||
process.stdout.write(renderAdvisorReport(report));
|
||||
if (deltaNote) process.stdout.write(deltaNote + '\n');
|
||||
}
|
||||
return { exitCode: exitFor(report) };
|
||||
}
|
||||
|
||||
/**
|
||||
* E5: run a single finding's fix. Allowlist = findings carrying a dispatch_id.
|
||||
* Local-only (refused over MCP by construction — this is the CLI path). Executes
|
||||
* the structured argv via a child process with NO shell.
|
||||
*/
|
||||
function applyFinding(report: AdvisorReport, id: string): AdvisorCliResult {
|
||||
const target = resolveApplyTarget(report, id);
|
||||
if (!target.ok) {
|
||||
console.error(
|
||||
target.error +
|
||||
(target.runnable.length ? ` Runnable now: ${target.runnable.join(', ')}.` : ' Nothing is runnable right now.'),
|
||||
);
|
||||
return { exitCode: 2 };
|
||||
}
|
||||
|
||||
console.error(`About to run: ${target.display}`);
|
||||
if (!confirmTty('Proceed? [y/N]: ')) {
|
||||
console.error('Aborted. Nothing was run.');
|
||||
return { exitCode: 1 };
|
||||
}
|
||||
|
||||
const [cmd, ...rest] = target.argv;
|
||||
const res = spawnSync(cmd!, rest, { stdio: 'inherit', shell: false });
|
||||
return { exitCode: (res.status ?? 1) === 0 ? 0 : 2 };
|
||||
}
|
||||
|
||||
/** Synchronous y/N TTY confirm. Non-TTY → false (never auto-run). */
|
||||
function confirmTty(prompt: string): boolean {
|
||||
if (!process.stdin.isTTY) return false;
|
||||
// Bun supports a synchronous prompt via readline only async; use a tiny
|
||||
// blocking read on the TTY fd instead.
|
||||
process.stderr.write(prompt);
|
||||
const buf = Buffer.alloc(8);
|
||||
try {
|
||||
const fs = require('fs') as typeof import('fs');
|
||||
const n = fs.readSync(0, buf, 0, 8, null);
|
||||
const ans = buf.toString('utf8', 0, n).trim().toLowerCase();
|
||||
return ans === 'y' || ans === 'yes';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// readline imported for type-compat with other commands; not used directly.
|
||||
void createInterface;
|
||||
@@ -73,7 +73,9 @@ export const LEARN_INSTRUCTION =
|
||||
'Once connected, call the `get_brain_identity` tool (whose brain this is), then ' +
|
||||
'`list_skills` (everything it can do; if it errors, the host has not enabled skill ' +
|
||||
'publishing — these core tools still work: search, query, get_page, put_page, ' +
|
||||
'think, find_experts). Always search the brain before answering or writing.';
|
||||
'think, find_experts). Then call `list_brain_skillpack`: if this brain ships a ' +
|
||||
'skillpack, ask the user whether to install it (gbrain skillpack scaffold <spec>). ' +
|
||||
'Always search the brain before answering or writing.';
|
||||
|
||||
const SECRET_NOTE =
|
||||
'Note: that bearer token is a long-lived, full-access secret — keep it private and ' +
|
||||
|
||||
@@ -434,6 +434,36 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
|
||||
const skillStatus = skillPublishStatus(publishSkills);
|
||||
if (skillStatus.nudge) console.error(skillStatus.nudge);
|
||||
|
||||
// Note when this brain ships a brain-resident pack so the operator knows
|
||||
// connecting harnesses will be offered it (only meaningful when publishing
|
||||
// is on — list_brain_skillpack is gated by the same flag). Fail-open.
|
||||
if (publishSkills) {
|
||||
try {
|
||||
const { loadAllSources } = await import('../core/sources-load.ts');
|
||||
const { loadSkillpackManifest } = await import('../core/skillpack/manifest-v1.ts');
|
||||
const { existsSync } = await import('fs');
|
||||
const { join } = await import('path');
|
||||
const srcs = await loadAllSources(engine);
|
||||
let n = 0;
|
||||
for (const s of srcs) {
|
||||
if (!s.local_path || !existsSync(join(s.local_path, 'skillpack.json'))) continue;
|
||||
try {
|
||||
if (loadSkillpackManifest(s.local_path).brain_resident === true) n++;
|
||||
} catch {
|
||||
/* malformed pack → ignore */
|
||||
}
|
||||
}
|
||||
if (n > 0) {
|
||||
console.error(
|
||||
`[serve-http] NOTE: ${n} source${n === 1 ? '' : 's'} ship a brain-resident skillpack — ` +
|
||||
'connecting harnesses can discover it via list_brain_skillpack and will be offered to install it.',
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* fail-open: banner is cosmetic */
|
||||
}
|
||||
}
|
||||
|
||||
// Engine-aware SQL adapter. Routes through engine.executeRaw on both
|
||||
// Postgres and PGLite — the OAuth/admin/auth surface no longer requires
|
||||
// a postgres.js singleton, so `gbrain serve --http` works against PGLite
|
||||
|
||||
@@ -81,6 +81,9 @@ Subcommands:
|
||||
auto-scaffold missing artifacts.
|
||||
init <name> Scaffold a fresh skillpack tree (cathedral
|
||||
default; --minimal opts out of test/e2e/evals).
|
||||
init-brain-pack <name> Scaffold a brain-resident pack inside a brain/
|
||||
source repo (brain_resident:true + machine-
|
||||
parseable README; discovered on connect).
|
||||
pack [<pack-dir>] Run doctor then emit a deterministic
|
||||
<name>-<version>.tgz tarball with SHA-256.
|
||||
endorse <name> (Operator-only) Set the tier for a pack in
|
||||
@@ -141,6 +144,9 @@ export async function runSkillpack(args: string[]): Promise<void> {
|
||||
case 'init':
|
||||
await cmdInit(rest);
|
||||
return;
|
||||
case 'init-brain-pack':
|
||||
await cmdInitBrainPack(rest);
|
||||
return;
|
||||
case 'pack':
|
||||
await cmdPack(rest);
|
||||
return;
|
||||
@@ -1104,6 +1110,103 @@ async function cmdInit(args: string[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// init-brain-pack — scaffold a brain-resident pack inside a brain/source repo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function cmdInitBrainPack(args: string[]): Promise<void> {
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(
|
||||
'gbrain skillpack init-brain-pack <name> [--target PATH] [--schema-pack NAME] [--author NAME] [--license SPDX] [--homepage URL] [--dry-run] [--json]\n\n' +
|
||||
' <name> Pack name (lowercase kebab; becomes manifest.name)\n' +
|
||||
' --target Brain/source repo root (default: .)\n' +
|
||||
' --schema-pack Schema pack these skills assume (default: gbrain-base)\n' +
|
||||
' --dry-run Report intent, no writes\n' +
|
||||
' --json JSON envelope for agent consumption\n\n' +
|
||||
'Writes a brain-resident skillpack (skillpack.json brain_resident:true + a\n' +
|
||||
'machine-parseable README) beside your brain content. Connecting harnesses\n' +
|
||||
'discover it via `sources add` and the `list_brain_skillpack` MCP tool.',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
const json = args.includes('--json');
|
||||
const dryRun = args.includes('--dry-run');
|
||||
let name: string | undefined;
|
||||
let target: string | undefined;
|
||||
let schemaPack: string | undefined;
|
||||
let author: string | undefined;
|
||||
let license: string | undefined;
|
||||
let homepage: string | undefined;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === '--target') {
|
||||
target = args[i + 1];
|
||||
i++;
|
||||
} else if (a === '--schema-pack') {
|
||||
schemaPack = args[i + 1];
|
||||
i++;
|
||||
} else if (a === '--author') {
|
||||
author = args[i + 1];
|
||||
i++;
|
||||
} else if (a === '--license') {
|
||||
license = args[i + 1];
|
||||
i++;
|
||||
} else if (a === '--homepage') {
|
||||
homepage = args[i + 1];
|
||||
i++;
|
||||
} else if (a && !a.startsWith('--') && !name) {
|
||||
name = a;
|
||||
}
|
||||
}
|
||||
if (!name) {
|
||||
console.error('Error: pass the new brain-pack name.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const { runInitBrainPack, InitBrainPackError } = await import('../core/skillpack/init-brain-pack.ts');
|
||||
const targetDir = resolveAbs(target ?? '.');
|
||||
|
||||
try {
|
||||
const result = runInitBrainPack({ targetDir, name, schemaPack, author, license, homepage, dryRun });
|
||||
if (json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
dry_run: dryRun,
|
||||
target: result.targetDir,
|
||||
files_written: result.filesWritten,
|
||||
files_skipped_existing: result.filesSkippedExisting,
|
||||
manifest: result.manifest,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`${dryRun ? 'init-brain-pack (dry-run)' : 'init-brain-pack'}: ${result.filesWritten.length} files written, ${result.filesSkippedExisting.length} skipped (already existed) at ${result.targetDir}`,
|
||||
);
|
||||
if (result.filesSkippedExisting.length > 0 && !dryRun) {
|
||||
console.log('\nSkipped existing files (preserved):');
|
||||
for (const p of result.filesSkippedExisting) console.log(` ${p}`);
|
||||
}
|
||||
if (!dryRun) {
|
||||
console.log(
|
||||
`\nNext:\n Edit README.md (the 5 sections a harness reads) + skills/<slug>/SKILL.md\n Commit it to the brain repo. Connecting harnesses will be offered it.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
if (err instanceof InitBrainPackError) {
|
||||
console.error(`skillpack init-brain-pack: ${err.message}`);
|
||||
process.exit(2);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pack — publisher tarball emit + local validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
|
||||
import { writeFileSync, unlinkSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { createHash } from 'crypto';
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import {
|
||||
assessDestructiveImpact,
|
||||
@@ -159,6 +160,11 @@ async function runAdd(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
cloneDir,
|
||||
});
|
||||
|
||||
// Topology A discovery: if the just-added source carries a brain-resident
|
||||
// skillpack, surface it (print, never execute). Fully fail-open — a malformed
|
||||
// or absent pack must never break `sources add`.
|
||||
await maybeAnnounceBrainPack(engine, created);
|
||||
|
||||
const fed = isFederated(created.config);
|
||||
const finalRemoteUrl = (created.config as Record<string, unknown>).remote_url as string | undefined;
|
||||
const tail = finalRemoteUrl
|
||||
@@ -177,6 +183,100 @@ async function runAdd(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Topology A brain-resident pack discovery. Inspects the just-added source's
|
||||
* local path for a `skillpack.json` with `brain_resident: true` and, if found,
|
||||
* prints an agent-readable advisory (escalating-then-suppressed via nag-state).
|
||||
* Fully wrapped in try/catch: a brain-pack discovery bug must never fail a
|
||||
* `sources add`.
|
||||
*/
|
||||
async function maybeAnnounceBrainPack(engine: BrainEngine, created: OpsSourceRow): Promise<void> {
|
||||
try {
|
||||
const localPath = created.local_path;
|
||||
if (!localPath || !existsSync(join(localPath, 'skillpack.json'))) return;
|
||||
|
||||
const { loadSkillpackManifest } = await import('../core/skillpack/manifest-v1.ts');
|
||||
let manifest;
|
||||
try {
|
||||
manifest = loadSkillpackManifest(localPath);
|
||||
} catch {
|
||||
return; // malformed pack → treat as no pack
|
||||
}
|
||||
if (manifest.brain_resident !== true) return;
|
||||
|
||||
const { loadState, findEntry } = await import('../core/skillpack/state.ts');
|
||||
const { loadNagState, saveNagState, findNag, decideNagAction, recordNagDisplay, upsertNag } = await import(
|
||||
'../core/skillpack/nag-state.ts'
|
||||
);
|
||||
const { buildBrainPackAdvisory } = await import('../core/skillpack/brain-pack-advisory.ts');
|
||||
|
||||
// Installed = pack present in skillpack-state at this exact version (#12: state-based).
|
||||
const stateEntry = findEntry(loadState(), manifest.name);
|
||||
const installed = !!stateEntry && stateEntry.version === manifest.version;
|
||||
|
||||
let activeSchemaPack: string | null = null;
|
||||
try {
|
||||
activeSchemaPack = await engine.getConfig('schema_pack');
|
||||
} catch {
|
||||
activeSchemaPack = null;
|
||||
}
|
||||
|
||||
const noNagFlag = process.env.GBRAIN_NO_SKILL_NAG === '1';
|
||||
const brainId = deriveBrainId(created, localPath);
|
||||
const key = { brain_id: brainId, source_id: created.id, pack_name: manifest.name };
|
||||
const nagState = loadNagState();
|
||||
const prior = findNag(nagState, key);
|
||||
|
||||
// Installed packs never nag; an uninstalled pack escalates then suppresses.
|
||||
if (installed) {
|
||||
// Still surface a schema mismatch once (build returns null when none).
|
||||
const text = buildBrainPackAdvisory({
|
||||
manifest,
|
||||
packRoot: localPath,
|
||||
scaffoldSource: localPath,
|
||||
installed: true,
|
||||
activeSchemaPack,
|
||||
});
|
||||
if (text) process.stderr.write(text);
|
||||
return;
|
||||
}
|
||||
|
||||
const decision = decideNagAction(prior, { pack_version: manifest.version, noNagFlag });
|
||||
if (!decision.show) return;
|
||||
|
||||
const text = buildBrainPackAdvisory({
|
||||
manifest,
|
||||
packRoot: localPath,
|
||||
scaffoldSource: localPath,
|
||||
installed: false,
|
||||
activeSchemaPack,
|
||||
level: decision.level,
|
||||
});
|
||||
if (!text) return;
|
||||
process.stderr.write(text);
|
||||
|
||||
// CLI-interactive display → count this decline (#11: never on cron/MCP/pipe).
|
||||
const updated = recordNagDisplay(prior, key, {
|
||||
pack_version: manifest.version,
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
saveNagState(upsertNag(nagState, updated));
|
||||
} catch {
|
||||
// fail-open: discovery is cosmetic, never blocks `sources add`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a stable brain-id for nag keying from the source's canonical git
|
||||
* remote (the repo carrying the pack) when available; else a deterministic
|
||||
* hash of the canonical local path. NOT the brain DB identity.
|
||||
*/
|
||||
function deriveBrainId(created: OpsSourceRow, localPath: string): string {
|
||||
const remote = (created.config as Record<string, unknown>).remote_url as string | undefined;
|
||||
if (remote && remote.length > 0) return `git:${remote}`;
|
||||
return `path:${createHash('sha256').update(localPath).digest('hex').slice(0, 16)}`;
|
||||
}
|
||||
|
||||
// ── Subcommand: list ────────────────────────────────────────
|
||||
|
||||
async function runList(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* advisor/apply.ts — pure resolution + validation for `gbrain advisor --apply`.
|
||||
*
|
||||
* Kept separate from the CLI command so the allowlist + injection guard are
|
||||
* unit-testable without spawning a process. The CLI confirms + executes; this
|
||||
* module decides WHAT (if anything) is safe to run.
|
||||
*
|
||||
* Safety model (#10/C5):
|
||||
* - Only findings carrying a `dispatch_id` are runnable (the allowlist).
|
||||
* - The command is a STRUCTURED argv; it is rejected if any token contains a
|
||||
* shell metacharacter (defense in depth — we never invoke a shell).
|
||||
* - argv[0] must be 'gbrain' (the advisor never runs arbitrary binaries).
|
||||
*/
|
||||
|
||||
import type { AdvisorReport } from './types.ts';
|
||||
|
||||
export type ApplyResolution =
|
||||
| { ok: true; argv: string[]; display: string }
|
||||
| { ok: false; error: string; runnable: string[] };
|
||||
|
||||
const SHELL_META = /[;&|`$<>(){}\n]/;
|
||||
|
||||
export function resolveApplyTarget(report: AdvisorReport, id: string): ApplyResolution {
|
||||
const runnable = report.findings.filter((f) => f.fix.dispatch_id).map((f) => f.fix.dispatch_id!) as string[];
|
||||
const finding = report.findings.find((f) => f.fix.dispatch_id === id);
|
||||
if (!finding) {
|
||||
return { ok: false, error: `No runnable finding with apply id "${id}".`, runnable };
|
||||
}
|
||||
const argv = finding.fix.command_argv;
|
||||
if (!argv || argv.length === 0) {
|
||||
return { ok: false, error: `Finding "${id}" has no runnable command.`, runnable };
|
||||
}
|
||||
if (argv[0] !== 'gbrain') {
|
||||
return { ok: false, error: `Refusing to run: fix does not invoke gbrain.`, runnable };
|
||||
}
|
||||
if (!argv.every((a) => typeof a === 'string' && !SHELL_META.test(a))) {
|
||||
return { ok: false, error: `Refusing to run: fix command contains unexpected characters.`, runnable };
|
||||
}
|
||||
return { ok: true, argv, display: argv.join(' ') };
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* advisor/collect-migration.ts — pending schema migrations.
|
||||
*
|
||||
* The single critical signal: an un-migrated brain can be missing columns/
|
||||
* indexes newer code expects. Fix is idempotent + safe to run via --apply.
|
||||
*/
|
||||
|
||||
import { hasPendingMigrations } from '../migrate.ts';
|
||||
import type { AdvisorCollector } from './types.ts';
|
||||
|
||||
export const collectMigration: AdvisorCollector = {
|
||||
id: 'migration',
|
||||
collect: async (ctx) => {
|
||||
let pending = false;
|
||||
try {
|
||||
pending = await hasPendingMigrations(ctx.engine);
|
||||
} catch {
|
||||
return []; // can't determine (e.g. no config table yet) → say nothing
|
||||
}
|
||||
if (!pending) return [];
|
||||
return [
|
||||
{
|
||||
id: 'pending_migration',
|
||||
severity: 'critical',
|
||||
title: 'Schema migrations are pending — run them before relying on newer features.',
|
||||
detail: 'Newer gbrain code assumes the latest schema; an un-migrated brain can fail or under-perform.',
|
||||
fix: { command_argv: ['gbrain', 'apply-migrations', '--yes'], dispatch_id: 'apply_migrations' },
|
||||
collector: 'migration',
|
||||
ask_user: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* advisor/collect-schema-pack.ts — schema-pack resolvability (a setup smell).
|
||||
*
|
||||
* Source-aware (#7): resolution goes through loadActivePack, which honors the
|
||||
* per-source + brain-wide config tiers. A healthy brain resolves to its pack (or
|
||||
* the gbrain-base default) and emits nothing; a brain configured to a pack that
|
||||
* isn't on disk surfaces a warn so the owner can fix the config.
|
||||
*/
|
||||
|
||||
import { loadActivePack } from '../schema-pack/load-active.ts';
|
||||
import type { AdvisorCollector } from './types.ts';
|
||||
|
||||
export const collectSchemaPack: AdvisorCollector = {
|
||||
id: 'schema-pack',
|
||||
collect: async (ctx) => {
|
||||
let dbConfig: string | undefined;
|
||||
try {
|
||||
dbConfig = (await ctx.engine.getConfig('schema_pack')) ?? undefined;
|
||||
} catch {
|
||||
dbConfig = undefined;
|
||||
}
|
||||
try {
|
||||
await loadActivePack({ cfg: ctx.config, remote: ctx.remote, dbConfig });
|
||||
return []; // resolves cleanly → nothing to say
|
||||
} catch (err) {
|
||||
const name = dbConfig ?? ctx.config?.schema_pack ?? '(configured)';
|
||||
return [
|
||||
{
|
||||
id: 'schema_pack_unresolved',
|
||||
severity: 'warn',
|
||||
title: `The configured schema pack "${name}" could not be resolved.`,
|
||||
detail: `${(err as Error).message}. Pick an installed pack or clear the override.`,
|
||||
fix: { command_argv: ['gbrain', 'schema', 'packs'] },
|
||||
collector: 'schema-pack',
|
||||
ask_user: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* advisor/collect-setup-smells.ts — config/setup misconfigurations.
|
||||
*
|
||||
* Reads merged config + DB-plane keys. Each smell is a concrete, fixable setup
|
||||
* problem an owner usually wants to know about: embeddings disabled while a
|
||||
* populated brain wants search, a missing embedding key, or skill publishing off
|
||||
* while a remote-MCP brain serves agents (they'd hit an empty list_skills).
|
||||
*/
|
||||
|
||||
import type { AdvisorCollector, AdvisorFinding } from './types.ts';
|
||||
|
||||
async function dbBool(ctx: { engine: { getConfig(k: string): Promise<string | null> } }, key: string): Promise<boolean | null> {
|
||||
try {
|
||||
const v = await ctx.engine.getConfig(key);
|
||||
if (v == null) return null;
|
||||
return v === 'true';
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const collectSetupSmells: AdvisorCollector = {
|
||||
id: 'setup-smells',
|
||||
collect: async (ctx) => {
|
||||
const findings: AdvisorFinding[] = [];
|
||||
const cfg = ctx.config ?? ({} as typeof ctx.config);
|
||||
|
||||
// Embeddings disabled — deferred setup never completed.
|
||||
if (cfg.embedding_disabled === true) {
|
||||
findings.push({
|
||||
id: 'embeddings_disabled',
|
||||
severity: 'warn',
|
||||
title: 'Embeddings are disabled — semantic search and dedup are off.',
|
||||
detail: 'Set an embedding model to turn on vector search.',
|
||||
fix: { command_argv: ['gbrain', 'config', 'set', 'embedding_model', '<model-id>'] },
|
||||
collector: 'setup-smells',
|
||||
ask_user: true,
|
||||
});
|
||||
} else if (!cfg.embedding_model && !cfg.zeroentropy_api_key && !process.env.ZEROENTROPY_API_KEY) {
|
||||
// Default provider needs a key; none present anywhere → embeds will fail.
|
||||
findings.push({
|
||||
id: 'embedding_key_missing',
|
||||
severity: 'warn',
|
||||
title: 'No embedding provider key is set — embedding will fail at write time.',
|
||||
detail: 'Set zeroentropy_api_key (or choose another provider via embedding_model).',
|
||||
fix: { command_argv: ['gbrain', 'config', 'set', 'zeroentropy_api_key', '<key>'] },
|
||||
collector: 'setup-smells',
|
||||
ask_user: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Remote-MCP brain serving agents but skill publishing is off → agents hit
|
||||
// an empty list_skills and never learn what the brain can do.
|
||||
if (cfg.remote_mcp) {
|
||||
const publishDb = await dbBool(ctx, 'mcp.publish_skills');
|
||||
const publish = publishDb ?? cfg.mcp?.publish_skills === true;
|
||||
if (!publish) {
|
||||
findings.push({
|
||||
id: 'publish_skills_off',
|
||||
severity: 'info',
|
||||
title: 'Skill publishing is off while this brain serves agents over MCP.',
|
||||
detail: 'Connected agents get an empty list_skills and miss this brain\'s capabilities.',
|
||||
fix: { command_argv: ['gbrain', 'config', 'set', 'mcp.publish_skills', 'true'] },
|
||||
collector: 'setup-smells',
|
||||
ask_user: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* advisor/collect-stalled-jobs.ts — stuck background work + stale sync.
|
||||
*
|
||||
* #14: `minion_jobs` may be ABSENT on older/partial brains — the query is
|
||||
* wrapped so a missing table yields no finding (never an error). Engine-agnostic
|
||||
* SQL via executeRaw (works on Postgres + PGLite); timestamps compared with
|
||||
* `now()` which both engines provide. No PG-only constructs.
|
||||
*/
|
||||
|
||||
import type { AdvisorCollector, AdvisorFinding } from './types.ts';
|
||||
|
||||
export const collectStalledJobs: AdvisorCollector = {
|
||||
id: 'stalled-jobs',
|
||||
collect: async (ctx) => {
|
||||
const findings: AdvisorFinding[] = [];
|
||||
|
||||
// Stuck active jobs: lock lapsed or stalled-counter climbing.
|
||||
try {
|
||||
const rows = await ctx.engine.executeRaw<{ name: string; n: number }>(
|
||||
`SELECT name, count(*)::int AS n
|
||||
FROM minion_jobs
|
||||
WHERE status = 'active'
|
||||
AND (lock_until < now() OR stalled_counter >= 2)
|
||||
GROUP BY name
|
||||
ORDER BY n DESC`,
|
||||
);
|
||||
for (const r of rows) {
|
||||
findings.push({
|
||||
id: `stalled_job:${r.name}`,
|
||||
severity: 'warn',
|
||||
title: `${r.n} "${r.name}" job${r.n === 1 ? '' : 's'} look stalled (lock lapsed / retrying).`,
|
||||
detail: 'A wedged worker stops backfill/sync from progressing.',
|
||||
fix: { command_argv: ['gbrain', 'jobs', 'status'] },
|
||||
collector: 'stalled-jobs',
|
||||
ask_user: true,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* minion_jobs absent / engine quirk → no stalled-jobs finding */
|
||||
}
|
||||
|
||||
// Stale federated sources: synced sources that haven't advanced in a week.
|
||||
try {
|
||||
const rows = await ctx.engine.executeRaw<{ id: string }>(
|
||||
`SELECT id
|
||||
FROM sources
|
||||
WHERE last_sync_at IS NOT NULL
|
||||
AND last_sync_at < now() - interval '7 days'
|
||||
ORDER BY id`,
|
||||
);
|
||||
for (const r of rows) {
|
||||
findings.push({
|
||||
id: `stale_sync:${r.id}`,
|
||||
severity: 'info',
|
||||
title: `Source "${r.id}" hasn't synced in over a week.`,
|
||||
detail: 'Re-sync to pull in new content the brain has not indexed yet.',
|
||||
fix: { command_argv: ['gbrain', 'sync', '--source', r.id] },
|
||||
collector: 'stalled-jobs',
|
||||
ask_user: true,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* sources table missing last_sync_at on a very old brain → skip */
|
||||
}
|
||||
|
||||
return findings;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* advisor/collect-uninstalled-brain-pack.ts — brain-resident packs the user
|
||||
* hasn't installed.
|
||||
*
|
||||
* workspace_dependent (A1): "installed" is a property of the local install
|
||||
* ledger (skillpack-state.json) — meaningless on the server side. runAdvisor
|
||||
* drops these over MCP. Respects the nag ceiling so a long-ignored pack stops
|
||||
* appearing (consistent with the Topology-A advisory; one nag engine).
|
||||
*/
|
||||
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { loadAllSources } from '../sources-load.ts';
|
||||
import { loadSkillpackManifest } from './../skillpack/manifest-v1.ts';
|
||||
import { loadState, findEntry } from './../skillpack/state.ts';
|
||||
import { loadNagState, findNag, decideNagAction } from './../skillpack/nag-state.ts';
|
||||
import { deriveBrainId } from './../skillpack/brain-resident-locate.ts';
|
||||
import type { AdvisorCollector, AdvisorFinding } from './types.ts';
|
||||
|
||||
export const collectUninstalledBrainPack: AdvisorCollector = {
|
||||
id: 'uninstalled-brain-pack',
|
||||
collect: async (ctx) => {
|
||||
if (ctx.remote) return []; // A1: no workspace/install ledger over MCP
|
||||
const findings: AdvisorFinding[] = [];
|
||||
|
||||
let sources;
|
||||
try {
|
||||
sources = await loadAllSources(ctx.engine);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const state = loadState();
|
||||
const nag = loadNagState();
|
||||
|
||||
for (const src of sources) {
|
||||
const localPath = src.local_path;
|
||||
if (!localPath || !existsSync(join(localPath, 'skillpack.json'))) continue;
|
||||
try {
|
||||
const manifest = loadSkillpackManifest(localPath);
|
||||
if (manifest.brain_resident !== true) continue;
|
||||
|
||||
const entry = findEntry(state, manifest.name);
|
||||
const installed = !!entry && entry.version === manifest.version;
|
||||
if (installed) continue;
|
||||
|
||||
// Honor the nag ceiling so a long-ignored pack goes quiet.
|
||||
const remoteUrl = (src.config as Record<string, unknown>)?.remote_url as string | undefined;
|
||||
const brainId = deriveBrainId(remoteUrl ?? null, localPath);
|
||||
const decision = decideNagAction(
|
||||
findNag(nag, { brain_id: brainId, source_id: src.id, pack_name: manifest.name }),
|
||||
{ pack_version: manifest.version },
|
||||
);
|
||||
if (!decision.show) continue;
|
||||
|
||||
findings.push({
|
||||
id: `uninstalled_brain_pack:${src.id}:${manifest.name}`,
|
||||
severity: 'info',
|
||||
title: `Brain source "${src.id}" ships ${manifest.skills.length} skill${manifest.skills.length === 1 ? '' : 's'} you haven't installed (${manifest.name}).`,
|
||||
detail: 'These skills were authored for this brain. Install them to get its full operating manual.',
|
||||
fix: { command_argv: ['gbrain', 'skillpack', 'scaffold', localPath] },
|
||||
collector: 'uninstalled-brain-pack',
|
||||
ask_user: true,
|
||||
workspace_dependent: true,
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* advisor/collect-uninstalled-bundled.ts — recommended bundled skills not yet
|
||||
* installed in this workspace.
|
||||
*
|
||||
* The generalized successor to post-install-advisory's hardcoded set: reads the
|
||||
* single current-state RECOMMENDED list and compares against what's installed.
|
||||
* workspace_dependent (A1): needs the agent's workspace, so it no-ops over MCP.
|
||||
*/
|
||||
|
||||
import { currentRecommendedSet } from './recommended-set.ts';
|
||||
import { detectInstalledSlugs } from './../skillpack/post-install-advisory.ts';
|
||||
import type { AdvisorCollector, AdvisorFinding } from './types.ts';
|
||||
|
||||
export const collectUninstalledBundled: AdvisorCollector = {
|
||||
id: 'uninstalled-bundled',
|
||||
collect: async (ctx) => {
|
||||
if (ctx.remote) return []; // A1: no workspace over MCP
|
||||
if (!ctx.workspace || !ctx.skillsDir) return [];
|
||||
|
||||
let installed: Set<string>;
|
||||
try {
|
||||
installed = detectInstalledSlugs(ctx.skillsDir, ctx.workspace);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const missing = currentRecommendedSet().filter((s) => !installed.has(s.slug));
|
||||
if (missing.length === 0) return [];
|
||||
|
||||
const findings: AdvisorFinding[] = [
|
||||
{
|
||||
id: 'uninstalled_bundled_skills',
|
||||
severity: 'info',
|
||||
title: `${missing.length} recommended skill${missing.length === 1 ? ' is' : 's are'} not installed in this workspace.`,
|
||||
detail: missing.map((s) => s.slug).join(', '),
|
||||
fix: { command_argv: ['gbrain', 'skillpack', 'scaffold', ...missing.map((s) => s.slug)] },
|
||||
collector: 'uninstalled-bundled',
|
||||
ask_user: true,
|
||||
workspace_dependent: true,
|
||||
},
|
||||
];
|
||||
return findings;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* advisor/collect-usage-shape.ts — brain health vs usage.
|
||||
*
|
||||
* Reads getStats + getHealth (both engine-parity-pinned). Surfaces the
|
||||
* highest-leverage health gaps: low embedding coverage (search is degraded),
|
||||
* orphan pages (knowledge not connected), and a low composite brain_score.
|
||||
*
|
||||
* Perf note (eng-review): getHealth runs the orphan/dead-link scan — heavier
|
||||
* than getStats. The advisor calls it on explicit `gbrain advisor` / weekly
|
||||
* cron; the sync-cadence path uses getStats only (commands/advisor.ts /
|
||||
* the sync hook decide which collectors to run).
|
||||
*/
|
||||
|
||||
import type { AdvisorCollector, AdvisorFinding } from './types.ts';
|
||||
|
||||
export const collectUsageShape: AdvisorCollector = {
|
||||
id: 'usage-shape',
|
||||
collect: async (ctx) => {
|
||||
const findings: AdvisorFinding[] = [];
|
||||
|
||||
let pageCount = 0;
|
||||
try {
|
||||
const stats = await ctx.engine.getStats();
|
||||
pageCount = stats.page_count;
|
||||
} catch {
|
||||
return []; // no stats → empty brain or unreachable; nothing to advise
|
||||
}
|
||||
if (pageCount === 0) return [];
|
||||
|
||||
try {
|
||||
const health = await ctx.engine.getHealth();
|
||||
if (health.embed_coverage < 0.7 && health.missing_embeddings > 0) {
|
||||
findings.push({
|
||||
id: 'low_embed_coverage',
|
||||
severity: 'warn',
|
||||
title: `Only ${Math.round(health.embed_coverage * 100)}% of content is embedded — semantic search is degraded.`,
|
||||
detail: `${health.missing_embeddings} pages are missing embeddings. Backfill to restore full recall.`,
|
||||
fix: { command_argv: ['gbrain', 'embed', '--all'] },
|
||||
collector: 'usage-shape',
|
||||
ask_user: true,
|
||||
});
|
||||
}
|
||||
if (health.orphan_pages > 0) {
|
||||
findings.push({
|
||||
id: 'orphan_pages',
|
||||
severity: 'info',
|
||||
title: `${health.orphan_pages} page${health.orphan_pages === 1 ? ' has' : 's have'} no links in or out.`,
|
||||
detail: 'Orphaned pages do not surface through graph traversal — connect or review them.',
|
||||
fix: { command_argv: ['gbrain', 'orphans'] },
|
||||
collector: 'usage-shape',
|
||||
ask_user: true,
|
||||
});
|
||||
}
|
||||
if (health.dead_links > 0) {
|
||||
findings.push({
|
||||
id: 'dead_links',
|
||||
severity: 'info',
|
||||
title: `${health.dead_links} link${health.dead_links === 1 ? '' : 's'} point to a page that no longer exists.`,
|
||||
fix: { command_argv: ['gbrain', 'doctor'] },
|
||||
collector: 'usage-shape',
|
||||
ask_user: true,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* getHealth unavailable → skip the health-derived findings */
|
||||
}
|
||||
|
||||
return findings;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* advisor/collect-version.ts — gbrain version drift.
|
||||
*
|
||||
* Reads the update CACHE only — never the network (the advisor op must stay fast
|
||||
* and cron-safe). The cache is refreshed out-of-band by `gbrain check-update` /
|
||||
* the self-upgrade refresh path.
|
||||
*/
|
||||
|
||||
import { readUpdateCache } from '../self-upgrade.ts';
|
||||
import type { AdvisorCollector } from './types.ts';
|
||||
|
||||
export const collectVersion: AdvisorCollector = {
|
||||
id: 'version',
|
||||
collect: async (ctx) => {
|
||||
let latest: string | undefined;
|
||||
try {
|
||||
const entry = readUpdateCache();
|
||||
if (entry && entry.marker.kind === 'upgrade_available' && entry.marker.latest) {
|
||||
latest = entry.marker.latest;
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (!latest) return [];
|
||||
return [
|
||||
{
|
||||
id: 'version_drift',
|
||||
severity: 'warn',
|
||||
title: `gbrain ${latest} is available — you're on ${ctx.version}.`,
|
||||
detail: 'A newer release shipped fixes and features. Upgrading keeps the brain current.',
|
||||
fix: { command_argv: ['gbrain', 'upgrade'] },
|
||||
collector: 'version',
|
||||
ask_user: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* advisor/history.ts — E3 finding history (local-only, file-plane).
|
||||
*
|
||||
* Append-only `~/.gbrain/advisor-history.jsonl`, bounded/rotated. Chosen over a
|
||||
* DB migration table (eng-review): it removes the plan's only schema migration +
|
||||
* engine-parity burden and matches the nag-state/skillpack-state file-plane
|
||||
* pattern. The advisor only appends a snapshot and reads the most recent one for
|
||||
* "since last run" deltas — no SQL queries needed.
|
||||
*
|
||||
* Local-only: callers skip these writes when remote (the MCP advisor is strictly
|
||||
* read-only). Best-effort: a write failure never blocks the report.
|
||||
*/
|
||||
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
|
||||
import { gbrainPath } from '../config.ts';
|
||||
import type { AdvisorReport } from './types.ts';
|
||||
|
||||
/** Max snapshots retained before rotation trims the file to the newest half. */
|
||||
export const ADVISOR_HISTORY_MAX = 100;
|
||||
|
||||
export interface AdvisorRunSnapshot {
|
||||
ts: string;
|
||||
version: string;
|
||||
worst: AdvisorReport['worst'];
|
||||
finding_ids: string[];
|
||||
}
|
||||
|
||||
export function advisorHistoryPath(): string {
|
||||
return gbrainPath('advisor-history.jsonl');
|
||||
}
|
||||
|
||||
function readSnapshots(path: string): AdvisorRunSnapshot[] {
|
||||
if (!existsSync(path)) return [];
|
||||
const out: AdvisorRunSnapshot[] = [];
|
||||
for (const line of readFileSync(path, 'utf-8').split('\n')) {
|
||||
const t = line.trim();
|
||||
if (!t) continue;
|
||||
try {
|
||||
out.push(JSON.parse(t) as AdvisorRunSnapshot);
|
||||
} catch {
|
||||
/* skip a torn line */
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a snapshot for this run and return the PRIOR snapshot (for deltas), or
|
||||
* null on a cold history. Rotates the file when it grows past the cap. Pure-ish:
|
||||
* `opts.path` overrides the default for tests.
|
||||
*/
|
||||
export function appendAdvisorRun(
|
||||
report: AdvisorReport,
|
||||
opts: { path?: string } = {},
|
||||
): AdvisorRunSnapshot | null {
|
||||
const path = opts.path ?? advisorHistoryPath();
|
||||
const prior = readSnapshots(path);
|
||||
const last = prior.length > 0 ? prior[prior.length - 1]! : null;
|
||||
|
||||
const snap: AdvisorRunSnapshot = {
|
||||
ts: report.generated_at,
|
||||
version: report.version,
|
||||
worst: report.worst,
|
||||
finding_ids: report.findings.map((f) => f.id),
|
||||
};
|
||||
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
// Rotate: when at/over the cap, rewrite with the newest half + this snapshot.
|
||||
if (prior.length >= ADVISOR_HISTORY_MAX) {
|
||||
const keep = prior.slice(Math.floor(ADVISOR_HISTORY_MAX / 2));
|
||||
const tmp = path + '.tmp';
|
||||
writeFileSync(tmp, [...keep, snap].map((s) => JSON.stringify(s)).join('\n') + '\n', { mode: 0o644 });
|
||||
renameSync(tmp, path);
|
||||
} else {
|
||||
appendFileSync(path, JSON.stringify(snap) + '\n', { mode: 0o644 });
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
/** A one-line "since last run" delta, or '' when there's no prior run. */
|
||||
export function summarizeDeltas(prior: AdvisorRunSnapshot | null, current: AdvisorReport): string {
|
||||
if (!prior) return '';
|
||||
const before = new Set(prior.finding_ids);
|
||||
const now = new Set(current.findings.map((f) => f.id));
|
||||
const added = [...now].filter((id) => !before.has(id));
|
||||
const resolved = [...before].filter((id) => !now.has(id));
|
||||
if (added.length === 0 && resolved.length === 0) return '';
|
||||
const parts: string[] = [];
|
||||
if (added.length) parts.push(`${added.length} new since last run`);
|
||||
if (resolved.length) parts.push(`${resolved.length} resolved`);
|
||||
return `(${parts.join(', ')})`;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* advisor/recommended-set.ts — the bundled skills the advisor + the post-install
|
||||
* advisory recommend a user install.
|
||||
*
|
||||
* CURRENT-STATE ONLY (eng-review Q1): this is a single list of what should be
|
||||
* installed now, NOT a release-keyed `Record<release, skills[]>` append-only
|
||||
* table. Per-release append-only structures are exactly the anti-pattern
|
||||
* CLAUDE.md forbids (they bloated CLAUDE.md to 147k tokens); release attribution
|
||||
* for a skill lives in git/CHANGELOG, not here. When the bundled set changes,
|
||||
* edit THIS list to the new truth.
|
||||
*/
|
||||
|
||||
export interface RecommendedSkill {
|
||||
slug: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const RECOMMENDED: RecommendedSkill[] = [
|
||||
{
|
||||
slug: 'book-mirror',
|
||||
description:
|
||||
'FLAGSHIP. Take any book (EPUB/PDF), produce a personalized two-column chapter-by-chapter analysis that maps every idea to your life using brain context.',
|
||||
},
|
||||
{
|
||||
slug: 'article-enrichment',
|
||||
description:
|
||||
'Turn raw article dumps into structured pages with executive summary, verbatim quotes, key insights, and why-it-matters.',
|
||||
},
|
||||
{
|
||||
slug: 'strategic-reading',
|
||||
description:
|
||||
'Read a book / article / case study through ONE specific problem-lens. Output: an applied playbook with do / avoid / watch-for.',
|
||||
},
|
||||
{
|
||||
slug: 'concept-synthesis',
|
||||
description:
|
||||
'Deduplicate raw concept stubs into a tiered intellectual map (T1 Canon to T4 Riff). Trace idea evolution across years.',
|
||||
},
|
||||
{
|
||||
slug: 'perplexity-research',
|
||||
description:
|
||||
'Brain-augmented web research. Sends brain context to the search so it focuses on what is NEW vs already-known.',
|
||||
},
|
||||
{
|
||||
slug: 'archive-crawler',
|
||||
description:
|
||||
'Universal archivist for personal file archives. REFUSES to run without a gbrain.yml allow-list — safe-by-default.',
|
||||
},
|
||||
{
|
||||
slug: 'academic-verify',
|
||||
description:
|
||||
'Trace a research claim through publication → methodology → raw data → independent replication. Verdict-shaped brain page.',
|
||||
},
|
||||
{
|
||||
slug: 'brain-pdf',
|
||||
description: 'Render any brain page to publication-quality PDF via the gstack make-pdf binary.',
|
||||
},
|
||||
{
|
||||
slug: 'voice-note-ingest',
|
||||
description:
|
||||
'Capture voice notes with EXACT-PHRASING preservation (never paraphrased). Routes content to the right page types.',
|
||||
},
|
||||
];
|
||||
|
||||
/** The current recommended set. (Param kept for call-site compatibility.) */
|
||||
export function currentRecommendedSet(): RecommendedSkill[] {
|
||||
return RECOMMENDED;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* advisor/render.ts — the shared `=`-bar agent-readable renderer.
|
||||
*
|
||||
* One renderer for BOTH `gbrain advisor` and the post-install advisory so the
|
||||
* two surfaces never drift (eng-review: shared render). Print-never-execute: the
|
||||
* output tells the harness to show the user and ask before acting.
|
||||
*/
|
||||
|
||||
import type { AdvisorFinding, AdvisorReport, AdvisorSeverity } from './types.ts';
|
||||
|
||||
const BAR = '='.repeat(72);
|
||||
|
||||
const SEV_LABEL: Record<AdvisorSeverity, string> = {
|
||||
critical: 'CRITICAL',
|
||||
warn: 'WARN',
|
||||
info: 'INFO',
|
||||
};
|
||||
|
||||
function fixLine(f: AdvisorFinding): string | null {
|
||||
if (!f.fix.command_argv || f.fix.command_argv.length === 0) return null;
|
||||
return f.fix.command_argv.join(' ');
|
||||
}
|
||||
|
||||
/** Render a full advisor report as the agent-readable `=`-bar block. */
|
||||
export function renderAdvisorReport(report: AdvisorReport): string {
|
||||
const lines: string[] = [];
|
||||
lines.push('');
|
||||
lines.push(BAR);
|
||||
lines.push(`gbrain advisor — ${report.findings.length} thing${report.findings.length === 1 ? '' : 's'} worth your attention (gbrain ${report.version})`);
|
||||
lines.push(BAR);
|
||||
lines.push('');
|
||||
|
||||
if (report.findings.length === 0) {
|
||||
lines.push('Nothing pressing — this brain looks healthy. Re-run `gbrain advisor` any time.');
|
||||
lines.push(BAR);
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
for (const f of report.findings) {
|
||||
lines.push(`[${SEV_LABEL[f.severity]}] ${f.title}`);
|
||||
if (f.detail) for (const wl of wrap(f.detail, 68, ' ')) lines.push(wl);
|
||||
const fl = fixLine(f);
|
||||
if (fl) lines.push(` fix: ${fl}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push('ACTION FOR THE AGENT:');
|
||||
lines.push(' 1. Show this list to the user, highest-severity first.');
|
||||
lines.push(' 2. Ask before running any fix. The user owns these decisions.');
|
||||
lines.push(' 3. To run a single fix locally after they agree:');
|
||||
lines.push('');
|
||||
const appliable = report.findings.filter((f) => f.fix.dispatch_id);
|
||||
if (appliable.length > 0) {
|
||||
lines.push(` gbrain advisor --apply <id> # e.g. ${appliable[0]!.fix.dispatch_id}`);
|
||||
} else {
|
||||
lines.push(' (run the per-finding `fix:` command above)');
|
||||
}
|
||||
lines.push('');
|
||||
lines.push(BAR);
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the post-install / upgrade flavor: the bundled-skill recommendations as
|
||||
* a single advisor finding set. Kept here so post-install-advisory.ts and the
|
||||
* advisor share one format.
|
||||
*/
|
||||
export function renderRecommendedSkills(opts: {
|
||||
version: string;
|
||||
context: 'init' | 'upgrade';
|
||||
skills: Array<{ slug: string; description: string }>;
|
||||
scaffoldAllCommand: string;
|
||||
workspaceNotDetected?: boolean;
|
||||
}): string {
|
||||
const lines: string[] = [];
|
||||
const verb = opts.context === 'init' ? 'installed' : 'upgraded to';
|
||||
lines.push('');
|
||||
lines.push(BAR);
|
||||
lines.push(`gbrain ${opts.version} — RECOMMENDED SKILLS FOR THE AGENT TO INSTALL`);
|
||||
lines.push(BAR);
|
||||
lines.push('');
|
||||
lines.push(
|
||||
`The user just ${verb} gbrain ${opts.version}. ${opts.skills.length} ` +
|
||||
`${opts.skills.length === 1 ? 'skill is' : 'skills are'} recommended but not yet installed.`,
|
||||
);
|
||||
lines.push('');
|
||||
if (opts.workspaceNotDetected) {
|
||||
lines.push('(NOTE: no workspace detected at the default location — pass --workspace to scaffold.)');
|
||||
lines.push('');
|
||||
}
|
||||
lines.push('THE SKILLS:');
|
||||
lines.push('');
|
||||
for (const s of opts.skills) {
|
||||
lines.push(` - ${s.slug}`);
|
||||
for (const wl of wrap(s.description, 68, ' ')) lines.push(wl);
|
||||
lines.push('');
|
||||
}
|
||||
lines.push('ACTION FOR THE AGENT:');
|
||||
lines.push(' 1. Show this list to the user. Briefly explain each skill.');
|
||||
lines.push(' 2. Ask the user explicitly: "Want me to install these now?"');
|
||||
lines.push(' 3. If YES:');
|
||||
lines.push('');
|
||||
lines.push(` ${opts.scaffoldAllCommand}`);
|
||||
lines.push('');
|
||||
lines.push(' Do NOT scaffold without asking. The user owns this decision.');
|
||||
lines.push(BAR);
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function wrap(text: string, width: number, indent: string): string[] {
|
||||
const words = text.split(/\s+/);
|
||||
const lines: string[] = [];
|
||||
let current = indent;
|
||||
for (const word of words) {
|
||||
if ((current + (current === indent ? '' : ' ') + word).length > width + indent.length) {
|
||||
lines.push(current.trimEnd());
|
||||
current = indent + word;
|
||||
} else {
|
||||
current = current === indent ? indent + word : current + ' ' + word;
|
||||
}
|
||||
}
|
||||
if (current.trim().length > 0) lines.push(current.trimEnd());
|
||||
return lines;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* advisor/run.ts — runs the collectors and ranks the findings.
|
||||
*
|
||||
* Hardcoded v1 collector list (open-Q4): a static array keeps ordering
|
||||
* deterministic and the surface auditable. Each collector runs in its OWN
|
||||
* try/catch so one failure never kills the whole report. Workspace-dependent
|
||||
* collectors (A1) are dropped over MCP.
|
||||
*/
|
||||
|
||||
import type { AdvisorCollector, AdvisorContext, AdvisorFinding, AdvisorReport, AdvisorSeverity } from './types.ts';
|
||||
import { collectVersion } from './collect-version.ts';
|
||||
import { collectMigration } from './collect-migration.ts';
|
||||
import { collectSchemaPack } from './collect-schema-pack.ts';
|
||||
import { collectStalledJobs } from './collect-stalled-jobs.ts';
|
||||
import { collectUsageShape } from './collect-usage-shape.ts';
|
||||
import { collectSetupSmells } from './collect-setup-smells.ts';
|
||||
import { collectUninstalledBrainPack } from './collect-uninstalled-brain-pack.ts';
|
||||
import { collectUninstalledBundled } from './collect-uninstalled-bundled.ts';
|
||||
|
||||
/** Deterministic v1 collector order (also the secondary sort key for ranking). */
|
||||
export const COLLECTORS: AdvisorCollector[] = [
|
||||
collectVersion,
|
||||
collectMigration,
|
||||
collectSchemaPack,
|
||||
collectStalledJobs,
|
||||
collectUsageShape,
|
||||
collectSetupSmells,
|
||||
collectUninstalledBrainPack,
|
||||
collectUninstalledBundled,
|
||||
];
|
||||
|
||||
const SEV_RANK: Record<AdvisorSeverity, number> = { critical: 0, warn: 1, info: 2 };
|
||||
|
||||
/**
|
||||
* Rank: critical > warn > info, ties broken by collector order (stable). All
|
||||
* criticals are always kept; the info tail is capped so the agent isn't buried.
|
||||
*/
|
||||
export function rankFindings(findings: AdvisorFinding[], opts: { infoCap?: number } = {}): AdvisorFinding[] {
|
||||
const order = new Map(COLLECTORS.map((c, i) => [c.id, i] as const));
|
||||
const sorted = [...findings].sort((a, b) => {
|
||||
const s = SEV_RANK[a.severity] - SEV_RANK[b.severity];
|
||||
if (s !== 0) return s;
|
||||
return (order.get(a.collector) ?? 99) - (order.get(b.collector) ?? 99);
|
||||
});
|
||||
const infoCap = opts.infoCap ?? 10;
|
||||
const out: AdvisorFinding[] = [];
|
||||
let infoSeen = 0;
|
||||
for (const f of sorted) {
|
||||
if (f.severity === 'info') {
|
||||
if (infoSeen >= infoCap) continue;
|
||||
infoSeen++;
|
||||
}
|
||||
out.push(f);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run every collector and return a ranked report. Resilient: a collector that
|
||||
* throws contributes nothing and never aborts the others.
|
||||
*/
|
||||
export async function runAdvisor(ctx: AdvisorContext): Promise<AdvisorReport> {
|
||||
const all: AdvisorFinding[] = [];
|
||||
for (const c of COLLECTORS) {
|
||||
try {
|
||||
const found = await c.collect(ctx);
|
||||
for (const f of found) {
|
||||
// A1: drop workspace-dependent findings over MCP.
|
||||
if (ctx.remote && f.workspace_dependent) continue;
|
||||
all.push(f);
|
||||
}
|
||||
} catch {
|
||||
// one collector failing must not kill the report
|
||||
}
|
||||
}
|
||||
const ranked = rankFindings(all);
|
||||
const worst: AdvisorSeverity | null =
|
||||
ranked.some((f) => f.severity === 'critical')
|
||||
? 'critical'
|
||||
: ranked.some((f) => f.severity === 'warn')
|
||||
? 'warn'
|
||||
: ranked.length > 0
|
||||
? 'info'
|
||||
: null;
|
||||
return {
|
||||
version: ctx.version,
|
||||
generated_at: ctx.now.toISOString(),
|
||||
findings: ranked,
|
||||
worst,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* advisor/types.ts — shared types for `gbrain advisor`.
|
||||
*
|
||||
* The advisor is a read-only, brain-state-aware recommender: it computes a
|
||||
* ranked list of high-leverage actions for THIS brain right now, each with a
|
||||
* severity, a one-line why-it-matters, and the exact fix command. It never
|
||||
* mutates (the CLI `--apply` path runs a fix only behind an explicit, local-only
|
||||
* confirm; see commands/advisor.ts). Same print-never-execute discipline as
|
||||
* post-install-advisory.ts.
|
||||
*/
|
||||
|
||||
import type { BrainEngine } from '../engine.ts';
|
||||
import type { GBrainConfig } from '../config.ts';
|
||||
|
||||
export type AdvisorSeverity = 'critical' | 'warn' | 'info';
|
||||
|
||||
/** A fix the advisor recommends. */
|
||||
export interface AdvisorFix {
|
||||
/**
|
||||
* The fix as a structured argv (e.g. ['gbrain','apply-migrations','--yes']).
|
||||
* Never a shell string — `--apply` executes via an allowlisted dispatcher, not
|
||||
* a shell, so source-derived names can't inject (#10/C5). Null when there is
|
||||
* no single mechanical fix.
|
||||
*/
|
||||
command_argv: string[] | null;
|
||||
/**
|
||||
* Allowlisted dispatch key for `gbrain advisor --apply <id>`. Present only on
|
||||
* findings whose fix is safe to run via the local-only dispatcher.
|
||||
*/
|
||||
dispatch_id?: string;
|
||||
}
|
||||
|
||||
export interface AdvisorFinding {
|
||||
/** Stable id (e.g. 'version_drift', 'pending_migration'). */
|
||||
id: string;
|
||||
severity: AdvisorSeverity;
|
||||
/** One-line why-it-matters. */
|
||||
title: string;
|
||||
/** Optional extra context. */
|
||||
detail?: string;
|
||||
fix: AdvisorFix;
|
||||
/** Which collector emitted it. */
|
||||
collector: string;
|
||||
/** Print-never-execute: the harness asks the user before acting. */
|
||||
ask_user: boolean;
|
||||
/**
|
||||
* A1: true when the finding depends on the agent's local WORKSPACE (installed
|
||||
* skills) rather than brain state. These no-op over MCP (no workspace on the
|
||||
* server side) — runAdvisor drops them when ctx.remote !== false.
|
||||
*/
|
||||
workspace_dependent?: boolean;
|
||||
}
|
||||
|
||||
export interface AdvisorContext {
|
||||
engine: BrainEngine;
|
||||
config: GBrainConfig;
|
||||
/** Serving gbrain version (src/version.ts VERSION). */
|
||||
version: string;
|
||||
/** Agent workspace root (CLI only; null over MCP). */
|
||||
workspace: string | null;
|
||||
/** Resolved skills dir (CLI only; null over MCP). */
|
||||
skillsDir: string | null;
|
||||
now: Date;
|
||||
/** True when invoked over MCP (untrusted/no-workspace); false for local CLI. */
|
||||
remote: boolean;
|
||||
}
|
||||
|
||||
export interface AdvisorCollector {
|
||||
id: string;
|
||||
collect: (ctx: AdvisorContext) => Promise<AdvisorFinding[]>;
|
||||
}
|
||||
|
||||
export interface AdvisorReport {
|
||||
version: string;
|
||||
generated_at: string;
|
||||
findings: AdvisorFinding[];
|
||||
/** Highest severity present, for exit-code mapping (E2). */
|
||||
worst: AdvisorSeverity | null;
|
||||
}
|
||||
@@ -327,6 +327,13 @@ export interface GBrainConfig {
|
||||
* Local CLI callers (`ctx.remote === false`) bypass the gate entirely.
|
||||
*/
|
||||
publish_skills?: boolean;
|
||||
/**
|
||||
* Gate for the `advisor` op over a REMOTE transport (#2180). Separate from
|
||||
* `publish_skills` because the advisor exposes operational diagnostics
|
||||
* (version drift, stalled jobs, embedding-key presence), not prose skills.
|
||||
* Default OFF; local CLI callers bypass. The MCP advisor is read-only.
|
||||
*/
|
||||
publish_advisor?: boolean;
|
||||
/**
|
||||
* Explicit skills-dir override. Wins over autodetect — makes which skills
|
||||
* get published deterministic across laptop / daemon / container launches.
|
||||
@@ -890,6 +897,12 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [
|
||||
'mcp.publish_skills',
|
||||
'mcp.publish_skills_prompted',
|
||||
'mcp.skills_dir',
|
||||
// MCP advisor publishing (#2180): separate gate from publish_skills because
|
||||
// the advisor exposes operational diagnostics (version/jobs/key presence),
|
||||
// not prose skills. Default OFF; read-only over MCP.
|
||||
'mcp.publish_advisor',
|
||||
// Skill-nag suppression (#2180): brain-resident pack install nag off-switch.
|
||||
'skillpack.nag_disabled',
|
||||
// Self-upgrade (v0.42; file plane, read on the hot path)
|
||||
'self_upgrade.mode',
|
||||
'self_upgrade.mode_prompted',
|
||||
|
||||
+20
-4
@@ -1145,13 +1145,23 @@ export interface BrainEngine {
|
||||
* sources (pre-v0.31.8 behavior; preserved via two-branch query in both
|
||||
* engines). When set, the from-page filter becomes
|
||||
* `WHERE f.slug = $1 AND f.source_id = $X`.
|
||||
*
|
||||
* #2200: `opts.sourceIds` is a federated read grant (caller's allowedSources).
|
||||
* It takes precedence over scalar `sourceId` and constrains BOTH endpoints
|
||||
* (from AND to) to the grant via `source_id = ANY($::text[])` — so an in-grant
|
||||
* page linking to an out-of-grant page does NOT leak the foreign slug/context
|
||||
* (carries the `traversePaths` both-endpoint invariant). Remote MCP clients
|
||||
* always route here (sourceScopeOpts emits an array for any allowedSources
|
||||
* grant); the scalar branch is internal/CLI and keeps cross-source visibility
|
||||
* (reconcileLinks + back-link validators depend on it).
|
||||
*/
|
||||
getLinks(slug: string, opts?: { sourceId?: string }): Promise<Link[]>;
|
||||
getLinks(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<Link[]>;
|
||||
/**
|
||||
* v0.31.8 (D12 + D16): same `opts.sourceId` semantics as `getLinks`,
|
||||
* applied to the to-page side of the join.
|
||||
* applied to the to-page side of the join. #2200: `opts.sourceIds` federated
|
||||
* grant constrains both endpoints (see `getLinks`).
|
||||
*/
|
||||
getBacklinks(slug: string, opts?: { sourceId?: string }): Promise<Link[]>;
|
||||
getBacklinks(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<Link[]>;
|
||||
/**
|
||||
* v114 (#1941): distinct link_source provenances with edge counts, for
|
||||
* `gbrain link-sources`. Source-scoped via `{sourceId?, sourceIds?}` (both
|
||||
@@ -1330,7 +1340,13 @@ export interface BrainEngine {
|
||||
*/
|
||||
addTag(slug: string, tag: string, opts?: { sourceId?: string }): Promise<void>;
|
||||
removeTag(slug: string, tag: string, opts?: { sourceId?: string }): Promise<void>;
|
||||
getTags(slug: string, opts?: { sourceId?: string }): Promise<string[]>;
|
||||
/**
|
||||
* #2200: getTags ALSO accepts a federated `sourceIds[]` read grant (precedence
|
||||
* over scalar `sourceId`); it unions tags across the matched same-slug pages
|
||||
* via `page_id IN (…) … DISTINCT`. The write-side addTag/removeTag deliberately
|
||||
* stay scalar-only — `allowedSources` is a read grant; writes route to one source.
|
||||
*/
|
||||
getTags(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<string[]>;
|
||||
|
||||
// Timeline
|
||||
/**
|
||||
|
||||
+129
-12
@@ -425,6 +425,29 @@ export function sourceScopeOpts(ctx: OperationContext): { sourceId?: string; sou
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* #2200: source scope for the LINK read ops (get_links / get_backlinks). A link
|
||||
* row references three pages (from, to, origin); the engine's federated
|
||||
* (`sourceIds[]`) branch scopes ALL THREE, but its scalar (`sourceId`) branch
|
||||
* scopes only the near endpoint — by design, because trusted internal callers
|
||||
* (reconcileLinks, back-link validators, enrich) call the engine directly with a
|
||||
* scalar scope and need the cross-source view.
|
||||
*
|
||||
* An UNTRUSTED remote caller carrying only a scalar scope (a legacy bearer token
|
||||
* or a pre-`federated_read` OAuth client) would otherwise hit that scalar branch
|
||||
* and have a foreign far/origin slug disclosed. So for remote callers we promote a
|
||||
* scalar scope to a single-element `sourceIds:[id]`, routing them through the
|
||||
* all-endpoint branch. Trusted local CLI (`ctx.remote === false`) keeps the scalar
|
||||
* cross-source view, and a federated array passes through unchanged.
|
||||
*/
|
||||
export function linkReadScopeOpts(ctx: OperationContext): { sourceId?: string; sourceIds?: string[] } {
|
||||
const scope = sourceScopeOpts(ctx);
|
||||
if (ctx.remote !== false && scope.sourceId && !scope.sourceIds) {
|
||||
return { sourceIds: [scope.sourceId] };
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a per-call requested source scope against the caller's trust + grant.
|
||||
* FAIL-CLOSED: anything not strictly `ctx.remote === false` is untrusted.
|
||||
@@ -642,7 +665,12 @@ const get_page: Operation = {
|
||||
// inside bumpLastRetrievedAt (D2).
|
||||
bumpLastRetrievedAt(ctx.engine, [page.id]);
|
||||
|
||||
const tags = await ctx.engine.getTags(page.slug, sourceOpts);
|
||||
// #2200: resolve tags against the concrete page's source. `sourceOpts` may
|
||||
// be { sourceIds:[...] } (federated) with no scalar sourceId, which getTags
|
||||
// would otherwise fall back to 'default' for — the wrong source for a
|
||||
// non-default page. We already hold the resolved page, so its source is
|
||||
// unambiguous.
|
||||
const tags = await ctx.engine.getTags(page.slug, { sourceId: page.source_id });
|
||||
// Privacy boundary for the per-token allow-list (v0.28.6 for takes,
|
||||
// v0.32.2 for facts).
|
||||
//
|
||||
@@ -1875,8 +1903,11 @@ const get_tags: Operation = {
|
||||
slug: { type: 'string', required: true },
|
||||
},
|
||||
handler: async (ctx, p) => {
|
||||
// v0.31.8 (D20): thread ctx.sourceId for read-side ops on multi-source brains.
|
||||
const sourceOpts = ctx.sourceId ? { sourceId: ctx.sourceId } : {};
|
||||
// #2200: route through sourceScopeOpts so a federated read grant
|
||||
// (ctx.auth.allowedSources) reaches the engine, not just scalar ctx.sourceId.
|
||||
// Was `ctx.sourceId ? {sourceId} : {}` — a federated client got '{}' →
|
||||
// engine fell back to 'default' (functionality gap + cross-source leak).
|
||||
const sourceOpts = sourceScopeOpts(ctx);
|
||||
return ctx.engine.getTags(p.slug as string, sourceOpts);
|
||||
},
|
||||
scope: 'read',
|
||||
@@ -1972,9 +2003,10 @@ const get_links: Operation = {
|
||||
slug: { type: 'string', required: true },
|
||||
},
|
||||
handler: async (ctx, p) => {
|
||||
// v0.31.8 (D16): thread ctx.sourceId. When unset, engine falls through
|
||||
// to cross-source view (back-compat).
|
||||
const sourceOpts = ctx.sourceId ? { sourceId: ctx.sourceId } : {};
|
||||
// #2200: linkReadScopeOpts so a federated grant — and an untrusted remote
|
||||
// scalar scope (promoted to sourceIds[]) — reaches the engine's all-endpoint
|
||||
// branch. Trusted local/internal callers keep the scalar cross-source view.
|
||||
const sourceOpts = linkReadScopeOpts(ctx);
|
||||
return ctx.engine.getLinks(p.slug as string, sourceOpts);
|
||||
},
|
||||
scope: 'read',
|
||||
@@ -1987,7 +2019,9 @@ const get_backlinks: Operation = {
|
||||
slug: { type: 'string', required: true },
|
||||
},
|
||||
handler: async (ctx, p) => {
|
||||
const sourceOpts = ctx.sourceId ? { sourceId: ctx.sourceId } : {};
|
||||
// #2200: linkReadScopeOpts — federated grant + untrusted remote scalar
|
||||
// (promoted to sourceIds[]) reach the engine's all-endpoint branch.
|
||||
const sourceOpts = linkReadScopeOpts(ctx);
|
||||
return ctx.engine.getBacklinks(p.slug as string, sourceOpts);
|
||||
},
|
||||
scope: 'read',
|
||||
@@ -2106,9 +2140,9 @@ const get_timeline: Operation = {
|
||||
slug: { type: 'string', required: true },
|
||||
},
|
||||
handler: async (ctx, p) => {
|
||||
// v0.31.8 (D20): thread ctx.sourceId.
|
||||
const sourceId = ctx.sourceId;
|
||||
return ctx.engine.getTimeline(p.slug as string, sourceId ? { sourceId } : undefined);
|
||||
// #2200: route through sourceScopeOpts so a federated grant reaches the
|
||||
// engine via TimelineOpts.sourceIds; scalar/unset unchanged.
|
||||
return ctx.engine.getTimeline(p.slug as string, sourceScopeOpts(ctx));
|
||||
},
|
||||
scope: 'read',
|
||||
cliHints: { name: 'timeline', positional: ['slug'] },
|
||||
@@ -2222,13 +2256,26 @@ const get_skill: Operation = {
|
||||
name: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Skill name exactly as returned by list_skills.',
|
||||
description: 'Skill name exactly as returned by list_skills (or the brain-pack skill slug when source_id is set).',
|
||||
},
|
||||
source_id: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Optional: fetch a brain-resident pack skill from this source instead of the host catalog. ' +
|
||||
'Disambiguates a slug that exists on more than one source (see list_brain_skillpack).',
|
||||
},
|
||||
},
|
||||
handler: async (ctx, p) => {
|
||||
const sc = await import('./skill-catalog.ts');
|
||||
const publish = await sc.readMcpPublishSkills(ctx);
|
||||
sc.assertPublishEnabled(ctx, publish);
|
||||
// Brain-resident path: when source_id is supplied, fetch the per-source pack
|
||||
// skill (confined to that source's pack root) rather than the host catalog.
|
||||
if (typeof p.source_id === 'string' && p.source_id.length > 0) {
|
||||
const brl = await import('./skillpack/brain-resident-locate.ts');
|
||||
const slug = typeof p.name === 'string' ? p.name : '';
|
||||
return brl.getResidentSkillDetail(ctx, p.source_id, slug);
|
||||
}
|
||||
const override = await sc.readMcpSkillsDir(ctx);
|
||||
const { dir } = sc.resolveSkillsDir(ctx, override);
|
||||
const name = typeof p.name === 'string' ? p.name : '';
|
||||
@@ -2238,6 +2285,76 @@ const get_skill: Operation = {
|
||||
cliHints: { name: 'skill', positional: ['name'] },
|
||||
};
|
||||
|
||||
const list_brain_skillpack: Operation = {
|
||||
name: 'list_brain_skillpack',
|
||||
description:
|
||||
'List brain-resident skillpacks this brain ships (per-source). Returns each pack\'s skills, ' +
|
||||
'one-line descriptions, the schema pack it targets + whether that matches this brain, and a ' +
|
||||
'git scaffold spec. Read-only; gated by mcp.publish_skills. After orienting, call this and ' +
|
||||
'ask the user whether to install any pack the brain offers (gbrain skillpack scaffold <spec>).',
|
||||
params: {},
|
||||
handler: async (ctx) => {
|
||||
const sc = await import('./skill-catalog.ts');
|
||||
const publish = await sc.readMcpPublishSkills(ctx);
|
||||
sc.assertPublishEnabled(ctx, publish);
|
||||
const brl = await import('./skillpack/brain-resident-locate.ts');
|
||||
return brl.loadResidentPacksForServer(ctx);
|
||||
},
|
||||
scope: 'read',
|
||||
cliHints: { name: 'brain-skillpack', positional: [] },
|
||||
};
|
||||
|
||||
const advisor: Operation = {
|
||||
name: 'advisor',
|
||||
description:
|
||||
'Ranked, read-only "what to do next" for this brain: version drift, pending migrations, ' +
|
||||
'schema-pack issues, stalled jobs, usage-shape gaps, and setup smells. Each finding has a ' +
|
||||
'severity, why-it-matters, and the exact fix command. Never mutates. Tell the user; ask ' +
|
||||
'before running any fix. Gated by mcp.publish_advisor (separate from mcp.publish_skills ' +
|
||||
'because diagnostics are not prose skills).',
|
||||
params: {},
|
||||
handler: async (ctx) => {
|
||||
// Publish gate: a remote caller needs mcp.publish_advisor=true. Local
|
||||
// (ctx.remote === false) callers bypass — the trust boundary is the OS.
|
||||
if (ctx.remote !== false) {
|
||||
let enabled = false;
|
||||
try {
|
||||
const dbVal = await ctx.engine.getConfig('mcp.publish_advisor');
|
||||
enabled = dbVal != null ? dbVal === 'true' : ctx.config?.mcp?.publish_advisor === true;
|
||||
} catch {
|
||||
enabled = ctx.config?.mcp?.publish_advisor === true;
|
||||
}
|
||||
if (!enabled) {
|
||||
throw new OperationError(
|
||||
'permission_denied',
|
||||
'The advisor is not published over MCP by the brain owner.',
|
||||
'The owner can enable it with `gbrain config set mcp.publish_advisor true`.',
|
||||
);
|
||||
}
|
||||
}
|
||||
const { runAdvisor } = await import('./advisor/run.ts');
|
||||
const { VERSION } = await import('../version.ts');
|
||||
// Over MCP there is no agent workspace on the server side: remote=true makes
|
||||
// runAdvisor drop workspace-dependent collectors (A1). The op never writes
|
||||
// history or nag state — it is strictly read-only.
|
||||
const report = await runAdvisor({
|
||||
engine: ctx.engine,
|
||||
config: ctx.config,
|
||||
version: VERSION,
|
||||
workspace: null,
|
||||
skillsDir: null,
|
||||
now: new Date(),
|
||||
remote: ctx.remote !== false,
|
||||
});
|
||||
return report;
|
||||
},
|
||||
scope: 'read',
|
||||
// NOT localOnly — exposed over MCP (E1) behind mcp.publish_advisor.
|
||||
// No cliHints: the CLI surface is the richer `gbrain advisor` command
|
||||
// (commands/advisor.ts) which adds --json exit codes + --apply.
|
||||
cliHints: { name: 'advisor', hidden: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* v0.41.19.0 — `gbrain status` thin-client surface.
|
||||
*
|
||||
@@ -4919,7 +5036,7 @@ export const operations: Operation[] = [
|
||||
// v0.31.1 (Issue #734): thin-client banner identity packet (read-scope, banner-only)
|
||||
get_brain_identity,
|
||||
// PR1: skill catalog over MCP — discover + fetch host-repo skills (read-scope)
|
||||
list_skills, get_skill,
|
||||
list_skills, get_skill, list_brain_skillpack, advisor,
|
||||
// v0.41.19.0: thin-client `gbrain status` payload (admin-scope, sync + cycle only)
|
||||
get_status_snapshot,
|
||||
// Sync
|
||||
|
||||
+65
-18
@@ -2583,12 +2583,29 @@ export class PGLiteEngine implements BrainEngine {
|
||||
}
|
||||
}
|
||||
|
||||
async getLinks(slug: string, opts?: { sourceId?: string }): Promise<Link[]> {
|
||||
// v0.31.8 (D16): two-branch query. Without opts.sourceId, no source filter
|
||||
// (preserves pre-v0.31.8 cross-source semantics for back-link validators
|
||||
// and read-side op handlers that haven't threaded sourceId yet). With
|
||||
// opts.sourceId, scope to that source — used by reconcileLinks and any
|
||||
// ctx.sourceId-aware read op (D20).
|
||||
async getLinks(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<Link[]> {
|
||||
// #2200: federated grant scopes ALL THREE page endpoints — from, to, AND the
|
||||
// origin (the authoring page, surfaced as origin_slug). The origin LEFT JOIN
|
||||
// carries the same ANY($) filter so an out-of-grant origin's slug nulls out.
|
||||
// Remote MCP clients always land here.
|
||||
if (opts?.sourceIds && opts.sourceIds.length > 0) {
|
||||
const { rows } = await this.db.query(
|
||||
`SELECT f.slug as from_slug, t.slug as to_slug,
|
||||
l.link_type, l.context, l.link_source,
|
||||
o.slug as origin_slug, l.origin_field
|
||||
FROM links l
|
||||
JOIN pages f ON f.id = l.from_page_id
|
||||
JOIN pages t ON t.id = l.to_page_id
|
||||
LEFT JOIN pages o ON o.id = l.origin_page_id AND o.source_id = ANY($2::text[])
|
||||
WHERE f.slug = $1 AND f.source_id = ANY($2::text[]) AND t.source_id = ANY($2::text[])`,
|
||||
[slug, opts.sourceIds]
|
||||
);
|
||||
return rows as unknown as Link[];
|
||||
}
|
||||
// v0.31.8 (D16) + #2200: the federated arm above is the first branch; the two
|
||||
// below preserve pre-v0.31.8 semantics. Without opts.sourceId, no source filter
|
||||
// (cross-source view for back-link validators and reconcileLinks). With
|
||||
// opts.sourceId, scope to that source (D20).
|
||||
if (opts?.sourceId) {
|
||||
const { rows } = await this.db.query(
|
||||
`SELECT f.slug as from_slug, t.slug as to_slug,
|
||||
@@ -2617,8 +2634,25 @@ export class PGLiteEngine implements BrainEngine {
|
||||
return rows as unknown as Link[];
|
||||
}
|
||||
|
||||
async getBacklinks(slug: string, opts?: { sourceId?: string }): Promise<Link[]> {
|
||||
// v0.31.8 (D16): two-branch query. See getLinks() comment.
|
||||
async getBacklinks(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<Link[]> {
|
||||
// #2200: federated grant scopes all three endpoints (mirrors getLinks) — the
|
||||
// referrer (from), the queried page (to), AND the origin — so neither a
|
||||
// foreign referrer nor a foreign origin slug is disclosed to the caller.
|
||||
if (opts?.sourceIds && opts.sourceIds.length > 0) {
|
||||
const { rows } = await this.db.query(
|
||||
`SELECT f.slug as from_slug, t.slug as to_slug,
|
||||
l.link_type, l.context, l.link_source,
|
||||
o.slug as origin_slug, l.origin_field
|
||||
FROM links l
|
||||
JOIN pages f ON f.id = l.from_page_id
|
||||
JOIN pages t ON t.id = l.to_page_id
|
||||
LEFT JOIN pages o ON o.id = l.origin_page_id AND o.source_id = ANY($2::text[])
|
||||
WHERE t.slug = $1 AND t.source_id = ANY($2::text[]) AND f.source_id = ANY($2::text[])`,
|
||||
[slug, opts.sourceIds]
|
||||
);
|
||||
return rows as unknown as Link[];
|
||||
}
|
||||
// v0.31.8 (D16) + #2200: federated arm above is first; two below mirror getLinks.
|
||||
if (opts?.sourceId) {
|
||||
const { rows } = await this.db.query(
|
||||
`SELECT f.slug as from_slug, t.slug as to_slug,
|
||||
@@ -3231,14 +3265,20 @@ export class PGLiteEngine implements BrainEngine {
|
||||
);
|
||||
}
|
||||
|
||||
async getTags(slug: string, opts?: { sourceId?: string }): Promise<string[]> {
|
||||
const sourceId = opts?.sourceId ?? 'default';
|
||||
// Source-qualify the page-id subquery; slugs are only unique per source.
|
||||
async getTags(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<string[]> {
|
||||
// #2200: federated grant (sourceIds[]) wins over scalar. `page_id IN (..)`
|
||||
// (not `= (..)`) so a slug present in >1 allowed source doesn't blow up;
|
||||
// DISTINCT unions tags across the matched pages. Scalar/unscoped keeps the
|
||||
// legacy `?? 'default'` default. Source-qualify; slugs are unique per source.
|
||||
const scope =
|
||||
opts?.sourceIds && opts.sourceIds.length > 0
|
||||
? { sql: 'source_id = ANY($2::text[])', param: opts.sourceIds }
|
||||
: { sql: 'source_id = $2', param: opts?.sourceId ?? 'default' };
|
||||
const { rows } = await this.db.query(
|
||||
`SELECT tag FROM tags
|
||||
WHERE page_id = (SELECT id FROM pages WHERE slug = $1 AND source_id = $2)
|
||||
`SELECT DISTINCT tag FROM tags
|
||||
WHERE page_id IN (SELECT id FROM pages WHERE slug = $1 AND ${scope.sql})
|
||||
ORDER BY tag`,
|
||||
[slug, sourceId]
|
||||
[slug, scope.param]
|
||||
);
|
||||
return (rows as { tag: string }[]).map(r => r.tag);
|
||||
}
|
||||
@@ -3300,9 +3340,11 @@ export class PGLiteEngine implements BrainEngine {
|
||||
}
|
||||
|
||||
async getTimeline(slug: string, opts?: TimelineOpts): Promise<TimelineEntry[]> {
|
||||
// v0.31.8 (D16): build WHERE clause dynamically so opts.sourceId composes
|
||||
// cleanly with the existing after/before filters. Without sourceId, no
|
||||
// source filter applies (preserves pre-v0.31.8 cross-source semantics).
|
||||
// v0.31.8 (D16) + #2200: build WHERE clause dynamically so the source scope
|
||||
// composes cleanly with the after/before filters. Precedence: federated
|
||||
// sourceIds[] > scalar sourceId > unscoped (cross-source, pre-v0.31.8).
|
||||
// (Postgres builds the equivalent via sql`` fragment composition — different
|
||||
// idiom, same result; the engines stay lockstep on behavior, not on builder.)
|
||||
const limit = opts?.limit || 100;
|
||||
const where: string[] = ['p.slug = $1'];
|
||||
const params: unknown[] = [slug];
|
||||
@@ -3314,7 +3356,12 @@ export class PGLiteEngine implements BrainEngine {
|
||||
params.push(opts.before);
|
||||
where.push(`te.date <= $${params.length}::date`);
|
||||
}
|
||||
if (opts?.sourceId) {
|
||||
// #2200: federated grant (sourceIds[]) wins over scalar sourceId. The join
|
||||
// unions timeline entries across every same-slug page in the grant.
|
||||
if (opts?.sourceIds && opts.sourceIds.length > 0) {
|
||||
params.push(opts.sourceIds);
|
||||
where.push(`p.source_id = ANY($${params.length}::text[])`);
|
||||
} else if (opts?.sourceId) {
|
||||
params.push(opts.sourceId);
|
||||
where.push(`p.source_id = $${params.length}`);
|
||||
}
|
||||
|
||||
+74
-55
@@ -2648,11 +2648,31 @@ export class PostgresEngine implements BrainEngine {
|
||||
}
|
||||
}
|
||||
|
||||
async getLinks(slug: string, opts?: { sourceId?: string }): Promise<Link[]> {
|
||||
async getLinks(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<Link[]> {
|
||||
const sql = this.sql;
|
||||
// v0.31.8 (D16): two-branch query. Without opts.sourceId, no source filter
|
||||
// (preserves pre-v0.31.8 cross-source semantics). With opts.sourceId,
|
||||
// scope the from-page lookup. See pglite-engine.ts:getLinks for context.
|
||||
// #2200: federated grant scopes ALL THREE page endpoints — from, to, AND the
|
||||
// origin (the page that authored the edge, surfaced as origin_slug). Scoping
|
||||
// only from+to would still leak an out-of-grant origin's slug; the origin
|
||||
// LEFT JOIN carries the same ANY($) filter so origin_slug nulls out of grant.
|
||||
// Remote MCP clients always land here.
|
||||
if (opts?.sourceIds && opts.sourceIds.length > 0) {
|
||||
const ids = opts.sourceIds;
|
||||
const rows = await sql`
|
||||
SELECT f.slug as from_slug, t.slug as to_slug,
|
||||
l.link_type, l.context, l.link_source,
|
||||
o.slug as origin_slug, l.origin_field
|
||||
FROM links l
|
||||
JOIN pages f ON f.id = l.from_page_id
|
||||
JOIN pages t ON t.id = l.to_page_id
|
||||
LEFT JOIN pages o ON o.id = l.origin_page_id AND o.source_id = ANY(${ids}::text[])
|
||||
WHERE f.slug = ${slug} AND f.source_id = ANY(${ids}::text[]) AND t.source_id = ANY(${ids}::text[])
|
||||
`;
|
||||
return rows as unknown as Link[];
|
||||
}
|
||||
// v0.31.8 (D16) + #2200: the federated arm above is the first branch; the
|
||||
// two below preserve pre-v0.31.8 semantics. Without opts.sourceId, no source
|
||||
// filter (cross-source view for internal callers). With opts.sourceId, scope
|
||||
// the from-page lookup. See pglite-engine.ts:getLinks for context.
|
||||
if (opts?.sourceId) {
|
||||
const rows = await sql`
|
||||
SELECT f.slug as from_slug, t.slug as to_slug,
|
||||
@@ -2679,9 +2699,26 @@ export class PostgresEngine implements BrainEngine {
|
||||
return rows as unknown as Link[];
|
||||
}
|
||||
|
||||
async getBacklinks(slug: string, opts?: { sourceId?: string }): Promise<Link[]> {
|
||||
async getBacklinks(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<Link[]> {
|
||||
const sql = this.sql;
|
||||
// v0.31.8 (D16): two-branch query, mirrors getLinks above.
|
||||
// #2200: federated grant scopes all three endpoints (mirrors getLinks) — the
|
||||
// referrer (from), the queried page (to), AND the origin — so neither a
|
||||
// foreign referrer nor a foreign origin slug is disclosed to the caller.
|
||||
if (opts?.sourceIds && opts.sourceIds.length > 0) {
|
||||
const ids = opts.sourceIds;
|
||||
const rows = await sql`
|
||||
SELECT f.slug as from_slug, t.slug as to_slug,
|
||||
l.link_type, l.context, l.link_source,
|
||||
o.slug as origin_slug, l.origin_field
|
||||
FROM links l
|
||||
JOIN pages f ON f.id = l.from_page_id
|
||||
JOIN pages t ON t.id = l.to_page_id
|
||||
LEFT JOIN pages o ON o.id = l.origin_page_id AND o.source_id = ANY(${ids}::text[])
|
||||
WHERE t.slug = ${slug} AND t.source_id = ANY(${ids}::text[]) AND f.source_id = ANY(${ids}::text[])
|
||||
`;
|
||||
return rows as unknown as Link[];
|
||||
}
|
||||
// v0.31.8 (D16) + #2200: federated arm above is first; two below mirror getLinks.
|
||||
if (opts?.sourceId) {
|
||||
const rows = await sql`
|
||||
SELECT f.slug as from_slug, t.slug as to_slug,
|
||||
@@ -3302,12 +3339,20 @@ export class PostgresEngine implements BrainEngine {
|
||||
`;
|
||||
}
|
||||
|
||||
async getTags(slug: string, opts?: { sourceId?: string }): Promise<string[]> {
|
||||
async getTags(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<string[]> {
|
||||
const sql = this.sql;
|
||||
const sourceId = opts?.sourceId ?? 'default';
|
||||
// #2200: federated grant (sourceIds[]) wins over scalar sourceId. Use
|
||||
// `page_id IN (subquery)` — NOT `= (subquery)` — because a federated read of
|
||||
// a slug present in >1 allowed source resolves multiple page-ids, which would
|
||||
// throw under the scalar-subquery form. DISTINCT unions tags across the
|
||||
// matched pages. Scalar/unscoped path keeps the legacy `?? 'default'` default.
|
||||
const scope =
|
||||
opts?.sourceIds && opts.sourceIds.length > 0
|
||||
? sql`source_id = ANY(${opts.sourceIds}::text[])`
|
||||
: sql`source_id = ${opts?.sourceId ?? 'default'}`;
|
||||
const rows = await sql`
|
||||
SELECT tag FROM tags
|
||||
WHERE page_id = (SELECT id FROM pages WHERE slug = ${slug} AND source_id = ${sourceId})
|
||||
SELECT DISTINCT tag FROM tags
|
||||
WHERE page_id IN (SELECT id FROM pages WHERE slug = ${slug} AND ${scope})
|
||||
ORDER BY tag
|
||||
`;
|
||||
return rows.map((r) => r.tag as string);
|
||||
@@ -3373,51 +3418,25 @@ export class PostgresEngine implements BrainEngine {
|
||||
async getTimeline(slug: string, opts?: TimelineOpts): Promise<TimelineEntry[]> {
|
||||
const sql = this.sql;
|
||||
const limit = opts?.limit || 100;
|
||||
// v0.31.8 (D16): branch on every combination of (after, before, sourceId).
|
||||
// 8 cases is too many — use an explicit branch on sourceId, then nested
|
||||
// branches on after/before. Mirrors pglite-engine but stays in postgres.js
|
||||
// template-literal idiom (which doesn't compose fragment WHERE chains
|
||||
// cleanly).
|
||||
const sourceId = opts?.sourceId;
|
||||
let rows;
|
||||
if (sourceId) {
|
||||
if (opts?.after && opts?.before) {
|
||||
rows = await sql`SELECT te.* FROM timeline_entries te JOIN pages p ON p.id = te.page_id
|
||||
WHERE p.slug = ${slug} AND p.source_id = ${sourceId}
|
||||
AND te.date >= ${opts.after}::date AND te.date <= ${opts.before}::date
|
||||
ORDER BY te.date DESC LIMIT ${limit}`;
|
||||
} else if (opts?.after) {
|
||||
rows = await sql`SELECT te.* FROM timeline_entries te JOIN pages p ON p.id = te.page_id
|
||||
WHERE p.slug = ${slug} AND p.source_id = ${sourceId}
|
||||
AND te.date >= ${opts.after}::date
|
||||
ORDER BY te.date DESC LIMIT ${limit}`;
|
||||
} else if (opts?.before) {
|
||||
rows = await sql`SELECT te.* FROM timeline_entries te JOIN pages p ON p.id = te.page_id
|
||||
WHERE p.slug = ${slug} AND p.source_id = ${sourceId}
|
||||
AND te.date <= ${opts.before}::date
|
||||
ORDER BY te.date DESC LIMIT ${limit}`;
|
||||
} else {
|
||||
rows = await sql`SELECT te.* FROM timeline_entries te JOIN pages p ON p.id = te.page_id
|
||||
WHERE p.slug = ${slug} AND p.source_id = ${sourceId}
|
||||
ORDER BY te.date DESC LIMIT ${limit}`;
|
||||
}
|
||||
} else if (opts?.after && opts?.before) {
|
||||
rows = await sql`SELECT te.* FROM timeline_entries te JOIN pages p ON p.id = te.page_id
|
||||
WHERE p.slug = ${slug} AND te.date >= ${opts.after}::date AND te.date <= ${opts.before}::date
|
||||
ORDER BY te.date DESC LIMIT ${limit}`;
|
||||
} else if (opts?.after) {
|
||||
rows = await sql`SELECT te.* FROM timeline_entries te JOIN pages p ON p.id = te.page_id
|
||||
WHERE p.slug = ${slug} AND te.date >= ${opts.after}::date
|
||||
ORDER BY te.date DESC LIMIT ${limit}`;
|
||||
} else if (opts?.before) {
|
||||
rows = await sql`SELECT te.* FROM timeline_entries te JOIN pages p ON p.id = te.page_id
|
||||
WHERE p.slug = ${slug} AND te.date <= ${opts.before}::date
|
||||
ORDER BY te.date DESC LIMIT ${limit}`;
|
||||
} else {
|
||||
rows = await sql`SELECT te.* FROM timeline_entries te JOIN pages p ON p.id = te.page_id
|
||||
WHERE p.slug = ${slug}
|
||||
ORDER BY te.date DESC LIMIT ${limit}`;
|
||||
}
|
||||
// #2200 (D5A): collapse the former 8-branch (sourceId × after × before)
|
||||
// cartesian tree into ONE query built from composed WHERE fragments — the
|
||||
// same postgres.js `sql`` idiom getPage/getBacklinks/listLinkSources use.
|
||||
// Scope precedence: federated sourceIds[] > scalar sourceId > unscoped. The
|
||||
// federated arm unions entries across every same-slug page in the grant.
|
||||
// (PGLite builds the equivalent via its dynamic where[]/params[] array —
|
||||
// different idiom by design, same behavior; lockstep is on result, not builder.)
|
||||
const sourceCond =
|
||||
opts?.sourceIds && opts.sourceIds.length > 0
|
||||
? sql`AND p.source_id = ANY(${opts.sourceIds}::text[])`
|
||||
: opts?.sourceId
|
||||
? sql`AND p.source_id = ${opts.sourceId}`
|
||||
: sql``;
|
||||
const afterCond = opts?.after ? sql`AND te.date >= ${opts.after}::date` : sql``;
|
||||
const beforeCond = opts?.before ? sql`AND te.date <= ${opts.before}::date` : sql``;
|
||||
const rows = await sql`
|
||||
SELECT te.* FROM timeline_entries te JOIN pages p ON p.id = te.page_id
|
||||
WHERE p.slug = ${slug} ${sourceCond} ${afterCond} ${beforeCond}
|
||||
ORDER BY te.date DESC LIMIT ${limit}`;
|
||||
return rows as unknown as TimelineEntry[];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* skillpack/brain-pack-advisory.ts — agent-readable "this brain ships skills"
|
||||
* advisory, printed to stderr when a federated source carrying a brain-resident
|
||||
* pack is added (Topology A discovery).
|
||||
*
|
||||
* Same print-never-execute contract as post-install-advisory.ts: we describe the
|
||||
* pack and tell the harness to ASK THE USER before scaffolding. We never install
|
||||
* anything. The recommended set is read from the brain's own skillpack.json, not
|
||||
* a hardcoded constant.
|
||||
*
|
||||
* `level` drives the nag policy (nag-state.ts): 'full' on first sight / version
|
||||
* bump, 'short' on subsequent reminders.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { parseMarkdown } from '../markdown.ts';
|
||||
import { coerceFrontmatterString } from '../markdown.ts';
|
||||
import type { SkillpackManifest } from './manifest-v1.ts';
|
||||
|
||||
export interface BrainPackAdvisoryInput {
|
||||
/** The validated brain-resident manifest. */
|
||||
manifest: SkillpackManifest;
|
||||
/** Absolute pack root (where skillpack.json + skills/ live). */
|
||||
packRoot: string;
|
||||
/** The exact source spec to put in the scaffold command (local path or git spec). */
|
||||
scaffoldSource: string;
|
||||
/** True when the pack is already scaffolded at this version (skillpack-state). */
|
||||
installed: boolean;
|
||||
/** The brain's active schema pack, for the mismatch note. */
|
||||
activeSchemaPack?: string | null;
|
||||
/** Nag level — 'full' (first/bump) or 'short' (reminder). Default 'full'. */
|
||||
level?: 'full' | 'short';
|
||||
}
|
||||
|
||||
/** Read a skill's one-line description from its SKILL.md frontmatter. */
|
||||
function readSkillDescription(packRoot: string, skillDir: string): string {
|
||||
const skillMd = join(packRoot, skillDir, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) return '(no description)';
|
||||
try {
|
||||
const fm = parseMarkdown(readFileSync(skillMd, 'utf-8'), skillMd).frontmatter;
|
||||
const desc = coerceFrontmatterString(fm.description);
|
||||
return desc && desc.length > 0 ? desc : '(no description)';
|
||||
} catch {
|
||||
return '(no description)';
|
||||
}
|
||||
}
|
||||
|
||||
function schemaMismatch(input: BrainPackAdvisoryInput): boolean {
|
||||
const want = input.manifest.schema_pack;
|
||||
if (!want) return false;
|
||||
const active = input.activeSchemaPack ?? null;
|
||||
return active != null && active !== want;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the brain-pack advisory text. Returns null when there is nothing to
|
||||
* surface (pack already installed AND no schema-pack mismatch).
|
||||
*/
|
||||
export function buildBrainPackAdvisory(input: BrainPackAdvisoryInput): string | null {
|
||||
const mismatch = schemaMismatch(input);
|
||||
if (input.installed && !mismatch) return null;
|
||||
|
||||
const bar = '='.repeat(72);
|
||||
const lines: string[] = [];
|
||||
const m = input.manifest;
|
||||
|
||||
if (input.level === 'short') {
|
||||
// One-line escalating reminder.
|
||||
lines.push('');
|
||||
lines.push(
|
||||
`[gbrain] This brain ships ${m.skills.length} skill${m.skills.length === 1 ? '' : 's'} ` +
|
||||
`you haven't installed (${m.name} ${m.version}). Install: gbrain skillpack scaffold ${input.scaffoldSource}`,
|
||||
);
|
||||
if (mismatch) {
|
||||
lines.push(
|
||||
`[gbrain] NOTE: pack targets schema_pack "${m.schema_pack}" but this brain is on ` +
|
||||
`"${input.activeSchemaPack}". Skills may assume a different schema.`,
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(bar);
|
||||
lines.push(`gbrain — THIS BRAIN SHIPS A SKILLPACK (${m.name} ${m.version})`);
|
||||
lines.push(bar);
|
||||
lines.push('');
|
||||
lines.push(m.description);
|
||||
lines.push('');
|
||||
lines.push('SKILLS IN THIS PACK:');
|
||||
lines.push('');
|
||||
for (const skillDir of m.skills) {
|
||||
const slug = skillDir.replace(/^skills\//, '');
|
||||
lines.push(` - ${slug}`);
|
||||
for (const wl of wrap(readSkillDescription(input.packRoot, skillDir), 68, ' ')) lines.push(wl);
|
||||
lines.push('');
|
||||
}
|
||||
if (mismatch) {
|
||||
lines.push(
|
||||
`SCHEMA NOTE: this pack targets schema_pack "${m.schema_pack}", but this brain is on ` +
|
||||
`"${input.activeSchemaPack}". The skills may assume a different schema; review before installing ` +
|
||||
`(see \`gbrain list_schema_packs\`).`,
|
||||
);
|
||||
lines.push('');
|
||||
}
|
||||
lines.push('ACTION FOR THE AGENT:');
|
||||
lines.push('');
|
||||
lines.push(' 1. Show this list to the user. Briefly explain each skill.');
|
||||
lines.push(' 2. Ask the user explicitly: "Want me to install these brain skills now?"');
|
||||
lines.push(' 3. If YES, run this command:');
|
||||
lines.push('');
|
||||
lines.push(` gbrain skillpack scaffold ${input.scaffoldSource}`);
|
||||
lines.push('');
|
||||
lines.push(' Do NOT scaffold without asking. The user owns this decision.');
|
||||
lines.push(bar);
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** Print to stderr; no-op when buildBrainPackAdvisory returns null. */
|
||||
export function printBrainPackAdvisory(input: BrainPackAdvisoryInput): boolean {
|
||||
const text = buildBrainPackAdvisory(input);
|
||||
if (!text) return false;
|
||||
process.stderr.write(text);
|
||||
return true;
|
||||
}
|
||||
|
||||
function wrap(text: string, width: number, indent: string): string[] {
|
||||
const words = text.split(/\s+/);
|
||||
const lines: string[] = [];
|
||||
let current = indent;
|
||||
for (const word of words) {
|
||||
if ((current + (current === indent ? '' : ' ') + word).length > width + indent.length) {
|
||||
lines.push(current.trimEnd());
|
||||
current = indent + word;
|
||||
} else {
|
||||
current = current === indent ? indent + word : current + ' ' + word;
|
||||
}
|
||||
}
|
||||
if (current.trim().length > 0) lines.push(current.trimEnd());
|
||||
return lines;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* skillpack/brain-pack-lint.ts — E6 version-skew lint for brain-resident packs.
|
||||
*
|
||||
* A brain ships skills that call gbrain operations. If the connecting gbrain is
|
||||
* older/newer than the pack assumed, a skill's declared `tools:` may reference
|
||||
* ops that don't exist on this binary — silent breakage when the harness tries
|
||||
* to call them. This lint reads every pack skill's SKILL.md frontmatter `tools:`
|
||||
* and flags any tool not present in the serving op set, so packs fail loud on
|
||||
* drift instead of mysteriously half-working.
|
||||
*
|
||||
* Pure-ish: filesystem reads only, no mutation. Used by
|
||||
* `gbrain skillpack init-brain-pack` (lint the freshly-generated pack) and
|
||||
* `gbrain skillpack check` (lint an existing brain repo's pack).
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { parseMarkdown } from '../markdown.ts';
|
||||
import { loadSkillpackManifest, type SkillpackManifest } from './manifest-v1.ts';
|
||||
|
||||
export interface ToolSkewFinding {
|
||||
/** Skill path relative to the pack root (e.g. "skills/diligence"). */
|
||||
skill: string;
|
||||
/** The declared tool name that is not in the serving op set. */
|
||||
tool: string;
|
||||
}
|
||||
|
||||
export interface BrainPackLintResult {
|
||||
packRoot: string;
|
||||
manifest: SkillpackManifest;
|
||||
/** Every `tools:` value across the pack that the serving binary cannot satisfy. */
|
||||
unknownTools: ToolSkewFinding[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lint a brain-resident pack's declared `tools:` against the set of op names the
|
||||
* serving gbrain actually exposes. `knownOps` is the authoritative op-name set
|
||||
* (callers pass `new Set(operations.map(o => o.name))`).
|
||||
*
|
||||
* A skill with no `tools:` frontmatter contributes nothing (the common case for
|
||||
* a freshly-scaffolded pack). Skills whose SKILL.md is missing or unparseable
|
||||
* are skipped silently — discovery is fail-open, this is a quality lint, not a
|
||||
* gate that should explode on a half-written pack.
|
||||
*/
|
||||
export function lintBrainPackTools(packRoot: string, knownOps: Set<string>): BrainPackLintResult {
|
||||
const manifest = loadSkillpackManifest(packRoot);
|
||||
const unknownTools: ToolSkewFinding[] = [];
|
||||
|
||||
for (const skillDir of manifest.skills) {
|
||||
const skillMd = join(packRoot, skillDir, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) continue;
|
||||
let tools: unknown;
|
||||
try {
|
||||
tools = parseMarkdown(readFileSync(skillMd, 'utf-8'), skillMd).frontmatter.tools;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(tools)) continue;
|
||||
for (const tool of tools) {
|
||||
if (typeof tool !== 'string' || tool.length === 0) continue;
|
||||
if (!knownOps.has(tool)) {
|
||||
unknownTools.push({ skill: skillDir, tool });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { packRoot, manifest, unknownTools };
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* skillpack/brain-resident-locate.ts — server-side discovery of brain-resident
|
||||
* skillpacks for the `list_brain_skillpack` MCP tool (Topology B).
|
||||
*
|
||||
* Distinct from skill-catalog.ts (host-global prose skills): brain-resident
|
||||
* packs are per-SOURCE — a brain can mount several sources, each carrying its
|
||||
* own pack. So discovery is scoped by `sourceScopeOpts(ctx)` (in-DB tenancy),
|
||||
* not the single host skills dir.
|
||||
*
|
||||
* Trust posture mirrors skill-catalog: contents are surfaced over HTTP only
|
||||
* behind the publish gate (enforced by the op). We NEVER emit a server-side
|
||||
* filesystem path to a thin client (#6) — the install hint is the source's git
|
||||
* remote spec, which the client can `resolveSource` on its own machine.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, realpathSync, statSync } from 'fs';
|
||||
import { join, relative, resolve } from 'path';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import { parseMarkdown, coerceFrontmatterString } from '../markdown.ts';
|
||||
import { loadAllSources } from '../sources-load.ts';
|
||||
import { loadSkillpackManifest } from './manifest-v1.ts';
|
||||
import { loadState, findEntry } from './state.ts';
|
||||
import { MAX_SKILL_MD_BYTES } from '../skill-catalog.ts';
|
||||
import type { BrainEngine } from '../engine.ts';
|
||||
import { OperationError, type OperationContext } from '../operations.ts';
|
||||
import { sourceScopeOpts } from '../operations.ts';
|
||||
|
||||
export interface ResidentPackSkill {
|
||||
slug: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ResidentPackEntry {
|
||||
/** The source id this pack was discovered under (provenance). */
|
||||
source_id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
/** Schema pack the pack targets (null when it declares none). */
|
||||
schema_pack: string | null;
|
||||
/** The brain's active schema pack for THIS source (server-computed, #7). */
|
||||
active_schema_pack: string | null;
|
||||
/** true/false when the pack declares a schema_pack; null when it doesn't. */
|
||||
schema_pack_match: boolean | null;
|
||||
skills: ResidentPackSkill[];
|
||||
/**
|
||||
* Git-source spec a thin client can `gbrain skillpack scaffold <spec>` on its
|
||||
* OWN machine. Null when the source has no git remote (local-only source —
|
||||
* the thin client cannot install it remotely; binary install is PR2 work).
|
||||
*/
|
||||
scaffold_spec: string | null;
|
||||
/** Whether this pack is already scaffolded at this exact version. */
|
||||
installed: boolean;
|
||||
}
|
||||
|
||||
export interface ResidentPackResult {
|
||||
packs: ResidentPackEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a stable brain-id for a source from its canonical git remote (the repo
|
||||
* carrying the pack) when available; else a deterministic hash of the canonical
|
||||
* local path. NOT the brain DB identity (a different axis). Shared by the
|
||||
* Topology-A nag hook and any server-side discovery that needs the same key.
|
||||
*/
|
||||
export function deriveBrainId(remoteUrl: string | null | undefined, localPath: string | null | undefined): string {
|
||||
if (remoteUrl && remoteUrl.length > 0) return `git:${remoteUrl}`;
|
||||
const p = localPath ?? '';
|
||||
return `path:${createHash('sha256').update(p).digest('hex').slice(0, 16)}`;
|
||||
}
|
||||
|
||||
function readSkillDescription(packRoot: string, skillDir: string): string {
|
||||
const skillMd = join(packRoot, skillDir, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) return '(no description)';
|
||||
try {
|
||||
const desc = coerceFrontmatterString(
|
||||
parseMarkdown(readFileSync(skillMd, 'utf-8'), skillMd).frontmatter.description,
|
||||
);
|
||||
return desc && desc.length > 0 ? desc : '(no description)';
|
||||
} catch {
|
||||
return '(no description)';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Source-aware active schema pack for the mismatch check (#7): per-source DB
|
||||
* config wins, then brain-wide, then the 'gbrain-base' default. Read-only.
|
||||
*/
|
||||
async function activeSchemaPackForSource(engine: BrainEngine, sourceId: string): Promise<string> {
|
||||
try {
|
||||
const perSource = await engine.getConfig(`schema_pack.source.${sourceId}`);
|
||||
if (perSource && perSource.length > 0) return perSource;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
const brainWide = await engine.getConfig('schema_pack');
|
||||
if (brainWide && brainWide.length > 0) return brainWide;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return 'gbrain-base';
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate brain-resident packs across the sources in scope for this caller.
|
||||
* Fail-open per source: a malformed/absent pack on one source never aborts the
|
||||
* whole listing. Returns one entry per in-scope source that ships a
|
||||
* `brain_resident: true` pack.
|
||||
*/
|
||||
export async function loadResidentPacksForServer(ctx: OperationContext): Promise<ResidentPackResult> {
|
||||
const scope = sourceScopeOpts(ctx);
|
||||
const allowed: Set<string> | null = scope.sourceIds
|
||||
? new Set(scope.sourceIds)
|
||||
: scope.sourceId
|
||||
? new Set([scope.sourceId])
|
||||
: null; // null = owner, all sources
|
||||
|
||||
let sources;
|
||||
try {
|
||||
sources = await loadAllSources(ctx.engine);
|
||||
} catch {
|
||||
return { packs: [] };
|
||||
}
|
||||
|
||||
const state = loadState();
|
||||
const packs: ResidentPackEntry[] = [];
|
||||
|
||||
for (const src of sources) {
|
||||
if (allowed && !allowed.has(src.id)) continue;
|
||||
const localPath = src.local_path;
|
||||
if (!localPath || !existsSync(join(localPath, 'skillpack.json'))) continue;
|
||||
try {
|
||||
const manifest = loadSkillpackManifest(localPath);
|
||||
if (manifest.brain_resident !== true) continue;
|
||||
|
||||
const active = await activeSchemaPackForSource(ctx.engine, src.id);
|
||||
const wantSchema = manifest.schema_pack ?? null;
|
||||
const stateEntry = findEntry(state, manifest.name);
|
||||
const remoteUrl = (src.config as Record<string, unknown>)?.remote_url as string | undefined;
|
||||
|
||||
packs.push({
|
||||
source_id: src.id,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
schema_pack: wantSchema,
|
||||
active_schema_pack: active,
|
||||
schema_pack_match: wantSchema === null ? null : wantSchema === active,
|
||||
skills: manifest.skills.map((s) => ({
|
||||
slug: s.replace(/^skills\//, ''),
|
||||
description: readSkillDescription(localPath, s),
|
||||
})),
|
||||
scaffold_spec: remoteUrl && remoteUrl.length > 0 ? remoteUrl : null,
|
||||
installed: !!stateEntry && stateEntry.version === manifest.version,
|
||||
});
|
||||
} catch {
|
||||
continue; // malformed pack on this source → skip, keep going
|
||||
}
|
||||
}
|
||||
|
||||
return { packs };
|
||||
}
|
||||
|
||||
export interface ResidentSkillDetail {
|
||||
source_id: string;
|
||||
pack_name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
/** Full SKILL.md body (size-capped). */
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one brain-resident skill's SKILL.md body from a specific source, for
|
||||
* `get_skill` when a `source_id` is supplied (disambiguates a slug that exists
|
||||
* on more than one source). Same confinement as skill-catalog: realpath +
|
||||
* relative-contained to the pack root, regular file named SKILL.md, size-capped.
|
||||
*
|
||||
* Throws OperationError (not_found / storage_error) on any miss so the MCP
|
||||
* dispatcher surfaces a structured error.
|
||||
*/
|
||||
export async function getResidentSkillDetail(
|
||||
ctx: OperationContext,
|
||||
sourceId: string,
|
||||
slug: string,
|
||||
): Promise<ResidentSkillDetail> {
|
||||
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(slug)) {
|
||||
throw new OperationError('invalid_params', `Invalid skill slug: ${JSON.stringify(slug)}`);
|
||||
}
|
||||
// Respect source scoping: a scoped caller can only reach its own sources.
|
||||
const scope = sourceScopeOpts(ctx);
|
||||
if (scope.sourceIds && !scope.sourceIds.includes(sourceId)) {
|
||||
throw new OperationError('not_found', `Source not in scope: ${sourceId}`);
|
||||
}
|
||||
if (scope.sourceId && scope.sourceId !== sourceId) {
|
||||
throw new OperationError('not_found', `Source not in scope: ${sourceId}`);
|
||||
}
|
||||
|
||||
let sources;
|
||||
try {
|
||||
sources = await loadAllSources(ctx.engine);
|
||||
} catch {
|
||||
throw new OperationError('storage_error', 'Could not enumerate sources.');
|
||||
}
|
||||
const src = sources.find((s) => s.id === sourceId);
|
||||
if (!src || !src.local_path) {
|
||||
throw new OperationError('not_found', `Source ${sourceId} has no local path.`);
|
||||
}
|
||||
const packRoot = src.local_path;
|
||||
let manifest;
|
||||
try {
|
||||
manifest = loadSkillpackManifest(packRoot);
|
||||
} catch {
|
||||
throw new OperationError('not_found', `Source ${sourceId} has no valid skillpack.`);
|
||||
}
|
||||
if (manifest.brain_resident !== true) {
|
||||
throw new OperationError('not_found', `Source ${sourceId} does not ship a brain-resident pack.`);
|
||||
}
|
||||
const skillDir = `skills/${slug}`;
|
||||
if (!manifest.skills.includes(skillDir)) {
|
||||
throw new OperationError('not_found', `Skill "${slug}" not in pack ${manifest.name}.`);
|
||||
}
|
||||
|
||||
// Confinement: realpath the resolved SKILL.md and require it stays under the
|
||||
// realpath'd pack root (defeats symlink/.. escape, including a poisoned pack).
|
||||
const skillMd = join(packRoot, skillDir, 'SKILL.md');
|
||||
let realRoot: string;
|
||||
let realFile: string;
|
||||
try {
|
||||
realRoot = realpathSync(packRoot);
|
||||
realFile = realpathSync(skillMd);
|
||||
} catch {
|
||||
throw new OperationError('not_found', `SKILL.md missing for ${slug} in ${sourceId}.`);
|
||||
}
|
||||
const rel = relative(realRoot, realFile);
|
||||
if (rel.startsWith('..') || resolve(realRoot, rel) !== realFile) {
|
||||
throw new OperationError('storage_error', 'Skill path escaped the pack root.');
|
||||
}
|
||||
const st = statSync(realFile);
|
||||
if (!st.isFile()) throw new OperationError('storage_error', 'Resolved skill path is not a file.');
|
||||
if (st.size > MAX_SKILL_MD_BYTES) {
|
||||
throw new OperationError('storage_error', `SKILL.md exceeds ${MAX_SKILL_MD_BYTES} bytes.`);
|
||||
}
|
||||
|
||||
const body = readFileSync(realFile, 'utf-8');
|
||||
return {
|
||||
source_id: sourceId,
|
||||
pack_name: manifest.name,
|
||||
slug,
|
||||
description: readSkillDescription(packRoot, skillDir),
|
||||
body,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* skillpack/init-brain-pack.ts — `gbrain skillpack init-brain-pack` scaffold.
|
||||
*
|
||||
* Writes a starter brain-resident skillpack into a brain/source repo: skills
|
||||
* co-evolved with that brain, versioned in its repo, discovered by any harness
|
||||
* that connects (Topology A `sources add` advisory + the `list_brain_skillpack`
|
||||
* MCP tool). Distinct from `runInitScaffold` (the registry-pack cathedral with
|
||||
* test/e2e/eval trees) — a brain-resident pack lives BESIDE brain content, so
|
||||
* the scaffold is lean and the README is the machine-parseable entry point a
|
||||
* connecting harness reads.
|
||||
*
|
||||
* Reuses the refuse-overwrite `applyWritePlan` contract from init-scaffold.ts.
|
||||
* Sets `brain_resident: true` and pins `gbrain_min_version` to the EXACT serving
|
||||
* version (not major.minor.0) so a pack that depends on a just-shipped op can't
|
||||
* silently install on a binary that predates it.
|
||||
*/
|
||||
|
||||
import { applyWritePlan, type WritePlanEntry } from './init-scaffold.ts';
|
||||
import { SKILLPACK_API_VERSION, type SkillpackManifest } from './manifest-v1.ts';
|
||||
import { VERSION } from '../../version.ts';
|
||||
|
||||
import { join } from 'path';
|
||||
|
||||
export interface InitBrainPackOptions {
|
||||
/** Brain/source repo root — where skillpack.json + README land. */
|
||||
targetDir: string;
|
||||
/** Pack name (lowercase kebab; becomes manifest.name). */
|
||||
name: string;
|
||||
/** Schema pack these skills assume (default "gbrain-base"). */
|
||||
schemaPack?: string;
|
||||
/** Optional initial skill slug (default: <pack-name>). */
|
||||
firstSkillSlug?: string;
|
||||
/** Pre-fill author + license + homepage. */
|
||||
author?: string;
|
||||
license?: string;
|
||||
homepage?: string;
|
||||
/** Pin gbrain_min_version to this exact version (default: serving VERSION). */
|
||||
gbrainVersion?: string;
|
||||
/** Dry-run: report intent without writing. */
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
export interface InitBrainPackResult {
|
||||
targetDir: string;
|
||||
filesWritten: string[];
|
||||
filesSkippedExisting: string[];
|
||||
manifest: SkillpackManifest;
|
||||
}
|
||||
|
||||
export class InitBrainPackError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: 'invalid_name',
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'InitBrainPackError';
|
||||
}
|
||||
}
|
||||
|
||||
const NAME_RE = /^[a-z][a-z0-9-]{1,63}$/;
|
||||
|
||||
/** Build the brain-resident pack scaffold. */
|
||||
export function runInitBrainPack(opts: InitBrainPackOptions): InitBrainPackResult {
|
||||
if (!NAME_RE.test(opts.name)) {
|
||||
throw new InitBrainPackError(
|
||||
`name "${opts.name}" is not lowercase kebab-case (must match ${NAME_RE.source})`,
|
||||
'invalid_name',
|
||||
);
|
||||
}
|
||||
const firstSlug = opts.firstSkillSlug ?? opts.name;
|
||||
if (!NAME_RE.test(firstSlug)) {
|
||||
throw new InitBrainPackError(
|
||||
`first-skill slug "${firstSlug}" is not lowercase kebab-case`,
|
||||
'invalid_name',
|
||||
);
|
||||
}
|
||||
const schemaPack = opts.schemaPack ?? 'gbrain-base';
|
||||
if (!NAME_RE.test(schemaPack)) {
|
||||
throw new InitBrainPackError(
|
||||
`schema_pack "${schemaPack}" is not lowercase kebab-case`,
|
||||
'invalid_name',
|
||||
);
|
||||
}
|
||||
// Pin to the EXACT serving version (#13) so a pack depending on a just-shipped
|
||||
// op can't install on an older binary that lacks it.
|
||||
const minVersion = opts.gbrainVersion ?? VERSION;
|
||||
|
||||
const manifest: SkillpackManifest = {
|
||||
api_version: SKILLPACK_API_VERSION,
|
||||
name: opts.name,
|
||||
version: '0.1.0',
|
||||
description: `(edit me) one-line description of the ${opts.name} brain pack`,
|
||||
author: opts.author ?? 'Your Name <you@example.com>',
|
||||
license: opts.license ?? 'MIT',
|
||||
homepage: opts.homepage ?? `https://github.com/your-user/${opts.name}`,
|
||||
gbrain_min_version: minVersion,
|
||||
skills: [`skills/${firstSlug}`],
|
||||
runbooks: { bootstrap: 'runbooks/bootstrap.md' },
|
||||
changelog: 'CHANGELOG.md',
|
||||
brain_resident: true,
|
||||
schema_pack: schemaPack,
|
||||
};
|
||||
|
||||
const dateIso = new Date().toISOString().slice(0, 10);
|
||||
const plan: WritePlanEntry[] = [];
|
||||
|
||||
plan.push({
|
||||
path: join(opts.targetDir, 'skillpack.json'),
|
||||
content: JSON.stringify(manifest, null, 2) + '\n',
|
||||
});
|
||||
|
||||
plan.push({
|
||||
path: join(opts.targetDir, `skills/${firstSlug}/SKILL.md`),
|
||||
content: [
|
||||
'---',
|
||||
`name: ${firstSlug}`,
|
||||
`description: (edit me) one-line description of what ${firstSlug} does for this brain`,
|
||||
'mutating: false',
|
||||
'triggers:',
|
||||
` - example trigger phrase 1 for ${firstSlug}`,
|
||||
` - example trigger phrase 2 for ${firstSlug}`,
|
||||
'# tools: list gbrain ops this skill calls (e.g. [search, put_page]); used by the',
|
||||
'# version-skew lint to fail loud if a connecting gbrain lacks them.',
|
||||
'---',
|
||||
'',
|
||||
`# ${firstSlug}`,
|
||||
'',
|
||||
'(edit me) What this skill does for THIS brain, the conventions it honors, and',
|
||||
'the user-facing contract. A harness reads this top-to-bottom when the user',
|
||||
'phrasing matches a trigger above.',
|
||||
'',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const intents = [1, 2, 3, 4, 5].map((n) => ({
|
||||
intent: `example phrase ${n} for ${firstSlug}`,
|
||||
expected_skill: firstSlug,
|
||||
}));
|
||||
plan.push({
|
||||
path: join(opts.targetDir, `skills/${firstSlug}/routing-eval.jsonl`),
|
||||
content: intents.map((x) => JSON.stringify(x)).join('\n') + '\n',
|
||||
});
|
||||
|
||||
plan.push({
|
||||
path: join(opts.targetDir, 'runbooks/bootstrap.md'),
|
||||
content: [
|
||||
'# Bootstrap',
|
||||
'',
|
||||
'Post-scaffold steps. gbrain displays this but does NOT auto-execute.',
|
||||
'The harness reads it and walks per-step at its own discretion.',
|
||||
'',
|
||||
`1. show user: "${opts.name} is installed. Try a trigger phrase from skills/${firstSlug}/SKILL.md."`,
|
||||
`2. (edit me) agent: honor the conventions in README.md section 4 before writing.`,
|
||||
'',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
// Machine-parseable README — the 5 stable headings a connecting harness scans.
|
||||
plan.push({
|
||||
path: join(opts.targetDir, 'README.md'),
|
||||
content: [
|
||||
`# ${opts.name} (brain-resident skillpack)`,
|
||||
'',
|
||||
'## 1. What this brain is',
|
||||
'',
|
||||
'(edit me) One paragraph describing this brain — mirrors `get_brain_identity`.',
|
||||
'',
|
||||
'## 2. Skills in this pack',
|
||||
'',
|
||||
`- \`${firstSlug}\` — (edit me) one-line description (when to use it; any schema/version assumptions)`,
|
||||
'',
|
||||
'## 3. Install',
|
||||
'',
|
||||
'Ask the user first, then:',
|
||||
'',
|
||||
'```bash',
|
||||
`gbrain skillpack scaffold <this-repo>`,
|
||||
'```',
|
||||
'',
|
||||
'## 4. Conventions this brain expects',
|
||||
'',
|
||||
'(edit me) The operating rules a connecting harness should honor (e.g. "always',
|
||||
'search before writing", "meetings propagate to all entity pages").',
|
||||
'',
|
||||
'## 5. Version compatibility',
|
||||
'',
|
||||
`- gbrain_min_version: ${minVersion}`,
|
||||
`- schema_pack: ${schemaPack}`,
|
||||
'',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
plan.push({
|
||||
path: join(opts.targetDir, 'CHANGELOG.md'),
|
||||
content: [
|
||||
'# Changelog',
|
||||
'',
|
||||
'All notable changes documented in Keep-a-Changelog shape.',
|
||||
'',
|
||||
`## [0.1.0] - ${dateIso}`,
|
||||
'',
|
||||
'- Initial release.',
|
||||
'',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const { written, skipped } = applyWritePlan(plan, { dryRun: opts.dryRun });
|
||||
|
||||
return {
|
||||
targetDir: opts.targetDir,
|
||||
filesWritten: written,
|
||||
filesSkippedExisting: skipped,
|
||||
manifest,
|
||||
};
|
||||
}
|
||||
@@ -54,6 +54,38 @@ export class InitScaffoldError extends Error {
|
||||
|
||||
const NAME_RE = /^[a-z][a-z0-9-]{1,63}$/;
|
||||
|
||||
/** A planned file write: absolute path + content. */
|
||||
export interface WritePlanEntry {
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a write plan with the refuse-overwrite contract shared by
|
||||
* `runInitScaffold` and `runInitBrainPack`: existing files are skipped (never
|
||||
* clobbered), missing parent dirs are created, and `dryRun` reports intent
|
||||
* without touching disk. Returns the split of written vs skipped paths.
|
||||
*/
|
||||
export function applyWritePlan(
|
||||
plan: WritePlanEntry[],
|
||||
opts: { dryRun?: boolean } = {},
|
||||
): { written: string[]; skipped: string[] } {
|
||||
const written: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
for (const p of plan) {
|
||||
if (existsSync(p.path)) {
|
||||
skipped.push(p.path);
|
||||
continue;
|
||||
}
|
||||
if (!opts.dryRun) {
|
||||
mkdirSync(join(p.path, '..'), { recursive: true });
|
||||
writeFileSync(p.path, p.content);
|
||||
}
|
||||
written.push(p.path);
|
||||
}
|
||||
return { written, skipped };
|
||||
}
|
||||
|
||||
/** Build the cathedral scaffold tree. */
|
||||
export function runInitScaffold(opts: InitScaffoldOptions): InitScaffoldResult {
|
||||
if (!NAME_RE.test(opts.name)) {
|
||||
@@ -247,20 +279,8 @@ export function runInitScaffold(opts: InitScaffoldOptions): InitScaffoldResult {
|
||||
});
|
||||
}
|
||||
|
||||
// Apply plan.
|
||||
const written: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
for (const p of plan) {
|
||||
if (existsSync(p.path)) {
|
||||
skipped.push(p.path);
|
||||
continue;
|
||||
}
|
||||
if (!opts.dryRun) {
|
||||
mkdirSync(join(p.path, '..'), { recursive: true });
|
||||
writeFileSync(p.path, p.content);
|
||||
}
|
||||
written.push(p.path);
|
||||
}
|
||||
// Apply plan (shared refuse-overwrite contract).
|
||||
const { written, skipped } = applyWritePlan(plan, { dryRun: opts.dryRun });
|
||||
|
||||
return {
|
||||
targetDir: opts.targetDir,
|
||||
|
||||
@@ -78,6 +78,21 @@ export interface SkillpackManifest {
|
||||
|
||||
/** Path to CHANGELOG.md. */
|
||||
changelog?: string;
|
||||
|
||||
/**
|
||||
* Marks this pack as authored for the brain/source repo it lives in.
|
||||
* Drives connect-time discovery (Topology A `sources add` advisory + the
|
||||
* `list_brain_skillpack` MCP tool) and the install nag. Absent/false = a
|
||||
* legacy or registry third-party pack. Additive + forward-compatible:
|
||||
* older gbrain ignores it; this validator tolerates it being absent.
|
||||
*/
|
||||
brain_resident?: boolean;
|
||||
/**
|
||||
* The schema pack these skills assume (e.g. "gbrain-base"). When set, the
|
||||
* discovery advisory warns if it differs from the brain's active schema
|
||||
* pack so a connecting harness installs against a compatible schema.
|
||||
*/
|
||||
schema_pack?: string;
|
||||
}
|
||||
|
||||
/** Structured error code surface. */
|
||||
@@ -258,6 +273,22 @@ export function validateSkillpackManifest(
|
||||
);
|
||||
}
|
||||
|
||||
// Brain-resident pack fields (v0.43 — issue #2180). Both optional + additive.
|
||||
if (obj.brain_resident !== undefined && typeof obj.brain_resident !== 'boolean') {
|
||||
throw new SkillpackManifestError(
|
||||
`brain_resident, if present, must be a boolean`,
|
||||
'manifest_invalid_field',
|
||||
{ field: 'brain_resident', actual: obj.brain_resident },
|
||||
);
|
||||
}
|
||||
if (obj.schema_pack !== undefined && (typeof obj.schema_pack !== 'string' || !NAME_RE.test(obj.schema_pack))) {
|
||||
throw new SkillpackManifestError(
|
||||
`schema_pack, if present, must be a lowercase kebab-case pack name; got ${JSON.stringify(obj.schema_pack)}`,
|
||||
'manifest_invalid_field',
|
||||
{ field: 'schema_pack', expected: NAME_RE.source, actual: obj.schema_pack },
|
||||
);
|
||||
}
|
||||
|
||||
// Schema-version forward-compat (codex outside-voice gap).
|
||||
const maxRunbook = opts.maxRunbookSchemaVersion ?? RUNBOOK_SCHEMA_VERSION;
|
||||
const maxEval = opts.maxEvalSchemaVersion ?? EVAL_SCHEMA_VERSION;
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* skillpack/nag-state.ts — machine-owned install-nag state at
|
||||
* `~/.gbrain/skillpack-nag-state.json`.
|
||||
*
|
||||
* Sibling of skillpack-state.json (the install-provenance ledger keyed by pack
|
||||
* name). This file tracks the OPPOSITE: declines of brain-resident packs the
|
||||
* user has NOT installed, keyed by (brain_id, source_id, pack_name), so the
|
||||
* connect-time advisory can escalate-then-suppress instead of nagging forever.
|
||||
*
|
||||
* Why a separate file: skillpack-state.json's identity check
|
||||
* (`isAlreadyTrusted`) is keyed by name and is the source of truth for trusted
|
||||
* installs; folding decline-counts in would pollute it with "seen but never
|
||||
* installed" rows. Same atomic .tmp+rename + schema-version conventions.
|
||||
*
|
||||
* `decideNagAction` is a pure function — the heart of the nag policy — so the
|
||||
* escalation logic is unit-testable without touching disk.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
|
||||
import { gbrainPath } from '../config.ts';
|
||||
|
||||
export const SKILLPACK_NAG_SCHEMA_VERSION = 'gbrain-skillpack-nag-v1' as const;
|
||||
|
||||
/** Default declines before the advisory goes quiet for a given pack version. */
|
||||
export const DEFAULT_NAG_CEILING = 3;
|
||||
|
||||
export interface NagEntry {
|
||||
/**
|
||||
* Stable identifier for the brain/source REPO that carries the pack —
|
||||
* derived from the source's canonical git remote when available, else a
|
||||
* deterministic fallback. NOT a bare local-path hash (diverges across
|
||||
* machines) and NOT the brain DB identity (which is a different axis).
|
||||
*/
|
||||
brain_id: string;
|
||||
/** The source id the pack was discovered under. */
|
||||
source_id: string;
|
||||
/** Pack name (matches skillpack.json `name`). */
|
||||
pack_name: string;
|
||||
/** Pack version at the last prompt; a bump re-surfaces once + resets count. */
|
||||
pack_version: string;
|
||||
/** ISO 8601 UTC timestamp of the last advisory display. */
|
||||
prompted_at: string;
|
||||
/** Count of CLI-interactive displays the user did not act on. */
|
||||
declined_count: number;
|
||||
/** Hard-off: hit the ceiling or `--no-skill-nag`. */
|
||||
suppressed: boolean;
|
||||
}
|
||||
|
||||
export interface NagState {
|
||||
schema_version: typeof SKILLPACK_NAG_SCHEMA_VERSION;
|
||||
entries: NagEntry[];
|
||||
}
|
||||
|
||||
export type NagStateErrorCode = 'nag_malformed_json' | 'nag_schema_unknown' | 'nag_atomic_write_failed';
|
||||
|
||||
export class NagStateError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: NagStateErrorCode,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'NagStateError';
|
||||
}
|
||||
}
|
||||
|
||||
const EMPTY: NagState = { schema_version: SKILLPACK_NAG_SCHEMA_VERSION, entries: [] };
|
||||
|
||||
export function defaultNagStatePath(): string {
|
||||
return gbrainPath('skillpack-nag-state.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load nag state. Returns empty on missing file. On corrupt/unknown-schema
|
||||
* content, returns empty (fail-open: a broken nag file must never block a
|
||||
* `sources add`; the cost is one extra advisory display, not a crash).
|
||||
*/
|
||||
export function loadNagState(opts: { statePath?: string } = {}): NagState {
|
||||
const path = opts.statePath ?? defaultNagStatePath();
|
||||
if (!existsSync(path)) return { ...EMPTY, entries: [] };
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(path, 'utf-8')) as Record<string, unknown>;
|
||||
if (raw.schema_version !== SKILLPACK_NAG_SCHEMA_VERSION || !Array.isArray(raw.entries)) {
|
||||
return { ...EMPTY, entries: [] };
|
||||
}
|
||||
return { schema_version: SKILLPACK_NAG_SCHEMA_VERSION, entries: raw.entries as NagEntry[] };
|
||||
} catch {
|
||||
return { ...EMPTY, entries: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveNagState(state: NagState, opts: { statePath?: string } = {}): void {
|
||||
const path = opts.statePath ?? defaultNagStatePath();
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const tmp = path + '.tmp';
|
||||
try {
|
||||
writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', { mode: 0o644 });
|
||||
renameSync(tmp, path);
|
||||
} catch (err) {
|
||||
try {
|
||||
rmSync(tmp, { force: true });
|
||||
} catch {}
|
||||
throw new NagStateError(
|
||||
`failed to atomically write skillpack-nag-state.json to ${path}: ${(err as Error).message}`,
|
||||
'nag_atomic_write_failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function findNag(
|
||||
state: NagState,
|
||||
key: { brain_id: string; source_id: string; pack_name: string },
|
||||
): NagEntry | undefined {
|
||||
return state.entries.find(
|
||||
(e) => e.brain_id === key.brain_id && e.source_id === key.source_id && e.pack_name === key.pack_name,
|
||||
);
|
||||
}
|
||||
|
||||
/** Upsert by (brain_id, source_id, pack_name). Returns a new state value. */
|
||||
export function upsertNag(state: NagState, entry: NagEntry): NagState {
|
||||
const others = state.entries.filter(
|
||||
(e) =>
|
||||
!(e.brain_id === entry.brain_id && e.source_id === entry.source_id && e.pack_name === entry.pack_name),
|
||||
);
|
||||
return { schema_version: SKILLPACK_NAG_SCHEMA_VERSION, entries: [...others, entry] };
|
||||
}
|
||||
|
||||
export interface NagDecision {
|
||||
show: boolean;
|
||||
/** Present only when show=true. */
|
||||
level?: 'full' | 'short';
|
||||
reason?: 'first' | 'reminder' | 'version_bump';
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure nag policy. Given the prior entry (or undefined) for a pack and the
|
||||
* current pack version + flags, decide whether and how to surface the advisory.
|
||||
*
|
||||
* - No prior entry → full advisory ('first').
|
||||
* - noNagFlag, prior suppressed, or declined_count >= ceiling → hidden.
|
||||
* - Same version, under ceiling → short reminder.
|
||||
* - Version bumped while still uninstalled → full advisory ('version_bump');
|
||||
* the caller resets declined_count to 0 (a new version is new information).
|
||||
*/
|
||||
export function decideNagAction(
|
||||
entry: NagEntry | undefined,
|
||||
current: { pack_version: string; noNagFlag?: boolean; ceiling?: number },
|
||||
): NagDecision {
|
||||
if (current.noNagFlag) return { show: false };
|
||||
if (!entry) return { show: true, level: 'full', reason: 'first' };
|
||||
if (entry.pack_version !== current.pack_version) {
|
||||
return { show: true, level: 'full', reason: 'version_bump' };
|
||||
}
|
||||
if (entry.suppressed) return { show: false };
|
||||
const ceiling = current.ceiling ?? DEFAULT_NAG_CEILING;
|
||||
if (entry.declined_count >= ceiling) return { show: false };
|
||||
return { show: true, level: 'short', reason: 'reminder' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a decision: bump the entry for persistence. On version_bump the count
|
||||
* resets to 1 (this display). Otherwise increments. Marks suppressed once the
|
||||
* ceiling is reached so the next call short-circuits.
|
||||
*/
|
||||
export function recordNagDisplay(
|
||||
prior: NagEntry | undefined,
|
||||
key: { brain_id: string; source_id: string; pack_name: string },
|
||||
current: { pack_version: string; ceiling?: number; nowIso: string },
|
||||
): NagEntry {
|
||||
const ceiling = current.ceiling ?? DEFAULT_NAG_CEILING;
|
||||
const versionChanged = !prior || prior.pack_version !== current.pack_version;
|
||||
const declined_count = versionChanged ? 1 : prior.declined_count + 1;
|
||||
return {
|
||||
brain_id: key.brain_id,
|
||||
source_id: key.source_id,
|
||||
pack_name: key.pack_name,
|
||||
pack_version: current.pack_version,
|
||||
prompted_at: current.nowIso,
|
||||
declined_count,
|
||||
suppressed: declined_count >= ceiling,
|
||||
};
|
||||
}
|
||||
@@ -31,64 +31,13 @@
|
||||
* - Pre-v0.19 fence with no receipt → use the row-extracted slug set.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { readFileSync } from 'fs';
|
||||
import { findResolverFile } from '../resolver-filenames.ts';
|
||||
import { extractManagedSlugs, parseReceipt } from './installer.ts';
|
||||
import { autoDetectSkillsDir } from '../repo-root.ts';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
|
||||
interface RecommendedSkill {
|
||||
slug: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const V0_25_1_RECOMMENDED: RecommendedSkill[] = [
|
||||
{
|
||||
slug: 'book-mirror',
|
||||
description:
|
||||
'FLAGSHIP. Take any book (EPUB/PDF), produce a personalized two-column chapter-by-chapter analysis. Left column preserves the chapter; right column maps every idea to your life using brain context. ~$6 for a 20-chapter book at Opus.',
|
||||
},
|
||||
{
|
||||
slug: 'article-enrichment',
|
||||
description:
|
||||
'Turn raw article dumps into structured pages with executive summary, verbatim quotes, key insights, why-it-matters.',
|
||||
},
|
||||
{
|
||||
slug: 'strategic-reading',
|
||||
description:
|
||||
'Read a book / article / case study through ONE specific problem-lens. Output: applied playbook with do / avoid / watch-for.',
|
||||
},
|
||||
{
|
||||
slug: 'concept-synthesis',
|
||||
description:
|
||||
'Deduplicate raw concept stubs into a tiered intellectual map (T1 Canon to T4 Riff). Trace idea evolution across years.',
|
||||
},
|
||||
{
|
||||
slug: 'perplexity-research',
|
||||
description:
|
||||
'Brain-augmented web research. Sends brain context to Perplexity so the search focuses on what is NEW vs already-known.',
|
||||
},
|
||||
{
|
||||
slug: 'archive-crawler',
|
||||
description:
|
||||
'Universal archivist for personal file archives (Dropbox / B2 / Gmail-takeout). REFUSES to run without a gbrain.yml allow-list — safe-by-default.',
|
||||
},
|
||||
{
|
||||
slug: 'academic-verify',
|
||||
description:
|
||||
'Trace a research claim through publication → methodology → raw data → independent replication. Verdict-shaped brain page.',
|
||||
},
|
||||
{
|
||||
slug: 'brain-pdf',
|
||||
description:
|
||||
'Render any brain page to publication-quality PDF via the gstack make-pdf binary. Optional gstack co-install.',
|
||||
},
|
||||
{
|
||||
slug: 'voice-note-ingest',
|
||||
description:
|
||||
'Capture voice notes with EXACT-PHRASING preservation (never paraphrased). Routes content to originals/concepts/people/companies/ideas.',
|
||||
},
|
||||
];
|
||||
import { currentRecommendedSet, type RecommendedSkill } from '../advisor/recommended-set.ts';
|
||||
import { renderRecommendedSkills } from '../advisor/render.ts';
|
||||
|
||||
/**
|
||||
* Read the managed block's cumulative-slugs receipt to find what's
|
||||
@@ -131,107 +80,41 @@ export function buildAdvisory(opts: {
|
||||
}
|
||||
|
||||
const installed = detectInstalledSlugs(skillsDir, workspace);
|
||||
const missing = V0_25_1_RECOMMENDED.filter((s) => !installed.has(s.slug));
|
||||
const all = currentRecommendedSet();
|
||||
const missing = all.filter((s) => !installed.has(s.slug));
|
||||
|
||||
if (missing.length === 0) return null;
|
||||
|
||||
return renderAdvisory({
|
||||
// #8: `skillpack install` was removed — scaffold is canonical. The shared
|
||||
// renderer emits scaffold commands so this surface and `gbrain advisor` agree.
|
||||
return renderRecommendedSkills({
|
||||
version: opts.version,
|
||||
context: opts.context,
|
||||
missing,
|
||||
installCommand:
|
||||
missing.length === V0_25_1_RECOMMENDED.length
|
||||
? 'gbrain skillpack install --all'
|
||||
: `gbrain skillpack install ${missing.map((s) => s.slug).join(' ')}`,
|
||||
skills: missing,
|
||||
scaffoldAllCommand: scaffoldCommandFor(missing, all),
|
||||
});
|
||||
}
|
||||
|
||||
function scaffoldCommandFor(missing: RecommendedSkill[], all: RecommendedSkill[]): string {
|
||||
return missing.length === all.length
|
||||
? 'gbrain skillpack scaffold --all'
|
||||
: `gbrain skillpack scaffold ${missing.map((s) => s.slug).join(' ')}`;
|
||||
}
|
||||
|
||||
function buildAdvisoryWithoutWorkspace(
|
||||
version: string,
|
||||
context: 'init' | 'upgrade',
|
||||
): string {
|
||||
return renderAdvisory({
|
||||
const all = currentRecommendedSet();
|
||||
return renderRecommendedSkills({
|
||||
version,
|
||||
context,
|
||||
missing: V0_25_1_RECOMMENDED,
|
||||
installCommand: 'gbrain skillpack install --all',
|
||||
skills: all,
|
||||
scaffoldAllCommand: 'gbrain skillpack scaffold --all',
|
||||
workspaceNotDetected: true,
|
||||
});
|
||||
}
|
||||
|
||||
function renderAdvisory(opts: {
|
||||
version: string;
|
||||
context: 'init' | 'upgrade';
|
||||
missing: RecommendedSkill[];
|
||||
installCommand: string;
|
||||
workspaceNotDetected?: boolean;
|
||||
}): string {
|
||||
const lines: string[] = [];
|
||||
const bar = '='.repeat(72);
|
||||
const verb = opts.context === 'init' ? 'installed' : 'upgraded to';
|
||||
|
||||
lines.push('');
|
||||
lines.push(bar);
|
||||
lines.push(`gbrain ${opts.version} — RECOMMENDED SKILLS FOR THE AGENT TO INSTALL`);
|
||||
lines.push(bar);
|
||||
lines.push('');
|
||||
lines.push(
|
||||
`The user just ${verb} gbrain ${opts.version}. This release ships ${opts.missing.length} ` +
|
||||
`${opts.missing.length === 1 ? 'skill' : 'skills'} that the user should ` +
|
||||
`consider installing into their workspace.`,
|
||||
);
|
||||
lines.push('');
|
||||
if (opts.workspaceNotDetected) {
|
||||
lines.push(
|
||||
'(NOTE: No OpenClaw workspace was detected at the default location. ' +
|
||||
'Set $OPENCLAW_WORKSPACE or pass --skills-dir to gbrain skillpack install.)',
|
||||
);
|
||||
lines.push('');
|
||||
}
|
||||
lines.push('THE NEW SKILLS:');
|
||||
lines.push('');
|
||||
for (const s of opts.missing) {
|
||||
lines.push(` - ${s.slug}`);
|
||||
const wrapped = wrap(s.description, 68, ' ');
|
||||
for (const line of wrapped) lines.push(line);
|
||||
lines.push('');
|
||||
}
|
||||
lines.push('ACTION FOR THE AGENT:');
|
||||
lines.push('');
|
||||
lines.push(' 1. Show this list to the user. Briefly explain each skill.');
|
||||
lines.push(' 2. Ask the user explicitly: "Want me to install these now?"');
|
||||
lines.push(' 3. If YES, run this command:');
|
||||
lines.push('');
|
||||
lines.push(` ${opts.installCommand}`);
|
||||
lines.push('');
|
||||
lines.push(' 4. If NO, tell them they can install any time with:');
|
||||
lines.push('');
|
||||
lines.push(' gbrain skillpack install <name> # one skill');
|
||||
lines.push(' gbrain skillpack install --all # all bundled');
|
||||
lines.push(' gbrain skillpack list # see all options');
|
||||
lines.push('');
|
||||
lines.push(' Do NOT install without asking. The user owns this decision.');
|
||||
lines.push(bar);
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function wrap(text: string, width: number, indent: string): string[] {
|
||||
const words = text.split(/\s+/);
|
||||
const lines: string[] = [];
|
||||
let current = indent;
|
||||
for (const word of words) {
|
||||
if ((current + (current === indent ? '' : ' ') + word).length > width + indent.length) {
|
||||
lines.push(current.trimEnd());
|
||||
current = indent + word;
|
||||
} else {
|
||||
current = current === indent ? indent + word : current + ' ' + word;
|
||||
}
|
||||
}
|
||||
if (current.trim().length > 0) lines.push(current.trimEnd());
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the advisory to stderr at the end of init / post-upgrade.
|
||||
* No-op when buildAdvisory returns null.
|
||||
|
||||
@@ -1270,6 +1270,13 @@ export interface TimelineOpts {
|
||||
* (pre-v0.31.8 behavior; preserved by the two-branch query in both engines).
|
||||
*/
|
||||
sourceId?: string;
|
||||
/**
|
||||
* #2200: federated read grant (caller's allowedSources). Takes precedence over
|
||||
* scalar `sourceId` when non-empty; scopes the page-id lookup to
|
||||
* `source_id = ANY($::text[])` so a federated read honors the whole grant
|
||||
* (union of allowed sources), not just one source. Mirrors `GetPageOpts.sourceIds`.
|
||||
*/
|
||||
sourceIds?: string[];
|
||||
}
|
||||
|
||||
// Raw data
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Tests for src/core/advisor/apply.ts — the --apply allowlist + injection guard
|
||||
* (E5/C5). The CLI confirms + spawns; this verifies WHAT is allowed to run.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { resolveApplyTarget } from '../src/core/advisor/apply.ts';
|
||||
import type { AdvisorFinding, AdvisorReport } from '../src/core/advisor/types.ts';
|
||||
|
||||
function report(findings: AdvisorFinding[]): AdvisorReport {
|
||||
return { version: '0.43.0.0', generated_at: 'x', findings, worst: 'info' };
|
||||
}
|
||||
function f(over: Partial<AdvisorFinding>): AdvisorFinding {
|
||||
return { id: 'x', severity: 'info', title: 't', fix: { command_argv: null }, collector: 'c', ask_user: true, ...over };
|
||||
}
|
||||
|
||||
describe('resolveApplyTarget', () => {
|
||||
test('resolves an allowlisted finding to its argv', () => {
|
||||
const r = report([
|
||||
f({ id: 'mig', fix: { command_argv: ['gbrain', 'apply-migrations', '--yes'], dispatch_id: 'apply_migrations' } }),
|
||||
]);
|
||||
const res = resolveApplyTarget(r, 'apply_migrations');
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) expect(res.argv).toEqual(['gbrain', 'apply-migrations', '--yes']);
|
||||
});
|
||||
|
||||
test('rejects unknown id and lists runnable ids', () => {
|
||||
const r = report([
|
||||
f({ id: 'mig', fix: { command_argv: ['gbrain', 'apply-migrations', '--yes'], dispatch_id: 'apply_migrations' } }),
|
||||
]);
|
||||
const res = resolveApplyTarget(r, 'nope');
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) expect(res.runnable).toEqual(['apply_migrations']);
|
||||
});
|
||||
|
||||
test('a finding without dispatch_id is NOT runnable', () => {
|
||||
const r = report([f({ id: 'v', fix: { command_argv: ['gbrain', 'upgrade'] } })]); // no dispatch_id
|
||||
expect(resolveApplyTarget(r, 'v').ok).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects shell metacharacters in the argv (injection guard)', () => {
|
||||
const r = report([
|
||||
f({ id: 'evil', fix: { command_argv: ['gbrain', 'scaffold', 'foo; rm -rf /'], dispatch_id: 'evil' } }),
|
||||
]);
|
||||
expect(resolveApplyTarget(r, 'evil').ok).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects a fix that does not invoke gbrain', () => {
|
||||
const r = report([f({ id: 'x', fix: { command_argv: ['rm', '-rf', '/'], dispatch_id: 'x' } })]);
|
||||
expect(resolveApplyTarget(r, 'x').ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Tests for the advisor core: ranking, collector resilience, individual
|
||||
* collectors with a stub engine, and finding-history deltas.
|
||||
*/
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import { rankFindings, runAdvisor } from '../src/core/advisor/run.ts';
|
||||
import { collectUsageShape } from '../src/core/advisor/collect-usage-shape.ts';
|
||||
import { collectStalledJobs } from '../src/core/advisor/collect-stalled-jobs.ts';
|
||||
import { collectSetupSmells } from '../src/core/advisor/collect-setup-smells.ts';
|
||||
import { appendAdvisorRun, summarizeDeltas } from '../src/core/advisor/history.ts';
|
||||
import { renderAdvisorReport } from '../src/core/advisor/render.ts';
|
||||
import type { AdvisorContext, AdvisorFinding, AdvisorReport } from '../src/core/advisor/types.ts';
|
||||
|
||||
function finding(over: Partial<AdvisorFinding>): AdvisorFinding {
|
||||
return {
|
||||
id: 'x',
|
||||
severity: 'info',
|
||||
title: 't',
|
||||
fix: { command_argv: null },
|
||||
collector: 'usage-shape',
|
||||
ask_user: true,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function ctx(engine: Partial<AdvisorContext['engine']>, over: Partial<AdvisorContext> = {}): AdvisorContext {
|
||||
return {
|
||||
engine: engine as AdvisorContext['engine'],
|
||||
config: {} as AdvisorContext['config'],
|
||||
version: '0.43.0.0',
|
||||
workspace: null,
|
||||
skillsDir: null,
|
||||
now: new Date('2026-06-16T00:00:00Z'),
|
||||
remote: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('rankFindings', () => {
|
||||
test('critical > warn > info, then collector order; info capped', () => {
|
||||
const fs = [
|
||||
finding({ id: 'i1', severity: 'info', collector: 'usage-shape' }),
|
||||
finding({ id: 'c1', severity: 'critical', collector: 'migration' }),
|
||||
finding({ id: 'w1', severity: 'warn', collector: 'version' }),
|
||||
];
|
||||
const ranked = rankFindings(fs);
|
||||
expect(ranked.map((f) => f.id)).toEqual(['c1', 'w1', 'i1']);
|
||||
});
|
||||
|
||||
test('info cap drops extra info but keeps all criticals', () => {
|
||||
const fs: AdvisorFinding[] = [];
|
||||
for (let i = 0; i < 15; i++) fs.push(finding({ id: `i${i}`, severity: 'info' }));
|
||||
fs.push(finding({ id: 'crit', severity: 'critical', collector: 'migration' }));
|
||||
const ranked = rankFindings(fs, { infoCap: 3 });
|
||||
expect(ranked.filter((f) => f.severity === 'info')).toHaveLength(3);
|
||||
expect(ranked.find((f) => f.id === 'crit')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('runAdvisor resilience', () => {
|
||||
test('does not throw when the engine throws everywhere', async () => {
|
||||
const engine = {
|
||||
getStats: async () => { throw new Error('boom'); },
|
||||
getHealth: async () => { throw new Error('boom'); },
|
||||
getConfig: async () => { throw new Error('boom'); },
|
||||
executeRaw: async () => { throw new Error('boom'); },
|
||||
};
|
||||
const report = await runAdvisor(ctx(engine));
|
||||
expect(report).toBeDefined();
|
||||
expect(Array.isArray(report.findings)).toBe(true);
|
||||
});
|
||||
|
||||
test('drops workspace_dependent findings when remote', async () => {
|
||||
const wd = finding({ id: 'wd', workspace_dependent: true });
|
||||
// simulate by ranking + the remote filter logic via runAdvisor is internal;
|
||||
// assert the flag exists so the filter has something to act on.
|
||||
expect(wd.workspace_dependent).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collect-usage-shape', () => {
|
||||
test('flags low embed coverage + orphans', async () => {
|
||||
const engine = {
|
||||
getStats: async () => ({ page_count: 100, chunk_count: 0, embedded_count: 0, link_count: 0, tag_count: 0, timeline_entry_count: 0, pages_by_type: {} }),
|
||||
getHealth: async () => ({
|
||||
page_count: 100, embed_coverage: 0.4, stale_pages: 0, orphan_pages: 5, missing_embeddings: 60,
|
||||
brain_score: 50, dead_links: 0, link_coverage: 0, timeline_coverage: 0, most_connected: [],
|
||||
embed_coverage_score: 0, link_density_score: 0, timeline_coverage_score: 0, no_orphans_score: 0, no_dead_links_score: 0,
|
||||
}),
|
||||
};
|
||||
const out = await collectUsageShape.collect(ctx(engine as never));
|
||||
const ids = out.map((f) => f.id);
|
||||
expect(ids).toContain('low_embed_coverage');
|
||||
expect(ids).toContain('orphan_pages');
|
||||
});
|
||||
|
||||
test('empty brain → no findings', async () => {
|
||||
const engine = { getStats: async () => ({ page_count: 0, chunk_count: 0, embedded_count: 0, link_count: 0, tag_count: 0, timeline_entry_count: 0, pages_by_type: {} }) };
|
||||
expect(await collectUsageShape.collect(ctx(engine as never))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collect-stalled-jobs', () => {
|
||||
test('absent minion_jobs table → no error, no finding', async () => {
|
||||
const engine = { executeRaw: async () => { throw new Error('relation "minion_jobs" does not exist'); } };
|
||||
expect(await collectStalledJobs.collect(ctx(engine as never))).toEqual([]);
|
||||
});
|
||||
|
||||
test('reports stalled jobs and stale sync', async () => {
|
||||
let call = 0;
|
||||
const engine = {
|
||||
executeRaw: async () => {
|
||||
call++;
|
||||
if (call === 1) return [{ name: 'embed-backfill', n: 2 }];
|
||||
return [{ id: 'wiki' }];
|
||||
},
|
||||
};
|
||||
const out = await collectStalledJobs.collect(ctx(engine as never));
|
||||
expect(out.find((f) => f.id === 'stalled_job:embed-backfill')).toBeDefined();
|
||||
expect(out.find((f) => f.id === 'stale_sync:wiki')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('collect-setup-smells', () => {
|
||||
test('embeddings disabled → warn', async () => {
|
||||
const engine = { getConfig: async () => null };
|
||||
const c = ctx(engine as never, { config: { embedding_disabled: true } as AdvisorContext['config'] });
|
||||
const out = await collectSetupSmells.collect(c);
|
||||
expect(out.find((f) => f.id === 'embeddings_disabled')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('advisor history (E3)', () => {
|
||||
let dir: string;
|
||||
let path: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'gbrain-advhist-'));
|
||||
path = join(dir, 'advisor-history.jsonl');
|
||||
});
|
||||
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
function report(ids: string[]): AdvisorReport {
|
||||
return {
|
||||
version: '0.43.0.0',
|
||||
generated_at: '2026-06-16T00:00:00Z',
|
||||
findings: ids.map((id) => finding({ id })),
|
||||
worst: 'info',
|
||||
};
|
||||
}
|
||||
|
||||
test('first run returns null prior; second run yields deltas', () => {
|
||||
expect(appendAdvisorRun(report(['a', 'b']), { path })).toBeNull();
|
||||
const prior = appendAdvisorRun(report(['b', 'c']), { path });
|
||||
expect(prior).not.toBeNull();
|
||||
const note = summarizeDeltas(prior, report(['b', 'c']));
|
||||
expect(note).toContain('1 new since last run');
|
||||
expect(note).toContain('1 resolved');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderAdvisorReport', () => {
|
||||
test('healthy brain renders the all-clear', () => {
|
||||
const txt = renderAdvisorReport({ version: '0.43.0.0', generated_at: 'x', findings: [], worst: null });
|
||||
expect(txt).toContain('looks healthy');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Tests for the `advisor` MCP op gate (T7 / C3): remote callers need
|
||||
* mcp.publish_advisor; local callers bypass; the op is read-only (drops
|
||||
* workspace-dependent findings over MCP via runAdvisor's remote filter).
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { operationsByName, OperationError, type OperationContext } from '../src/core/operations.ts';
|
||||
|
||||
const advisor = operationsByName['advisor']!;
|
||||
|
||||
function ctx(over: Partial<OperationContext>, cfg: Record<string, string | null> = {}): OperationContext {
|
||||
return {
|
||||
engine: {
|
||||
getConfig: async (k: string) => cfg[k] ?? null,
|
||||
getStats: async () => { throw new Error('no'); },
|
||||
getHealth: async () => { throw new Error('no'); },
|
||||
executeRaw: async () => { throw new Error('no'); },
|
||||
} as unknown as OperationContext['engine'],
|
||||
config: {} as OperationContext['config'],
|
||||
logger: { info() {}, warn() {}, error() {}, debug() {} } as unknown as OperationContext['logger'],
|
||||
dryRun: false,
|
||||
remote: true,
|
||||
...over,
|
||||
} as OperationContext;
|
||||
}
|
||||
|
||||
describe('advisor op gate', () => {
|
||||
test('op exists, is read-scoped and not localOnly (exposed over MCP)', () => {
|
||||
expect(advisor).toBeDefined();
|
||||
expect(advisor.scope).toBe('read');
|
||||
expect(advisor.localOnly).not.toBe(true);
|
||||
});
|
||||
|
||||
test('remote without mcp.publish_advisor → permission_denied', async () => {
|
||||
await expect(advisor.handler(ctx({ remote: true }), {})).rejects.toBeInstanceOf(OperationError);
|
||||
});
|
||||
|
||||
test('remote WITH mcp.publish_advisor → returns a report', async () => {
|
||||
const report = (await advisor.handler(ctx({ remote: true }, { 'mcp.publish_advisor': 'true' }), {})) as {
|
||||
findings: unknown[];
|
||||
};
|
||||
expect(Array.isArray(report.findings)).toBe(true);
|
||||
});
|
||||
|
||||
test('local caller bypasses the gate', async () => {
|
||||
const report = (await advisor.handler(ctx({ remote: false }), {})) as { findings: unknown[] };
|
||||
expect(Array.isArray(report.findings)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* E4 — advisor ranking-precision eval.
|
||||
*
|
||||
* Measures the FEATURE (per the North Star): on synthetic, anonymized brains
|
||||
* with KNOWN seeded defects, does the advisor surface the right findings and
|
||||
* rank them correctly (critical > warn > info)? This tests the advisor's own
|
||||
* logic, not the remediation loop — each fixture injects defects through the
|
||||
* exact channels the collectors read (getStats/getHealth, minion_jobs, config),
|
||||
* then we score precision = (expected findings surfaced) / (expected total).
|
||||
*
|
||||
* Runs in the unit shard (under test/) so it's CI-gated; deterministic and
|
||||
* zero-API-cost (seeded fake engines), so it belongs with the unit suite rather
|
||||
* than a manual harness. Never ships downstream (test/ is not bundled).
|
||||
*
|
||||
* Synthetic only: no real brain content (anonymized fixtures), per the
|
||||
* calibration-corpus rule.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { runAdvisor } from '../src/core/advisor/run.ts';
|
||||
import type { AdvisorContext } from '../src/core/advisor/types.ts';
|
||||
|
||||
interface Fixture {
|
||||
name: string;
|
||||
engine: Partial<AdvisorContext['engine']>;
|
||||
config?: Partial<AdvisorContext['config']>;
|
||||
remote?: boolean;
|
||||
/** Finding ids (or id prefixes) we expect to surface. */
|
||||
expect: string[];
|
||||
/** Finding ids that must NOT surface. */
|
||||
forbid?: string[];
|
||||
}
|
||||
|
||||
const HEALTHY_STATS = {
|
||||
getStats: async () => ({
|
||||
page_count: 500, chunk_count: 0, embedded_count: 0, link_count: 0, tag_count: 0, timeline_entry_count: 0, pages_by_type: {},
|
||||
}),
|
||||
getHealth: async () => ({
|
||||
page_count: 500, embed_coverage: 0.99, stale_pages: 0, orphan_pages: 0, missing_embeddings: 0,
|
||||
brain_score: 95, dead_links: 0, link_coverage: 1, timeline_coverage: 1, most_connected: [],
|
||||
embed_coverage_score: 35, link_density_score: 25, timeline_coverage_score: 15, no_orphans_score: 15, no_dead_links_score: 10,
|
||||
}),
|
||||
getConfig: async () => null,
|
||||
executeRaw: async () => [],
|
||||
};
|
||||
|
||||
const FIXTURES: Fixture[] = [
|
||||
{
|
||||
name: 'healthy brain → no findings',
|
||||
engine: { ...HEALTHY_STATS },
|
||||
expect: [],
|
||||
forbid: ['low_embed_coverage', 'orphan_pages', 'embeddings_disabled'],
|
||||
},
|
||||
{
|
||||
name: 'degraded recall: low embed coverage + orphans',
|
||||
engine: {
|
||||
...HEALTHY_STATS,
|
||||
getHealth: async () => ({
|
||||
page_count: 500, embed_coverage: 0.3, stale_pages: 0, orphan_pages: 12, missing_embeddings: 350,
|
||||
brain_score: 40, dead_links: 2, link_coverage: 0.2, timeline_coverage: 0.2, most_connected: [],
|
||||
embed_coverage_score: 10, link_density_score: 5, timeline_coverage_score: 3, no_orphans_score: 2, no_dead_links_score: 8,
|
||||
}),
|
||||
},
|
||||
expect: ['low_embed_coverage', 'orphan_pages', 'dead_links'],
|
||||
},
|
||||
{
|
||||
name: 'stalled worker + stale source',
|
||||
engine: {
|
||||
...HEALTHY_STATS,
|
||||
executeRaw: (async (sql: string) => {
|
||||
if (/minion_jobs/.test(sql)) return [{ name: 'embed-backfill', n: 3 }];
|
||||
if (/sources/.test(sql)) return [{ id: 'wiki' }];
|
||||
return [];
|
||||
}) as AdvisorContext['engine']['executeRaw'],
|
||||
},
|
||||
expect: ['stalled_job:embed-backfill', 'stale_sync:wiki'],
|
||||
},
|
||||
{
|
||||
name: 'setup smell: embeddings disabled',
|
||||
engine: { ...HEALTHY_STATS },
|
||||
config: { embedding_disabled: true } as AdvisorContext['config'],
|
||||
expect: ['embeddings_disabled'],
|
||||
},
|
||||
];
|
||||
|
||||
function ctxFor(fx: Fixture): AdvisorContext {
|
||||
return {
|
||||
engine: fx.engine as AdvisorContext['engine'],
|
||||
config: (fx.config ?? {}) as AdvisorContext['config'],
|
||||
version: '0.43.0.0',
|
||||
workspace: null,
|
||||
skillsDir: null,
|
||||
now: new Date('2026-06-16T00:00:00Z'),
|
||||
remote: fx.remote ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
describe('E4 advisor ranking-precision eval', () => {
|
||||
test('every seeded defect is surfaced and severities rank correctly', async () => {
|
||||
let expectedTotal = 0;
|
||||
let surfaced = 0;
|
||||
const SEV: Record<string, number> = { critical: 0, warn: 1, info: 2 };
|
||||
|
||||
for (const fx of FIXTURES) {
|
||||
const report = await runAdvisor(ctxFor(fx));
|
||||
const ids = report.findings.map((f) => f.id);
|
||||
|
||||
for (const want of fx.expect) {
|
||||
expectedTotal++;
|
||||
if (ids.some((id) => id === want || id.startsWith(want))) surfaced++;
|
||||
else console.error(` [${fx.name}] MISSED expected finding: ${want}`);
|
||||
}
|
||||
for (const no of fx.forbid ?? []) {
|
||||
expect(ids.find((id) => id === no || id.startsWith(no))).toBeUndefined();
|
||||
}
|
||||
// Ranking monotonic: severities never increase down the list.
|
||||
const sevs = report.findings.map((f) => SEV[f.severity]!);
|
||||
for (let i = 1; i < sevs.length; i++) expect(sevs[i]!).toBeGreaterThanOrEqual(sevs[i - 1]!);
|
||||
}
|
||||
|
||||
const precision = expectedTotal === 0 ? 1 : surfaced / expectedTotal;
|
||||
console.error(`\nE4 advisor ranking precision: ${surfaced}/${expectedTotal} = ${(precision * 100).toFixed(0)}%`);
|
||||
expect(precision).toBe(1);
|
||||
});
|
||||
|
||||
test('remote advisor drops workspace-dependent findings (A1)', async () => {
|
||||
// A brain-pack uninstalled finding is workspace_dependent; over MCP it must
|
||||
// not appear even though the collector would fire locally.
|
||||
const report = await runAdvisor(ctxFor({ ...FIXTURES[0]!, remote: true }));
|
||||
expect(report.findings.every((f) => !f.workspace_dependent)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -612,3 +612,89 @@ describeBoth('Engine parity — relationalFanout', () => {
|
||||
expect(shape(pg)).toEqual(shape(pglite));
|
||||
});
|
||||
});
|
||||
|
||||
// #2200 — federated sourceIds[] on the secondary-fetch reads must behave
|
||||
// identically on both engines (a drift would mean a federated MCP client sees
|
||||
// different tags/links/timeline after `gbrain migrate --to supabase`).
|
||||
async function seedFederated(eng: BrainEngine) {
|
||||
await eng.executeRaw(`INSERT INTO sources (id, name, local_path) VALUES ('beta', 'beta', '/tmp/beta') ON CONFLICT (id) DO NOTHING`);
|
||||
await eng.putPage('fed/doc', { type: 'note', title: 'Fed doc', compiled_truth: 'b', timeline: '' }, { sourceId: 'beta' });
|
||||
await eng.putPage('fed/target', { type: 'note', title: 'Fed target', compiled_truth: 'b', timeline: '' }, { sourceId: 'beta' });
|
||||
await eng.putPage('fed/doc', { type: 'note', title: 'Default decoy', compiled_truth: 'd', timeline: '' }, { sourceId: 'default' });
|
||||
await eng.putPage('fed/outside', { type: 'note', title: 'Outside', compiled_truth: 'd', timeline: '' }, { sourceId: 'default' });
|
||||
await eng.addTag('fed/doc', 'beta-tag', { sourceId: 'beta' });
|
||||
await eng.addTag('fed/doc', 'default-decoy-tag', { sourceId: 'default' });
|
||||
await eng.addLink('fed/doc', 'fed/target', 'in', 'cites', 'markdown', undefined, undefined, { fromSourceId: 'beta', toSourceId: 'beta' });
|
||||
await eng.addLink('fed/doc', 'fed/outside', 'leak', 'cites', 'markdown', undefined, undefined, { fromSourceId: 'beta', toSourceId: 'default' });
|
||||
await eng.addLink('fed/target', 'fed/doc', 'inback', 'cites', 'markdown', undefined, undefined, { fromSourceId: 'beta', toSourceId: 'beta' });
|
||||
await eng.addLink('fed/outside', 'fed/doc', 'leakback', 'cites', 'markdown', undefined, undefined, { fromSourceId: 'default', toSourceId: 'beta' });
|
||||
// F1: in-grant edge authored by an out-of-grant origin — origin_slug must null out.
|
||||
await eng.addLink('fed/doc', 'fed/target', 'originleak', 'mentions', 'frontmatter', 'fed/outside', 'related', { fromSourceId: 'beta', toSourceId: 'beta', originSourceId: 'default' });
|
||||
await eng.addTimelineEntry('fed/doc', { date: '2026-02-02', source: 't', summary: 'fed event', detail: 'd' }, { sourceId: 'beta' });
|
||||
// Second-dated entry so the after/before fragment paths (D5A Postgres refactor) are exercised.
|
||||
await eng.addTimelineEntry('fed/doc', { date: '2026-08-08', source: 't', summary: 'late event', detail: 'd' }, { sourceId: 'beta' });
|
||||
}
|
||||
|
||||
describeBoth('Engine parity — federated sourceIds[] secondary reads (#2200)', () => {
|
||||
let pgEngine: BrainEngine;
|
||||
let pgliteEngine: PGLiteEngine;
|
||||
const grant = { sourceIds: ['beta'] };
|
||||
|
||||
beforeAll(async () => {
|
||||
pgEngine = await setupDB();
|
||||
await seedFederated(pgEngine);
|
||||
pgliteEngine = new PGLiteEngine();
|
||||
await pgliteEngine.connect({});
|
||||
await pgliteEngine.initSchema();
|
||||
await seedFederated(pgliteEngine);
|
||||
}, 90_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await pgliteEngine.disconnect();
|
||||
await teardownDB();
|
||||
}, 30_000);
|
||||
|
||||
test('getTags identical under sourceIds[]', async () => {
|
||||
const pg = (await pgEngine.getTags('fed/doc', grant)).sort();
|
||||
const pglite = (await pgliteEngine.getTags('fed/doc', grant)).sort();
|
||||
expect(pg).toEqual(pglite);
|
||||
expect(pg).toEqual(['beta-tag']); // default decoy excluded
|
||||
});
|
||||
|
||||
test('getLinks identical under sourceIds[] (all three endpoints scoped)', async () => {
|
||||
const pg = (await pgEngine.getLinks('fed/doc', grant)).map(l => l.to_slug).sort();
|
||||
const pglite = (await pgliteEngine.getLinks('fed/doc', grant)).map(l => l.to_slug).sort();
|
||||
expect(pg).toEqual(pglite);
|
||||
expect([...new Set(pg)]).toEqual(['fed/target']); // far-endpoint 'fed/outside' excluded
|
||||
// F1: origin_slug nulled identically on both engines when origin is out-of-grant.
|
||||
const pgOrigins = (await pgEngine.getLinks('fed/doc', grant)).map(l => l.origin_slug ?? null);
|
||||
const pgliteOrigins = (await pgliteEngine.getLinks('fed/doc', grant)).map(l => l.origin_slug ?? null);
|
||||
expect(pgOrigins.sort()).toEqual(pgliteOrigins.sort());
|
||||
expect(pgOrigins).not.toContain('fed/outside');
|
||||
});
|
||||
|
||||
test('getBacklinks identical under sourceIds[] (both endpoints scoped)', async () => {
|
||||
const pg = (await pgEngine.getBacklinks('fed/doc', grant)).map(l => l.from_slug).sort();
|
||||
const pglite = (await pgliteEngine.getBacklinks('fed/doc', grant)).map(l => l.from_slug).sort();
|
||||
expect(pg).toEqual(pglite);
|
||||
expect(pg).toEqual(['fed/target']);
|
||||
});
|
||||
|
||||
test('getTimeline identical under sourceIds[]', async () => {
|
||||
const pg = (await pgEngine.getTimeline('fed/doc', grant)).map(e => e.summary).sort();
|
||||
const pglite = (await pgliteEngine.getTimeline('fed/doc', grant)).map(e => e.summary).sort();
|
||||
expect(pg).toEqual(pglite);
|
||||
expect(pg).toEqual(['fed event', 'late event']);
|
||||
});
|
||||
|
||||
// Pins the D5A Postgres fragment refactor: after/before/both window paths must
|
||||
// match PGLite under a federated grant (the 8-branch→composed-WHERE rewrite).
|
||||
test('getTimeline date-window fragments identical across engines (D5A regression guard)', async () => {
|
||||
for (const win of [{ after: '2026-05-01' }, { before: '2026-05-01' }, { after: '2026-01-01', before: '2026-12-31' }]) {
|
||||
const opts = { ...grant, ...win };
|
||||
const pg = (await pgEngine.getTimeline('fed/doc', opts)).map(e => e.summary).sort();
|
||||
const pglite = (await pgliteEngine.getTimeline('fed/doc', opts)).map(e => e.summary).sort();
|
||||
expect(pg).toEqual(pglite);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,18 @@
|
||||
* any page by slug. The fuzzy path was already scoped (#1436); this closes the
|
||||
* exact path by (a) routing it through sourceScopeOpts and (b) teaching
|
||||
* engine.getPage to honor a `sourceIds[]` array (both engines).
|
||||
*
|
||||
* #2200 — the SAME class on the secondary-fetch read ops. get_page resolves the
|
||||
* page under the grant but fetched tags against 'default' (wrong source for a
|
||||
* non-default page); get_tags / get_links / get_backlinks / get_timeline didn't
|
||||
* route the federated grant to the engine at all (functionality gap + a
|
||||
* cross-source fallback/foreign-endpoint leak). These tests cover:
|
||||
* - get_page tags resolved against the concrete page's source
|
||||
* - the 4 standalone ops honoring a federated grant
|
||||
* - isolation (out-of-grant slug → empty, never the 'default' page's data)
|
||||
* - the foreign-endpoint link leak (D4A: both endpoints scoped)
|
||||
* - same-slug-across-sources union (D3A)
|
||||
* - engine getTags/getLinks/getBacklinks/getTimeline sourceIds[] precedence
|
||||
*/
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
@@ -16,6 +28,10 @@ import { operations, OperationError, type OperationContext } from '../src/core/o
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
const get_page = operations.find(o => o.name === 'get_page')!;
|
||||
const get_tags = operations.find(o => o.name === 'get_tags')!;
|
||||
const get_links = operations.find(o => o.name === 'get_links')!;
|
||||
const get_backlinks = operations.find(o => o.name === 'get_backlinks')!;
|
||||
const get_timeline = operations.find(o => o.name === 'get_timeline')!;
|
||||
|
||||
function ctxOf(overrides: Partial<OperationContext> = {}): OperationContext {
|
||||
return {
|
||||
@@ -50,8 +66,56 @@ beforeEach(async () => {
|
||||
await engine.putPage('shared/alpha-doc', {
|
||||
type: 'note', title: 'Alpha doc', compiled_truth: 'alpha content', frontmatter: {},
|
||||
}, { sourceId: 'alpha' });
|
||||
|
||||
// --- #2200 secondary-fetch fixtures ---
|
||||
// beta page's own tags.
|
||||
await engine.addTag('secret/beta-doc', 'beta-confidential', { sourceId: 'beta' });
|
||||
await engine.addTag('secret/beta-doc', 'beta-tag', { sourceId: 'beta' });
|
||||
// A same-slug page in 'default' with DIFFERENT tags — the cross-source bleed
|
||||
// guard. A federated read scoped to [alpha,beta] must NEVER surface these.
|
||||
await engine.putPage('secret/beta-doc', {
|
||||
type: 'note', title: 'Default decoy', compiled_truth: 'default content', frontmatter: {},
|
||||
}, { sourceId: 'default' });
|
||||
await engine.addTag('secret/beta-doc', 'default-secret-tag', { sourceId: 'default' });
|
||||
// Link endpoints. NOTE (Codex #7): addLink defaults BOTH endpoints to 'default'
|
||||
// unless given {fromSourceId,toSourceId} — pass them or the beta edges won't seed.
|
||||
await engine.putPage('secret/beta-target', {
|
||||
type: 'note', title: 'Beta target', compiled_truth: 'beta target', frontmatter: {},
|
||||
}, { sourceId: 'beta' });
|
||||
await engine.putPage('default/only-doc', {
|
||||
type: 'note', title: 'Default only', compiled_truth: 'default only', frontmatter: {},
|
||||
}, { sourceId: 'default' });
|
||||
// In-grant outgoing link beta→beta (must show for [alpha,beta]).
|
||||
await engine.addLink('secret/beta-doc', 'secret/beta-target', 'in-grant ctx', 'cites', 'markdown', undefined, undefined, { fromSourceId: 'beta', toSourceId: 'beta' });
|
||||
// Far-endpoint-leak outgoing link beta→default (must NOT show for [alpha,beta] — D4A).
|
||||
await engine.addLink('secret/beta-doc', 'default/only-doc', 'LEAK ctx', 'cites', 'markdown', undefined, undefined, { fromSourceId: 'beta', toSourceId: 'default' });
|
||||
// In-grant backlink beta→beta (referrer beta-target → secret/beta-doc).
|
||||
await engine.addLink('secret/beta-target', 'secret/beta-doc', 'in-grant back', 'cites', 'markdown', undefined, undefined, { fromSourceId: 'beta', toSourceId: 'beta' });
|
||||
// Far-endpoint-leak backlink: referrer in 'default' → must NOT show for [alpha,beta].
|
||||
await engine.addLink('default/only-doc', 'secret/beta-doc', 'LEAK back', 'cites', 'markdown', undefined, undefined, { fromSourceId: 'default', toSourceId: 'beta' });
|
||||
// Origin-leak guard (F1): both endpoints in-grant (beta→beta) but the AUTHORING
|
||||
// (origin) page is out-of-grant ('default'). origin_slug must NOT leak that slug.
|
||||
await engine.addLink('secret/beta-doc', 'secret/beta-target', 'origin-leak ctx', 'mentions', 'frontmatter', 'default/only-doc', 'related', { fromSourceId: 'beta', toSourceId: 'beta', originSourceId: 'default' });
|
||||
// Timeline entry on the beta page.
|
||||
await engine.addTimelineEntry('secret/beta-doc', {
|
||||
date: '2026-01-01', source: 'test', summary: 'beta event', detail: 'beta detail',
|
||||
}, { sourceId: 'beta' });
|
||||
// D3A union: same slug in BOTH alpha and beta with distinct tags.
|
||||
await engine.putPage('shared/dup', {
|
||||
type: 'note', title: 'Dup alpha', compiled_truth: 'a', frontmatter: {},
|
||||
}, { sourceId: 'alpha' });
|
||||
await engine.addTag('shared/dup', 'alpha-only', { sourceId: 'alpha' });
|
||||
await engine.putPage('shared/dup', {
|
||||
type: 'note', title: 'Dup beta', compiled_truth: 'b', frontmatter: {},
|
||||
}, { sourceId: 'beta' });
|
||||
await engine.addTag('shared/dup', 'beta-only', { sourceId: 'beta' });
|
||||
});
|
||||
|
||||
function remoteCtx(allowedSources: string[]): OperationContext {
|
||||
// Federated remote client: no scalar ctx.sourceId, grant via allowedSources.
|
||||
return ctxOf({ remote: true, sourceId: undefined, auth: { token: 't', clientId: 'c', scopes: [], allowedSources } as any });
|
||||
}
|
||||
|
||||
describe('engine.getPage honors sourceIds[] (federated grant)', () => {
|
||||
test('sourceIds[] matching the page returns it', async () => {
|
||||
const page = await engine.getPage('secret/beta-doc', { sourceIds: ['alpha', 'beta'] });
|
||||
@@ -89,3 +153,170 @@ describe('get_page handler closes the cross-source exact-read leak', () => {
|
||||
expect(page.title).toBe('Alpha doc');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #2200 — secondary-fetch read ops honor the federated grant
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('#2200 get_page resolves tags against the concrete page source', () => {
|
||||
test('federated [alpha,beta] read of the beta page returns BETA tags, not default decoy', async () => {
|
||||
const page: any = await get_page.handler(remoteCtx(['alpha', 'beta']), { slug: 'secret/beta-doc' });
|
||||
expect(page.title).toBe('Beta secret');
|
||||
expect(page.tags.sort()).toEqual(['beta-confidential', 'beta-tag']);
|
||||
expect(page.tags).not.toContain('default-secret-tag');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2200 get_tags honors the federated grant', () => {
|
||||
test('[alpha,beta] returns the beta page tags', async () => {
|
||||
const tags = await get_tags.handler(remoteCtx(['alpha', 'beta']), { slug: 'secret/beta-doc' });
|
||||
expect((tags as string[]).sort()).toEqual(['beta-confidential', 'beta-tag']);
|
||||
});
|
||||
|
||||
test('[alpha] only → empty, never the default decoy tags (isolation)', async () => {
|
||||
const tags = await get_tags.handler(remoteCtx(['alpha']), { slug: 'secret/beta-doc' });
|
||||
expect(tags).toEqual([]);
|
||||
});
|
||||
|
||||
test('D3A same-slug-across-sources → union of tags', async () => {
|
||||
const tags = await get_tags.handler(remoteCtx(['alpha', 'beta']), { slug: 'shared/dup' });
|
||||
expect((tags as string[]).sort()).toEqual(['alpha-only', 'beta-only']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2200 get_links honors the grant and scopes BOTH endpoints (D4A)', () => {
|
||||
test('[alpha,beta] returns the in-grant beta→beta link', async () => {
|
||||
const links = (await get_links.handler(remoteCtx(['alpha', 'beta']), { slug: 'secret/beta-doc' })) as any[];
|
||||
expect(links.map(l => l.to_slug)).toContain('secret/beta-target');
|
||||
});
|
||||
|
||||
test('[alpha,beta] does NOT leak the beta→default far-endpoint link', async () => {
|
||||
const links = (await get_links.handler(remoteCtx(['alpha', 'beta']), { slug: 'secret/beta-doc' })) as any[];
|
||||
expect(links.map(l => l.to_slug)).not.toContain('default/only-doc');
|
||||
expect(links.map(l => l.context)).not.toContain('LEAK ctx');
|
||||
});
|
||||
|
||||
test('[alpha] only → no beta links (isolation)', async () => {
|
||||
const links = (await get_links.handler(remoteCtx(['alpha']), { slug: 'secret/beta-doc' })) as any[];
|
||||
expect(links).toEqual([]);
|
||||
});
|
||||
|
||||
test('F1: in-grant link authored by an out-of-grant origin does NOT leak origin_slug', async () => {
|
||||
const links = (await get_links.handler(remoteCtx(['alpha', 'beta']), { slug: 'secret/beta-doc' })) as any[];
|
||||
const originLeakLink = links.find(l => l.link_type === 'mentions' && l.to_slug === 'secret/beta-target');
|
||||
expect(originLeakLink).toBeDefined();
|
||||
// origin page 'default/only-doc' is out of the [alpha,beta] grant → origin_slug nulled.
|
||||
expect(originLeakLink.origin_slug ?? null).toBeNull();
|
||||
expect(links.map(l => l.origin_slug)).not.toContain('default/only-doc');
|
||||
});
|
||||
|
||||
test('D1: UNTRUSTED remote with a scalar source scope is promoted to all-endpoint scoping (no far-endpoint leak)', async () => {
|
||||
// legacy/pre-federated token: remote, scalar ctx.sourceId='beta', NO allowedSources.
|
||||
const ctx = ctxOf({ remote: true, sourceId: 'beta', auth: undefined });
|
||||
const links = (await get_links.handler(ctx, { slug: 'secret/beta-doc' })) as any[];
|
||||
expect(links.map(l => l.to_slug)).toContain('secret/beta-target');
|
||||
expect(links.map(l => l.to_slug)).not.toContain('default/only-doc'); // far endpoint out of scope
|
||||
expect(links.map(l => l.origin_slug)).not.toContain('default/only-doc'); // origin too
|
||||
});
|
||||
|
||||
test('D1: TRUSTED local CLI (remote=false) with a scalar scope keeps the cross-source view', async () => {
|
||||
// reconcileLinks / validators depend on this — local CLI sees cross-source links.
|
||||
const ctx = ctxOf({ remote: false, sourceId: 'beta', auth: undefined });
|
||||
const links = (await get_links.handler(ctx, { slug: 'secret/beta-doc' })) as any[];
|
||||
expect(links.map(l => l.to_slug)).toContain('default/only-doc'); // cross-source visible for trusted local
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2200 get_backlinks honors the grant and scopes BOTH endpoints (D4A)', () => {
|
||||
test('[alpha,beta] returns the in-grant beta→beta backlink', async () => {
|
||||
const back = (await get_backlinks.handler(remoteCtx(['alpha', 'beta']), { slug: 'secret/beta-doc' })) as any[];
|
||||
expect(back.map(l => l.from_slug)).toContain('secret/beta-target');
|
||||
});
|
||||
|
||||
test('[alpha,beta] does NOT leak the default→beta far-referrer backlink', async () => {
|
||||
const back = (await get_backlinks.handler(remoteCtx(['alpha', 'beta']), { slug: 'secret/beta-doc' })) as any[];
|
||||
expect(back.map(l => l.from_slug)).not.toContain('default/only-doc');
|
||||
expect(back.map(l => l.context)).not.toContain('LEAK back');
|
||||
});
|
||||
|
||||
test('[alpha] only → no beta backlinks (isolation)', async () => {
|
||||
const back = (await get_backlinks.handler(remoteCtx(['alpha']), { slug: 'secret/beta-doc' })) as any[];
|
||||
expect(back).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2200 get_timeline honors the federated grant', () => {
|
||||
test('[alpha,beta] returns the beta timeline entry', async () => {
|
||||
const tl = (await get_timeline.handler(remoteCtx(['alpha', 'beta']), { slug: 'secret/beta-doc' })) as any[];
|
||||
expect(tl.map(e => e.summary)).toContain('beta event');
|
||||
});
|
||||
|
||||
test('[alpha] only → empty (isolation)', async () => {
|
||||
const tl = (await get_timeline.handler(remoteCtx(['alpha']), { slug: 'secret/beta-doc' })) as any[];
|
||||
expect(tl).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2200 engine secondary-fetch methods honor sourceIds[]', () => {
|
||||
test('getTags: sourceIds[] matching → returns; excluding → empty; union on collision', async () => {
|
||||
expect((await engine.getTags('secret/beta-doc', { sourceIds: ['alpha', 'beta'] })).sort())
|
||||
.toEqual(['beta-confidential', 'beta-tag']);
|
||||
expect(await engine.getTags('secret/beta-doc', { sourceIds: ['alpha'] })).toEqual([]);
|
||||
expect((await engine.getTags('shared/dup', { sourceIds: ['alpha', 'beta'] })).sort())
|
||||
.toEqual(['alpha-only', 'beta-only']);
|
||||
});
|
||||
|
||||
test('getTags: sourceIds[] takes precedence over scalar sourceId', async () => {
|
||||
// scalar says default (decoy), array says beta — array wins.
|
||||
const tags = await engine.getTags('secret/beta-doc', { sourceId: 'default', sourceIds: ['beta'] });
|
||||
expect(tags.sort()).toEqual(['beta-confidential', 'beta-tag']);
|
||||
});
|
||||
|
||||
test('engine contract: empty sourceIds[] is NOT a federated scope — falls through to scalar (length>0 guard)', async () => {
|
||||
// sourceScopeOpts never emits [] (it treats an empty grant as no-scope), but
|
||||
// the engine methods are public: the `sourceIds && length > 0` guard must NOT
|
||||
// treat [] as "match nothing" (ANY('{}')) NOR widen scope. It falls to scalar,
|
||||
// here defaulting to 'default'. Pins the guard so a future `>= 0` regression fails.
|
||||
// getTags scalar fallback defaults to 'default' → the decoy tag.
|
||||
expect(await engine.getTags('secret/beta-doc', { sourceIds: [] })).toEqual(['default-secret-tag']);
|
||||
// getTimeline's scalar branch with no sourceId is UNSCOPED (cross-source,
|
||||
// pre-v0.31.8 semantics) — so [] yields the cross-source view, here the beta
|
||||
// entry. The point: [] is treated as "no federated scope", never as ANY('{}').
|
||||
const tl = await engine.getTimeline('secret/beta-doc', { sourceIds: [] });
|
||||
expect(tl.map(e => e.summary)).toEqual(['beta event']);
|
||||
});
|
||||
|
||||
test('getLinks: sourceIds[] scopes both endpoints (no far-endpoint leak); precedence over scalar', async () => {
|
||||
const links = await engine.getLinks('secret/beta-doc', { sourceIds: ['alpha', 'beta'] });
|
||||
expect(links.map(l => l.to_slug)).toContain('secret/beta-target');
|
||||
expect(links.map(l => l.to_slug)).not.toContain('default/only-doc');
|
||||
expect(await engine.getLinks('secret/beta-doc', { sourceIds: ['alpha'] })).toEqual([]);
|
||||
// array beats scalar: scalar 'default' would surface the leak link; array ['beta'] must not.
|
||||
const prec = await engine.getLinks('secret/beta-doc', { sourceId: 'default', sourceIds: ['beta'] });
|
||||
expect([...new Set(prec.map(l => l.to_slug))]).toEqual(['secret/beta-target']); // only in-grant targets (multiple link_types collapse)
|
||||
expect(prec.map(l => l.to_slug)).not.toContain('default/only-doc');
|
||||
});
|
||||
|
||||
test('getBacklinks: sourceIds[] scopes both endpoints; precedence over scalar', async () => {
|
||||
const back = await engine.getBacklinks('secret/beta-doc', { sourceIds: ['alpha', 'beta'] });
|
||||
expect(back.map(l => l.from_slug)).toContain('secret/beta-target');
|
||||
expect(back.map(l => l.from_slug)).not.toContain('default/only-doc');
|
||||
const prec = await engine.getBacklinks('secret/beta-doc', { sourceId: 'default', sourceIds: ['beta'] });
|
||||
expect(prec.map(l => l.from_slug)).toEqual(['secret/beta-target']);
|
||||
});
|
||||
|
||||
test('getTimeline: sourceIds[] matching → returns; excluding → empty; precedence over scalar', async () => {
|
||||
const hit = await engine.getTimeline('secret/beta-doc', { sourceIds: ['alpha', 'beta'] });
|
||||
expect(hit.map(e => e.summary)).toContain('beta event');
|
||||
expect(await engine.getTimeline('secret/beta-doc', { sourceIds: ['alpha'] })).toEqual([]);
|
||||
// array beats scalar: scalar 'default' page has no timeline entry; array ['beta'] returns the beta event.
|
||||
const prec = await engine.getTimeline('secret/beta-doc', { sourceId: 'default', sourceIds: ['beta'] });
|
||||
expect(prec.map(e => e.summary)).toContain('beta event');
|
||||
});
|
||||
|
||||
test('getTimeline: date-window filters still correct after the fragment refactor (D5A regression guard)', async () => {
|
||||
await engine.addTimelineEntry('secret/beta-doc', { date: '2026-06-01', source: 'test', summary: 'june event', detail: 'd' }, { sourceId: 'beta' });
|
||||
const windowed = await engine.getTimeline('secret/beta-doc', { sourceIds: ['beta'], after: '2026-03-01', before: '2026-12-31' });
|
||||
expect(windowed.map(e => e.summary)).toEqual(['june event']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,7 +113,7 @@ describe('buildAdvisory — partial-install path', () => {
|
||||
expect(advisory).toContain('book-mirror');
|
||||
expect(advisory).not.toContain('article-enrichment');
|
||||
expect(advisory).not.toContain('strategic-reading');
|
||||
expect(advisory).toContain('gbrain skillpack install book-mirror');
|
||||
expect(advisory).toContain('gbrain skillpack scaffold book-mirror');
|
||||
});
|
||||
|
||||
it('uses --all command when ALL recommended are missing (fresh workspace)', () => {
|
||||
@@ -125,7 +125,7 @@ describe('buildAdvisory — partial-install path', () => {
|
||||
targetSkillsDir: skillsDir,
|
||||
});
|
||||
expect(advisory).not.toBeNull();
|
||||
expect(advisory).toContain('gbrain skillpack install --all');
|
||||
expect(advisory).toContain('gbrain skillpack scaffold --all');
|
||||
expect(advisory).toContain('book-mirror');
|
||||
expect(advisory).toContain('article-enrichment');
|
||||
expect(advisory).toContain('strategic-reading');
|
||||
@@ -167,7 +167,7 @@ describe('buildAdvisory — agent-readable framing', () => {
|
||||
})!;
|
||||
expect(advisory).toContain('ACTION FOR THE AGENT');
|
||||
expect(advisory).toContain('Ask the user');
|
||||
expect(advisory).toContain('Do NOT install without asking');
|
||||
expect(advisory).toContain('Do NOT scaffold without asking');
|
||||
expect(advisory).toContain('user owns this decision');
|
||||
});
|
||||
|
||||
@@ -213,8 +213,8 @@ describe('buildAdvisory — agent-readable framing', () => {
|
||||
targetWorkspace: workspace,
|
||||
targetSkillsDir: skillsDir,
|
||||
})!;
|
||||
expect(advisory).toContain('gbrain skillpack install --all');
|
||||
expect(advisory).toContain('gbrain skillpack list');
|
||||
expect(advisory).toContain('gbrain skillpack scaffold --all');
|
||||
expect(advisory).toContain('ACTION FOR THE AGENT');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -228,6 +228,6 @@ describe('buildAdvisory — no workspace detected', () => {
|
||||
});
|
||||
expect(advisory).not.toBeNull();
|
||||
// No workspace -> empty installed set -> all recommended treated as missing.
|
||||
expect(advisory).toContain('gbrain skillpack install --all');
|
||||
expect(advisory).toContain('gbrain skillpack scaffold --all');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Tests for src/core/skillpack/brain-resident-locate.ts — the source-scoped
|
||||
* discovery behind the list_brain_skillpack MCP tool and get_skill source_id.
|
||||
*/
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import { runInitBrainPack } from '../src/core/skillpack/init-brain-pack.ts';
|
||||
import {
|
||||
loadResidentPacksForServer,
|
||||
getResidentSkillDetail,
|
||||
deriveBrainId,
|
||||
} from '../src/core/skillpack/brain-resident-locate.ts';
|
||||
import type { OperationContext } from '../src/core/operations.ts';
|
||||
|
||||
let root: string;
|
||||
let packDir: string;
|
||||
let plainDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'gbrain-brl-'));
|
||||
packDir = join(root, 'pack-source');
|
||||
plainDir = join(root, 'plain-source');
|
||||
runInitBrainPack({ targetDir: packDir, name: 'deal-brain', firstSkillSlug: 'diligence', schemaPack: 'gbrain-base' });
|
||||
// plainDir: a source with no skillpack at all
|
||||
mkdtempSync(join(tmpdir(), 'noop-')); // touch tmp to avoid lints; plainDir stays empty
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Minimal fake engine: only executeRaw (sources SELECT) + getConfig are used. */
|
||||
function fakeEngine(sources: Array<{ id: string; local_path: string | null; config?: Record<string, unknown> }>, cfg: Record<string, string> = {}) {
|
||||
return {
|
||||
executeRaw: async () =>
|
||||
sources.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.id,
|
||||
local_path: s.local_path,
|
||||
last_commit: null,
|
||||
last_sync_at: null,
|
||||
config: s.config ?? {},
|
||||
created_at: new Date(),
|
||||
archived: false,
|
||||
})),
|
||||
getConfig: async (k: string) => cfg[k] ?? null,
|
||||
} as unknown as OperationContext['engine'];
|
||||
}
|
||||
|
||||
function ctxFor(engine: OperationContext['engine'], over: Partial<OperationContext> = {}): OperationContext {
|
||||
return {
|
||||
engine,
|
||||
config: {} as OperationContext['config'],
|
||||
logger: { info() {}, warn() {}, error() {}, debug() {} } as unknown as OperationContext['logger'],
|
||||
dryRun: false,
|
||||
remote: true,
|
||||
...over,
|
||||
} as OperationContext;
|
||||
}
|
||||
|
||||
describe('loadResidentPacksForServer', () => {
|
||||
test('finds brain_resident packs, skips plain sources', async () => {
|
||||
const engine = fakeEngine([
|
||||
{ id: 'default', local_path: packDir, config: { remote_url: 'https://github.com/u/deal-brain.git' } },
|
||||
{ id: 'plain', local_path: plainDir },
|
||||
]);
|
||||
const result = await loadResidentPacksForServer(ctxFor(engine, { remote: false }));
|
||||
expect(result.packs).toHaveLength(1);
|
||||
const p = result.packs[0]!;
|
||||
expect(p.name).toBe('deal-brain');
|
||||
expect(p.source_id).toBe('default');
|
||||
expect(p.skills.map((s) => s.slug)).toEqual(['diligence']);
|
||||
// git remote → scaffold_spec is the git spec, NEVER a server FS path (#6)
|
||||
expect(p.scaffold_spec).toBe('https://github.com/u/deal-brain.git');
|
||||
expect(p.scaffold_spec).not.toContain(packDir);
|
||||
});
|
||||
|
||||
test('computes schema_pack_match server-side against per-source config (#7)', async () => {
|
||||
const engine = fakeEngine(
|
||||
[{ id: 'default', local_path: packDir }],
|
||||
{ 'schema_pack.source.default': 'gbrain-other' },
|
||||
);
|
||||
const result = await loadResidentPacksForServer(ctxFor(engine, { remote: false }));
|
||||
expect(result.packs[0]!.active_schema_pack).toBe('gbrain-other');
|
||||
expect(result.packs[0]!.schema_pack_match).toBe(false);
|
||||
});
|
||||
|
||||
test('local-only source (no git remote) → scaffold_spec null', async () => {
|
||||
const engine = fakeEngine([{ id: 'default', local_path: packDir }]);
|
||||
const result = await loadResidentPacksForServer(ctxFor(engine, { remote: false }));
|
||||
expect(result.packs[0]!.scaffold_spec).toBeNull();
|
||||
});
|
||||
|
||||
test('source scoping: out-of-scope source is excluded', async () => {
|
||||
const engine = fakeEngine([{ id: 'default', local_path: packDir }]);
|
||||
// caller scoped to a different source id → no packs
|
||||
const ctx = ctxFor(engine, { auth: { allowedSources: ['other'] } as OperationContext['auth'] });
|
||||
const result = await loadResidentPacksForServer(ctx);
|
||||
expect(result.packs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getResidentSkillDetail', () => {
|
||||
test('returns the SKILL.md body for an in-pack slug', async () => {
|
||||
const engine = fakeEngine([{ id: 'default', local_path: packDir }]);
|
||||
const detail = await getResidentSkillDetail(ctxFor(engine, { remote: false }), 'default', 'diligence');
|
||||
expect(detail.pack_name).toBe('deal-brain');
|
||||
expect(detail.slug).toBe('diligence');
|
||||
expect(detail.body).toContain('# diligence');
|
||||
});
|
||||
|
||||
test('throws not_found for an unknown slug', async () => {
|
||||
const engine = fakeEngine([{ id: 'default', local_path: packDir }]);
|
||||
await expect(
|
||||
getResidentSkillDetail(ctxFor(engine, { remote: false }), 'default', 'nope'),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveBrainId', () => {
|
||||
test('prefers git remote; falls back to path hash', () => {
|
||||
expect(deriveBrainId('https://x/y.git', '/p')).toBe('git:https://x/y.git');
|
||||
expect(deriveBrainId(null, '/p')).toMatch(/^path:[0-9a-f]{16}$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Tests for src/core/skillpack/init-brain-pack.ts (the brain-resident pack
|
||||
* scaffolder) and src/core/skillpack/brain-pack-lint.ts (E6 version-skew lint).
|
||||
*/
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import {
|
||||
runInitBrainPack,
|
||||
InitBrainPackError,
|
||||
} from '../src/core/skillpack/init-brain-pack.ts';
|
||||
import { validateSkillpackManifest } from '../src/core/skillpack/manifest-v1.ts';
|
||||
import { lintBrainPackTools } from '../src/core/skillpack/brain-pack-lint.ts';
|
||||
import { VERSION } from '../src/version.ts';
|
||||
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'gbrain-brainpack-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('runInitBrainPack', () => {
|
||||
test('writes a brain_resident manifest pinned to exact VERSION', () => {
|
||||
const result = runInitBrainPack({ targetDir: dir, name: 'deal-brain', schemaPack: 'gbrain-base' });
|
||||
expect(result.manifest.brain_resident).toBe(true);
|
||||
expect(result.manifest.schema_pack).toBe('gbrain-base');
|
||||
expect(result.manifest.gbrain_min_version).toBe(VERSION); // exact, not major.minor.0
|
||||
// round-trips through the validator
|
||||
const raw = JSON.parse(readFileSync(join(dir, 'skillpack.json'), 'utf-8'));
|
||||
expect(() => validateSkillpackManifest(raw)).not.toThrow();
|
||||
});
|
||||
|
||||
test('README has all 5 stable machine-parseable headings', () => {
|
||||
runInitBrainPack({ targetDir: dir, name: 'deal-brain' });
|
||||
const readme = readFileSync(join(dir, 'README.md'), 'utf-8');
|
||||
expect(readme).toContain('## 1. What this brain is');
|
||||
expect(readme).toContain('## 2. Skills in this pack');
|
||||
expect(readme).toContain('## 3. Install');
|
||||
expect(readme).toContain('## 4. Conventions this brain expects');
|
||||
expect(readme).toContain('## 5. Version compatibility');
|
||||
});
|
||||
|
||||
test('refuses to overwrite existing files', () => {
|
||||
writeFileSync(join(dir, 'skillpack.json'), '{"keep":"me"}');
|
||||
const result = runInitBrainPack({ targetDir: dir, name: 'deal-brain' });
|
||||
expect(result.filesSkippedExisting).toContain(join(dir, 'skillpack.json'));
|
||||
expect(readFileSync(join(dir, 'skillpack.json'), 'utf-8')).toBe('{"keep":"me"}');
|
||||
});
|
||||
|
||||
test('dry-run writes nothing', () => {
|
||||
const result = runInitBrainPack({ targetDir: dir, name: 'deal-brain', dryRun: true });
|
||||
expect(result.filesWritten.length).toBeGreaterThan(0);
|
||||
expect(existsSync(join(dir, 'skillpack.json'))).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects non-kebab name', () => {
|
||||
expect(() => runInitBrainPack({ targetDir: dir, name: 'Deal Brain' })).toThrow(InitBrainPackError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lintBrainPackTools (E6)', () => {
|
||||
test('flags a declared tool the serving op set does not have', () => {
|
||||
runInitBrainPack({ targetDir: dir, name: 'deal-brain', firstSkillSlug: 'diligence' });
|
||||
// Inject a tools: frontmatter with one known + one unknown op.
|
||||
const skillMd = join(dir, 'skills/diligence/SKILL.md');
|
||||
writeFileSync(
|
||||
skillMd,
|
||||
['---', 'name: diligence', 'description: x', 'tools: [search, totally_made_up_op]', '---', '', '# diligence', ''].join('\n'),
|
||||
);
|
||||
const result = lintBrainPackTools(dir, new Set(['search', 'put_page']));
|
||||
expect(result.unknownTools).toEqual([{ skill: 'skills/diligence', tool: 'totally_made_up_op' }]);
|
||||
});
|
||||
|
||||
test('no findings when all tools known (fresh pack has no tools:)', () => {
|
||||
runInitBrainPack({ targetDir: dir, name: 'deal-brain', firstSkillSlug: 'diligence' });
|
||||
const result = lintBrainPackTools(dir, new Set(['search', 'put_page']));
|
||||
expect(result.unknownTools).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -390,3 +390,51 @@ describe('bundleManifestFromSkillpack — adapter', () => {
|
||||
expect(bundle.shared_deps).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('brain-resident fields (issue #2180)', () => {
|
||||
test('accepts brain_resident:true + schema_pack', () => {
|
||||
const result = validateSkillpackManifest({
|
||||
...VALID_MANIFEST,
|
||||
brain_resident: true,
|
||||
schema_pack: 'gbrain-base',
|
||||
});
|
||||
expect(result.brain_resident).toBe(true);
|
||||
expect(result.schema_pack).toBe('gbrain-base');
|
||||
});
|
||||
|
||||
test('both absent still valid (backward compatible)', () => {
|
||||
const result = validateSkillpackManifest(VALID_MANIFEST);
|
||||
expect(result.brain_resident).toBeUndefined();
|
||||
expect(result.schema_pack).toBeUndefined();
|
||||
});
|
||||
|
||||
test('rejects non-boolean brain_resident', () => {
|
||||
try {
|
||||
validateSkillpackManifest({ ...VALID_MANIFEST, brain_resident: 'yes' });
|
||||
throw new Error('should have thrown');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(SkillpackManifestError);
|
||||
expect((err as SkillpackManifestError).code).toBe('manifest_invalid_field');
|
||||
expect((err as SkillpackManifestError).detail?.field).toBe('brain_resident');
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects non-kebab schema_pack', () => {
|
||||
try {
|
||||
validateSkillpackManifest({ ...VALID_MANIFEST, schema_pack: 'Not Kebab' });
|
||||
throw new Error('should have thrown');
|
||||
} catch (err) {
|
||||
expect((err as SkillpackManifestError).code).toBe('manifest_invalid_field');
|
||||
expect((err as SkillpackManifestError).detail?.field).toBe('schema_pack');
|
||||
}
|
||||
});
|
||||
|
||||
test('forward-compat: unknown extra fields are tolerated', () => {
|
||||
const result = validateSkillpackManifest({
|
||||
...VALID_MANIFEST,
|
||||
brain_resident: true,
|
||||
some_future_field: { nested: 1 },
|
||||
});
|
||||
expect(result.brain_resident).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Tests for src/core/skillpack/nag-state.ts — the brain-pack install nag policy.
|
||||
*/
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, existsSync, writeFileSync, readFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import {
|
||||
loadNagState,
|
||||
saveNagState,
|
||||
findNag,
|
||||
upsertNag,
|
||||
decideNagAction,
|
||||
recordNagDisplay,
|
||||
DEFAULT_NAG_CEILING,
|
||||
type NagEntry,
|
||||
} from '../src/core/skillpack/nag-state.ts';
|
||||
|
||||
let dir: string;
|
||||
let statePath: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'gbrain-nag-'));
|
||||
statePath = join(dir, 'skillpack-nag-state.json');
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const KEY = { brain_id: 'git:repo', source_id: 'host', pack_name: 'deal-brain' };
|
||||
|
||||
function entry(over: Partial<NagEntry> = {}): NagEntry {
|
||||
return {
|
||||
...KEY,
|
||||
pack_version: '0.1.0',
|
||||
prompted_at: '2026-06-16T00:00:00Z',
|
||||
declined_count: 1,
|
||||
suppressed: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('atomic load/save', () => {
|
||||
test('round-trips and starts empty on missing file', () => {
|
||||
expect(loadNagState({ statePath }).entries).toEqual([]);
|
||||
saveNagState(upsertNag(loadNagState({ statePath }), entry()), { statePath });
|
||||
expect(existsSync(statePath)).toBe(true);
|
||||
expect(loadNagState({ statePath }).entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('fail-open on corrupt JSON (returns empty, never throws)', () => {
|
||||
writeFileSync(statePath, '{ not json');
|
||||
expect(loadNagState({ statePath }).entries).toEqual([]);
|
||||
});
|
||||
|
||||
test('fail-open on unknown schema version', () => {
|
||||
writeFileSync(statePath, JSON.stringify({ schema_version: 'future-v9', entries: [entry()] }));
|
||||
expect(loadNagState({ statePath }).entries).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findNag / upsertNag keyed by (brain_id, source_id, pack_name)', () => {
|
||||
test('upsert replaces the matching triple only', () => {
|
||||
let s = upsertNag(loadNagState({ statePath }), entry({ declined_count: 1 }));
|
||||
s = upsertNag(s, entry({ declined_count: 2 }));
|
||||
expect(s.entries).toHaveLength(1);
|
||||
expect(findNag(s, KEY)?.declined_count).toBe(2);
|
||||
s = upsertNag(s, entry({ source_id: 'other', declined_count: 5 }));
|
||||
expect(s.entries).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decideNagAction policy matrix', () => {
|
||||
test('no entry → full/first', () => {
|
||||
expect(decideNagAction(undefined, { pack_version: '0.1.0' })).toEqual({
|
||||
show: true,
|
||||
level: 'full',
|
||||
reason: 'first',
|
||||
});
|
||||
});
|
||||
|
||||
test('--no-skill-nag → hidden', () => {
|
||||
expect(decideNagAction(entry(), { pack_version: '0.1.0', noNagFlag: true }).show).toBe(false);
|
||||
});
|
||||
|
||||
test('same version, under ceiling → short reminder', () => {
|
||||
expect(decideNagAction(entry({ declined_count: 1 }), { pack_version: '0.1.0' })).toEqual({
|
||||
show: true,
|
||||
level: 'short',
|
||||
reason: 'reminder',
|
||||
});
|
||||
});
|
||||
|
||||
test('ceiling reached → hidden', () => {
|
||||
expect(
|
||||
decideNagAction(entry({ declined_count: DEFAULT_NAG_CEILING }), { pack_version: '0.1.0' }).show,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('suppressed flag → hidden', () => {
|
||||
expect(decideNagAction(entry({ suppressed: true }), { pack_version: '0.1.0' }).show).toBe(false);
|
||||
});
|
||||
|
||||
test('version bump while uninstalled → full/version_bump even past ceiling', () => {
|
||||
expect(
|
||||
decideNagAction(entry({ declined_count: 9, suppressed: true }), { pack_version: '0.2.0' }),
|
||||
).toEqual({ show: true, level: 'full', reason: 'version_bump' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordNagDisplay', () => {
|
||||
test('increments on same version and suppresses at ceiling', () => {
|
||||
const e1 = recordNagDisplay(undefined, KEY, { pack_version: '0.1.0', nowIso: 'now' });
|
||||
expect(e1.declined_count).toBe(1);
|
||||
const e2 = recordNagDisplay(e1, KEY, { pack_version: '0.1.0', nowIso: 'now' });
|
||||
const e3 = recordNagDisplay(e2, KEY, { pack_version: '0.1.0', nowIso: 'now' });
|
||||
expect(e3.declined_count).toBe(DEFAULT_NAG_CEILING);
|
||||
expect(e3.suppressed).toBe(true);
|
||||
});
|
||||
|
||||
test('version bump resets count to 1 and clears suppression', () => {
|
||||
const bumped = recordNagDisplay(
|
||||
entry({ declined_count: 5, suppressed: true }),
|
||||
KEY,
|
||||
{ pack_version: '0.2.0', nowIso: 'now' },
|
||||
);
|
||||
expect(bumped.declined_count).toBe(1);
|
||||
expect(bumped.suppressed).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user