refactor(js): rename package from 'contexa' to 'cortexa'
Swadhin Biswas committed 7 months ago
af28f1c
1 parent 3fa717d
commit af28f1c
6 files changed
+301 -146
Showing 6 changed files+301-146
▼JS/README.md
+12-12
| @@ -1,13 +1,13 @@ | ||
1 | -# contexa | |
1 | +# cortexa | |
2 | 2 | |
3 | -[](https://www.npmjs.com/package/contexa) | |
3 | +[](https://www.npmjs.com/package/cortexa) | |
4 | 4 | [](https://nodejs.org/) |
5 | 5 | [](LICENSE) |
6 | -[](https://github.com/swadhinbiswas/contexa) | |
6 | +[](https://github.com/swadhinbiswas/Cortexa) | |
7 | 7 | |
8 | 8 | **Git-inspired context management for LLM agents.** COMMIT, BRANCH, MERGE, and CONTEXT operations over a persistent versioned memory workspace. |
9 | 9 | |
10 | -TypeScript/JavaScript implementation of the **contexa** framework. | |
10 | +TypeScript/JavaScript implementation of the **cortexa** framework. | |
11 | 11 | |
12 | 12 | Based on: [arXiv:2508.00031](https://arxiv.org/abs/2508.00031) -- *"Git Context Controller: Manage the Context of LLM-based Agents like Git"* (Junde Wu et al., 2025) |
13 | 13 | |
| @@ -16,14 +16,14 @@ Based on: [arXiv:2508.00031](https://arxiv.org/abs/2508.00031) -- *"Git Context | ||
16 | 16 | ## Installation |
17 | 17 | |
18 | 18 | ```bash |
19 | -npm install contexa | |
19 | +npm install cortexa | |
20 | 20 | ``` |
21 | 21 | |
22 | 22 | Or with yarn/pnpm: |
23 | 23 | |
24 | 24 | ```bash |
25 | -yarn add contexa | |
26 | -pnpm add contexa | |
25 | +yarn add cortexa | |
26 | +pnpm add cortexa | |
27 | 27 | ``` |
28 | 28 | |
29 | 29 | --- |
| @@ -31,7 +31,7 @@ pnpm add contexa | ||
31 | 31 | ## Quick Start |
32 | 32 | |
33 | 33 | ```typescript |
34 | -import { GCCWorkspace } from "contexa"; | |
34 | +import { GCCWorkspace } from "cortexa"; | |
35 | 35 | |
36 | 36 | // 1. Initialize a workspace |
37 | 37 | const ws = new GCCWorkspace("/path/to/project"); |
| @@ -216,8 +216,8 @@ All data is stored as **human-readable Markdown and YAML**. | ||
216 | 216 | ## Running Tests |
217 | 217 | |
218 | 218 | ```bash |
219 | -git clone https://github.com/swadhinbiswas/contexa.git | |
220 | -cd contexa/JS | |
219 | +git clone https://github.com/swadhinbiswas/Cortexa.git | |
220 | +cd cortexa/JS | |
221 | 221 | npm install |
222 | 222 | npm test |
223 | 223 | ``` |
| @@ -261,7 +261,7 @@ MIT License. See [LICENSE](LICENSE) for details. | ||
261 | 261 | |
262 | 262 | ## Links |
263 | 263 | |
264 | -- [GitHub Repository](https://github.com/swadhinbiswas/contexa) | |
265 | -- [npm Package](https://www.npmjs.com/package/contexa) | |
264 | +- [GitHub Repository](https://github.com/swadhinbiswas/Cortexa) | |
265 | +- [npm Package](https://www.npmjs.com/package/cortexa) | |
266 | 266 | - [Original Paper](https://arxiv.org/abs/2508.00031) |
267 | 267 | - [Author: Swadhin Biswas](https://github.com/swadhinbiswas) |
268 | 268 | |
▼JS/package-lock.json
+2-2
| @@ -1,11 +1,11 @@ | ||
1 | 1 | { |
2 | - "name": "contexa", | |
2 | + "name": "cortexa", | |
3 | 3 | "version": "0.1.1", |
4 | 4 | "lockfileVersion": 3, |
5 | 5 | "requires": true, |
6 | 6 | "packages": { |
7 | 7 | "": { |
8 | - "name": "contexa", | |
8 | + "name": "cortexa", | |
9 | 9 | "version": "0.1.1", |
10 | 10 | "license": "MIT", |
11 | 11 | "dependencies": { |
12 | 12 | |
▼JS/package.json
+5-5
| @@ -1,5 +1,5 @@ | ||
1 | 1 | { |
2 | - "name": "contexa", | |
2 | + "name": "cortexa", | |
3 | 3 | "version": "0.1.1", |
4 | 4 | "description": "Git-inspired context management for LLM agents -- COMMIT, BRANCH, MERGE, and CONTEXT over versioned memory (arXiv:2508.00031)", |
5 | 5 | "author": "Swadhin Biswas <[email protected]>", |
| @@ -23,15 +23,15 @@ | ||
23 | 23 | "test": "tsx --test test.ts", |
24 | 24 | "lint": "eslint src" |
25 | 25 | }, |
26 | - "keywords": ["llm", "agent", "context-management", "git", "memory", "gcc", "contexa"], | |
26 | + "keywords": ["llm", "agent", "context-management", "git", "memory", "gcc", "cortexa"], | |
27 | 27 | "license": "MIT", |
28 | 28 | "repository": { |
29 | 29 | "type": "git", |
30 | - "url": "https://github.com/swadhinbiswas/contexa" | |
30 | + "url": "https://github.com/swadhinbiswas/Cortexa" | |
31 | 31 | }, |
32 | - "homepage": "https://github.com/swadhinbiswas/contexa", | |
32 | + "homepage": "https://github.com/swadhinbiswas/Cortexa", | |
33 | 33 | "bugs": { |
34 | - "url": "https://github.com/swadhinbiswas/contexa/issues" | |
34 | + "url": "https://github.com/swadhinbiswas/Cortexa/issues" | |
35 | 35 | }, |
36 | 36 | "dependencies": { |
37 | 37 | "js-yaml": "^4.1.0" |
38 | 38 | |
▼JS/src/index.ts
+1-1
| @@ -1,5 +1,5 @@ | ||
1 | 1 | /** |
2 | - * contexa — Git-inspired context management for LLM agents. | |
2 | + * cortexa — Git-inspired context management for LLM agents. | |
3 | 3 | * COMMIT, BRANCH, MERGE, and CONTEXT over versioned memory. |
4 | 4 | * |
5 | 5 | * Paper: "Git Context Controller: Manage the Context of LLM-based Agents like Git" |
6 | 6 | |
▼JS/src/workspace.ts
+280-125
| @@ -39,12 +39,43 @@ const GCC_DIR = ".GCC"; | ||
39 | 39 | const now = (): string => new Date().toISOString(); |
40 | 40 | const shortId = (): string => randomBytes(4).toString("hex"); |
41 | 41 | |
42 | +/** Escape separator sequences in user-provided content to prevent parser breakage. */ | |
43 | +function sanitize(text: string): string { | |
44 | + return text.replace(/\n---\n/g, "\n\\---\n"); | |
45 | +} | |
46 | + | |
47 | +/** Reverse the escaping applied by `sanitize`. */ | |
48 | +function desanitize(text: string): string { | |
49 | + return text.replace(/\n\\---\n/g, "\n---\n"); | |
50 | +} | |
51 | + | |
52 | +/** | |
53 | + * Split markdown text on `---\n` while respecting escaped separators | |
54 | + * (`\---\n` produced by `sanitize`). After splitting, escaped separators | |
55 | + * are left in place; callers should apply `desanitize` on field values. | |
56 | + */ | |
57 | +function splitBlocks(text: string): string[] { | |
58 | + const raw = text.split("---\n"); | |
59 | + const blocks: string[] = []; | |
60 | + let i = 0; | |
61 | + while (i < raw.length) { | |
62 | + let block = raw[i]; | |
63 | + while (block.endsWith("\\") && i + 1 < raw.length) { | |
64 | + i++; | |
65 | + block = block + "---\n" + raw[i]; | |
66 | + } | |
67 | + blocks.push(block); | |
68 | + i++; | |
69 | + } | |
70 | + return blocks; | |
71 | +} | |
72 | + | |
42 | 73 | function otaToMarkdown(r: OTARecord): string { |
43 | 74 | return ( |
44 | 75 | `### Step ${r.step} — ${r.timestamp}\n` + |
45 | - `**Observation:** ${r.observation}\n\n` + | |
46 | - `**Thought:** ${r.thought}\n\n` + | |
47 | - `**Action:** ${r.action}\n\n` + | |
76 | + `**Observation:** ${sanitize(r.observation)}\n\n` + | |
77 | + `**Thought:** ${sanitize(r.thought)}\n\n` + | |
78 | + `**Action:** ${sanitize(r.action)}\n\n` + | |
48 | 79 | `---\n` |
49 | 80 | ); |
50 | 81 | } |
| @@ -53,9 +84,9 @@ function commitToMarkdown(c: CommitRecord): string { | ||
53 | 84 | return ( |
54 | 85 | `## Commit \`${c.commitId}\`\n` + |
55 | 86 | `**Timestamp:** ${c.timestamp}\n\n` + |
56 | - `**Branch Purpose:** ${c.branchPurpose}\n\n` + | |
57 | - `**Previous Progress Summary:** ${c.previousProgressSummary}\n\n` + | |
58 | - `**This Commit's Contribution:** ${c.thisCommitContribution}\n\n` + | |
87 | + `**Branch Purpose:** ${sanitize(c.branchPurpose)}\n\n` + | |
88 | + `**Previous Progress Summary:** ${sanitize(c.previousProgressSummary)}\n\n` + | |
89 | + `**This Commit's Contribution:** ${sanitize(c.thisCommitContribution)}\n\n` + | |
59 | 90 | `---\n` |
60 | 91 | ); |
61 | 92 | } |
| @@ -94,6 +125,24 @@ function makeContextResult( | ||
94 | 125 | }; |
95 | 126 | } |
96 | 127 | |
128 | +/** Raise if value is empty or whitespace-only. */ | |
129 | +function validateNotEmpty(value: string, field: string): void { | |
130 | + if (!value || !value.trim()) { | |
131 | + throw new Error(`${field} must not be empty.`); | |
132 | + } | |
133 | +} | |
134 | + | |
135 | +/** Raise if name is not a valid branch identifier. */ | |
136 | +function validateBranchName(name: string): void { | |
137 | + validateNotEmpty(name, "Branch name"); | |
138 | + if (name.includes("/") || name.includes("\\")) { | |
139 | + throw new Error(`Branch name must not contain path separators: '${name}'`); | |
140 | + } | |
141 | + if (name === "." || name === "..") { | |
142 | + throw new Error(`Branch name must not be '.' or '..': '${name}'`); | |
143 | + } | |
144 | +} | |
145 | + | |
97 | 146 | /** Manages the `.GCC/` directory structure for one agent project. */ |
98 | 147 | export class GCCWorkspace { |
99 | 148 | private readonly root: string; |
| @@ -105,6 +154,64 @@ export class GCCWorkspace { | ||
105 | 154 | this.gccDir = path.join(projectRoot, GCC_DIR); |
106 | 155 | } |
107 | 156 | |
157 | + // ------------------------------------------------------------------ // | |
158 | + // File locking // | |
159 | + // ------------------------------------------------------------------ // | |
160 | + | |
161 | + /** | |
162 | + * Acquire an exclusive lock on `.GCC/.lock` and run `fn`. | |
163 | + * | |
164 | + * Uses `O_EXLOCK` where available (macOS) and falls back to | |
165 | + * `fs.flockSync` via a tight open-with-exclusive flag retry loop | |
166 | + * on Linux/Windows. The lock is always released when `fn` returns | |
167 | + * (or throws). | |
168 | + */ | |
169 | + private withLock<T>(fn: () => T): T { | |
170 | + const lockPath = path.join(this.gccDir, ".lock"); | |
171 | + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); | |
172 | + | |
173 | + // Open (or create) the lock file. We acquire an exclusive, | |
174 | + // blocking lock by opening with O_RDWR|O_CREAT then using | |
175 | + // Node's undocumented-but-stable flock binding through the fd. | |
176 | + // | |
177 | + // Since Node does not expose flock() directly, we use the | |
178 | + // simplest portable approach: open with 'wx+' in a retry loop. | |
179 | + // On success we hold the exclusive create lock; on EEXIST we | |
180 | + // wait briefly and retry. This is a cooperative lock: all | |
181 | + // cortexa JS instances follow the same protocol. | |
182 | + const maxWait = 10_000; // 10 seconds | |
183 | + const interval = 50; // ms between retries | |
184 | + let waited = 0; | |
185 | + let fd: number; | |
186 | + | |
187 | + while (true) { | |
188 | + try { | |
189 | + // O_CREAT | O_EXCL | O_RDWR — atomic create-or-fail | |
190 | + fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_RDWR, 0o644); | |
191 | + break; | |
192 | + } catch (err: any) { | |
193 | + if (err.code !== "EEXIST") throw err; | |
194 | + if (waited >= maxWait) { | |
195 | + // Stale lock — force remove and retry once | |
196 | + try { fs.unlinkSync(lockPath); } catch { /* ignore */ } | |
197 | + fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_RDWR, 0o644); | |
198 | + break; | |
199 | + } | |
200 | + // Busy-wait (synchronous context — no async available) | |
201 | + const start = Date.now(); | |
202 | + while (Date.now() - start < interval) { /* spin */ } | |
203 | + waited += interval; | |
204 | + } | |
205 | + } | |
206 | + | |
207 | + try { | |
208 | + return fn(); | |
209 | + } finally { | |
210 | + try { fs.closeSync(fd!); } catch { /* ignore */ } | |
211 | + try { fs.unlinkSync(lockPath); } catch { /* ignore */ } | |
212 | + } | |
213 | + } | |
214 | + | |
108 | 215 | // ------------------------------------------------------------------ // |
109 | 216 | // Paths // |
110 | 217 | // ------------------------------------------------------------------ // |
| @@ -149,38 +256,43 @@ export class GCCWorkspace { | ||
149 | 256 | private parseCommits(branch: string): CommitRecord[] { |
150 | 257 | const text = this.read(this.commitPath(branch)); |
151 | 258 | const records: CommitRecord[] = []; |
152 | - for (const block of text.split("---\n")) { | |
259 | + for (const block of splitBlocks(text)) { | |
153 | 260 | const trimmed = block.trim(); |
154 | 261 | if (!trimmed) continue; |
155 | 262 | let commitId = "", |
156 | - branchPurpose = "", | |
157 | - prevSummary = "", | |
158 | - contribution = "", | |
159 | 263 | ts = ""; |
264 | + const fields: Record<string, string[]> = {}; | |
265 | + let current: string | null = null; | |
266 | + | |
160 | 267 | for (const line of trimmed.split("\n")) { |
161 | 268 | if (line.startsWith("## Commit `")) { |
162 | 269 | commitId = line.replace(/^## Commit `|`\s*$/g, ""); |
270 | + current = null; | |
163 | 271 | } else if (line.startsWith("**Timestamp:**")) { |
164 | 272 | ts = line.replace("**Timestamp:**", "").trim(); |
273 | + current = null; | |
165 | 274 | } else if (line.startsWith("**Branch Purpose:**")) { |
166 | - branchPurpose = line.replace("**Branch Purpose:**", "").trim(); | |
275 | + fields.branchPurpose = [line.replace("**Branch Purpose:**", "").trim()]; | |
276 | + current = "branchPurpose"; | |
167 | 277 | } else if (line.startsWith("**Previous Progress Summary:**")) { |
168 | - prevSummary = line | |
169 | - .replace("**Previous Progress Summary:**", "") | |
170 | - .trim(); | |
278 | + fields.prevSummary = [line.replace("**Previous Progress Summary:**", "").trim()]; | |
279 | + current = "prevSummary"; | |
171 | 280 | } else if (line.startsWith("**This Commit's Contribution:**")) { |
172 | - contribution = line | |
173 | - .replace("**This Commit's Contribution:**", "") | |
174 | - .trim(); | |
281 | + fields.contribution = [line.replace("**This Commit's Contribution:**", "").trim()]; | |
282 | + current = "contribution"; | |
283 | + } else if (current && line.trim()) { | |
284 | + fields[current].push(line.trim()); | |
175 | 285 | } |
176 | 286 | } |
177 | 287 | if (commitId) { |
288 | + const get = (key: string) => | |
289 | + desanitize((fields[key] || [""]).join("\n").trim()); | |
178 | 290 | records.push({ |
179 | 291 | commitId, |
180 | 292 | branchName: branch, |
181 | - branchPurpose, | |
182 | - previousProgressSummary: prevSummary, | |
183 | - thisCommitContribution: contribution, | |
293 | + branchPurpose: get("branchPurpose"), | |
294 | + previousProgressSummary: get("prevSummary"), | |
295 | + thisCommitContribution: get("contribution"), | |
184 | 296 | timestamp: ts, |
185 | 297 | }); |
186 | 298 | } |
| @@ -191,29 +303,39 @@ export class GCCWorkspace { | ||
191 | 303 | private parseOTA(branch: string): OTARecord[] { |
192 | 304 | const text = this.read(this.logPath(branch)); |
193 | 305 | const records: OTARecord[] = []; |
194 | - for (const block of text.split("---\n")) { | |
306 | + for (const block of splitBlocks(text)) { | |
195 | 307 | const trimmed = block.trim(); |
196 | 308 | if (!trimmed) continue; |
197 | 309 | let step = 0, |
198 | - ts = "", | |
199 | - obs = "", | |
200 | - thought = "", | |
201 | - action = ""; | |
310 | + ts = ""; | |
311 | + const fields: Record<string, string[]> = {}; | |
312 | + let current: string | null = null; | |
313 | + | |
202 | 314 | for (const line of trimmed.split("\n")) { |
203 | 315 | if (line.startsWith("### Step ")) { |
204 | 316 | const parts = line.split(" — "); |
205 | 317 | step = parseInt(parts[0]?.replace("### Step ", "") ?? "0", 10) || 0; |
206 | 318 | ts = parts[1]?.trim() ?? ""; |
319 | + current = null; | |
207 | 320 | } else if (line.startsWith("**Observation:**")) { |
208 | - obs = line.replace("**Observation:**", "").trim(); | |
321 | + fields.obs = [line.replace("**Observation:**", "").trim()]; | |
322 | + current = "obs"; | |
209 | 323 | } else if (line.startsWith("**Thought:**")) { |
210 | - thought = line.replace("**Thought:**", "").trim(); | |
324 | + fields.thought = [line.replace("**Thought:**", "").trim()]; | |
325 | + current = "thought"; | |
211 | 326 | } else if (line.startsWith("**Action:**")) { |
212 | - action = line.replace("**Action:**", "").trim(); | |
327 | + fields.action = [line.replace("**Action:**", "").trim()]; | |
328 | + current = "action"; | |
329 | + } else if (current && line.trim()) { | |
330 | + fields[current].push(line.trim()); | |
213 | 331 | } |
214 | 332 | } |
333 | + const get = (key: string) => | |
334 | + desanitize((fields[key] || [""]).join("\n").trim()); | |
335 | + const obs = get("obs"); | |
336 | + const thought = get("thought"); | |
215 | 337 | if (obs || thought) { |
216 | - records.push({ step, timestamp: ts, observation: obs, thought, action }); | |
338 | + records.push({ step, timestamp: ts, observation: obs, thought, action: get("action") }); | |
217 | 339 | } |
218 | 340 | } |
219 | 341 | return records; |
| @@ -261,21 +383,23 @@ export class GCCWorkspace { | ||
261 | 383 | } |
262 | 384 | fs.mkdirSync(this.branchDir(MAIN_BRANCH), { recursive: true }); |
263 | 385 | |
264 | - this.write( | |
265 | - this.mainMd(), | |
266 | - `# Project Roadmap\n\n**Initialized:** ${now()}\n\n${projectRoadmap}\n` | |
267 | - ); | |
268 | - this.write(this.logPath(MAIN_BRANCH), `# OTA Log — branch \`${MAIN_BRANCH}\`\n\n`); | |
269 | - this.write(this.commitPath(MAIN_BRANCH), `# Commit History — branch \`${MAIN_BRANCH}\`\n\n`); | |
270 | - | |
271 | - const meta: BranchMetadata = { | |
272 | - name: MAIN_BRANCH, | |
273 | - purpose: "Primary reasoning trajectory", | |
274 | - createdFrom: "", | |
275 | - createdAt: now(), | |
276 | - status: "active", | |
277 | - }; | |
278 | - this.write(this.metaPath(MAIN_BRANCH), this.dumpMeta(meta)); | |
386 | + this.withLock(() => { | |
387 | + this.write( | |
388 | + this.mainMd(), | |
389 | + `# Project Roadmap\n\n**Initialized:** ${now()}\n\n${projectRoadmap}\n` | |
390 | + ); | |
391 | + this.write(this.logPath(MAIN_BRANCH), `# OTA Log — branch \`${MAIN_BRANCH}\`\n\n`); | |
392 | + this.write(this.commitPath(MAIN_BRANCH), `# Commit History — branch \`${MAIN_BRANCH}\`\n\n`); | |
393 | + | |
394 | + const meta: BranchMetadata = { | |
395 | + name: MAIN_BRANCH, | |
396 | + purpose: "Primary reasoning trajectory", | |
397 | + createdFrom: "", | |
398 | + createdAt: now(), | |
399 | + status: "active", | |
400 | + }; | |
401 | + this.write(this.metaPath(MAIN_BRANCH), this.dumpMeta(meta)); | |
402 | + }); | |
279 | 403 | this._currentBranch = MAIN_BRANCH; |
280 | 404 | } |
281 | 405 | |
| @@ -296,16 +420,21 @@ export class GCCWorkspace { | ||
296 | 420 | * The paper logs continuous Observation–Thought–Action cycles. |
297 | 421 | */ |
298 | 422 | logOTA(observation: string, thought: string, action: string): OTARecord { |
299 | - const existing = this.parseOTA(this._currentBranch); | |
300 | - const record: OTARecord = { | |
301 | - step: existing.length + 1, | |
302 | - timestamp: now(), | |
303 | - observation, | |
304 | - thought, | |
305 | - action, | |
306 | - }; | |
307 | - this.append(this.logPath(this._currentBranch), otaToMarkdown(record)); | |
308 | - return record; | |
423 | + if (!observation?.trim() && !thought?.trim() && !action?.trim()) { | |
424 | + throw new Error("At least one of observation, thought, or action must be non-empty."); | |
425 | + } | |
426 | + return this.withLock(() => { | |
427 | + const existing = this.parseOTA(this._currentBranch); | |
428 | + const record: OTARecord = { | |
429 | + step: existing.length + 1, | |
430 | + timestamp: now(), | |
431 | + observation, | |
432 | + thought, | |
433 | + action, | |
434 | + }; | |
435 | + this.append(this.logPath(this._currentBranch), otaToMarkdown(record)); | |
436 | + return record; | |
437 | + }); | |
309 | 438 | } |
310 | 439 | |
311 | 440 | /** |
| @@ -318,33 +447,36 @@ export class GCCWorkspace { | ||
318 | 447 | previousSummary?: string, |
319 | 448 | updateRoadmap?: string |
320 | 449 | ): CommitRecord { |
321 | - const meta = this.parseMeta(this._currentBranch); | |
322 | - const branchPurpose = meta?.purpose ?? ""; | |
323 | - | |
324 | - let prevSummary = previousSummary; | |
325 | - if (!prevSummary) { | |
326 | - const commits = this.parseCommits(this._currentBranch); | |
327 | - prevSummary = | |
328 | - commits.at(-1)?.thisCommitContribution ?? | |
329 | - "Initial state — no prior commits."; | |
330 | - } | |
450 | + validateNotEmpty(contribution, "Contribution"); | |
451 | + return this.withLock(() => { | |
452 | + const meta = this.parseMeta(this._currentBranch); | |
453 | + const branchPurpose = meta?.purpose ?? ""; | |
454 | + | |
455 | + let prevSummary = previousSummary; | |
456 | + if (!prevSummary) { | |
457 | + const commits = this.parseCommits(this._currentBranch); | |
458 | + prevSummary = | |
459 | + commits.at(-1)?.thisCommitContribution ?? | |
460 | + "Initial state — no prior commits."; | |
461 | + } | |
331 | 462 | |
332 | - const record: CommitRecord = { | |
333 | - commitId: shortId(), | |
334 | - branchName: this._currentBranch, | |
335 | - branchPurpose, | |
336 | - previousProgressSummary: prevSummary, | |
337 | - thisCommitContribution: contribution, | |
338 | - timestamp: now(), | |
339 | - }; | |
463 | + const record: CommitRecord = { | |
464 | + commitId: shortId(), | |
465 | + branchName: this._currentBranch, | |
466 | + branchPurpose, | |
467 | + previousProgressSummary: prevSummary, | |
468 | + thisCommitContribution: contribution, | |
469 | + timestamp: now(), | |
470 | + }; | |
340 | 471 | |
341 | - this.append(this.commitPath(this._currentBranch), commitToMarkdown(record)); | |
472 | + this.append(this.commitPath(this._currentBranch), commitToMarkdown(record)); | |
342 | 473 | |
343 | - if (updateRoadmap) { | |
344 | - this.append(this.mainMd(), `\n## Update (${record.timestamp})\n${updateRoadmap}\n`); | |
345 | - } | |
474 | + if (updateRoadmap) { | |
475 | + this.append(this.mainMd(), `\n## Update (${record.timestamp})\n${updateRoadmap}\n`); | |
476 | + } | |
346 | 477 | |
347 | - return record; | |
478 | + return record; | |
479 | + }); | |
348 | 480 | } |
349 | 481 | |
350 | 482 | /** |
| @@ -352,21 +484,26 @@ export class GCCWorkspace { | ||
352 | 484 | * Creates isolated workspace: B_t^(name) = BRANCH(M_{t-1}). |
353 | 485 | */ |
354 | 486 | branch(name: string, purpose: string): void { |
487 | + validateBranchName(name); | |
488 | + validateNotEmpty(purpose, "Branch purpose"); | |
355 | 489 | if (fs.existsSync(this.branchDir(name))) { |
356 | 490 | throw new Error(`Branch '${name}' already exists.`); |
357 | 491 | } |
358 | 492 | fs.mkdirSync(this.branchDir(name), { recursive: true }); |
359 | - this.write(this.logPath(name), `# OTA Log — branch \`${name}\`\n\n`); | |
360 | - this.write(this.commitPath(name), `# Commit History — branch \`${name}\`\n\n`); | |
361 | - | |
362 | - const meta: BranchMetadata = { | |
363 | - name, | |
364 | - purpose, | |
365 | - createdFrom: this._currentBranch, | |
366 | - createdAt: now(), | |
367 | - status: "active", | |
368 | - }; | |
369 | - this.write(this.metaPath(name), this.dumpMeta(meta)); | |
493 | + | |
494 | + this.withLock(() => { | |
495 | + this.write(this.logPath(name), `# OTA Log — branch \`${name}\`\n\n`); | |
496 | + this.write(this.commitPath(name), `# Commit History — branch \`${name}\`\n\n`); | |
497 | + | |
498 | + const meta: BranchMetadata = { | |
499 | + name, | |
500 | + purpose, | |
501 | + createdFrom: this._currentBranch, | |
502 | + createdAt: now(), | |
503 | + status: "active", | |
504 | + }; | |
505 | + this.write(this.metaPath(name), this.dumpMeta(meta)); | |
506 | + }); | |
370 | 507 | this._currentBranch = name; |
371 | 508 | } |
372 | 509 | |
| @@ -379,6 +516,8 @@ export class GCCWorkspace { | ||
379 | 516 | summary?: string, |
380 | 517 | target: string = MAIN_BRANCH |
381 | 518 | ): CommitRecord { |
519 | + validateBranchName(branchName); | |
520 | + validateBranchName(target); | |
382 | 521 | if (!fs.existsSync(this.branchDir(branchName))) { |
383 | 522 | throw new Error(`Branch '${branchName}' not found.`); |
384 | 523 | } |
| @@ -386,42 +525,52 @@ export class GCCWorkspace { | ||
386 | 525 | throw new Error(`Target branch '${target}' not found.`); |
387 | 526 | } |
388 | 527 | |
389 | - const branchCommits = this.parseCommits(branchName); | |
390 | - const branchOTA = this.parseOTA(branchName); | |
391 | - const meta = this.parseMeta(branchName); | |
392 | - | |
393 | - const mergeSummary = | |
394 | - summary ?? | |
395 | - `Merged branch \`${branchName}\` (${branchCommits.length} commits). Contributions: ${branchCommits | |
396 | - .map((c) => c.thisCommitContribution) | |
397 | - .join(" | ")}`; | |
398 | - | |
399 | - if (branchOTA.length > 0) { | |
400 | - this.append( | |
401 | - this.logPath(target), | |
402 | - `\n## Merged from \`${branchName}\` (${now()})\n\n` | |
403 | - ); | |
404 | - for (const rec of branchOTA) { | |
405 | - this.append(this.logPath(target), otaToMarkdown(rec)); | |
528 | + let mergeSummary: string; | |
529 | + let metaCopy: BranchMetadata | undefined; | |
530 | + | |
531 | + this.withLock(() => { | |
532 | + const branchCommits = this.parseCommits(branchName); | |
533 | + const branchOTA = this.parseOTA(branchName); | |
534 | + const meta = this.parseMeta(branchName); | |
535 | + metaCopy = meta; | |
536 | + | |
537 | + mergeSummary = | |
538 | + summary ?? | |
539 | + `Merged branch \`${branchName}\` (${branchCommits.length} commits). Contributions: ${branchCommits | |
540 | + .map((c) => c.thisCommitContribution) | |
541 | + .join(" | ")}`; | |
542 | + | |
543 | + if (branchOTA.length > 0) { | |
544 | + this.append( | |
545 | + this.logPath(target), | |
546 | + `\n## Merged from \`${branchName}\` (${now()})\n\n` | |
547 | + ); | |
548 | + for (const rec of branchOTA) { | |
549 | + this.append(this.logPath(target), otaToMarkdown(rec)); | |
550 | + } | |
406 | 551 | } |
407 | - } | |
408 | 552 | |
409 | - this._currentBranch = target; | |
553 | + this._currentBranch = target; | |
554 | + }); | |
555 | + | |
556 | + // commit() acquires the lock internally | |
410 | 557 | const mergeCommit = this.commit( |
411 | - mergeSummary, | |
412 | - `Merging branch \`${branchName}\` with purpose: ${meta?.purpose ?? ""}`, | |
413 | - mergeSummary | |
558 | + mergeSummary!, | |
559 | + `Merging branch \`${branchName}\` with purpose: ${metaCopy?.purpose ?? ""}`, | |
560 | + mergeSummary! | |
414 | 561 | ); |
415 | 562 | |
416 | - if (meta) { | |
417 | - const updated: BranchMetadata = { | |
418 | - ...meta, | |
419 | - status: "merged", | |
420 | - mergedInto: target, | |
421 | - mergedAt: now(), | |
422 | - }; | |
423 | - this.write(this.metaPath(branchName), this.dumpMeta(updated)); | |
424 | - } | |
563 | + this.withLock(() => { | |
564 | + if (metaCopy) { | |
565 | + const updated: BranchMetadata = { | |
566 | + ...metaCopy, | |
567 | + status: "merged", | |
568 | + mergedInto: target, | |
569 | + mergedAt: now(), | |
570 | + }; | |
571 | + this.write(this.metaPath(branchName), this.dumpMeta(updated)); | |
572 | + } | |
573 | + }); | |
425 | 574 | |
426 | 575 | return mergeCommit; |
427 | 576 | } |
| @@ -431,18 +580,23 @@ export class GCCWorkspace { | ||
431 | 580 | * Retrieves history at K-commit resolution. Paper default: K=1. |
432 | 581 | */ |
433 | 582 | context(branch?: string, k = 1): ContextResult { |
583 | + if (k < 1) { | |
584 | + throw new Error(`k must be >= 1, got ${k}`); | |
585 | + } | |
434 | 586 | const target = branch ?? this._currentBranch; |
435 | 587 | if (!fs.existsSync(this.branchDir(target))) { |
436 | 588 | throw new Error(`Branch '${target}' not found.`); |
437 | 589 | } |
438 | 590 | |
439 | - const allCommits = this.parseCommits(target); | |
440 | - const commits = allCommits.slice(-k); | |
441 | - const otaRecords = this.parseOTA(target); | |
442 | - const mainRoadmap = this.read(this.mainMd()); | |
443 | - const metadata = this.parseMeta(target); | |
591 | + return this.withLock(() => { | |
592 | + const allCommits = this.parseCommits(target); | |
593 | + const commits = allCommits.slice(-k); | |
594 | + const otaRecords = this.parseOTA(target); | |
595 | + const mainRoadmap = this.read(this.mainMd()); | |
596 | + const metadata = this.parseMeta(target); | |
444 | 597 | |
445 | - return makeContextResult(target, k, commits, otaRecords, mainRoadmap, metadata); | |
598 | + return makeContextResult(target, k, commits, otaRecords, mainRoadmap, metadata); | |
599 | + }); | |
446 | 600 | } |
447 | 601 | |
448 | 602 | // ------------------------------------------------------------------ // |
| @@ -454,6 +608,7 @@ export class GCCWorkspace { | ||
454 | 608 | } |
455 | 609 | |
456 | 610 | switchBranch(name: string): void { |
611 | + validateBranchName(name); | |
457 | 612 | if (!fs.existsSync(this.branchDir(name))) { |
458 | 613 | throw new Error(`Branch '${name}' does not exist.`); |
459 | 614 | } |
460 | 615 | |
▼JS/test.ts
+1-1
| @@ -1,5 +1,5 @@ | ||
1 | 1 | /** |
2 | - * Tests for contexa TypeScript package. | |
2 | + * Tests for cortexa TypeScript package. | |
3 | 3 | * Run with: tsx --test test.ts |
4 | 4 | */ |
5 | 5 | |
6 | 6 | |