@gajae-code/coding-agent 0.16.4 → 0.16.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/types/capability/types.d.ts +4 -0
  3. package/dist/types/cli/customize-doctor.d.ts +22 -0
  4. package/dist/types/config/file-lock.d.ts +31 -3
  5. package/dist/types/coordinator-mcp/question-state.d.ts +7 -0
  6. package/dist/types/coordinator-mcp/server.d.ts +7 -0
  7. package/dist/types/discovery/helpers.d.ts +33 -0
  8. package/dist/types/exec/bash-executor.d.ts +17 -1
  9. package/dist/types/exec/bash-shell-guardian.d.ts +15 -0
  10. package/dist/types/exec/bash-shell-runtime-entry.d.ts +2 -0
  11. package/dist/types/exec/bash-shell-signals.d.ts +2 -0
  12. package/dist/types/exec/bash-shell-supervisor-entry.d.ts +1 -0
  13. package/dist/types/exec/bash-shell-supervisor.d.ts +10 -0
  14. package/dist/types/exec/bash-shell-worker-entry.d.ts +2 -0
  15. package/dist/types/exec/bash-shell-worker-protocol.d.ts +63 -0
  16. package/dist/types/exec/bash-shell-worker.d.ts +1 -0
  17. package/dist/types/exec/isolated-shell.d.ts +19 -0
  18. package/dist/types/extensibility/extensions/function-hooks-internal.d.ts +9 -0
  19. package/dist/types/extensibility/extensions/function-hooks.d.ts +169 -0
  20. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  21. package/dist/types/extensibility/extensions/runner.d.ts +46 -6
  22. package/dist/types/extensibility/extensions/types.d.ts +16 -0
  23. package/dist/types/extensibility/extensions/wrapper.d.ts +3 -1
  24. package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +35 -2
  25. package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
  26. package/dist/types/extensibility/hooks/types.d.ts +4 -0
  27. package/dist/types/extensibility/runtime-skill-discovery.d.ts +4 -1
  28. package/dist/types/extensibility/skill-management.d.ts +9 -4
  29. package/dist/types/extensibility/skills.d.ts +2 -0
  30. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +4 -1
  31. package/dist/types/gjc-runtime/linux-proc.d.ts +2 -0
  32. package/dist/types/gjc-runtime/session-state-lock.d.ts +12 -0
  33. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +41 -3
  34. package/dist/types/gjc-runtime/ultragoal-succession.d.ts +251 -0
  35. package/dist/types/modes/components/bash-execution.d.ts +1 -0
  36. package/dist/types/modes/components/execution-shared.d.ts +1 -0
  37. package/dist/types/modes/interactive-mode.d.ts +2 -0
  38. package/dist/types/modes/types.d.ts +6 -0
  39. package/dist/types/sdk/broker/ensure.d.ts +32 -0
  40. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +0 -1
  41. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  42. package/dist/types/sdk/broker/startup-failure.d.ts +4 -1
  43. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -0
  44. package/dist/types/sdk/bus/chat-effect-journal.d.ts +17 -2
  45. package/dist/types/sdk/bus/conversation-store.d.ts +11 -0
  46. package/dist/types/sdk/bus/lifecycle-commands.d.ts +8 -8
  47. package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
  48. package/dist/types/sdk/bus/topic-registry.d.ts +8 -0
  49. package/dist/types/sdk/client/client.d.ts +6 -0
  50. package/dist/types/sdk/host/host.d.ts +3 -1
  51. package/dist/types/sdk/host/session-runtime.d.ts +4 -2
  52. package/dist/types/sdk/lifecycle/service.d.ts +1 -0
  53. package/dist/types/sdk/router/session-router.d.ts +10 -2
  54. package/dist/types/sdk/session.d.ts +5 -1
  55. package/dist/types/session/agent-session.d.ts +2 -0
  56. package/dist/types/session/fallback-chain-controller.d.ts +15 -0
  57. package/dist/types/session/internal/managed-session-storage.d.ts +7 -0
  58. package/dist/types/session/messages.d.ts +1 -0
  59. package/dist/types/session/session-manager.d.ts +3 -1
  60. package/dist/types/setup/paseo/announce.d.ts +4 -3
  61. package/dist/types/skill-state/canonical-skills.d.ts +4 -0
  62. package/dist/types/system-prompt.d.ts +8 -0
  63. package/dist/types/task/executor.d.ts +3 -0
  64. package/dist/types/tools/bash.d.ts +1 -0
  65. package/dist/types/tools/github-cache.d.ts +2 -1
  66. package/dist/types/tools/index.d.ts +2 -0
  67. package/package.json +9 -7
  68. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  69. package/src/capability/index.ts +14 -2
  70. package/src/capability/types.ts +4 -0
  71. package/src/cli/customize-doctor.ts +155 -12
  72. package/src/cli.ts +22 -0
  73. package/src/commands/sdk.ts +74 -31
  74. package/src/config/file-lock-gc.ts +40 -2
  75. package/src/config/file-lock.ts +198 -14
  76. package/src/config/model-preset-registry.ts +115 -16
  77. package/src/config/model-registry.ts +16 -12
  78. package/src/coordinator-mcp/question-state.ts +33 -2
  79. package/src/coordinator-mcp/server.ts +477 -101
  80. package/src/crash/sanitize.ts +1 -1
  81. package/src/defaults/gjc/skills/ultragoal/SKILL.md +22 -0
  82. package/src/discovery/builtin.ts +50 -62
  83. package/src/discovery/helpers.ts +297 -32
  84. package/src/exec/bash-executor.ts +330 -50
  85. package/src/exec/bash-shell-guardian.ts +388 -0
  86. package/src/exec/bash-shell-runtime-entry.ts +5 -0
  87. package/src/exec/bash-shell-signals.ts +29 -0
  88. package/src/exec/bash-shell-supervisor-entry.ts +3 -0
  89. package/src/exec/bash-shell-supervisor.ts +359 -0
  90. package/src/exec/bash-shell-worker-entry.ts +5 -0
  91. package/src/exec/bash-shell-worker-protocol.ts +36 -0
  92. package/src/exec/bash-shell-worker.ts +116 -0
  93. package/src/exec/isolated-shell.ts +393 -0
  94. package/src/extensibility/extensions/function-hooks-internal.ts +73 -0
  95. package/src/extensibility/extensions/function-hooks.ts +959 -0
  96. package/src/extensibility/extensions/index.ts +22 -0
  97. package/src/extensibility/extensions/loader.ts +53 -2
  98. package/src/extensibility/extensions/runner.ts +895 -264
  99. package/src/extensibility/extensions/types.ts +42 -0
  100. package/src/extensibility/extensions/wrapper.ts +56 -15
  101. package/src/extensibility/gjc-plugins/compiler.ts +20 -2
  102. package/src/extensibility/gjc-plugins/constrained-hooks.ts +321 -23
  103. package/src/extensibility/gjc-plugins/registry.ts +113 -1
  104. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +20 -2
  105. package/src/extensibility/gjc-plugins/schema.ts +121 -4
  106. package/src/extensibility/gjc-plugins/types.ts +25 -0
  107. package/src/extensibility/hooks/loader.ts +133 -16
  108. package/src/extensibility/hooks/types.ts +28 -0
  109. package/src/extensibility/runtime-skill-discovery.ts +80 -47
  110. package/src/extensibility/skill-management.ts +200 -52
  111. package/src/extensibility/skills.ts +29 -9
  112. package/src/gjc-runtime/empty-delete-gc.ts +94 -4
  113. package/src/gjc-runtime/gc-runtime.ts +29 -0
  114. package/src/gjc-runtime/linux-proc.ts +14 -0
  115. package/src/gjc-runtime/session-state-lock.ts +51 -8
  116. package/src/gjc-runtime/session-state-sidecar.ts +27 -11
  117. package/src/gjc-runtime/state-writer.ts +30 -29
  118. package/src/gjc-runtime/ultragoal-runtime.ts +189 -47
  119. package/src/gjc-runtime/ultragoal-succession.ts +1667 -0
  120. package/src/gjc-runtime/workflow-manifest.generated.json +52 -1
  121. package/src/gjc-runtime/workflow-manifest.ts +16 -0
  122. package/src/harness-control-plane/session-lease.ts +2 -2
  123. package/src/internal-urls/docs-index.generated.ts +6 -6
  124. package/src/lsp/config.ts +10 -3
  125. package/src/lsp/index.ts +1 -1
  126. package/src/lsp/path-trust.ts +69 -14
  127. package/src/main.ts +6 -1
  128. package/src/modes/acp/acp-agent.ts +16 -3
  129. package/src/modes/components/bash-execution.ts +4 -1
  130. package/src/modes/components/execution-shared.ts +4 -1
  131. package/src/modes/components/session-observer-overlay.ts +1 -1
  132. package/src/modes/components/tool-status-header.ts +3 -1
  133. package/src/modes/controllers/command-controller.ts +1 -0
  134. package/src/modes/controllers/event-controller.ts +3 -2
  135. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  136. package/src/modes/controllers/input-controller.ts +6 -3
  137. package/src/modes/interactive-mode.ts +6 -5
  138. package/src/modes/print-mode.ts +5 -1
  139. package/src/modes/prompt-action-autocomplete.ts +54 -27
  140. package/src/modes/runtime-init.ts +2 -0
  141. package/src/modes/types.ts +16 -3
  142. package/src/modes/utils/ui-helpers.ts +1 -0
  143. package/src/prompts/tools/subagent.md +1 -0
  144. package/src/runtime-mcp/manager.ts +20 -2
  145. package/src/runtime-mcp/pool.test.ts +20 -11
  146. package/src/sdk/broker/broker.ts +119 -28
  147. package/src/sdk/broker/ensure.ts +248 -41
  148. package/src/sdk/broker/lifecycle-ledger.ts +7 -17
  149. package/src/sdk/broker/lifecycle.ts +56 -6
  150. package/src/sdk/broker/startup-failure.ts +15 -6
  151. package/src/sdk/bus/chat-daemon-control.ts +3 -1
  152. package/src/sdk/bus/chat-daemon-runtime.ts +35 -5
  153. package/src/sdk/bus/chat-effect-journal.ts +132 -7
  154. package/src/sdk/bus/conversation-store.ts +29 -0
  155. package/src/sdk/bus/lifecycle-commands.ts +17 -11
  156. package/src/sdk/bus/slack-conversation.ts +1 -0
  157. package/src/sdk/bus/slack-daemon.ts +387 -48
  158. package/src/sdk/bus/telegram-daemon.ts +16 -2
  159. package/src/sdk/bus/topic-registry.ts +19 -0
  160. package/src/sdk/cli/master-cli.ts +1 -1
  161. package/src/sdk/cli/session-cli.ts +2 -2
  162. package/src/sdk/client/client.ts +66 -17
  163. package/src/sdk/host/host.ts +29 -9
  164. package/src/sdk/host/query/revision-store.ts +19 -13
  165. package/src/sdk/host/reverse-leases.ts +7 -4
  166. package/src/sdk/host/session-runtime.test.ts +50 -8
  167. package/src/sdk/host/session-runtime.ts +158 -51
  168. package/src/sdk/lifecycle/service.ts +4 -0
  169. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  170. package/src/sdk/router/session-router.ts +89 -38
  171. package/src/sdk/session.ts +155 -26
  172. package/src/sdk/transport/relay.ts +35 -13
  173. package/src/session/agent-session.ts +117 -52
  174. package/src/session/fallback-chain-controller.ts +38 -0
  175. package/src/session/internal/managed-session-storage.ts +19 -5
  176. package/src/session/messages.ts +5 -0
  177. package/src/session/session-manager.ts +29 -5
  178. package/src/session-import/redact.ts +5 -1
  179. package/src/setup/paseo/announce.ts +49 -43
  180. package/src/setup/paseo/setup-deps.ts +1 -1
  181. package/src/skill-state/canonical-skills.ts +12 -0
  182. package/src/skills/skills.test.ts +41 -21
  183. package/src/system-prompt.ts +25 -3
  184. package/src/task/executor.ts +5 -0
  185. package/src/task/index.ts +4 -0
  186. package/src/tools/bash.ts +28 -11
  187. package/src/tools/gh.ts +26 -3
  188. package/src/tools/github-cache.ts +68 -16
  189. package/src/tools/index.ts +2 -0
  190. package/src/tools/job.ts +13 -2
  191. package/src/tools/read.ts +14 -2
  192. package/src/tools/render-utils.ts +1 -3
  193. package/src/tools/resource-gc.ts +28 -8
  194. package/src/tools/skill-discovery.ts +5 -0
  195. package/src/tools/skill.ts +15 -0
  196. package/src/tools/subagent.ts +3 -2
  197. package/src/tools/tool-catalog.generated.ts +2 -2
  198. package/src/tools/ultragoal-ask-guard.ts +2 -2
