mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
5b5fcd5be3
commit
a31418e3e9
@@ -163,6 +163,7 @@ export function resolveSkillsDir(flags: Flags): {
|
||||
openclaw_workspace_env_root: '$OPENCLAW_WORKSPACE (AGENTS.md at workspace root)',
|
||||
openclaw_workspace_home: '~/.openclaw/workspace/skills',
|
||||
openclaw_workspace_home_root: '~/.openclaw/workspace (AGENTS.md at workspace root)',
|
||||
cwd_walk_up: 'skills/ found by walking up from cwd (v0.33)',
|
||||
cwd_skills: './skills',
|
||||
install_path: 'gbrain install path (read-only fallback)',
|
||||
}[detected.source!]!;
|
||||
|
||||
+35
-4
@@ -42,6 +42,7 @@ export type SkillsDirSource =
|
||||
| 'openclaw_workspace_env_root'
|
||||
| 'openclaw_workspace_home'
|
||||
| 'openclaw_workspace_home_root'
|
||||
| 'cwd_walk_up'
|
||||
| 'repo_root'
|
||||
| 'cwd_skills'
|
||||
| 'install_path';
|
||||
@@ -138,6 +139,30 @@ export function autoDetectSkillsDir(
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
|
||||
// 1b. (v0.33) Walk up from cwd looking for any `skills/` dir. No
|
||||
// resolver-file gating — this is for non-OpenClaw hosts
|
||||
// (~/git/wintermute, ~/git/neuromancer, ~/git/zion) that own
|
||||
// their `skills/` directly. Stops at the first ancestor with a
|
||||
// `skills/` subdirectory. Comes after $OPENCLAW_WORKSPACE so
|
||||
// R5 (precedence regression) holds: explicit env still wins.
|
||||
// Comes before ~/.openclaw/workspace so that `cd ~/git/wintermute
|
||||
// && gbrain skillpack scaffold X` finds wintermute, not an
|
||||
// implicit fallback to OpenClaw's default install.
|
||||
{
|
||||
let dir = startDir;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const candidate = join(dir, 'skills');
|
||||
if (existsSync(candidate)) {
|
||||
return { dir: candidate, source: 'cwd_walk_up' };
|
||||
}
|
||||
const parent = join(dir, '..');
|
||||
const resolvedParent = resolvePath(parent);
|
||||
const resolvedDir = resolvePath(dir);
|
||||
if (resolvedParent === resolvedDir) break;
|
||||
dir = resolvedParent;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. ~/.openclaw/workspace as the default user-level OpenClaw deployment.
|
||||
if (env.HOME) {
|
||||
const workspace = join(env.HOME, '.openclaw', 'workspace');
|
||||
@@ -155,7 +180,12 @@ export function autoDetectSkillsDir(
|
||||
return { dir: join(repoRoot, 'skills'), source: 'repo_root' };
|
||||
}
|
||||
|
||||
// 4. ./skills fallback.
|
||||
// 4. ./skills fallback (with hasResolverFile gate). Functionally
|
||||
// subsumed by tier 1b's `cwd_walk_up` (broader, no resolver gate),
|
||||
// but kept for callers that explicitly want to distinguish a
|
||||
// resolver-bearing fallback from a plain skills-dir match.
|
||||
// In practice this tier never fires after 1b — cwd_walk_up matches
|
||||
// the same path first. Kept in the type union for back-compat.
|
||||
const cwdSkills = join(startDir, 'skills');
|
||||
if (hasResolverFile(cwdSkills)) {
|
||||
return { dir: cwdSkills, source: 'cwd_skills' };
|
||||
@@ -227,9 +257,10 @@ export const AUTO_DETECT_HINT = [
|
||||
` 1. --skills-dir flag`,
|
||||
` 2. $GBRAIN_SKILLS_DIR (explicit operator override)`,
|
||||
` 3. $OPENCLAW_WORKSPACE/{skills/,}{${RESOLVER_FILENAMES.join(',')}}`,
|
||||
` 4. ~/.openclaw/workspace/{skills/,}{${RESOLVER_FILENAMES.join(',')}}`,
|
||||
` 5. repo root with skills/${RESOLVER_FILENAMES.join(' or skills/')}`,
|
||||
` 6. ./skills/${RESOLVER_FILENAMES.join(' or ./skills/')}`,
|
||||
` 4. cwd + walk-up for any skills/ directory (v0.33; for non-OpenClaw hosts)`,
|
||||
` 5. ~/.openclaw/workspace/{skills/,}{${RESOLVER_FILENAMES.join(',')}}`,
|
||||
` 6. repo root with skills/${RESOLVER_FILENAMES.join(' or skills/')}`,
|
||||
` 7. ./skills/${RESOLVER_FILENAMES.join(' or ./skills/')}`,
|
||||
].join('\n');
|
||||
|
||||
/**
|
||||
|
||||
+58
-4
@@ -82,12 +82,15 @@ describe('findRepoRoot', () => {
|
||||
expect(found.source).toBe('openclaw_workspace_home');
|
||||
});
|
||||
|
||||
it('auto-detect: falls back to ./skills as final candidate', () => {
|
||||
it('auto-detect: cwd has ./skills directory → cwd_walk_up tier (v0.33)', () => {
|
||||
// Behavior unchanged from pre-v0.33 except for the reported source
|
||||
// — cwd_walk_up fires first now that the broader tier exists.
|
||||
// cwd_skills (resolver-gated) is functionally subsumed.
|
||||
const cwd = scratch();
|
||||
seedSkillsDir(join(cwd, 'skills'));
|
||||
const found = autoDetectSkillsDir(cwd, {});
|
||||
expect(found.dir).toBe(join(cwd, 'skills'));
|
||||
expect(found.source).toBe('cwd_skills');
|
||||
expect(found.source).toBe('cwd_walk_up');
|
||||
});
|
||||
|
||||
it('D-CX-4: $OPENCLAW_WORKSPACE wins over repo-root walk when explicitly set', () => {
|
||||
@@ -106,14 +109,18 @@ describe('findRepoRoot', () => {
|
||||
expect(found.source).toBe('openclaw_workspace_env');
|
||||
});
|
||||
|
||||
it('D-CX-4: repo-root walk still wins when OPENCLAW_WORKSPACE is NOT set', () => {
|
||||
it('D-CX-4 + v0.33: walk-up from nested finds the repo skills/ via cwd_walk_up', () => {
|
||||
// Pre-v0.33: this returned source='repo_root' via findRepoRoot's
|
||||
// isGbrainRepoRoot gate. v0.33 added a broader cwd_walk_up tier
|
||||
// ahead of repo_root — same dir matched, different source name.
|
||||
// Behavior preserved; source label changed.
|
||||
const root = scratch();
|
||||
seedRepo(root);
|
||||
const nested = join(root, 'a', 'b');
|
||||
mkdirSync(nested, { recursive: true });
|
||||
const found = autoDetectSkillsDir(nested, {});
|
||||
expect(found.dir).toBe(join(root, 'skills'));
|
||||
expect(found.source).toBe('repo_root');
|
||||
expect(found.source).toBe('cwd_walk_up');
|
||||
});
|
||||
|
||||
it('W1: AGENTS.md at skills dir is accepted (OpenClaw skills-subdir variant)', () => {
|
||||
@@ -257,6 +264,53 @@ describe('findRepoRoot', () => {
|
||||
expect(AUTO_DETECT_HINT_READ_ONLY).toContain('$OPENCLAW_WORKSPACE');
|
||||
});
|
||||
|
||||
// ─── v0.33 cwd_walk_up tier (D3) ──────────────────────────────
|
||||
// Non-OpenClaw hosts (wintermute, neuromancer, zion) own their
|
||||
// skills/ dir directly. The new tier resolves these without
|
||||
// requiring a resolver file or gbrain-shape `src/cli.ts`.
|
||||
|
||||
it('v0.33 cwd_walk_up: cwd has skills/ but no resolver, no src/cli.ts (wintermute shape)', () => {
|
||||
const wintermute = scratch();
|
||||
mkdirSync(join(wintermute, 'skills'));
|
||||
// Intentionally: no RESOLVER.md / AGENTS.md, no src/cli.ts.
|
||||
const found = autoDetectSkillsDir(wintermute, {});
|
||||
expect(found.dir).toBe(join(wintermute, 'skills'));
|
||||
expect(found.source).toBe('cwd_walk_up');
|
||||
});
|
||||
|
||||
it('v0.33 cwd_walk_up: walks up from nested cwd to first ancestor with skills/', () => {
|
||||
const wintermute = scratch();
|
||||
mkdirSync(join(wintermute, 'skills'));
|
||||
const nested = join(wintermute, 'src', 'commands');
|
||||
mkdirSync(nested, { recursive: true });
|
||||
const found = autoDetectSkillsDir(nested, {});
|
||||
expect(found.dir).toBe(join(wintermute, 'skills'));
|
||||
expect(found.source).toBe('cwd_walk_up');
|
||||
});
|
||||
|
||||
it('R5 regression: $OPENCLAW_WORKSPACE still wins when both env and cwd-skills exist', () => {
|
||||
// Pre-v0.33 R5: explicit env beats implicit cwd. The new cwd_walk_up
|
||||
// tier MUST NOT regress this — explicit operator intent always wins.
|
||||
const wintermute = scratch();
|
||||
mkdirSync(join(wintermute, 'skills'));
|
||||
const openclawWs = scratch();
|
||||
mkdirSync(join(openclawWs, 'skills'));
|
||||
writeFileSync(join(openclawWs, 'skills', 'AGENTS.md'), '# agents\n');
|
||||
|
||||
const found = autoDetectSkillsDir(wintermute, { OPENCLAW_WORKSPACE: openclawWs });
|
||||
expect(found.dir).toBe(join(openclawWs, 'skills'));
|
||||
expect(found.source).toBe('openclaw_workspace_env');
|
||||
});
|
||||
|
||||
it('v0.33 cwd_walk_up: no skills/ anywhere up to root → returns null', () => {
|
||||
const empty = scratch();
|
||||
const nested = join(empty, 'deep', 'nested', 'dir');
|
||||
mkdirSync(nested, { recursive: true });
|
||||
const found = autoDetectSkillsDir(nested, {});
|
||||
expect(found.dir).toBeNull();
|
||||
expect(found.source).toBeNull();
|
||||
});
|
||||
|
||||
it('v0.31.7 D5 regression guard: autoDetectSkillsDir does NOT install-path-fallback', () => {
|
||||
// CRITICAL: this test pins the read-path/write-path split. The shared
|
||||
// autoDetectSkillsDir is used by write-path callers (skillpack install,
|
||||
|
||||
Reference in New Issue
Block a user