projecta-rrr 1.16.5 → 1.16.7

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.
@@ -535,6 +535,58 @@ if (require.main === module) {
535
535
  process.exit(summary.verification.valid ? 0 : 1);
536
536
  }
537
537
 
538
+ /**
539
+ * Clean up old quarantine directories
540
+ * Removes quarantine folders older than maxAgeMs to prevent duplicates from being scanned
541
+ * @param {object} options
542
+ * @param {string} [options.configDir] - Explicit config directory
543
+ * @param {number} [options.maxAgeMs=7*24*60*60*1000] - Max age in ms (default 7 days)
544
+ * @returns {{cleaned: number, errors: string[]}}
545
+ */
546
+ function cleanOldQuarantines(options = {}, maxAgeMs = 7 * 24 * 60 * 60 * 1000) {
547
+ const roots = getCanonicalRoots(options);
548
+ const quarantineRoot = roots.quarantineRoot;
549
+ const cleaned = [];
550
+ const errors = [];
551
+
552
+ if (!fs.existsSync(quarantineRoot)) {
553
+ return { cleaned: 0, errors: [] };
554
+ }
555
+
556
+ const now = Date.now();
557
+ const entries = fs.readdirSync(quarantineRoot, { withFileTypes: true });
558
+
559
+ for (const entry of entries) {
560
+ if (!entry.isDirectory()) continue;
561
+ if (entry.name === 'QUARANTINE_LOG.json') continue;
562
+
563
+ const dirPath = path.join(quarantineRoot, entry.name);
564
+ const stats = fs.statSync(dirPath);
565
+ const age = now - stats.mtimeMs;
566
+
567
+ if (age > maxAgeMs) {
568
+ try {
569
+ // Delete directory contents and directory itself
570
+ const files = fs.readdirSync(dirPath);
571
+ for (const file of files) {
572
+ const filePath = path.join(dirPath, file);
573
+ if (fs.statSync(filePath).isDirectory()) {
574
+ fs.rmSync(filePath, { recursive: true });
575
+ } else {
576
+ fs.unlinkSync(filePath);
577
+ }
578
+ }
579
+ fs.rmdirSync(dirPath);
580
+ cleaned.push(entry.name);
581
+ } catch (e) {
582
+ errors.push(`Failed to clean ${entry.name}: ${e.message}`);
583
+ }
584
+ }
585
+ }
586
+
587
+ return { cleaned, errors };
588
+ }
589
+
538
590
  module.exports = {
539
591
  getCanonicalRoots,
540
592
  detectAllCandidateRoots,
@@ -545,5 +597,6 @@ module.exports = {
545
597
  getInstallInfo,
546
598
  detectAndQuarantineDuplicates,
547
599
  verifyInstallation,
548
- getInstallationSummary
600
+ getInstallationSummary,
601
+ cleanOldQuarantines
549
602
  };
@@ -0,0 +1,412 @@
1
+ /**
2
+ * RRR Memory Store
3
+ *
4
+ * Central memory library for tracking:
5
+ * - Command history (what commands user ran)
6
+ * - Intent tracking (what user is trying to accomplish)
7
+ * - Decision log (key decisions made)
8
+ * - Drift detection (is user on track?)
9
+ *
10
+ * Uses existing LanceDB for semantic memory and JSON for structured data.
11
+ * NO new commands - this is a library integrated into existing workflows.
12
+ *
13
+ * Usage:
14
+ * const memory = require('./rrr/lib/memory-store');
15
+ * await memory.init();
16
+ * await memory.trackCommand('/rrr:execute-plan', '52-01', { intent: 'TTS integration' });
17
+ * const drift = await memory.detectDrift(['src/db/schema.ts']);
18
+ */
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+
23
+ // Configuration
24
+ const MEMORY_DIR = '.rrr/memory';
25
+ const COMMAND_FILE = '.planning/rrr-command-memory.json';
26
+ const DECISIONS_FILE = '.planning/rrr-decisions.json';
27
+
28
+ /**
29
+ * Get active milestone from ROADMAP.md
30
+ */
31
+ function getActiveMilestone() {
32
+ const roadmap = path.join(process.cwd(), '.planning', 'ROADMAP.md');
33
+ if (!fs.existsSync(roadmap)) return null;
34
+ const content = fs.readFileSync(roadmap, 'utf8');
35
+ const match = content.match(/^## Current Milestone:\s*(v[0-9]+\.[0-9]+)/m);
36
+ return match ? match[1] : null;
37
+ }
38
+
39
+ /**
40
+ * Initialize memory store
41
+ */
42
+ async function initMemory() {
43
+ const projectPath = process.cwd();
44
+ const memoryPath = path.join(projectPath, MEMORY_DIR);
45
+ const dbPath = path.join(memoryPath, 'memory.lance');
46
+
47
+ // Create memory directory
48
+ if (!fs.existsSync(memoryPath)) {
49
+ fs.mkdirSync(memoryPath, { recursive: true });
50
+ }
51
+
52
+ // Connect to LanceDB
53
+ let semanticDb = null;
54
+ let semanticTable = null;
55
+ try {
56
+ const lancedb = require('@lancedb/lancedb');
57
+ semanticDb = await lancedb.connect(dbPath);
58
+ const tableNames = await semanticDb.tableNames();
59
+ if (!tableNames.includes('memories')) {
60
+ await semanticDb.createEmptyTable('memories', [
61
+ { id: 'string', vector: 'float32[1024]', type: 'string', content: 'string', milestone: 'string', created_at: 'int64' }
62
+ ]);
63
+ }
64
+ semanticTable = await semanticDb.openTable('memories');
65
+ } catch (e) {
66
+ // LanceDB not available, continue with JSON-only mode
67
+ }
68
+
69
+ return {
70
+ projectPath,
71
+ memoryPath,
72
+ semanticDb,
73
+ semanticTable,
74
+ commands: loadJson(COMMAND_FILE, { version: 1, commands: [] }),
75
+ decisions: loadJson(DECISIONS_FILE, { version: 1, decisions: [] }),
76
+ sessionStart: Date.now()
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Load JSON or return default
82
+ */
83
+ function loadJson(file, defaultValue) {
84
+ if (fs.existsSync(file)) {
85
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return defaultValue; }
86
+ }
87
+ return defaultValue;
88
+ }
89
+
90
+ /**
91
+ * Save memory to disk
92
+ */
93
+ function saveMemory(memory) {
94
+ fs.writeFileSync(COMMAND_FILE, JSON.stringify(memory.commands, null, 2));
95
+ fs.writeFileSync(DECISIONS_FILE, JSON.stringify(memory.decisions, null, 2));
96
+ }
97
+
98
+ /**
99
+ * Track a command execution
100
+ */
101
+ async function trackCommand(memory, { command, target, intent, reason, files }) {
102
+ const entry = {
103
+ timestamp: Date.now(),
104
+ command,
105
+ target,
106
+ intent,
107
+ reason,
108
+ files: files || [],
109
+ milestone: getActiveMilestone()
110
+ };
111
+ memory.commands.commands.push(entry);
112
+
113
+ // Keep only last 100 commands
114
+ if (memory.commands.commands.length > 100) {
115
+ memory.commands.commands = memory.commands.commands.slice(-100);
116
+ }
117
+
118
+ // Add semantic memory if intent provided
119
+ if (intent && memory.semanticTable) {
120
+ try {
121
+ const embedding = await generateEmbedding(intent);
122
+ await memory.semanticTable.add([{
123
+ id: `cmd:${entry.timestamp}`,
124
+ type: 'intent',
125
+ content: `${command}: ${intent}`,
126
+ vector: embedding,
127
+ milestone: entry.milestone,
128
+ created_at: entry.timestamp
129
+ }]);
130
+ } catch (e) {
131
+ // Silently fail semantic indexing
132
+ }
133
+ }
134
+
135
+ saveMemory(memory);
136
+ return entry;
137
+ }
138
+
139
+ /**
140
+ * Record a decision
141
+ */
142
+ async function recordDecision(memory, { decision, rationale, tradeoffs, alternatives, context, relatedPlans }) {
143
+ const id = `DEC-${String(memory.decisions.decisions.length + 1).padStart(3, '0')}`;
144
+ const entry = {
145
+ id,
146
+ timestamp: Date.now(),
147
+ decision,
148
+ rationale,
149
+ tradeoffs,
150
+ alternatives: alternatives || [],
151
+ context,
152
+ relatedPlans: relatedPlans || [],
153
+ outcome: 'pending',
154
+ milestone: getActiveMilestone()
155
+ };
156
+ memory.decisions.decisions.push(entry);
157
+ saveMemory(memory);
158
+ return entry;
159
+ }
160
+
161
+ /**
162
+ * Get current session intent (last intent mentioned)
163
+ */
164
+ function getCurrentIntent(memory) {
165
+ // Find most recent command with intent
166
+ for (let i = memory.commands.commands.length - 1; i >= 0; i--) {
167
+ if (memory.commands.commands[i].intent) {
168
+ return memory.commands.commands[i];
169
+ }
170
+ }
171
+ return null;
172
+ }
173
+
174
+ /**
175
+ * Get recent commands
176
+ */
177
+ function getRecentCommands(memory, limit = 10) {
178
+ return memory.commands.commands.slice(-limit).reverse();
179
+ }
180
+
181
+ /**
182
+ * Get recent decisions
183
+ */
184
+ function getRecentDecisions(memory, limit = 5) {
185
+ return memory.decisions.decisions.slice(-limit).reverse();
186
+ }
187
+
188
+ /**
189
+ * Detect drift - is user working on things related to their intent?
190
+ */
191
+ async function detectDrift(memory, currentFiles = []) {
192
+ const signals = [];
193
+ const intent = getCurrentIntent(memory);
194
+
195
+ if (!intent) {
196
+ return { drifting: false, signals, message: 'No intent tracked yet' };
197
+ }
198
+
199
+ // 1. Check if current files relate to intent
200
+ if (currentFiles.length > 0 && intent.intent) {
201
+ let relatedCount = 0;
202
+
203
+ // Simple keyword matching as fallback
204
+ const intentWords = intent.intent.toLowerCase().split(/\s+/);
205
+ for (const file of currentFiles) {
206
+ const fileContent = file.toLowerCase();
207
+ for (const word of intentWords) {
208
+ if (word.length > 3 && fileContent.includes(word)) {
209
+ relatedCount++;
210
+ break;
211
+ }
212
+ }
213
+ }
214
+
215
+ const relatedRatio = currentFiles.length > 0 ? relatedCount / currentFiles.length : 1;
216
+ if (relatedRatio < 0.3 && currentFiles.length >= 3) {
217
+ signals.push({
218
+ type: 'context_drift',
219
+ severity: 'high',
220
+ message: `Working on ${currentFiles.length} files but only ${relatedCount} relate to intent`,
221
+ intent: intent.intent,
222
+ suggestion: 'Are you switching contexts? Consider /rrr:pause-work'
223
+ });
224
+ }
225
+ }
226
+
227
+ // 2. Check for stale incomplete plans (>48 hours)
228
+ const staleThreshold = 48 * 60 * 60 * 1000;
229
+ const stalePlans = memory.commands.commands.filter(c =>
230
+ c.command.includes('execute-plan') &&
231
+ c.outcome !== 'completed' &&
232
+ Date.now() - c.timestamp > staleThreshold
233
+ );
234
+
235
+ if (stalePlans.length > 0) {
236
+ signals.push({
237
+ type: 'stale_plan',
238
+ severity: 'low',
239
+ message: `${stalePlans.length} plans incomplete for >48 hours`,
240
+ plans: stalePlans.map(p => p.target),
241
+ suggestion: 'Run /rrr:verify-work or /rrr:pause-work'
242
+ });
243
+ }
244
+
245
+ // 3. Check for conflicting decisions
246
+ const recentDecisions = getRecentDecisions(memory, 5);
247
+ const conflictSignals = recentDecisions.filter(d => {
248
+ if (d.outcome === 'rejected') return false;
249
+ return currentFiles.some(f => d.relatedPlans?.some(p => f.includes(p)));
250
+ });
251
+
252
+ if (conflictSignals.length > 0) {
253
+ signals.push({
254
+ type: 'decision_conflict',
255
+ severity: 'medium',
256
+ message: 'Current work may conflict with past decisions',
257
+ decisions: conflictSignals.map(d => d.id),
258
+ suggestion: 'Review past decisions before proceeding'
259
+ });
260
+ }
261
+
262
+ return {
263
+ drifting: signals.length > 0,
264
+ signals,
265
+ currentIntent: intent.intent,
266
+ severity: signals.length > 2 ? 'high' : signals.length > 0 ? 'medium' : 'none',
267
+ message: signals.length === 0 ? 'On track - working on intent-aligned files' : 'Drift detected'
268
+ };
269
+ }
270
+
271
+ /**
272
+ * Generate embedding using existing Ollama setup
273
+ */
274
+ async function generateEmbedding(text) {
275
+ try {
276
+ const embeddings = require('./search/embeddings');
277
+ const result = await embeddings.generateQwenEmbedding(text);
278
+ return result.embedding;
279
+ } catch (e) {
280
+ // Return zero vector if embedding fails
281
+ return new Array(1024).fill(0);
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Get memory summary for HUD
287
+ */
288
+ async function getSummary(memory) {
289
+ const recentCommands = getRecentCommands(memory, 5);
290
+ const recentDecisions = getRecentDecisions(memory, 3);
291
+ const intent = getCurrentIntent(memory);
292
+ const drift = await detectDrift(memory, []);
293
+
294
+ return {
295
+ sessionStart: new Date(memory.sessionStart).toLocaleString(),
296
+ lastCommand: recentCommands[0]?.command || 'none',
297
+ lastTarget: recentCommands[0]?.target || '',
298
+ currentIntent: intent?.intent || 'tracking...',
299
+ commandCount: memory.commands.commands.length,
300
+ decisionCount: memory.decisions.decisions.length,
301
+ recentCommands: recentCommands.map(c => ({
302
+ cmd: c.command.replace('/rrr:', ''),
303
+ target: c.target,
304
+ time: new Date(c.timestamp).toLocaleTimeString(),
305
+ intent: c.intent
306
+ })),
307
+ recentDecisions: recentDecisions.map(d => ({
308
+ id: d.id,
309
+ decision: d.decision.substring(0, 50),
310
+ outcome: d.outcome
311
+ })),
312
+ drift
313
+ };
314
+ }
315
+
316
+ /**
317
+ * Get project state for guidance
318
+ */
319
+ async function getProjectState() {
320
+ const planningDir = path.join(process.cwd(), '.planning');
321
+ const state = {
322
+ projectType: 'NEW_PROJECT',
323
+ milestoneState: 'NO_MILESTONE',
324
+ phaseState: 'NO_PHASES',
325
+ phases: []
326
+ };
327
+
328
+ if (!fs.existsSync(planningDir)) {
329
+ return state;
330
+ }
331
+
332
+ // Detect project type
333
+ const hasProject = fs.existsSync(path.join(planningDir, 'PROJECT.md'));
334
+ const hasMilestones = fs.existsSync(path.join(planningDir, 'milestones'));
335
+
336
+ if (hasProject && hasMilestones) {
337
+ state.projectType = 'EXISTING_PROJECT';
338
+ } else if (hasProject) {
339
+ state.projectType = 'PROJECT_INITIALIZED';
340
+ }
341
+
342
+ // Detect milestone state
343
+ const roadmap = path.join(planningDir, 'ROADMAP.md');
344
+ if (fs.existsSync(roadmap)) {
345
+ const content = fs.readFileSync(roadmap, 'utf8');
346
+ if (content.includes('## Current Milestone:')) {
347
+ if (content.includes(':construction:')) {
348
+ state.milestoneState = 'MILESTONE_STARTING';
349
+ } else {
350
+ state.milestoneState = 'ACTIVE_MILESTONE';
351
+ }
352
+ } else if (content.includes('SHIPPED') || content.includes('ARCHIVED')) {
353
+ state.milestoneState = 'COMPLETING_MILESTONE';
354
+ } else {
355
+ state.milestoneState = 'NO_MILESTONE';
356
+ }
357
+ }
358
+
359
+ // Detect phase state
360
+ if (state.milestoneState === 'ACTIVE_MILESTONE') {
361
+ const match = content.match(/## Current Milestone:\s*(v[0-9]+\.[0-9]+)/);
362
+ if (match) {
363
+ const phasesDir = path.join(planningDir, 'milestones', match[1], 'phases');
364
+ if (fs.existsSync(phasesDir)) {
365
+ const phases = [];
366
+ for (const dir of fs.readdirSync(phasesDir)) {
367
+ if (!dir.match(/^\d/)) continue;
368
+ const phaseDir = path.join(phasesDir, dir);
369
+ const planCount = fs.readdirSync(phaseDir).filter(f => f.endsWith('-PLAN.md')).length;
370
+ const summaryCount = fs.readdirSync(phaseDir).filter(f => f.endsWith('-SUMMARY.md')).length;
371
+ phases.push({
372
+ name: dir,
373
+ plans: planCount,
374
+ completed: summaryCount,
375
+ pct: planCount > 0 ? Math.round((summaryCount / planCount) * 100) : 0
376
+ });
377
+ }
378
+ state.phases = phases.sort((a, b) =>
379
+ parseInt(a.name.split('-')[0]) - parseInt(b.name.split('-')[0])
380
+ );
381
+
382
+ const totalPlans = state.phases.reduce((sum, p) => sum + p.plans, 0);
383
+ const donePlans = state.phases.reduce((sum, p) => sum + p.completed, 0);
384
+
385
+ if (totalPlans === 0) {
386
+ state.phaseState = 'NO_PHASES';
387
+ } else if (donePlans === 0) {
388
+ state.phaseState = 'PLANNED';
389
+ } else if (donePlans < totalPlans) {
390
+ state.phaseState = 'EXECUTING';
391
+ } else if (donePlans === totalPlans) {
392
+ state.phaseState = 'COMPLETE';
393
+ }
394
+ }
395
+ }
396
+ }
397
+
398
+ return state;
399
+ }
400
+
401
+ // Export functions
402
+ module.exports = {
403
+ initMemory,
404
+ trackCommand,
405
+ recordDecision,
406
+ getCurrentIntent,
407
+ getRecentCommands,
408
+ getRecentDecisions,
409
+ detectDrift,
410
+ getSummary,
411
+ getProjectState
412
+ };
@@ -28,25 +28,45 @@ get_phase_base_dir() {
28
28
  local planning_dir="${1:-.planning}"
29
29
  local roadmap_file="$planning_dir/ROADMAP.md"
30
30
 
31
- # Check if we have milestone-based structure with active milestone
32
- if [ -d "$planning_dir/milestones" ]; then
33
- # Get active milestone from ROADMAP.md
34
- local active_milestone=""
35
- if [ -f "$roadmap_file" ]; then
36
- # Look for "Current Milestone:" or ":construction:" marker for in-progress milestone
37
- active_milestone=$(grep -E "Current Milestone:|:construction:" "$roadmap_file" 2>/dev/null | head -1 | grep -oE "v[0-9]+\.[0-9]+" | head -1)
38
- fi
31
+ # Strategy 1: Look for ## Current Milestone: header
32
+ local active_milestone=""
33
+ if [ -f "$roadmap_file" ]; then
34
+ active_milestone=$(grep -E "^## Current Milestone:" "$roadmap_file" 2>/dev/null | \
35
+ grep -oE "v[0-9]+\.[0-9]+" | head -1)
36
+ fi
39
37
 
40
- # If we found an active milestone, use its phases directory (create if needed)
41
- if [ -n "$active_milestone" ]; then
42
- local phases_dir="$planning_dir/milestones/$active_milestone/phases"
43
- mkdir -p "$phases_dir" 2>/dev/null
44
- echo "$phases_dir"
45
- return 0
46
- fi
38
+ # Strategy 2: Look for :construction: marker
39
+ if [ -z "$active_milestone" ]; then
40
+ active_milestone=$(grep -E ":construction:" "$roadmap_file" 2>/dev/null | \
41
+ grep -oE "v[0-9]+\.[0-9]+" | head -1)
47
42
  fi
48
43
 
49
- # Fallback to old structure only if no milestones directory exists
44
+ # Strategy 3: Find milestone directory with phases (even without ROADMAP entry)
45
+ if [ -z "$active_milestone" ] && [ -d "$planning_dir/milestones" ]; then
46
+ for milestone_dir in "$planning_dir/milestones"/v*/; do
47
+ if [ -d "${milestone_dir}phases" ] && [ "$(ls -A "${milestone_dir}phases" 2>/dev/null)" ]; then
48
+ active_milestone=$(basename "$milestone_dir")
49
+ break
50
+ fi
51
+ done
52
+ fi
53
+
54
+ # ERROR if milestones dir exists but no active milestone
55
+ if [ -d "$planning_dir/milestones" ] && [ -z "$active_milestone" ]; then
56
+ echo "ERROR: .planning/milestones/ exists but no active milestone detected" >&2
57
+ echo "Add '## Current Milestone: vX.Y Name' to ROADMAP.md" >&2
58
+ return 1
59
+ fi
60
+
61
+ # Use milestone phases dir if found
62
+ if [ -n "$active_milestone" ]; then
63
+ local phases_dir="$planning_dir/milestones/$active_milestone/phases"
64
+ mkdir -p "$phases_dir" 2>/dev/null
65
+ echo "$phases_dir"
66
+ return 0
67
+ fi
68
+
69
+ # Legacy fallback ONLY if no milestones directory at all
50
70
  echo "$planning_dir/phases"
51
71
  return 0
52
72
  }
@@ -57,12 +77,16 @@ get_phase_base_dir() {
57
77
 
58
78
  **Returns:**
59
79
  - Path to the phase base directory (e.g., `.planning/phases` or `.planning/milestones/v1.11/phases`)
80
+ - Exits with error if `.planning/milestones/` exists but no active milestone is detected
60
81
 
61
82
  **Example:**
62
83
  ```bash
63
84
  base_dir=$(get_phase_base_dir)
64
85
  echo "Phases stored in: $base_dir"
65
86
  # Output: "Phases stored in: .planning/milestones/v1.11/phases"
87
+
88
+ # Error case (milestones dir exists but no active milestone):
89
+ # ERROR: .planning/milestones/ exists but no active milestone detected
66
90
  ```
67
91
 
68
92
  ---