@@ -1,6 +1,11 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
- import { getTrustedHomeDir } from "@gajae-code/utils";
3
+ import {
4
+ getAgentDir,
5
+ getAgentProfileAuthority,
6
+ getTrustedHomeDir,
7
+ normalizePathForComparison,
8
+ } from "@gajae-code/utils";
4
9
  import { findRepoRoot } from "../capability/fs";
5
10
  import type { Skill as CapabilitySkill } from "../capability/skill";
6
11
  import type { SkillsSettings } from "../config/settings-schema";
@@ -8,8 +13,17 @@ import { resolveSkillScopeTrust } from "../config/skill-settings-defaults";
8
13
  import { scanClaudeProjectSkills, scanClaudeUserSkills } from "../discovery/claude";
9
14
  import { loadMarketplaceSkills } from "../discovery/claude-plugins";
10
15
  import { scanCodexProjectSkills, scanCodexUserSkills } from "../discovery/codex";
11
- import { compareSkillOrder, SOURCE_PATHS, scanSkillsFromDir } from "../discovery/helpers";
12
- import { CANONICAL_GJC_WORKFLOW_SKILLS } from "../skill-state/canonical-skills";
16
+ import {
17
+ compareSkillOrder,
18
+ getAncestorDirs,
19
+ getUserSkillScanDirs,
20
+ SOURCE_PATHS,
21
+ scanSkillsFromDir,
22
+ } from "../discovery/helpers";
23
+ import {
24
+ getSkillFilesystemIdentity,
25
+ isCanonicalGjcWorkflowSkillFilesystemAlias,
26
+ } from "../skill-state/canonical-skills";
13
27
  import { expandTilde } from "../tools/path-utils";
