contextgit 0.0.1 → 0.0.2

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 (83) hide show
  1. package/.claude/settings.local.json +41 -0
  2. package/.contextgit/config.json +10 -0
  3. package/.contextgit/system-prompt.md +4 -0
  4. package/.github/workflows/contextgit-ci.yml +40 -0
  5. package/CLAUDE.md +123 -0
  6. package/CLAUDE.md.next +65 -0
  7. package/docs/ContextGit_ARCHITECTURE_v3.md +1141 -0
  8. package/docs/ContextGit_DELTA.md +84 -0
  9. package/docs/ContextGit_PHASE1_PLAN.md +177 -0
  10. package/docs/ContextGit_PHASE2_PLAN.md +535 -0
  11. package/docs/ContextGit_PRD_v4.md +488 -0
  12. package/docs/decisions.md +370 -0
  13. package/package.json +23 -8
  14. package/packages/api/package.json +25 -0
  15. package/packages/api/src/bootstrap.ts +64 -0
  16. package/packages/api/src/config.ts +45 -0
  17. package/packages/api/src/index.ts +17 -0
  18. package/packages/api/src/middleware/auth.test.ts +83 -0
  19. package/packages/api/src/middleware/auth.ts +41 -0
  20. package/packages/api/src/remote-store.test.ts +301 -0
  21. package/packages/api/src/router.ts +121 -0
  22. package/packages/api/src/server-config.ts +34 -0
  23. package/packages/api/src/server.ts +38 -0
  24. package/packages/api/src/store-router.ts +241 -0
  25. package/packages/api/tsconfig.json +8 -0
  26. package/packages/cli/bin/run.js +4 -0
  27. package/packages/cli/package.json +29 -0
  28. package/packages/cli/src/bootstrap.ts +68 -0
  29. package/packages/cli/src/commands/branch.ts +58 -0
  30. package/packages/cli/src/commands/claim.ts +58 -0
  31. package/packages/cli/src/commands/commit.ts +79 -0
  32. package/packages/cli/src/commands/context.ts +46 -0
  33. package/packages/cli/src/commands/doctor.ts +99 -0
  34. package/packages/cli/src/commands/init.ts +141 -0
  35. package/packages/cli/src/commands/keygen.ts +65 -0
  36. package/packages/cli/src/commands/log.ts +103 -0
  37. package/packages/cli/src/commands/merge.ts +36 -0
  38. package/packages/cli/src/commands/pull.ts +145 -0
  39. package/packages/cli/src/commands/push.ts +158 -0
  40. package/packages/cli/src/commands/remote-show.ts +87 -0
  41. package/packages/cli/src/commands/search.ts +54 -0
  42. package/packages/cli/src/commands/serve.ts +61 -0
  43. package/packages/cli/src/commands/set-remote.ts +30 -0
  44. package/packages/cli/src/commands/status.ts +62 -0
  45. package/packages/cli/src/commands/unclaim.ts +28 -0
  46. package/packages/cli/src/config.ts +64 -0
  47. package/packages/cli/src/git-hooks.ts +61 -0
  48. package/packages/cli/tsconfig.json +9 -0
  49. package/packages/core/package.json +28 -0
  50. package/packages/core/src/embeddings.test.ts +58 -0
  51. package/packages/core/src/embeddings.ts +75 -0
  52. package/packages/core/src/engine.ts +274 -0
  53. package/packages/core/src/index.ts +6 -0
  54. package/packages/core/src/snapshot.ts +82 -0
  55. package/packages/core/src/summarizer.test.ts +120 -0
  56. package/packages/core/src/summarizer.ts +113 -0
  57. package/packages/core/src/threads.ts +29 -0
  58. package/packages/core/src/types.ts +240 -0
  59. package/packages/core/tsconfig.json +9 -0
  60. package/packages/mcp/package.json +31 -0
  61. package/packages/mcp/src/auto-snapshot.ts +83 -0
  62. package/packages/mcp/src/config.ts +53 -0
  63. package/packages/mcp/src/git-sync.ts +94 -0
  64. package/packages/mcp/src/index.ts +19 -0
  65. package/packages/mcp/src/server.ts +377 -0
  66. package/packages/mcp/tsconfig.json +9 -0
  67. package/packages/store/package.json +30 -0
  68. package/packages/store/src/branch-merge.test.ts +127 -0
  69. package/packages/store/src/engine-integration.test.ts +93 -0
  70. package/packages/store/src/index.ts +3 -0
  71. package/packages/store/src/interface.ts +62 -0
  72. package/packages/store/src/local/claims.test.ts +190 -0
  73. package/packages/store/src/local/index.ts +380 -0
  74. package/packages/store/src/local/local-store.test.ts +164 -0
  75. package/packages/store/src/local/migrations.ts +99 -0
  76. package/packages/store/src/local/queries.ts +760 -0
  77. package/packages/store/src/local/schema.ts +157 -0
  78. package/packages/store/src/remote/index.ts +300 -0
  79. package/packages/store/tsconfig.json +9 -0
  80. package/pnpm-workspace.yaml +2 -0
  81. package/scripts/build.sh +28 -0
  82. package/tsconfig.base.json +14 -0
  83. package/vitest.config.ts +15 -0
