refactor(rust): rename crate from 'contexa' to 'cortexa'
Swadhin Biswas committed 7 months ago
ff86ca4
1 parent af28f1c
commit ff86ca4
7 files changed
+255 -53
Showing 7 changed files+255-53
▼RUST/Cargo.lock
+40-7
| @@ -66,10 +66,17 @@ dependencies = [ | ||
66 | 66 | ] |
67 | 67 | |
68 | 68 | [[package]] |
69 | -name = "contexa" | |
69 | +name = "core-foundation-sys" | |
70 | +version = "0.8.7" | |
71 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
72 | +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" | |
73 | + | |
74 | +[[package]] | |
75 | +name = "cortexa" | |
70 | 76 | version = "0.1.1" |
71 | 77 | dependencies = [ |
72 | 78 | "chrono", |
79 | + "fs2", | |
73 | 80 | "serde", |
74 | 81 | "serde_yaml", |
75 | 82 | "tempfile", |
| @@ -77,12 +84,6 @@ dependencies = [ | ||
77 | 84 | "uuid", |
78 | 85 | ] |
79 | 86 | |
80 | -[[package]] | |
81 | -name = "core-foundation-sys" | |
82 | -version = "0.8.7" | |
83 | -source = "registry+https://github.com/rust-lang/crates.io-index" | |
84 | -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" | |
85 | - | |
86 | 87 | [[package]] |
87 | 88 | name = "equivalent" |
88 | 89 | version = "1.0.2" |
| @@ -117,6 +118,16 @@ version = "0.1.5" | ||
117 | 118 | source = "registry+https://github.com/rust-lang/crates.io-index" |
118 | 119 | checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" |
119 | 120 | |
121 | +[[package]] | |
122 | +name = "fs2" | |
123 | +version = "0.4.3" | |
124 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
125 | +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" | |
126 | +dependencies = [ | |
127 | + "libc", | |
128 | + "winapi", | |
129 | +] | |
130 | + | |
120 | 131 | [[package]] |
121 | 132 | name = "getrandom" |
122 | 133 | version = "0.4.2" |
| @@ -551,6 +562,28 @@ dependencies = [ | ||
551 | 562 | "semver", |
552 | 563 | ] |
553 | 564 | |
565 | +[[package]] | |
566 | +name = "winapi" | |
567 | +version = "0.3.9" | |
568 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
569 | +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" | |
570 | +dependencies = [ | |
571 | + "winapi-i686-pc-windows-gnu", | |
572 | + "winapi-x86_64-pc-windows-gnu", | |
573 | +] | |
574 | + | |
575 | +[[package]] | |
576 | +name = "winapi-i686-pc-windows-gnu" | |
577 | +version = "0.4.0" | |
578 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
579 | +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" | |
580 | + | |
581 | +[[package]] | |
582 | +name = "winapi-x86_64-pc-windows-gnu" | |
583 | +version = "0.4.0" | |
584 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
585 | +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" | |
586 | + | |
554 | 587 | [[package]] |
555 | 588 | name = "windows-core" |
556 | 589 | version = "0.62.2" |
557 | 590 | |
▼RUST/Cargo.toml
+4-3
| @@ -1,5 +1,5 @@ | ||
1 | 1 | [package] |
2 | -name = "contexa" | |
2 | +name = "cortexa" | |
3 | 3 | version = "0.1.1" |
4 | 4 | edition = "2021" |
5 | 5 | description = "Git-inspired context management for LLM agents -- COMMIT, BRANCH, MERGE, and CONTEXT over versioned memory (arXiv:2508.00031)" |
| @@ -8,8 +8,8 @@ authors = ["Swadhin Biswas <[email protected]>"] | ||
8 | 8 | keywords = ["llm", "agent", "context-management", "git", "memory"] |
9 | 9 | categories = ["data-structures", "filesystem"] |
10 | 10 | readme = "README.md" |
11 | -repository = "https://github.com/swadhinbiswas/contexa" | |
12 | -homepage = "https://github.com/swadhinbiswas/contexa" | |
11 | +repository = "https://github.com/swadhinbiswas/Cortexa" | |
12 | +homepage = "https://github.com/swadhinbiswas/Cortexa" | |
13 | 13 | |
14 | 14 | [dependencies] |
15 | 15 | serde = { version = "1", features = ["derive"] } |
| @@ -17,6 +17,7 @@ serde_yaml = "0.9" | ||
17 | 17 | chrono = { version = "0.4", features = ["serde"] } |
18 | 18 | uuid = { version = "1", features = ["v4"] } |
19 | 19 | thiserror = "1" |
20 | +fs2 = "0.4" | |
20 | 21 | |
21 | 22 | [dev-dependencies] |
22 | 23 | tempfile = "3" |
23 | 24 | |
▼RUST/README.md
+13-13
| @@ -1,13 +1,13 @@ | ||
1 | -# contexa | |
1 | +# cortexa | |
2 | 2 | |
3 | -[](https://crates.io/crates/contexa) | |
4 | -[](https://docs.rs/contexa) | |
3 | +[](https://crates.io/crates/cortexa) | |
4 | +[](https://docs.rs/cortexa) | |
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 | -Rust implementation of the **contexa** framework. | |
10 | +Rust 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 | |
| @@ -17,7 +17,7 @@ Based on: [arXiv:2508.00031](https://arxiv.org/abs/2508.00031) -- *"Git Context | ||
17 | 17 | |
18 | 18 | ```toml |
19 | 19 | [dependencies] |
20 | -contexa = "0.1" | |
20 | +cortexa = "0.1" | |
21 | 21 | ``` |
22 | 22 | |
23 | 23 | --- |
| @@ -25,9 +25,9 @@ contexa = "0.1" | ||
25 | 25 | ## Quick Start |
26 | 26 | |
27 | 27 | ```rust |
28 | -use contexa::GCCWorkspace; | |
28 | +use cortexa::GCCWorkspace; | |
29 | 29 | |
30 | -fn main() -> contexa::Result<()> { | |
30 | +fn main() -> cortexa::Result<()> { | |
31 | 31 | let mut ws = GCCWorkspace::new("/path/to/project"); |
32 | 32 | ws.init("Build a REST API service with user auth")?; |
33 | 33 | |
| @@ -172,8 +172,8 @@ All data is stored as **human-readable Markdown and YAML**. | ||
172 | 172 | ## Running Tests |
173 | 173 | |
174 | 174 | ```bash |
175 | -git clone https://github.com/swadhinbiswas/contexa.git | |
176 | -cd contexa/RUST | |
175 | +git clone https://github.com/swadhinbiswas/Cortexa.git | |
176 | +cd cortexa/RUST | |
177 | 177 | cargo test |
178 | 178 | ``` |
179 | 179 | |
| @@ -211,8 +211,8 @@ MIT License. See [LICENSE](LICENSE) for details. | ||
211 | 211 | |
212 | 212 | ## Links |
213 | 213 | |
214 | -- [GitHub Repository](https://github.com/swadhinbiswas/contexa) | |
215 | -- [crates.io](https://crates.io/crates/contexa) | |
216 | -- [docs.rs](https://docs.rs/contexa) | |
214 | +- [GitHub Repository](https://github.com/swadhinbiswas/Cortexa) | |
215 | +- [crates.io](https://crates.io/crates/cortexa) | |
216 | +- [docs.rs](https://docs.rs/cortexa) | |
217 | 217 | - [Original Paper](https://arxiv.org/abs/2508.00031) |
218 | 218 | - [Author: Swadhin Biswas](https://github.com/swadhinbiswas) |
219 | 219 | |
▼RUST/src/error.rs
+6-0
| @@ -14,6 +14,12 @@ pub enum GCCError { | ||
14 | 14 | #[error("Branch '{name}' not found")] |
15 | 15 | BranchNotFound { name: String }, |
16 | 16 | |
17 | + #[error("Failed to acquire lock: {0}")] | |
18 | + Lock(String), | |
19 | + | |
20 | + #[error("Validation error: {0}")] | |
21 | + Validation(String), | |
22 | + | |
17 | 23 | #[error("IO error: {0}")] |
18 | 24 | Io(#[from] std::io::Error), |
19 | 25 | |
20 | 26 | |
▼RUST/src/lib.rs
+2-2
| @@ -1,4 +1,4 @@ | ||
1 | -//! # contexa | |
1 | +//! # cortexa | |
2 | 2 | //! |
3 | 3 | //! Git-inspired context management for LLM agents. COMMIT, BRANCH, MERGE, and |
4 | 4 | //! CONTEXT operations over a persistent versioned memory workspace. |
| @@ -21,7 +21,7 @@ | ||
21 | 21 | //! |
22 | 22 | //! ## Example |
23 | 23 | //! ```rust,no_run |
24 | -//! use contexa::GCCWorkspace; | |
24 | +//! use cortexa::GCCWorkspace; | |
25 | 25 | //! |
26 | 26 | //! let mut ws = GCCWorkspace::new("/path/to/project"); |
27 | 27 | //! ws.init("Build a production REST API").unwrap(); |
28 | 28 | |
▼RUST/src/models.rs
+40-4
| @@ -2,6 +2,38 @@ | ||
2 | 2 | |
3 | 3 | use serde::{Deserialize, Serialize}; |
4 | 4 | |
5 | +/// Escape the `---` block separator so user content cannot break parsers. | |
6 | +pub fn sanitize(text: &str) -> String { | |
7 | + text.replace("\n---\n", "\n\\---\n") | |
8 | +} | |
9 | + | |
10 | +/// Reverse the escaping applied by [`sanitize`]. | |
11 | +pub fn desanitize(text: &str) -> String { | |
12 | + text.replace("\n\\---\n", "\n---\n") | |
13 | +} | |
14 | + | |
15 | +/// Split markdown text on `---\n` separators while respecting escaped | |
16 | +/// separators (`\---\n` produced by [`sanitize`]). After splitting, | |
17 | +/// the escaped backslash is left in place; callers should apply | |
18 | +/// [`desanitize`] on individual field values. | |
19 | +pub fn split_blocks(text: &str) -> Vec<String> { | |
20 | + let raw: Vec<&str> = text.split("---\n").collect(); | |
21 | + let mut blocks: Vec<String> = Vec::new(); | |
22 | + let mut i = 0; | |
23 | + while i < raw.len() { | |
24 | + let mut block = raw[i].to_string(); | |
25 | + // If block ends with '\', the `---\n` was an escaped separator — rejoin. | |
26 | + while block.ends_with('\\') && i + 1 < raw.len() { | |
27 | + i += 1; | |
28 | + block.push_str("---\n"); | |
29 | + block.push_str(raw[i]); | |
30 | + } | |
31 | + blocks.push(block); | |
32 | + i += 1; | |
33 | + } | |
34 | + blocks | |
35 | +} | |
36 | + | |
5 | 37 | /// A single Observation–Thought–Action step logged to `log.md`. |
6 | 38 | /// The paper continuously logs OTA cycles as the agent executes. |
7 | 39 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| @@ -17,7 +49,11 @@ impl OTARecord { | ||
17 | 49 | pub fn to_markdown(&self) -> String { |
18 | 50 | format!( |
19 | 51 | "### Step {} — {}\n**Observation:** {}\n\n**Thought:** {}\n\n**Action:** {}\n\n---\n", |
20 | - self.step, self.timestamp, self.observation, self.thought, self.action | |
52 | + self.step, | |
53 | + self.timestamp, | |
54 | + sanitize(&self.observation), | |
55 | + sanitize(&self.thought), | |
56 | + sanitize(&self.action), | |
21 | 57 | ) |
22 | 58 | } |
23 | 59 | } |
| @@ -42,9 +78,9 @@ impl CommitRecord { | ||
42 | 78 | **This Commit's Contribution:** {}\n\n---\n", |
43 | 79 | self.commit_id, |
44 | 80 | self.timestamp, |
45 | - self.branch_purpose, | |
46 | - self.previous_progress_summary, | |
47 | - self.this_commit_contribution, | |
81 | + sanitize(&self.branch_purpose), | |
82 | + sanitize(&self.previous_progress_summary), | |
83 | + sanitize(&self.this_commit_contribution), | |
48 | 84 | ) |
49 | 85 | } |
50 | 86 | } |
51 | 87 | |
▼RUST/src/workspace.rs
+150-24
| @@ -4,10 +4,13 @@ use std::fs; | ||
4 | 4 | use std::path::{Path, PathBuf}; |
5 | 5 | |
6 | 6 | use chrono::Utc; |
7 | +use fs2::FileExt; | |
7 | 8 | use uuid::Uuid; |
8 | 9 | |
9 | 10 | use crate::error::{GCCError, Result}; |
10 | -use crate::models::{BranchMetadata, CommitRecord, ContextResult, OTARecord}; | |
11 | +use crate::models::{ | |
12 | + desanitize, split_blocks, BranchMetadata, CommitRecord, ContextResult, OTARecord, | |
13 | +}; | |
11 | 14 | |
12 | 15 | const MAIN_BRANCH: &str = "main"; |
13 | 16 | const GCC_DIR: &str = ".GCC"; |
| @@ -20,6 +23,30 @@ fn short_id() -> String { | ||
20 | 23 | Uuid::new_v4().to_string()[..8].to_string() |
21 | 24 | } |
22 | 25 | |
26 | +/// Return `Err(Validation)` if `value` is empty or whitespace-only. | |
27 | +fn validate_not_empty(value: &str, field: &str) -> Result<()> { | |
28 | + if value.trim().is_empty() { | |
29 | + return Err(GCCError::Validation(format!("{field} must not be empty"))); | |
30 | + } | |
31 | + Ok(()) | |
32 | +} | |
33 | + | |
34 | +/// Return `Err(Validation)` if `name` is not a valid branch identifier. | |
35 | +fn validate_branch_name(name: &str) -> Result<()> { | |
36 | + validate_not_empty(name, "Branch name")?; | |
37 | + if name.contains('/') || name.contains('\\') { | |
38 | + return Err(GCCError::Validation(format!( | |
39 | + "Branch name must not contain path separators: {name:?}" | |
40 | + ))); | |
41 | + } | |
42 | + if name == "." || name == ".." { | |
43 | + return Err(GCCError::Validation(format!( | |
44 | + "Branch name must not be '.' or '..': {name:?}" | |
45 | + ))); | |
46 | + } | |
47 | + Ok(()) | |
48 | +} | |
49 | + | |
23 | 50 | /// Manages the `.GCC/` directory structure for one agent project. |
24 | 51 | /// |
25 | 52 | /// Implements the four GCC commands from arXiv:2508.00031v2: |
| @@ -28,17 +55,42 @@ fn short_id() -> String { | ||
28 | 55 | /// - MERGE (§3.4): synthesise divergent paths |
29 | 56 | /// - CONTEXT (§3.5): hierarchical memory retrieval |
30 | 57 | pub struct GCCWorkspace { |
31 | - root: PathBuf, | |
32 | 58 | gcc_dir: PathBuf, |
33 | 59 | current_branch: String, |
34 | 60 | } |
35 | 61 | |
62 | +/// RAII file lock guard. Acquires exclusive lock on creation, releases on drop. | |
63 | +struct FileLock { | |
64 | + _file: fs::File, | |
65 | +} | |
66 | + | |
67 | +impl FileLock { | |
68 | + fn acquire(gcc_dir: &Path) -> Result<Self> { | |
69 | + let lock_path = gcc_dir.join(".lock"); | |
70 | + if let Some(parent) = lock_path.parent() { | |
71 | + fs::create_dir_all(parent)?; | |
72 | + } | |
73 | + let file = fs::OpenOptions::new() | |
74 | + .create(true) | |
75 | + .read(true) | |
76 | + .write(true) | |
77 | + .open(&lock_path)?; | |
78 | + file.lock_exclusive() | |
79 | + .map_err(|e| GCCError::Lock(e.to_string()))?; | |
80 | + Ok(Self { _file: file }) | |
81 | + } | |
82 | +} | |
83 | + | |
84 | +impl Drop for FileLock { | |
85 | + fn drop(&mut self) { | |
86 | + let _ = self._file.unlock(); | |
87 | + } | |
88 | +} | |
89 | + | |
36 | 90 | impl GCCWorkspace { |
37 | 91 | pub fn new(project_root: impl AsRef<Path>) -> Self { |
38 | - let root = project_root.as_ref().to_path_buf(); | |
39 | - let gcc_dir = root.join(GCC_DIR); | |
92 | + let gcc_dir = project_root.as_ref().join(GCC_DIR); | |
40 | 93 | Self { |
41 | - root, | |
42 | 94 | gcc_dir, |
43 | 95 | current_branch: MAIN_BRANCH.to_string(), |
44 | 96 | } |
| @@ -97,45 +149,65 @@ impl GCCWorkspace { | ||
97 | 149 | fn read_commits(&self, branch: &str) -> Vec<CommitRecord> { |
98 | 150 | let text = self.read(&self.commit_path(branch)); |
99 | 151 | let mut records = Vec::new(); |
100 | - for block in text.split("---\n") { | |
152 | + for block in split_blocks(&text) { | |
101 | 153 | let block = block.trim(); |
102 | 154 | if block.is_empty() { |
103 | 155 | continue; |
104 | 156 | } |
105 | 157 | let mut commit_id = String::new(); |
106 | - let mut branch_purpose = String::new(); | |
107 | - let mut prev_summary = String::new(); | |
108 | - let mut contribution = String::new(); | |
109 | 158 | let mut ts = String::new(); |
159 | + let mut current_field: Option<&str> = None; | |
160 | + let mut fields: std::collections::HashMap<&str, String> = | |
161 | + std::collections::HashMap::new(); | |
162 | + | |
110 | 163 | for line in block.lines() { |
111 | 164 | if line.starts_with("## Commit `") { |
112 | 165 | commit_id = line |
113 | 166 | .trim_start_matches("## Commit `") |
114 | 167 | .trim_end_matches('`') |
115 | 168 | .to_string(); |
169 | + current_field = None; | |
116 | 170 | } else if line.starts_with("**Timestamp:**") { |
117 | 171 | ts = line.replace("**Timestamp:**", "").trim().to_string(); |
172 | + current_field = None; | |
118 | 173 | } else if line.starts_with("**Branch Purpose:**") { |
119 | - branch_purpose = line.replace("**Branch Purpose:**", "").trim().to_string(); | |
174 | + let val = line.replace("**Branch Purpose:**", "").trim().to_string(); | |
175 | + fields.insert("branch_purpose", val); | |
176 | + current_field = Some("branch_purpose"); | |
120 | 177 | } else if line.starts_with("**Previous Progress Summary:**") { |
121 | - prev_summary = line | |
178 | + let val = line | |
122 | 179 | .replace("**Previous Progress Summary:**", "") |
123 | 180 | .trim() |
124 | 181 | .to_string(); |
182 | + fields.insert("prev_summary", val); | |
183 | + current_field = Some("prev_summary"); | |
125 | 184 | } else if line.starts_with("**This Commit's Contribution:**") { |
126 | - contribution = line | |
185 | + let val = line | |
127 | 186 | .replace("**This Commit's Contribution:**", "") |
128 | 187 | .trim() |
129 | 188 | .to_string(); |
189 | + fields.insert("contribution", val); | |
190 | + current_field = Some("contribution"); | |
191 | + } else if let Some(field) = current_field { | |
192 | + let trimmed = line.trim(); | |
193 | + if !trimmed.is_empty() { | |
194 | + if let Some(existing) = fields.get_mut(field) { | |
195 | + existing.push('\n'); | |
196 | + existing.push_str(trimmed); | |
197 | + } | |
198 | + } | |
130 | 199 | } |
131 | 200 | } |
132 | 201 | if !commit_id.is_empty() { |
202 | + let get = |key: &str| -> String { | |
203 | + desanitize(fields.get(key).map(|s| s.trim()).unwrap_or("")) | |
204 | + }; | |
133 | 205 | records.push(CommitRecord { |
134 | 206 | commit_id, |
135 | 207 | branch_name: branch.to_string(), |
136 | - branch_purpose, | |
137 | - previous_progress_summary: prev_summary, | |
138 | - this_commit_contribution: contribution, | |
208 | + branch_purpose: get("branch_purpose"), | |
209 | + previous_progress_summary: get("prev_summary"), | |
210 | + this_commit_contribution: get("contribution"), | |
139 | 211 | timestamp: ts, |
140 | 212 | }); |
141 | 213 | } |
| @@ -146,16 +218,17 @@ impl GCCWorkspace { | ||
146 | 218 | fn read_ota(&self, branch: &str) -> Vec<OTARecord> { |
147 | 219 | let text = self.read(&self.log_path(branch)); |
148 | 220 | let mut records = Vec::new(); |
149 | - for block in text.split("---\n") { | |
221 | + for block in split_blocks(&text) { | |
150 | 222 | let block = block.trim(); |
151 | 223 | if block.is_empty() { |
152 | 224 | continue; |
153 | 225 | } |
154 | 226 | let mut step = 0usize; |
155 | 227 | let mut ts = String::new(); |
156 | - let mut obs = String::new(); | |
157 | - let mut thought = String::new(); | |
158 | - let mut action = String::new(); | |
228 | + let mut current_field: Option<&str> = None; | |
229 | + let mut fields: std::collections::HashMap<&str, String> = | |
230 | + std::collections::HashMap::new(); | |
231 | + | |
159 | 232 | for line in block.lines() { |
160 | 233 | if line.starts_with("### Step ") { |
161 | 234 | let parts: Vec<&str> = line.splitn(2, '—').collect(); |
| @@ -165,21 +238,41 @@ impl GCCWorkspace { | ||
165 | 238 | .parse() |
166 | 239 | .unwrap_or(0); |
167 | 240 | ts = parts.get(1).unwrap_or(&"").trim().to_string(); |
241 | + current_field = None; | |
168 | 242 | } else if line.starts_with("**Observation:**") { |
169 | - obs = line.replace("**Observation:**", "").trim().to_string(); | |
243 | + let val = line.replace("**Observation:**", "").trim().to_string(); | |
244 | + fields.insert("obs", val); | |
245 | + current_field = Some("obs"); | |
170 | 246 | } else if line.starts_with("**Thought:**") { |
171 | - thought = line.replace("**Thought:**", "").trim().to_string(); | |
247 | + let val = line.replace("**Thought:**", "").trim().to_string(); | |
248 | + fields.insert("thought", val); | |
249 | + current_field = Some("thought"); | |
172 | 250 | } else if line.starts_with("**Action:**") { |
173 | - action = line.replace("**Action:**", "").trim().to_string(); | |
251 | + let val = line.replace("**Action:**", "").trim().to_string(); | |
252 | + fields.insert("action", val); | |
253 | + current_field = Some("action"); | |
254 | + } else if let Some(field) = current_field { | |
255 | + let trimmed = line.trim(); | |
256 | + if !trimmed.is_empty() { | |
257 | + if let Some(existing) = fields.get_mut(field) { | |
258 | + existing.push('\n'); | |
259 | + existing.push_str(trimmed); | |
260 | + } | |
261 | + } | |
174 | 262 | } |
175 | 263 | } |
264 | + let get = |key: &str| -> String { | |
265 | + desanitize(fields.get(key).map(|s| s.trim()).unwrap_or("")) | |
266 | + }; | |
267 | + let obs = get("obs"); | |
268 | + let thought = get("thought"); | |
176 | 269 | if !obs.is_empty() || !thought.is_empty() { |
177 | 270 | records.push(OTARecord { |
178 | 271 | step, |
179 | 272 | timestamp: ts, |
180 | 273 | observation: obs, |
181 | 274 | thought, |
182 | - action, | |
275 | + action: get("action"), | |
183 | 276 | }); |
184 | 277 | } |
185 | 278 | } |
| @@ -209,6 +302,8 @@ impl GCCWorkspace { | ||
209 | 302 | let main_dir = self.branch_dir(MAIN_BRANCH); |
210 | 303 | fs::create_dir_all(&main_dir)?; |
211 | 304 | |
305 | + let _lock = FileLock::acquire(&self.gcc_dir)?; | |
306 | + | |
212 | 307 | // main.md — global roadmap |
213 | 308 | let roadmap = format!( |
214 | 309 | "# Project Roadmap\n\n**Initialized:** {}\n\n{}\n", |
| @@ -261,6 +356,12 @@ impl GCCWorkspace { | ||
261 | 356 | /// Append an OTA step to the current branch's `log.md`. |
262 | 357 | /// The paper logs continuous Observation–Thought–Action cycles. |
263 | 358 | pub fn log_ota(&self, observation: &str, thought: &str, action: &str) -> Result<OTARecord> { |
359 | + if observation.trim().is_empty() && thought.trim().is_empty() && action.trim().is_empty() { | |
360 | + return Err(GCCError::Validation( | |
361 | + "At least one of observation, thought, or action must be non-empty".to_string(), | |
362 | + )); | |
363 | + } | |
364 | + let _lock = FileLock::acquire(&self.gcc_dir)?; | |
264 | 365 | let existing = self.read_ota(&self.current_branch); |
265 | 366 | let step = existing.len() + 1; |
266 | 367 | let record = OTARecord { |
| @@ -283,6 +384,18 @@ impl GCCWorkspace { | ||
283 | 384 | contribution: &str, |
284 | 385 | previous_summary: Option<&str>, |
285 | 386 | update_roadmap: Option<&str>, |
387 | + ) -> Result<CommitRecord> { | |
388 | + validate_not_empty(contribution, "Contribution")?; | |
389 | + let _lock = FileLock::acquire(&self.gcc_dir)?; | |
390 | + self.commit_inner(contribution, previous_summary, update_roadmap) | |
391 | + } | |
392 | + | |
393 | + /// Internal commit logic, called with lock already held. | |
394 | + fn commit_inner( | |
395 | + &self, | |
396 | + contribution: &str, | |
397 | + previous_summary: Option<&str>, | |
398 | + update_roadmap: Option<&str>, | |
286 | 399 | ) -> Result<CommitRecord> { |
287 | 400 | let meta = self.read_meta(&self.current_branch); |
288 | 401 | let branch_purpose = meta.as_ref().map(|m| m.purpose.clone()).unwrap_or_default(); |
| @@ -325,6 +438,8 @@ impl GCCWorkspace { | ||
325 | 438 | /// Creates isolated workspace: B_t^(name) = BRANCH(M_{t-1}). |
326 | 439 | /// Initialises empty OTA trace and commit.md; metadata records intent. |
327 | 440 | pub fn branch(&mut self, name: &str, purpose: &str) -> Result<()> { |
441 | + validate_branch_name(name)?; | |
442 | + validate_not_empty(purpose, "Branch purpose")?; | |
328 | 443 | let branch_dir = self.branch_dir(name); |
329 | 444 | if branch_dir.exists() { |
330 | 445 | return Err(GCCError::BranchExists { |
| @@ -333,6 +448,8 @@ impl GCCWorkspace { | ||
333 | 448 | } |
334 | 449 | |
335 | 450 | fs::create_dir_all(&branch_dir)?; |
451 | + | |
452 | + let _lock = FileLock::acquire(&self.gcc_dir)?; | |
336 | 453 | self.write( |
337 | 454 | &self.log_path(name), |
338 | 455 | &format!("# OTA Log — branch `{name}`\n\n"), |
| @@ -367,12 +484,16 @@ impl GCCWorkspace { | ||
367 | 484 | summary: Option<&str>, |
368 | 485 | target: &str, |
369 | 486 | ) -> Result<CommitRecord> { |
487 | + validate_branch_name(branch_name)?; | |
488 | + validate_branch_name(target)?; | |
370 | 489 | if !self.branch_dir(branch_name).exists() { |
371 | 490 | return Err(GCCError::BranchNotFound { |
372 | 491 | name: branch_name.to_string(), |
373 | 492 | }); |
374 | 493 | } |
375 | 494 | |
495 | + let _lock = FileLock::acquire(&self.gcc_dir)?; | |
496 | + | |
376 | 497 | let branch_commits = self.read_commits(branch_name); |
377 | 498 | let branch_ota = self.read_ota(branch_name); |
378 | 499 | let meta = self.read_meta(branch_name); |
| @@ -409,7 +530,7 @@ impl GCCWorkspace { | ||
409 | 530 | branch_name, |
410 | 531 | meta.as_ref().map(|m| m.purpose.as_str()).unwrap_or("") |
411 | 532 | ); |
412 | - let merge_commit = self.commit(&merge_summary, Some(&prev), Some(&merge_summary))?; | |
533 | + let merge_commit = self.commit_inner(&merge_summary, Some(&prev), Some(&merge_summary))?; | |
413 | 534 | |
414 | 535 | // Mark branch as merged |
415 | 536 | if let Some(mut m) = meta { |
| @@ -427,6 +548,10 @@ impl GCCWorkspace { | ||
427 | 548 | /// Retrieves historical context at K-commit resolution. |
428 | 549 | /// Paper experiments use K=1 (only most recent commit revealed). |
429 | 550 | pub fn context(&self, branch: Option<&str>, k: usize) -> Result<ContextResult> { |
551 | + if k < 1 { | |
552 | + return Err(GCCError::Validation(format!("k must be >= 1, got {k}"))); | |
553 | + } | |
554 | + let _lock = FileLock::acquire(&self.gcc_dir)?; | |
430 | 555 | let target = branch.unwrap_or(&self.current_branch); |
431 | 556 | if !self.branch_dir(target).exists() { |
432 | 557 | return Err(GCCError::BranchNotFound { |
| @@ -464,6 +589,7 @@ impl GCCWorkspace { | ||
464 | 589 | } |
465 | 590 | |
466 | 591 | pub fn switch_branch(&mut self, name: &str) -> Result<()> { |
592 | + validate_branch_name(name)?; | |
467 | 593 | if !self.branch_dir(name).exists() { |
468 | 594 | return Err(GCCError::BranchNotFound { |
469 | 595 | name: name.to_string(), |
470 | 596 | |