14
28
  import { loadSkills, type Skill } from "./skills";
15
29
 
@@ -41,6 +55,9 @@ export interface RuntimeSkillDiscoveryResult {
41
55
  export interface DiscoverRuntimeSkillsOptions {
42
56
  cwd: string;
43
57
  home?: string;
58
+ agentDir?: string;
59
+ /** Resolver-owned profile classification; unlike path comparison, this survives HOME refreshes. */
60
+ profileAuthority?: "default" | "custom";
44
61
  query?: string;
45
62
  limit?: number;
46
63
  source?: RuntimeSkillDiscoverySource | "all";
@@ -54,8 +71,6 @@ function getRuntimeHome(): string {
54
71
  const DEFAULT_LIMIT = 20;
55
72
  const MAX_LIMIT = 50;
56
73
  const MAX_DIAGNOSTICS = 10;
57
- const BUILT_IN_SKILL_NAMES = new Set<string>(CANONICAL_GJC_WORKFLOW_SKILLS);
58
-
59
74
  function normalizeLimit(limit: number | undefined): number {
60
75
  if (limit === undefined || !Number.isFinite(limit)) return DEFAULT_LIMIT;
61
76
  return Math.max(1, Math.min(MAX_LIMIT, Math.trunc(limit)));
@@ -82,41 +97,19 @@ async function getProjectSkillDirs(
82
97
  ): Promise<{ scans: ProjectScanDir[]; repoRoot: string | null }> {
83
98
  const scans: ProjectScanDir[] = [];
84
99
  const repoRoot = await findRepoRoot(cwd);
85
- const walkDirs = ancestorDirs(cwd, path.resolve(repoRoot ?? cwd), home);
86
- for (const dir of walkDirs) {
100
+ const walkDirs = getAncestorDirs(cwd, path.resolve(repoRoot ?? cwd), home);
101
+ for (const { dir } of walkDirs) {
87
102
  scans.push({ dir: path.join(dir, ".gjc", "skills"), label: "project .gjc/skills" });
88
103
  }
89
104
  return { scans, repoRoot };
90
105
  }
91
106
 
92
- /** Ancestor directories from `cwd` (inclusive) up to `stop` (inclusive), excluding `home`. */
93
- function ancestorDirs(cwd: string, stop: string, home: string): string[] {
94
- const dirs: string[] = [];
95
- let current = path.resolve(cwd);
96
- const resolvedStop = path.resolve(stop);
97
- const resolvedHome = path.resolve(home);
98
- while (true) {
99
- if (current !== resolvedHome) {
100
- dirs.push(current);
101
- }
102
- if (current === resolvedStop) break;
103
- const parent = path.dirname(current);
104
- if (parent === current) break;
105
- current = parent;
106
- }
107
- return dirs;
107
+ function getUserSkillDirs(home: string, agentDir = getAgentDir(), profileAuthority?: "default" | "custom"): string[] {
108
+ return getUserSkillScanDirs(home, agentDir, profileAuthority);
108
109
  }
109
110
 
110
- function getUserSkillDirs(home: string): string[] {
111
- const canonicalUserDir = SOURCE_PATHS.native.userAgent;
112
- const configuredLegacyDir = SOURCE_PATHS.native.userBase;
113
- return [
114
- ...new Set([
115
- path.join(home, canonicalUserDir, "skills"),
116
- path.join(home, configuredLegacyDir, "skills"),
117
- path.join(home, ".gjc", "skills"),
118
- ]),
119
- ];
111
+ function resolveRuntimeAgentDir(home: string, agentDir: string | undefined, homeWasInjected: boolean): string {
112
+ return agentDir ?? (homeWasInjected ? path.join(home, SOURCE_PATHS.native.userAgent) : getAgentDir());
120
113
  }
121
114
 
122
115
  /**
@@ -197,7 +190,7 @@ interface ScanJobResult {
197
190
  }
198
191
 
199
192
  function isAllowedByPolicy(skill: CapabilitySkill, policy: SkillsSettings | undefined, diagnostics: string[]): boolean {
200
- if (BUILT_IN_SKILL_NAMES.has(skill.name)) {
193
+ if (isCanonicalGjcWorkflowSkillFilesystemAlias(skill.name)) {
201
194
  pushDiagnostic(
202
195
  diagnostics,
203
196
  `skill "${skill.name}" is a bundled GJC workflow skill and always resolves to the bundled definition; the filesystem copy at ${skill.path} is shadowed`,
@@ -270,9 +263,11 @@ async function scanProjectOrUserDir(
270
263
  label: string,
271
264
  source: RuntimeSkillDiscoverySource,
272
265
  providerPriority: number,
266
+ authorityRoot?: string,
273
267
  ): Promise<ScanJobResult> {
274
268
  const result = await scanSkillsFromDir(ctx, {
275
269
  dir,
270
+ authorityRoot,
276
271
  providerId: "runtime",
277
272
  level,
278
273
  requireDescription: true,
@@ -287,6 +282,7 @@ async function scanProjectOrUserDir(
287
282
  interface ConventionImportScan {
288
283
  host: "Claude Code" | "Codex";
289
284
  dir: string;
285
+ scope: RuntimeSkillDiscoverySource;
290
286
  skills: CapabilitySkill[];
291
287
  }
292
288
 
@@ -309,20 +305,20 @@ async function collectConventionImportCandidates(
309
305
  if ((source === "all" || source === "project") && sourceEnabled("project", policy)) {
310
306
  jobs.push(
311
307
  scanClaudeProjectSkills(ctx).then(result => {
312
- scans.push({ host: "Claude Code", dir: ".claude/skills", skills: result.items });
308
+ scans.push({ host: "Claude Code", dir: ".claude/skills", scope: "project", skills: result.items });
313
309
  }),
314
310
  scanCodexProjectSkills(ctx).then(result => {
315
- scans.push({ host: "Codex", dir: ".codex/skills", skills: result.items });
311
+ scans.push({ host: "Codex", dir: ".codex/skills", scope: "project", skills: result.items });
316
312
  }),
317
313
  );
318
314
  }
319
315
  if ((source === "all" || source === "user") && sourceEnabled("user", policy)) {
320
316
  jobs.push(
321
317
  scanClaudeUserSkills(ctx).then(result => {
322
- scans.push({ host: "Claude Code", dir: "~/.claude/skills", skills: result.items });
318
+ scans.push({ host: "Claude Code", dir: "~/.claude/skills", scope: "user", skills: result.items });
323
319
  }),
324
320
  scanCodexUserSkills(ctx).then(result => {
325
- scans.push({ host: "Codex", dir: "~/.codex/skills", skills: result.items });
321
+ scans.push({ host: "Codex", dir: "~/.codex/skills", scope: "user", skills: result.items });
326
322
  }),
327
323
  );
328
324
  }
@@ -336,9 +332,15 @@ function reportConventionImportCandidates(
336
332
  seenNames: Set<string>,
337
333
  diagnostics: string[],
338
334
  ): void {
339
- for (const scan of scans.sort((a, b) => a.host.localeCompare(b.host) || a.dir.localeCompare(b.dir))) {
335
+ // Project scope is reported first: the bounded diagnostic budget must not be
336
+ // spent on a populated user-home convention directory (hundreds of skills is
337
+ // ordinary) before a project convention skill is ever named.
338
+ const scopeRank = (scan: ConventionImportScan) => (scan.scope === "project" ? 0 : 1);
339
+ for (const scan of scans.sort(
340
+ (a, b) => scopeRank(a) - scopeRank(b) || a.host.localeCompare(b.host) || a.dir.localeCompare(b.dir),
341
+ )) {
340
342
  for (const skill of scan.skills) {
341
- const normalizedName = skill.name.toLowerCase();
343
+ const normalizedName = getSkillFilesystemIdentity(skill.name);
342
344
  if (seenNames.has(normalizedName)) continue;
343
345
  seenNames.add(normalizedName);
344
346
  pushDiagnostic(
@@ -380,10 +382,20 @@ export function describeDisabledSkillScopes(
380
382
  export async function discoverRuntimeSkills(
381
383
  options: DiscoverRuntimeSkillsOptions,
382
384
  ): Promise<RuntimeSkillDiscoveryResult> {
385
+ const hasExplicitHome = options.home !== undefined;
383
386
  const home = options.home ?? getRuntimeHome();
384
387
  const source = options.source ?? "all";
385
388
  const policy = options.policy;
386
389
  const diagnostics: string[] = [];
390
+ const agentDir = resolveRuntimeAgentDir(home, options.agentDir, hasExplicitHome);
391
+ const profileAuthority =
392
+ options.profileAuthority ??
393
+ (!hasExplicitHome
394
+ ? options.agentDir !== undefined &&
395
+ normalizePathForComparison(options.agentDir) !== normalizePathForComparison(getAgentDir())
396
+ ? "custom"
397
+ : getAgentProfileAuthority()
398
+ : undefined);
387
399
  const scanJobs: Array<Promise<ScanJobResult>> = [];
388
400
  const projectDirs = await getProjectSkillDirs(options.cwd, home);
389
401
  const projectContext = { cwd: options.cwd, home, repoRoot: projectDirs.repoRoot };
@@ -393,9 +405,17 @@ export async function discoverRuntimeSkills(
393
405
  }
394
406
  }
395
407
  if ((source === "all" || source === "user") && sourceEnabled("user", policy)) {
396
- for (const dir of getUserSkillDirs(home)) {
408
+ for (const dir of getUserSkillDirs(home, agentDir, profileAuthority)) {
397
409
  scanJobs.push(
398
- scanProjectOrUserDir({ cwd: options.cwd, home, repoRoot: home }, dir, "user", `user ${dir}`, "user", 100),
410
+ scanProjectOrUserDir(
411
+ { cwd: options.cwd, home, repoRoot: home },
412
+ dir,
413
+ "user",
414
+ `user ${dir}`,
415
+ "user",
416
+ 100,
417
+ path.resolve(dir) === path.resolve(agentDir, "skills") ? agentDir : undefined,
418
+ ),
399
419
  );
400
420
  }
401
421
  }
@@ -461,7 +481,7 @@ export async function discoverRuntimeSkills(
461
481
  for (const item of orderedItems) {
462
482
  if (!isAllowedByPolicy(item.skill, policy, diagnostics)) continue;
463
483
  const realPath = await realPathOrSelf(item.skill.path);
464
- const normalizedName = item.skill.name.toLowerCase();
484
+ const normalizedName = getSkillFilesystemIdentity(item.skill.name);
465
485
  if (seenPaths.has(realPath) || seenNames.has(normalizedName)) {
466
486
  pushDiagnostic(
467
487
  diagnostics,
@@ -497,16 +517,29 @@ export async function findRuntimeSkillByName(
497
517
  cwd: string,
498
518
  name: string,
499
519
  policy?: SkillsSettings,
500
- home = getRuntimeHome(),
520
+ home?: string,
521
+ agentDir?: string,
522
+ profileAuthority?: "default" | "custom",
501
523
  ): Promise<Skill | undefined> {
502
524
  const normalized = name.trim();
503
525
  if (!normalized) return undefined;
504
- if (BUILT_IN_SKILL_NAMES.has(normalized)) return undefined;
526
+ if (isCanonicalGjcWorkflowSkillFilesystemAlias(normalized)) return undefined;
527
+ const hasExplicitHome = home !== undefined;
528
+ const resolvedHome = home ?? getRuntimeHome();
529
+ const resolvedAgentDir = resolveRuntimeAgentDir(resolvedHome, agentDir, hasExplicitHome);
530
+ const resolvedProfileAuthority =
531
+ profileAuthority ??
532
+ (!hasExplicitHome
533
+ ? agentDir !== undefined && normalizePathForComparison(agentDir) !== normalizePathForComparison(getAgentDir())
534
+ ? "custom"
535
+ : getAgentProfileAuthority()
536
+ : undefined);
505
537
  const loaded = await loadSkills({
506
538
  ...policy,
507
539
  cwd,
508
- home,
509
- agentDir: path.join(home, SOURCE_PATHS.native.userAgent),
540
+ home: resolvedHome,
541
+ agentDir: resolvedAgentDir,
542
+ profileAuthority: resolvedProfileAuthority,
510
543
  });
511
544
  return loaded.skills.find(skill => skill.name === normalized);
512
545
  }
@@ -12,16 +12,32 @@
12
12
  * writing a skill file into a scope, and per-skill enable/disable. The import
13
13
  * preview/apply transaction and the UI itself are owned by #4291.
14
14
  */
15
+ import * as nodeFs from "node:fs";
15
16
  import * as fs from "node:fs/promises";
16
17
  import * as path from "node:path";
17
- import { getTrustedHomeDir, parseFrontmatter } from "@gajae-code/utils";
18
+ import {
19
+ getAgentDir,
20
+ getAgentProfileAuthority,
21
+ getTrustedHomeDir,
22
+ normalizePathForComparison,
23
+ parseFrontmatter,
24
+ } from "@gajae-code/utils";
18
25
  import { findRepoRoot } from "../capability/fs";
19
26
  import type { Skill as CapabilitySkill } from "../capability/skill";
20
27
  import { resolveSkillScopeTrust } from "../config/skill-settings-defaults";
21
28
  import { scanClaudeProjectSkills, scanClaudeUserSkills } from "../discovery/claude";
22
29
  import { scanCodexProjectSkills, scanCodexUserSkills } from "../discovery/codex";
23
- import { compareSkillOrder, SOURCE_PATHS, scanSkillsFromDir } from "../discovery/helpers";
24
- import { CANONICAL_GJC_WORKFLOW_SKILLS } from "../skill-state/canonical-skills";
30
+ import {
31
+ compareSkillOrder,
32
+ getAncestorDirs,
33
+ getUserSkillScanDirs,
34
+ resolveUserAgentDir,
35
+ scanSkillsFromDir,
36
+ } from "../discovery/helpers";
37
+ import {
38
+ CANONICAL_GJC_WORKFLOW_SKILLS,
39
+ isCanonicalGjcWorkflowSkillFilesystemAlias,
40
+ } from "../skill-state/canonical-skills";
25
41
  export type SkillScope = "project" | "user";
26
42
  export type ConventionSkillHost = "claude" | "codex";
27
43
 
@@ -61,6 +77,7 @@ export interface ConventionSkillImportSource {
61
77
  export interface WriteNativeSkillInput {
62
78
  cwd: string;
63
79
  home?: string;
80
+ agentDir?: string;
64
81
  scope: SkillScope;
65
82
  name: string;
66
83
  content: string;
@@ -93,28 +110,127 @@ export class SkillFrontmatterError extends Error {
93
110
  }
94
111
  }
95
112
 
96
- const BUILT_IN_SKILL_NAMES = new Set<string>(CANONICAL_GJC_WORKFLOW_SKILLS);
113
+ function isProtectedSkillName(name: string): boolean {
114
+ return isCanonicalGjcWorkflowSkillFilesystemAlias(name);
115
+ }
97
116
 
98
- function getRuntimeHome(): string {
99
- return getTrustedHomeDir();
117
+ function assertSafeSkillName(name: string): void {
118
+ if (
119
+ name === "." ||
120
+ name === ".." ||
121
+ /[. ]$/u.test(name) ||
122
+ name.includes("/") ||
123
+ name.includes("\\") ||
124
+ name.includes("\0") ||
125
+ path.basename(name) !== name
126
+ ) {
127
+ throw new SkillFrontmatterError(`skill name must be a single path segment: ${name}`);
128
+ }
129
+ }
130
+
131
+ function isPathWithin(root: string, candidate: string): boolean {
132
+ const relative = path.relative(root, candidate);
133
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
134
+ }
135
+
136
+ async function ensureSafeDescendantDirectory(root: string, segments: readonly string[]): Promise<string> {
137
+ try {
138
+ const rootStat = await fs.lstat(root);
139
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
140
+ throw new SkillFrontmatterError(`skill authority root is not a safe directory: ${root}`);
141
+ }
142
+ } catch (error) {
143
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
144
+ await fs.mkdir(root, { recursive: true });
145
+ const rootStat = await fs.lstat(root);
146
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
147
+ throw new SkillFrontmatterError(`skill authority root is not a safe directory: ${root}`);
148
+ }
149
+ }
150
+ const realRoot = await fs.realpath(root);
151
+ let current = root;
152
+ for (const segment of segments) {
153
+ current = path.join(current, segment);
154
+ try {
155
+ const stat = await fs.lstat(current);
156
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
157
+ throw new SkillFrontmatterError(`skill destination is not a safe directory: ${current}`);
158
+ }
159
+ } catch (error) {
160
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
161
+ try {
162
+ await fs.mkdir(current);
163
+ } catch (mkdirError) {
164
+ if ((mkdirError as NodeJS.ErrnoException).code !== "EEXIST") throw mkdirError;
165
+ const stat = await fs.lstat(current);
166
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
167
+ throw new SkillFrontmatterError(`skill destination is not a safe directory: ${current}`);
168
+ }
169
+ }
170
+ }
171
+ const realCurrent = await fs.realpath(current);
172
+ if (!isPathWithin(realRoot, realCurrent)) {
173
+ throw new SkillFrontmatterError(`skill destination escapes its scope root: ${current}`);
174
+ }
175
+ }
176
+ return current;
100
177
  }
101
178
 
102
- /** Ancestor directories from `cwd` (inclusive) up to `stop` (inclusive), excluding `home`. */
103
- function ancestorDirs(cwd: string, stop: string, home: string): string[] {
104
- const dirs: string[] = [];
105
- let current = path.resolve(cwd);
106
- const resolvedStop = path.resolve(stop);
107
- const resolvedHome = path.resolve(home);
108
- while (true) {
109
- if (current !== resolvedHome) {
110
- dirs.push(current);
179
+ async function writeSkillFileSafely(skillDir: string, scopeRoot: string, content: string): Promise<string> {
180
+ const [canonicalSkillDir, canonicalScopeRoot] = await Promise.all([fs.realpath(skillDir), fs.realpath(scopeRoot)]);
181
+ if (!isPathWithin(canonicalScopeRoot, canonicalSkillDir)) {
182
+ throw new SkillFrontmatterError(`skill destination escapes its scope root: ${skillDir}`);
183
+ }
184
+ const filePath = path.join(skillDir, "SKILL.md");
185
+ let destinationExists = false;
186
+ try {
187
+ const destination = await fs.lstat(filePath);
188
+ if (destination.isSymbolicLink() || !destination.isFile()) {
189
+ throw new SkillFrontmatterError(`skill destination is not a safe file: ${filePath}`);
111
190
  }
112
- if (current === resolvedStop) break;
113
- const parent = path.dirname(current);
114
- if (parent === current) break;
115
- current = parent;
191
+ destinationExists = true;
192
+ } catch (error) {
193
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
194
+ }
195
+ const noFollowFlags =
196
+ process.platform === "win32" ? 0 : (nodeFs.constants.O_NOFOLLOW ?? 0) | (nodeFs.constants.O_NONBLOCK ?? 0);
197
+ const flags = destinationExists
198
+ ? nodeFs.constants.O_WRONLY | noFollowFlags
199
+ : nodeFs.constants.O_WRONLY | nodeFs.constants.O_CREAT | nodeFs.constants.O_EXCL | noFollowFlags;
200
+ let handle: fs.FileHandle;
201
+ try {
202
+ handle = await fs.open(filePath, flags, 0o600);
203
+ } catch (error) {
204
+ throw new SkillFrontmatterError(`skill destination is not a safe file: ${filePath} (${String(error)})`);
116
205
  }
117
- return dirs;
206
+ try {
207
+ const [opened, observed, currentSkillDir] = await Promise.all([
208
+ handle.stat({ bigint: true }),
209
+ fs.lstat(filePath, { bigint: true }),
210
+ fs.realpath(skillDir),
211
+ ]);
212
+ if (
213
+ !opened.isFile() ||
214
+ opened.nlink !== 1n ||
215
+ observed.isSymbolicLink() ||
216
+ !observed.isFile() ||
217
+ opened.dev !== observed.dev ||
218
+ opened.ino !== observed.ino ||
219
+ normalizePathForComparison(currentSkillDir) !== normalizePathForComparison(canonicalSkillDir) ||
220
+ !isPathWithin(canonicalScopeRoot, currentSkillDir)
221
+ ) {
222
+ throw new SkillFrontmatterError(`skill destination changed during write: ${filePath}`);
223
+ }
224
+ await handle.truncate(0);
225
+ await handle.writeFile(content);
226
+ } finally {
227
+ await handle.close();
228
+ }
229
+ return filePath;
230
+ }
231
+
232
+ function getRuntimeHome(): string {
233
+ return getTrustedHomeDir();
118
234
  }
119
235
 
120
236
  /**
@@ -126,34 +242,42 @@ export async function getProjectSkillDirs(
126
242
  home: string,
127
243
  ): Promise<{ dirs: string[]; repoRoot: string | null }> {
128
244
  const repoRoot = await findRepoRoot(cwd);
129
- const walkDirs = ancestorDirs(cwd, path.resolve(repoRoot ?? cwd), home);
130
- return { dirs: walkDirs.map(dir => path.join(dir, ".gjc", "skills")), repoRoot };
245
+ const walkDirs = getAncestorDirs(cwd, path.resolve(repoRoot ?? cwd), home);
246
+ return { dirs: walkDirs.map(({ dir }) => path.join(dir, ".gjc", "skills")), repoRoot };
131
247
  }
132
248
 
133
249
  /** Canonical user skill directories in precedence order (same resolution as runtime discovery). */
134
- export function getUserSkillDirs(home: string): string[] {
135
- return [
136
- ...new Set([
137
- path.join(home, SOURCE_PATHS.native.userAgent, "skills"),
138
- path.join(home, SOURCE_PATHS.native.userBase, "skills"),
139
- path.join(home, ".gjc", "skills"),
140
- ]),
141
- ];
250
+ export function getUserSkillDirs(home: string, agentDir?: string, profileAuthority?: "default" | "custom"): string[] {
251
+ return getUserSkillScanDirs(home, agentDir, profileAuthority);
142
252
  }
143
253
 
144
254
  /**
145
255
  * The canonical directory a write targets for a scope: the repo root (or `cwd`)
146
- * `.gjc/skills` for project scope, the canonical `<config>/agent/skills` user
147
- * root for user scope (honoring `GJC_CONFIG_DIR` / `PI_CONFIG_DIR`).
256
+ * `.gjc/skills` for project scope, the agent directory's `skills` root for user
257
+ * scope — the same directory every reader scans first (`gjc config path` prints
258
+ * it; `--agent-dir` / `GJC_CODING_AGENT_DIR` / `setAgentDir()` move it).
148
259
  */
149
260
  export async function resolveNativeSkillScopeDir(
150
261
  cwd: string,
151
262
  scope: SkillScope,
152
- home = getRuntimeHome(),
263
+ _home?: string,
264
+ agentDir?: string,
153
265
  ): Promise<string> {
154
- if (scope === "user") return path.join(home, SOURCE_PATHS.native.userAgent, "skills");
266
+ const home = _home ?? getRuntimeHome();
267
+ if (scope === "user") {
268
+ const resolvedAgentDir = agentDir ?? (_home === undefined ? getAgentDir() : resolveUserAgentDir(home));
269
+ return path.join(path.resolve(resolvedAgentDir), "skills");
270
+ }
155
271
  const repoRoot = await findRepoRoot(cwd);
156
- return path.join(repoRoot ?? path.resolve(cwd), ".gjc", "skills");
272
+ const resolvedCwd = path.resolve(cwd);
273
+ const projectRoot = repoRoot ?? resolvedCwd;
274
+ if (normalizePathForComparison(projectRoot) === normalizePathForComparison(home)) {
275
+ if (normalizePathForComparison(resolvedCwd) === normalizePathForComparison(home)) {
276
+ throw new SkillFrontmatterError("project skill scope is unavailable at the user home boundary");
277
+ }
278
+ return path.join(resolvedCwd, ".gjc", "skills");
279
+ }
280
+ return path.join(projectRoot, ".gjc", "skills");
157
281
  }
158
282
 
159
283
  function matchesPattern(name: string, patterns: string[] | undefined): boolean {
@@ -174,14 +298,31 @@ function isDisabledByExtension(name: string, disabledExtensions: string[] | unde
174
298
  export async function listNativeSkillsForManagement(options: {
175
299
  cwd: string;
176
300
  home?: string;
301
+ agentDir?: string;
302
+ /** Resolver-owned profile classification; unlike path comparison, this survives HOME refreshes. */
303
+ profileAuthority?: "default" | "custom";
177
304
  policy?: SkillManagementPolicy;
178
305
  }): Promise<ManagedSkillRecord[]> {
306
+ const homeWasInjected = options.home !== undefined;
179
307
  const home = options.home ?? getRuntimeHome();
308
+ const agentDir = options.agentDir ?? (homeWasInjected ? resolveUserAgentDir(home) : getAgentDir());
309
+ const profileAuthority =
310
+ options.profileAuthority ??
311
+ (options.agentDir !== undefined
312
+ ? normalizePathForComparison(options.agentDir) ===
313
+ normalizePathForComparison(homeWasInjected ? resolveUserAgentDir(home) : getAgentDir())
314
+ ? homeWasInjected
315
+ ? "default"
316
+ : getAgentProfileAuthority()
317
+ : "custom"
318
+ : !homeWasInjected
319
+ ? getAgentProfileAuthority()
320
+ : undefined);
180
321
  const policy = options.policy;
181
322
  const projectTrusted = resolveSkillScopeTrust(policy ?? {}, "project");
182
323
  const userTrusted = resolveSkillScopeTrust(policy ?? {}, "user");
183
324
 
184
- const scanJobs: Array<Promise<{ dir: string; items: CapabilitySkill[] }>> = [];
325
+ const scanJobs: Array<Promise<{ dir: string; scope: SkillScope; items: CapabilitySkill[] }>> = [];
185
326
  const projectDirs = await getProjectSkillDirs(options.cwd, home);
186
327
  if (projectTrusted) {
187
328
  for (const dir of projectDirs.dirs) {
@@ -189,17 +330,23 @@ export async function listNativeSkillsForManagement(options: {
189
330
  scanSkillsFromDir(
190
331
  { cwd: options.cwd, home, repoRoot: projectDirs.repoRoot },
191
332
  { dir, providerId: "runtime", level: "project", requireDescription: true },
192
- ).then(result => ({ dir, items: result.items })),
333
+ ).then(result => ({ dir, scope: "project" as const, items: result.items })),
193
334
  );
194
335
  }
195
336
  }
196
337
  if (userTrusted) {
197
- for (const dir of getUserSkillDirs(home)) {
338
+ for (const dir of getUserSkillScanDirs(home, agentDir, profileAuthority)) {
198
339
  scanJobs.push(
199
340
  scanSkillsFromDir(
200
341
  { cwd: options.cwd, home, repoRoot: home },
201
- { dir, providerId: "runtime", level: "user", requireDescription: true },
202
- ).then(result => ({ dir, items: result.items })),
342
+ {
343
+ dir,
344
+ authorityRoot: path.resolve(dir) === path.resolve(agentDir, "skills") ? agentDir : undefined,
345
+ providerId: "runtime",
346
+ level: "user",
347
+ requireDescription: true,
348
+ },
349
+ ).then(result => ({ dir, scope: "user" as const, items: result.items })),
203
350
  );
204
351
  }
205
352
  }
@@ -208,8 +355,7 @@ export async function listNativeSkillsForManagement(options: {
208
355
  const seenNames = new Set<string>();
209
356
  const seenPaths = new Set<string>();
210
357
 
211
- for (const { dir, items } of await Promise.all(scanJobs)) {
212
- const scope: SkillScope = path.resolve(dir).startsWith(`${path.resolve(home)}${path.sep}`) ? "user" : "project";
358
+ for (const { dir, scope, items } of await Promise.all(scanJobs)) {
213
359
  const source = scope === "project" ? "project .gjc/skills" : `user ${dir}`;
214
360
  for (const skill of items) {
215
361
  const realPath = await safeRealpath(skill.path);
@@ -217,7 +363,7 @@ export async function listNativeSkillsForManagement(options: {
217
363
 
218
364
  let disabledReason: SkillDisabledReason | undefined;
219
365
  let enabled = true;
220
- if (BUILT_IN_SKILL_NAMES.has(skill.name)) {
366
+ if (isProtectedSkillName(skill.name)) {
221
367
  enabled = false;
222
368
  disabledReason = "protected";
223
369
  } else if (!(scope === "project" ? projectTrusted : userTrusted)) {
@@ -272,19 +418,21 @@ export async function writeNativeSkill(input: WriteNativeSkillInput): Promise<Wr
272
418
 
273
419
  const effectiveName =
274
420
  typeof frontmatter.name === "string" && frontmatter.name.trim() ? frontmatter.name.trim() : name;
275
- if (BUILT_IN_SKILL_NAMES.has(effectiveName)) throw new SkillNameProtectedError(effectiveName);
276
-
277
- const directory = await resolveNativeSkillScopeDir(input.cwd, input.scope, input.home ?? getRuntimeHome());
278
- const skillDir = path.join(directory, effectiveName);
279
- await fs.mkdir(skillDir, { recursive: true });
280
- const filePath = path.join(skillDir, "SKILL.md");
281
- await Bun.write(filePath, `${input.content.trimEnd()}\n`);
421
+ if (isProtectedSkillName(effectiveName)) throw new SkillNameProtectedError(effectiveName);
422
+ assertSafeSkillName(effectiveName);
423
+
424
+ const directory = await resolveNativeSkillScopeDir(input.cwd, input.scope, input.home, input.agentDir);
425
+ const scopeRoot = input.scope === "user" ? path.dirname(directory) : path.dirname(path.dirname(directory));
426
+ const relativeDirectory = path.relative(scopeRoot, directory);
427
+ const directorySegments = relativeDirectory.split(path.sep).filter(Boolean);
428
+ const skillDir = await ensureSafeDescendantDirectory(scopeRoot, [...directorySegments, effectiveName]);
429
+ const filePath = await writeSkillFileSafely(skillDir, scopeRoot, `${input.content.trimEnd()}\n`);
282
430
  return { name: effectiveName, scope: input.scope, directory, path: filePath };
283
431
  }
284
432
 
285
433
  /** Whether a skill is enabled under the current policy (disabledExtensions/ignored/scope trust). */
286
434
  export function isNativeSkillEnabled(name: string, policy: SkillManagementPolicy | undefined): boolean {
287
- if (BUILT_IN_SKILL_NAMES.has(name)) return true;
435
+ if (isProtectedSkillName(name)) return true;
288
436
  if (isDisabledByExtension(name, policy?.disabledExtensions)) return false;
289
437
  if (matchesPattern(name, policy?.ignoredSkills)) return false;
290
438
  if (policy?.includeSkills?.length && !matchesPattern(name, policy.includeSkills)) return false;
@@ -299,7 +447,7 @@ export function isNativeSkillEnabled(name: string, policy: SkillManagementPolicy
299
447
  export function setNativeSkillEnabled(name: string, enabled: boolean, disabledExtensions: string[]): string[] {
300
448
  const id = `skill:${name}`;
301
449
  const next = new Set(disabledExtensions);
302
- if (BUILT_IN_SKILL_NAMES.has(name)) return [...next];
450
+ if (isProtectedSkillName(name)) return [...next];
303
451
  if (enabled) {
304
452
  next.delete(id);
305
453
  } else {