Files
gbrain/src/commands/skillpack.ts
T
4fc1246606 v0.24.0: production-hardening pass on the skillify loop (#387)
* feat: v0.19.0 — skillify loop + AGENTS.md compat + brain-first convention

This is the v0.19.0 release. The branch ships four new CLI commands, a
refactor to check-resolvable, and an expansion of the brain-first
convention for sub-agent tool discovery. The original commit message
described only the convention expansion, undercounting the scope by ~5x;
this amend captures the full release.

NEW COMMANDS

- gbrain skillify scaffold <name>     — 4 stub files + idempotent resolver row
- gbrain skillify check [path]        — 10-item post-task audit (promoted)
- gbrain skillpack list / install     — curated 25-skill bundle, atomic install
- gbrain skillpack diff <name>        — per-file diff preview
- gbrain routing-eval                 — dedicated CI verb for Check 5 fixtures

CHECK-RESOLVABLE REFACTOR

- Accepts AGENTS.md as a resolver file alongside RESOLVER.md, at either
  the skills directory or one level up (workspace root layout).
- Auto-derives the skill manifest by walking skills/*/SKILL.md when
  manifest.json is missing.
- Splits ResolvableReport into errors[] + warnings[] so advisory checks
  (filing audit, routing gaps, DRY violations) don't break CI by default.
- New --strict opt-in flag promotes warnings to exit 1.

BRAIN-FIRST CONVENTION

- skills/conventions/brain-first.md expanded from 5-step lookup guide to
  full sub-agent reference: tool inventory, lookup chain, score thresholds,
  authority hierarchy, sync rules, entity page conventions, sub-agent
  propagation rule.

PRODUCTION-READINESS HARDENING (this branch's review pass)

- routing-eval --llm: emits stderr placeholder notice + runs structural
  layer only. README, CHANGELOG, CLI help all rewritten consistently.
  Was a silent no-op against documented contract.
- skillpack installer: receipt comment in fence (cumulative-slugs="...")
  preserves single-skill-install accumulation while letting install --all
  prune removed bundle skills cleanly. Unknown rows preserved + stderr
  warning for the operating agent. Pre-v0.19 fences upgrade silently.
- skillify scaffold: resolver-row regex broadened to detect backticked,
  quoted, and bare path forms. No duplicate row on --force after the
  user normalizes formatting.
- scripts/check-privacy.sh: now wired into package.json test chain so
  the wintermute-ban rule is actually enforced. New regression test.
- E2E Tier 2 (LLM skills) promoted from schedule-only to required per-PR
  CI. Local Tier 1 + Tier 2 verified clean.
- Stale v0.17/v0.18 version labels rewritten across new files.

TESTS

- test/routing-eval-cli.test.ts: 4 cases covering --llm warn semantics
- test/privacy-script-wired.test.ts: regression guard for CI wiring
- test/skillpack-install.test.ts: 4 new cases for receipt + cumulative
  + unknown-row preserve+warn + pre-v0.19 upgrade path
- test/skillify-scaffold.test.ts: 4 new cases for broadened regex

VERIFICATION

- bun test: 2237 pass / 18 known PGLite-contention flakes (CI green;
  documented as P3 dev-experience in TODOS.md)
- bun run typecheck: clean
- bun run test:e2e: 18/19 files green (1 pre-existing flake on master,
  not caused by this branch — verified via git stash)
- llms.txt + llms-full.txt regenerated to match README + CHANGELOG

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: scrub banned fork name from public artifacts

The privacy guard wired into the test chain in this branch caught 5
pre-existing references to the banned OpenClaw fork name in CHANGELOG.md
(2x), skills/migrations/v0.19.0.md (1x), src/cli.ts (1x), and
src/commands/sync.ts (1x). All originated in master's v0.19.0 release
notes and migration doc when the privacy script existed but wasn't
wired into CI yet.

Replacements per CLAUDE.md privacy mapping:
- Origin-story copy (CHANGELOG layer narratives, code comments naming
  the production deployment that drove the feature) → "Garry's OpenClaw"
- Reader-facing migration step → "your OpenClaw"

No code semantics changed. Comments + headings only.

Verification: scripts/check-privacy.sh exits 0, full CI guard chain
green (privacy + jsonb + progress + wasm + typecheck).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump VERSION to 0.24.0 + new CHANGELOG entry

Bump branch version above master's v0.21.0 per CLAUDE.md
"CHANGELOG + VERSION are branch-scoped" rule. The new v0.24.0 entry at
the top of CHANGELOG covers what THIS branch adds vs master:

- routing-eval --llm honesty pass (4-surface contract drift fix)
- skillpack installer cumulative-receipt + unknown-row preserve+warn
  (the Codex-caught regression that would have shipped in master if
  the original v0.19.0 had landed without this branch's review pass)
- skillify scaffold resolver-row regex broadening (backtick + quoted
  + bare forms; idempotency contract preserved under hand-editing)
- 5 banned-name leaks scrubbed from public artifacts
- check-privacy.sh wired into CI test chain + regression guard test
- 7 stale v0.17/v0.18 version labels rewritten across 5 files
- Tier 2 (LLM-skills E2E) promoted from schedule-only to required per-PR

VERSION 0.21.0 → 0.24.0
package.json version field synced.
llms.txt + llms-full.txt regenerated (no content drift; sizes match).

Test suite: 62/62 green across the 5 test files this branch added or
extended (routing-eval-cli, privacy-script-wired, skillpack-install,
skillify-scaffold, build-llms).

CI guards: privacy + jsonb + progress + wasm + typecheck all clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: update project documentation for v0.24.0

Auto-discovered drift via /document-release after the v0.24.0 hardening
pass landed. All factual corrections clearly warranted by the diff.

CLAUDE.md:
- Skillpack installer: documented the cumulative-slugs receipt comment,
  install --all prune semantics, unknown-row preserve+warn behavior,
  and pre-v0.24 silent upgrade. Was previously vague about
  "tracks a skill manifest so install --update diffs cleanly" without
  explaining what the receipt is or why it matters.
- routing-eval: replaced the false claim that --llm "opts into a Haiku
  tie-break layer for CI." Now correctly describes the placeholder
  semantic landed in v0.24.0 (stderr notice + structural-only run).

README.md:
- Skillpack section: added one paragraph on the receipt comment + the
  user-visible stderr message for hand-added rows. Connects the safe
  rerun promise to the v0.24.0 implementation that actually enforces it.

CONTRIBUTING.md:
- Running tests section: now recommends `bun run test` (full CI guard
  chain + typecheck + tests) before pushing. Names each guard so new
  contributors understand what catches what. The privacy guard (newly
  wired in v0.24.0) is one of these — without `bun run test` you'd skip
  it locally and find out from CI.

llms-full.txt: regenerated to reflect CLAUDE.md changes.

Verification: full guard chain green locally (privacy + jsonb + progress
+ wasm + typecheck).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Garry Tan <garry@ycombinator.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 23:17:54 -07:00

441 lines
14 KiB
TypeScript

/**
* gbrain skillpack <list|install|diff|check> — W5 CLI namespace.
*
* D-CX-2 pattern: unified subcommand namespace. The pre-existing
* `skillpack-check` command keeps its top-level name for backwards
* compat but is also reachable as `gbrain skillpack check` here.
*/
import { existsSync, readFileSync } from 'fs';
import { isAbsolute, resolve as resolvePath, join } from 'path';
import {
bundledSkillSlugs,
findGbrainRoot,
loadBundleManifest,
BundleError,
} from '../core/skillpack/bundle.ts';
import {
planInstall,
applyInstall,
diffSkill,
InstallError,
} from '../core/skillpack/installer.ts';
import { autoDetectSkillsDir } from '../core/repo-root.ts';
const HELP_TOP = `gbrain skillpack <subcommand> [options]
Subcommands:
list Print every skill bundled in openclaw.plugin.json.
install <name> Copy one skill (or --all) into a target workspace.
Data-loss protected: per-file diff, --overwrite-local
escape hatch, lockfile + atomic AGENTS.md update.
diff <name> Show per-file diff status between the bundle and
the target workspace for one skill.
check Run the skillpack health report (same as the
top-level \`gbrain skillpack-check\`).
Run \`gbrain skillpack <subcommand> --help\` for per-subcommand options.
`;
export async function runSkillpack(args: string[]): Promise<void> {
const sub = args[0];
const rest = args.slice(1);
if (!sub || sub === '--help' || sub === '-h') {
console.log(HELP_TOP);
process.exit(0);
}
if (sub === 'list') {
await runList(rest);
return;
}
if (sub === 'install') {
await runInstall(rest);
return;
}
if (sub === 'diff') {
await runDiff(rest);
return;
}
if (sub === 'check') {
const { runSkillpackCheck } = await import('./skillpack-check.ts');
await runSkillpackCheck(rest);
return;
}
console.error(`Unknown subcommand: ${sub}\n`);
console.error(HELP_TOP);
process.exit(2);
}
// ---------------------------------------------------------------------------
// list
// ---------------------------------------------------------------------------
const HELP_LIST = `gbrain skillpack list [--json]
Print every skill bundled in openclaw.plugin.json, one per line.
Exit 0 always (unless the manifest is missing/malformed).
`;
async function runList(args: string[]): Promise<void> {
if (args.includes('--help') || args.includes('-h')) {
console.log(HELP_LIST);
process.exit(0);
}
const json = args.includes('--json');
const gbrainRoot = findGbrainRoot();
if (!gbrainRoot) {
console.error(
'Error: could not find gbrain repo root. Run this from inside a gbrain checkout, or pass --gbrain-root (not yet implemented).',
);
process.exit(2);
}
let manifest;
try {
manifest = loadBundleManifest(gbrainRoot);
} catch (err) {
console.error(`skillpack list: ${(err as Error).message}`);
process.exit(2);
}
const slugs = bundledSkillSlugs(manifest);
if (json) {
const entries = slugs.map(slug => {
const skillMd = join(gbrainRoot, 'skills', slug, 'SKILL.md');
let description: string | null = null;
if (existsSync(skillMd)) {
const body = readFileSync(skillMd, 'utf-8');
const fm = body.match(/^---\n([\s\S]*?)\n---/);
if (fm) {
const descMatch = fm[1].match(/^description:\s*["']?([^\n"']+)/m);
if (descMatch) description = descMatch[1].trim();
}
}
return { name: slug, description };
});
console.log(
JSON.stringify(
{ name: manifest.name, version: manifest.version, skills: entries },
null,
2,
),
);
} else {
console.log(`${manifest.name} ${manifest.version} bundle — ${slugs.length} skills:`);
for (const slug of slugs) {
console.log(` ${slug}`);
}
}
process.exit(0);
}
// ---------------------------------------------------------------------------
// install
// ---------------------------------------------------------------------------
interface InstallFlags {
help: boolean;
json: boolean;
dryRun: boolean;
force: boolean;
overwriteLocal: boolean;
forceUnlock: boolean;
all: boolean;
skillName: string | null;
skillsDir: string | null;
workspace: string | null;
}
const HELP_INSTALL = `gbrain skillpack install <name> | --all [options]
Copy bundled skills into a target OpenClaw workspace. The target is
auto-detected (\$OPENCLAW_WORKSPACE, then ~/.openclaw/workspace, then
--skills-dir). Shared convention files are installed alongside
(dependency closure per codex D-CX-10).
Arguments:
<name> Install a single skill by slug.
--all Install every skill in openclaw.plugin.json#skills.
Options:
--dry-run Preview file operations; no writes.
--overwrite-local For per-file diff: overwrite target files that
differ from the bundle. Default: skip locally-
modified files for data-loss protection.
--force-unlock Acquire the skillpack lockfile even if a stale
peer lock exists.
--skills-dir PATH Override target skills directory.
--workspace PATH Override target workspace (parent of skills/).
--json Machine-readable envelope.
--help Show this message.
Exit codes:
0 success
1 some files skipped due to local-modification protection
2 setup error (no workspace, no bundle, lock held)
`;
function parseInstallFlags(argv: string[]): InstallFlags {
const f: InstallFlags = {
help: false,
json: false,
dryRun: false,
force: false,
overwriteLocal: false,
forceUnlock: false,
all: false,
skillName: null,
skillsDir: null,
workspace: null,
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--help' || a === '-h') f.help = true;
else if (a === '--json') f.json = true;
else if (a === '--dry-run') f.dryRun = true;
else if (a === '--force') f.force = true;
else if (a === '--overwrite-local') f.overwriteLocal = true;
else if (a === '--force-unlock') f.forceUnlock = true;
else if (a === '--all') f.all = true;
else if (a === '--skills-dir') {
f.skillsDir = argv[i + 1] ?? null;
i++;
} else if (a?.startsWith('--skills-dir=')) {
f.skillsDir = a.slice('--skills-dir='.length) || null;
} else if (a === '--workspace') {
f.workspace = argv[i + 1] ?? null;
i++;
} else if (a?.startsWith('--workspace=')) {
f.workspace = a.slice('--workspace='.length) || null;
} else if (a && !a.startsWith('--') && !f.skillName) {
f.skillName = a;
}
}
return f;
}
function resolveAbs(p: string): string {
return isAbsolute(p) ? p : resolvePath(process.cwd(), p);
}
async function runInstall(args: string[]): Promise<void> {
const flags = parseInstallFlags(args);
if (flags.help) {
console.log(HELP_INSTALL);
process.exit(0);
}
if (!flags.all && !flags.skillName) {
console.error('Error: pass a skill name or --all.\n');
console.error(HELP_INSTALL);
process.exit(2);
}
const gbrainRoot = findGbrainRoot();
if (!gbrainRoot) {
console.error('Error: could not find gbrain repo root.');
process.exit(2);
}
// Resolve target: workspace (parent of skills/) is required for the
// managed block + lockfile. skillsDir defaults to workspace/skills.
let targetWorkspace: string | null = flags.workspace
? resolveAbs(flags.workspace)
: null;
let targetSkillsDir: string | null = flags.skillsDir
? resolveAbs(flags.skillsDir)
: null;
if (!targetSkillsDir) {
const detected = autoDetectSkillsDir();
if (detected.dir) {
targetSkillsDir = detected.dir;
if (!targetWorkspace) {
// workspace is parent of skills/
targetWorkspace = resolvePath(targetSkillsDir, '..');
}
}
}
if (!targetSkillsDir) {
console.error(
'Error: could not find a target skills directory. Set $OPENCLAW_WORKSPACE or pass --skills-dir / --workspace.',
);
process.exit(2);
}
if (!targetWorkspace) {
targetWorkspace = resolvePath(targetSkillsDir, '..');
}
try {
const plan = planInstall({
gbrainRoot,
targetWorkspace,
targetSkillsDir,
skillSlug: flags.all ? null : flags.skillName!,
overwriteLocal: flags.overwriteLocal,
dryRun: flags.dryRun,
forceUnlock: flags.forceUnlock,
});
const result = applyInstall(plan, {
gbrainRoot,
targetWorkspace,
targetSkillsDir,
skillSlug: flags.all ? null : flags.skillName!,
overwriteLocal: flags.overwriteLocal,
dryRun: flags.dryRun,
forceUnlock: flags.forceUnlock,
});
if (flags.json) {
console.log(
JSON.stringify(
{
ok: result.summary.skippedLocallyModified === 0,
dryRun: result.dryRun,
gbrainRoot,
targetWorkspace,
targetSkillsDir,
summary: result.summary,
managedBlock: result.managedBlock,
files: result.files.map(f => ({
source: f.source,
target: f.target,
outcome: f.outcome,
sharedDep: f.sharedDep,
})),
},
null,
2,
),
);
} else {
const label = flags.dryRun ? 'skillpack install --dry-run' : 'skillpack install';
console.log(
`${label}: ${result.summary.wroteNew} new, ${result.summary.wroteOverwrite} overwrites, ${result.summary.skippedIdentical} unchanged, ${result.summary.skippedLocallyModified} skipped (local edits)`,
);
for (const f of result.files) {
if (f.outcome === 'skipped_identical') continue;
const tag = f.outcome.padEnd(25);
const dep = f.sharedDep ? ' [shared]' : '';
console.log(` ${tag} ${f.target}${dep}`);
}
if (result.managedBlock.applied) {
console.log(` managed-block ${result.managedBlock.resolverFile}`);
} else if (result.managedBlock.skippedReason === 'resolver_not_found') {
console.log(
` warn: no RESOLVER.md / AGENTS.md in ${targetWorkspace} or ${targetSkillsDir} — managed block not written. Create one and re-run.`,
);
}
if (result.summary.skippedLocallyModified > 0) {
console.log(
`\nNote: ${result.summary.skippedLocallyModified} file(s) differ from the bundle and were skipped. Pass --overwrite-local to replace them (loses local edits) or run \`gbrain skillpack diff <name>\` to inspect.`,
);
}
}
const exitCode = result.summary.skippedLocallyModified > 0 ? 1 : 0;
process.exit(exitCode);
} catch (err) {
if (err instanceof InstallError || err instanceof BundleError) {
if (flags.json) {
console.log(
JSON.stringify(
{ ok: false, error: (err as Error & { code?: string }).code, message: err.message },
null,
2,
),
);
} else {
console.error(`skillpack install: ${err.message}`);
}
process.exit(err instanceof InstallError && err.code === 'lock_held' ? 2 : 2);
}
throw err;
}
}
// ---------------------------------------------------------------------------
// diff
// ---------------------------------------------------------------------------
const HELP_DIFF = `gbrain skillpack diff <name> [options]
Show per-file diff status between the bundle and the target workspace
for one skill. Read-only; no writes.
Options:
--skills-dir PATH Override target skills directory.
--json Machine-readable envelope.
--help Show this message.
Exit codes:
0 every file matches the bundle
1 at least one file differs (or is missing)
`;
async function runDiff(args: string[]): Promise<void> {
const help = args.includes('--help') || args.includes('-h');
if (help) {
console.log(HELP_DIFF);
process.exit(0);
}
const json = args.includes('--json');
let targetSkillsDir: string | null = null;
let skillName: string | null = null;
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--skills-dir') {
targetSkillsDir = args[i + 1] ? resolveAbs(args[i + 1]) : null;
i++;
} else if (a?.startsWith('--skills-dir=')) {
targetSkillsDir = resolveAbs(a.slice('--skills-dir='.length));
} else if (a && !a.startsWith('--') && !skillName) {
skillName = a;
}
}
if (!skillName) {
console.error('Error: pass a skill name.\n');
console.error(HELP_DIFF);
process.exit(2);
}
const gbrainRoot = findGbrainRoot();
if (!gbrainRoot) {
console.error('Error: could not find gbrain repo root.');
process.exit(2);
}
if (!targetSkillsDir) {
const detected = autoDetectSkillsDir();
if (detected.dir) targetSkillsDir = detected.dir;
}
if (!targetSkillsDir) {
console.error('Error: pass --skills-dir or set $OPENCLAW_WORKSPACE.');
process.exit(2);
}
try {
const diffs = diffSkill(gbrainRoot, skillName, targetSkillsDir);
const clean = diffs.every(d => d.identical && d.existing);
if (json) {
console.log(JSON.stringify({ ok: clean, skillName, diffs }, null, 2));
} else {
console.log(`skillpack diff ${skillName}${targetSkillsDir}`);
for (const d of diffs) {
let tag: string;
if (!d.existing) tag = 'missing ';
else if (d.identical) tag = 'identical';
else tag = 'differs ';
console.log(` ${tag} ${d.target} (src ${d.sourceBytes}B / tgt ${d.targetBytes}B)`);
}
console.log(clean ? '\n✓ all files match the bundle.' : '\n(run `gbrain skillpack install <name> --overwrite-local` to replace local-modified files.)');
}
process.exit(clean ? 0 : 1);
} catch (err) {
if (err instanceof BundleError) {
if (json) {
console.log(JSON.stringify({ ok: false, error: err.code, message: err.message }, null, 2));
} else {
console.error(`skillpack diff: ${err.message}`);
}
process.exit(2);
}
throw err;
}
}