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.
Files changed (118) hide show
  1. package/README.md +105 -0
  2. package/VERSION +1 -0
  3. package/bin/install +174 -0
  4. package/bootstrap.sh +95 -0
  5. package/cli/Cargo.lock +270 -0
  6. package/cli/Cargo.toml +24 -0
  7. package/cli/src/cmd/context.rs +59 -0
  8. package/cli/src/cmd/dag.rs +133 -0
  9. package/cli/src/cmd/git.rs +148 -0
  10. package/cli/src/cmd/hook.rs +51 -0
  11. package/cli/src/cmd/index.rs +363 -0
  12. package/cli/src/cmd/manifest.rs +34 -0
  13. package/cli/src/cmd/memory.rs +680 -0
  14. package/cli/src/cmd/migrate.rs +790 -0
  15. package/cli/src/cmd/mod.rs +14 -0
  16. package/cli/src/cmd/progress.rs +107 -0
  17. package/cli/src/cmd/project.rs +1700 -0
  18. package/cli/src/cmd/session.rs +64 -0
  19. package/cli/src/cmd/state.rs +317 -0
  20. package/cli/src/cmd/validate/mod.rs +506 -0
  21. package/cli/src/cmd/validate/prd.rs +472 -0
  22. package/cli/src/cmd/version.rs +89 -0
  23. package/cli/src/helpers.rs +40 -0
  24. package/cli/src/main.rs +75 -0
  25. package/cli/tests/fixtures/plan_empty_pointers.json +60 -0
  26. package/cli/tests/fixtures/plan_missing_pointers.json +59 -0
  27. package/cli/tests/fixtures/plan_too_many_pointers.json +92 -0
  28. package/cli/tests/fixtures/plan_v1_valid.json +64 -0
  29. package/cli/tests/fixtures/prd_bad_flow_bullet.md +37 -0
  30. package/cli/tests/fixtures/prd_bad_flow_prose.md +33 -0
  31. package/cli/tests/fixtures/prd_good_flow.md +41 -0
  32. package/cli/tests/fixtures/prd_xref_dangling.md +38 -0
  33. package/cli/tests/fixtures/prd_xref_valid.md +53 -0
  34. package/cli/tests/fixtures/projects/proj_a/.compass/.state/config.json +12 -0
  35. package/cli/tests/fixtures/projects/proj_b/.compass/.state/config.json +12 -0
  36. package/cli/tests/fixtures/projects/proj_c/.compass/.state/config.json +12 -0
  37. package/cli/tests/fixtures/registry/all_dead.json +18 -0
  38. package/cli/tests/fixtures/registry/corrupt.json +1 -0
  39. package/cli/tests/fixtures/registry/empty.json +1 -0
  40. package/cli/tests/fixtures/registry/last_active_dead.json +24 -0
  41. package/cli/tests/fixtures/registry/multi_alive.json +24 -0
  42. package/cli/tests/fixtures/registry/one_alive.json +12 -0
  43. package/cli/tests/fixtures/v0_project/.compass/.state/config.json +5 -0
  44. package/cli/tests/fixtures/v0_project/.compass/.state/sessions/onboarding-redesign/plan.json +29 -0
  45. package/cli/tests/fixtures/v0_project/.compass/.state/sessions/sample-feature/context.json +11 -0
  46. package/cli/tests/fixtures/v0_project/.compass/.state/sessions/sample-feature/plan.json +49 -0
  47. package/core/colleagues/base-rules.md +112 -0
  48. package/core/colleagues/manifest.json +85 -0
  49. package/core/colleagues/market-analyst.md +50 -0
  50. package/core/colleagues/prioritizer.md +53 -0
  51. package/core/colleagues/researcher.md +54 -0
  52. package/core/colleagues/reviewer.md +55 -0
  53. package/core/colleagues/stakeholder-comm.md +59 -0
  54. package/core/colleagues/story-breaker.md +57 -0
  55. package/core/colleagues/ux-reviewer.md +54 -0
  56. package/core/colleagues/writer.md +55 -0
  57. package/core/commands/compass/brief.md +28 -0
  58. package/core/commands/compass/check.md +27 -0
  59. package/core/commands/compass/epic.md +32 -0
  60. package/core/commands/compass/feedback.md +32 -0
  61. package/core/commands/compass/help.md +24 -0
  62. package/core/commands/compass/ideate.md +32 -0
  63. package/core/commands/compass/init.md +30 -0
  64. package/core/commands/compass/plan.md +27 -0
  65. package/core/commands/compass/prd.md +39 -0
  66. package/core/commands/compass/prioritize.md +36 -0
  67. package/core/commands/compass/prototype.md +28 -0
  68. package/core/commands/compass/release.md +32 -0
  69. package/core/commands/compass/research.md +31 -0
  70. package/core/commands/compass/roadmap.md +32 -0
  71. package/core/commands/compass/run.md +28 -0
  72. package/core/commands/compass/setup.md +32 -0
  73. package/core/commands/compass/sprint.md +32 -0
  74. package/core/commands/compass/status.md +32 -0
  75. package/core/commands/compass/story.md +37 -0
  76. package/core/commands/compass/undo.md +33 -0
  77. package/core/commands/compass/update.md +29 -0
  78. package/core/hooks/context-monitor.sh +5 -0
  79. package/core/hooks/manifest-tracker.sh +62 -0
  80. package/core/hooks/statusline.sh +12 -0
  81. package/core/hooks/update-checker.sh +24 -0
  82. package/core/integrations/confluence.md +267 -0
  83. package/core/integrations/figma.md +277 -0
  84. package/core/integrations/jira.md +436 -0
  85. package/core/integrations/vercel.md +170 -0
  86. package/core/manifest.json +172 -0
  87. package/core/shared/SCHEMAS-v1.md +404 -0
  88. package/core/shared/progress.md +145 -0
  89. package/core/shared/project-scan.md +293 -0
  90. package/core/shared/resolve-project.md +136 -0
  91. package/core/shared/ux-rules.md +52 -0
  92. package/core/shared/version-backup.md +38 -0
  93. package/core/templates/prd-template.md +145 -0
  94. package/core/templates/story-template.md +99 -0
  95. package/core/workflows/brief.md +184 -0
  96. package/core/workflows/check.md +436 -0
  97. package/core/workflows/epic.md +177 -0
  98. package/core/workflows/feedback.md +164 -0
  99. package/core/workflows/help.md +79 -0
  100. package/core/workflows/ideate.md +320 -0
  101. package/core/workflows/init.md +524 -0
  102. package/core/workflows/migrate.md +136 -0
  103. package/core/workflows/plan.md +320 -0
  104. package/core/workflows/prd.md +632 -0
  105. package/core/workflows/prioritize.md +301 -0
  106. package/core/workflows/project.md +177 -0
  107. package/core/workflows/prototype.md +174 -0
  108. package/core/workflows/release.md +179 -0
  109. package/core/workflows/research.md +613 -0
  110. package/core/workflows/roadmap.md +152 -0
  111. package/core/workflows/run.md +367 -0
  112. package/core/workflows/setup.md +294 -0
  113. package/core/workflows/sprint.md +187 -0
  114. package/core/workflows/status.md +185 -0
  115. package/core/workflows/story.md +477 -0
  116. package/core/workflows/undo.md +42 -0
  117. package/core/workflows/update.md +127 -0
  118. 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
+ }