@@ -0,0 +1,113 @@
1
+ // RollingSummarizer — Week 2: Claude Haiku with graceful string-truncation fallback.
2
+ // If the API key is absent or the API call fails, falls back to string truncation.
3
+ // The interface is stable; callers never know which implementation is running.
4
+
5
+ import Anthropic from '@anthropic-ai/sdk'
6
+
7
+ export interface SummarizerOptions {
8
+ /** Max characters for project-level summaries (~2000 tokens). */
9
+ maxProjectChars?: number
10
+ /** Max characters for branch-level summaries (~500 tokens). */
11
+ maxBranchChars?: number
12
+ /**
13
+ * Inject a pre-built Anthropic client (useful for tests).
14
+ * If omitted, one is created from ANTHROPIC_API_KEY.
15
+ * If ANTHROPIC_API_KEY is also absent, falls back to string truncation.
16
+ */
17
+ client?: Anthropic
18
+ }
19
+
20
+ export class RollingSummarizer {
21
+ private readonly maxProjectChars: number
22
+ private readonly maxBranchChars: number
23
+ private readonly client: Anthropic | null
24
+
25
+ constructor(options: SummarizerOptions = {}) {
26
+ this.maxProjectChars = options.maxProjectChars ?? 8000
27
+ this.maxBranchChars = options.maxBranchChars ?? 2000
28
+
29
+ if (options.client !== undefined) {
30
+ this.client = options.client
31
+ } else if (process.env['ANTHROPIC_API_KEY']) {
32
+ this.client = new Anthropic()
33
+ } else {
34
+ this.client = null
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Roll `content` into `previousSummary` and return a new summary that fits
40
+ * within the budget for the given scope.
41
+ *
42
+ * Week 2: uses Claude Haiku for intelligent compression.
43
+ * Fallback: dumb truncation (newest content appended, trimmed from the start
44
+ * so the most-recent work is always present).
45
+ *
46
+ * Never throws — summarizer failures are silently swallowed.
47
+ */
48
+ async summarize(
49
+ content: string,
50
+ previousSummary: string,
51
+ budget: 'project' | 'branch',
52
+ ): Promise<string> {
53
+ const max = budget === 'project' ? this.maxProjectChars : this.maxBranchChars
54
+
55
+ if (this.client !== null) {
56
+ try {
57
+ return await this._claudeSummarize(content, previousSummary, max)
58
+ } catch {
59
+ // Graceful fallback — never propagate summarizer failure to the caller.
60
+ }
61
+ }
62
+
63
+ return this._truncateSummarize(content, previousSummary, max)
64
+ }
65
+
66
+ // ─── Private ────────────────────────────────────────────────────────────────
67
+
68
+ private async _claudeSummarize(
69
+ content: string,
70
+ previousSummary: string,
71
+ maxChars: number,
72
+ ): Promise<string> {
73
+ const userContent = previousSummary
74
+ ? `Previous summary:\n${previousSummary}\n\nNew content to integrate:\n${content}`
75
+ : `Content to summarize:\n${content}`
76
+
77
+ const response = await this.client!.messages.create({
78
+ model: 'claude-haiku-4-5',
79
+ max_tokens: 1024,
80
+ system:
81
+ 'You are a context summarizer for an AI coding agent. ' +
82
+ 'Return only the summary text with no preamble or explanation.',
83
+ messages: [
84
+ {
85
+ role: 'user',
86
+ content:
87
+ userContent +
88
+ `\n\nCreate a concise rolling summary (max ${maxChars} characters) ` +
89
+ 'that preserves the most important recent work and decisions. ' +
90
+ 'Return only the summary text.',
91
+ },
92
+ ],
93
+ })
94
+
95
+ const text = response.content
96
+ .filter((b): b is Anthropic.TextBlock => b.type === 'text')
97
+ .map(b => b.text)
98
+ .join('')
99
+
100
+ return text.slice(0, maxChars)
101
+ }
102
+
103
+ private _truncateSummarize(
104
+ content: string,
105
+ previousSummary: string,
106
+ max: number,
107
+ ): string {
108
+ const combined = previousSummary ? `${previousSummary}\n\n${content}` : content
109
+ if (combined.length <= max) return combined
110
+ // Keep the tail so recent work is never lost.
111
+ return combined.slice(combined.length - max)
112
+ }
113
+ }
@@ -0,0 +1,29 @@
1
+ // ThreadManager — read-side helper for the open-thread immune-to-compression guarantee.
2
+ //
3
+ // Key invariant: open threads are stored in the `threads` table and NEVER
4
+ // passed to the summarizer. This file enforces that boundary by providing
5
+ // the only sanctioned way to query open threads.
6
+ //
7
+ // Write-side (opening / closing threads) happens via CommitInput.threads
8
+ // inside store.createCommit(), which is the right transactional boundary.
9
+
10
+ import type { Thread } from './types.js'
11
+
12
+ export interface ThreadReader {
13
+ listOpenThreads(projectId: string): Promise<Thread[]>
14
+ listOpenThreadsByBranch(branchId: string): Promise<Thread[]>
15
+ }
16
+
17
+ export class ThreadManager {
18
+ constructor(private readonly store: ThreadReader) {}
19
+
20
+ /** All open threads for a project, across all branches. */
21
+ openForProject(projectId: string): Promise<Thread[]> {
22
+ return this.store.listOpenThreads(projectId)
23
+ }
24
+
25
+ /** Open threads scoped to a single branch. */
26
+ openForBranch(branchId: string): Promise<Thread[]> {
27
+ return this.store.listOpenThreadsByBranch(branchId)
28
+ }
29
+ }
@@ -0,0 +1,240 @@
1
+
2
+
3
+ // ============================================
4
+ // Primitive Types
5
+ // ============================================
6
+
7
+ export type AgentRole =
8
+ | 'orchestrator'
9
+ | 'dev'
10
+ | 'test'
11
+ | 'review'
12
+ | 'background'
13
+ | 'ci'
14
+ | 'solo'
15
+
16
+ export type WorkflowType =
17
+ | 'interactive'
18
+ | 'ralph-loop'
19
+ | 'ci'
20
+ | 'background'
21
+ | 'custom'
22
+
23
+ export type CommitType =
24
+ | 'manual'
25
+ | 'auto'
26
+ | 'merge'
27
+ | 'branch-init'
28
+
29
+ export type BranchStatus =
30
+ | 'active'
31
+ | 'merged'
32
+ | 'abandoned'
33
+
34
+ export type ClaimStatus =
35
+ | 'proposed'
36
+ | 'active'
37
+ | 'released'
38
+
39
+ export type SnapshotFormat =
40
+ | 'agents-md'
41
+ | 'json'
42
+ | 'text'
43
+
44
+ export type ContextScope =
45
+ | 'global'
46
+ | 'branch'
47
+ | 'search'
48
+ | 'commit'
49
+ | 'raw'
50
+
51
+ // ============================================
52
+ // Core Entities
53
+ // ============================================
54
+
55
+ export interface Project {
56
+ id: string
57
+ name: string
58
+ description?: string
59
+ githubUrl?: string
60
+ createdAt: Date
61
+ }
62
+
63
+ export interface Branch {
64
+ id: string
65
+ projectId: string
66
+ name: string
67
+ gitBranch: string
68
+ githubPrUrl?: string
69
+ parentBranchId?: string
70
+ headCommitId?: string
71
+ status: BranchStatus
72
+ createdAt: Date
73
+ mergedAt?: Date
74
+ }
75
+
76
+ export interface Commit {
77
+ id: string
78
+ branchId: string
79
+ parentId?: string
80
+ mergeSourceBranchId?: string
81
+ agentId: string
82
+ agentRole: AgentRole
83
+ tool: string
84
+ workflowType: WorkflowType
85
+ loopIteration?: number
86
+ ciRunId?: string
87
+ pipelineName?: string
88
+ message: string
89
+ content: string
90
+ summary: string
91
+ commitType: CommitType
92
+ gitCommitSha?: string
93
+ createdAt: Date
94
+ }
95
+
96
+ export interface Thread {
97
+ id: string
98
+ projectId: string
99
+ branchId: string
100
+ description: string // max 200 chars
101
+ status: 'open' | 'closed'
102
+ workflowType?: WorkflowType
103
+ openedInCommit: string
104
+ closedInCommit?: string
105
+ closedNote?: string
106
+ createdAt: Date
107
+ }
108
+
109
+ export interface Agent {
110
+ id: string
111
+ projectId: string
112
+ role: AgentRole
113
+ tool: string
114
+ workflowType: WorkflowType
115
+ displayName?: string
116
+ totalCommits: number
117
+ lastSeen: Date
118
+ createdAt: Date
119
+ }
120
+
121
+ export interface Claim {
122
+ id: string
123
+ projectId: string
124
+ branchId: string
125
+ task: string
126
+ agentId: string
127
+ role: AgentRole
128
+ claimedAt: Date
129
+ status: ClaimStatus
130
+ ttl: number // ms, default 7_200_000 (2h)
131
+ releasedAt?: Date
132
+ }
133
+
134
+ // ============================================
135
+ // Input Types (for creating entities)
136
+ // ============================================
137
+
138
+ export interface ProjectInput {
139
+ id?: string // caller-supplied ID; LocalStore generates nanoid() if omitted
140
+ name: string
141
+ description?: string
142
+ githubUrl?: string
143
+ }
144
+
145
+ export interface BranchInput {
146
+ id?: string // caller-supplied ID; LocalStore generates nanoid() if omitted
147
+ projectId: string
148
+ name: string
149
+ gitBranch: string
150
+ parentBranchId?: string
151
+ githubPrUrl?: string
152
+ }
153
+
154
+ export interface CommitInput {
155
+ id?: string // caller-supplied ID; LocalStore generates nanoid() if omitted
156
+ branchId: string
157
+ parentId?: string
158
+ agentId: string
159
+ agentRole: AgentRole
160
+ tool: string
161
+ workflowType: WorkflowType
162
+ loopIteration?: number
163
+ ciRunId?: string
164
+ pipelineName?: string
165
+ message: string
166
+ content: string
167
+ summary: string
168
+ commitType: CommitType
169
+ gitCommitSha?: string
170
+ threads?: {
171
+ open?: string[]
172
+ close?: Array<{ id: string; note: string }>
173
+ }
174
+ }
175
+
176
+ export interface AgentInput {
177
+ id: string
178
+ projectId: string
179
+ role: AgentRole
180
+ tool: string
181
+ workflowType: WorkflowType
182
+ displayName?: string
183
+ }
184
+
185
+ export interface ClaimInput {
186
+ task: string
187
+ agentId: string
188
+ role: AgentRole
189
+ status?: ClaimStatus // defaults to 'proposed'
190
+ ttl?: number // ms, defaults to 7_200_000 (2h)
191
+ }
192
+
193
+ // ============================================
194
+ // Session Snapshot
195
+ // ============================================
196
+
197
+ export interface SessionSnapshot {
198
+ projectSummary: string // max 2000 tokens
199
+ branchName: string
200
+ branchSummary: string // max 500 tokens
201
+ recentCommits: Commit[] // last 3
202
+ openThreads: Thread[]
203
+ activeClaims: Claim[] // non-released, non-TTL-expired claims
204
+ }
205
+
206
+ // ============================================
207
+ // Search
208
+ // ============================================
209
+
210
+ export interface SearchResult {
211
+ commit: Commit
212
+ score: number
213
+ matchType: 'semantic' | 'fulltext'
214
+ }
215
+
216
+ // ============================================
217
+ // Config
218
+ // ============================================
219
+
220
+ export interface ContextGitConfig {
221
+ project: string
222
+ projectId: string
223
+ store: 'local' | string // 'local' or remote URL
224
+ remote?: string // remote ContextGit API URL for push/pull
225
+ agentRole: AgentRole
226
+ workflowType: WorkflowType
227
+ autoSnapshot: boolean
228
+ snapshotInterval: number
229
+ embeddingModel: 'local' | 'openai'
230
+ apiKey?: string
231
+ }
232
+
233
+ // ============================================
234
+ // Pagination
235
+ // ============================================
236
+
237
+ export interface Pagination {
238
+ limit: number
239
+ offset: number
240
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "./src",
5
+ "outDir": "./dist"
6
+ },
7
+ "include": ["src/**/*"],
8
+ "exclude": ["node_modules", "dist"]
9
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@contextgit/mcp",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/index.js",
10
+ "types": "./dist/index.d.ts"
11
+ }
12
+ },
13
+ "bin": {
14
+ "contextgit-mcp": "./dist/index.js"
15
+ },
16
+ "scripts": {
17
+ "build": "tsc",
18
+ "typecheck": "tsc --noEmit"
19
+ },
20
+ "dependencies": {
21
+ "@contextgit/core": "workspace:*",
22
+ "@contextgit/store": "workspace:*",
23
+ "@modelcontextprotocol/sdk": "^1.0.0",
24
+ "simple-git": "^3.27.0",
25
+ "zod": "^3.23.0"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^20.0.0",
29
+ "typescript": "^5.4.0"
30
+ }
31
+ }
@@ -0,0 +1,83 @@
1
+ // auto-snapshot.ts — AutoSnapshotManager
2
+ //
3
+ // Tracks tool calls made to the MCP server and fires an automatic context_commit
4
+ // after every N=10 non-commit calls. This ensures context is preserved even when
5
+ // the agent forgets to call context_commit manually.
6
+ //
7
+ // Context tool semantics:
8
+ // context_commit → resets the counter (manual commit = checkpoint reached)
9
+ // context_get → counted (each session-start snapshot counts toward the interval)
10
+ // context_search → counted
11
+ // any other tool → counted (future tools)
12
+
13
+ import type { ContextEngine } from '@contextgit/core'
14
+
15
+ export interface AutoSnapshotOptions {
16
+ /** Number of non-commit tool calls before an auto-commit fires. Default: 10. */
17
+ interval?: number
18
+ }
19
+
20
+ export class AutoSnapshotManager {
21
+ private count = 0
22
+ private readonly interval: number
23
+ private readonly engine: ContextEngine
24
+
25
+ constructor(engine: ContextEngine, options: AutoSnapshotOptions = {}) {
26
+ this.engine = engine
27
+ this.interval = options.interval ?? 10
28
+ }
29
+
30
+ /**
31
+ * Record a tool call by name.
32
+ *
33
+ * - `context_commit` resets the counter (manual commit; no auto-commit fired).
34
+ * - All other tool names increment the counter.
35
+ * - When the counter reaches `interval`, an auto-commit is fired and the
36
+ * counter resets to 0.
37
+ *
38
+ * Auto-commit failures are swallowed — the tool call is never blocked.
39
+ *
40
+ * @returns The new commit ID if an auto-commit was fired, otherwise undefined.
41
+ */
42
+ async onToolCall(toolName: string): Promise<string | undefined> {
43
+ if (toolName === 'context_commit') {
44
+ this.count = 0
45
+ return undefined
46
+ }
47
+
48
+ this.count++
49
+
50
+ if (this.count >= this.interval) {
51
+ this.count = 0
52
+ return this.fireAutoCommit()
53
+ }
54
+
55
+ return undefined
56
+ }
57
+
58
+ /** Current tool-call count since the last commit (manual or auto). */
59
+ get toolCallCount(): number {
60
+ return this.count
61
+ }
62
+
63
+ /** Manually reset the counter (e.g. after an out-of-band commit). */
64
+ reset(): void {
65
+ this.count = 0
66
+ }
67
+
68
+ // ─── Private ──────────────────────────────────────────────────────────────
69
+
70
+ private async fireAutoCommit(): Promise<string | undefined> {
71
+ try {
72
+ const commit = await this.engine.commit({
73
+ message: `Auto-snapshot after ${this.interval} tool calls`,
74
+ content: `Automatic context checkpoint triggered after ${this.interval} tool calls without a manual context_commit.`,
75
+ commitType: 'auto',
76
+ })
77
+ return commit.id
78
+ } catch {
79
+ // Never block a tool call due to auto-snapshot failure.
80
+ return undefined
81
+ }
82
+ }
83
+ }
@@ -0,0 +1,53 @@
1
+ // config.ts — load and validate .contextgit/config.json
2
+ // Searches from CWD upwards until it finds the config file.
3
+
4
+ import { readFileSync } from 'fs'
5
+ import { join, dirname } from 'path'
6
+ import type { ContextGitConfig } from '@contextgit/core'
7
+
8
+ export class ConfigNotFoundError extends Error {
9
+ constructor(startDir: string) {
10
+ super(`No .contextgit/config.json found searching upward from: ${startDir}`)
11
+ this.name = 'ConfigNotFoundError'
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Search upward from `startDir` for `.contextgit/config.json`.
17
+ * Returns the first match found, or throws ConfigNotFoundError.
18
+ */
19
+ export function findConfigPath(startDir: string = process.cwd()): string {
20
+ let current = startDir
21
+ while (true) {
22
+ const candidate = join(current, '.contextgit', 'config.json')
23
+ try {
24
+ readFileSync(candidate)
25
+ return candidate
26
+ } catch {
27
+ const parent = dirname(current)
28
+ if (parent === current) {
29
+ throw new ConfigNotFoundError(startDir)
30
+ }
31
+ current = parent
32
+ }
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Load and parse `.contextgit/config.json`.
38
+ * Throws ConfigNotFoundError if not found, or Error if JSON is invalid.
39
+ */
40
+ export function loadConfig(startDir?: string): ContextGitConfig {
41
+ const configPath = findConfigPath(startDir)
42
+ const raw = readFileSync(configPath, 'utf-8')
43
+ const config = JSON.parse(raw) as ContextGitConfig
44
+
45
+ if (!config.projectId) {
46
+ throw new Error(`Invalid config at ${configPath}: missing required field 'projectId'`)
47
+ }
48
+ if (!config.project) {
49
+ throw new Error(`Invalid config at ${configPath}: missing required field 'project'`)
50
+ }
51
+
52
+ return config
53
+ }
@@ -0,0 +1,94 @@
1
+ // git-sync.ts — git metadata capture and hook installation.
2
+ //
3
+ // captureGitMetadata: used by context_commit (MCP) and commit CLI command to
4
+ // auto-populate gitCommitSha on every context commit.
5
+ //
6
+ // installGitHooks: idempotent hook installer — writes post-commit,
7
+ // post-checkout, post-merge scripts into .git/hooks/.
8
+
9
+ import { writeFileSync, readFileSync, mkdirSync, existsSync, appendFileSync } from 'fs'
10
+ import { join, resolve } from 'path'
11
+ import { homedir } from 'os'
12
+ import { simpleGit } from 'simple-git'
13
+
14
+ const SENTINEL = '# contextgit'
15
+ const HOOKS_LOG = join(homedir(), '.contextgit', 'hooks.log')
16
+
17
+ // ─── captureGitMetadata ────────────────────────────────────────────────────────
18
+
19
+ /**
20
+ * Capture the current git commit SHA and branch name.
21
+ * Returns null on any error — must never block a context commit.
22
+ */
23
+ export async function captureGitMetadata(
24
+ cwd: string,
25
+ ): Promise<{ sha: string; branch: string } | null> {
26
+ try {
27
+ const git = simpleGit(cwd)
28
+ const [sha, branch] = await Promise.all([
29
+ git.revparse(['HEAD']),
30
+ git.revparse(['--abbrev-ref', 'HEAD']),
31
+ ])
32
+ return { sha: sha.trim(), branch: branch.trim() }
33
+ } catch {
34
+ return null
35
+ }
36
+ }
37
+
38
+ // ─── installGitHooks ──────────────────────────────────────────────────────────
39
+
40
+ const HOOK_SCRIPTS: Record<string, string> = {
41
+ 'post-commit': `#!/bin/sh
42
+ ${SENTINEL}
43
+ contextgit commit -m "git: $(git log -1 --pretty=%s)" 2>>"${HOOKS_LOG}" || true
44
+ `,
45
+ 'post-checkout': `#!/bin/sh
46
+ ${SENTINEL}
47
+ contextgit context --quiet 2>>"${HOOKS_LOG}" || true
48
+ `,
49
+ 'post-merge': `#!/bin/sh
50
+ ${SENTINEL}
51
+ contextgit commit -m "Merged into $(git rev-parse --abbrev-ref HEAD)" 2>>"${HOOKS_LOG}" || true
52
+ `,
53
+ }
54
+
55
+ /**
56
+ * Install contextgit git hooks into <projectRoot>/.git/hooks/.
57
+ * Idempotent: checks for the sentinel comment before appending.
58
+ * Hook failures are logged to ~/.contextgit/hooks.log — never to stderr.
59
+ */
60
+ export async function installGitHooks(projectRoot: string): Promise<void> {
61
+ const hooksDir = join(resolve(projectRoot), '.git', 'hooks')
62
+ mkdirSync(hooksDir, { recursive: true })
63
+
64
+ for (const [hookName, script] of Object.entries(HOOK_SCRIPTS)) {
65
+ const hookPath = join(hooksDir, hookName)
66
+
67
+ if (existsSync(hookPath)) {
68
+ const existing = readFileSync(hookPath, 'utf-8')
69
+ if (existing.includes(SENTINEL)) continue // already installed
70
+ // Append to existing hook
71
+ writeFileSync(hookPath, existing.trimEnd() + '\n\n' + script)
72
+ } else {
73
+ writeFileSync(hookPath, script)
74
+ }
75
+
76
+ // Make executable (chmod +x)
77
+ try {
78
+ const { chmodSync } = await import('fs')
79
+ chmodSync(hookPath, 0o755)
80
+ } catch {
81
+ logHookError(`chmod failed for ${hookPath}`)
82
+ }
83
+ }
84
+ }
85
+
86
+ function logHookError(msg: string): void {
87
+ try {
88
+ const dir = join(homedir(), '.contextgit')
89
+ mkdirSync(dir, { recursive: true })
90
+ appendFileSync(HOOKS_LOG, `[${new Date().toISOString()}] ${msg}\n`)
91
+ } catch {
92
+ // truly silent
93
+ }
94
+ }
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ // index.ts — entry point for the ContextGit MCP server process.
3
+ // Started by the MCP host (Claude Desktop / Claude Code) via stdio.
4
+
5
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
6
+ import { createServer } from './server.js'
7
+
8
+ async function main(): Promise<void> {
9
+ const server = await createServer()
10
+ const transport = new StdioServerTransport()
11
+ await server.connect(transport)
12
+ // Server is now listening on stdin/stdout — process stays alive until the
13
+ // host closes the connection.
14
+ }
15
+
16
+ main().catch(err => {
17
+ console.error('[contextgit-mcp] Fatal error:', err)
18
+ process.exit(1)
19
+ })