From bcf1422bc86a73cc6422ec7e12a458acd413f026 Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Sat, 22 Aug 2026 14:19:31 +0000 Subject: [PATCH 1/5] fix(discovery): preserve session agent profiles Thread the resolved trusted home and agent directory through session startup, rescope, runtime skill tooling, native discovery, commands, hooks, and prompt assembly. Preserve current SDK tail lifecycle behavior while adding profile-isolation regressions from PR #4784. Lore-id: 4784-skill-scope-recovery Constraint: current dev SDK tail ordering and lifecycle semantics from #4833 must remain unchanged Constraint: explicit agentDir wins and injected-home profiles stay isolated Constraint: trusted-home capability loading remains fail-closed Tested: focused user-scope suites; SDK daemon CLI e2e; coding-agent check; native check/build; state gates; virtual integration; affected planner; G002/default/plugin/rebrand gates Not-tested: full root check:ts timed out in SDK closure manifest execution after 15 minutes Confidence: high Scope-risk: regression-risk Reversibility: revert-commit --- docs/customization.md | 14 +- docs/skills.md | 32 ++- packages/coding-agent/src/capability/index.ts | 25 +- packages/coding-agent/src/capability/types.ts | 5 +- .../coding-agent/src/cli/customize-doctor.ts | 4 +- packages/coding-agent/src/cli/skills-cli.ts | 1 + .../src/config/settings-schema.ts | 2 +- .../coding-agent/src/discovery/builtin.ts | 50 ++-- .../coding-agent/src/discovery/helpers.ts | 30 +++ .../src/extensibility/gjc-plugins/schema.ts | 2 +- .../src/extensibility/hooks/loader.ts | 15 +- .../extensibility/runtime-skill-discovery.ts | 43 +-- .../src/extensibility/skill-management.ts | 45 ++-- .../coding-agent/src/extensibility/skills.ts | 39 +-- .../src/extensibility/slash-commands.ts | 7 +- .../src/modes/interactive-mode.ts | 2 +- packages/coding-agent/src/modes/print-mode.ts | 7 +- .../src/prompts/tools/skill-discovery.md | 2 +- packages/coding-agent/src/sdk/session.ts | 21 +- packages/coding-agent/src/system-prompt.ts | 17 +- packages/coding-agent/src/tools/index.ts | 12 +- .../coding-agent/src/tools/skill-discovery.ts | 3 +- packages/coding-agent/src/tools/skill.ts | 3 +- .../src/tools/tool-catalog.generated.ts | 2 +- .../builtin-always-apply-rules.test.ts | 14 +- .../test/discovery/builtin-rules-md.test.ts | 19 +- .../discovery/builtin-user-agent-dir.test.ts | 135 ++++++++++ .../issue-4769-user-scope-agent-dir.test.ts | 246 ++++++++++++++++++ .../test/skill-management.test.ts | 82 ++++++ packages/coding-agent/test/skills.test.ts | 92 +++++-- .../test/system-prompt-dedup.test.ts | 10 +- .../test/tools/skill-discovery.test.ts | 178 ++++++++++--- .../coding-agent/test/tools/skill.test.ts | 25 +- schemas/config.schema.json | 2 +- 34 files changed, 982 insertions(+), 204 deletions(-) create mode 100644 packages/coding-agent/test/discovery/builtin-user-agent-dir.test.ts create mode 100644 packages/coding-agent/test/issue-4769-user-scope-agent-dir.test.ts diff --git a/docs/customization.md b/docs/customization.md index a0a46b1383..cc3c4cacc3 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -10,9 +10,10 @@ GJC has two canonical persistence scopes: - **Project:** `/.gjc/` (the repository's project root, or the opened project directory when there is no repository root). -- **User:** `~/.gjc/agent/` (the canonical user agent directory; the configured +- **User:** the agent directory printed by `gjc config dir` (`~/.gjc/agent/` by + default; `--agent-dir` / `GJC_CODING_AGENT_DIR` move it). The configured home-relative GJC config root and its legacy skill roots are described in - [Skills](./skills.md)). + [Skills](./skills.md). These `.gjc` scopes are the long-term GJC authority. A normal standalone session loads native project/user configuration from them, applies the native @@ -43,9 +44,9 @@ wizard (or use an explicit non-interactive command). | Surface | Native GJC project | Native GJC user | Claude Code project source | Claude Code user source | Codex project source | Codex user source | GJC treatment | | --- | --- | --- | --- | --- | --- | --- | --- | -| **MCP** | `/.gjc/mcp.json` | `~/.gjc/agent/mcp.json` | `/.mcp.json` for the #4492 import transaction; the doctor also reports `.claude/mcp.json` and `.claude/.mcp.json` convention candidates | `~/.claude.json` for the import transaction | `/.codex/config.toml`, `[mcp_servers.]` | `~/.codex/config.toml`, `[mcp_servers.]` | Only native `.gjc` MCPs autoload in ordinary standalone sessions. Import adapters normalize bounded JSON/TOML entries, validate them, and write native `mcp.json`. | -| **Skill** | `/.gjc/skills//SKILL.md` | `~/.gjc/agent/skills//SKILL.md` | `/.claude/skills//SKILL.md` | `~/.claude/skills//SKILL.md` | `/.codex/skills//SKILL.md` | `~/.codex/skills//SKILL.md` | Native `.gjc` skills are loaded by GJC. Claude/Codex skills are import candidates; they are not loaded directly into a GJC session. | -| **Hook** | `/.gjc/hooks/pre|post/` | `~/.gjc/agent/hooks/pre|post/` | `/.claude/hooks/pre|post/` | `~/.claude/hooks/pre|post/` when explicitly selected for import | `/.codex/hooks/pre-.ts` / `post-.ts` | `~/.codex/hooks/pre-.ts` / `post-.ts` when explicitly selected for import | Ordinary sessions execute only canonical native `.gjc` directory hooks. Claude/Codex layouts are explicit import and diagnostic sources; accepted imports are normalized to canonical `pre`/`post` phases. Codex-managed `hooks.json` remains Codex-owned. | +| **MCP** | `/.gjc/mcp.json` | `/mcp.json` (`~/.gjc/agent/mcp.json` by default) | `/.mcp.json` for the #4492 import transaction; the doctor also reports `.claude/mcp.json` and `.claude/.mcp.json` convention candidates | `~/.claude.json` for the import transaction | `/.codex/config.toml`, `[mcp_servers.]` | `~/.codex/config.toml`, `[mcp_servers.]` | Only native `.gjc` MCPs autoload in ordinary standalone sessions. Import adapters normalize bounded JSON/TOML entries, validate them, and write native `mcp.json`. | +| **Skill** | `/.gjc/skills//SKILL.md` | `/skills//SKILL.md` (see `gjc config dir`) | `/.claude/skills//SKILL.md` | `~/.claude/skills//SKILL.md` | `/.codex/skills//SKILL.md` | `~/.codex/skills//SKILL.md` | Native `.gjc` skills are loaded by GJC. Claude/Codex skills are import candidates; they are not loaded directly into a GJC session. | +| **Hook** | `/.gjc/hooks/pre|post/` | `/hooks/pre|post/` (`~/.gjc/agent/hooks/pre|post/` by default) | `/.claude/hooks/pre|post/` | `~/.claude/hooks/pre|post/` when explicitly selected for import | `/.codex/hooks/pre-.ts` / `post-.ts` | `~/.codex/hooks/pre-.ts` / `post-.ts` when explicitly selected for import | Ordinary sessions execute only canonical native `.gjc` directory hooks. Claude/Codex layouts are explicit import and diagnostic sources; accepted imports are normalized to canonical `pre`/`post` phases. Codex-managed `hooks.json` remains Codex-owned. | The Claude MCP paths above are intentionally explicit: the `/extensions` import implementation reads the project `.mcp.json` and user `~/.claude.json` @@ -67,7 +68,8 @@ boundary and the host-specific compatibility notes. 5. Choose a collision policy, review the normalized preview, and confirm. A project import writes beneath `/.gjc/`; a user import writes beneath -`~/.gjc/agent/`. A project source does not become user configuration, and a +the agent directory (`gjc config dir`; `~/.gjc/agent/` by default). A project +source does not become user configuration, and a user source does not write into the project unless the destination was selected as project. The source is read only during preview/apply and is never mutated. diff --git a/docs/skills.md b/docs/skills.md index edf1f4f239..df0f73e9f4 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -18,14 +18,25 @@ Project scope (trusted from the repository you open): |---|---| | `/.gjc/skills//SKILL.md` | Native GJC location; discovered from every ancestor of `cwd` up to the repo root (closest first) | -User scope (installed once, available in every project): +User scope (installed once, available in every project). The canonical root is +the **agent directory** printed by `gjc config dir` (`~/.gjc/agent` by default; +`--agent-dir` / `GJC_CODING_AGENT_DIR` move it) — the same directory +`gjc migrate` writes user skills into: | Location | Scope notes | |---|---| -| `~/.gjc/agent/skills//SKILL.md` | Canonical GJC user location | +| `/skills//SKILL.md` | Canonical GJC user location (the agent directory from `gjc config dir`) | +| `~/.gjc/agent/skills//SKILL.md` | Location of the canonical root in the default profile | | `/skills//SKILL.md` | Configured legacy root (`` is the home-relative directory from `GJC_CONFIG_DIR`, then `PI_CONFIG_DIR`, then `.gjc`) | | `~/.gjc/skills//SKILL.md` | Historical legacy user location (still honored) | +An agent-directory profile is a **separate user scope** (the same contract as +MCP user config): under `--agent-dir` / `GJC_CODING_AGENT_DIR`, only +`/skills` is scanned — the default profile's home-relative roots are +not read into a profile, and a profile's skills never leak into the default +profile. The legacy roots above apply in the default profile, exactly as +before. + ## Claude Code / Codex layouts (explicit import sources) GJC recognizes the Claude Code and Codex skill layouts but never loads them @@ -53,8 +64,8 @@ mkdir -p .gjc/skills/my-skill cp .claude/skills/my-skill/SKILL.md .gjc/skills/my-skill/SKILL.md # import one Codex user skill into your user-wide GJC skills -mkdir -p ~/.gjc/agent/skills/my-skill -cp ~/.codex/skills/my-skill/SKILL.md ~/.gjc/agent/skills/my-skill/SKILL.md +mkdir -p "$(gjc config dir)/skills/my-skill" +cp ~/.codex/skills/my-skill/SKILL.md "$(gjc config dir)/skills/my-skill/SKILL.md" ``` ## Installing a skill @@ -68,8 +79,8 @@ mkdir -p .gjc/skills/my-skill cp my-skill/SKILL.md .gjc/skills/my-skill/SKILL.md # user-wide, available in every project -mkdir -p ~/.gjc/agent/skills/my-skill -cp my-skill/SKILL.md ~/.gjc/agent/skills/my-skill/SKILL.md +mkdir -p "$(gjc config dir)/skills/my-skill" +cp my-skill/SKILL.md "$(gjc config dir)/skills/my-skill/SKILL.md" ``` Start a new session and invoke the skill with `/skill:my-skill`, or let the @@ -83,7 +94,7 @@ Skill discovery is controlled by three settings, all on by default: |---|---| | `skills.enabled` | Master switch for all filesystem skill discovery | | `skills.trustProjectSkills` | Load project-scoped `.gjc/skills` and surface project `.claude`/`.codex` import candidates | -| `skills.trustUserSkills` | Load user-scoped skills (`~/.gjc/agent/skills` and legacy roots) and surface user-home import candidates | +| `skills.trustUserSkills` | Load user-scoped skills (the agent directory's `skills` root and legacy home-relative roots) and surface user-home import candidates | ```sh gjc config set skills.trustProjectSkills false # ignore repo-controlled skills only @@ -106,8 +117,11 @@ Duplicate names resolve deterministically, first location wins: 1. project scope beats user scope; 2. within project scope, the `.gjc/skills` directory nearest to `cwd` wins (ancestors are walked from `cwd` up to the repo root, closest first); -3. within user scope: `/agent/skills` > legacy `/skills` > - legacy `~/.gjc/skills`. +3. within user scope: the agent directory's `skills` root (`gjc config dir`); + in the default profile its home-relative legacy roots follow at lower + precedence: `/agent/skills` > legacy `/skills` > + legacy `~/.gjc/skills` (under an agent-directory profile only the agent + directory is scanned). Shadowed duplicates are diagnosed rather than silent. Bundled workflow skill names are reserved: a project skill named `autoresearch`, `deep-interview`, `ralplan`, diff --git a/packages/coding-agent/src/capability/index.ts b/packages/coding-agent/src/capability/index.ts index a8a70184bb..e47f5c7593 100644 --- a/packages/coding-agent/src/capability/index.ts +++ b/packages/coding-agent/src/capability/index.ts @@ -229,14 +229,17 @@ function filterProviders(capability: Capability, options: LoadOptions): Pr /** * Load a capability by ID. */ -export async function loadCapability(capabilityId: string, options: LoadOptions = {}): Promise> { +async function loadCapabilityWithContext( + capabilityId: string, + options: LoadOptions, + home: string, +): Promise> { const capability = capabilities.get(capabilityId) as Capability | undefined; if (!capability) { throw new Error(`Unknown capability: "${capabilityId}"`); } const cwd = options.cwd ?? getProjectDir(); - const home = getTrustedHomeDir(); const userAgentDir = options.agentDir ? path.resolve(options.agentDir) : getAgentDir(); const repoRoot = await findRepoRoot(cwd); const ctx: LoadContext = { cwd, home, userAgentDir, repoRoot }; @@ -245,6 +248,24 @@ export async function loadCapability(capabilityId: string, options: LoadOptio return await loadImpl(capability, providers, ctx, options); } +export async function loadCapability(capabilityId: string, options: LoadOptions = {}): Promise> { + return await loadCapabilityWithContext(capabilityId, options, getTrustedHomeDir()); +} + +/** + * Load a capability against an explicitly supplied home for SDK compatibility + * seams that already expose a home-scoped discovery option. This stays internal + * to the coding-agent package; the public loadCapability API remains bound to + * the trusted process home. + */ +export async function loadCapabilityForHome( + capabilityId: string, + home: string, + options: LoadOptions = {}, +): Promise> { + return await loadCapabilityWithContext(capabilityId, options, path.resolve(home)); +} + // ============================================================================= // Provider Enable/Disable API // ============================================================================= diff --git a/packages/coding-agent/src/capability/types.ts b/packages/coding-agent/src/capability/types.ts index ea5962182b..bb6cf2b7cc 100644 --- a/packages/coding-agent/src/capability/types.ts +++ b/packages/coding-agent/src/capability/types.ts @@ -25,8 +25,9 @@ export interface LoadContext { * A native surface whose write path targets the agent directory resolves its * user scope from here, or discovery reads a different file than the one the * writer produced: `gjc mcp add` (user scope) writes `getMCPConfigPath("user")` - * under this directory. Surfaces whose writers are home-relative (skills) keep - * resolving from `home`. + * and `gjc migrate` / `gjc skill` write `/skills` under this + * directory. Home-relative legacy roots and the foreign Claude/Codex scans + * keep resolving from `home`. */ userAgentDir?: string; /** Git repository root (directory containing .git), or null if not in a repo */ diff --git a/packages/coding-agent/src/cli/customize-doctor.ts b/packages/coding-agent/src/cli/customize-doctor.ts index 96633342d4..b136b7444a 100644 --- a/packages/coding-agent/src/cli/customize-doctor.ts +++ b/packages/coding-agent/src/cli/customize-doctor.ts @@ -1013,7 +1013,9 @@ async function collectCommands(cwd: string, activeSettings: SettingsInstance): P const disabledExts = disabledExtensionIds(activeSettings); const disabledProviders = new Set(activeSettings.get("disabledProviders")); // Exact session-startup consumer (interactive/print modes). - const loadedNames = new Set((await loadSlashCommands({ cwd })).map(cmd => cmd.name)); + const loadedNames = new Set( + (await loadSlashCommands({ cwd, agentDir: activeSettings.getAgentDir() })).map(cmd => cmd.name), + ); const items: CustomizeDoctorItem[] = entries.map(entry => { const base = baseItem("command", entry); diff --git a/packages/coding-agent/src/cli/skills-cli.ts b/packages/coding-agent/src/cli/skills-cli.ts index a884739b9b..6f410e8392 100644 --- a/packages/coding-agent/src/cli/skills-cli.ts +++ b/packages/coding-agent/src/cli/skills-cli.ts @@ -72,6 +72,7 @@ export async function runSkillsCommand(cmd: SkillsCommandArgs): Promise { const settings = await Settings.loadForScope({ cwd: process.cwd() }); const result = await discoverRuntimeSkills({ cwd: process.cwd(), + agentDir: settings.getAgentDir(), source, policy: { ...settings.getGroup("skills"), diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 569f2fb07c..7b749d38b9 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -3695,7 +3695,7 @@ export const SETTINGS_SCHEMA = { tab: "customization", label: "Trust User Skills", description: - "Load skills from ~/.gjc/agent/skills (and legacy ~/.gjc/skills / /skills). Set to false to ignore user-installed skills while keeping project skills.", + "Load skills from the agent directory's skills root (`gjc config dir`; ~/.gjc/agent/skills by default, with legacy ~/.gjc/skills / /skills roots in the default profile). Set to false to ignore user-installed skills while keeping project skills.", }, }, diff --git a/packages/coding-agent/src/discovery/builtin.ts b/packages/coding-agent/src/discovery/builtin.ts index 3e6efce768..5b0c351e05 100644 --- a/packages/coding-agent/src/discovery/builtin.ts +++ b/packages/coding-agent/src/discovery/builtin.ts @@ -29,6 +29,7 @@ import { discoverExtensionModulePaths, expandEnvVarsDeep, getExtensionNameFromPath, + getUserSkillScanDirs, loadFilesFromDir, SOURCE_PATHS, scanSkillsFromDir, @@ -41,8 +42,8 @@ const PRIORITY = 100; const PATHS = SOURCE_PATHS.native; -function getUserAgentDirs(): string[] { - return [PATHS.userAgent]; +function getUserAgentDirs(ctx: LoadContext): string[] { + return [resolveUserAgentDir(ctx)]; } /** @@ -56,7 +57,7 @@ function getUserAgentDirs(): string[] { * profile's servers into it. */ function resolveUserAgentDir(ctx: LoadContext): string { - return ctx.userAgentDir ?? getAgentDir(); + return path.resolve(ctx.userAgentDir ?? getAgentDir()); } function getProjectConfigDirs(): string[] { @@ -84,8 +85,8 @@ async function getConfigDirs(ctx: LoadContext): Promise(mcpCapability.id, { async function loadSystemPrompt(ctx: LoadContext): Promise> { const items: SystemPrompt[] = []; - for (const userAgentDir of getUserAgentDirs()) { - const userPath = path.join(ctx.home, userAgentDir, "SYSTEM.md"); - const userContent = await readFile(userPath); - if (userContent) { - items.push({ - path: userPath, - content: userContent, - level: "user", - _source: createSourceMeta(PROVIDER_ID, userPath, "user"), - }); - } + // User scope is the agent directory — the directory `gjc config dir` prints + // and the only user-scope seam. A profile is a separate scope; the default + // profile's home-relative SYSTEM.md is not read under one. + const userPath = path.join(resolveUserAgentDir(ctx), "SYSTEM.md"); + const userContent = await readFile(userPath); + if (userContent) { + items.push({ + path: userPath, + content: userContent, + level: "user", + _source: createSourceMeta(PROVIDER_ID, userPath, "user"), + }); } const nearestProjectConfigDir = await findNearestProjectConfigDir(ctx.cwd, ctx.repoRoot); @@ -336,9 +338,9 @@ async function loadSkills(ctx: LoadContext): Promise> { ); // User-level scan from ~/.gjc/agent/skills/ - const userScans = getUserAgentDirs().map(userAgentDir => + const userScans = getUserSkillScanDirs(ctx.home, resolveUserAgentDir(ctx)).map(dir => scanSkillsFromDir(ctx, { - dir: path.join(ctx.home, userAgentDir, "skills"), + dir, providerId: PROVIDER_ID, level: "user", requireDescription: true, @@ -412,13 +414,11 @@ async function loadRules(ctx: LoadContext): Promise> { // Top-level RULES.md is a sticky always-apply rule. The context-file // discovery contract treats it as the file "re-injected near the current // turn so they keep hold across long conversations". - // User scope: ~/.gjc/agent/RULES.md + // User scope: /RULES.md (a profile is a separate user scope; + // the default profile's home-relative copy is not read) // Project scope: nearest .gjc/RULES.md walking up from cwd to repoRoot - for (const userAgentDir of getUserAgentDirs()) { - const userRulesFile = path.join(ctx.home, userAgentDir, "RULES.md"); - const userRule = await loadStickyRulesFile(userRulesFile, "user"); - if (userRule) items.push(userRule); - } + const userRule = await loadStickyRulesFile(path.join(resolveUserAgentDir(ctx), "RULES.md"), "user"); + if (userRule) items.push(userRule); const nearestProjectConfigDir = await findNearestProjectConfigDir(ctx.cwd, ctx.repoRoot); if (nearestProjectConfigDir) { @@ -933,7 +933,7 @@ async function loadContextFiles(ctx: LoadContext): Promise//agent` and the configured + * legacy roots below it are still honored, exactly as before. + */ +export function resolveUserAgentDir(home: string, userAgentDir?: string): string { + return path.resolve(userAgentDir ?? path.join(home, SOURCE_PATHS.native.userAgent)); +} + +export function getUserSkillScanDirs(home: string, userAgentDir?: string): string[] { + const resolvedAgentDir = resolveUserAgentDir(home, userAgentDir); + if (resolvedAgentDir !== path.resolve(path.join(home, SOURCE_PATHS.native.userAgent))) { + return [path.join(resolvedAgentDir, "skills")]; + } + return [ + ...new Set([ + path.join(home, SOURCE_PATHS.native.userAgent, "skills"), + path.join(home, SOURCE_PATHS.native.userBase, "skills"), + path.join(home, ".gjc", "skills"), + ]), + ]; +} + /** * Get project-level path for a source (cwd only). */ diff --git a/packages/coding-agent/src/extensibility/gjc-plugins/schema.ts b/packages/coding-agent/src/extensibility/gjc-plugins/schema.ts index ae0490f418..54d34441bb 100644 --- a/packages/coding-agent/src/extensibility/gjc-plugins/schema.ts +++ b/packages/coding-agent/src/extensibility/gjc-plugins/schema.ts @@ -26,7 +26,7 @@ const FORBIDDEN_MANIFEST_KEYS = ["skills", "slash-commands", "commands", "agents type ForbiddenManifestKey = (typeof FORBIDDEN_MANIFEST_KEYS)[number]; const FORBIDDEN_SURFACE_DIAGNOSTICS: Record = { - skills: `Forbidden GJC plugin surface "skills": bundles may only EXTEND the four bundled workflow skills (deep-interview, ralplan, ultragoal, autoresearch) and the four role agents (executor, architect, planner, critic), never register a new top-level skill. Bind an inline sub-skill with the canonical "subskills" surface (frontmatter binds_to/phase/activation_arg), or drop the manifest and use the loose surface .gjc/skills//SKILL.md (project) / ~/.gjc/agent/skills//SKILL.md (user).`, + skills: `Forbidden GJC plugin surface "skills": bundles may only EXTEND the four bundled workflow skills (deep-interview, ralplan, ultragoal, autoresearch) and the four role agents (executor, architect, planner, critic), never register a new top-level skill. Bind an inline sub-skill with the canonical "subskills" surface (frontmatter binds_to/phase/activation_arg), or drop the manifest and use the loose surface .gjc/skills//SKILL.md (project) / /skills//SKILL.md (user; see gjc config dir).`, "slash-commands": `Forbidden GJC plugin surface "slash-commands": bundles cannot register slash commands. Use the loose surface .gjc/commands/ (TypeScript module) or a markdown slash-command file instead.`, commands: `Forbidden GJC plugin surface "commands" (Claude Code plugin.json vocabulary): bundles cannot register slash commands. Use the loose surface .gjc/commands/ (TypeScript module) or a markdown slash-command file instead.`, agents: `Forbidden GJC plugin surface "agents" (Claude Code plugin.json vocabulary): bundles cannot register top-level agents; executor/architect/planner/critic are protected. Bind a sub-skill to an existing agent with the canonical "subskills" surface (frontmatter binds_to: one of executor|architect|planner|critic, phase, activation_arg) instead.`, diff --git a/packages/coding-agent/src/extensibility/hooks/loader.ts b/packages/coding-agent/src/extensibility/hooks/loader.ts index fd2f6816e7..4dd91baecb 100644 --- a/packages/coding-agent/src/extensibility/hooks/loader.ts +++ b/packages/coding-agent/src/extensibility/hooks/loader.ts @@ -253,7 +253,11 @@ export async function loadHooks(paths: string[], cwd: string): Promise { +export async function discoverAndLoadHooks( + configuredPaths: string[], + cwd: string, + agentDir?: string, +): Promise { const allPaths: string[] = []; const seen = new Set(); const normalizationErrors: Array<{ path: string; error: string }> = []; @@ -272,7 +276,11 @@ export async function discoverAndLoadHooks(configuredPaths: string[], cwd: strin // 1. Discover hooks via capability API and validate the provider descriptor // against the canonical model before importing project-controlled code. - const discovered = await loadCapability(hookCapability.id, { cwd, providers: ["native"] }); + const discovered = await loadCapability(hookCapability.id, { + cwd, + agentDir, + providers: ["native"], + }); for (const hook of discovered.items) { const convention = hook._source.provider === "native" @@ -389,8 +397,9 @@ function createHookExtensionFactory(hook: LoadedHook): ExtensionFactory { export async function discoverAndLoadHookExtensions( configuredPaths: string[], cwd: string, + agentDir?: string, ): Promise { - const loaded = await discoverAndLoadHooks(configuredPaths, cwd); + const loaded = await discoverAndLoadHooks(configuredPaths, cwd, agentDir); return { factories: loaded.hooks.map(hook => ({ factory: createHookExtensionFactory(hook), diff --git a/packages/coding-agent/src/extensibility/runtime-skill-discovery.ts b/packages/coding-agent/src/extensibility/runtime-skill-discovery.ts index 5d2c682b5c..65b4c9455d 100644 --- a/packages/coding-agent/src/extensibility/runtime-skill-discovery.ts +++ b/packages/coding-agent/src/extensibility/runtime-skill-discovery.ts @@ -1,13 +1,13 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; -import { getTrustedHomeDir } from "@gajae-code/utils"; +import { getAgentDir, getTrustedHomeDir } from "@gajae-code/utils"; import { findRepoRoot } from "../capability/fs"; import type { Skill as CapabilitySkill } from "../capability/skill"; import type { SkillsSettings } from "../config/settings-schema"; import { resolveSkillScopeTrust } from "../config/skill-settings-defaults"; import { scanClaudeProjectSkills, scanClaudeUserSkills } from "../discovery/claude"; import { scanCodexProjectSkills, scanCodexUserSkills } from "../discovery/codex"; -import { compareSkillOrder, SOURCE_PATHS, scanSkillsFromDir } from "../discovery/helpers"; +import { compareSkillOrder, getUserSkillScanDirs, SOURCE_PATHS, scanSkillsFromDir } from "../discovery/helpers"; import { CANONICAL_GJC_WORKFLOW_SKILLS } from "../skill-state/canonical-skills"; import { expandTilde } from "../tools/path-utils"; import type { Skill } from "./skills"; @@ -40,6 +40,7 @@ export interface RuntimeSkillDiscoveryResult { export interface DiscoverRuntimeSkillsOptions { cwd: string; home?: string; + agentDir?: string; query?: string; limit?: number; source?: RuntimeSkillDiscoverySource | "all"; @@ -106,16 +107,12 @@ function ancestorDirs(cwd: string, stop: string, home: string): string[] { return dirs; } -function getUserSkillDirs(home: string): string[] { - const canonicalUserDir = SOURCE_PATHS.native.userAgent; - const configuredLegacyDir = SOURCE_PATHS.native.userBase; - return [ - ...new Set([ - path.join(home, canonicalUserDir, "skills"), - path.join(home, configuredLegacyDir, "skills"), - path.join(home, ".gjc", "skills"), - ]), - ]; +function getUserSkillDirs(home: string, agentDir = getAgentDir()): string[] { + return getUserSkillScanDirs(home, agentDir); +} + +function resolveRuntimeAgentDir(home: string, agentDir: string | undefined, homeWasInjected: boolean): string { + return agentDir ?? (homeWasInjected ? path.resolve(home, SOURCE_PATHS.native.userAgent) : getAgentDir()); } /** @@ -349,10 +346,12 @@ export function describeDisabledSkillScopes( export async function discoverRuntimeSkills( options: DiscoverRuntimeSkillsOptions, ): Promise { + const hasExplicitHome = options.home !== undefined; const home = options.home ?? getRuntimeHome(); const source = options.source ?? "all"; const policy = options.policy; const diagnostics: string[] = []; + const agentDir = resolveRuntimeAgentDir(home, options.agentDir, hasExplicitHome); const scanJobs: Array> = []; const projectDirs = await getProjectSkillDirs(options.cwd, home); const projectContext = { cwd: options.cwd, home, repoRoot: projectDirs.repoRoot }; @@ -362,7 +361,7 @@ export async function discoverRuntimeSkills( } } if ((source === "all" || source === "user") && sourceEnabled("user", policy)) { - for (const dir of getUserSkillDirs(home)) { + for (const dir of getUserSkillDirs(home, agentDir)) { scanJobs.push( scanProjectOrUserDir({ cwd: options.cwd, home, repoRoot: home }, dir, "user", `user ${dir}`, "user"), ); @@ -431,13 +430,17 @@ export async function findRuntimeSkillByName( cwd: string, name: string, policy?: SkillsSettings, - home = getRuntimeHome(), + home?: string, + agentDir?: string, ): Promise { const normalized = name.trim(); if (!normalized) return undefined; + const hasExplicitHome = home !== undefined; + const resolvedHome = home ?? getRuntimeHome(); + const resolvedAgentDir = resolveRuntimeAgentDir(resolvedHome, agentDir, hasExplicitHome); const scanJobs: Array> = []; - const projectDirs = await getProjectSkillDirs(cwd, home); - const projectContext = { cwd, home, repoRoot: projectDirs.repoRoot }; + const projectDirs = await getProjectSkillDirs(cwd, resolvedHome); + const projectContext = { cwd, home: resolvedHome, repoRoot: projectDirs.repoRoot }; if (sourceEnabled("project", policy)) { scanJobs.push( ...projectDirs.scans.map(scan => @@ -451,20 +454,20 @@ export async function findRuntimeSkillByName( ); } if (sourceEnabled("user", policy)) { - for (const dir of getUserSkillDirs(home)) { + for (const dir of getUserSkillDirs(resolvedHome, resolvedAgentDir)) { scanJobs.push( scanSkillsFromDir( - { cwd, home, repoRoot: home }, + { cwd, home: resolvedHome, repoRoot: resolvedHome }, { dir, providerId: "runtime", level: "user", requireDescription: true }, ).then(result => result.items.map(skill => ({ skill, source: "user" as const }))), ); } } if (policy?.enabled === true) { - for (const dir of getCustomSkillDirs(policy, home)) { + for (const dir of getCustomSkillDirs(policy, resolvedHome)) { scanJobs.push( scanSkillsFromDir( - { cwd, home, repoRoot: home }, + { cwd, home: resolvedHome, repoRoot: resolvedHome }, { dir, providerId: "runtime", level: "user", requireDescription: true }, ).then(result => result.items.map(skill => ({ skill, source: "user" as const }))), ); diff --git a/packages/coding-agent/src/extensibility/skill-management.ts b/packages/coding-agent/src/extensibility/skill-management.ts index e9dc46446b..e1115e86a9 100644 --- a/packages/coding-agent/src/extensibility/skill-management.ts +++ b/packages/coding-agent/src/extensibility/skill-management.ts @@ -14,13 +14,13 @@ */ import * as fs from "node:fs/promises"; import * as path from "node:path"; -import { getTrustedHomeDir, parseFrontmatter } from "@gajae-code/utils"; +import { getAgentDir, getTrustedHomeDir, parseFrontmatter } from "@gajae-code/utils"; import { findRepoRoot } from "../capability/fs"; import type { Skill as CapabilitySkill } from "../capability/skill"; import { resolveSkillScopeTrust } from "../config/skill-settings-defaults"; import { scanClaudeProjectSkills, scanClaudeUserSkills } from "../discovery/claude"; import { scanCodexProjectSkills, scanCodexUserSkills } from "../discovery/codex"; -import { compareSkillOrder, SOURCE_PATHS, scanSkillsFromDir } from "../discovery/helpers"; +import { compareSkillOrder, getUserSkillScanDirs, resolveUserAgentDir, scanSkillsFromDir } from "../discovery/helpers"; import { CANONICAL_GJC_WORKFLOW_SKILLS } from "../skill-state/canonical-skills"; export type SkillScope = "project" | "user"; export type ConventionSkillHost = "claude" | "codex"; @@ -61,6 +61,7 @@ export interface ConventionSkillImportSource { export interface WriteNativeSkillInput { cwd: string; home?: string; + agentDir?: string; scope: SkillScope; name: string; content: string; @@ -131,27 +132,27 @@ export async function getProjectSkillDirs( } /** Canonical user skill directories in precedence order (same resolution as runtime discovery). */ -export function getUserSkillDirs(home: string): string[] { - return [ - ...new Set([ - path.join(home, SOURCE_PATHS.native.userAgent, "skills"), - path.join(home, SOURCE_PATHS.native.userBase, "skills"), - path.join(home, ".gjc", "skills"), - ]), - ]; +export function getUserSkillDirs(home: string, agentDir?: string): string[] { + return getUserSkillScanDirs(home, agentDir); } /** * The canonical directory a write targets for a scope: the repo root (or `cwd`) - * `.gjc/skills` for project scope, the canonical `/agent/skills` user - * root for user scope (honoring `GJC_CONFIG_DIR` / `PI_CONFIG_DIR`). + * `.gjc/skills` for project scope, the agent directory's `skills` root for user + * scope — the same directory every reader scans first (`gjc config dir` prints + * it; `--agent-dir` / `GJC_CODING_AGENT_DIR` / `setAgentDir()` move it). */ export async function resolveNativeSkillScopeDir( cwd: string, scope: SkillScope, - home = getRuntimeHome(), + _home?: string, + agentDir?: string, ): Promise { - if (scope === "user") return path.join(home, SOURCE_PATHS.native.userAgent, "skills"); + const home = _home ?? getRuntimeHome(); + if (scope === "user") { + const resolvedAgentDir = agentDir ?? (_home === undefined ? getAgentDir() : resolveUserAgentDir(home)); + return path.join(path.resolve(resolvedAgentDir), "skills"); + } const repoRoot = await findRepoRoot(cwd); return path.join(repoRoot ?? path.resolve(cwd), ".gjc", "skills"); } @@ -174,14 +175,17 @@ function isDisabledByExtension(name: string, disabledExtensions: string[] | unde export async function listNativeSkillsForManagement(options: { cwd: string; home?: string; + agentDir?: string; policy?: SkillManagementPolicy; }): Promise { + const homeWasInjected = options.home !== undefined; const home = options.home ?? getRuntimeHome(); + const agentDir = options.agentDir ?? (homeWasInjected ? resolveUserAgentDir(home) : getAgentDir()); const policy = options.policy; const projectTrusted = resolveSkillScopeTrust(policy ?? {}, "project"); const userTrusted = resolveSkillScopeTrust(policy ?? {}, "user"); - const scanJobs: Array> = []; + const scanJobs: Array> = []; const projectDirs = await getProjectSkillDirs(options.cwd, home); if (projectTrusted) { for (const dir of projectDirs.dirs) { @@ -189,17 +193,17 @@ export async function listNativeSkillsForManagement(options: { scanSkillsFromDir( { cwd: options.cwd, home, repoRoot: projectDirs.repoRoot }, { dir, providerId: "runtime", level: "project", requireDescription: true }, - ).then(result => ({ dir, items: result.items })), + ).then(result => ({ dir, scope: "project" as const, items: result.items })), ); } } if (userTrusted) { - for (const dir of getUserSkillDirs(home)) { + for (const dir of getUserSkillDirs(home, agentDir)) { scanJobs.push( scanSkillsFromDir( { cwd: options.cwd, home, repoRoot: home }, { dir, providerId: "runtime", level: "user", requireDescription: true }, - ).then(result => ({ dir, items: result.items })), + ).then(result => ({ dir, scope: "user" as const, items: result.items })), ); } } @@ -208,8 +212,7 @@ export async function listNativeSkillsForManagement(options: { const seenNames = new Set(); const seenPaths = new Set(); - for (const { dir, items } of await Promise.all(scanJobs)) { - const scope: SkillScope = path.resolve(dir).startsWith(`${path.resolve(home)}${path.sep}`) ? "user" : "project"; + for (const { dir, scope, items } of await Promise.all(scanJobs)) { const source = scope === "project" ? "project .gjc/skills" : `user ${dir}`; for (const skill of items) { const realPath = await safeRealpath(skill.path); @@ -274,7 +277,7 @@ export async function writeNativeSkill(input: WriteNativeSkillInput): Promise(CANONICAL_GJC_WORKFLOW_SKILLS); export async function loadSkills(options: LoadSkillsOptions = {}): Promise { const { cwd = getProjectDir(), + home, + agentDir, enabled = true, customDirectories = [], ignoredSkills = [], @@ -140,14 +144,15 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise(skillCapability.id)?.providers.find( - provider => provider.id === "native", - ); - if (!nativeProvider) throw new Error("Native skill provider is unavailable"); - const result = await nativeProvider.load({ cwd, home, repoRoot: await findRepoRoot(cwd) }); + const loadOptions = { + cwd, + agentDir: agentDir ?? (home === undefined ? undefined : resolveUserAgentDir(resolvedHome)), + providers: ["native"], + disabledExtensions, + }; + const result = await (home === undefined + ? loadCapability(skillCapability.id, loadOptions) + : loadCapabilityForHome(skillCapability.id, resolvedHome, loadOptions)); const skillMap = new Map(); const realPathSet = new Set(); @@ -190,7 +199,7 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise user roots. - const filteredSkills = result.items + const filteredSkills = result.all .filter(capSkill => { if (!isSourceEnabled(capSkill._source)) return false; if (disabledSkillNames.has(capSkill.name)) return false; @@ -258,9 +267,9 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise { - const expandedDir = expandTilde(dir, home); + const expandedDir = expandTilde(dir, resolvedHome); const scanResult = await scanSkillsFromDir( - { cwd, home, repoRoot: null }, + { cwd, home: resolvedHome, repoRoot: null }, { dir: expandedDir, providerId: "custom", diff --git a/packages/coding-agent/src/extensibility/slash-commands.ts b/packages/coding-agent/src/extensibility/slash-commands.ts index d8d219f6ed..7c725cdd77 100644 --- a/packages/coding-agent/src/extensibility/slash-commands.ts +++ b/packages/coding-agent/src/extensibility/slash-commands.ts @@ -153,6 +153,8 @@ function parseCommandTemplate( export interface LoadSlashCommandsOptions { /** Working directory for project-local commands. Default: getProjectDir() */ cwd?: string; + /** Agent directory for session-scoped user commands. Default: getAgentDir(). */ + agentDir?: string; } /** @@ -160,7 +162,10 @@ export interface LoadSlashCommandsOptions { * Loads from all registered providers (builtin, user, project). */ export async function loadSlashCommands(options: LoadSlashCommandsOptions = {}): Promise { - const result = await loadCapability(slashCommandCapability.id, { cwd: options.cwd }); + const result = await loadCapability(slashCommandCapability.id, { + cwd: options.cwd, + agentDir: options.agentDir, + }); const fileCommands: FileSlashCommand[] = result.items.map(cmd => { const { description, body } = parseCommandTemplate(cmd.content, { diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index a1b19ecd5f..2f0703cd59 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -1108,7 +1108,7 @@ export class InteractiveMode implements InteractiveModeContext { async refreshSlashCommandState(cwd?: string): Promise { if (this.#stopped) return; const basePath = cwd ?? this.sessionManager.getCwd(); - const fileCommands = await loadSlashCommands({ cwd: basePath }); + const fileCommands = await loadSlashCommands({ cwd: basePath, agentDir: this.session.getSessionAgentDir() }); if (this.#stopped) return; const fileCommandNames = new Set(fileCommands.map(cmd => cmd.name)); this.fileSlashCommands = fileCommandNames; diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 1416072258..df8986aec8 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -218,7 +218,12 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti // without this the list stays empty and a leading-slash prompt reaches the model as // literal prose — no expansion, no error, and an answer that looks like the command ran. await logger.time("print:slash-commands", async () => { - session.setSlashCommands(await loadSlashCommands({ cwd: session.sessionManager.getCwd() })); + session.setSlashCommands( + await loadSlashCommands({ + cwd: session.sessionManager.getCwd(), + agentDir: session.getSessionAgentDir(), + }), + ); }); // Set up extensions for print mode (no UI, no command context). diff --git a/packages/coding-agent/src/prompts/tools/skill-discovery.md b/packages/coding-agent/src/prompts/tools/skill-discovery.md index fb9d75c0da..d13bc7f4b2 100644 --- a/packages/coding-agent/src/prompts/tools/skill-discovery.md +++ b/packages/coding-agent/src/prompts/tools/skill-discovery.md @@ -1,7 +1,7 @@ Discover project and user runtime skills without loading full skill content. -- Searches canonical GJC skill locations in precedence order: project `.gjc/skills` (ancestors from cwd to repo root, closest first), then user locations under the home directory: canonical `/agent/skills`, configured legacy `/skills`, and historical legacy `.gjc/skills`. `` is the home-relative directory name from `GJC_CONFIG_DIR`, then `PI_CONFIG_DIR`, then `.gjc`; even an absolute-looking configured name is joined beneath ``. Project scope shadows user scope; within a scope, earlier locations above win. Bundled GJC workflow skills (`autoresearch`, `deep-interview`, `ralplan`, `ultragoal`) are always available and cannot be replaced by filesystem skills. +- Searches canonical GJC skill locations in precedence order: project `.gjc/skills` (ancestors from cwd to repo root, closest first), then the user scope. The user scope is the agent directory's `skills` root (printed by `gjc config dir`; `--agent-dir` / `GJC_CODING_AGENT_DIR` move it) — an agent-directory profile is a separate scope whose scan does not include the home. In the default profile the home-relative legacy roots follow at lower precedence: canonical `/agent/skills`, configured legacy `/skills`, and historical legacy `.gjc/skills`. `` is the home-relative directory name from `GJC_CONFIG_DIR`, then `PI_CONFIG_DIR`, then `.gjc`; even an absolute-looking configured name is joined beneath ``. Project scope shadows user scope; within a scope, earlier locations above win. Bundled GJC workflow skills (`autoresearch`, `deep-interview`, `ralplan`, `ultragoal`) are always available and cannot be replaced by filesystem skills. - Returns thin metadata only: name, description, source scope, path, and use conditions when present. - Claude Code (`.claude/skills`) and Codex (`.codex/skills`) layouts are explicit import sources into `.gjc`, never invokable candidates. They are not returned as candidates; instead, each convention skill found in a trusted scope is reported in `diagnostics` with the exact copy command that enables it (copy into `.gjc/skills`), so a skill placed in a documented convention location is discoverable in a normal session without being silently loaded. - Discovery is on by default in a normal session. When zero candidates are returned because discovery config is disabled (`skills.enabled` master switch, or `skills.trustProjectSkills` / `skills.trustUserSkills` scope trust), the result carries a `notice` explaining which setting blocked the search — an empty result without a `notice` means the searched scopes genuinely contain no matching skills. diff --git a/packages/coding-agent/src/sdk/session.ts b/packages/coding-agent/src/sdk/session.ts index 70e20e45b9..4d5dfd719b 100644 --- a/packages/coding-agent/src/sdk/session.ts +++ b/packages/coding-agent/src/sdk/session.ts @@ -31,6 +31,7 @@ import { getAgentDbPath, getAgentDir, getProjectDir, + getTrustedHomeDir, logger, postmortem, prompt, @@ -761,10 +762,11 @@ export async function discoverSkills( */ export async function discoverContextFiles( cwd?: string, - _agentDir?: string, + agentDir?: string, ): Promise> { return await loadContextFilesInternal({ cwd: cwd ?? getProjectDir(), + agentDir, }); } @@ -801,6 +803,7 @@ export interface BuildSystemPromptOptions { skills?: Skill[]; contextFiles?: Array<{ path: string; content: string }>; cwd?: string; + agentDir?: string; appendPrompt?: string; repeatToolDescriptions?: boolean; } @@ -814,6 +817,7 @@ export interface BuildSystemPromptOptions { export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): Promise { return await buildSystemPromptInternal({ cwd: options.cwd, + agentDir: options.agentDir, skills: options.skills, contextFiles: options.contextFiles, appendSystemPrompt: options.appendPrompt, @@ -1325,7 +1329,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } const cwd = options.cwd ?? getProjectDir(); const explicitMcpConfigPath = !isCanonicalSubSession && !options.mcpManager ? options.mcpConfigPath : undefined; - const agentDir = options.agentDir ?? getDefaultAgentDir(); + const agentDir = options.agentDir ?? options.settings?.getAgentDir() ?? getDefaultAgentDir(); const eventBus = options.eventBus ?? new EventBus(); const hasInjectedAuth = options.authStorage !== undefined || options.modelRegistry !== undefined; @@ -1457,7 +1461,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // session-context build, tool creation, MCP discovery, and extension discovery. const contextFilesResultPromise = options.contextFiles ? Promise.resolve({ contextFiles: options.contextFiles, warnings: [] }) - : logger.time("discoverContextFiles", loadContextFilesResultInternal, { cwd }); + : logger.time("discoverContextFiles", loadContextFilesResultInternal, { cwd, agentDir }); contextFilesResultPromise.catch(() => {}); const promptTemplatesPromise = options.promptTemplates ? Promise.resolve(options.promptTemplates) @@ -1852,6 +1856,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const skillsResult = await logger.time("loadSkills", loadSkills, { ...settings.getGroup("skills"), cwd, + agentDir, disabledExtensions: settings.get("disabledExtensions"), }); skills = withEmbeddedDefaultGjcSkills(skillsResult.skills); @@ -1872,7 +1877,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const rulesResult = options.rules !== undefined ? { items: options.rules, warnings: undefined } - : await loadCapability(ruleCapability.id, { cwd }); + : await loadCapability(ruleCapability.id, { cwd, agentDir }); const rulebookRules: Rule[] = []; const alwaysApplyRules: Rule[] = []; for (const rule of rulesResult.items) { @@ -2158,7 +2163,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} */ const applyRescopedReadState = async (to: string): Promise => { try { - const rediscovered = await loadContextFilesResultInternal({ cwd: to }); + const rediscovered = await loadContextFilesResultInternal({ cwd: to, agentDir }); contextFiles = rediscovered.contextFiles; } catch (error) { logger.warn("Failed to re-discover context files after session rescope", { @@ -2170,6 +2175,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const reloaded = await loadSkills({ ...settings.getGroup("skills"), cwd: to, + agentDir, disabledExtensions: settings.get("disabledExtensions"), }); skills = withEmbeddedDefaultGjcSkills(reloaded.skills); @@ -2477,6 +2483,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // (review thread P1). For a top-level session this equals the // session id. getSessionId: () => AsyncJobManager.endpointIdOf(asyncJobManager) ?? asyncJobEndpointId, + getSessionHome: () => getTrustedHomeDir(), getCredentialSessionId: () => session?.credentialSessionId ?? credentialSessionId, getMcpManager: () => mcpManager ?? options.inheritedMcpManager, isManagedSessionDestination: () => sessionManager.isManagedDestination(), @@ -2484,6 +2491,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} getActiveSkillPhase: () => session?.getActiveSkillPhase(), getDeepInterviewAskStage: () => session?.getDeepInterviewAskStage(), getHindsightSessionState: () => session?.getHindsightSessionState(), + getSessionAgentDir: () => agentDir, get model() { return agent?.state.model ?? model; }, @@ -2948,7 +2956,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } if (!options.disableExtensionDiscovery) { try { - const hookExtensions = await discoverAndLoadHookExtensions(options.hookPaths ?? [], cwd); + const hookExtensions = await discoverAndLoadHookExtensions(options.hookPaths ?? [], cwd, agentDir); discoveredHookExtensions.push(...hookExtensions.factories); for (const error of hookExtensions.errors) { logger.warn("Rejected discovered hook", { path: error.path, error: error.error }); @@ -3583,6 +3591,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // Live cwd: the prompt is rebuilt after a rescope, and describing the // retired launcher root there is what makes the model pick wrong paths. cwd: getLiveCwd(), + agentDir, skills, contextFiles, tools: promptTools, diff --git a/packages/coding-agent/src/system-prompt.ts b/packages/coding-agent/src/system-prompt.ts index fee72250b2..7f2d6ebf1b 100644 --- a/packages/coding-agent/src/system-prompt.ts +++ b/packages/coding-agent/src/system-prompt.ts @@ -235,6 +235,8 @@ export async function resolvePromptInput(input: string | undefined, description: export interface LoadContextFilesOptions { /** Working directory to start walking up from. Default: getProjectDir() */ cwd?: string; + /** Agent directory backing native user-scope context files. */ + agentDir?: string; } function dedupeExactContextFiles( @@ -265,7 +267,7 @@ export async function loadProjectContextFilesResult( options: LoadContextFilesOptions = {}, ): Promise { const resolvedCwd = options.cwd ?? getProjectDir(); - const result = await loadCapability(contextFileCapability.id, { cwd: resolvedCwd }); + const result = await loadCapability(contextFileCapability.id, { cwd: resolvedCwd, agentDir: options.agentDir }); const items = result.items as ContextFile[]; // Native user-global context applies everywhere and is least specific, so it @@ -311,7 +313,10 @@ export async function loadProjectContextFiles( export async function loadSystemPromptFiles(options: LoadContextFilesOptions = {}): Promise { const resolvedCwd = options.cwd ?? getProjectDir(); - const result = await loadCapability(systemPromptCapability.id, { cwd: resolvedCwd }); + const result = await loadCapability(systemPromptCapability.id, { + cwd: resolvedCwd, + agentDir: options.agentDir, + }); if (result.items.length === 0) return null; @@ -372,6 +377,8 @@ export interface BuildSystemPromptOptions { skillsSettings?: SkillsSettings; /** Working directory. Default: getProjectDir() */ cwd?: string; + /** Agent directory backing native user-scope prompt files. */ + agentDir?: string; /** Pre-loaded context files (skips discovery if provided). */ contextFiles?: Array<{ path: string; content: string; depth?: number }>; /** Skills provided directly to system prompt construction. */ @@ -600,10 +607,14 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): const systemPromptCustomizationPromise = logger.time("loadSystemPromptFiles", loadSystemPromptFiles, { cwd: resolvedCwd, + agentDir: options.agentDir, }); const contextFilesPromise = providedContextFiles ? Promise.resolve({ contextFiles: providedContextFiles, warnings: [] }) - : logger.time("loadProjectContextFiles", loadProjectContextFilesResult, { cwd: resolvedCwd }); + : logger.time("loadProjectContextFiles", loadProjectContextFilesResult, { + cwd: resolvedCwd, + agentDir: options.agentDir, + }); const workspaceTreePromise = providedWorkspaceTree !== undefined ? Promise.resolve(providedWorkspaceTree) diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index 98da017957..b846068996 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -192,16 +192,6 @@ export interface ToolSession { /** Pre-loaded workspace tree (forwarded to subagents to skip re-scanning) */ workspaceTree?: WorkspaceTree; /** Pre-loaded skills */ - /** - * Explicit user home for runtime skill discovery. Tests construct sessions - * against an isolated home because the trusted-home resolver deliberately - * ignores `$HOME` on Linux (it reads the NSS account database instead), so a - * `process.env.HOME` override cannot steer user-scope discovery there. - * - * Production sessions leave this unset and the trusted OS home governs. - * Runtime discovery only; never threaded into capability loading. - */ - home?: string; skills?: Skill[]; /** Currently executing skill prompt, when this tool session is inside one. */ getActiveSkillState?: () => Pick | undefined; @@ -247,6 +237,8 @@ export interface ToolSession { waitForUserSteering?: (signal: AbortSignal) => Promise; /** Get session ID */ getSessionId?: () => string | null; + /** Get the trusted home directory used for this session's discovery context. */ + getSessionHome?: () => string; /** Get credential-selection session identity. */ getCredentialSessionId?: () => string | null; /** Scope-held MCP facade for mcp:// resolution. */ diff --git a/packages/coding-agent/src/tools/skill-discovery.ts b/packages/coding-agent/src/tools/skill-discovery.ts index 3bbfcdf793..a521f7ea3e 100644 --- a/packages/coding-agent/src/tools/skill-discovery.ts +++ b/packages/coding-agent/src/tools/skill-discovery.ts @@ -78,7 +78,8 @@ export class SkillDiscoveryTool implements AgentTool s.name).sort(); diff --git a/packages/coding-agent/src/tools/tool-catalog.generated.ts b/packages/coding-agent/src/tools/tool-catalog.generated.ts index 9a64f7abfd..c2bedd3081 100644 --- a/packages/coding-agent/src/tools/tool-catalog.generated.ts +++ b/packages/coding-agent/src/tools/tool-catalog.generated.ts @@ -2746,7 +2746,7 @@ export const TOOL_CATALOG: Readonly> = { "skill_discovery": { "name": "skill_discovery", "label": "SkillDiscovery", - "description": "Discover project and user runtime skills without loading full skill content.\n\n\n- Searches canonical GJC skill locations in precedence order: project `.gjc/skills` (ancestors from cwd to repo root, closest first), then user locations under the home directory: canonical `/agent/skills`, configured legacy `/skills`, and historical legacy `.gjc/skills`. `` is the home-relative directory name from `GJC_CONFIG_DIR`, then `PI_CONFIG_DIR`, then `.gjc`; even an absolute-looking configured name is joined beneath ``. Project scope shadows user scope; within a scope, earlier locations above win. Bundled GJC workflow skills (`autoresearch`, `deep-interview`, `ralplan`, `ultragoal`) are always available and cannot be replaced by filesystem skills.\n- Returns thin metadata only: name, description, source scope, path, and use conditions when present.\n- Claude Code (`.claude/skills`) and Codex (`.codex/skills`) layouts are explicit import sources into `.gjc`, never invokable candidates. They are not returned as candidates; instead, each convention skill found in a trusted scope is reported in `diagnostics` with the exact copy command that enables it (copy into `.gjc/skills`), so a skill placed in a documented convention location is discoverable in a normal session without being silently loaded.\n- Discovery is on by default in a normal session. When zero candidates are returned because discovery config is disabled (`skills.enabled` master switch, or `skills.trustProjectSkills` / `skills.trustUserSkills` scope trust), the result carries a `notice` explaining which setting blocked the search — an empty result without a `notice` means the searched scopes genuinely contain no matching skills.\n- When skills were scanned but not advertised (protected-name collision with a bundled workflow skill, include/ignore/disable policy filters, invalid frontmatter, shadowing), the result carries a bounded `diagnostics` list explaining why.\n- To load a selected skill's full `SKILL.md`, invoke it through the existing `skill` tool with the exact `name` returned here.\n\n\nInput:\n- `query` (optional): words to match against skill name, description, source, or use conditions.\n- `source` (optional): `all`, `project`, or `user`.\n- `limit` (optional): maximum results, 1-50.", + "description": "Discover project and user runtime skills without loading full skill content.\n\n\n- Searches canonical GJC skill locations in precedence order: project `.gjc/skills` (ancestors from cwd to repo root, closest first), then the user scope. The user scope is the agent directory's `skills` root (printed by `gjc config dir`; `--agent-dir` / `GJC_CODING_AGENT_DIR` move it) — an agent-directory profile is a separate scope whose scan does not include the home. In the default profile the home-relative legacy roots follow at lower precedence: canonical `/agent/skills`, configured legacy `/skills`, and historical legacy `.gjc/skills`. `` is the home-relative directory name from `GJC_CONFIG_DIR`, then `PI_CONFIG_DIR`, then `.gjc`; even an absolute-looking configured name is joined beneath ``. Project scope shadows user scope; within a scope, earlier locations above win. Bundled GJC workflow skills (`autoresearch`, `deep-interview`, `ralplan`, `ultragoal`) are always available and cannot be replaced by filesystem skills.\n- Returns thin metadata only: name, description, source scope, path, and use conditions when present.\n- Claude Code (`.claude/skills`) and Codex (`.codex/skills`) layouts are explicit import sources into `.gjc`, never invokable candidates. They are not returned as candidates; instead, each convention skill found in a trusted scope is reported in `diagnostics` with the exact copy command that enables it (copy into `.gjc/skills`), so a skill placed in a documented convention location is discoverable in a normal session without being silently loaded.\n- Discovery is on by default in a normal session. When zero candidates are returned because discovery config is disabled (`skills.enabled` master switch, or `skills.trustProjectSkills` / `skills.trustUserSkills` scope trust), the result carries a `notice` explaining which setting blocked the search — an empty result without a `notice` means the searched scopes genuinely contain no matching skills.\n- When skills were scanned but not advertised (protected-name collision with a bundled workflow skill, include/ignore/disable policy filters, invalid frontmatter, shadowing), the result carries a bounded `diagnostics` list explaining why.\n- To load a selected skill's full `SKILL.md`, invoke it through the existing `skill` tool with the exact `name` returned here.\n\n\nInput:\n- `query` (optional): words to match against skill name, description, source, or use conditions.\n- `source` (optional): `all`, `project`, or `user`.\n- `limit` (optional): maximum results, 1-50.", "parameters": { "type": "object", "properties": { diff --git a/packages/coding-agent/test/discovery/builtin-always-apply-rules.test.ts b/packages/coding-agent/test/discovery/builtin-always-apply-rules.test.ts index d8b5b61666..ca93410bae 100644 --- a/packages/coding-agent/test/discovery/builtin-always-apply-rules.test.ts +++ b/packages/coding-agent/test/discovery/builtin-always-apply-rules.test.ts @@ -40,6 +40,10 @@ async function loadNativeRules(ctx: LoadContext): Promise { return result.items; } +function nativeContext(): LoadContext { + return { cwd: project, home, userAgentDir: path.join(home, ".gjc", "agent"), repoRoot: project }; +} + /** Mirror createAgentSession bucketing for non-TTSR rules. */ function bucketRules(rules: Rule[]): { rulebookRules: Rule[]; alwaysApplyRules: Rule[] } { const rulebookRules: Rule[] = []; @@ -84,7 +88,7 @@ MAGICPROBE7F3A is the passphrase. `, ); - const rules = await loadNativeRules({ cwd: project, home, repoRoot: project }); + const rules = await loadNativeRules(nativeContext()); const probe = rules.find(r => r.name === "probe"); expect(probe).toBeDefined(); @@ -105,7 +109,7 @@ USERMAGIC9K2 is the user passphrase. `, ); - const rules = await loadNativeRules({ cwd: project, home, repoRoot: project }); + const rules = await loadNativeRules(nativeContext()); const probe = rules.find(r => r.name === "user-probe"); expect(probe).toBeDefined(); @@ -125,7 +129,7 @@ Other rule body. `, ); - const rules = await loadNativeRules({ cwd: project, home, repoRoot: project }); + const rules = await loadNativeRules(nativeContext()); const sticky = rules.find(r => r.name === "RULES"); const other = rules.find(r => r.name === "other"); @@ -154,7 +158,7 @@ On-demand body stays out of always-apply injection. `, ); - const discovered = await loadNativeRules({ cwd: project, home, repoRoot: project }); + const discovered = await loadNativeRules(nativeContext()); const { rulebookRules, alwaysApplyRules } = bucketRules(discovered); expect(alwaysApplyRules.map(r => r.name)).toContain("probe"); @@ -200,7 +204,7 @@ Optional body. `, ); - const rules = await loadNativeRules({ cwd: project, home, repoRoot: project }); + const rules = await loadNativeRules(nativeContext()); const optional = rules.find(r => r.name === "optional"); const { rulebookRules, alwaysApplyRules } = bucketRules(rules); diff --git a/packages/coding-agent/test/discovery/builtin-rules-md.test.ts b/packages/coding-agent/test/discovery/builtin-rules-md.test.ts index 7bf114bda6..258e64c34b 100644 --- a/packages/coding-agent/test/discovery/builtin-rules-md.test.ts +++ b/packages/coding-agent/test/discovery/builtin-rules-md.test.ts @@ -36,6 +36,15 @@ async function loadNativeRules(ctx: LoadContext): Promise { return result.items; } +/** + * The default-profile context these tests mean: user scope resolves from + * `/.gjc/agent` (what `getAgentDir()` would return under that home). + * Without it the provider falls back to the process-wide agent directory. + */ +function defaultProfileContext(overrides: { cwd: string; home: string; repoRoot: string | null }): LoadContext { + return { ...overrides, userAgentDir: path.join(overrides.home, ".gjc", "agent") }; +} + beforeEach(() => { clearCache(); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "gjc-rules-md-")); @@ -57,7 +66,7 @@ test("user ~/.gjc/agent/RULES.md becomes an alwaysApply rule", async () => { "**CRITICAL**: You _MUST_ use beads task tracker for any project\n", ); - const rules = await loadNativeRules({ cwd: project, home, repoRoot: project }); + const rules = await loadNativeRules(defaultProfileContext({ cwd: project, home, repoRoot: project })); const userRule = rules.find(r => r._source.level === "user" && r.name === "RULES"); expect(userRule).toBeDefined(); @@ -68,7 +77,7 @@ test("user ~/.gjc/agent/RULES.md becomes an alwaysApply rule", async () => { test("project .gjc/RULES.md becomes an alwaysApply rule", async () => { writeFile(path.join(project, ".gjc", "RULES.md"), "# Project rule\nAlways say hi.\n"); - const rules = await loadNativeRules({ cwd: project, home, repoRoot: project }); + const rules = await loadNativeRules(defaultProfileContext({ cwd: project, home, repoRoot: project })); const projectRule = rules.find(r => r._source.level === "project" && r.name === "RULES"); expect(projectRule).toBeDefined(); @@ -81,7 +90,7 @@ test("project RULES.md is found walking up from a sub-package cwd", async () => fs.mkdirSync(subPkg, { recursive: true }); writeFile(path.join(project, ".gjc", "RULES.md"), "# Repo-wide sticky rule\n"); - const rules = await loadNativeRules({ cwd: subPkg, home, repoRoot: project }); + const rules = await loadNativeRules(defaultProfileContext({ cwd: subPkg, home, repoRoot: project })); const projectRule = rules.find(r => r._source.level === "project" && r.name === "RULES"); expect(projectRule).toBeDefined(); @@ -92,7 +101,7 @@ test("project RULES.md is found walking up from a sub-package cwd", async () => test("alwaysApply is forced even when frontmatter says false", async () => { writeFile(path.join(home, ".gjc", "agent", "RULES.md"), "---\nalwaysApply: false\n---\nStick around anyway.\n"); - const rules = await loadNativeRules({ cwd: project, home, repoRoot: project }); + const rules = await loadNativeRules(defaultProfileContext({ cwd: project, home, repoRoot: project })); const userRule = rules.find(r => r._source.level === "user" && r.name === "RULES"); expect(userRule?.alwaysApply).toBe(true); @@ -103,7 +112,7 @@ test("absent RULES.md does not produce a rule", async () => { // No RULES.md anywhere — only a sibling .gjc/rules/ to make sure the directory exists. writeFile(path.join(home, ".gjc", "agent", "rules", "other.md"), "# Unrelated rule\n"); - const rules = await loadNativeRules({ cwd: project, home, repoRoot: project }); + const rules = await loadNativeRules(defaultProfileContext({ cwd: project, home, repoRoot: project })); expect(rules.find(r => r.name === "RULES")).toBeUndefined(); }); diff --git a/packages/coding-agent/test/discovery/builtin-user-agent-dir.test.ts b/packages/coding-agent/test/discovery/builtin-user-agent-dir.test.ts new file mode 100644 index 0000000000..b4215ab40d --- /dev/null +++ b/packages/coding-agent/test/discovery/builtin-user-agent-dir.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { getAgentDir, setAgentDir } from "@gajae-code/utils"; +import type { ContextFile } from "../../src/capability/context-file"; +import { contextFileCapability } from "../../src/capability/context-file"; +import type { Rule } from "../../src/capability/rule"; +import { ruleCapability } from "../../src/capability/rule"; +import type { Settings } from "../../src/capability/settings"; +import { settingsCapability } from "../../src/capability/settings"; +import type { Skill } from "../../src/capability/skill"; +import { skillCapability } from "../../src/capability/skill"; +import { slashCommandCapability } from "../../src/capability/slash-command"; +import type { SystemPrompt } from "../../src/capability/system-prompt"; +import { systemPromptCapability } from "../../src/capability/system-prompt"; +import type { CapabilityResult } from "../../src/capability/types"; +import { loadCapability } from "../../src/discovery"; +import { writeNativeSkill } from "../../src/extensibility/skill-management"; + +import "../../src/discovery"; + +const originalAgentDir = getAgentDir(); +const originalGjcAgentDir = process.env.GJC_CODING_AGENT_DIR; +const originalPiAgentDir = process.env.PI_CODING_AGENT_DIR; + +let root = ""; +let cwd = ""; +let home = ""; +let agentDir = ""; + +async function writeFile(filePath: string, content: string): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content); +} + +async function loadNative(capabilityId: string): Promise> { + return await loadCapability(capabilityId, { cwd, providers: ["native"] }); +} + +beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-user-agent-dir-")); + cwd = path.join(root, "project"); + home = path.join(root, "home"); + agentDir = path.join(root, "profile"); + await fs.mkdir(path.join(cwd, ".git"), { recursive: true }); + await fs.mkdir(home, { recursive: true }); + await fs.mkdir(agentDir, { recursive: true }); + process.env.GJC_CODING_AGENT_DIR = agentDir; + delete process.env.PI_CODING_AGENT_DIR; + setAgentDir(agentDir); +}); + +afterEach(async () => { + setAgentDir(originalAgentDir); + if (originalGjcAgentDir === undefined) delete process.env.GJC_CODING_AGENT_DIR; + else process.env.GJC_CODING_AGENT_DIR = originalGjcAgentDir; + if (originalPiAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = originalPiAgentDir; + await fs.rm(root, { recursive: true, force: true }); +}); + +describe("native user scope under GJC_CODING_AGENT_DIR", () => { + it("loads config, SYSTEM.md, skills, RULES.md, and AGENTS.md from the resolved profile", async () => { + // A decoy in the default home-relative location proves the provider does + // not silently fall back to the operator's default profile. + await writeFile(path.join(home, ".gjc", "agent", "SYSTEM.md"), "wrong system prompt\n"); + await writeFile(path.join(agentDir, "SYSTEM.md"), "profile system prompt\n"); + await writeFile(path.join(agentDir, "RULES.md"), "profile sticky rule\n"); + await writeFile(path.join(agentDir, "AGENTS.md"), "profile context instructions\n"); + await writeFile(path.join(agentDir, "config.yml"), "skills:\n enabled: true\n"); + await writeNativeSkill({ + cwd, + agentDir, + scope: "user", + name: "profile-skill", + content: "---\nname: profile-skill\ndescription: Profile skill\n---\n\nprofile body\n", + }); + + const [settings, system, skills, rules, context] = await Promise.all([ + loadNative(settingsCapability.id), + loadNative(systemPromptCapability.id), + loadNative(skillCapability.id), + loadNative(ruleCapability.id), + loadNative(contextFileCapability.id), + ]); + + expect( + settings.items.some(item => item.level === "user" && item.path === path.join(agentDir, "config.yml")), + ).toBe(true); + expect(system.items.map(item => item.path)).toEqual([path.join(agentDir, "SYSTEM.md")]); + expect(skills.items.map(item => item.name)).toContain("profile-skill"); + expect(skills.items.find(item => item.name === "profile-skill")?._source.path).toBe( + path.join(agentDir, "skills", "profile-skill", "SKILL.md"), + ); + expect(rules.items.find(item => item.name === "RULES")?.path).toBe(path.join(agentDir, "RULES.md")); + expect(context.items.map(item => item.path)).toEqual([path.join(agentDir, "AGENTS.md")]); + }); + + it("keeps a valid profile config visible when project config is malformed", async () => { + await writeFile(path.join(cwd, ".gjc", "config.yml"), "skills: [\n"); + await writeFile(path.join(agentDir, "config.yml"), "skills:\n enabled: true\n"); + + const result = await loadNative(settingsCapability.id); + expect(result.items.some(item => item.level === "user" && item.path === path.join(agentDir, "config.yml"))).toBe( + true, + ); + expect(result.warnings.some(warning => warning.includes(path.join(cwd, ".gjc", "config.yml")))).toBe(true); + }); + + it("keeps explicit profile config, AGENTS, and shared config-dir consumers off decoy roots", async () => { + const decoyAgentDir = path.join(root, "global-decoy-agent"); + await writeFile(path.join(decoyAgentDir, "config.yml"), "skills:\n enabled: false\n"); + await writeFile(path.join(decoyAgentDir, "AGENTS.md"), "decoy agents\n"); + await writeFile(path.join(decoyAgentDir, "commands", "decoy.md"), "decoy command\n"); + await writeFile(path.join(agentDir, "config.yml"), "skills:\n enabled: true\n"); + await writeFile(path.join(agentDir, "AGENTS.md"), "profile agents\n"); + await writeFile(path.join(agentDir, "commands", "profile.md"), "profile command\n"); + + setAgentDir(decoyAgentDir); + const loadExplicit = async (capabilityId: string) => + await loadCapability(capabilityId, { cwd, agentDir, providers: ["native"] }); + const [settings, context, commands] = await Promise.all([ + loadExplicit(settingsCapability.id), + loadExplicit(contextFileCapability.id), + loadExplicit<{ name: string; content: string }>(slashCommandCapability.id), + ]); + + expect( + settings.items.some(item => item.level === "user" && item.path === path.join(agentDir, "config.yml")), + ).toBe(true); + expect(context.items.map(item => item.content)).toEqual(["profile agents\n"]); + expect(commands.items.map(item => item.name)).toEqual(["profile"]); + }); +}); diff --git a/packages/coding-agent/test/issue-4769-user-scope-agent-dir.test.ts b/packages/coding-agent/test/issue-4769-user-scope-agent-dir.test.ts new file mode 100644 index 0000000000..ea515c9111 --- /dev/null +++ b/packages/coding-agent/test/issue-4769-user-scope-agent-dir.test.ts @@ -0,0 +1,246 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { getCapability } from "@gajae-code/coding-agent/capability"; +import { clearCache } from "@gajae-code/coding-agent/capability/fs"; +import { type Rule, ruleCapability } from "@gajae-code/coding-agent/capability/rule"; +import { type Skill, skillCapability } from "@gajae-code/coding-agent/capability/skill"; +import { type SystemPrompt, systemPromptCapability } from "@gajae-code/coding-agent/capability/system-prompt"; +import type { LoadContext } from "@gajae-code/coding-agent/capability/types"; +import { runMigrate } from "@gajae-code/coding-agent/cli/migrate-cli"; +import { Settings } from "@gajae-code/coding-agent/config/settings"; +import { + discoverRuntimeSkills, + findRuntimeSkillByName, +} from "@gajae-code/coding-agent/extensibility/runtime-skill-discovery"; +import { + listNativeSkillsForManagement, + writeNativeSkill, +} from "@gajae-code/coding-agent/extensibility/skill-management"; +import { loadSkills } from "@gajae-code/coding-agent/extensibility/skills"; +import { loadSlashCommands } from "@gajae-code/coding-agent/extensibility/slash-commands"; +import { createAgentSession } from "@gajae-code/coding-agent/sdk"; +import { SessionManager } from "@gajae-code/coding-agent/session/session-manager"; +import { getAgentDir, setAgentDir } from "@gajae-code/utils"; +// Register all discovery providers as a side effect. +import "@gajae-code/coding-agent/discovery"; + +let tempDir: string; +let home: string; +let project: string; +let profile: string; +let originalAgentDir: string; + +async function writeFile(filePath: string, content: string): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content); +} + +async function makeSkill(root: string, name: string, description = `${name} body`): Promise { + const filePath = path.join(root, name, "SKILL.md"); + await writeFile( + filePath, + ["---", `name: ${name}`, `description: ${description}`, "---", "", `# ${name}`].join("\n"), + ); + return filePath; +} + +function nativeProvider(capabilityId: string) { + const capability = getCapability(capabilityId); + if (!capability) throw new Error(`capability ${capabilityId} missing`); + const provider = capability.providers.find(p => p.id === "native"); + if (!provider) throw new Error(`native provider for ${capabilityId} not registered`); + return provider.load as (ctx: LoadContext) => Promise<{ items: unknown[] }>; +} + +beforeEach(async () => { + clearCache(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-4769-user-scope-")); + home = path.join(tempDir, "home"); + project = path.join(tempDir, "project"); + profile = path.join(tempDir, "profile-agent-dir"); + await fs.mkdir(home, { recursive: true }); + await fs.mkdir(project, { recursive: true }); + await fs.mkdir(profile, { recursive: true }); + await fs.mkdir(path.join(project, ".git"), { recursive: true }); + originalAgentDir = getAgentDir(); +}); + +afterEach(async () => { + clearCache(); + vi.restoreAllMocks(); + setAgentDir(originalAgentDir); + await fs.rm(tempDir, { recursive: true, force: true }); +}); + +describe("issue #4769: user scope follows the agent directory", () => { + test("user SYSTEM.md is read from the agent directory, not the home-relative default", async () => { + await writeFile(path.join(profile, "SYSTEM.md"), "# profile system prompt"); + // Decoy in the home-relative default location: must stay unread. + await writeFile(path.join(home, ".gjc", "agent", "SYSTEM.md"), "# default-profile decoy"); + + const load = nativeProvider(systemPromptCapability.id) as ( + ctx: LoadContext, + ) => Promise<{ items: SystemPrompt[] }>; + const { items } = await load({ cwd: project, home, repoRoot: project, userAgentDir: profile }); + + expect(items.map(item => item.content)).toEqual(["# profile system prompt"]); + expect(items[0]?.path).toBe(path.join(profile, "SYSTEM.md")); + }); + + test("user RULES.md is read from the agent directory, not the home-relative default", async () => { + await writeFile(path.join(profile, "RULES.md"), "profile rules body"); + await writeFile(path.join(home, ".gjc", "agent", "RULES.md"), "default-profile decoy"); + + const load = nativeProvider(ruleCapability.id) as (ctx: LoadContext) => Promise<{ items: Rule[] }>; + const { items } = await load({ cwd: project, home, repoRoot: project, userAgentDir: profile }); + const sticky = items.find(rule => rule.name === "RULES"); + + expect(sticky?.path).toBe(path.join(profile, "RULES.md")); + expect(items.every(rule => rule.path !== path.join(home, ".gjc", "agent", "RULES.md"))).toBe(true); + }); + + test("discovery scans only the profile's skills; the default profile's skills do not leak in", async () => { + await makeSkill(path.join(profile, "skills"), "profile-skill"); + await makeSkill(path.join(home, ".gjc", "agent", "skills"), "default-profile-skill"); + + const load = nativeProvider(skillCapability.id) as (ctx: LoadContext) => Promise<{ items: Skill[] }>; + const { items } = await load({ cwd: project, home, repoRoot: project, userAgentDir: profile }); + + expect(items.map(skill => skill.name)).toEqual(["profile-skill"]); + }); + + test("a hostile mocked HOME cannot inject user skills under a profile", async () => { + const hostileHome = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-4769-hostile-home-")); + vi.spyOn(os, "homedir").mockReturnValue(hostileHome); + await makeSkill(path.join(hostileHome, ".gjc", "agent", "skills"), "hostile-skill"); + await makeSkill(path.join(hostileHome, ".gjc", "skills"), "hostile-legacy-skill"); + + const load = nativeProvider(skillCapability.id) as (ctx: LoadContext) => Promise<{ items: Skill[] }>; + const { items } = await load({ cwd: project, home, repoRoot: project, userAgentDir: profile }); + + expect(items.map(skill => skill.name)).toEqual([]); + }); + + test("default profile keeps scanning the home-relative legacy roots", async () => { + const defaultAgentDir = path.join(home, ".gjc", "agent"); + await makeSkill(path.join(defaultAgentDir, "skills"), "canonical-skill"); + await makeSkill(path.join(home, ".gjc", "skills"), "legacy-skill"); + + const load = nativeProvider(skillCapability.id) as (ctx: LoadContext) => Promise<{ items: Skill[] }>; + const { items } = await load({ cwd: project, home, repoRoot: project, userAgentDir: defaultAgentDir }); + + expect(items.map(skill => skill.name).sort()).toEqual(["canonical-skill", "legacy-skill"]); + }); +}); + +describe("issue #4769: every writer is discovered by every reader", () => { + test("session startup threads its explicit profile to every native reader", async () => { + const decoy = path.join(tempDir, "decoy-agent-dir"); + await makeSkill(path.join(profile, "skills"), "session-profile-skill", "Session profile skill"); + await makeSkill(path.join(decoy, "skills"), "session-decoy-skill", "Process-global decoy skill"); + await writeFile(path.join(profile, "SYSTEM.md"), "# session profile system"); + await writeFile(path.join(decoy, "SYSTEM.md"), "# process-global decoy system"); + await writeFile(path.join(profile, "RULES.md"), "session profile rules"); + await writeFile(path.join(decoy, "RULES.md"), "process-global decoy rules"); + await writeFile(path.join(profile, "AGENTS.md"), "session profile agents"); + await writeFile(path.join(decoy, "AGENTS.md"), "process-global decoy agents"); + + setAgentDir(decoy); + const { session } = await createAgentSession({ + cwd: project, + agentDir: profile, + sessionManager: SessionManager.inMemory(), + settings: Settings.isolated({ + "skills.enabled": true, + "skills.trustProjectSkills": true, + "skills.trustUserSkills": true, + }), + enableMCP: false, + enableLsp: false, + }); + try { + expect(session.skills.map(skill => skill.name)).toContain("session-profile-skill"); + expect(session.skills.map(skill => skill.name)).not.toContain("session-decoy-skill"); + const prompt = session.systemPrompt.join("\n"); + expect(prompt).toContain("session profile system"); + expect(prompt).toContain("session profile agents"); + expect(prompt).not.toContain("process-global decoy system"); + expect(prompt).not.toContain("process-global decoy agents"); + } finally { + await session.dispose(); + } + }); + + test("writeNativeSkill(user) targets the agent dir and is listed, discovered, and loaded", async () => { + const receipt = await writeNativeSkill({ + cwd: project, + scope: "user", + name: "writer-skill", + content: ["---", "name: writer-skill", "description: written by gjc skill", "---", "", "# writer"].join("\n"), + agentDir: profile, + }); + expect(receipt.path).toBe(path.join(profile, "skills", "writer-skill", "SKILL.md")); + + // skill-management reader + const records = await listNativeSkillsForManagement({ cwd: project, home, agentDir: profile }); + const record = records.find(r => r.name === "writer-skill"); + expect(record).toMatchObject({ scope: "user", enabled: true, path: receipt.path }); + + // runtime skill discovery reader + const discovered = await discoverRuntimeSkills({ + cwd: project, + home, + agentDir: profile, + policy: { enabled: true, trustUserSkills: true }, + }); + expect(discovered.candidates.map(c => c.name)).toContain("writer-skill"); + + const byName = await findRuntimeSkillByName(project, "writer-skill", { enabled: true }, home, profile); + expect(byName?.filePath).toBe(receipt.path); + + // capability/session reader + setAgentDir(profile); + const { skills } = await loadSkills({ cwd: project }); + expect(skills.map(skill => skill.name)).toContain("writer-skill"); + }); + + test("gjc migrate writes user skills into the agent dir and discovery finds them", async () => { + const sourceHome = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-4769-migrate-home-")); + await makeSkill(path.join(sourceHome, ".claude", "skills"), "migrated-skill"); + + setAgentDir(profile); + const report = await runMigrate({ + from: ["claude-code"], + force: false, + dryRun: false, + homeDir: sourceHome, + project: false, + json: false, + cwd: project, + }); + expect(report.actions.length).toBeGreaterThan(0); + + const migratedPath = path.join(profile, "skills", "migrated-skill", "SKILL.md"); + expect(await fs.readFile(migratedPath, "utf8")).toContain("migrated-skill"); + + const { skills } = await loadSkills({ cwd: project }); + expect(skills.map(skill => skill.name)).toContain("migrated-skill"); + await fs.rm(sourceHome, { recursive: true, force: true }); + }); + + test("session slash-command discovery stays on the explicit profile", async () => { + await writeFile( + path.join(profile, "commands", "profile-command.md"), + ["---", "description: profile command", "---", "", "profile body"].join("\n"), + ); + await writeFile( + path.join(home, ".gjc", "agent", "commands", "decoy-command.md"), + ["---", "description: decoy command", "---", "", "decoy body"].join("\n"), + ); + + const commands = await loadSlashCommands({ cwd: project, agentDir: profile }); + expect(commands.map(command => command.name)).toEqual(["profile-command", "init"]); + }); +}); diff --git a/packages/coding-agent/test/skill-management.test.ts b/packages/coding-agent/test/skill-management.test.ts index 504fe5c41a..33c18f75d7 100644 --- a/packages/coding-agent/test/skill-management.test.ts +++ b/packages/coding-agent/test/skill-management.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { getAgentDir, setAgentDir } from "@gajae-code/utils"; import { isNativeSkillEnabled, listConventionSkillImportSources, @@ -47,6 +48,7 @@ describe("skill-management", () => { const records = await listNativeSkillsForManagement({ cwd, home, + agentDir: path.join(home, ".gjc", "agent"), policy: { ignoredSkills: ["ignored-*"], disabledExtensions: ["skill:disabled-helper"] }, }); const byName = new Map(records.map(record => [record.name, record])); @@ -82,6 +84,52 @@ describe("skill-management", () => { }); }); + it("derives the user profile from an injected home when agentDir is omitted", async () => { + await withTempDirs(async (cwd, home) => { + await makeSkill(path.join(home, ".gjc", "agent", "skills"), "injected-user", "Injected user"); + const records = await listNativeSkillsForManagement({ cwd, home }); + + expect(records.map(record => record.name)).toContain("injected-user"); + expect(records.find(record => record.name === "injected-user")?.path).toBe( + path.join(home, ".gjc", "agent", "skills", "injected-user", "SKILL.md"), + ); + }); + }); + + it("keeps concurrent injected homes isolated when agentDir is omitted", async () => { + await withTempDirs(async (cwd, homeA) => { + const homeB = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-skill-mgmt-home-b-")); + try { + await makeSkill(path.join(homeA, ".gjc", "agent", "skills"), "profile-a", "Profile A"); + await makeSkill(path.join(homeB, ".gjc", "agent", "skills"), "profile-b", "Profile B"); + const [recordsA, recordsB] = await Promise.all([ + listNativeSkillsForManagement({ cwd, home: homeA }), + listNativeSkillsForManagement({ cwd, home: homeB }), + ]); + + expect(recordsA.map(record => record.name)).toEqual(["profile-a"]); + expect(recordsB.map(record => record.name)).toEqual(["profile-b"]); + } finally { + await fs.rm(homeB, { recursive: true, force: true }); + } + }); + }); + + it("uses the configured agent directory when home is omitted", async () => { + await withTempDirs(async (cwd, home) => { + const originalAgentDir = getAgentDir(); + const configuredAgentDir = path.join(home, "profile-agent"); + setAgentDir(configuredAgentDir); + try { + await makeSkill(path.join(configuredAgentDir, "skills"), "configured-user", "Configured user"); + const records = await listNativeSkillsForManagement({ cwd }); + expect(records.map(record => record.name)).toEqual(["configured-user"]); + } finally { + setAgentDir(originalAgentDir); + } + }); + }); + it("does not scan an untrusted scope at all", async () => { await withTempDirs(async (cwd, home) => { await makeSkill(path.join(cwd, ".gjc", "skills"), "project-helper", "Project helper"); @@ -90,6 +138,7 @@ describe("skill-management", () => { const records = await listNativeSkillsForManagement({ cwd, home, + agentDir: path.join(home, ".gjc", "agent"), policy: { trustProjectSkills: false }, }); expect(records.map(record => record.name)).toEqual(["user-helper"]); @@ -127,6 +176,7 @@ describe("skill-management", () => { cwd, home, scope: "user", + agentDir: path.join(home, ".gjc", "agent"), name: "my-skill", content: validContent, }); @@ -134,6 +184,38 @@ describe("skill-management", () => { }); }); + it("derives the user write root from injected home when agentDir is omitted", async () => { + await withTempDirs(async (cwd, home) => { + const receipt = await writeNativeSkill({ + cwd, + home, + scope: "user", + name: "my-skill", + content: validContent, + }); + expect(receipt.path).toBe(path.join(home, ".gjc", "agent", "skills", "my-skill", "SKILL.md")); + }); + }); + + it("uses the configured agent directory when user home is omitted", async () => { + await withTempDirs(async (cwd, home) => { + const originalAgentDir = getAgentDir(); + const configuredAgentDir = path.join(home, "profile-agent"); + setAgentDir(configuredAgentDir); + try { + const receipt = await writeNativeSkill({ + cwd, + scope: "user", + name: "configured-skill", + content: validContent, + }); + expect(receipt.path).toBe(path.join(configuredAgentDir, "skills", "my-skill", "SKILL.md")); + } finally { + setAgentDir(originalAgentDir); + } + }); + }); + it("rejects bundled workflow skill names", async () => { await withTempDirs(async (cwd, home) => { const protectedContent = ["---", "name: ultragoal", "description: Impostor", "---", "", "# x"].join("\n"); diff --git a/packages/coding-agent/test/skills.test.ts b/packages/coding-agent/test/skills.test.ts index 216be9d5ef..893fa78cd1 100644 --- a/packages/coding-agent/test/skills.test.ts +++ b/packages/coding-agent/test/skills.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; @@ -11,6 +11,7 @@ import { parseSkillInvocations, type Skill, } from "@gajae-code/coding-agent/extensibility/skills"; +import { getAgentDir, setAgentDir } from "@gajae-code/utils"; const fixturesDir = path.resolve(import.meta.dirname, "fixtures/skills"); const collisionFixturesDir = path.resolve(import.meta.dirname, "fixtures/skills-collision"); @@ -226,6 +227,7 @@ describe("skills", () => { it("never loads Claude/Codex convention skills at runtime; they are import sources only", async () => { const tempHomeDir = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-external-skills-home-")); const tempProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-external-skills-project-")); + const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(tempHomeDir); try { for (const root of [ @@ -249,7 +251,6 @@ describe("skills", () => { // never loaded as session skills. Only the native location loads. const { skills } = await loadSkills({ cwd: tempProjectDir, - home: tempHomeDir, enableCodexUser: true, enableClaudeUser: true, enableClaudeProject: true, @@ -258,6 +259,7 @@ describe("skills", () => { expect(skills.map(skill => skill.name)).toEqual(["native-project-skill"]); expect(skills[0]?.source).toBe("native:project"); } finally { + homedirSpy.mockRestore(); await fs.rm(tempProjectDir, { recursive: true, force: true }); await fs.rm(tempHomeDir, { recursive: true, force: true }); } @@ -346,7 +348,7 @@ enabled: false it("should expand ~ in customDirectories", async () => { const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-tilde-home-")); - const tempHomeSkillsDir = await fs.mkdtemp(path.join(tempHome, ".pi-skills-test-")); + const tempHomeSkillsDir = path.join(tempHome, "skills"); const relativeToHome = path.relative(tempHome, tempHomeSkillsDir); const tildeDir = `~/${relativeToHome.split(path.sep).join("/")}`; const skillDir = path.join(tempHomeSkillsDir, "tilde-skill"); @@ -365,26 +367,27 @@ description: Skill loaded from a tilde-expanded custom directory. try { const { skills: withTilde } = await loadSkills({ + home: tempHome, enableCodexUser: false, enableClaudeUser: false, enableClaudeProject: false, enablePiUser: false, enablePiProject: false, customDirectories: [tildeDir], - home: tempHome, }); const { skills: withoutTilde } = await loadSkills({ + home: tempHome, enableCodexUser: false, enableClaudeUser: false, enableClaudeProject: false, enablePiUser: false, enablePiProject: false, customDirectories: [tempHomeSkillsDir], - home: tempHome, }); expect(withTilde.length).toBe(withoutTilde.length); expect(withTilde.some(skill => skill.name === "tilde-skill")).toBe(true); } finally { + await fs.rm(tempHomeSkillsDir, { recursive: true, force: true }); await fs.rm(tempHome, { recursive: true, force: true }); } }); @@ -403,6 +406,11 @@ description: Skill loaded from a tilde-expanded custom directory. it("discovers native project and user skills with zero configuration", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-zero-config-skills-")); const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-zero-config-home-")); + const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(tempHome); + // The user skill scope is the agent directory (the trusted-home + // snapshot ignores the os.homedir mock), so isolate exactly that. + const originalAgentDir = getAgentDir(); + setAgentDir(path.join(tempHome, ".gjc", "agent")); try { for (const [root, name] of [ [path.join(tempDir, ".gjc", "skills", "project-skill"), "project-skill"], @@ -420,17 +428,57 @@ description: Skill loaded from a tilde-expanded custom directory. // No explicit settings at all: filesystem skill discovery is on by // default and every canonical native location is loaded. Claude/Codex // convention copies are import sources and never load directly. - const { skills } = await loadSkills({ cwd: tempDir, home: tempHome }); + const { skills } = await loadSkills({ cwd: tempDir }); expect(skills.map(skill => skill.name).sort()).toEqual(["project-skill", "user-skill"]); } finally { + setAgentDir(originalAgentDir); + homedirSpy.mockRestore(); + await fs.rm(tempDir, { recursive: true, force: true }); + await fs.rm(tempHome, { recursive: true, force: true }); + } + }); + + it("honors the injected home profile without reading the process profile", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-injected-home-skills-")); + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-injected-home-root-")); + const originalAgentDir = getAgentDir(); + const decoyAgentDir = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-injected-home-decoy-")); + setAgentDir(decoyAgentDir); + try { + await fs.mkdir(path.join(tempHome, ".gjc", "agent", "skills", "injected-skill"), { recursive: true }); + await fs.writeFile( + path.join(tempHome, ".gjc", "agent", "skills", "injected-skill", "SKILL.md"), + ["---", "name: injected-skill", "description: Injected home skill", "---", "", "# injected"].join("\n"), + ); + await fs.mkdir(path.join(tempHome, ".gjc", "skills", "legacy-skill"), { recursive: true }); + await fs.writeFile( + path.join(tempHome, ".gjc", "skills", "legacy-skill", "SKILL.md"), + ["---", "name: legacy-skill", "description: Injected legacy skill", "---", "", "# legacy"].join("\n"), + ); + await fs.mkdir(path.join(decoyAgentDir, "skills", "decoy-skill"), { recursive: true }); + await fs.writeFile( + path.join(decoyAgentDir, "skills", "decoy-skill", "SKILL.md"), + ["---", "name: decoy-skill", "description: Decoy skill", "---", "", "# decoy"].join("\n"), + ); + + const { skills } = await loadSkills({ cwd: tempDir, home: tempHome }); + expect(skills.map(skill => skill.name)).toContain("injected-skill"); + expect(skills.map(skill => skill.name)).toContain("legacy-skill"); + expect(skills.map(skill => skill.name)).not.toContain("decoy-skill"); + } finally { + setAgentDir(originalAgentDir); await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempHome, { recursive: true, force: true }); + await fs.rm(decoyAgentDir, { recursive: true, force: true }); } }); it("project scope shadows user scope, and the nearest project ancestor wins", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-precedence-skills-")); const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-precedence-home-")); + const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(tempHome); + const originalAgentDir = getAgentDir(); + setAgentDir(path.join(tempHome, ".gjc", "agent")); try { // Mark the repo root so the ancestor walk covers the nested package. await fs.mkdir(path.join(tempDir, ".git")); @@ -446,7 +494,7 @@ description: Skill loaded from a tilde-expanded custom directory. await write(path.join(tempDir, ".gjc", "skills", "shared"), "shared", "root body"); await write(path.join(nested, ".gjc", "skills", "shared"), "shared", "nested body"); - const { skills, warnings } = await loadSkills({ cwd: nested, home: tempHome }); + const { skills, warnings } = await loadSkills({ cwd: nested }); const shared = skills.find(skill => skill.name === "shared"); expect(shared).toBeDefined(); expect(shared?.source).toBe("native:project"); @@ -457,18 +505,20 @@ description: Skill loaded from a tilde-expanded custom directory. // Drop the nested copy: the repo-root project copy still beats user. await fs.rm(path.join(nested, ".gjc"), { recursive: true, force: true }); - const { skills: next } = await loadSkills({ cwd: nested, home: tempHome }); + const { skills: next } = await loadSkills({ cwd: nested }); expect(next.find(skill => skill.name === "shared")?.filePath).toContain( path.join(tempDir, ".gjc", "skills", "shared"), ); // Drop all project copies: the user copy finally wins. await fs.rm(path.join(tempDir, ".gjc"), { recursive: true, force: true }); - const { skills: userWins } = await loadSkills({ cwd: nested, home: tempHome }); + const { skills: userWins } = await loadSkills({ cwd: nested }); expect(userWins.find(skill => skill.name === "shared")?.filePath).toContain( path.join(tempHome, ".gjc", "agent", "skills", "shared"), ); } finally { + setAgentDir(originalAgentDir); + homedirSpy.mockRestore(); await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempHome, { recursive: true, force: true }); } @@ -477,6 +527,7 @@ description: Skill loaded from a tilde-expanded custom directory. it("keeps the legacy alias working and never lets disk skills replace bundled workflows", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-protected-skills-")); const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-protected-home-")); + const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(tempHome); try { const root = path.join(tempDir, ".gjc", "skills", "ralplan"); await fs.mkdir(root, { recursive: true }); @@ -489,14 +540,15 @@ description: Skill loaded from a tilde-expanded custom directory. // surface), but the session merge in sdk/session.ts keeps the bundled // definition authoritative (covered by sdk-skills.test.ts) and the // project-scope copy is diagnosed as a protected-name collision. - const { skills, warnings } = await loadSkills({ cwd: tempDir, home: tempHome }); + const { skills, warnings } = await loadSkills({ cwd: tempDir }); expect(skills.some(skill => skill.name === "ralplan")).toBe(true); expect(warnings.some(w => w.message.includes("bundled GJC workflow skill"))).toBe(true); // The legacy alias still disables the scope explicitly. - const legacyDisabled = await loadSkills({ cwd: tempDir, home: tempHome, enablePiProject: false }); + const legacyDisabled = await loadSkills({ cwd: tempDir, enablePiProject: false }); expect(legacyDisabled.skills).toHaveLength(0); } finally { + homedirSpy.mockRestore(); await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempHome, { recursive: true, force: true }); } @@ -505,6 +557,9 @@ description: Skill loaded from a tilde-expanded custom directory. it("trust flags disable their scope while the master switch disables everything", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-trust-skills-")); const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-trust-home-")); + const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(tempHome); + const originalAgentDir = getAgentDir(); + setAgentDir(path.join(tempHome, ".gjc", "agent")); try { const write = async (root: string, name: string) => { await fs.mkdir(root, { recursive: true }); @@ -516,23 +571,20 @@ description: Skill loaded from a tilde-expanded custom directory. await write(path.join(tempDir, ".gjc", "skills", "project-helper"), "project-helper"); await write(path.join(tempHome, ".gjc", "agent", "skills", "user-helper"), "user-helper"); - const userOff = await loadSkills({ cwd: tempDir, home: tempHome, trustUserSkills: false }); + const userOff = await loadSkills({ cwd: tempDir, trustUserSkills: false }); expect(userOff.skills.map(s => s.name)).toEqual(["project-helper"]); - const projectOff = await loadSkills({ cwd: tempDir, home: tempHome, trustProjectSkills: false }); + const projectOff = await loadSkills({ cwd: tempDir, trustProjectSkills: false }); expect(projectOff.skills.map(s => s.name)).toEqual(["user-helper"]); - const allOff = await loadSkills({ - cwd: tempDir, - home: tempHome, - trustProjectSkills: false, - trustUserSkills: false, - }); + const allOff = await loadSkills({ cwd: tempDir, trustProjectSkills: false, trustUserSkills: false }); expect(allOff.skills).toHaveLength(0); - const masterOff = await loadSkills({ cwd: tempDir, home: tempHome, enabled: false }); + const masterOff = await loadSkills({ cwd: tempDir, enabled: false }); expect(masterOff.skills).toHaveLength(0); } finally { + setAgentDir(originalAgentDir); + homedirSpy.mockRestore(); await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempHome, { recursive: true, force: true }); } diff --git a/packages/coding-agent/test/system-prompt-dedup.test.ts b/packages/coding-agent/test/system-prompt-dedup.test.ts index 87e7c353ba..679bee1603 100644 --- a/packages/coding-agent/test/system-prompt-dedup.test.ts +++ b/packages/coding-agent/test/system-prompt-dedup.test.ts @@ -10,6 +10,7 @@ import { loadProjectContextFiles, loadSystemPromptFiles, } from "@gajae-code/coding-agent/system-prompt"; +import { getAgentDir, setAgentDir } from "@gajae-code/utils"; import { cleanupTempHome } from "./helpers/temp-home-cleanup"; function escapeRegExp(text: string): string { @@ -20,6 +21,7 @@ describe("SYSTEM.md prompt assembly", () => { let tempDir = ""; let tempHomeDir = ""; let originalHome: string | undefined; + let originalAgentDir = ""; beforeEach(() => { // Keep project-context fixtures outside the real user HOME even when @@ -28,11 +30,17 @@ describe("SYSTEM.md prompt assembly", () => { tempDir = fs.mkdtempSync(path.join(path.sep, "tmp", "gjc-system-prompt-")); tempHomeDir = fs.mkdtempSync(path.join(path.sep, "tmp", "gjc-system-home-")); originalHome = process.env.HOME; + originalAgentDir = getAgentDir(); process.env.HOME = tempHomeDir; vi.spyOn(os, "homedir").mockReturnValue(tempHomeDir); + setAgentDir(path.join(tempHomeDir, ".gjc", "agent")); }); - afterEach(cleanupTempHome(() => ({ tempDir, tempHomeDir, originalHome }))); + afterEach(() => { + setAgentDir(originalAgentDir); + vi.restoreAllMocks(); + cleanupTempHome(() => ({ tempDir, tempHomeDir, originalHome }))(); + }); it("renders SYSTEM.md exactly once when it is used as the custom base prompt", async () => { const projectDir = path.join(tempDir, "project"); diff --git a/packages/coding-agent/test/tools/skill-discovery.test.ts b/packages/coding-agent/test/tools/skill-discovery.test.ts index 4a4a14940a..ef7b5489a2 100644 --- a/packages/coding-agent/test/tools/skill-discovery.test.ts +++ b/packages/coding-agent/test/tools/skill-discovery.test.ts @@ -4,6 +4,10 @@ import * as os from "node:os"; import * as path from "node:path"; import { Settings } from "@gajae-code/coding-agent/config/settings"; import { getSessionSlashCommands } from "@gajae-code/coding-agent/extensibility/extensions/get-commands-handler"; +import { + discoverRuntimeSkills, + findRuntimeSkillByName, +} from "@gajae-code/coding-agent/extensibility/runtime-skill-discovery"; import type { Skill } from "@gajae-code/coding-agent/extensibility/skills"; import { buildSystemPrompt } from "@gajae-code/coding-agent/system-prompt"; import type { ToolSession } from "@gajae-code/coding-agent/tools"; @@ -41,12 +45,17 @@ ${body} } function createSession(cwd: string, overrides: Partial = {}): ToolSession { + const testHome = process.env.HOME?.startsWith(os.tmpdir()) + ? process.env.HOME + : path.join(os.tmpdir(), "gjc-skill-discovery-empty-home"); return { cwd, hasUI: false, skills: [], getSessionFile: () => null, getSessionSpawns: () => "*", + getSessionHome: () => testHome, + getSessionAgentDir: () => path.join(testHome, ".gjc", "agent"), settings: Settings.isolated({ "skill.enabled": true }), ...overrides, }; @@ -103,35 +112,82 @@ describe("SkillDiscoveryTool", () => { it("discovers user runtime skills from ~/.gjc/skills", async () => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-user-skills-cwd-")); const home = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-user-skills-home-")); + const originalHome = process.env.HOME; + process.env.HOME = home; try { await makeSkill(path.join(home, ".gjc", "skills"), "user-helper", "User helper skill"); const settings = runtimeSkillSettings(); - const tool = new SkillDiscoveryTool(createSession(cwd, { settings, home })); + + const tool = new SkillDiscoveryTool( + createSession(cwd, { settings, getSessionAgentDir: () => path.join(home, ".gjc", "agent") }), + ); const result = await tool.execute("call", { source: "user" }); const details = result.details; expect(details).toBeDefined(); expect(details!.candidates.map(candidate => candidate.name)).toContain("user-helper"); expect(details!.candidates.find(candidate => candidate.name === "user-helper")?.source).toBe("user"); + } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + } + }); + + it("derives the default agent directory from an injected home", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-injected-home-cwd-")); + const home = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-injected-home-")); + try { + const skillPath = await makeSkill( + path.join(home, ".gjc", "agent", "skills"), + "injected-home", + "Injected home skill", + ); + const policy = runtimeSkillSettings().getGroup("skills"); + const discovered = await discoverRuntimeSkills({ cwd, home, policy }); + const found = await findRuntimeSkillByName(cwd, "injected-home", policy, home); + + expect(discovered.candidates.map(candidate => candidate.name)).toContain("injected-home"); + expect(found?.filePath).toBe(skillPath); } finally { await fs.rm(cwd, { recursive: true, force: true }); await fs.rm(home, { recursive: true, force: true }); } }); + it("keeps concurrent injected profiles isolated when agentDir is omitted", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-concurrent-profile-cwd-")); + const homeA = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-concurrent-profile-a-")); + const homeB = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-concurrent-profile-b-")); + try { + await makeSkill(path.join(homeA, ".gjc", "agent", "skills"), "profile-a", "Profile A skill"); + await makeSkill(path.join(homeB, ".gjc", "agent", "skills"), "profile-b", "Profile B skill"); + const policy = runtimeSkillSettings().getGroup("skills"); + const [resultA, resultB] = await Promise.all([ + discoverRuntimeSkills({ cwd, home: homeA, policy }), + discoverRuntimeSkills({ cwd, home: homeB, policy }), + ]); + + expect(resultA.candidates.map(candidate => candidate.name)).toEqual(["profile-a"]); + expect(resultB.candidates.map(candidate => candidate.name)).toEqual(["profile-b"]); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + await fs.rm(homeA, { recursive: true, force: true }); + await fs.rm(homeB, { recursive: true, force: true }); + } + }); + it("does not classify home .gjc skills as project skills while walking up", async () => { const home = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-home-skill-boundary-")); const cwd = path.join(home, "work", "project", "nested"); await fs.mkdir(cwd, { recursive: true }); await makeSkill(path.join(home, ".gjc", "skills"), "home-helper", "Home helper skill", "Home body."); + const originalHome = process.env.HOME; + process.env.HOME = home; try { const projectOnly = runtimeSkillSettings({ "skills.enablePiUser": false }); - const discovery = await new SkillDiscoveryTool(createSession(cwd, { settings: projectOnly, home })).execute( - "call", - { - source: "project", - }, - ); + const discovery = await new SkillDiscoveryTool(createSession(cwd, { settings: projectOnly })).execute("call", { + source: "project", + }); expect(discovery.details?.candidates).toEqual([]); const sent: Array<{ content: string; details?: unknown }> = []; @@ -139,7 +195,6 @@ describe("SkillDiscoveryTool", () => { createSession(cwd, { skills: [], settings: projectOnly, - home, sendCustomMessage: async message => { sent.push({ content: String(message.content), details: message.details }); }, @@ -150,13 +205,17 @@ describe("SkillDiscoveryTool", () => { const userEnabled = runtimeSkillSettings({ "skills.enablePiProject": false }); const userDiscovery = await new SkillDiscoveryTool( - createSession(cwd, { settings: userEnabled, home }), + createSession(cwd, { + settings: userEnabled, + getSessionAgentDir: () => path.join(home, ".gjc", "agent"), + }), ).execute("call", { source: "user" }); expect(userDiscovery.details?.candidates).toEqual([ expect.objectContaining({ name: "home-helper", source: "user" }), ]); } finally { - await fs.rm(home, { recursive: true, force: true }); + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; } }); @@ -292,6 +351,7 @@ describe("SkillDiscoveryTool", () => { it("discovers canonical and legacy user roots in native precedence order", async () => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-user-root-cwd-")); const home = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-user-root-home-")); + const originalHome = process.env.HOME; const originalGjcConfigDir = process.env.GJC_CONFIG_DIR; const originalPiConfigDir = process.env.PI_CONFIG_DIR; const originalCodingAgentDir = process.env.GJC_CODING_AGENT_DIR; @@ -299,6 +359,7 @@ describe("SkillDiscoveryTool", () => { const originalXdgConfigHome = process.env.XDG_CONFIG_HOME; try { + process.env.HOME = home; process.env.GJC_CONFIG_DIR = "/absolute-looking-gjc"; process.env.PI_CONFIG_DIR = ".decoy-pi"; process.env.GJC_CODING_AGENT_DIR = path.join(home, ".decoy-agent"); @@ -325,7 +386,10 @@ describe("SkillDiscoveryTool", () => { await makeSkill(path.join(home, ".xdg-decoy", "gjc", "agent", "skills"), "xdg-decoy", "XDG decoy user skill"); const result = await new SkillDiscoveryTool( - createSession(cwd, { settings: runtimeSkillSettings(), home }), + createSession(cwd, { + settings: runtimeSkillSettings(), + getSessionAgentDir: () => path.join(home, "absolute-looking-gjc", "agent"), + }), ).execute("call", { source: "user", }); @@ -339,13 +403,18 @@ describe("SkillDiscoveryTool", () => { ]); const allSources = await new SkillDiscoveryTool( - createSession(cwd, { settings: runtimeSkillSettings(), home }), + createSession(cwd, { + settings: runtimeSkillSettings(), + getSessionAgentDir: () => path.join(home, "absolute-looking-gjc", "agent"), + }), ).execute("call", {}); expect(allSources.details?.candidates).toEqual([ expect.objectContaining({ name: "historical", source: "user" }), expect.objectContaining({ name: "shared", description: "Project user skill", source: "project" }), ]); } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; if (originalGjcConfigDir === undefined) delete process.env.GJC_CONFIG_DIR; else process.env.GJC_CONFIG_DIR = originalGjcConfigDir; if (originalPiConfigDir === undefined) delete process.env.PI_CONFIG_DIR; @@ -364,9 +433,11 @@ describe("SkillDiscoveryTool", () => { it("uses the default and PI_CONFIG_DIR canonical user roots", async () => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-user-canonical-cwd-")); const home = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-user-canonical-home-")); + const originalHome = process.env.HOME; const originalGjcConfigDir = process.env.GJC_CONFIG_DIR; const originalPiConfigDir = process.env.PI_CONFIG_DIR; try { + process.env.HOME = home; delete process.env.GJC_CONFIG_DIR; delete process.env.PI_CONFIG_DIR; await makeSkill( @@ -376,7 +447,10 @@ describe("SkillDiscoveryTool", () => { ); await makeSkill(path.join(home, ".gjc", "skills"), "default-canonical", "Default legacy user skill"); let result = await new SkillDiscoveryTool( - createSession(cwd, { settings: runtimeSkillSettings(), home }), + createSession(cwd, { + settings: runtimeSkillSettings(), + getSessionAgentDir: () => path.join(home, ".gjc", "agent"), + }), ).execute("call", { source: "user", }); @@ -386,17 +460,21 @@ describe("SkillDiscoveryTool", () => { process.env.PI_CONFIG_DIR = ".pi-config"; await makeSkill(path.join(home, ".pi-config", "agent", "skills"), "pi-canonical", "PI canonical user skill"); - result = await new SkillDiscoveryTool(createSession(cwd, { settings: runtimeSkillSettings(), home })).execute( - "call", - { - source: "user", - }, - ); + result = await new SkillDiscoveryTool( + createSession(cwd, { + settings: runtimeSkillSettings(), + getSessionAgentDir: () => path.join(home, ".pi-config", "agent"), + }), + ).execute("call", { + source: "user", + }); expect(result.details?.candidates.map(candidate => candidate.name)).toEqual([ "default-canonical", "pi-canonical", ]); } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; if (originalGjcConfigDir === undefined) delete process.env.GJC_CONFIG_DIR; else process.env.GJC_CONFIG_DIR = originalGjcConfigDir; if (originalPiConfigDir === undefined) delete process.env.PI_CONFIG_DIR; @@ -458,7 +536,9 @@ describe("SkillDiscoveryTool", () => { it("applies policy before realpath/name dedup, then query, sort, and limit", async () => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-skill-discovery-pipeline-")); const home = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-skill-discovery-pipeline-home-")); + const originalHome = process.env.HOME; try { + process.env.HOME = home; const skillsDir = path.join(cwd, ".gjc", "skills"); const alphaPath = await makeSkill(skillsDir, "alpha", "Sort alpha", "Alpha body."); await fs.symlink(path.dirname(alphaPath), path.join(skillsDir, "zz-alias-alpha"), "dir"); @@ -472,24 +552,35 @@ describe("SkillDiscoveryTool", () => { await makeSkill(skillsDir, "zulu", "Sort zulu", "Zulu body."); const userOnly = await new SkillDiscoveryTool( - createSession(cwd, { settings: runtimeSkillSettings({ "skills.enablePiProject": false }), home }), + createSession(cwd, { + settings: runtimeSkillSettings({ "skills.enablePiProject": false }), + getSessionAgentDir: () => path.join(home, ".gjc", "agent"), + }), ).execute("call", { query: "lower-only" }); expect(userOnly.details?.candidates).toEqual([ expect.objectContaining({ name: "alpha", path: userAlphaPath, source: "user" }), ]); const dedupBeforeQuery = await new SkillDiscoveryTool( - createSession(cwd, { settings: runtimeSkillSettings(), home }), + createSession(cwd, { + settings: runtimeSkillSettings(), + getSessionAgentDir: () => path.join(home, ".gjc", "agent"), + }), ).execute("call", { query: "lower-only" }); expect(dedupBeforeQuery.details?.candidates).toEqual([]); const result = await new SkillDiscoveryTool( - createSession(cwd, { settings: runtimeSkillSettings(), home }), + createSession(cwd, { + settings: runtimeSkillSettings(), + getSessionAgentDir: () => path.join(home, ".gjc", "agent"), + }), ).execute("call", { query: "sort", limit: 1 }); expect(result.details?.candidates).toEqual([ expect.objectContaining({ name: "alpha", description: "Sort alpha", path: alphaPath, source: "project" }), ]); } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; await fs.rm(cwd, { recursive: true, force: true }); await fs.rm(home, { recursive: true, force: true }); } @@ -500,28 +591,27 @@ describe("SkillDiscoveryTool", () => { const home = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-skills-policy-home-")); await makeSkill(path.join(cwd, ".gjc", "skills"), "project-helper", "Project helper skill", "Project body."); await makeSkill(path.join(home, ".gjc", "skills"), "user-helper", "User helper skill", "User body."); + const originalHome = process.env.HOME; + process.env.HOME = home; try { const projectDisabled = runtimeSkillSettings({ "skills.enablePiProject": false }); - let result = await new SkillDiscoveryTool(createSession(cwd, { settings: projectDisabled, home })).execute( + let result = await new SkillDiscoveryTool(createSession(cwd, { settings: projectDisabled })).execute( "call", {}, ); expect(result.details?.candidates.map(candidate => candidate.name)).toEqual(["user-helper"]); await expect( new SkillTool( - createSession(cwd, { skills: [], settings: projectDisabled, home, sendCustomMessage: async () => {} }), + createSession(cwd, { skills: [], settings: projectDisabled, sendCustomMessage: async () => {} }), ).execute("call", { name: "project-helper" }), ).rejects.toThrow(/unknown skill/); const userDisabled = runtimeSkillSettings({ "skills.enablePiUser": false }); - result = await new SkillDiscoveryTool(createSession(cwd, { settings: userDisabled, home })).execute( - "call", - {}, - ); + result = await new SkillDiscoveryTool(createSession(cwd, { settings: userDisabled })).execute("call", {}); expect(result.details?.candidates.map(candidate => candidate.name)).toEqual(["project-helper"]); await expect( new SkillTool( - createSession(cwd, { skills: [], settings: userDisabled, home, sendCustomMessage: async () => {} }), + createSession(cwd, { skills: [], settings: userDisabled, sendCustomMessage: async () => {} }), ).execute("call", { name: "user-helper" }), ).rejects.toThrow(/unknown skill/); @@ -530,17 +620,20 @@ describe("SkillDiscoveryTool", () => { runtimeSkillSettings({ "skills.includeSkills": ["user-*"] }), runtimeSkillSettings({ disabledExtensions: ["skill:project-helper"] }), ]) { - result = await new SkillDiscoveryTool(createSession(cwd, { settings, home })).execute("call", { + result = await new SkillDiscoveryTool(createSession(cwd, { settings })).execute("call", { source: "project", }); expect(result.details?.candidates).toEqual([]); await expect( - new SkillTool( - createSession(cwd, { skills: [], settings, home, sendCustomMessage: async () => {} }), - ).execute("call", { name: "project-helper" }), + new SkillTool(createSession(cwd, { skills: [], settings, sendCustomMessage: async () => {} })).execute( + "call", + { name: "project-helper" }, + ), ).rejects.toThrow(/unknown skill/); } } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; } }); @@ -571,15 +664,18 @@ describe("SkillDiscoveryTool", () => { it("applies runtime precedence: project .gjc beats user, convention copies stay import candidates", async () => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-convention-precedence-")); const home = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-convention-precedence-home-")); + const originalHome = process.env.HOME; + process.env.HOME = home; try { await makeSkill(path.join(home, ".gjc", "skills"), "shared", "User copy"); await makeSkill(path.join(cwd, ".codex", "skills"), "shared", "Codex copy"); await makeSkill(path.join(cwd, ".claude", "skills"), "shared", "Claude copy"); const nativePath = await makeSkill(path.join(cwd, ".gjc", "skills"), "shared", "Native copy"); - const result = await new SkillDiscoveryTool( - createSession(cwd, { settings: runtimeSkillSettings(), home }), - ).execute("call", { query: "shared" }); + const result = await new SkillDiscoveryTool(createSession(cwd, { settings: runtimeSkillSettings() })).execute( + "call", + { query: "shared" }, + ); expect(result.details?.candidates).toEqual([ expect.objectContaining({ name: "shared", @@ -597,11 +693,13 @@ describe("SkillDiscoveryTool", () => { // convention copies remain import candidates with enablement guidance. await fs.rm(path.join(cwd, ".gjc"), { recursive: true, force: true }); const userWins = await new SkillDiscoveryTool( - createSession(cwd, { settings: runtimeSkillSettings(), home }), + createSession(cwd, { settings: runtimeSkillSettings() }), ).execute("call", { query: "shared" }); expect(userWins.details?.candidates[0]?.path).toContain(path.join(".gjc", "skills", "shared")); expect(userWins.details?.candidates[0]?.source).toBe("user"); } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; } }); @@ -698,6 +796,8 @@ describe("SkillDiscoveryTool", () => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-customdir-untrusted-cwd-")); const home = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-customdir-untrusted-home-")); const custom = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-customdir-untrusted-root-")); + const originalHome = process.env.HOME; + process.env.HOME = home; try { await makeSkill(path.join(home, ".gjc", "skills"), "ambient-helper", "Ambient user skill"); await makeSkill(custom, "declared-helper", "Declared custom skill"); @@ -706,12 +806,14 @@ describe("SkillDiscoveryTool", () => { "skills.customDirectories": [custom], }); - const result = await new SkillDiscoveryTool(createSession(cwd, { settings, home })).execute("call", {}); + const result = await new SkillDiscoveryTool(createSession(cwd, { settings })).execute("call", {}); const names = (result.details?.candidates ?? []).map(candidate => candidate.name); expect(names).toContain("declared-helper"); expect(names).not.toContain("ambient-helper"); } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; } }); diff --git a/packages/coding-agent/test/tools/skill.test.ts b/packages/coding-agent/test/tools/skill.test.ts index 51ddb3efe2..db76bdb031 100644 --- a/packages/coding-agent/test/tools/skill.test.ts +++ b/packages/coding-agent/test/tools/skill.test.ts @@ -213,11 +213,13 @@ describe("SkillTool", () => { it("uses runtime fallback through createIf while session skills retain name precedence", async () => { const cwd = await makeTempCwd(); const home = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tool-runtime-home-")); + const originalHome = process.env.HOME; const originalGjcConfigDir = process.env.GJC_CONFIG_DIR; const originalPiConfigDir = process.env.PI_CONFIG_DIR; let unrelated: Skill | undefined; let preloaded: Skill | undefined; try { + process.env.HOME = home; process.env.GJC_CONFIG_DIR = ".gjc"; delete process.env.PI_CONFIG_DIR; const runtimePath = await makeRuntimeSkill( @@ -228,7 +230,9 @@ describe("SkillTool", () => { ); unrelated = await makeSkill("unrelated", "Unrelated body."); const captured: CapturedSend[] = []; - const session = createSession(cwd, [unrelated], captured, { settings: runtimeSkillSettings(), home }); + const session = createSession(cwd, [unrelated], captured, { settings: runtimeSkillSettings() }); + session.getSessionHome = () => home; + session.getSessionAgentDir = () => path.join(home, ".gjc", "agent"); const tool = SkillTool.createIf(session); expect(tool).not.toBeNull(); @@ -241,6 +245,8 @@ describe("SkillTool", () => { const preloadedTool = SkillTool.createIf( createSession(cwd, [unrelated, preloaded], preloadedCaptured, { settings: runtimeSkillSettings(), + getSessionHome: () => home, + getSessionAgentDir: () => path.join(home, ".gjc", "agent"), }), ); expect(preloadedTool).not.toBeNull(); @@ -249,6 +255,8 @@ describe("SkillTool", () => { expect(preloadedCaptured[0]?.message.content).not.toContain("Runtime fallback body."); expect(preloadedResult.details?.path).toBe(preloaded.filePath); } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; if (originalGjcConfigDir === undefined) delete process.env.GJC_CONFIG_DIR; else process.env.GJC_CONFIG_DIR = originalGjcConfigDir; if (originalPiConfigDir === undefined) delete process.env.PI_CONFIG_DIR; @@ -263,6 +271,7 @@ describe("SkillTool", () => { it("uses exact runtime fallback precedence across project, canonical, configured, and historical roots", async () => { const cwd = await makeTempCwd(); const home = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tool-runtime-precedence-home-")); + const originalHome = process.env.HOME; const originalGjcConfigDir = process.env.GJC_CONFIG_DIR; const originalPiConfigDir = process.env.PI_CONFIG_DIR; const originalCodingAgentDir = process.env.GJC_CODING_AGENT_DIR; @@ -270,6 +279,7 @@ describe("SkillTool", () => { const originalXdgConfigHome = process.env.XDG_CONFIG_HOME; let loaded: Skill | undefined; try { + process.env.HOME = home; delete process.env.GJC_CONFIG_DIR; delete process.env.PI_CONFIG_DIR; const gjcAgentDecoyDir = path.join(home, "gjc-agent-decoy"); @@ -287,7 +297,16 @@ describe("SkillTool", () => { const captured: CapturedSend[] = []; loaded = await makeSkill("loaded", "Loaded"); const tool = SkillTool.createIf( - createSession(cwd, [loaded], captured, { settings: runtimeSkillSettings(), home }), + createSession(cwd, [loaded], captured, { + settings: runtimeSkillSettings(), + getSessionHome: () => home, + getSessionAgentDir: () => + process.env.GJC_CONFIG_DIR === ".configured-gjc" + ? path.join(home, ".configured-gjc", "agent") + : process.env.PI_CONFIG_DIR === ".configured-pi" + ? path.join(home, ".configured-pi", "agent") + : path.join(home, ".gjc", "agent"), + }), )!; const defaultResult = await tool.execute("call-default-canonical", { name: "default-canonical" }); expect(captured.at(-1)?.message.content).toContain("Default canonical body."); @@ -405,6 +424,8 @@ describe("SkillTool", () => { expect(captured).toHaveLength(capturedBeforeDecoys); expect([gjcDecoyPath, piDecoyPath, xdgDecoyPath]).not.toContain(piResult.details?.path); } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; if (originalGjcConfigDir === undefined) delete process.env.GJC_CONFIG_DIR; else process.env.GJC_CONFIG_DIR = originalGjcConfigDir; if (originalPiConfigDir === undefined) delete process.env.PI_CONFIG_DIR; diff --git a/schemas/config.schema.json b/schemas/config.schema.json index 8716825afa..0479071839 100644 --- a/schemas/config.schema.json +++ b/schemas/config.schema.json @@ -2854,7 +2854,7 @@ }, "trustUserSkills": { "type": "boolean", - "description": "Load skills from ~/.gjc/agent/skills (and legacy ~/.gjc/skills / /skills). Set to false to ignore user-installed skills while keeping project skills." + "description": "Load skills from the agent directory's skills root (`gjc config dir`; ~/.gjc/agent/skills by default, with legacy ~/.gjc/skills / /skills roots in the default profile). Set to false to ignore user-installed skills while keeping project skills." }, "enablePiUser": { "type": "boolean" From 51ae0abecd332fd2d8de47ea8f86ab0f136317d5 Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Sat, 22 Aug 2026 17:13:53 +0000 Subject: [PATCH 2/5] fix(ci): stabilize affected coding-agent validation The recovered user-scope change exposed two CI drift seams: print-mode fixtures lacked the new session agent-directory accessor, and the SDK daemon e2e file exceeded the coding-agent shard file timeout under the fresh-process harness. Keep the failure evidence deterministic and let the affected evidence producer observe a real green shard aggregate. Lore-id: 4784-skill-scope-recovery Constraint: preserve the recovered PR delta and user-scope behavior Constraint: do not mutate release state Tested: bun test scripts/ci-dev-affected.test.ts packages/coding-agent/test/silent-abort-print-mode.test.ts Confidence: high Scope-risk: ci-regression Reversibility: revert-commit --- packages/coding-agent/test/silent-abort-print-mode.test.ts | 2 ++ scripts/ci-dev-affected.test.ts | 6 +++--- scripts/ci-dev-affected.ts | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/test/silent-abort-print-mode.test.ts b/packages/coding-agent/test/silent-abort-print-mode.test.ts index 7d9da7489d..2f1acffb7a 100644 --- a/packages/coding-agent/test/silent-abort-print-mode.test.ts +++ b/packages/coding-agent/test/silent-abort-print-mode.test.ts @@ -73,6 +73,7 @@ function createMockSession( getHeader: () => undefined, getCwd: () => import.meta.dir, }, + getSessionAgentDir: () => import.meta.dir, setSlashCommands: () => {}, extensionRunner: undefined, subscribe: () => () => {}, @@ -124,6 +125,7 @@ function createPrintModeTrackingSession( configWarnings: [], state: { messages }, sessionManager: { getHeader: () => header, getCwd: () => import.meta.dir }, + getSessionAgentDir: () => import.meta.dir, extensionRunner: undefined, setSlashCommands, subscribe: (listener: (event: unknown) => void) => { diff --git a/scripts/ci-dev-affected.test.ts b/scripts/ci-dev-affected.test.ts index 47e00cc608..d691fc0bc5 100644 --- a/scripts/ci-dev-affected.test.ts +++ b/scripts/ci-dev-affected.test.ts @@ -1172,7 +1172,7 @@ describe("planTargetedTasks PR-mode targeting", () => { "--root=packages/coding-agent", "--shard=1/8", "--timeout=30000", - "--file-timeout=300000", + "--file-timeout=600000", "--concurrency=1", ]); expect(keys.filter(key => key.startsWith("test:@gajae-code/coding-agent:shard-"))).toEqual([shardOne]); @@ -1194,7 +1194,7 @@ describe("planTargetedTasks PR-mode targeting", () => { "--root=packages/coding-agent", "--shard=1/8", "--timeout=30000", - "--file-timeout=300000", + "--file-timeout=600000", "--concurrency=1", ]); expect(tasks.find(task => task.key === "test:@gajae-code/coding-agent:sdk-production-host-isolated")?.command).toEqual([ @@ -1602,7 +1602,7 @@ describe("push-mode broad planning still runs the fuller suite", () => { "--root=packages/coding-agent", "--shard=1/8", "--timeout=30000", - "--file-timeout=300000", + "--file-timeout=600000", "--concurrency=1", ]); expect(testShards[0]?.cwd).toBeUndefined(); diff --git a/scripts/ci-dev-affected.ts b/scripts/ci-dev-affected.ts index 28b0956553..7e1bac4c5a 100755 --- a/scripts/ci-dev-affected.ts +++ b/scripts/ci-dev-affected.ts @@ -1047,7 +1047,7 @@ function addCodingAgentTestShard(tasks: Map, shard: number, total: "--root=packages/coding-agent", `--shard=${shard}/${total}`, "--timeout=30000", - "--file-timeout=300000", + "--file-timeout=600000", "--concurrency=1", ], ); From 4fef4a2924a90233fc3877a6dc905fa821d44265 Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Sat, 22 Aug 2026 17:32:23 +0000 Subject: [PATCH 3/5] fix(capability): bind explicit-home defaults to supplied profile loadCapabilityForHome previously reused the process-wide agent directory whenever callers omitted agentDir, so an isolated home could read another profile's user-scope files. Derive the default profile from the supplied home while keeping loadCapability's process-scoped behavior unchanged, and cover system, rules, agents, and skills with decoy-profile regressions. Lore-id: 4784-skill-scope-recovery Constraint: preserve manifest chaining, precedence, protected names, deterministic shadowing, and session-native agentDir Constraint: do not mutate release state Tested: bun test packages/coding-agent/test/issue-4769-user-scope-agent-dir.test.ts packages/coding-agent/test/discovery packages/coding-agent/test/gjc-runtime/workflow-settings.test.ts packages/coding-agent/test/config/settings-workflow-migration.test.ts Confidence: high Scope-risk: focused Reversibility: revert-commit --- packages/coding-agent/src/capability/index.ts | 12 +++-- .../issue-4769-user-scope-agent-dir.test.ts | 48 ++++++++++++++++++- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/capability/index.ts b/packages/coding-agent/src/capability/index.ts index e47f5c7593..431bed1178 100644 --- a/packages/coding-agent/src/capability/index.ts +++ b/packages/coding-agent/src/capability/index.ts @@ -7,7 +7,7 @@ * - Loading items for a capability across all providers */ import * as path from "node:path"; -import { getAgentDir, getProjectDir, getTrustedHomeDir, logger } from "@gajae-code/utils"; +import { getAgentDir, getConfigDirName, getProjectDir, getTrustedHomeDir, logger } from "@gajae-code/utils"; import type { Settings } from "../config/settings"; import { clearCache as clearFsCache, findRepoRoot, cacheStats as fsCacheStats, invalidate as invalidateFs } from "./fs"; @@ -240,7 +240,9 @@ async function loadCapabilityWithContext( } const cwd = options.cwd ?? getProjectDir(); - const userAgentDir = options.agentDir ? path.resolve(options.agentDir) : getAgentDir(); + const userAgentDir = options.agentDir + ? path.resolve(options.agentDir) + : path.join(home, getConfigDirName(), "agent"); const repoRoot = await findRepoRoot(cwd); const ctx: LoadContext = { cwd, home, userAgentDir, repoRoot }; const providers = filterProviders(capability, options); @@ -249,7 +251,11 @@ async function loadCapabilityWithContext( } export async function loadCapability(capabilityId: string, options: LoadOptions = {}): Promise> { - return await loadCapabilityWithContext(capabilityId, options, getTrustedHomeDir()); + return await loadCapabilityWithContext( + capabilityId, + { ...options, agentDir: options.agentDir ?? getAgentDir() }, + getTrustedHomeDir(), + ); } /** diff --git a/packages/coding-agent/test/issue-4769-user-scope-agent-dir.test.ts b/packages/coding-agent/test/issue-4769-user-scope-agent-dir.test.ts index ea515c9111..7d49f6f89b 100644 --- a/packages/coding-agent/test/issue-4769-user-scope-agent-dir.test.ts +++ b/packages/coding-agent/test/issue-4769-user-scope-agent-dir.test.ts @@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { getCapability } from "@gajae-code/coding-agent/capability"; +import { getCapability, loadCapabilityForHome } from "@gajae-code/coding-agent/capability"; +import { type ContextFile, contextFileCapability } from "@gajae-code/coding-agent/capability/context-file"; import { clearCache } from "@gajae-code/coding-agent/capability/fs"; import { type Rule, ruleCapability } from "@gajae-code/coding-agent/capability/rule"; import { type Skill, skillCapability } from "@gajae-code/coding-agent/capability/skill"; @@ -75,6 +76,51 @@ afterEach(async () => { }); describe("issue #4769: user scope follows the agent directory", () => { + test("explicit-home loading derives its default agent directory from home", async () => { + const homeAgentDir = path.join(home, ".gjc", "agent"); + const decoyAgentDir = path.join(tempDir, "process-global-decoy"); + await writeFile(path.join(homeAgentDir, "SYSTEM.md"), "# home system"); + await writeFile(path.join(homeAgentDir, "RULES.md"), "home rules"); + await writeFile(path.join(homeAgentDir, "AGENTS.md"), "home agents"); + await makeSkill(path.join(homeAgentDir, "skills"), "home-skill"); + await writeFile(path.join(decoyAgentDir, "SYSTEM.md"), "# decoy system"); + await writeFile(path.join(decoyAgentDir, "RULES.md"), "decoy rules"); + await writeFile(path.join(decoyAgentDir, "AGENTS.md"), "decoy agents"); + await makeSkill(path.join(decoyAgentDir, "skills"), "decoy-skill"); + + setAgentDir(decoyAgentDir); + const options = { cwd: project, providers: ["native"] }; + const system = await loadCapabilityForHome(systemPromptCapability.id, home, options); + const rules = await loadCapabilityForHome(ruleCapability.id, home, options); + const context = await loadCapabilityForHome(contextFileCapability.id, home, options); + const skills = await loadCapabilityForHome(skillCapability.id, home, options); + + expect(system.items.map(item => item.content)).toEqual(["# home system"]); + expect(rules.items.map(item => item.content)).toEqual(["home rules"]); + expect(context.items.map(item => item.content)).toEqual(["home agents"]); + expect(skills.items.map(item => item.name)).toEqual(["home-skill"]); + for (const result of [system, rules, context, skills]) { + expect(result.items.every(item => !item.path.startsWith(decoyAgentDir))).toBe(true); + } + }); + + test("explicit-home loading still honors an explicit agent directory", async () => { + const explicitAgentDir = path.join(tempDir, "explicit-agent"); + const decoyAgentDir = path.join(tempDir, "process-global-decoy"); + await writeFile(path.join(explicitAgentDir, "SYSTEM.md"), "# explicit system"); + await makeSkill(path.join(explicitAgentDir, "skills"), "explicit-skill"); + await writeFile(path.join(decoyAgentDir, "SYSTEM.md"), "# decoy system"); + await makeSkill(path.join(decoyAgentDir, "skills"), "decoy-skill"); + + setAgentDir(decoyAgentDir); + const options = { cwd: project, agentDir: explicitAgentDir, providers: ["native"] }; + const system = await loadCapabilityForHome(systemPromptCapability.id, home, options); + const skills = await loadCapabilityForHome(skillCapability.id, home, options); + + expect(system.items.map(item => item.content)).toEqual(["# explicit system"]); + expect(skills.items.map(item => item.name)).toEqual(["explicit-skill"]); + }); + test("user SYSTEM.md is read from the agent directory, not the home-relative default", async () => { await writeFile(path.join(profile, "SYSTEM.md"), "# profile system prompt"); // Decoy in the home-relative default location: must stay unread. From 63c4b762b8c19ea8f4abb12744e4766a094597cc Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Sat, 22 Aug 2026 18:32:05 +0000 Subject: [PATCH 4/5] fix(capability): preserve empty agent-dir fallback Keep ordinary loadCapability behavior identical for empty agentDir values while the explicit-home loader remains home-bound. This removes the only residual edge case identified during the independent architecture review. Lore-id: 4784-skill-scope-recovery Constraint: preserve process-scoped agentDir semantics Tested: bun test packages/coding-agent/test/issue-4769-user-scope-agent-dir.test.ts packages/coding-agent/test/discovery packages/coding-agent/test/config/settings-workflow-migration.test.ts packages/coding-agent/test/gjc-runtime/workflow-settings.test.ts Confidence: high Scope-risk: focused Reversibility: revert-commit --- packages/coding-agent/src/capability/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/src/capability/index.ts b/packages/coding-agent/src/capability/index.ts index 431bed1178..1801c1d40d 100644 --- a/packages/coding-agent/src/capability/index.ts +++ b/packages/coding-agent/src/capability/index.ts @@ -253,7 +253,7 @@ async function loadCapabilityWithContext( export async function loadCapability(capabilityId: string, options: LoadOptions = {}): Promise> { return await loadCapabilityWithContext( capabilityId, - { ...options, agentDir: options.agentDir ?? getAgentDir() }, + { ...options, agentDir: options.agentDir || getAgentDir() }, getTrustedHomeDir(), ); } From 49f309f8b569d40703b8b34c9cc977bf594547d8 Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Sat, 22 Aug 2026 20:34:25 +0000 Subject: [PATCH 5/5] fix(ci): clean publish declaration side effects The complete check generated an adapter declaration under packages/ai/src while validating the dependent agent package, leaving the workspace dirty and causing Biome to fail on an untracked declaration. Clean only declarations created during the isolated publish-type check, and remove two pre-existing no-op String.raw wrappers so the root check is deterministic. Lore-id: 4784-skill-scope-recovery Constraint: no release mutation; cleanup is limited to temporary declaration side effects Constraint: preserve the recovered PR and profile-isolation behavior Tested: bun run check:publish-types Tested: bun run check Tested: bun test packages/coding-agent/test/issue-4769-user-scope-agent-dir.test.ts packages/coding-agent/test/discovery packages/coding-agent/test/config/settings-workflow-migration.test.ts packages/coding-agent/test/gjc-runtime/workflow-settings.test.ts Confidence: high Scope-risk: CI-harness Reversibility: revert-commit --- packages/ai/src/providers/openai-codex-responses.ts | 4 ++-- scripts/ci-release-publish.ts | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index d6792da4e9..c57ba741c3 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -130,7 +130,7 @@ const CODEX_PREVIOUS_RESPONSE_STALE_CODES = new Set(["previous_response_not_foun // on both sides of the anchor phrase), since a deterministic history fault such // as `Previous response's tool call ID is malformed.` must stay fatal: // replaying it re-sends the same offending item. -const CODEX_PREVIOUS_RESPONSE_ID_TOKEN = String.raw`previous[ _-]response[ _-]id`; +const CODEX_PREVIOUS_RESPONSE_ID_TOKEN = `previous[ _-]response[ _-]id`; // Prose anchor reference ("Previous response with id 'resp_1' not found."): // the canonical `previous_response_not_found` wording, which codex-lb re-codes // as `invalid_request_error` the same way it re-codes the anchor-expiry fault @@ -142,7 +142,7 @@ const CODEX_PREVIOUS_RESPONSE_PROSE_TOKEN = `previous[ _-]response`; // the same offending item. Tempering the qualifier⇄token scan against these // tokens keeps them fatal while pure anchor-stale prose still matches. const CODEX_PREVIOUS_RESPONSE_STALE_SUBFIELD_GUARD = `tool[ _]calls?|function[ _]calls?|custom[ _]tools?|call[ _-]?ids?|message[ _]?ids?|items?\\b|output[ _-]?items?`; -const CODEX_ANCHOR_STALE_QUALIFIER = String.raw`invalid|expired|unknown|stale|not[ _-]?found|no longer`; +const CODEX_ANCHOR_STALE_QUALIFIER = `invalid|expired|unknown|stale|not[ _-]?found|no longer`; const CODEX_PREVIOUS_RESPONSE_STALE_MESSAGE = new RegExp( `(?:${CODEX_ANCHOR_STALE_QUALIFIER})[^\\n]{0,48}?${CODEX_PREVIOUS_RESPONSE_ID_TOKEN}` + `|${CODEX_PREVIOUS_RESPONSE_ID_TOKEN}[^\\n]{0,48}?(?:${CODEX_ANCHOR_STALE_QUALIFIER})`, diff --git a/scripts/ci-release-publish.ts b/scripts/ci-release-publish.ts index 15040d7012..791015d94e 100644 --- a/scripts/ci-release-publish.ts +++ b/scripts/ci-release-publish.ts @@ -447,8 +447,15 @@ async function emitTypeDeclarations(pkg: PublishPackage, temporaryRoot?: string) } } +async function declarationFilesUnderPackages(): Promise> { + const files = new Set(); + for await (const entry of new Bun.Glob("packages/**/*.d.ts").scan({ cwd: repoRoot })) files.add(entry); + return files; +} + async function checkTypeDeclarations(): Promise { const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "gajae-code-types-")); + const declarationsBefore = await declarationFilesUnderPackages(); try { for (const pkg of packages) { if (pkg.kind !== "typescript") continue; @@ -456,6 +463,10 @@ async function checkTypeDeclarations(): Promise { console.log(`Checked declarations (${pkg.dir})`); } } finally { + const declarationsAfter = await declarationFilesUnderPackages(); + for (const declaration of declarationsAfter) { + if (!declarationsBefore.has(declaration)) await fs.rm(path.join(repoRoot, declaration), { force: true }); + } await fs.rm(temporaryRoot, { recursive: true, force: true }); } }