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
14 changed files with 506 additions and 311 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}
-109
View File
@@ -151,100 +151,6 @@ export function shouldSpawnAutopilotWorker(args: string[]): boolean {
return !args.includes('--no-worker');
}
/**
* #1525 — positional subcommand translation.
*
* Pre-fix, `gbrain autopilot status` silently fell through to "start daemon"
* because `runAutopilot()` only branched on flag forms (`--status`, etc.).
* `status` was treated as a stray positional and ignored.
*
* This translator maps known positional subcommands to their flag form so
* `autopilot status` is equivalent to `autopilot --status`, then rejects
* any unrecognized positional with a fail-loud error before any side
* effect (lockfile, daemon spawn, sync dispatch) runs.
*
* Scope decisions:
* - Known aliases: `status` → `--status`, `install` → `--install`,
* `uninstall` → `--uninstall`, `start` → (drop; default daemon launch).
* - `stop` is intentionally NOT aliased here. Stopping a running daemon
* is a new behavior (read PID from lock, SIGTERM, drain) that deserves
* its own design and PR. Users typing `gbrain autopilot stop` today get
* the unknown-positional error with the canonical alternatives.
* - At most one positional allowed; multiple positionals fail loud.
*/
// Every flag that consumes the NEXT argv token. Missing one here makes the
// translator misread the flag's value as a positional subcommand and exit 2
// (e.g. `--install --target linux-cron`). Keep in sync with parseArg call sites.
const AUTOPILOT_VALUE_FLAGS = new Set(['--repo', '--interval', '--target']);
const AUTOPILOT_POSITIONAL_ALIASES: Record<string, string | null> = {
status: '--status',
install: '--install',
uninstall: '--uninstall',
start: null, // drop the positional; default behavior is daemon launch
};
export type PositionalTranslation =
| { ok: true; args: string[] }
| {
ok: false;
reason: 'unknown_subcommand' | 'multiple_subcommands';
message: string;
};
export function translatePositionalSubcommands(args: string[]): PositionalTranslation {
const out: string[] = [];
let positionalSeen = false;
let i = 0;
while (i < args.length) {
const a = args[i];
if (AUTOPILOT_VALUE_FLAGS.has(a)) {
// Pass through the flag and its value untouched. If the value is
// missing at end-of-argv, fall through so the existing parseArg
// path can report the broken usage.
out.push(a);
if (i + 1 < args.length) {
out.push(args[i + 1]);
i += 2;
} else {
i += 1;
}
continue;
}
if (a.startsWith('-')) {
out.push(a);
i += 1;
continue;
}
// Positional subcommand.
if (positionalSeen) {
const known = Object.keys(AUTOPILOT_POSITIONAL_ALIASES).join(', ');
return {
ok: false,
reason: 'multiple_subcommands',
message: `Multiple subcommands given. Use only one of: ${known}.`,
};
}
positionalSeen = true;
if (a in AUTOPILOT_POSITIONAL_ALIASES) {
const alias = AUTOPILOT_POSITIONAL_ALIASES[a];
if (alias) out.push(alias);
i += 1;
continue;
}
const known = Object.keys(AUTOPILOT_POSITIONAL_ALIASES).join(', ');
return {
ok: false,
reason: 'unknown_subcommand',
message:
`Unknown subcommand: \`${a}\`.\n` +
`Allowed subcommands: ${known}.\n` +
`Or use the flag form: --status, --install, --uninstall.\n` +
`Run \`gbrain autopilot --help\` for full usage.`,
};
}
return { ok: true, args: out };
}
export function isPidAlive(pid: number): boolean {
if (!Number.isFinite(pid) || pid <= 0) return false;
try {
@@ -457,11 +363,6 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
' gbrain autopilot --install [--repo <path>]\n' +
' gbrain autopilot --uninstall\n' +
' gbrain autopilot --status [--json]\n\n' +
'Subcommand aliases:\n' +
' gbrain autopilot status → --status\n' +
' gbrain autopilot install → --install\n' +
' gbrain autopilot uninstall → --uninstall\n' +
' gbrain autopilot start → (default daemon launch)\n\n' +
'Self-maintaining brain daemon. Runs the full maintenance cycle\n' +
'(lint + backlinks + sync + extract + embed + orphans) on an interval.\n\n' +
'For a one-shot cron-triggered cycle, see `gbrain dream`.',
@@ -469,16 +370,6 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
return;
}
// #1525: translate positional subcommands to their flag form BEFORE any
// side effect (lockfile, daemon spawn, sync dispatch). Unknown positionals
// fail loud here rather than silently starting the daemon.
const translated = translatePositionalSubcommands(args);
if (!translated.ok) {
console.error(translated.message);
process.exit(2);
}
args = translated.args;
if (args.includes('--install')) {
await installDaemon(engine, args);
return;
@@ -1,197 +0,0 @@
/**
* Tests for translatePositionalSubcommands() — the v0.41.x #1525 fix that
* prevents `gbrain autopilot status` from silently starting the daemon.
*
* IRON RULE regression guard: the exact ticket repro (`gbrain autopilot
* status`) MUST translate to `--status`, not fall through to the default
* daemon launch. Verified by the "ticket-exact repro" case below.
*/
import { describe, test, expect } from 'bun:test';
import { translatePositionalSubcommands } from '../src/commands/autopilot.ts';
describe('translatePositionalSubcommands — known aliases', () => {
test('IRON RULE — `autopilot status` translates to `--status` (ticket #1525 repro)', () => {
const r = translatePositionalSubcommands(['status']);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual(['--status']);
});
test('`install` translates to `--install`', () => {
const r = translatePositionalSubcommands(['install']);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual(['--install']);
});
test('`uninstall` translates to `--uninstall`', () => {
const r = translatePositionalSubcommands(['uninstall']);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual(['--uninstall']);
});
test('`start` drops the positional (default daemon launch)', () => {
const r = translatePositionalSubcommands(['start']);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual([]);
});
test('`start --json` drops only the positional, keeps the flag', () => {
const r = translatePositionalSubcommands(['start', '--json']);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual(['--json']);
});
});
describe('translatePositionalSubcommands — flag/positional interleaving', () => {
test('`status --json` preserves the trailing flag', () => {
const r = translatePositionalSubcommands(['status', '--json']);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual(['--status', '--json']);
});
test('`--json status` preserves the leading flag', () => {
const r = translatePositionalSubcommands(['--json', 'status']);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual(['--json', '--status']);
});
test('`--repo /foo status` does not mis-classify the path as positional', () => {
const r = translatePositionalSubcommands(['--repo', '/foo', 'status']);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual(['--repo', '/foo', '--status']);
});
test('`--interval 300 install` does not mis-classify the number as positional', () => {
const r = translatePositionalSubcommands(['--interval', '300', 'install']);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual(['--interval', '300', '--install']);
});
test('`--install --target linux-cron` does not mis-classify the target as positional', () => {
// --target is installDaemon's value flag; its value must never be read
// as a positional subcommand (regression guard for the review fix).
const r = translatePositionalSubcommands(['--install', '--target', 'linux-cron']);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual(['--install', '--target', 'linux-cron']);
});
test('`install --target macos` keeps the alias translation and the target value', () => {
const r = translatePositionalSubcommands(['install', '--target', 'macos']);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual(['--install', '--target', 'macos']);
});
test('value-flag at end of argv with missing value passes through (so parseArg can report it)', () => {
const r = translatePositionalSubcommands(['--repo']);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual(['--repo']);
});
test('value-flag whose value looks like an alias is NOT translated', () => {
// `--repo status` means "use repo path 'status'", not "show status".
// Translator must not destructure the value of --repo.
const r = translatePositionalSubcommands(['--repo', 'status']);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual(['--repo', 'status']);
});
});
describe('translatePositionalSubcommands — pass-through cases', () => {
test('empty args returns empty args', () => {
const r = translatePositionalSubcommands([]);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual([]);
});
test('flag-only invocation passes through unchanged', () => {
const r = translatePositionalSubcommands(['--status', '--json']);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual(['--status', '--json']);
});
test('short flag `-h` passes through unchanged', () => {
const r = translatePositionalSubcommands(['-h']);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual(['-h']);
});
test('all known bare flags pass through unchanged', () => {
const flags = ['--help', '--install', '--uninstall', '--status', '--json', '--inline', '--no-worker'];
const r = translatePositionalSubcommands(flags);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual(flags);
});
});
describe('translatePositionalSubcommands — rejection of unknown positionals', () => {
test('unknown positional `foo` fails with reason=unknown_subcommand + structured message', () => {
const r = translatePositionalSubcommands(['foo']);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.reason).toBe('unknown_subcommand');
expect(r.message).toContain('Unknown subcommand: `foo`');
expect(r.message).toContain('status');
expect(r.message).toContain('install');
expect(r.message).toContain('uninstall');
expect(r.message).toContain('--help');
}
});
test('unknown positional `stop` fails with reason=unknown_subcommand (NOT silently aliased)', () => {
// Stop is mentioned in the ticket but deliberately NOT aliased in this
// PR — stopping a running daemon is a new behavior, not just an alias.
// Until that feature lands separately, `stop` must fail loud rather
// than starting the daemon (the bug we're fixing).
const r = translatePositionalSubcommands(['stop']);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.reason).toBe('unknown_subcommand');
expect(r.message).toContain('Unknown subcommand: `stop`');
}
});
test('unknown positional `status-detail` (close-but-not-matching) fails', () => {
const r = translatePositionalSubcommands(['status-detail']);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.reason).toBe('unknown_subcommand');
expect(r.message).toContain('Unknown subcommand: `status-detail`');
}
});
test('multiple positionals fail with reason=multiple_subcommands (`start install`)', () => {
const r = translatePositionalSubcommands(['start', 'install']);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.reason).toBe('multiple_subcommands');
expect(r.message).toContain('Multiple subcommands');
}
});
test('multiple positionals fail even when both are known aliases (`status install`)', () => {
const r = translatePositionalSubcommands(['status', 'install']);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.reason).toBe('multiple_subcommands');
expect(r.message).toContain('Multiple subcommands');
}
});
test('known-then-unknown rejects with multiple_subcommands (first-positional-wins)', () => {
// First positional is known, second is not. Rejection comes from the
// multiple-positional rule, which fires before the unknown check; the
// intent is "only one subcommand allowed."
const r = translatePositionalSubcommands(['status', 'garbage']);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toBe('multiple_subcommands');
});
test('unknown-then-known rejects on the unknown (unknown fires before second-positional check)', () => {
const r = translatePositionalSubcommands(['garbage', 'status']);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.reason).toBe('unknown_subcommand');
expect(r.message).toContain('garbage');
}
});
});
@@ -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);
});
});
+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);
});
});
+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);
});
});