Procházet zdrojové kódy

Merge pull request #2983 from Kilo-Org/project-tracking-3

Adds project usage tracking for Teams and Enterprise customers. Organization members can view and filter usage by project
John Fawcett před 4 měsíci
rodič
revize
c66df671cc

+ 13 - 0
.changeset/bumpy-symbols-peel.md

@@ -0,0 +1,13 @@
+---
+"kilo-code": patch
+---
+
+Adds project usage tracking for Teams and Enterprise customers. Organization members can view and filter usage by project. Project identifier is automatically inferred from `.git/config`. It can be overwritten by writing a `.kilocode/config.json` file with the following contents:
+
+```json
+{
+	"project": {
+		"id": "my-project-id"
+	}
+}
+```

+ 1 - 1
pnpm-lock.yaml

@@ -37377,7 +37377,7 @@ snapshots:
 
   [email protected]:
     dependencies:
-      ansi-styles: 6.2.1
+      ansi-styles: 6.2.3
       is-fullwidth-code-point: 4.0.0
 
   [email protected]:

+ 8 - 0
src/api/index.ts

@@ -74,6 +74,14 @@ export interface ApiHandlerCreateMessageMetadata {
 	 * @default true
 	 */
 	store?: boolean
+	// kilocode_change start
+	/**
+	 * KiloCode-specific: The project ID for the current workspace (derived from git origin remote).
+	 * Used by KiloCodeOpenrouterHandler for backend tracking. Ignored by other providers.
+	 * @kilocode-only
+	 */
+	projectId?: string
+	// kilocode_change end
 }
 
 export interface ApiHandler {

+ 199 - 0
src/api/providers/__tests__/kilocode-openrouter.spec.ts

@@ -0,0 +1,199 @@
+// kilocode_change - new file
+// npx vitest run src/api/providers/__tests__/kilocode-openrouter.spec.ts
+
+// Mock vscode first to avoid import errors
+vitest.mock("vscode", () => ({}))
+
+import { Anthropic } from "@anthropic-ai/sdk"
+import OpenAI from "openai"
+
+import { KilocodeOpenrouterHandler } from "../kilocode-openrouter"
+import { ApiHandlerOptions } from "../../../shared/api"
+import { X_KILOCODE_TASKID, X_KILOCODE_ORGANIZATIONID, X_KILOCODE_PROJECTID } from "../../../shared/kilocode/headers"
+
+// Mock dependencies
+vitest.mock("openai")
+vitest.mock("delay", () => ({ default: vitest.fn(() => Promise.resolve()) }))
+vitest.mock("../fetchers/modelCache", () => ({
+	getModels: vitest.fn().mockResolvedValue({
+		"anthropic/claude-sonnet-4": {
+			maxTokens: 8192,
+			contextWindow: 200000,
+			supportsImages: true,
+			supportsPromptCache: true,
+			inputPrice: 3,
+			outputPrice: 15,
+			cacheWritesPrice: 3.75,
+			cacheReadsPrice: 0.3,
+			description: "Claude 3.7 Sonnet",
+		},
+	}),
+}))
+vitest.mock("../fetchers/modelEndpointCache", () => ({
+	getModelEndpoints: vitest.fn().mockResolvedValue({}),
+}))
+vitest.mock("../kilocode/getKilocodeDefaultModel", () => ({
+	getKilocodeDefaultModel: vitest.fn().mockResolvedValue("anthropic/claude-sonnet-4"),
+}))
+
+describe("KilocodeOpenrouterHandler", () => {
+	const mockOptions: ApiHandlerOptions = {
+		kilocodeToken: "test-token",
+		kilocodeModel: "anthropic/claude-sonnet-4",
+	}
+
+	beforeEach(() => vitest.clearAllMocks())
+
+	describe("customRequestOptions", () => {
+		it("includes taskId header when provided in metadata", () => {
+			const handler = new KilocodeOpenrouterHandler(mockOptions)
+			const result = handler.customRequestOptions({ taskId: "test-task-id", mode: "code" })
+
+			expect(result).toEqual({
+				headers: {
+					[X_KILOCODE_TASKID]: "test-task-id",
+				},
+			})
+		})
+
+		it("includes organizationId header when configured", () => {
+			const handler = new KilocodeOpenrouterHandler({
+				...mockOptions,
+				kilocodeOrganizationId: "test-org-id",
+			})
+			const result = handler.customRequestOptions({ taskId: "test-task-id", mode: "code" })
+
+			expect(result).toEqual({
+				headers: {
+					[X_KILOCODE_TASKID]: "test-task-id",
+					[X_KILOCODE_ORGANIZATIONID]: "test-org-id",
+				},
+			})
+		})
+
+		it("includes projectId header when provided in metadata with organizationId", () => {
+			const handler = new KilocodeOpenrouterHandler({
+				...mockOptions,
+				kilocodeOrganizationId: "test-org-id",
+			})
+			const result = handler.customRequestOptions({
+				taskId: "test-task-id",
+				mode: "code",
+				projectId: "https://github.com/user/repo.git",
+			})
+
+			expect(result).toEqual({
+				headers: {
+					[X_KILOCODE_TASKID]: "test-task-id",
+					[X_KILOCODE_ORGANIZATIONID]: "test-org-id",
+					[X_KILOCODE_PROJECTID]: "https://github.com/user/repo.git",
+				},
+			})
+		})
+
+		it("includes all headers when all metadata is provided", () => {
+			const handler = new KilocodeOpenrouterHandler({
+				...mockOptions,
+				kilocodeOrganizationId: "test-org-id",
+			})
+			const result = handler.customRequestOptions({
+				taskId: "test-task-id",
+				mode: "code",
+				projectId: "https://github.com/user/repo.git",
+			})
+
+			expect(result).toEqual({
+				headers: {
+					[X_KILOCODE_TASKID]: "test-task-id",
+					[X_KILOCODE_PROJECTID]: "https://github.com/user/repo.git",
+					[X_KILOCODE_ORGANIZATIONID]: "test-org-id",
+				},
+			})
+		})
+
+		it("omits projectId header when not provided in metadata", () => {
+			const handler = new KilocodeOpenrouterHandler({
+				...mockOptions,
+				kilocodeOrganizationId: "test-org-id",
+			})
+			const result = handler.customRequestOptions({ taskId: "test-task-id", mode: "code" })
+
+			expect(result).toEqual({
+				headers: {
+					[X_KILOCODE_TASKID]: "test-task-id",
+					[X_KILOCODE_ORGANIZATIONID]: "test-org-id",
+				},
+			})
+			expect(result?.headers).not.toHaveProperty(X_KILOCODE_PROJECTID)
+		})
+
+		it("omits projectId header when no organizationId is configured", () => {
+			const handler = new KilocodeOpenrouterHandler(mockOptions)
+			const result = handler.customRequestOptions({
+				taskId: "test-task-id",
+				mode: "code",
+				projectId: "https://github.com/user/repo.git",
+			})
+
+			expect(result).toEqual({
+				headers: {
+					[X_KILOCODE_TASKID]: "test-task-id",
+				},
+			})
+			expect(result?.headers).not.toHaveProperty(X_KILOCODE_PROJECTID)
+		})
+
+		it("returns undefined when no headers are needed", () => {
+			const handler = new KilocodeOpenrouterHandler(mockOptions)
+			const result = handler.customRequestOptions()
+
+			expect(result).toBeUndefined()
+		})
+	})
+
+	describe("createMessage", () => {
+		it("passes custom headers to OpenAI client", async () => {
+			const handler = new KilocodeOpenrouterHandler({
+				...mockOptions,
+				kilocodeOrganizationId: "test-org-id",
+			})
+
+			const mockStream = {
+				async *[Symbol.asyncIterator]() {
+					yield {
+						id: "test-id",
+						choices: [{ delta: { content: "test response" } }],
+					}
+				},
+			}
+
+			const mockCreate = vitest.fn().mockResolvedValue(mockStream)
+			;(OpenAI as any).prototype.chat = {
+				completions: { create: mockCreate },
+			} as any
+
+			const systemPrompt = "test system prompt"
+			const messages: Anthropic.Messages.MessageParam[] = [{ role: "user" as const, content: "test message" }]
+			const metadata = {
+				taskId: "test-task-id",
+				mode: "code",
+				projectId: "https://github.com/user/repo.git",
+			}
+
+			const generator = handler.createMessage(systemPrompt, messages, metadata)
+			await generator.next()
+
+			// Verify the second argument (options) contains our custom headers
+			expect(mockCreate).toHaveBeenCalledWith(
+				expect.any(Object),
+				expect.objectContaining({
+					headers: {
+						[X_KILOCODE_TASKID]: "test-task-id",
+						[X_KILOCODE_PROJECTID]: "https://github.com/user/repo.git",
+						[X_KILOCODE_ORGANIZATIONID]: "test-org-id",
+					},
+				}),
+			)
+		})
+	})
+})

+ 10 - 1
src/api/providers/kilocode-openrouter.ts

@@ -7,7 +7,12 @@ import { getKiloBaseUriFromToken } from "../../shared/kilocode/token"
 import { ApiHandlerCreateMessageMetadata } from ".."
 import { getModelEndpoints } from "./fetchers/modelEndpointCache"
 import { getKilocodeDefaultModel } from "./kilocode/getKilocodeDefaultModel"
-import { X_KILOCODE_ORGANIZATIONID, X_KILOCODE_TASKID, X_KILOCODE_TESTER } from "../../shared/kilocode/headers"
+import {
+	X_KILOCODE_ORGANIZATIONID,
+	X_KILOCODE_TASKID,
+	X_KILOCODE_PROJECTID,
+	X_KILOCODE_TESTER,
+} from "../../shared/kilocode/headers"
 
 /**
  * A custom OpenRouter handler that overrides the getModel function
@@ -43,6 +48,10 @@ export class KilocodeOpenrouterHandler extends OpenRouterHandler {
 
 		if (kilocodeOptions.kilocodeOrganizationId) {
 			headers[X_KILOCODE_ORGANIZATIONID] = kilocodeOptions.kilocodeOrganizationId
+
+			if (metadata?.projectId) {
+				headers[X_KILOCODE_PROJECTID] = metadata.projectId
+			}
 		}
 
 		// Add X-KILOCODE-TESTER: SUPPRESS header if the setting is enabled

+ 9 - 0
src/core/task/Task.ts

@@ -2830,6 +2830,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 			({ role, content }) => ({ role, content }),
 		)
 
+		// kilocode_change start
+		// Fetch project properties for KiloCode provider tracking
+		const kiloConfig = this.providerRef.deref()?.getKiloConfig()
+		// kilocode_change end
+
 		// Check auto-approval limits
 		const approvalResult = await this.autoApprovalHandler.checkAutoApprovalLimits(
 			state,
@@ -2876,6 +2881,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 			...(previousResponseId && !this.skipPrevResponseIdOnce ? { previousResponseId } : {}),
 			// If a condense just occurred, explicitly suppress continuity fallback for the next call
 			...(this.skipPrevResponseIdOnce ? { suppressPreviousResponseId: true } : {}),
+			// kilocode_change start
+			// KiloCode-specific: pass projectId for backend tracking (ignored by other providers)
+			projectId: (await kiloConfig)?.project?.id,
+			// kilocode_change end
 		}
 
 		// Reset skip flag after applying (it only affects the immediate next call)

+ 6 - 0
src/core/task/__tests__/Task.spec.ts

@@ -86,6 +86,10 @@ vi.mock("vscode", () => {
 			QuickFix: { value: "quickfix" },
 			RefactorRewrite: { value: "refactor.rewrite" },
 		},
+		Uri: {
+			file: vi.fn((path) => ({ fsPath: path, toString: () => `file://${path}` })),
+		},
+		RelativePattern: vi.fn((base, pattern) => ({ base, pattern })),
 		window: {
 			createTextEditorDecorationType: vi.fn().mockReturnValue({
 				dispose: vi.fn(),
@@ -116,6 +120,7 @@ vi.mock("vscode", () => {
 				stat: vi.fn().mockResolvedValue({ type: 1 }), // FileType.File = 1
 			},
 			onDidSaveTextDocument: vi.fn(() => mockDisposable),
+			onDidChangeWorkspaceFolders: vi.fn(() => mockDisposable),
 			getConfiguration: vi.fn(() => ({ get: (key: string, defaultValue: any) => defaultValue })),
 		},
 		env: {
@@ -990,6 +995,7 @@ describe("Cline", () => {
 					postStateToWebview: vi.fn().mockResolvedValue(undefined),
 					postMessageToWebview: vi.fn().mockResolvedValue(undefined),
 					updateTaskHistory: vi.fn().mockResolvedValue(undefined),
+					getKiloConfig: vi.fn().mockResolvedValue(undefined),
 				}
 
 				// Get the mocked delay function

+ 13 - 0
src/core/webview/ClineProvider.ts

@@ -104,6 +104,7 @@ import { stringifyError } from "../../shared/kilocode/errorUtils"
 import isWsl from "is-wsl"
 import { getKilocodeDefaultModel } from "../../api/providers/kilocode/getKilocodeDefaultModel"
 import { getKiloCodeWrapperProperties } from "../../core/kilocode/wrapper"
+import { getKilocodeConfig, getWorkspaceProjectId, KilocodeConfig } from "../../utils/kilo-config-file" // kilocode_change
 
 export type ClineProviderState = Awaited<ReturnType<ClineProvider["getState"]>>
 // kilocode_change end
@@ -2951,6 +2952,18 @@ export class ClineProvider
 		return this._gitProperties
 	}
 
+	// kilocode_change start
+	private _kiloConfig: KilocodeConfig | null = null
+	public async getKiloConfig(): Promise<KilocodeConfig | null> {
+		if (this._kiloConfig === null) {
+			const { repositoryUrl } = await this.getGitProperties()
+			this._kiloConfig = await getKilocodeConfig(this.cwd, repositoryUrl)
+			console.log("getKiloConfig", this._kiloConfig)
+		}
+		return this._kiloConfig
+	}
+	// kilocode_change end
+
 	public async getTelemetryProperties(): Promise<TelemetryProperties> {
 		// kilocode_change start
 		const { apiConfiguration, experiments } = await this.getState()

+ 3 - 3
src/package.json

@@ -678,8 +678,8 @@
 		"ignore": "^7.0.3",
 		"is-wsl": "^3.1.0",
 		"isbinaryfile": "^5.0.2",
-		"json5": "^2.2.3",
 		"jsdom": "^26.0.0",
+		"json5": "^2.2.3",
 		"jwt-decode": "^4.0.0",
 		"lodash.debounce": "^4.0.8",
 		"lru-cache": "^11.1.0",
@@ -729,13 +729,12 @@
 		"@roo-code/config-eslint": "workspace:^",
 		"@roo-code/config-typescript": "workspace:^",
 		"@types/clone-deep": "^4.0.4",
-		"dotenv": "^16.4.7",
 		"@types/debug": "^4.1.12",
 		"@types/diff": "^5.2.3",
 		"@types/diff-match-patch": "^1.0.36",
 		"@types/glob": "^8.1.0",
-		"@types/json5": "^2.2.0",
 		"@types/jsdom": "^21.1.7",
+		"@types/json5": "^2.2.0",
 		"@types/lodash.debounce": "^4.0.9",
 		"@types/mocha": "^10.0.10",
 		"@types/node": "20.x",
@@ -750,6 +749,7 @@
 		"@types/vscode": "^1.84.0",
 		"@vscode/test-electron": "^2.5.2",
 		"@vscode/vsce": "3.3.2",
+		"dotenv": "^16.4.7",
 		"esbuild": "^0.25.0",
 		"execa": "^9.5.2",
 		"glob": "^11.0.1",

+ 1 - 0
src/shared/kilocode/headers.ts

@@ -1,4 +1,5 @@
 export const X_KILOCODE_VERSION = "X-KiloCode-Version"
 export const X_KILOCODE_ORGANIZATIONID = "X-KiloCode-OrganizationId"
 export const X_KILOCODE_TASKID = "X-KiloCode-TaskId"
+export const X_KILOCODE_PROJECTID = "X-KiloCode-ProjectId"
 export const X_KILOCODE_TESTER = "X-KILOCODE-TESTER"

+ 205 - 0
src/utils/__tests__/project-config.spec.ts

@@ -0,0 +1,205 @@
+// kilocode_change - new file
+// npx vitest run src/utils/__tests__/project-config.spec.ts
+
+import { describe, it, expect, beforeEach, afterEach } from "vitest"
+import * as path from "path"
+import { promises as fs } from "fs"
+import * as os from "os"
+import { getKilocodeConfigFile, getProjectId, normalizeProjectId } from "../kilo-config-file"
+
+describe("project-config", () => {
+	let tempDir: string
+
+	beforeEach(async () => {
+		// Create a temporary directory for testing
+		tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "kilocode-test-"))
+	})
+
+	afterEach(async () => {
+		// Clean up temporary directory
+		try {
+			await fs.rm(tempDir, { recursive: true, force: true })
+		} catch (error) {
+			// Ignore cleanup errors
+		}
+	})
+
+	describe("normalizeProjectId", () => {
+		it("extracts repository name from HTTPS git URL", () => {
+			expect(normalizeProjectId("https://github.com/Kilo-Org/handbook.git")).toBe("handbook")
+		})
+
+		it("extracts repository name from SSH git URL", () => {
+			expect(normalizeProjectId("[email protected]:Kilo-Org/handbook.git")).toBe("handbook")
+		})
+
+		it("returns plain project ID as-is", () => {
+			expect(normalizeProjectId("my-project")).toBe("my-project")
+		})
+
+		it("returns undefined for undefined input", () => {
+			expect(normalizeProjectId(undefined)).toBeUndefined()
+		})
+
+		it("handles git URLs without .git extension", () => {
+			expect(normalizeProjectId("my-custom-id")).toBe("my-custom-id")
+		})
+	})
+
+	describe("getProjectConfig", () => {
+		it("returns config from .kilocode/config.json", async () => {
+			const kilocodeDir = path.join(tempDir, ".kilocode")
+			await fs.mkdir(kilocodeDir, { recursive: true })
+			await fs.writeFile(
+				path.join(kilocodeDir, "config.json"),
+				JSON.stringify({
+					project: {
+						id: "my-project",
+					},
+				}),
+			)
+
+			const config = await getKilocodeConfigFile(tempDir)
+
+			expect(config).toEqual({
+				project: {
+					id: "my-project",
+				},
+			})
+		})
+
+		it("returns null when no config file exists", async () => {
+			const config = await getKilocodeConfigFile(tempDir)
+
+			expect(config).toBeNull()
+		})
+
+		it("returns null when config file is invalid JSON", async () => {
+			const kilocodeDir = path.join(tempDir, ".kilocode")
+			await fs.mkdir(kilocodeDir, { recursive: true })
+			await fs.writeFile(path.join(kilocodeDir, "config.json"), "{ invalid json }")
+
+			const config = await getKilocodeConfigFile(tempDir)
+
+			expect(config).toBeNull()
+		})
+
+		it("returns null when config file has no project.id", async () => {
+			const kilocodeDir = path.join(tempDir, ".kilocode")
+			await fs.mkdir(kilocodeDir, { recursive: true })
+			await fs.writeFile(
+				path.join(kilocodeDir, "config.json"),
+				JSON.stringify({
+					project: {},
+				}),
+			)
+
+			const config = await getKilocodeConfigFile(tempDir)
+
+			expect(config).toBeNull()
+		})
+
+		it("returns null when config file has empty project.id", async () => {
+			const kilocodeDir = path.join(tempDir, ".kilocode")
+			await fs.mkdir(kilocodeDir, { recursive: true })
+			await fs.writeFile(
+				path.join(kilocodeDir, "config.json"),
+				JSON.stringify({
+					project: {
+						id: "",
+					},
+				}),
+			)
+
+			const config = await getKilocodeConfigFile(tempDir)
+
+			expect(config).toBeNull()
+		})
+	})
+
+	describe("getProjectId", () => {
+		it("returns normalized project ID from config file when available", async () => {
+			const kilocodeDir = path.join(tempDir, ".kilocode")
+			await fs.mkdir(kilocodeDir, { recursive: true })
+			await fs.writeFile(
+				path.join(kilocodeDir, "config.json"),
+				JSON.stringify({
+					project: {
+						id: "config-project-id",
+					},
+				}),
+			)
+
+			const projectId = await getProjectId(tempDir, "https://github.com/user/repo.git")
+
+			expect(projectId).toBe("config-project-id")
+		})
+
+		it("normalizes git repository URL when config file doesn't exist", async () => {
+			const projectId = await getProjectId(tempDir, "https://github.com/user/repo.git")
+
+			expect(projectId).toBe("repo")
+		})
+
+		it("normalizes SSH git URL when config file doesn't exist", async () => {
+			const projectId = await getProjectId(tempDir, "[email protected]:Kilo-Org/handbook.git")
+
+			expect(projectId).toBe("handbook")
+		})
+
+		it("normalizes git URL when config file has no project.id", async () => {
+			const kilocodeDir = path.join(tempDir, ".kilocode")
+			await fs.mkdir(kilocodeDir, { recursive: true })
+			await fs.writeFile(
+				path.join(kilocodeDir, "config.json"),
+				JSON.stringify({
+					project: {},
+				}),
+			)
+
+			const projectId = await getProjectId(tempDir, "https://github.com/user/repo.git")
+
+			expect(projectId).toBe("repo")
+		})
+
+		it("returns undefined when neither config nor git URL is available", async () => {
+			const projectId = await getProjectId(tempDir)
+
+			expect(projectId).toBeUndefined()
+		})
+
+		it("prioritizes config file over git URL", async () => {
+			const kilocodeDir = path.join(tempDir, ".kilocode")
+			await fs.mkdir(kilocodeDir, { recursive: true })
+			await fs.writeFile(
+				path.join(kilocodeDir, "config.json"),
+				JSON.stringify({
+					project: {
+						id: "override-project",
+					},
+				}),
+			)
+
+			const projectId = await getProjectId(tempDir, "https://github.com/user/repo.git")
+
+			expect(projectId).toBe("override-project")
+		})
+
+		it("normalizes git URL in config file", async () => {
+			const kilocodeDir = path.join(tempDir, ".kilocode")
+			await fs.mkdir(kilocodeDir, { recursive: true })
+			await fs.writeFile(
+				path.join(kilocodeDir, "config.json"),
+				JSON.stringify({
+					project: {
+						id: "https://github.com/Kilo-Org/handbook.git",
+					},
+				}),
+			)
+
+			const projectId = await getProjectId(tempDir)
+
+			expect(projectId).toBe("handbook")
+		})
+	})
+})

+ 128 - 0
src/utils/kilo-config-file.ts

@@ -0,0 +1,128 @@
+// kilocode_change - new file
+import * as vscode from "vscode"
+import * as path from "path"
+import { promises as fs } from "fs"
+import z from "zod"
+
+export type KilocodeConfigProject = z.infer<typeof KilocodeConfigProject>
+export const KilocodeConfigProject = z.object({
+	id: z.string(),
+})
+export type KilocodeConfig = z.infer<typeof KilocodeConfig>
+export const KilocodeConfig = z.object({
+	project: KilocodeConfigProject.optional(),
+})
+
+/**
+ * Normalizes a project identifier for consistent tracking.
+ * If the project is a git repository URL, extracts the repository name.
+ * Otherwise, returns the project ID as-is.
+ *
+ * @param projectId - The project identifier (could be a URL or plain string)
+ * @returns The normalized project ID
+ *
+ * @example
+ * normalizeProjectId('https://github.com/Kilo-Org/handbook.git') // returns 'handbook'
+ * normalizeProjectId('[email protected]:Kilo-Org/handbook.git') // returns 'handbook'
+ * normalizeProjectId('my-project') // returns 'my-project'
+ * normalizeProjectId(undefined) // returns undefined
+ */
+export function normalizeProjectId(projectId?: KilocodeConfigProject["id"]): string | undefined {
+	if (!projectId) {
+		return undefined
+	}
+
+	// Check if it looks like a git URL (https or ssh)
+	const httpsGitPattern = /^https?:\/\/.+\.git$/i
+	const sshGitPattern = /^git@.+\.git$/i
+
+	if (httpsGitPattern.test(projectId) || sshGitPattern.test(projectId)) {
+		// Extract the last path component and remove .git extension
+		const parts = projectId.split("/")
+		const lastPart = parts[parts.length - 1]
+		return lastPart.replace(/\.git$/i, "")
+	}
+
+	// If it's not a git URL, return as-is
+	return projectId
+}
+
+export async function getKilocodeConfig(
+	workspaceRoot: string,
+	gitRepositoryUrl?: string,
+): Promise<KilocodeConfig | null> {
+	console.log("getKilocodeConfig", workspaceRoot, gitRepositoryUrl)
+
+	const config = await getKilocodeConfigFile(workspaceRoot)
+
+	if (!config?.project?.id) {
+		const id = normalizeProjectId(gitRepositoryUrl)
+		return id ? { project: { id } } : null
+	}
+
+	const id = normalizeProjectId(config.project.id)
+	return id ? { project: { id } } : null
+}
+
+/**
+ * Reads the project configuration from .kilocode/config.json
+ * Note: .kilocode/config.jsonc is not supported to avoid bundling issues
+ *
+ * @param workspaceRoot The root path of the workspace
+ * @returns The project configuration or undefined if not found or invalid
+ */
+export async function getKilocodeConfigFile(workspaceRoot: string): Promise<KilocodeConfig | null> {
+	const configPath = path.join(workspaceRoot, ".kilocode", "config.json")
+	try {
+		const content = await fs.readFile(configPath, "utf8")
+		const config = KilocodeConfig.parse(JSON.parse(content))
+		// Return null if project.id is missing or empty
+		if (!config.project?.id || config.project.id.trim() === "") {
+			return null
+		}
+		return config
+	} catch (error) {
+		// File doesn't exist or can't be read
+		return null
+	}
+}
+
+/**
+ * Gets the project ID from configuration file or git repository
+ * Priority:
+ * 1. .kilocode/config.json (project.id) - normalized
+ * 2. Git repository URL (origin remote) - normalized to repo name
+ * 3. undefined if neither exists
+ *
+ * @param workspaceRoot The root path of the workspace
+ * @param gitRepositoryUrl Optional git repository URL to use as fallback
+ * @returns The normalized project ID or undefined
+ */
+export async function getProjectId(workspaceRoot: string, gitRepositoryUrl?: string): Promise<string | undefined> {
+	// First, try to get project ID from config file
+	const config = await getKilocodeConfigFile(workspaceRoot)
+	if (config?.project?.id) {
+		return normalizeProjectId(config.project.id)
+	}
+
+	// Fall back to normalized git repository URL
+	return normalizeProjectId(gitRepositoryUrl)
+}
+
+/**
+ * Gets the project ID for the current VSCode workspace
+ * Priority:
+ * 1. .kilocode/config.json (project.id) - normalized
+ * 2. Git repository URL (origin remote) - normalized to repo name
+ * 3. undefined if neither exists
+ * @returns The normalized project ID or undefined
+ */
+export async function getWorkspaceProjectId(gitRepositoryUrl?: string): Promise<string | undefined> {
+	const workspaceFolders = vscode.workspace.workspaceFolders
+	if (!workspaceFolders || workspaceFolders.length === 0) {
+		return undefined
+	}
+
+	const workspaceRoot = workspaceFolders[0].uri.fsPath
+	return await getProjectId(workspaceRoot, gitRepositoryUrl)
+}