* feat(skillpack): extract copyArtifacts shared helper (T1) Pure file-copy primitive for scaffold (gbrain→host) and harvest (host→gbrain). Atomic-refusal contract: symlink-reject + canonical-path containment validate every item before any write. Used by both directions of the v0.33 loop. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(skillpack): scaffold subcommand + SKILL.md frontmatter sources (T2) New scaffold.ts replaces the managed-block installer. One-time additive copy into the user's repo via copyArtifacts; refuses to overwrite existing files (user owns them). Partial-state policy: copies missing paired sources even when the skill dir already exists. bundle.ts extended with loadSkillSources + enumerateScaffoldEntries — paired source files declared in each SKILL.md's frontmatter sources: array, not in openclaw.plugin.json. Single source of truth, co-located with the skill. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(skillpack): reference command + apply-clean-hunks (T4 + T15) reference is the read-only diff lens with an agent-readable framing line. Pure-JS unified-diff producer + parser + applier (no patch(1) dependency). Two-way merge with documented limitation: without scaffold-time base tracking, applied hunks align everything to gbrain. The agent dry-runs reference first, then decides. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(skillpack): migrate-fence + scrub-legacy-fence-rows (T5 + T16) migrate-fence is the one-shot transition from the pre-v0.36 managed-block model. Strips begin/end markers and the cumulative-slugs receipt comment; preserves fence rows verbatim as user-owned routing during the transition to frontmatter discovery. Receipt-then-row fallback (F-CDX-8) covers stale/missing receipts. scrub-legacy-fence-rows is the opt-in cleanup after migrate-fence. Two-condition gate: removes a row only when skills/<slug>/ exists AND that skill's frontmatter declares non-empty triggers (proof frontmatter discovery covers it). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(skillpack): harvest + privacy linter (T6 + T7) The inverse loop: lift a proven skill from a host repo (~/git/wintermute, etc.) back into gbrain so other clients can scaffold it. --from <host-repo-root> is symmetric with scaffold's --workspace. Security: symlink rejection + canonical-path containment (mirrors validateUploadPath). Privacy: default-on linter scans harvested files against ~/.gbrain/harvest-private-patterns.txt plus built-in defaults (Wintermute, email, Slack channel patterns). Any match rolls back the copy and exits non-zero. --no-lint bypasses for the editorial workflow after a manual scrub. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(repo-root): cwd_walk_up tier for non-OpenClaw hosts (T9 + D3) autoDetectSkillsDir now walks up from cwd looking for any skills/ directory, ahead of the implicit ~/.openclaw/workspace fallback. cd ~/git/wintermute && gbrain skillpack scaffold ... finds wintermute automatically without requiring a RESOLVER.md/AGENTS.md to exist yet. R5 regression preserved: $OPENCLAW_WORKSPACE still wins when explicitly set. +5 test cases in test/repo-root.test.ts pin the new tier order and the R5 guard. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(skillpack): rewrite CLI dispatch, drop install + uninstall (T3 + T10) skillpack.ts dispatcher rewritten for the v0.36 contract: scaffold, reference (+ --apply-clean-hunks), migrate-fence, scrub-legacy-fence-rows, harvest, plus the existing list / diff / check. install and uninstall are gone — both exit non-zero with a hint pointing at scaffold / migrate-fence. Clean break, no deprecated alias. skillpack-check gains --strict for CI gating. When invoked as the subcommand `gbrain skillpack check`, default is informational (exit 0 even with drift); --strict opts back into the cron-friendly exit-1-on-issues behavior. Top-level gbrain skillpack-check preserves its existing exit semantics for backwards compat. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(skills): skillpack-harvest editorial workflow + resolver wiring (T8) The companion editorial skill for the gbrain skillpack harvest CLI. Walks the genericization checklist (scrub fork names, generalize triggers, lift fork- specific conventions to references) before the CLI runs. Routing-eval fixtures use paraphrased intents to avoid the intent_copies_trigger lint. Wires the new slug into openclaw.plugin.json#skills, skills/manifest.json, and skills/RESOLVER.md. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(skillpack): 9-case real-subprocess E2E flow (T11) Spawns gbrain as a subprocess against tempdir workspaces. Covers: scaffold first-run + re-run no-op, reference diff + --apply-clean-hunks, migrate-fence, scrub-legacy-fence-rows, harvest privacy-lint catch + --no-lint bypass, and the install removed-error path. No DATABASE_URL needed — skillpack is filesystem-only. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: docs + VERSION + CHANGELOG for v0.36.0.0 (T13 + T14) Skillpacks as scaffolding, not amber. v0.36 retires the managed-block install model. Six new subcommands replace install + uninstall: scaffold, reference (with --apply-clean-hunks), migrate-fence, scrub-legacy-fence-rows, harvest, plus the existing list / diff / check (check gains --strict for CI gating). Routing comes from each skill's frontmatter triggers — gbrain does not touch your RESOLVER.md or AGENTS.md. Companion editorial skill skillpack-harvest drives the genericization checklist; default-on privacy linter catches Wintermute / email / Slack references before they leak into gbrain core. New docs guide at docs/guides/skillpacks-as-scaffolding.md walks the model and the migration path for pre-v0.36 installs. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(ci): privacy checks — allow-list harvest-lint tests, scrub user-facing fork-name references CI's check-privacy.sh and check-test-real-names.sh both flagged the literal fork name across the v0.36 skillpack diff. Two failure modes, two fixes: 1. **Meta-rule-enforcement files** added to both allow-lists. The harvest privacy linter's whole job is to catch the banned literal leaking into gbrain; its source has the regex pattern, its tests verify the linter fires by feeding it the banned string, and the skill markdown documents the substitution policy. Same exception status as check-privacy.sh and check-proposal-pii.sh themselves. Files allow-listed: - src/core/skillpack/harvest-lint.ts - test/skillpack-harvest-lint.test.ts - test/skillpack-harvest.test.ts - test/e2e/skillpack-flow.test.ts - skills/skillpack-harvest/SKILL.md 2. **User-facing references** swapped for canonical phrasing per CLAUDE.md's responsible-disclosure rule. README + new docs guide + 4 src docstrings + 1 test now say 'your OpenClaw' / 'host agent repo' / 'agentRepo' var name. Behavior unchanged — only documentation strings touched. Verify gate (the script CI runs) passes locally: EXIT=0. Tests still pass: 60/60 across the affected files. llms-full.txt regenerated. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(test): update check-resolvable-cli expectation for cwd_walk_up tier Sister fix to the test/repo-root.test.ts update in commit a31418e3. The new v0.33 cwd_walk_up tier fires before repo_root when running from inside the gbrain repo — same skills/ dir matched, different source label. Behavior unchanged; the legacy repo_root tier is now functionally subsumed (kept in the type union for back-compat). CI shard 3 failure: test/check-resolvable-cli.test.ts:171. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(test): pin clock in sync_freshness boundary tests (CI flake) The 24h and 72h exact-boundary tests scheduled last_sync_at relative to Date.now() at construction time, then let the check call Date.now() again internally. CI scheduler jitter between the two reads pushed ageMs past the strict > thresholds by microseconds, dropping the 72h-boundary case into the fail branch instead of warn. Fix: add an optional `opts.now` test seam to checkSyncFreshness. The two boundary tests now capture t0 once and pass it both to the timestamp constructor and to the check, making ageMs deterministically equal to the boundary. The non-boundary tests (4d, 30h, 2h, etc.) don't need pinning — they're comfortably away from the > comparison. CI shard 1 flake: test/doctor.test.ts:479. Locally 48/48 doctor tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(skillpack): agent-onboarding readme + next-action hints on every CLI surface (DX review) DX audit of the v0.36 scaffold model surfaced one structural gap and four output gaps. When scaffolded files land on a downstream agent's disk, the agent had no agent-facing manifest telling it what to do — no routing contract, no upgrade flow, no two-way merge warning at the right surface. Fixes: 1. **New shared dep: skills/_AGENT_README.md.** Lands on every scaffold + migrate-fence alongside the existing _brain-filing-rules.md and _output-rules.md. Short, agent-readable contract: walk *.SKILL.md frontmatter triggers: for routing, gbrain is reference not law on upgrade, no managed-block fence anymore, two-way merge has known limitations. Single source of truth for the agent operating contract. 2. **scaffold stdout** prints a next-action hint pointing at the readme (with absolute path) and the reference --all upgrade-sweep command. 3. **reference stdout** adds per-category decision policy: - missing → scaffold again - differs → was edit intentional? keep it. Accidental? patch by hand or apply-clean-hunks after reading the two-way warning. 4. **reference --apply-clean-hunks** prints the two-way merge WARNING BEFORE the apply (to stderr, survives stdout redirect). Spells out that gbrain has no scaffold-time base and local edits in differing sections WILL be aligned to gbrain. Skipped in --json mode for machine consumers. On conflicts, prints how to inspect and patch. 5. **migrate-fence stdout** tells the agent its routing model just changed (fence gone, walk frontmatter now) and points at scrub-legacy-fence-rows as the eventual cleanup. References the new _AGENT_README for fresh-install agents. Smoke verified end-to-end: 16 files land (was 15, +1 for _AGENT_README), hint prints with absolute path, readme lands on disk. Tests + verify gate pass clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(skillpack): upgrade-time reference sweep + reference --since version filter (DX deferred items) Closes the last two DX gaps from the v0.36 audit: 1. **Post-upgrade reference sweep.** New `postUpgradeReferenceSweep` helper called at the end of `gbrain post-upgrade`. After migrations apply, auto-runs `reference --all` against the detected host workspace and prints a one-line-per-skill summary of drift. Five gates: GBRAIN_SKIP_REFERENCE_SWEEP env-var bypass, no detected workspace (silent), workspace IS gbrain repo (dev-mode silent), zero drift (silent), and pure-missing skills the host never scaffolded are filtered out as noise. All errors swallowed — never blocks post-upgrade. Helper accepts test-seam opts (gbrainRoot, targetWorkspace) for unit testability. 2. **`reference --all --since <version>`.** Filters the sweep to skills whose source actually changed in gbrain between <version> and HEAD, using a new `changedSlugsSinceVersion` helper in bundle.ts. Pure-JS git wrapper (spawnSync), no deps. Accepts bare '0.X.Y.Z' or 'v0.X.Y.Z' or commit SHA. Falls back loudly to full sweep when git can't resolve the ref (tarball install, missing tag). Test coverage added — total +32 new test cases: UNIT (15 cases): - test/skillpack-changed-since-version.test.ts (9 cases): git-aware filter against a fixture git repo. Covers null on non-repo, null on bad tag, empty array on no changes, single + multi-slug drift (deduped + sorted), bare + v-prefix version forms, non- skills/ path filtering, SHA-prefix ref form. - test/upgrade-reference-sweep.test.ts (6 cases): gate logic. Covers env-var bypass, zero drift, empty-host suppression, drift-detected output shape, dev-mode workspace==gbrain guard, error-swallowing contract. E2E (8 new cases in test/e2e/skillpack-flow.test.ts): - 10: scaffold lands skills/_AGENT_README.md - 11: scaffold stdout prints the Next: hint - 12: scaffold re-run (skipped-existing) suppresses the hint - 13: reference stdout prints per-category decision policy - 14: --apply-clean-hunks WARNING on stderr, not stdout - 15: --apply-clean-hunks --json suppresses the WARNING (bug fix surfaced here: code originally printed unconditionally, now gated on !json) - 16: migrate-fence stdout points at the new routing model - 17: --since with a bad tag falls back to full sweep with warn Local sweep: 579/579 pass across 18 affected test files, verify gate EXIT=0, llms regenerated. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(README): zero-base rewrite — 921 → 422 lines, refreshed catalog, MECE structure The README had drifted into a changelog dumping ground. Four 'New in vX.Y' paragraphs competed for the lead, 16 version tags scattered through headings, the production-numbers hook (17,888 pages, 4,383 people) was six months stale, and skills were described in three places (Skills section, Commands section, inline marketing prose). Zero-based rewrite: **Refreshed catalog** (surveyed live brain + live agent fork, broad strokes per CLAUDE.md privacy rules): - ~100K total brain items (was 17,888 in the old README — 6x stale) - ~16K people (was 4,383) - ~5K companies (was 723) - ~8K concepts, ~4K originals, ~3.5K daily notes - ~31K media (30K tweets, 179 books, papers/films/games/interviews) - 108 cron jobs running (was 21) - 273 skills in the live agent fork (35 bundled + 238 user-built) **Structure** — MECE, single source of truth per concept: 1. Hook + at-a-glance table (refreshed numbers) 2. Install (3 paths, terse) 3. What it does (5 capability areas — replaces 12 scattered sections) 4. Skills (categorized one-liners — 35 lines, was ~200) 5. How it works (one coherent flow — replaces 4 overlapping sections: Architecture, Knowledge Model, Knowledge Graph, Search, Why It Works) 6. Commands (terse cheatsheet — every command, one line each) 7. Docs (link map — points to docs/ for the heavy stuff) 8. Origin / Contributing / License **Cut entirely** (moved or deleted): - 4 'New in vX.Y' leads (→ CHANGELOG.md is the changelog) - 16 (vX.Y) version tags in section headings - Minions stats subsection (subsumed into hook + 'durable background work') - Voice section (was 12 lines of brand prose) - Engine Architecture detail (→ docs/architecture/) - File Storage section (→ docs/guides/storage-tiering.md) - Per-skill marketing prose (one-liner per skill in the table) The README is no longer the changelog. Future releases append to CHANGELOG.md; the README only changes when a structural capability does. llms-full.txt regenerated. Privacy check + verify gate pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(README): fix line-start '+' rendering bug + lead with eval evidence Two fixes in one: 1. **Markdown bug fix.** The OAuth 2.1 paragraph had `+ PKCE,` on a line start (column 1), which GitHub-flavored markdown interprets as a list marker — the line break before it broke the paragraph and rendered as an orphan first line followed by a bullet. Rewrote the OAuth 2.1 capabilities as inline-comma-separated, escaped the `+` semantics. Swept the whole file for the same bug class — no other instances. 2. **Maximum-sell mode for evals.** Surveyed every published benchmark in both this repo and ~/git/gbrain-evals. Strongest evidence pulled to the top: - **97.60% R@5 on the public LongMemEval _s (500 questions).** No LLM in the retrieval loop. $0.50 per 1000 queries. Beats MemPalace raw by a point on the same dataset, beats every academic dense retriever (Stella, Contriever, BM25). Mastra/Supermemory measure a different metric (QA accuracy with LLM judge) — flagged honestly. - **+31.4 points P@5 from the self-wiring knowledge graph** on BrainBench v0.20.0 (240-page rich-prose corpus, 145 relational gold queries). Separable, measured, load-bearing. Zero retrieval regression across seven releases (v0.16 → v0.20). New '## Benchmarks' section after Install: - Public benchmark table with cross-system comparison - In-house BrainBench scorecard with per-adapter Δ vs gbrain - Source-swamp resistance result (93.3% top-1 vs 80% grep-only) - Skill/prompt compression: 25KB → 13KB AGENTS.md, +13-17pp accuracy across Opus 4.7 / Sonnet 4.6 / Haiku 4.5 - 'Run your own evals' subsection with copy-pasteable commands for every eval surface (longmemeval, cross-modal, eval capture/replay, BrainBench) Tightened the lead's cost-comparison claim to what's defensible per the underlying eval doc (MemPal LLM-rerank $0.001/q vs gbrain $0.0005/q; dropped the overstated '6x' I'd written initially). Privacy + verify gate + build-llms test all pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(README): integrate the eval story into the lead, move jargon into 'Receipts on the evals' Previous lead dumped metric acronyms (R@5, P@5, P@5 deltas, MemPalace, Stella, Contriever, BM25) before the reader knew what gbrain does. A 'somewhat technical' reader hits the wall of jargon and bounces. Rewritten: **Lead (jargon-free, 3 paragraphs)** — describes the value in plain English, with two anchor numbers: - 'right answer in top 5 results 97.6% of the time' (not 'R@5 97.60%') - 'roughly 4x more relevant than plain vector RAG' (not '+31.4 pts P@5') - 'better than every comparable system that doesn't pay for a language- model call on every retrieval' (the load-bearing honest framing, without naming the competitors mid-hook) - ends with '[Receipts on the evals →]' linking down **'## Benchmarks' renamed '## Receipts on the evals'** with a glossary at the top defining R@5, P@5, and 'no LLM in the loop' in one line each. Then the full tables: LongMemEval cross-system (with the metric-mismatch flag for Mastra/Supermemory), in-house BrainBench scorecard, source-swamp resistance, and prompt compression. The competitor names + metrics stay here where readers who want the receipts can find them, with the glossary so the acronyms don't tax cold readers. Net: lead reads as 'here's what it does and the proof' instead of 'here are the benchmark numbers, figure out what they mean.' Comparison facts unchanged. Privacy + verify gate + build-llms test all pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(README): name LongMemEval explicitly + first-person voice in lead Two specific edits from user feedback: 1. 'the standard public benchmark for AI memory systems' → 'LongMemEval' (linked to the HuggingFace dataset). The benchmark has a name; use it. 2. 'Built by the President and CEO of Y Combinator to run his own AI agents' (passive third-person) → 'I'm the President and CEO of Y Combinator, and I use this 16 hours a day' (active first-person). Carried the voice change through the rest of the README — the downstream 'Garry's personal agent' line and the Origin section's 'Garry Tan needed... he'd ever drafted... so he built one' all flip to first person ('my personal agent', 'I needed', 'I'd ever drafted', 'so I built one'). The README is now consistently first-person from the author's voice instead of a hagiographic third-person framing. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(README): add Multi-player and company brains section Three deployment patterns documented: 1. Single GBrain server + thin MCP clients (recommended). Tailscale private networking, OAuth scope, source-scoped clients, exhaustive what-clients-can/cannot-do lists. 2. Local PGLite + GStack for per-worktree code search. 3. Federated repos (advanced) — multiple servers indexing the same brain repo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(README): tighten install path + add tech-orientation block + visceral query example Self-eval as a cold reader surfaced four gaps blocking a 10/10 first read: 1. Lead never says WHAT it is technically — CLI? service? cloud? local? Added a "What it is, technically" block right after the hook: open-source MIT, Bun CLI + MCP server, local-first, data stays on disk, MCP-native. 2. Install path optimized for committed users not evaluators. The old "recommended" path (deploy OpenClaw on Render, 8GB RAM) blocked anyone trying gbrain for the first time. Reordered into 3 paths by commitment: 60-second standalone CLI first, MCP for Claude Code / Cursor second, full agentic install third. 3. No example output showing what success looks like. Added a real sample `gbrain query` invocation with the hybrid-search result format so a reader can feel the experience before they install. 4. Privacy / data-locality unaddressed in lead. Now stated up front: embedding calls only hit external APIs if you configure them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
398 lines
16 KiB
TypeScript
398 lines
16 KiB
TypeScript
import { describe, it, expect, afterEach } from 'bun:test';
|
|
import { spawnSync } from 'child_process';
|
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs';
|
|
import { tmpdir } from 'os';
|
|
import { join, resolve } from 'path';
|
|
import {
|
|
parseFlags,
|
|
resolveSkillsDir,
|
|
DEFERRED,
|
|
} from '../src/commands/check-resolvable.ts';
|
|
|
|
// Path to the CLI entry point. Runs through bun directly so tests don't
|
|
// require a pre-built binary. Always invoked from the repo root so bun can
|
|
// resolve transitive node_modules (the top-level cli.ts imports pull in
|
|
// @anthropic-ai/sdk which walks from the file path, but some internal
|
|
// shim resolution requires node_modules to be reachable from cwd too).
|
|
const CLI = resolve(import.meta.dir, '..', 'src', 'cli.ts');
|
|
const REPO_ROOT = resolve(import.meta.dir, '..');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixture builders
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface SkillSpec {
|
|
name: string;
|
|
triggers?: string[];
|
|
/** Register in manifest.json — defaults true. */
|
|
inManifest?: boolean;
|
|
/** Add a RESOLVER.md row pointing at this skill — defaults true. */
|
|
inResolver?: boolean;
|
|
}
|
|
|
|
function makeFixture(skills: SkillSpec[], created: string[]): string {
|
|
const root = mkdtempSync(join(tmpdir(), 'check-resolvable-cli-'));
|
|
created.push(root);
|
|
const skillsDir = join(root, 'skills');
|
|
mkdirSync(skillsDir, { recursive: true });
|
|
|
|
const manifest = {
|
|
skills: skills
|
|
.filter(s => s.inManifest !== false)
|
|
.map(s => ({ name: s.name, path: `${s.name}/SKILL.md` })),
|
|
};
|
|
writeFileSync(join(skillsDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
|
|
for (const s of skills) {
|
|
const skillDir = join(skillsDir, s.name);
|
|
mkdirSync(skillDir, { recursive: true });
|
|
const fm = ['---', `name: ${s.name}`];
|
|
if (s.triggers && s.triggers.length) {
|
|
fm.push('triggers:');
|
|
for (const t of s.triggers) fm.push(` - "${t}"`);
|
|
}
|
|
fm.push('---');
|
|
fm.push(`# ${s.name}\n\nA test skill.\n`);
|
|
writeFileSync(join(skillDir, 'SKILL.md'), fm.join('\n'));
|
|
}
|
|
|
|
const rows = skills
|
|
.filter(s => s.inResolver !== false)
|
|
.map(s => `| "${s.name} trigger" | \`skills/${s.name}/SKILL.md\` |`);
|
|
const resolver = [
|
|
'# RESOLVER',
|
|
'',
|
|
'## Brain operations',
|
|
'| Trigger | Skill |',
|
|
'|---------|-------|',
|
|
...rows,
|
|
'',
|
|
].join('\n');
|
|
writeFileSync(join(skillsDir, 'RESOLVER.md'), resolver);
|
|
|
|
return skillsDir;
|
|
}
|
|
|
|
interface RunResult {
|
|
status: number;
|
|
stdout: string;
|
|
stderr: string;
|
|
json: any;
|
|
}
|
|
|
|
function run(args: string[]): RunResult {
|
|
const res = spawnSync('bun', [CLI, 'check-resolvable', ...args], {
|
|
encoding: 'utf-8',
|
|
cwd: REPO_ROOT,
|
|
maxBuffer: 10 * 1024 * 1024,
|
|
});
|
|
let json: any = null;
|
|
if (args.includes('--json')) {
|
|
try { json = JSON.parse(res.stdout); } catch { /* leave null */ }
|
|
}
|
|
return {
|
|
status: res.status ?? -1,
|
|
stdout: res.stdout,
|
|
stderr: res.stderr,
|
|
json,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Unit tests: direct helpers (fast, no subprocess)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('check-resolvable — unit: parseFlags', () => {
|
|
it('parses all known flags', () => {
|
|
const f = parseFlags(['--json', '--fix', '--dry-run', '--verbose', '--skills-dir', '/x']);
|
|
expect(f.json).toBe(true);
|
|
expect(f.fix).toBe(true);
|
|
expect(f.dryRun).toBe(true);
|
|
expect(f.verbose).toBe(true);
|
|
expect(f.skillsDir).toBe('/x');
|
|
});
|
|
|
|
it('supports --skills-dir=PATH syntax', () => {
|
|
const f = parseFlags(['--skills-dir=/x/y']);
|
|
expect(f.skillsDir).toBe('/x/y');
|
|
});
|
|
|
|
it('silently ignores unknown flags (permissive, matches lint/orphans convention)', () => {
|
|
const f = parseFlags(['--json', '--bogus', '--another-unknown']);
|
|
expect(f.json).toBe(true);
|
|
expect(f.help).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('check-resolvable — unit: resolveSkillsDir', () => {
|
|
it('resolves absolute --skills-dir unchanged', () => {
|
|
const r = resolveSkillsDir({ help: false, json: false, fix: false, dryRun: false, verbose: false, strict: false, skillsDir: '/tmp/absolute-path' });
|
|
expect(r.dir).toBe('/tmp/absolute-path');
|
|
expect(r.error).toBeNull();
|
|
});
|
|
|
|
it('resolves relative --skills-dir against cwd', () => {
|
|
const r = resolveSkillsDir({ help: false, json: false, fix: false, dryRun: false, verbose: false, strict: false, skillsDir: 'skills' });
|
|
expect(r.dir).toMatch(/\/skills$/);
|
|
expect(r.error).toBeNull();
|
|
expect(r.source).toBe('explicit');
|
|
});
|
|
|
|
it('v0.31.7: empty cwd falls back to install-path (finds bundled skills/)', () => {
|
|
// Temporarily chdir to a guaranteed-empty tmpdir. findRepoRoot from cwd
|
|
// walks up and fails — but autoDetectSkillsDirReadOnly's tier-5
|
|
// install-path fallback then walks up from the gbrain module's own
|
|
// location and finds the bundled skills/ dir. This is the v0.31.7
|
|
// capability: doctor and check-resolvable work from anywhere.
|
|
//
|
|
// To test the underlying no_skills_dir error path, see the unit tests
|
|
// in test/repo-root.test.ts that drive autoDetectSkillsDirReadOnly
|
|
// with mocked env to suppress the install-path success.
|
|
const empty = mkdtempSync(join(tmpdir(), 'empty-for-resolve-'));
|
|
const original = process.cwd();
|
|
try {
|
|
process.chdir(empty);
|
|
const r = resolveSkillsDir({ help: false, json: false, fix: false, dryRun: false, verbose: false, strict: false, skillsDir: null });
|
|
// Install-path fallback succeeds when test runs inside the gbrain repo.
|
|
expect(r.error).toBeNull();
|
|
expect(r.dir).toMatch(/\/skills$/);
|
|
expect(r.source).toBe('install_path');
|
|
} finally {
|
|
process.chdir(original);
|
|
rmSync(empty, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('finds skills via cwd_walk_up when cwd is inside a repo (no --skills-dir)', () => {
|
|
// Running from this test file — we're inside the real gbrain repo.
|
|
// v0.33 added the cwd_walk_up tier ahead of repo_root, so the same
|
|
// skills/ dir is matched via the broader (no gbrain-shape gate)
|
|
// path. Behavior unchanged — source label updated. The repo_root
|
|
// tier is now functionally subsumed; kept in the type union for
|
|
// back-compat. See src/core/repo-root.ts.
|
|
const r = resolveSkillsDir({ help: false, json: false, fix: false, dryRun: false, verbose: false, strict: false, skillsDir: null });
|
|
expect(r.error).toBeNull();
|
|
expect(r.dir).toMatch(/\/skills$/);
|
|
expect(r.source).toBe('cwd_walk_up');
|
|
});
|
|
|
|
it('REGRESSION-GATE: --skills-dir override takes precedence over OpenClaw env auto-detection', () => {
|
|
const explicit = mkdtempSync(join(tmpdir(), 'explicit-skills-'));
|
|
mkdirSync(explicit, { recursive: true });
|
|
writeFileSync(join(explicit, 'RESOLVER.md'), '# RESOLVER\n');
|
|
|
|
const workspace = mkdtempSync(join(tmpdir(), 'openclaw-ws-'));
|
|
mkdirSync(join(workspace, 'skills'), { recursive: true });
|
|
writeFileSync(join(workspace, 'skills', 'RESOLVER.md'), '# RESOLVER\n');
|
|
|
|
const prev = process.env.OPENCLAW_WORKSPACE;
|
|
process.env.OPENCLAW_WORKSPACE = workspace;
|
|
try {
|
|
const r = resolveSkillsDir({
|
|
help: false,
|
|
json: false,
|
|
fix: false,
|
|
dryRun: false,
|
|
verbose: false,
|
|
strict: false,
|
|
skillsDir: explicit,
|
|
});
|
|
expect(r.error).toBeNull();
|
|
expect(r.dir).toBe(explicit);
|
|
expect(r.source).toBe('explicit');
|
|
} finally {
|
|
if (prev === undefined) delete process.env.OPENCLAW_WORKSPACE;
|
|
else process.env.OPENCLAW_WORKSPACE = prev;
|
|
rmSync(explicit, { recursive: true, force: true });
|
|
rmSync(workspace, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('check-resolvable — unit: DEFERRED', () => {
|
|
it('v0.17 ships Checks 5 and 6 — DEFERRED is empty', () => {
|
|
// Pre-v0.17: both Check 5 (routing eval) and Check 6 (brain filing)
|
|
// were deferred. v0.17 W2 shipped Check 5; v0.17 W3 shipped Check 6.
|
|
// The DEFERRED export stays (stable --json field) for future checks.
|
|
expect(DEFERRED.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Integration tests: subprocess via bun src/cli.ts (cwd = repo root)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('gbrain check-resolvable CLI — integration', () => {
|
|
const created: string[] = [];
|
|
afterEach(() => {
|
|
while (created.length) {
|
|
const p = created.pop()!;
|
|
try { rmSync(p, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
}
|
|
});
|
|
|
|
it('prints usage and exits 0 on --help', () => {
|
|
const r = run(['--help']);
|
|
expect(r.status).toBe(0);
|
|
expect(r.stdout).toContain('gbrain check-resolvable');
|
|
expect(r.stdout).toContain('--json');
|
|
expect(r.stdout).toContain('--fix');
|
|
expect(r.stdout).toContain('--strict');
|
|
// Check 5 shipped in v0.17 (mentioned in the body); Check 6 is
|
|
// still deferred and must appear under "Deferred to separate issues".
|
|
expect(r.stdout).toContain('Check 5');
|
|
expect(r.stdout).toContain('Check 6');
|
|
});
|
|
|
|
it('--json success envelope has all seven stable keys', () => {
|
|
const skillsDir = makeFixture([{ name: 'alpha', triggers: ['alpha'] }], created);
|
|
const r = run(['--json', '--skills-dir', skillsDir]);
|
|
expect(r.json).not.toBeNull();
|
|
const keys = Object.keys(r.json).sort();
|
|
expect(keys).toEqual(['autoFix', 'deferred', 'error', 'message', 'ok', 'report', 'skillsDir']);
|
|
expect(r.json.ok).toBe(true);
|
|
// v0.17 ships Checks 5 and 6; DEFERRED is empty. The key remains
|
|
// stable for future checks.
|
|
expect(Array.isArray(r.json.deferred)).toBe(true);
|
|
expect(r.json.deferred.length).toBe(0);
|
|
});
|
|
|
|
it('--json success: autoFix is null when --fix was not passed', () => {
|
|
const skillsDir = makeFixture([{ name: 'alpha', triggers: ['alpha'] }], created);
|
|
const r = run(['--json', '--skills-dir', skillsDir]);
|
|
expect(r.json.autoFix).toBeNull();
|
|
});
|
|
|
|
it('exits 0 on clean fixture with zero issues', () => {
|
|
const skillsDir = makeFixture([{ name: 'alpha', triggers: ['alpha'] }], created);
|
|
const r = run(['--skills-dir', skillsDir]);
|
|
expect(r.status).toBe(0);
|
|
expect(r.stdout).toContain('resolver_health: OK');
|
|
});
|
|
|
|
it('D-CX-3: warnings-only fixture exits 0 in default mode', () => {
|
|
// "alpha" is in resolver but not manifest → orphan_trigger (warning)
|
|
// Per D-CX-3 (codex review outside voice): warnings alone do not flip
|
|
// the exit code. This is the new contract; callers who want strict
|
|
// behavior pass --strict. Prior contract (exit 1 on any warning) broke
|
|
// CI for workspaces emitting warning-level advisories like filing-audit.
|
|
const skillsDir = makeFixture(
|
|
[{ name: 'alpha', triggers: ['alpha'], inManifest: false }],
|
|
created,
|
|
);
|
|
const r = run(['--json', '--skills-dir', skillsDir]);
|
|
expect(r.json).not.toBeNull();
|
|
expect(r.json.report.warnings.length).toBeGreaterThan(0);
|
|
expect(r.json.report.errors.length).toBe(0);
|
|
// warnings.length > 0 but errors.length === 0 → exit 0 (advisory)
|
|
expect(r.status).toBe(0);
|
|
expect(r.json.ok).toBe(true);
|
|
});
|
|
|
|
it('D-CX-3: --strict promotes warnings to exit 1', () => {
|
|
const skillsDir = makeFixture(
|
|
[{ name: 'alpha', triggers: ['alpha'], inManifest: false }],
|
|
created,
|
|
);
|
|
const r = run(['--json', '--strict', '--skills-dir', skillsDir]);
|
|
expect(r.json).not.toBeNull();
|
|
expect(r.json.report.warnings.length).toBeGreaterThan(0);
|
|
expect(r.json.report.errors.length).toBe(0);
|
|
// --strict flips ok to false and exit to 1 when warnings exist
|
|
expect(r.json.ok).toBe(false);
|
|
expect(r.status).toBe(1);
|
|
});
|
|
|
|
it('D-CX-3: report has separate errors[] and warnings[] arrays alongside issues[]', () => {
|
|
const skillsDir = makeFixture(
|
|
[{ name: 'alpha', triggers: ['alpha'], inManifest: false }],
|
|
created,
|
|
);
|
|
const r = run(['--json', '--skills-dir', skillsDir]);
|
|
expect(r.json).not.toBeNull();
|
|
const rep = r.json.report;
|
|
expect(Array.isArray(rep.errors)).toBe(true);
|
|
expect(Array.isArray(rep.warnings)).toBe(true);
|
|
// Deprecated flat `issues` still present for one-release backcompat
|
|
expect(Array.isArray(rep.issues)).toBe(true);
|
|
expect(rep.issues.length).toBe(rep.errors.length + rep.warnings.length);
|
|
});
|
|
|
|
it('exits 1 when fixture has an error-level unreachable skill', () => {
|
|
// "alpha" is in manifest but not resolver → unreachable (error)
|
|
const skillsDir = makeFixture(
|
|
[{ name: 'alpha', triggers: ['alpha'], inResolver: false }],
|
|
created,
|
|
);
|
|
const r = run(['--json', '--skills-dir', skillsDir]);
|
|
expect(r.json).not.toBeNull();
|
|
const errors = r.json.report.issues.filter((i: any) => i.severity === 'error');
|
|
expect(errors.length).toBeGreaterThan(0);
|
|
expect(r.status).toBe(1);
|
|
});
|
|
|
|
it('--fix --dry-run includes an autoFix object in the JSON envelope', () => {
|
|
const skillsDir = makeFixture([{ name: 'alpha', triggers: ['alpha'] }], created);
|
|
const r = run(['--json', '--fix', '--dry-run', '--skills-dir', skillsDir]);
|
|
expect(r.json).not.toBeNull();
|
|
expect(r.json.autoFix).not.toBeNull();
|
|
expect(Array.isArray(r.json.autoFix.fixed)).toBe(true);
|
|
expect(Array.isArray(r.json.autoFix.skipped)).toBe(true);
|
|
});
|
|
|
|
it('--verbose prints the deferred checks note in human mode', () => {
|
|
const skillsDir = makeFixture([{ name: 'alpha', triggers: ['alpha'] }], created);
|
|
const r = run(['--verbose', '--skills-dir', skillsDir]);
|
|
// v0.17 ships Checks 5 and 6 → DEFERRED is empty. Verbose still
|
|
// prints the "Deferred:" header for stable UX; downstream content
|
|
// is empty until a future release adds a new deferred check.
|
|
expect(r.stdout).toContain('Deferred:');
|
|
expect(r.stdout).not.toContain('trigger_routing_eval');
|
|
expect(r.stdout).not.toContain('brain_filing');
|
|
});
|
|
|
|
it('clean fixture human output says all skills reachable', () => {
|
|
const skillsDir = makeFixture(
|
|
[
|
|
{ name: 'alpha', triggers: ['alpha'] },
|
|
{ name: 'beta', triggers: ['beta'] },
|
|
],
|
|
created,
|
|
);
|
|
const r = run(['--skills-dir', skillsDir]);
|
|
expect(r.stdout).toContain('resolver_health: OK');
|
|
expect(r.stdout).toContain('2 skills');
|
|
expect(r.status).toBe(0);
|
|
});
|
|
|
|
it('logs the auto-detected skills directory path in human mode', () => {
|
|
const r = run([]);
|
|
expect(r.status === 0 || r.status === 1).toBe(true);
|
|
expect(r.stdout).toContain('Auto-detected skills directory');
|
|
expect(r.stdout).toContain('/skills');
|
|
});
|
|
|
|
// v0.31.7 D6 regression guard: --fix must refuse install-path fallback.
|
|
// Without this gate, `cd ~ && gbrain check-resolvable --fix` would silently
|
|
// mutate SKILL.md files in the bundled gbrain repo via autoFixDryViolations.
|
|
// Codex caught this leak in the v0.31.7 ship review.
|
|
it('v0.31.7 D6: --fix refuses when source is install_path', () => {
|
|
// Run from a guaranteed-empty tempdir so the install-path fallback fires.
|
|
const empty = mkdtempSync(join(tmpdir(), 'cr-fix-installpath-'));
|
|
try {
|
|
// Pass --fix; expect refusal exit + clear error message.
|
|
const r = spawnSync('bun', ['run', CLI, 'check-resolvable', '--fix'], {
|
|
cwd: empty,
|
|
env: { ...process.env, OPENCLAW_WORKSPACE: '', GBRAIN_SKILLS_DIR: '' },
|
|
encoding: 'utf-8',
|
|
});
|
|
expect(r.status).toBe(1);
|
|
expect(r.stderr).toContain('install-path fallback');
|
|
expect(r.stderr).toContain('refused');
|
|
expect(r.stderr).toMatch(/GBRAIN_SKILLS_DIR|OPENCLAW_WORKSPACE|--skills-dir/);
|
|
} finally {
|
|
rmSync(empty, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|