From a31418e3e923cef6a372020118436c43b6a81404 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 17 May 2026 15:02:21 -0700 Subject: [PATCH] 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 --- src/commands/check-resolvable.ts | 1 + src/core/repo-root.ts | 39 +++++++++++++++++--- test/repo-root.test.ts | 62 +++++++++++++++++++++++++++++--- 3 files changed, 94 insertions(+), 8 deletions(-) diff --git a/src/commands/check-resolvable.ts b/src/commands/check-resolvable.ts index f047af7a0..0de7fdd07 100644 --- a/src/commands/check-resolvable.ts +++ b/src/commands/check-resolvable.ts @@ -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!]!; diff --git a/src/core/repo-root.ts b/src/core/repo-root.ts index 86ce6bc88..4c1d289ce 100644 --- a/src/core/repo-root.ts +++ b/src/core/repo-root.ts @@ -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'); /** diff --git a/test/repo-root.test.ts b/test/repo-root.test.ts index 9e1f8c46a..9f969962c 100644 --- a/test/repo-root.test.ts +++ b/test/repo-root.test.ts @@ -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,