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:
Garry Tan
2026-05-17 15:02:21 -07:00
co-authored by Claude Opus 4.7
parent 5b5fcd5be3
commit a31418e3e9
3 changed files with 94 additions and 8 deletions
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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,