compass-st 1.1.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.
- package/README.md +105 -0
- package/VERSION +1 -0
- package/bin/install +174 -0
- package/bootstrap.sh +95 -0
- package/cli/Cargo.lock +270 -0
- package/cli/Cargo.toml +24 -0
- package/cli/src/cmd/context.rs +59 -0
- package/cli/src/cmd/dag.rs +133 -0
- package/cli/src/cmd/git.rs +148 -0
- package/cli/src/cmd/hook.rs +51 -0
- package/cli/src/cmd/index.rs +363 -0
- package/cli/src/cmd/manifest.rs +34 -0
- package/cli/src/cmd/memory.rs +680 -0
- package/cli/src/cmd/migrate.rs +790 -0
- package/cli/src/cmd/mod.rs +14 -0
- package/cli/src/cmd/progress.rs +107 -0
- package/cli/src/cmd/project.rs +1700 -0
- package/cli/src/cmd/session.rs +64 -0
- package/cli/src/cmd/state.rs +317 -0
- package/cli/src/cmd/validate/mod.rs +506 -0
- package/cli/src/cmd/validate/prd.rs +472 -0
- package/cli/src/cmd/version.rs +89 -0
- package/cli/src/helpers.rs +40 -0
- package/cli/src/main.rs +75 -0
- package/cli/tests/fixtures/plan_empty_pointers.json +60 -0
- package/cli/tests/fixtures/plan_missing_pointers.json +59 -0
- package/cli/tests/fixtures/plan_too_many_pointers.json +92 -0
- package/cli/tests/fixtures/plan_v1_valid.json +64 -0
- package/cli/tests/fixtures/prd_bad_flow_bullet.md +37 -0
- package/cli/tests/fixtures/prd_bad_flow_prose.md +33 -0
- package/cli/tests/fixtures/prd_good_flow.md +41 -0
- package/cli/tests/fixtures/prd_xref_dangling.md +38 -0
- package/cli/tests/fixtures/prd_xref_valid.md +53 -0
- package/cli/tests/fixtures/projects/proj_a/.compass/.state/config.json +12 -0
- package/cli/tests/fixtures/projects/proj_b/.compass/.state/config.json +12 -0
- package/cli/tests/fixtures/projects/proj_c/.compass/.state/config.json +12 -0
- package/cli/tests/fixtures/registry/all_dead.json +18 -0
- package/cli/tests/fixtures/registry/corrupt.json +1 -0
- package/cli/tests/fixtures/registry/empty.json +1 -0
- package/cli/tests/fixtures/registry/last_active_dead.json +24 -0
- package/cli/tests/fixtures/registry/multi_alive.json +24 -0
- package/cli/tests/fixtures/registry/one_alive.json +12 -0
- package/cli/tests/fixtures/v0_project/.compass/.state/config.json +5 -0
- package/cli/tests/fixtures/v0_project/.compass/.state/sessions/onboarding-redesign/plan.json +29 -0
- package/cli/tests/fixtures/v0_project/.compass/.state/sessions/sample-feature/context.json +11 -0
- package/cli/tests/fixtures/v0_project/.compass/.state/sessions/sample-feature/plan.json +49 -0
- package/core/colleagues/base-rules.md +112 -0
- package/core/colleagues/manifest.json +85 -0
- package/core/colleagues/market-analyst.md +50 -0
- package/core/colleagues/prioritizer.md +53 -0
- package/core/colleagues/researcher.md +54 -0
- package/core/colleagues/reviewer.md +55 -0
- package/core/colleagues/stakeholder-comm.md +59 -0
- package/core/colleagues/story-breaker.md +57 -0
- package/core/colleagues/ux-reviewer.md +54 -0
- package/core/colleagues/writer.md +55 -0
- package/core/commands/compass/brief.md +28 -0
- package/core/commands/compass/check.md +27 -0
- package/core/commands/compass/epic.md +32 -0
- package/core/commands/compass/feedback.md +32 -0
- package/core/commands/compass/help.md +24 -0
- package/core/commands/compass/ideate.md +32 -0
- package/core/commands/compass/init.md +30 -0
- package/core/commands/compass/plan.md +27 -0
- package/core/commands/compass/prd.md +39 -0
- package/core/commands/compass/prioritize.md +36 -0
- package/core/commands/compass/prototype.md +28 -0
- package/core/commands/compass/release.md +32 -0
- package/core/commands/compass/research.md +31 -0
- package/core/commands/compass/roadmap.md +32 -0
- package/core/commands/compass/run.md +28 -0
- package/core/commands/compass/setup.md +32 -0
- package/core/commands/compass/sprint.md +32 -0
- package/core/commands/compass/status.md +32 -0
- package/core/commands/compass/story.md +37 -0
- package/core/commands/compass/undo.md +33 -0
- package/core/commands/compass/update.md +29 -0
- package/core/hooks/context-monitor.sh +5 -0
- package/core/hooks/manifest-tracker.sh +62 -0
- package/core/hooks/statusline.sh +12 -0
- package/core/hooks/update-checker.sh +24 -0
- package/core/integrations/confluence.md +267 -0
- package/core/integrations/figma.md +277 -0
- package/core/integrations/jira.md +436 -0
- package/core/integrations/vercel.md +170 -0
- package/core/manifest.json +172 -0
- package/core/shared/SCHEMAS-v1.md +404 -0
- package/core/shared/progress.md +145 -0
- package/core/shared/project-scan.md +293 -0
- package/core/shared/resolve-project.md +136 -0
- package/core/shared/ux-rules.md +52 -0
- package/core/shared/version-backup.md +38 -0
- package/core/templates/prd-template.md +145 -0
- package/core/templates/story-template.md +99 -0
- package/core/workflows/brief.md +184 -0
- package/core/workflows/check.md +436 -0
- package/core/workflows/epic.md +177 -0
- package/core/workflows/feedback.md +164 -0
- package/core/workflows/help.md +79 -0
- package/core/workflows/ideate.md +320 -0
- package/core/workflows/init.md +524 -0
- package/core/workflows/migrate.md +136 -0
- package/core/workflows/plan.md +320 -0
- package/core/workflows/prd.md +632 -0
- package/core/workflows/prioritize.md +301 -0
- package/core/workflows/project.md +177 -0
- package/core/workflows/prototype.md +174 -0
- package/core/workflows/release.md +179 -0
- package/core/workflows/research.md +613 -0
- package/core/workflows/roadmap.md +152 -0
- package/core/workflows/run.md +367 -0
- package/core/workflows/setup.md +294 -0
- package/core/workflows/sprint.md +187 -0
- package/core/workflows/status.md +185 -0
- package/core/workflows/story.md +477 -0
- package/core/workflows/undo.md +42 -0
- package/core/workflows/update.md +127 -0
- package/package.json +37 -0
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
use crate::helpers;
|
|
2
|
+
use fs2::FileExt;
|
|
3
|
+
use serde_json::{json, Value};
|
|
4
|
+
use std::fs::{self, OpenOptions};
|
|
5
|
+
use std::path::{Path, PathBuf};
|
|
6
|
+
use std::time::SystemTime;
|
|
7
|
+
|
|
8
|
+
const MEMORY_VERSION: &str = "1.0";
|
|
9
|
+
const MAX_SESSIONS: usize = 10;
|
|
10
|
+
|
|
11
|
+
pub fn run(args: &[String]) -> Result<String, String> {
|
|
12
|
+
if args.is_empty() {
|
|
13
|
+
return Err(usage());
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
match args[0].as_str() {
|
|
17
|
+
"init" => {
|
|
18
|
+
if args.len() < 2 {
|
|
19
|
+
return Err("Usage: compass-cli memory init <project_root>".into());
|
|
20
|
+
}
|
|
21
|
+
init(&args[1])
|
|
22
|
+
}
|
|
23
|
+
"get" => {
|
|
24
|
+
if args.len() < 2 {
|
|
25
|
+
return Err("Usage: compass-cli memory get <project_root> [--key <dot.path>]".into());
|
|
26
|
+
}
|
|
27
|
+
let key = parse_flag(args, "--key");
|
|
28
|
+
get(&args[1], key.as_deref())
|
|
29
|
+
}
|
|
30
|
+
"update" => {
|
|
31
|
+
if args.len() < 2 {
|
|
32
|
+
return Err("Usage: compass-cli memory update <project_root> --patch <json>".into());
|
|
33
|
+
}
|
|
34
|
+
let patch = parse_flag(args, "--patch")
|
|
35
|
+
.or_else(|| args.get(2).cloned())
|
|
36
|
+
.ok_or_else(|| "Missing --patch <json>".to_string())?;
|
|
37
|
+
update(&args[1], &patch)
|
|
38
|
+
}
|
|
39
|
+
"list-sessions" => {
|
|
40
|
+
if args.len() < 2 {
|
|
41
|
+
return Err("Usage: compass-cli memory list-sessions <project_root>".into());
|
|
42
|
+
}
|
|
43
|
+
list_sessions(&args[1])
|
|
44
|
+
}
|
|
45
|
+
other => Err(format!("Unknown memory command: {}", other)),
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
fn usage() -> String {
|
|
50
|
+
"Usage: compass-cli memory <init|get|update|list-sessions> <project_root> [...]".into()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
fn parse_flag(args: &[String], flag: &str) -> Option<String> {
|
|
54
|
+
args.iter()
|
|
55
|
+
.position(|a| a == flag)
|
|
56
|
+
.and_then(|i| args.get(i + 1).cloned())
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/// Resolve the canonical memory-file path for a project root.
|
|
60
|
+
///
|
|
61
|
+
/// Canonical location is `<project_root>/.compass/.state/project-memory.json`.
|
|
62
|
+
/// For backwards compatibility with already-present flat files, if the canonical
|
|
63
|
+
/// parent doesn't exist yet but a flat `<project_root>/project-memory.json` does,
|
|
64
|
+
/// we use the flat one.
|
|
65
|
+
fn resolve_memory_path(project_root: &str) -> PathBuf {
|
|
66
|
+
let canonical = Path::new(project_root)
|
|
67
|
+
.join(".compass")
|
|
68
|
+
.join(".state")
|
|
69
|
+
.join("project-memory.json");
|
|
70
|
+
if canonical.exists() {
|
|
71
|
+
return canonical;
|
|
72
|
+
}
|
|
73
|
+
let flat = Path::new(project_root).join("project-memory.json");
|
|
74
|
+
if flat.exists() {
|
|
75
|
+
return flat;
|
|
76
|
+
}
|
|
77
|
+
canonical
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
fn now_iso() -> String {
|
|
81
|
+
// RFC3339/ISO-8601 UTC without pulling chrono in — seconds granularity is fine
|
|
82
|
+
// for schema compliance (`created_at`/`updated_at`).
|
|
83
|
+
let secs = SystemTime::now()
|
|
84
|
+
.duration_since(SystemTime::UNIX_EPOCH)
|
|
85
|
+
.map(|d| d.as_secs())
|
|
86
|
+
.unwrap_or(0);
|
|
87
|
+
format_iso_utc(secs)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
fn format_iso_utc(total_secs: u64) -> String {
|
|
91
|
+
// Civil-from-days algorithm (Howard Hinnant) — enough for a schema timestamp,
|
|
92
|
+
// no external deps required.
|
|
93
|
+
let days = (total_secs / 86_400) as i64;
|
|
94
|
+
let tod = total_secs % 86_400;
|
|
95
|
+
let (y, m, d) = civil_from_days(days);
|
|
96
|
+
let hh = tod / 3600;
|
|
97
|
+
let mm = (tod % 3600) / 60;
|
|
98
|
+
let ss = tod % 60;
|
|
99
|
+
format!(
|
|
100
|
+
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
|
|
101
|
+
y, m, d, hh, mm, ss
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
|
106
|
+
let z = z + 719_468;
|
|
107
|
+
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
|
108
|
+
let doe = (z - era * 146_097) as u64;
|
|
109
|
+
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
|
|
110
|
+
let y = yoe as i64 + era * 400;
|
|
111
|
+
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
|
112
|
+
let mp = (5 * doy + 2) / 153;
|
|
113
|
+
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
|
114
|
+
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
|
|
115
|
+
(y + if m <= 2 { 1 } else { 0 }, m, d)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
fn init(project_root: &str) -> Result<String, String> {
|
|
119
|
+
let path = Path::new(project_root)
|
|
120
|
+
.join(".compass")
|
|
121
|
+
.join(".state")
|
|
122
|
+
.join("project-memory.json");
|
|
123
|
+
|
|
124
|
+
if path.exists() {
|
|
125
|
+
eprintln!("already exists: {}", path.display());
|
|
126
|
+
return Ok(json!({
|
|
127
|
+
"ok": true,
|
|
128
|
+
"already_exists": true,
|
|
129
|
+
"path": path.to_string_lossy(),
|
|
130
|
+
})
|
|
131
|
+
.to_string());
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if let Some(parent) = path.parent() {
|
|
135
|
+
fs::create_dir_all(parent).map_err(|e| format!("Cannot create {}: {}", parent.display(), e))?;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let now = now_iso();
|
|
139
|
+
let skeleton = json!({
|
|
140
|
+
"memory_version": MEMORY_VERSION,
|
|
141
|
+
"created_at": now,
|
|
142
|
+
"updated_at": now,
|
|
143
|
+
"sessions": [],
|
|
144
|
+
"decisions": [],
|
|
145
|
+
"discovered_conventions": [],
|
|
146
|
+
"resolved_ambiguities": [],
|
|
147
|
+
"glossary": {},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
helpers::write_json(&path, &skeleton)?;
|
|
151
|
+
|
|
152
|
+
Ok(json!({
|
|
153
|
+
"ok": true,
|
|
154
|
+
"already_exists": false,
|
|
155
|
+
"path": path.to_string_lossy(),
|
|
156
|
+
})
|
|
157
|
+
.to_string())
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
fn read_memory(path: &Path) -> Result<Value, String> {
|
|
161
|
+
let content = fs::read_to_string(path)
|
|
162
|
+
.map_err(|e| format!("Cannot read {}: {}", path.display(), e))?;
|
|
163
|
+
let value: Value = serde_json::from_str(&content)
|
|
164
|
+
.map_err(|e| format!("CORRUPT_MEMORY: {} ({})", path.display(), e))?;
|
|
165
|
+
check_version(&value)?;
|
|
166
|
+
Ok(value)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
fn check_version(value: &Value) -> Result<(), String> {
|
|
170
|
+
match value.get("memory_version").and_then(|v| v.as_str()) {
|
|
171
|
+
Some(MEMORY_VERSION) => Ok(()),
|
|
172
|
+
Some(other) => Err(format!(
|
|
173
|
+
"UNSUPPORTED_MEMORY_VERSION: found {:?}, expected {:?}",
|
|
174
|
+
other, MEMORY_VERSION
|
|
175
|
+
)),
|
|
176
|
+
None => Err("UNSUPPORTED_MEMORY_VERSION: missing memory_version".into()),
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
fn get(project_root: &str, key: Option<&str>) -> Result<String, String> {
|
|
181
|
+
let path = resolve_memory_path(project_root);
|
|
182
|
+
let data = read_memory(&path)?;
|
|
183
|
+
|
|
184
|
+
let value = match key {
|
|
185
|
+
Some(k) => lookup_dot_path(&data, k)
|
|
186
|
+
.ok_or_else(|| format!("Key not found: {}", k))?
|
|
187
|
+
.clone(),
|
|
188
|
+
None => data,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
serde_json::to_string_pretty(&value).map_err(|e| format!("JSON serialize error: {}", e))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
fn lookup_dot_path<'a>(root: &'a Value, path: &str) -> Option<&'a Value> {
|
|
195
|
+
let mut cur = root;
|
|
196
|
+
for seg in path.split('.') {
|
|
197
|
+
if seg.is_empty() {
|
|
198
|
+
return None;
|
|
199
|
+
}
|
|
200
|
+
cur = match cur {
|
|
201
|
+
Value::Object(map) => map.get(seg)?,
|
|
202
|
+
Value::Array(arr) => {
|
|
203
|
+
let idx: usize = seg.parse().ok()?;
|
|
204
|
+
arr.get(idx)?
|
|
205
|
+
}
|
|
206
|
+
_ => return None,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
Some(cur)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
fn update(project_root: &str, patch_str: &str) -> Result<String, String> {
|
|
213
|
+
let path = resolve_memory_path(project_root);
|
|
214
|
+
|
|
215
|
+
if !path.exists() {
|
|
216
|
+
return Err(format!(
|
|
217
|
+
"Memory file not found at {}. Run `memory init` first.",
|
|
218
|
+
path.display()
|
|
219
|
+
));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let patch: Value = serde_json::from_str(patch_str)
|
|
223
|
+
.map_err(|e| format!("Invalid JSON patch: {}", e))?;
|
|
224
|
+
|
|
225
|
+
// Exclusive advisory lock around the read-merge-write cycle to dodge the
|
|
226
|
+
// TEST-SPEC race between two concurrent `memory update` callers.
|
|
227
|
+
let file = OpenOptions::new()
|
|
228
|
+
.read(true)
|
|
229
|
+
.write(true)
|
|
230
|
+
.open(&path)
|
|
231
|
+
.map_err(|e| format!("Cannot open {}: {}", path.display(), e))?;
|
|
232
|
+
file.lock_exclusive()
|
|
233
|
+
.map_err(|e| format!("Cannot lock {}: {}", path.display(), e))?;
|
|
234
|
+
|
|
235
|
+
let result = (|| -> Result<Value, String> {
|
|
236
|
+
let mut data = read_memory(&path)?;
|
|
237
|
+
deep_merge(&mut data, &patch);
|
|
238
|
+
enforce_fifo_and_aggregate(&mut data);
|
|
239
|
+
if let Some(obj) = data.as_object_mut() {
|
|
240
|
+
obj.insert("updated_at".to_string(), json!(now_iso()));
|
|
241
|
+
}
|
|
242
|
+
helpers::write_json(&path, &data)?;
|
|
243
|
+
Ok(data)
|
|
244
|
+
})();
|
|
245
|
+
|
|
246
|
+
let _ = file.unlock();
|
|
247
|
+
|
|
248
|
+
let data = result?;
|
|
249
|
+
Ok(json!({
|
|
250
|
+
"ok": true,
|
|
251
|
+
"path": path.to_string_lossy(),
|
|
252
|
+
"sessions_count": data.get("sessions").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0),
|
|
253
|
+
})
|
|
254
|
+
.to_string())
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/// Recursive deep merge: patch values override dst values. Objects merge
|
|
258
|
+
/// key-by-key; arrays and scalars are replaced wholesale, EXCEPT that when both
|
|
259
|
+
/// sides are arrays at the same path we concatenate (dst first, patch after).
|
|
260
|
+
/// This lets callers append to `sessions[]` / `decisions[]` naturally by
|
|
261
|
+
/// patching `{"sessions":[new_entry]}`.
|
|
262
|
+
fn deep_merge(dst: &mut Value, patch: &Value) {
|
|
263
|
+
match (dst, patch) {
|
|
264
|
+
(Value::Object(dst_map), Value::Object(patch_map)) => {
|
|
265
|
+
for (k, v) in patch_map {
|
|
266
|
+
match dst_map.get_mut(k) {
|
|
267
|
+
Some(existing) => deep_merge(existing, v),
|
|
268
|
+
None => {
|
|
269
|
+
dst_map.insert(k.clone(), v.clone());
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
(Value::Array(dst_arr), Value::Array(patch_arr)) => {
|
|
275
|
+
for item in patch_arr {
|
|
276
|
+
dst_arr.push(item.clone());
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
(dst_slot, patch_val) => {
|
|
280
|
+
*dst_slot = patch_val.clone();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/// Enforce `sessions` length ≤ 10; before dropping index 0, merge its aggregates
|
|
286
|
+
/// into the top-level aggregates with dedup on the schema-defined composite keys.
|
|
287
|
+
fn enforce_fifo_and_aggregate(data: &mut Value) {
|
|
288
|
+
let obj = match data.as_object_mut() {
|
|
289
|
+
Some(o) => o,
|
|
290
|
+
None => return,
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
loop {
|
|
294
|
+
let overflow = obj
|
|
295
|
+
.get("sessions")
|
|
296
|
+
.and_then(|v| v.as_array())
|
|
297
|
+
.map(|a| a.len())
|
|
298
|
+
.unwrap_or(0)
|
|
299
|
+
> MAX_SESSIONS;
|
|
300
|
+
if !overflow {
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Pop oldest session
|
|
305
|
+
let oldest = match obj.get_mut("sessions").and_then(|v| v.as_array_mut()) {
|
|
306
|
+
Some(arr) if !arr.is_empty() => arr.remove(0),
|
|
307
|
+
_ => break,
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
// Merge oldest's aggregates into top-level, deduped.
|
|
311
|
+
if let Some(decisions) = oldest.get("decisions").and_then(|v| v.as_array()) {
|
|
312
|
+
merge_dedup(obj, "decisions", decisions, &["topic", "decision"]);
|
|
313
|
+
}
|
|
314
|
+
if let Some(convs) = oldest.get("discovered_conventions").and_then(|v| v.as_array()) {
|
|
315
|
+
merge_dedup(obj, "discovered_conventions", convs, &["area", "convention"]);
|
|
316
|
+
}
|
|
317
|
+
if let Some(ambigs) = oldest.get("resolved_ambiguities").and_then(|v| v.as_array()) {
|
|
318
|
+
merge_dedup(obj, "resolved_ambiguities", ambigs, &["question", "answer"]);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
fn merge_dedup(
|
|
324
|
+
obj: &mut serde_json::Map<String, Value>,
|
|
325
|
+
key: &str,
|
|
326
|
+
incoming: &[Value],
|
|
327
|
+
id_fields: &[&str],
|
|
328
|
+
) {
|
|
329
|
+
let slot = obj.entry(key.to_string()).or_insert_with(|| json!([]));
|
|
330
|
+
let arr = match slot.as_array_mut() {
|
|
331
|
+
Some(a) => a,
|
|
332
|
+
None => return,
|
|
333
|
+
};
|
|
334
|
+
for item in incoming {
|
|
335
|
+
if !arr.iter().any(|existing| composite_eq(existing, item, id_fields)) {
|
|
336
|
+
arr.push(item.clone());
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
fn composite_eq(a: &Value, b: &Value, id_fields: &[&str]) -> bool {
|
|
342
|
+
id_fields.iter().all(|f| a.get(*f) == b.get(*f))
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
fn list_sessions(project_root: &str) -> Result<String, String> {
|
|
346
|
+
let path = resolve_memory_path(project_root);
|
|
347
|
+
let data = read_memory(&path)?;
|
|
348
|
+
|
|
349
|
+
let empty: Vec<Value> = Vec::new();
|
|
350
|
+
let sessions = data
|
|
351
|
+
.get("sessions")
|
|
352
|
+
.and_then(|v| v.as_array())
|
|
353
|
+
.unwrap_or(&empty);
|
|
354
|
+
|
|
355
|
+
let mut rows: Vec<Value> = sessions
|
|
356
|
+
.iter()
|
|
357
|
+
.map(|s| {
|
|
358
|
+
let deliverables_count = s
|
|
359
|
+
.get("deliverables")
|
|
360
|
+
.and_then(|v| v.as_array())
|
|
361
|
+
.map(|a| a.len())
|
|
362
|
+
.unwrap_or(0);
|
|
363
|
+
json!({
|
|
364
|
+
"session_id": s.get("session_id").cloned().unwrap_or(Value::Null),
|
|
365
|
+
"slug": s.get("slug").cloned().unwrap_or(Value::Null),
|
|
366
|
+
"finished_at": s.get("finished_at").cloned().unwrap_or(Value::Null),
|
|
367
|
+
"deliverables_count": deliverables_count,
|
|
368
|
+
})
|
|
369
|
+
})
|
|
370
|
+
.collect();
|
|
371
|
+
|
|
372
|
+
// Newest first — sessions[] is FIFO (index 0 oldest), so reverse for output.
|
|
373
|
+
rows.reverse();
|
|
374
|
+
|
|
375
|
+
serde_json::to_string_pretty(&Value::Array(rows))
|
|
376
|
+
.map_err(|e| format!("JSON serialize error: {}", e))
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
#[cfg(test)]
|
|
380
|
+
mod tests {
|
|
381
|
+
use super::*;
|
|
382
|
+
use serde_json::json;
|
|
383
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
384
|
+
use std::time::{SystemTime, UNIX_EPOCH};
|
|
385
|
+
|
|
386
|
+
// ---- helpers ------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
389
|
+
|
|
390
|
+
/// Create a unique temp directory under std::env::temp_dir() and return its path.
|
|
391
|
+
/// Caller is responsible for cleanup via `cleanup_tmp`.
|
|
392
|
+
fn unique_tmp_dir(tag: &str) -> PathBuf {
|
|
393
|
+
let nanos = SystemTime::now()
|
|
394
|
+
.duration_since(UNIX_EPOCH)
|
|
395
|
+
.map(|d| d.as_nanos())
|
|
396
|
+
.unwrap_or(0);
|
|
397
|
+
let n = TMP_COUNTER.fetch_add(1, Ordering::SeqCst);
|
|
398
|
+
let pid = std::process::id();
|
|
399
|
+
let dir = std::env::temp_dir().join(format!(
|
|
400
|
+
"compass-cli-memtest-{}-{}-{}-{}",
|
|
401
|
+
tag, pid, nanos, n
|
|
402
|
+
));
|
|
403
|
+
fs::create_dir_all(&dir).expect("create unique tmp dir");
|
|
404
|
+
dir
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
fn cleanup_tmp(dir: &Path) {
|
|
408
|
+
let _ = fs::remove_dir_all(dir);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
fn parse(s: &str) -> Value {
|
|
412
|
+
serde_json::from_str(s).expect("valid json from command")
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ---- tests --------------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
#[test]
|
|
418
|
+
fn deep_merge_scalars_override() {
|
|
419
|
+
let mut dst = json!({"a": 1, "b": 2});
|
|
420
|
+
deep_merge(&mut dst, &json!({"b": 99, "c": 3}));
|
|
421
|
+
assert_eq!(dst, json!({"a": 1, "b": 99, "c": 3}));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
#[test]
|
|
425
|
+
fn deep_merge_arrays_concat() {
|
|
426
|
+
let mut dst = json!({"xs": [1, 2]});
|
|
427
|
+
deep_merge(&mut dst, &json!({"xs": [3]}));
|
|
428
|
+
assert_eq!(dst, json!({"xs": [1, 2, 3]}));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
#[test]
|
|
432
|
+
fn fifo_preserves_aggregates_with_dedup() {
|
|
433
|
+
let mut data = json!({
|
|
434
|
+
"memory_version": "1.0",
|
|
435
|
+
"sessions": [],
|
|
436
|
+
"decisions": [],
|
|
437
|
+
"discovered_conventions": [],
|
|
438
|
+
"resolved_ambiguities": [],
|
|
439
|
+
"glossary": {},
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// Seed 11 sessions; the first one has a decision that must survive.
|
|
443
|
+
let sessions = data
|
|
444
|
+
.get_mut("sessions")
|
|
445
|
+
.unwrap()
|
|
446
|
+
.as_array_mut()
|
|
447
|
+
.unwrap();
|
|
448
|
+
sessions.push(json!({
|
|
449
|
+
"session_id": "s0",
|
|
450
|
+
"slug": "s0",
|
|
451
|
+
"finished_at": "2026-01-01T00:00:00Z",
|
|
452
|
+
"deliverables": [],
|
|
453
|
+
"decisions": [{"topic": "T", "decision": "D", "rationale": "R", "session_id": "s0"}],
|
|
454
|
+
"discovered_conventions": [],
|
|
455
|
+
"resolved_ambiguities": [],
|
|
456
|
+
}));
|
|
457
|
+
for i in 1..=10 {
|
|
458
|
+
sessions.push(json!({
|
|
459
|
+
"session_id": format!("s{}", i),
|
|
460
|
+
"slug": format!("s{}", i),
|
|
461
|
+
"finished_at": "2026-01-01T00:00:00Z",
|
|
462
|
+
"deliverables": [],
|
|
463
|
+
"decisions": [],
|
|
464
|
+
"discovered_conventions": [],
|
|
465
|
+
"resolved_ambiguities": [],
|
|
466
|
+
}));
|
|
467
|
+
}
|
|
468
|
+
enforce_fifo_and_aggregate(&mut data);
|
|
469
|
+
|
|
470
|
+
assert_eq!(data["sessions"].as_array().unwrap().len(), 10);
|
|
471
|
+
assert_eq!(data["sessions"][0]["session_id"], "s1");
|
|
472
|
+
let agg = data["decisions"].as_array().unwrap();
|
|
473
|
+
assert_eq!(agg.len(), 1);
|
|
474
|
+
assert_eq!(agg[0]["topic"], "T");
|
|
475
|
+
|
|
476
|
+
// Running again with the same decision should NOT duplicate.
|
|
477
|
+
enforce_fifo_and_aggregate(&mut data);
|
|
478
|
+
assert_eq!(data["decisions"].as_array().unwrap().len(), 1);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
#[test]
|
|
482
|
+
fn dot_path_lookup() {
|
|
483
|
+
let v = json!({"sessions": [{"session_id": "abc"}]});
|
|
484
|
+
assert_eq!(
|
|
485
|
+
lookup_dot_path(&v, "sessions.0.session_id").unwrap(),
|
|
486
|
+
&json!("abc")
|
|
487
|
+
);
|
|
488
|
+
assert!(lookup_dot_path(&v, "sessions.5").is_none());
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/// REQ-04: `init` on a fresh project root creates
|
|
492
|
+
/// `.compass/.state/project-memory.json` with the v1 skeleton.
|
|
493
|
+
#[test]
|
|
494
|
+
fn init_default() {
|
|
495
|
+
let root = unique_tmp_dir("init_default");
|
|
496
|
+
let root_str = root.to_string_lossy().to_string();
|
|
497
|
+
|
|
498
|
+
let out = init(&root_str).expect("init ok");
|
|
499
|
+
let out_val = parse(&out);
|
|
500
|
+
assert_eq!(out_val["ok"], json!(true));
|
|
501
|
+
assert_eq!(out_val["already_exists"], json!(false));
|
|
502
|
+
|
|
503
|
+
let mem_path = root
|
|
504
|
+
.join(".compass")
|
|
505
|
+
.join(".state")
|
|
506
|
+
.join("project-memory.json");
|
|
507
|
+
assert!(mem_path.exists(), "memory file should exist at {}", mem_path.display());
|
|
508
|
+
|
|
509
|
+
let content = fs::read_to_string(&mem_path).expect("read memory file");
|
|
510
|
+
let data: Value = serde_json::from_str(&content).expect("valid json");
|
|
511
|
+
|
|
512
|
+
assert_eq!(data["memory_version"], json!("1.0"));
|
|
513
|
+
assert_eq!(data["sessions"], json!([]));
|
|
514
|
+
// The 3 schema aggregates MUST be present as empty arrays.
|
|
515
|
+
assert_eq!(data["decisions"], json!([]));
|
|
516
|
+
assert_eq!(data["discovered_conventions"], json!([]));
|
|
517
|
+
assert_eq!(data["resolved_ambiguities"], json!([]));
|
|
518
|
+
// Glossary is a required object (may be empty).
|
|
519
|
+
assert!(data["glossary"].is_object(), "glossary must be an object");
|
|
520
|
+
// Timestamps required by schema.
|
|
521
|
+
assert!(data["created_at"].is_string());
|
|
522
|
+
assert!(data["updated_at"].is_string());
|
|
523
|
+
|
|
524
|
+
cleanup_tmp(&root);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/// REQ-04: after 11 `update` calls each appending one session entry,
|
|
528
|
+
/// `sessions.len() == 10` and the oldest entry is dropped (FIFO).
|
|
529
|
+
#[test]
|
|
530
|
+
fn fifo_rotate() {
|
|
531
|
+
let root = unique_tmp_dir("fifo_rotate");
|
|
532
|
+
let root_str = root.to_string_lossy().to_string();
|
|
533
|
+
init(&root_str).expect("init ok");
|
|
534
|
+
|
|
535
|
+
for i in 0..11 {
|
|
536
|
+
let patch = json!({
|
|
537
|
+
"sessions": [{
|
|
538
|
+
"session_id": format!("sess-{:02}", i),
|
|
539
|
+
"slug": format!("sess-{:02}", i),
|
|
540
|
+
"finished_at": "2026-01-01T00:00:00Z",
|
|
541
|
+
"deliverables": [],
|
|
542
|
+
"decisions": [],
|
|
543
|
+
"discovered_conventions": [],
|
|
544
|
+
"resolved_ambiguities": [],
|
|
545
|
+
}]
|
|
546
|
+
});
|
|
547
|
+
update(&root_str, &patch.to_string()).expect("update ok");
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
let mem_path = root
|
|
551
|
+
.join(".compass")
|
|
552
|
+
.join(".state")
|
|
553
|
+
.join("project-memory.json");
|
|
554
|
+
let data: Value = serde_json::from_str(
|
|
555
|
+
&fs::read_to_string(&mem_path).expect("read memory file"),
|
|
556
|
+
)
|
|
557
|
+
.expect("valid json");
|
|
558
|
+
|
|
559
|
+
let sessions = data["sessions"].as_array().expect("sessions array");
|
|
560
|
+
assert_eq!(sessions.len(), 10, "should cap at 10 after 11 updates");
|
|
561
|
+
// The oldest (`sess-00`) must be gone; index 0 is now `sess-01`.
|
|
562
|
+
assert_eq!(sessions[0]["session_id"], json!("sess-01"));
|
|
563
|
+
assert_eq!(sessions[9]["session_id"], json!("sess-10"));
|
|
564
|
+
|
|
565
|
+
cleanup_tmp(&root);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/// REQ-04: the session dropped by rotation carried 2 decisions +
|
|
569
|
+
/// 1 discovered_convention; after rotation those survive at top level.
|
|
570
|
+
#[test]
|
|
571
|
+
fn preserve_aggregates() {
|
|
572
|
+
let root = unique_tmp_dir("preserve_aggregates");
|
|
573
|
+
let root_str = root.to_string_lossy().to_string();
|
|
574
|
+
init(&root_str).expect("init ok");
|
|
575
|
+
|
|
576
|
+
// Session 0 is the one that will be dropped by rotation.
|
|
577
|
+
// Give it 2 decisions and 1 discovered_convention.
|
|
578
|
+
let first = json!({
|
|
579
|
+
"sessions": [{
|
|
580
|
+
"session_id": "sess-00",
|
|
581
|
+
"slug": "sess-00",
|
|
582
|
+
"finished_at": "2026-01-01T00:00:00Z",
|
|
583
|
+
"deliverables": [],
|
|
584
|
+
"decisions": [
|
|
585
|
+
{"topic": "T1", "decision": "D1", "rationale": "R1", "session_id": "sess-00"},
|
|
586
|
+
{"topic": "T2", "decision": "D2", "rationale": "R2", "session_id": "sess-00"}
|
|
587
|
+
],
|
|
588
|
+
"discovered_conventions": [
|
|
589
|
+
{"area": "A1", "convention": "C1", "source_session": "sess-00"}
|
|
590
|
+
],
|
|
591
|
+
"resolved_ambiguities": []
|
|
592
|
+
}]
|
|
593
|
+
});
|
|
594
|
+
update(&root_str, &first.to_string()).expect("update ok (sess-00)");
|
|
595
|
+
|
|
596
|
+
// Ten more plain sessions; this causes sess-00 to be rotated out.
|
|
597
|
+
for i in 1..=10 {
|
|
598
|
+
let patch = json!({
|
|
599
|
+
"sessions": [{
|
|
600
|
+
"session_id": format!("sess-{:02}", i),
|
|
601
|
+
"slug": format!("sess-{:02}", i),
|
|
602
|
+
"finished_at": "2026-01-01T00:00:00Z",
|
|
603
|
+
"deliverables": [],
|
|
604
|
+
"decisions": [],
|
|
605
|
+
"discovered_conventions": [],
|
|
606
|
+
"resolved_ambiguities": []
|
|
607
|
+
}]
|
|
608
|
+
});
|
|
609
|
+
update(&root_str, &patch.to_string()).expect("update ok");
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
let mem_path = root
|
|
613
|
+
.join(".compass")
|
|
614
|
+
.join(".state")
|
|
615
|
+
.join("project-memory.json");
|
|
616
|
+
let data: Value = serde_json::from_str(
|
|
617
|
+
&fs::read_to_string(&mem_path).expect("read memory file"),
|
|
618
|
+
)
|
|
619
|
+
.expect("valid json");
|
|
620
|
+
|
|
621
|
+
// Rotation happened.
|
|
622
|
+
let sessions = data["sessions"].as_array().expect("sessions array");
|
|
623
|
+
assert_eq!(sessions.len(), 10);
|
|
624
|
+
assert!(
|
|
625
|
+
!sessions
|
|
626
|
+
.iter()
|
|
627
|
+
.any(|s| s["session_id"] == json!("sess-00")),
|
|
628
|
+
"sess-00 should have been rotated out"
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
// Aggregates preserved (dedup-aware: >= original counts).
|
|
632
|
+
let decisions = data["decisions"].as_array().expect("decisions array");
|
|
633
|
+
assert!(
|
|
634
|
+
decisions.len() >= 2,
|
|
635
|
+
"expected >=2 top-level decisions after rotation, got {}",
|
|
636
|
+
decisions.len()
|
|
637
|
+
);
|
|
638
|
+
let convs = data["discovered_conventions"]
|
|
639
|
+
.as_array()
|
|
640
|
+
.expect("discovered_conventions array");
|
|
641
|
+
assert!(
|
|
642
|
+
convs.len() >= 1,
|
|
643
|
+
"expected >=1 top-level discovered_conventions after rotation, got {}",
|
|
644
|
+
convs.len()
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
cleanup_tmp(&root);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/// REQ-04: `get --key sessions.0.session_id` returns the FIRST session's
|
|
651
|
+
/// id — which, given FIFO semantics after a single append, is the most
|
|
652
|
+
/// recently added one (the array has length 1 at that point).
|
|
653
|
+
#[test]
|
|
654
|
+
fn get_dot_path() {
|
|
655
|
+
let root = unique_tmp_dir("get_dot_path");
|
|
656
|
+
let root_str = root.to_string_lossy().to_string();
|
|
657
|
+
init(&root_str).expect("init ok");
|
|
658
|
+
|
|
659
|
+
let patch = json!({
|
|
660
|
+
"sessions": [{
|
|
661
|
+
"session_id": "most-recent-abc",
|
|
662
|
+
"slug": "most-recent-abc",
|
|
663
|
+
"finished_at": "2026-01-01T00:00:00Z",
|
|
664
|
+
"deliverables": [],
|
|
665
|
+
"decisions": [],
|
|
666
|
+
"discovered_conventions": [],
|
|
667
|
+
"resolved_ambiguities": []
|
|
668
|
+
}]
|
|
669
|
+
});
|
|
670
|
+
update(&root_str, &patch.to_string()).expect("update ok");
|
|
671
|
+
|
|
672
|
+
let raw = get(&root_str, Some("sessions.0.session_id")).expect("get ok");
|
|
673
|
+
// `get` pretty-prints via serde_json; the returned string must parse
|
|
674
|
+
// to a JSON string equal to the session id.
|
|
675
|
+
let parsed: Value = serde_json::from_str(&raw).expect("get output is json");
|
|
676
|
+
assert_eq!(parsed, json!("most-recent-abc"));
|
|
677
|
+
|
|
678
|
+
cleanup_tmp(&root);
|
|
679
|
+
}
|
|
680
|
+
}
|