Compare commits

..
Author SHA1 Message Date
Garry TanandClaude Fable 5 edf7fc6b5a fix(skills): green CI for vault skills — resolver health, fixture lint, privacy literal, llms bundle
- RESOLVER.md rows now round-trip against SKILL.md frontmatter triggers
  ('put this setup change in the vault'; replace 'safe/free gbrain index'
  with 'import my vault to gbrain').
- Rewrite both routing-eval.jsonl fixture sets: positives embed a trigger
  phrase in natural context (fixture linter rejects verbatim tautologies),
  declare ambiguous_with for legitimate co-fires (capture, idea-ingest),
  and negatives no longer collide with skillpack-harvest/setup triggers.
  check:resolver --strict is clean (0 errors, 0 warnings).
- Privacy: the vault-capture regression test sources the banned fork-name
  pattern from harvest-lint's DEFAULT_PRIVATE_PATTERNS instead of the
  literal, so scripts/check-privacy.sh passes.
- Regenerate llms-full.txt after the RESOLVER.md edit (build-llms
  freshness gate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 11:03:38 -07:00
b7f42f099a feat(skills): skillify vault skills — obsidian-gbrain-safe-index + skill-vault-capture-policy (takeover of #2685)
Salvages the intended 8-file change from PR #2685 (two vault skills with
routing-eval fixtures + unit/E2E tests, registered in RESOLVER.md,
manifest.json, and openclaw.plugin.json) without the ~670 unrelated
third-party skill dumps that contaminated that branch.

Also scrubs private user/agent names from skill-vault-capture-policy per
the repo privacy rule, with a regression test.

Takeover of #2685.

Co-authored-by: MortdAdam <MortdAdam@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:27:59 -07:00
23 changed files with 539 additions and 284 deletions
+3 -1
View File
@@ -1411,7 +1411,8 @@ This is the dispatcher. Skills are the implementation. **Read the skill file bef
| "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` |
| "save this learning to the vault", "capture this skill in Obsidian", "record this workflow in my notes", "put this setup change in the vault" | `skills/skill-vault-capture-policy/SKILL.md` |
| "Skillify this", "is this a skill?", "make this proper", "add tests and evals for this" | `skills/skillify/SKILL.md` |
| "Compress my resolver", "AGENTS.md too large", "RESOLVER.md too big", "functional area dispatcher", "shrink routing table" | `skills/functional-area-resolver/SKILL.md` |
| "Is gbrain healthy?", morning health check, skillpack-check | `skills/skillpack-check/SKILL.md` |
| "harvest this skill into gbrain", "publish this skill to gbrain", "lift this skill upstream", "share this skill with other gbrain clients", "promote my skill to gbrain" | `skills/skillpack-harvest/SKILL.md` |
@@ -1429,6 +1430,7 @@ This is the dispatcher. Skills are the implementation. **Read the skill file bef
| "Set up GBrain", first boot | `skills/setup/SKILL.md` |
| "Now what?", "fill my brain", "cold start", "bootstrap", "import my data", "what should I import first" | `skills/cold-start/SKILL.md` |
| "Migrate from Obsidian/Notion/Logseq" | `skills/migrate/SKILL.md` |
| "Connect Obsidian to gbrain", "import my vault to gbrain", "sync vault and gbrain", "embed gbrain after vault update", "is gbrain synced with my vault" | `skills/obsidian-gbrain-safe-index/SKILL.md` |
| Brain health check, maintenance run | `skills/maintain/SKILL.md` |
| "Extract links", "build link graph", "populate timeline" | `skills/maintain/SKILL.md` (extraction sections) |
| "Run dream", "process today's session", "synthesize my conversations", "consolidate yesterday's conversations", "what patterns did you see", "did the dream cycle run" | `skills/maintain/SKILL.md` (dream cycle section) |
+3 -2
View File
@@ -39,8 +39,8 @@
"skills/briefing",
"skills/citation-fixer",
"skills/concept-synthesis",
"skills/cross-modal-review",
"skills/cron-scheduler",
"skills/cross-modal-review",
"skills/daily-task-manager",
"skills/daily-task-prep",
"skills/data-research",
@@ -54,10 +54,11 @@
"skills/media-ingest",
"skills/meeting-ingestion",
"skills/minion-orchestrator",
"skills/obsidian-gbrain-safe-index",
"skills/perplexity-research",
"skills/query",
"skills/reports",
"skills/repo-architecture",
"skills/reports",
"skills/signal-detector",
"skills/skill-creator",
"skills/skillify",
+3 -1
View File
@@ -60,7 +60,8 @@ This is the dispatcher. Skills are the implementation. **Read the skill file bef
| "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` |
| "save this learning to the vault", "capture this skill in Obsidian", "record this workflow in my notes", "put this setup change in the vault" | `skills/skill-vault-capture-policy/SKILL.md` |
| "Skillify this", "is this a skill?", "make this proper", "add tests and evals for this" | `skills/skillify/SKILL.md` |
| "Compress my resolver", "AGENTS.md too large", "RESOLVER.md too big", "functional area dispatcher", "shrink routing table" | `skills/functional-area-resolver/SKILL.md` |
| "Is gbrain healthy?", morning health check, skillpack-check | `skills/skillpack-check/SKILL.md` |
| "harvest this skill into gbrain", "publish this skill to gbrain", "lift this skill upstream", "share this skill with other gbrain clients", "promote my skill to gbrain" | `skills/skillpack-harvest/SKILL.md` |
@@ -78,6 +79,7 @@ This is the dispatcher. Skills are the implementation. **Read the skill file bef
| "Set up GBrain", first boot | `skills/setup/SKILL.md` |
| "Now what?", "fill my brain", "cold start", "bootstrap", "import my data", "what should I import first" | `skills/cold-start/SKILL.md` |
| "Migrate from Obsidian/Notion/Logseq" | `skills/migrate/SKILL.md` |
| "Connect Obsidian to gbrain", "import my vault to gbrain", "sync vault and gbrain", "embed gbrain after vault update", "is gbrain synced with my vault" | `skills/obsidian-gbrain-safe-index/SKILL.md` |
| Brain health check, maintenance run | `skills/maintain/SKILL.md` |
| "Extract links", "build link graph", "populate timeline" | `skills/maintain/SKILL.md` (extraction sections) |
| "Run dream", "process today's session", "synthesize my conversations", "consolidate yesterday's conversations", "what patterns did you see", "did the dream cycle run" | `skills/maintain/SKILL.md` (dream cycle section) |
+11 -1
View File
@@ -2,7 +2,7 @@
"name": "gbrain",
"version": "0.32.3.0",
"conformance_version": "1.0.0",
"description": "Personal knowledge brain with hybrid RAG search \u2014 GStack mod for agent platforms",
"description": "Personal knowledge brain with hybrid RAG search GStack mod for agent platforms",
"skills": [
{
"name": "ingest",
@@ -34,6 +34,11 @@
"path": "migrate/SKILL.md",
"description": "Universal migration from Obsidian, Notion, Logseq, markdown, CSV, JSON, Roam"
},
{
"name": "obsidian-gbrain-safe-index",
"path": "obsidian-gbrain-safe-index/SKILL.md",
"description": "Connect and sync an Obsidian-style Markdown vault with gbrain using a cost-controlled import-first workflow, explicit embedding gates, and durable skill/workflow capture."
},
{
"name": "setup",
"path": "setup/SKILL.md",
@@ -263,6 +268,11 @@
"name": "skill-optimizer",
"path": "skill-optimizer/SKILL.md",
"description": "Self-evolving skill optimization via gbrain skillopt — SkillOpt-paper-grounded text-space optimizer with validation gating (median-of-3 + epsilon=0.05), bundled-skill safety, bootstrap review sentinel, per-skill DB lock, and atomic versioned writes."
},
{
"name": "skill-vault-capture-policy",
"path": "skill-vault-capture-policy/SKILL.md",
"description": "Capture durable operational learnings, new agent skills, and environment/setup changes into the Obsidian vault instead of transient chat memory."
}
],
"dependencies": {
+137
View File
@@ -0,0 +1,137 @@
---
name: obsidian-gbrain-safe-index
version: 1.0.0
description: |
Connect, maintain, and sync an Obsidian-style Markdown vault with gbrain while preserving a cost-controlled workflow: the vault remains the source of truth, gbrain is the searchable/embedded index, durable skills/workflows are captured into the vault, and paid embedding runs only after explicit approval.
triggers:
- "connect Obsidian to gbrain"
- "import my vault to gbrain"
- "sync vault and gbrain"
- "capture this skill in my vault"
- "embed gbrain after vault update"
- "is gbrain synced with my vault"
tools:
- terminal
- read_file
- search_files
- write_file
- patch
mutating: true
---
# Obsidian → gbrain Safe Index and Capture
## Contract
This skill guarantees:
- Treats the user's Obsidian-style Markdown vault as the source of truth before gbrain indexing.
- Keeps gbrain in conservative mode unless the user explicitly approves a more expensive mode.
- Imports vault changes with `--no-embed` first, then embeds only after explicit approval for the specific paid action.
- Captures durable new skills, workflows, and environment learnings into the vault instead of leaving them only in chat memory.
- Verifies every sync with concrete `gbrain stats`, search mode, and, when embeddings run, exact embedded chunk counts.
## Phases
1. **Resolve the vault path.**
- Prefer an existing environment variable such as `OBSIDIAN_VAULT_PATH` or `WIKI_PATH`.
- If no path is configured, search likely note directories and ask the user before writing.
- Verify the directory exists and contains markdown files or an `.obsidian` directory.
2. **Read vault operating rules before writing.**
- If the vault has `SCHEMA.md`, `index.md`, `log.md`, `AGENTS.md`, or similar operating files, read them before ingest/query/major edit.
- Respect immutable source folders such as `raw/` when the vault declares them.
- Use the vault's native link convention, usually Obsidian `[[wikilinks]]`, for durable relationships.
3. **MECE/capture decision.**
- If new knowledge belongs on an existing page, update that page.
- If it is a distinct recurring workflow or operational policy, create a small meta or concept page following the vault schema.
- Update the vault index/catalog for every new page when the vault maintains one.
- Append a log entry for meaningful vault updates when the vault maintains a log.
4. **Safe gbrain import path.**
- Pre-check source directory; do not import a nonexistent path.
- Run `gbrain config set search.mode conservative` before/after risky reinit steps.
- Run `gbrain import "$OBSIDIAN_VAULT_PATH" --no-embed`.
- Run `gbrain extract links --source fs --dir "$OBSIDIAN_VAULT_PATH"` when wikilinks changed materially.
5. **Paid embedding gate.**
- Do not run `gbrain embed --stale` unless the user explicitly asks or a prior instruction clearly approved this exact paid action.
- Before embedding, verify provider readiness with `gbrain providers test --model <provider:model>`.
- Confirm the configured embedding dimensions match the local schema.
- After embedding, verify `gbrain stats` and record exact `Pages`, `Chunks`, `Embedded`, and `Links` counts.
6. **Final verification and vault echo.**
- Run `gbrain stats` and `gbrain search modes`.
- If vault files changed, re-import with `--no-embed`; if embedding was approved, embed stale chunks afterward.
- Report what changed, what was free/local, what used API billing, and what remains pending.
## Output Format
Use a compact status table:
| Item | Status |
|---|---|
| Vault path | `/path` |
| Vault updated | yes/no + files |
| gbrain mode | conservative/balanced/tokenmax |
| Import | `--no-embed` completed / skipped / failed |
| Pages/chunks | exact counts from `gbrain stats` |
| Embeddings | exact count; note whether this run used API billing |
| Links | exact count |
| Background jobs | none / list exact jobs |
Then include:
- **Safe next step:** free/local action.
- **Paid next step:** embedding/LLM action, if any, with explicit approval requirement.
## Anti-Patterns
- Creating a duplicate skill/page when an existing Obsidian, gbrain, or vault-ingest skill already covers the workflow.
- Running `gbrain embed --stale`, `gbrain dream`, `gbrain autopilot --install`, `gbrain onboard --auto`, or `tokenmax` without explicit cost approval.
- Importing a nonexistent or wrong directory and treating a zero-page import as success.
- Forgetting to update the vault index/catalog and log after creating or materially updating vault pages.
- Recording API keys, tokens, or raw secrets in the vault or final response.
## Tools Used
- `read_file` — read vault schema/index/log and target notes.
- `search_files` — find existing vault pages and avoid duplicates.
- `write_file` / `patch` — create or update vault pages.
- `terminal` — run `gbrain`, `git`, and environment checks with secret values redacted.
## Safe Commands
```bash
export PATH="$HOME/.bun/bin:$PATH"
gbrain config set search.mode conservative
gbrain import "$OBSIDIAN_VAULT_PATH" --no-embed
gbrain extract links --source fs --dir "$OBSIDIAN_VAULT_PATH"
gbrain stats
gbrain search modes
```
## Paid / Approval-Gated Commands
```bash
gbrain providers test --model <provider:model>
gbrain embed --stale
gbrain dream
gbrain autopilot --install
gbrain onboard --auto --max-usd 5
gbrain config set search.mode tokenmax
```
## Verification Checklist
- [ ] Vault path exists and is the intended source.
- [ ] Vault operating files were read before edits when present.
- [ ] Existing pages/skills were searched to avoid duplicates.
- [ ] New/updated vault pages follow the vault schema and link convention.
- [ ] Vault index/catalog updated for new pages when present.
- [ ] Vault log appended for meaningful actions when present.
- [ ] `gbrain import ... --no-embed` completed.
- [ ] `gbrain stats` recorded pages/chunks/embeddings/links.
- [ ] `gbrain search modes` confirms conservative mode unless a different mode was explicitly approved.
- [ ] No paid/background commands ran without approval.
@@ -0,0 +1,11 @@
// Routing eval fixtures for skills/obsidian-gbrain-safe-index.
// Positive cases: intents embed a trigger phrase in natural surrounding context
// (never verbatim-identical to a trigger — the fixture linter rejects tautologies).
{"intent": "help me connect Obsidian to gbrain for my notes", "expected_skill": "obsidian-gbrain-safe-index"}
{"intent": "import my vault to gbrain but skip embeddings for now", "expected_skill": "obsidian-gbrain-safe-index"}
{"intent": "please sync vault and gbrain after I edit notes", "expected_skill": "obsidian-gbrain-safe-index"}
{"intent": "run embed gbrain after vault update tonight", "expected_skill": "obsidian-gbrain-safe-index"}
{"intent": "hey is gbrain synced with my vault right now", "expected_skill": "obsidian-gbrain-safe-index"}
// Negative cases: related but owned by other skills. Assert NO route to this skill.
{"intent": "migrate my notes from Notion to gbrain", "expected_skill": null}
{"intent": "what is on my calendar tomorrow", "expected_skill": null}
+100
View File
@@ -0,0 +1,100 @@
---
name: skill-vault-capture-policy
version: 1.0.0
description: |
Use when the user wants a durable operational learning, new agent skill, or
important environment/setup change to be captured into the Obsidian vault
instead of left only in transient chat memory. Covers the capture rule,
preferred page patterns, and index/log update obligations.
triggers:
- "save this learning to the vault"
- "capture this skill in Obsidian"
- "record this workflow in my notes"
- "put this setup change in the vault"
- "should we add this to the knowledge base"
tools:
- read_file
- search_files
- write_file
- patch
mutating: true
---
# Skill Vault Capture Policy
## Contract
This skill guarantees:
- Durable operational learnings, newly adopted skills, and important environment/setup changes are captured into the Obsidian vault rather than left only in chat memory.
- An existing page is updated when the knowledge clearly belongs there; a new page is created only when the topic is distinct and likely to recur.
- Every new vault page is added to `index.md`.
- Every meaningful create/update appends a dated entry to `log.md`.
- Small linked pages are preferred over one giant running note.
## Phases
1. **Classify the learning.**
- New gbrain operating rule, cost control, or embedding/provider change.
- New agent-fork / harness / coding-tool integration fact.
- New Obsidian vault workflow or structure decision.
- New recurring agent skill that changes how the agent should operate here.
2. **Avoid duplicates.**
- Search the vault for an existing page that already owns the topic.
- If found, update it with a new section or dated note rather than creating a near-duplicate.
3. **Create when distinct.**
- Place new pages under the vault schema: `_meta/` for operating notes, `concepts/` for workflows, `entities/` for tools/people.
- Use YAML frontmatter and at least two `[[wikilinks]]` unless it is a short seed page.
4. **Update navigation.**
- Add the page to `index.md` under the correct type heading.
- Append a `## [YYYY-MM-DD] create|update | subject` entry to `log.md`.
5. **Report the capture.**
- State which files changed and whether `index.md` / `log.md` were updated.
## Output Format
Use a short status block:
| Item | Status |
|---|---|
| Learning classified | type |
| Page created/updated | path |
| index.md updated | yes/no |
| log.md updated | yes/no |
## Anti-Patterns
- Leaving durable learnings only in chat memory.
- Creating a near-duplicate page instead of updating the existing one.
- Forgetting to update `index.md` and `log.md`.
- Writing one giant running note instead of small linked pages.
- Recording secrets, API keys, or raw credentials in the vault.
## Tools Used
- `read_file` — read `SCHEMA.md`, `index.md`, `log.md`, and target pages.
- `search_files` — find existing pages to avoid duplicates.
- `write_file` / `patch` — create or update vault pages and navigation.
## Safe Commands
```bash
# inspect vault navigation before writing
read SCHEMA.md index.md log.md
# create or update a page, then refresh catalog/log
# index.md: add [[page-slug]] under the matching type heading
# log.md: append ## [YYYY-MM-DD] create|update | subject
```
## Verification Checklist
- [ ] Vault schema/read files were checked before writing.
- [ ] Existing pages were searched to avoid duplicates.
- [ ] New/updated page has frontmatter and wikilinks.
- [ ] `index.md` updated for new pages.
- [ ] `log.md` appended for meaningful actions.
- [ ] No secrets or raw credentials were written.
@@ -0,0 +1,10 @@
// Routing eval fixtures for skills/skill-vault-capture-policy.
// Positive cases: intents embed a trigger phrase in natural surrounding context
// (never verbatim-identical to a trigger — the fixture linter rejects tautologies).
{"intent": "please save this learning to the vault so we keep it", "expected_skill": "skill-vault-capture-policy", "ambiguous_with": ["idea-ingest"]}
{"intent": "we should capture this skill in Obsidian for reuse", "expected_skill": "skill-vault-capture-policy", "ambiguous_with": ["capture"]}
{"intent": "can you record this workflow in my notes for next time", "expected_skill": "skill-vault-capture-policy"}
{"intent": "put this setup change in the vault before we forget", "expected_skill": "skill-vault-capture-policy"}
// Negative cases: related but owned by other skills or out of scope.
{"intent": "connect my Obsidian vault to gbrain", "expected_skill": null}
{"intent": "what is on my calendar tomorrow", "expected_skill": null}
+5 -5
View File
@@ -19,7 +19,7 @@
*/
import type { BrainEngine } from '../core/engine.ts';
import { resolveCalibrationHolder, runPhaseCalibrationProfile } from '../core/cycle/calibration-profile.ts';
import { runPhaseCalibrationProfile } from '../core/cycle/calibration-profile.ts';
import { sourceScopeOpts, type OperationContext } from '../core/operations.ts';
import type { GBrainConfig } from '../core/config.ts';
import { GBrainError } from '../core/types.ts';
@@ -167,7 +167,7 @@ export async function runCalibration(
config: GBrainConfig,
): Promise<void> {
const { opts } = parseArgs(args);
const holder = await resolveCalibrationHolder(engine, opts.holder);
const holder = opts.holder ?? 'garry';
// Resolve --source / GBRAIN_SOURCE / .gbrain-source so the (now reachable, #2035)
// calibration command targets the right source in a multi-source brain instead
// of always reading `default`. No signal → 'default' (prior behavior).
@@ -253,14 +253,14 @@ export async function getCalibrationProfileOp(
ctx: OperationContext,
params: { holder?: string },
): Promise<CalibrationProfileRow | null> {
if (params.holder !== undefined && (typeof params.holder !== 'string' || params.holder.length === 0)) {
const holder = params.holder ?? 'garry';
if (typeof holder !== 'string' || holder.length === 0) {
throw new GBrainError(
'INVALID_HOLDER',
'get_calibration_profile.holder must be a non-empty string',
'pass holder="<slug>" or omit to default to the calibration.user_holder config (then "garry")',
'pass holder="<slug>" or omit to default to "garry"',
);
}
const holder = await resolveCalibrationHolder(ctx.engine, params.holder);
const scope = sourceScopeOpts(ctx);
return getLatestProfile(ctx.engine, { holder, ...scope });
}
-4
View File
@@ -928,10 +928,6 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [
// Emotional weight (v0.29)
'emotional_weight.high_tags',
'emotional_weight.user_holder',
// Calibration holder (#1726): persistent default for the nightly
// calibration_profile phase + `gbrain calibration`, symmetric with
// emotional_weight.user_holder. Falls back to 'garry' when unset.
'calibration.user_holder',
// Cycle phase config
'cycle.grade_takes.write_gstack_learnings',
// Content sanity (v0.41)
+2 -22
View File
@@ -96,7 +96,7 @@ export type PatternStatementsGenerator = (input: {
export type BiasTagsGenerator = (patterns: string[]) => Promise<string[]>;
export interface CalibrationProfileOpts extends BasePhaseOpts {
/** Holder to generate the profile for. Default: `calibration.user_holder` config, then 'garry'. */
/** Holder to generate the profile for. Default 'garry'. */
holder?: string;
/** Inject the patterns generator (tests). */
patternsGenerator?: PatternStatementsGenerator;
@@ -194,26 +194,6 @@ export function parseBiasTagsOutput(raw: string): string[] {
.slice(0, 4);
}
/**
* #1726: resolve the calibration holder. Explicit param wins, then the
* persistent `calibration.user_holder` config key (symmetric with
* emotional_weight.user_holder), then the legacy 'garry' default. Fail-open:
* a missing config table / mock engine without getConfig falls through.
*/
export async function resolveCalibrationHolder(
engine: BrainEngine,
explicit?: string,
): Promise<string> {
if (explicit) return explicit;
try {
const configured = await engine.getConfig('calibration.user_holder');
if (configured && configured.trim().length > 0) return configured.trim();
} catch {
// Config unavailable — use the legacy default.
}
return 'garry';
}
/** Pick the "loudest" pattern slot for the template fallback. */
function pickFallbackSlots(scorecard: TakesScorecard): PatternStatementSlots {
if (!scorecard || scorecard.resolved === 0) {
@@ -247,7 +227,7 @@ class CalibrationProfilePhase extends BaseCyclePhase {
_ctx: OperationContext,
opts: CalibrationProfileOpts,
): Promise<{ summary: string; details: Record<string, unknown>; status?: PhaseStatus }> {
const holder = await resolveCalibrationHolder(engine, opts.holder);
const holder = opts.holder ?? 'garry';
const promptVersion = opts.promptVersion ?? CALIBRATION_PROFILE_PROMPT_VERSION;
const modelId = opts.model ?? TIER_DEFAULTS.reasoning;
const gradeCompletion = opts.gradeCompletion ?? 1.0;
+2 -5
View File
@@ -68,14 +68,11 @@ export async function extractTimelineFromMeetings(
// 1. Fetch all meeting pages (one round-trip).
const sourceFilter = opts.sourceIdFilter ? `AND source_id = $1` : '';
const meetingParams = opts.sourceIdFilter ? [opts.sourceIdFilter] : [];
// #2109: gbrain-base-v2's unify-types catch-all retypes meeting pages to
// `note` with frontmatter.legacy_type = 'meeting'. Match both spellings so
// the extractor keeps working on migrated (v2) brains, not just v1 ones.
const meetings = await engine.executeRaw<MeetingRow>(
`SELECT slug, source_id, title, effective_date, updated_at,
compiled_truth, COALESCE(timeline, '') AS timeline
FROM pages
WHERE (type = 'meeting' OR frontmatter ->> 'legacy_type' = 'meeting')
WHERE type = 'meeting'
AND deleted_at IS NULL
${sourceFilter}
ORDER BY effective_date DESC NULLS LAST, slug`,
@@ -97,7 +94,7 @@ export async function extractTimelineFromMeetings(
JOIN pages pf ON pf.id = l.from_page_id
JOIN pages pt ON pt.id = l.to_page_id
WHERE l.link_type = 'attended'
AND (pf.type = 'meeting' OR pf.frontmatter ->> 'legacy_type' = 'meeting')
AND pf.type = 'meeting'
AND pf.deleted_at IS NULL
AND pt.deleted_at IS NULL`,
);
+1 -4
View File
@@ -4562,10 +4562,7 @@ const list_schema_packs: Operation = {
const { existsSync, readdirSync } = await import('node:fs');
const { join } = await import('node:path');
const { gbrainPath } = await import('./config.ts');
// #1726: derive from the locator's registry instead of a hand-copied
// subset (which had frozen at 2 of 7 bundled packs).
const { BUNDLED_PACKS } = await import('./schema-pack/load-active.ts');
const bundled = [...BUNDLED_PACKS];
const bundled = ['gbrain-base', 'gbrain-recommended'];
const installedDir = gbrainPath('schema-packs');
const installed: string[] = [];
if (existsSync(installedDir)) {
@@ -41,12 +41,6 @@ migration_from:
pack: gbrain-base
version: "1.x"
# #2117 — cycle-phase participation. `phases:` is additive and pack-gated;
# without this key extract_atoms is silently off on v2 brains even though
# onboard + doctor recommend it (v2 declares the `atom` type it writes).
phases:
- extract_atoms
page_types:
- name: person
primitive: entity
@@ -325,10 +319,6 @@ page_types:
extractable: false
expert_routing: false
# #2117 — inference rules ported from gbrain-base v1 so extract-ner keeps
# working on v2 brains (it hard-skips with pack_unavailable when no
# link_type declares an inference.regex). Same ReDoS-guarded sketch
# regexes v1 ships; production matchers in link-extraction.ts still apply.
link_types:
- name: partner_of
inverse: partner_of
@@ -338,24 +328,14 @@ link_types:
- name: discusses
- name: founded
inverse: founded_by
inference:
regex: \b(founded|founder of|co-?founded|started)\b
- name: works_at
inverse: employs
inference:
regex: \b(works? at|employed by|works? for|joined|hired by|ceo of|cto of|cmo of)\b
- name: invested_in
inverse: investor_of
inference:
regex: \b(invested in|backed|seeded|funded|wrote a check)\b
- name: sourced_from
- name: derived_from
- name: supersedes
- name: redirects_to
# NOTE: v1's `attended` inference is page_type-bound to `meeting`, which
# v2 does not declare (lint: link_types_undeclared_page_type). Meeting
# pages retyped by unify-types are matched via frontmatter.legacy_type
# in extract-timeline-from-meetings (#2109) instead.
- name: attended
inverse: attended_by
- name: authored
+22 -26
View File
@@ -91,33 +91,29 @@ export function _resetPackLocatorForTests(): void {
* Returns null when the pack is not found. Callers handle null by
* throwing UnknownPackError with a paste-ready install hint.
*/
// v0.39 T8 — bundled packs registry. gbrain-base + gbrain-recommended
// ship in src/core/schema-pack/base/. Add a new entry here to bundle
// additional canonical packs.
//
// v0.41 T4 — lens packs join the bundle: creator (atoms + concepts +
// extract_atoms/synthesize_concepts phases), investor (theses + bet
// resolution + 3 calibration domains), engineer (gstack-learnings bridge
// + 3 calibration domains), everything (meta-pack stacking all three
// via extends + borrow_from). Each ships as a real YAML at base/<name>.yaml.
//
// #1726: exported so reporting surfaces (list_schema_packs) derive from the
// same list the locator resolves — no more hand-copied 2-of-7 subsets.
export const BUNDLED_PACKS: ReadonlyArray<string> = [
'gbrain-base',
'gbrain-recommended',
'gbrain-creator',
'gbrain-investor',
'gbrain-engineer',
'gbrain-everything',
// v0.42 type-unification: 15-type canonical successor to gbrain-base.
// Ships as install default (Lane E T17) + via gbrain onboard pack
// upgrade flow (the unify-types Minion handler).
'gbrain-base-v2',
];
function defaultPackLocator(name: string): string | null {
if (BUNDLED_PACKS.includes(name)) {
// v0.39 T8 — bundled packs registry. gbrain-base + gbrain-recommended
// ship in src/core/schema-pack/base/. Add a new entry here to bundle
// additional canonical packs.
//
// v0.41 T4 — lens packs join the bundle: creator (atoms + concepts +
// extract_atoms/synthesize_concepts phases), investor (theses + bet
// resolution + 3 calibration domains), engineer (gstack-learnings bridge
// + 3 calibration domains), everything (meta-pack stacking all three
// via extends + borrow_from). Each ships as a real YAML at base/<name>.yaml.
const BUNDLED: ReadonlyArray<string> = [
'gbrain-base',
'gbrain-recommended',
'gbrain-creator',
'gbrain-investor',
'gbrain-engineer',
'gbrain-everything',
// v0.42 type-unification: 15-type canonical successor to gbrain-base.
// Ships as install default (Lane E T17) + via gbrain onboard pack
// upgrade flow (the unify-types Minion handler).
'gbrain-base-v2',
];
if (BUNDLED.includes(name)) {
// Resolve bundled YAML relative to this source file. Works in both
// direct-bun execution and bun --compile binaries.
const here = dirname(fileURLToPath(import.meta.url));
+1 -34
View File
@@ -32,7 +32,7 @@ interface CapturedSql {
params: unknown[];
}
function buildMockEngine(opts: { scorecard: TakesScorecard; config?: Record<string, string> }): {
function buildMockEngine(opts: { scorecard: TakesScorecard }): {
engine: BrainEngine;
captured: CapturedSql[];
} {
@@ -42,9 +42,6 @@ function buildMockEngine(opts: { scorecard: TakesScorecard; config?: Record<stri
async getScorecard() {
return opts.scorecard;
},
async getConfig(key: string) {
return opts.config?.[key] ?? null;
},
async executeRaw<T>(sql: string, params?: unknown[]): Promise<T[]> {
captured.push({ sql, params: params ?? [] });
return [];
@@ -244,36 +241,6 @@ describe('runPhaseCalibrationProfile — phase integration', () => {
expect(insert!.params[11]).toEqual(['over-confident-geography']); // active_bias_tags
});
test('#1726: calibration.user_holder config drives the holder when no explicit opt', async () => {
const { engine, captured } = buildMockEngine({
scorecard: ENOUGH_RESOLVED_SCORECARD,
config: { 'calibration.user_holder': 'alice-example' },
});
await runPhaseCalibrationProfile(buildCtx(engine), {
patternsGenerator: async () => ['You call early-stage tactics well — 8 of 10 held up.'],
biasTagsGenerator: async () => [],
voiceGateJudge: passJudge,
});
const insert = captured.find(c => c.sql.includes('INSERT INTO calibration_profiles'));
expect(insert).toBeDefined();
expect(insert!.params[1]).toBe('alice-example'); // holder from config
});
test('#1726: explicit holder opt wins over calibration.user_holder config', async () => {
const { engine, captured } = buildMockEngine({
scorecard: ENOUGH_RESOLVED_SCORECARD,
config: { 'calibration.user_holder': 'alice-example' },
});
await runPhaseCalibrationProfile(buildCtx(engine), {
holder: 'charlie-example',
patternsGenerator: async () => ['You call early-stage tactics well — 8 of 10 held up.'],
biasTagsGenerator: async () => [],
voiceGateJudge: passJudge,
});
const insert = captured.find(c => c.sql.includes('INSERT INTO calibration_profiles'));
expect(insert!.params[1]).toBe('charlie-example');
});
test('default model is a provider-prefixed id, persisted to model_id (#2451)', async () => {
const { engine, captured } = buildMockEngine({ scorecard: ENOUGH_RESOLVED_SCORECARD });
const patternsGenerator: PatternStatementsGenerator = async () => [
@@ -0,0 +1,61 @@
/**
* E2E smoke for skills/obsidian-gbrain-safe-index.
*
* Verifies the from-trigger-to-side-effect path that skillify requires:
* a real user trigger phrase routes to the skill, the resolver/check
* pipeline treats it as reachable, and the skill file exposes the
* gbrain commands the workflow actually runs.
*
* This stays local-only (no paid embedding, no external API): it asserts
* the documented safe/import path is present and parseable, not that it
* mutates a live brain.
*/
import { describe, expect, it } from 'bun:test';
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
const SKILLS = join(import.meta.dir, '..', '..', 'skills');
const SKILL_MD = join(SKILLS, 'obsidian-gbrain-safe-index', 'SKILL.md');
const RESOLVER = join(SKILLS, 'RESOLVER.md');
const TRIGGER_PHRASES = [
'connect my Obsidian vault to gbrain',
'import my vault to gbrain',
'sync vault and gbrain',
'capture this skill in my vault',
'embed gbrain after vault update',
'is gbrain synced with my vault',
];
describe('obsidian-gbrain-safe-index E2E', () => {
it('resolver maps real trigger phrasings to the skill', () => {
const resolver = readFileSync(RESOLVER, 'utf-8');
expect(resolver).toContain('obsidian-gbrain-safe-index/SKILL.md');
// Each representative phrase shares a token substring with a resolver row.
const rows = resolver
.split('\n')
.filter((l) => l.includes('obsidian-gbrain-safe-index/SKILL.md'))
.join('\n');
for (const phrase of TRIGGER_PHRASES) {
const hit = phrase
.toLowerCase()
.split(/\s+/)
.some((tok) => tok.length > 3 && rows.toLowerCase().includes(tok));
expect(hit, `no resolver token for: ${phrase}`).toBe(true);
}
});
it('skill documents the gbrain import-first safe path', () => {
const body = readFileSync(SKILL_MD, 'utf-8');
expect(body).toContain('gbrain import');
expect(body).toContain('--no-embed');
expect(body).toContain('gbrain config set search.mode conservative');
// Paid gate must be explicit, not a silent default.
expect(body).toContain('gbrain embed --stale');
});
it('skill is reachable from the skill tree', () => {
expect(existsSync(SKILL_MD)).toBe(true);
});
});
@@ -0,0 +1,52 @@
/**
* E2E smoke for skills/skill-vault-capture-policy.
*
* Verifies the from-trigger-to-side-effect path: a real capture request
* routes to the skill, and the skill documents the vault navigation
* obligations (index.md / log.md) that make a capture durable.
*
* Local-only: it asserts documented behavior, not live vault writes.
*/
import { describe, expect, it } from 'bun:test';
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
const SKILLS = join(import.meta.dir, '..', '..', 'skills');
const SKILL_MD = join(SKILLS, 'skill-vault-capture-policy', 'SKILL.md');
const RESOLVER = join(SKILLS, 'RESOLVER.md');
const TRIGGER_PHRASES = [
'save this learning to the vault',
'capture this skill in Obsidian',
'record this workflow in my notes',
'put this setup change in the knowledge base',
];
describe('skill-vault-capture-policy E2E', () => {
it('resolver maps real capture phrasings to the skill', () => {
const resolver = readFileSync(RESOLVER, 'utf-8');
expect(resolver).toContain('skill-vault-capture-policy/SKILL.md');
const rows = resolver
.split('\n')
.filter((l) => l.includes('skill-vault-capture-policy/SKILL.md'))
.join('\n');
for (const phrase of TRIGGER_PHRASES) {
const hit = phrase
.toLowerCase()
.split(/\s+/)
.some((tok) => tok.length > 3 && rows.toLowerCase().includes(tok));
expect(hit, `no resolver token for: ${phrase}`).toBe(true);
}
});
it('skill documents index.md and log.md update obligations', () => {
const body = readFileSync(SKILL_MD, 'utf-8');
expect(body).toContain('index.md');
expect(body).toContain('log.md');
});
it('skill is reachable from the skill tree', () => {
expect(existsSync(SKILL_MD)).toBe(true);
});
});
-106
View File
@@ -1,106 +0,0 @@
// #2109 — gbrain-base-v2's unify-types retypes meeting pages to `note`
// with frontmatter.legacy_type='meeting'. extract-timeline-from-meetings
// used to hardcode type='meeting' and silently scan 0 meetings on migrated
// brains. These tests fail without the legacy_type fallback in both SQL
// sites (meeting walk + attended-edge join).
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import { extractTimelineFromMeetings } from '../src/core/extract-timeline-from-meetings.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
async function insertPage(opts: {
slug: string;
type: string;
title: string;
effectiveDate?: string;
legacyType?: string;
}): Promise<number> {
const frontmatterLiteral = opts.legacyType
? `'{"legacy_type": "${opts.legacyType}"}'::jsonb`
: `'{}'::jsonb`;
const rows = await engine.executeRaw<{ id: number }>(
`INSERT INTO pages (slug, source_id, type, title, compiled_truth, timeline, effective_date, frontmatter)
VALUES ($1, 'default', $2, $3, '', '', $4, ${frontmatterLiteral})
RETURNING id`,
[opts.slug, opts.type, opts.title, opts.effectiveDate ?? null],
);
return rows[0]!.id;
}
describe('extractTimelineFromMeetings — legacy_type fallback (#2109)', () => {
it('scans pages retyped to note with legacy_type=meeting and walks their attended edges', async () => {
const meetingId = await insertPage({
slug: 'meetings/2026-01-05',
type: 'note', // post-unify-types shape on a gbrain-base-v2 brain
legacyType: 'meeting',
title: 'Weekly sync',
effectiveDate: '2026-01-05',
});
const personId = await insertPage({
slug: 'people/alice-example',
type: 'person',
title: 'Alice Example',
});
await engine.executeRaw(
`INSERT INTO links (from_page_id, to_page_id, link_type) VALUES ($1, $2, 'attended')`,
[meetingId, personId],
);
const result = await extractTimelineFromMeetings(engine);
expect(result.meetings_scanned).toBe(1);
expect(result.entries_created).toBe(1);
expect(result.entities_touched).toBe(1);
expect(result.batch_errors).toBe(0);
});
it('still scans pre-unify pages with type=meeting (v1 behavior preserved)', async () => {
const meetingId = await insertPage({
slug: 'meetings/2026-02-01',
type: 'meeting',
title: 'Board prep',
effectiveDate: '2026-02-01',
});
const personId = await insertPage({
slug: 'people/charlie-example',
type: 'person',
title: 'Charlie Example',
});
await engine.executeRaw(
`INSERT INTO links (from_page_id, to_page_id, link_type) VALUES ($1, $2, 'attended')`,
[meetingId, personId],
);
const result = await extractTimelineFromMeetings(engine);
expect(result.meetings_scanned).toBe(1);
expect(result.entries_created).toBe(1);
});
it('does not scan unrelated note pages without legacy_type=meeting', async () => {
await insertPage({
slug: 'notes/random',
type: 'note',
title: 'Random note',
effectiveDate: '2026-03-01',
});
const result = await extractTimelineFromMeetings(engine);
expect(result.meetings_scanned).toBe(0);
expect(result.entries_created).toBe(0);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'bun:test';
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
const SKILL_DIR = join(import.meta.dir, '..', 'skills', 'obsidian-gbrain-safe-index');
const SKILL_MD = join(SKILL_DIR, 'SKILL.md');
const RESOLVER = join(import.meta.dir, '..', 'skills', 'RESOLVER.md');
function parseFrontmatter(raw: string): Record<string, unknown> {
const m = raw.match(/^---\n([\s\S]*?)\n---/);
if (!m) throw new Error('no frontmatter');
const out: Record<string, unknown> = {};
for (const line of m[1].split('\n')) {
const mm = line.match(/^([a-zA-Z_]+):\s*(.*)$/);
if (mm) out[mm[1]] = mm[2].trim();
}
return out;
}
describe('obsidian-gbrain-safe-index skill', () => {
it('has a SKILL.md with required frontmatter', () => {
expect(existsSync(SKILL_MD)).toBe(true);
const fm = parseFrontmatter(readFileSync(SKILL_MD, 'utf-8'));
expect(fm['name']).toBe('obsidian-gbrain-safe-index');
expect(fm['description']).toBeTruthy();
});
it('has the required conformance sections', () => {
const body = readFileSync(SKILL_MD, 'utf-8');
for (const section of ['## Contract', '## Phases', '## Output Format', '## Anti-Patterns']) {
expect(body.includes(section), `missing ${section}`).toBe(true);
}
});
it('is registered in RESOLVER.md', () => {
expect(existsSync(RESOLVER)).toBe(true);
const resolver = readFileSync(RESOLVER, 'utf-8');
expect(resolver.includes('obsidian-gbrain-safe-index/SKILL.md')).toBe(true);
});
it('has routing-eval fixtures that exercise real trigger phrasings', () => {
const evalPath = join(SKILL_DIR, 'routing-eval.jsonl');
expect(existsSync(evalPath)).toBe(true);
const lines = readFileSync(evalPath, 'utf-8')
.split('\n')
.filter((l) => l.trim() && !l.trim().startsWith('//'))
.map((l) => JSON.parse(l));
const positives = lines.filter((l) => l.expected_skill === 'obsidian-gbrain-safe-index');
expect(positives.length).toBeGreaterThanOrEqual(5);
});
});
-13
View File
@@ -152,19 +152,6 @@ describe('list_schema_packs', () => {
expect(result.installed).toContain('mine');
});
});
it('reports the full bundled registry, not a hand-copied subset (#1726)', async () => {
await withEnv({ GBRAIN_HOME: tmpDir }, async () => {
const { BUNDLED_PACKS } = await import('../src/core/schema-pack/load-active.ts');
const result = await operationsByName.list_schema_packs!.handler(ctxOf(), {}) as { bundled: string[] };
expect(result.bundled.slice().sort()).toEqual([...BUNDLED_PACKS].sort());
// The lens packs that declare extract_atoms/synthesize_concepts phases
// were the ones dropped by the frozen 2-pack literal.
for (const name of ['gbrain-creator', 'gbrain-everything', 'gbrain-base-v2']) {
expect(result.bundled).toContain(name);
}
});
});
});
// ── schema_stats ───────────────────────────────────────────────────────
@@ -1,40 +0,0 @@
// #2117 — gbrain-base-v2 shipped with no `phases:` declaration and zero
// link_types[].inference regexes, so extract_atoms was silently pack-gated
// off and extract-ner returned pack_unavailable on the bundled default pack.
// These assertions fail against the pre-fix yaml.
import { describe, expect, it } from 'bun:test';
import { join } from 'node:path';
import { loadPackFromFile } from '../src/core/schema-pack/loader.ts';
import { linkTypesUndeclared } from '../src/core/schema-pack/lint-rules.ts';
const V2_PATH = join(import.meta.dir, '..', 'src', 'core', 'schema-pack', 'base', 'gbrain-base-v2.yaml');
describe('gbrain-base-v2 capability parity (#2117)', () => {
const manifest = loadPackFromFile(V2_PATH);
it('declares the extract_atoms cycle phase', () => {
expect(manifest.phases ?? []).toContain('extract_atoms');
});
it('ships at least one link_type inference regex so extract-ner is not pack_unavailable', () => {
// Mirrors the extract-ner hasRegex predicate exactly.
const hasRegex = manifest.link_types.some(
(lt) => lt.inference && typeof lt.inference === 'object' && 'regex' in lt.inference,
);
expect(hasRegex).toBe(true);
});
it('ports the v1 inference verbs it declares link types for', () => {
const withRegex = manifest.link_types
.filter((lt) => lt.inference?.regex)
.map((lt) => lt.name)
.sort();
expect(withRegex).toEqual(['founded', 'invested_in', 'works_at']);
});
it('inference rules pass the undeclared-page-type lint (no meeting-bound inference)', async () => {
const issues = await linkTypesUndeclared(manifest);
expect(issues).toEqual([]);
});
});
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'bun:test';
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { DEFAULT_PRIVATE_PATTERNS } from '../src/core/skillpack/harvest-lint.ts';
const SKILL_DIR = join(import.meta.dir, '..', 'skills', 'skill-vault-capture-policy');
const SKILL_MD = join(SKILL_DIR, 'SKILL.md');
const RESOLVER = join(import.meta.dir, '..', 'skills', 'RESOLVER.md');
function parseFrontmatter(raw: string): Record<string, unknown> {
const m = raw.match(/^---\n([\s\S]*?)\n---/);
if (!m) throw new Error('no frontmatter');
const out: Record<string, unknown> = {};
for (const line of m[1].split('\n')) {
const mm = line.match(/^([a-zA-Z_]+):\s*(.*)$/);
if (mm) out[mm[1]] = mm[2].trim();
}
return out;
}
describe('skill-vault-capture-policy skill', () => {
it('has a SKILL.md with required frontmatter', () => {
expect(existsSync(SKILL_MD)).toBe(true);
const fm = parseFrontmatter(readFileSync(SKILL_MD, 'utf-8'));
expect(fm['name']).toBe('skill-vault-capture-policy');
expect(fm['description']).toBeTruthy();
});
it('has the required conformance sections', () => {
const body = readFileSync(SKILL_MD, 'utf-8');
for (const section of ['## Contract', '## Phases', '## Output Format', '## Anti-Patterns']) {
expect(body.includes(section), `missing ${section}`).toBe(true);
}
});
it('is registered in RESOLVER.md', () => {
expect(existsSync(RESOLVER)).toBe(true);
expect(readFileSync(RESOLVER, 'utf-8').includes('skill-vault-capture-policy/SKILL.md')).toBe(true);
});
it('contains no private user or agent-fork names (privacy rule)', () => {
for (const file of [SKILL_MD, join(SKILL_DIR, 'routing-eval.jsonl')]) {
const body = readFileSync(file, 'utf-8');
// DEFAULT_PRIVATE_PATTERNS[0] is the banned fork-name pattern; sourced
// from harvest-lint so this file never contains the literal itself
// (scripts/check-privacy.sh would reject it).
const forkName = new RegExp(DEFAULT_PRIVATE_PATTERNS[0], 'i');
for (const name of [/\bAdam\b/, /\bHermes\b/, /\bHerdr\b/, /\bArk\b/, forkName]) {
expect(name.test(body), `private name ${name} in ${file}`).toBe(false);
}
}
});
it('has routing-eval fixtures', () => {
const evalPath = join(SKILL_DIR, 'routing-eval.jsonl');
expect(existsSync(evalPath)).toBe(true);
const positives = readFileSync(evalPath, 'utf-8')
.split('\n')
.filter((l) => l.trim() && !l.trim().startsWith('//'))
.map((l) => JSON.parse(l))
.filter((l) => l.expected_skill === 'skill-vault-capture-policy');
expect(positives.length).toBeGreaterThanOrEqual(4);
});
});