@equilateral_ai/mindmeld 3.5.1 → 3.5.3

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.
@@ -291,6 +291,31 @@ async function recordSessionEnd() {
291
291
  return { skipped: true, reason: 'No session_id' };
292
292
  }
293
293
 
294
+ // Stop session watcher — write stop file (primary) + SIGTERM (secondary)
295
+ try {
296
+ // Write stop file — watcher checks this on its liveness interval
297
+ await fs.writeFile(path.join(cwd, '.mindmeld', 'watcher.stop'), '').catch(() => {});
298
+
299
+ const pidPath = path.join(cwd, '.mindmeld', 'watcher.pid');
300
+ const pidContent = await fs.readFile(pidPath, 'utf-8');
301
+ const watcherPid = parseInt(pidContent.trim(), 10);
302
+ if (watcherPid && !isNaN(watcherPid)) {
303
+ try {
304
+ process.kill(watcherPid, 'SIGTERM');
305
+ console.error(`[MindMeld] Stopped session watcher (PID: ${watcherPid})`);
306
+ } catch (killErr) {
307
+ if (killErr.code !== 'ESRCH') {
308
+ console.error(`[MindMeld] Watcher kill failed: ${killErr.message}`);
309
+ }
310
+ }
311
+ }
312
+ await fs.unlink(pidPath).catch(() => {});
313
+ } catch (err) {
314
+ if (err.code !== 'ENOENT') {
315
+ console.error(`[MindMeld] Watcher cleanup error (non-fatal): ${err.message}`);
316
+ }
317
+ }
318
+
294
319
  // Load auth and config in parallel
295
320
  const [authToken, apiConfig, projectId, duration, gitBranch] = await Promise.all([
296
321
  loadAuthToken(),
@@ -771,13 +771,31 @@ async function injectContext() {
771
771
  // 3b. Persist session context for pre-compact hook
772
772
  // Pre-compact creates a new MindmeldClient and needs project context
773
773
  try {
774
+ // Derive transcript path for watcher breadcrumb
775
+ const encodedCwd = process.cwd().replace(/\//g, '-');
776
+ const claudeProjectDir = path.join(require('os').homedir(), '.claude', 'projects', encodedCwd);
777
+ let transcriptPath = null;
778
+ try {
779
+ const dirFiles = await fs.readdir(claudeProjectDir);
780
+ const jsonlFiles = dirFiles.filter(f => f.endsWith('.jsonl'));
781
+ let newestMtime = 0;
782
+ for (const f of jsonlFiles) {
783
+ const fp = path.join(claudeProjectDir, f);
784
+ const s = await fs.stat(fp);
785
+ if (s.mtimeMs > newestMtime) { newestMtime = s.mtimeMs; transcriptPath = fp; }
786
+ }
787
+ } catch (err) {
788
+ // Claude project dir may not exist yet
789
+ }
790
+
774
791
  const sessionContext = {
775
792
  sessionId: sessionId,
776
793
  projectId: context.projectId,
777
794
  projectName: context.projectName,
778
795
  companyId: context.companyId,
779
796
  userEmail: context.userEmail,
780
- startedAt: new Date().toISOString()
797
+ startedAt: new Date().toISOString(),
798
+ transcriptPath: transcriptPath
781
799
  };
782
800
  const mindmeldDir = path.join(process.cwd(), '.mindmeld');
783
801
  await fs.mkdir(mindmeldDir, { recursive: true });
@@ -789,6 +807,41 @@ async function injectContext() {
789
807
  console.error('[MindMeld] Could not persist session context (non-fatal):', err.message);
790
808
  }
791
809
 
810
+ // 3c. Spawn background session watcher for continuous pattern harvesting
811
+ // (covers 1M context sessions where pre-compact may never fire)
812
+ // Watcher survives /clear — if already alive, it picks up the new transcript via breadcrumb
813
+ try {
814
+ const pidPath = path.join(process.cwd(), '.mindmeld', 'watcher.pid');
815
+ let watcherAlive = false;
816
+ try {
817
+ const existingPid = parseInt(await fs.readFile(pidPath, 'utf-8'), 10);
818
+ if (existingPid && !isNaN(existingPid)) {
819
+ process.kill(existingPid, 0); // Check if alive
820
+ watcherAlive = true;
821
+ console.error(`[MindMeld] Existing watcher alive (PID: ${existingPid}), will pick up new transcript via breadcrumb`);
822
+ }
823
+ } catch (err) {
824
+ // No PID file or process gone — spawn a new one
825
+ }
826
+
827
+ if (!watcherAlive) {
828
+ const { spawn } = require('child_process');
829
+ const watcherScript = path.resolve(__dirname, 'session-watcher.js');
830
+ await fs.access(watcherScript);
831
+ const parentPid = process.ppid || process.pid;
832
+ const watcher = spawn(process.execPath, [watcherScript, process.cwd(), String(parentPid)], {
833
+ detached: true,
834
+ stdio: ['ignore', 'ignore', 'ignore'],
835
+ cwd: process.cwd(),
836
+ env: { ...process.env }
837
+ });
838
+ watcher.unref();
839
+ console.error(`[MindMeld] Session watcher spawned (PID: ${watcher.pid})`);
840
+ }
841
+ } catch (err) {
842
+ console.error(`[MindMeld] Watcher spawn failed (non-fatal): ${err.message}`);
843
+ }
844
+
792
845
  // 4. Detect project characteristics locally (file system only, no DB)
793
846
  const characteristics = await mindmeld.relevanceDetector.detectProjectCharacteristics();
794
847
 
@@ -0,0 +1,585 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * MindMeld - Background Pattern Watcher
4
+ *
5
+ * Long-lived background process that monitors the JSONL session transcript
6
+ * and harvests patterns on resolution detection rather than a dumb timer.
7
+ *
8
+ * With 1M context windows, pre-compact may never fire. This watcher ensures
9
+ * patterns are harvested continuously throughout long sessions.
10
+ *
11
+ * Lifecycle:
12
+ * 1. SessionStart spawns watcher (or watcher detects breadcrumb update)
13
+ * 2. Polls transcript every 10s, buffers new content
14
+ * 3. Detects resolution signals (test runs, completions, task done)
15
+ * 4. Extracts patterns after 60s cooldown post-resolution
16
+ * 5. Survives /clear via breadcrumb transcript switch
17
+ * 6. Stops on: parent death, stop file, or 2h idle
18
+ *
19
+ * Args:
20
+ * argv[2] - project cwd
21
+ * argv[3] - parent PID (terminal process to monitor)
22
+ *
23
+ * @equilateral_ai/mindmeld v3.5.3
24
+ */
25
+
26
+ const path = require('path');
27
+ const fs = require('fs').promises;
28
+ const fsSync = require('fs');
29
+ const os = require('os');
30
+
31
+ process.title = 'mindmeld-watcher';
32
+
33
+ // Import harvest pipeline from pre-compact
34
+ let harvestPatterns = null;
35
+ try {
36
+ const preCompact = require('./pre-compact');
37
+ harvestPatterns = preCompact.harvestPatterns;
38
+ } catch (error) {
39
+ console.error(`[MindMeld:Watcher] Cannot load pre-compact module: ${error.message}`);
40
+ process.exit(0);
41
+ }
42
+
43
+ const PROJECT_CWD = process.argv[2];
44
+ const PARENT_PID = parseInt(process.argv[3], 10);
45
+
46
+ if (!PROJECT_CWD) {
47
+ console.error('[MindMeld:Watcher] No project cwd provided');
48
+ process.exit(0);
49
+ }
50
+
51
+ // ── Timing constants ──────────────────────────────────────────────────────────
52
+ const POLL_MS = 10 * 1000; // 10s transcript poll
53
+ const COOLDOWN_MS = 60 * 1000; // 60s after resolution before extracting
54
+ const IDLE_TIMEOUT_MS = 2 * 60 * 60 * 1000; // 2h no transcript activity → exit
55
+ const PARENT_CHECK_MS = 10 * 1000; // 10s parent liveness check
56
+ const FALLBACK_HARVEST_MS = 5 * 60 * 1000; // 5 min safety net if no resolution detected
57
+ const MIN_BUFFER_CHARS = 500; // skip trivial buffers
58
+ const MAX_DELTA_BYTES = 200 * 1024; // 200KB cap per read
59
+ const STARTUP_RETRY_MS = 10 * 1000;
60
+ const STARTUP_MAX_RETRIES = 12;
61
+
62
+ // ── Resolution signal patterns ────────────────────────────────────────────────
63
+ // Bash tool calls that indicate work completion
64
+ const RESOLUTION_COMMANDS = [
65
+ /npm\s+test/,
66
+ /npm\s+run\s+test/,
67
+ /jest/,
68
+ /pytest/,
69
+ /ruff\s+check/,
70
+ /eslint/,
71
+ /tsc\s+--noEmit/,
72
+ /go\s+test/,
73
+ /cargo\s+test/,
74
+ /make\s+test/,
75
+ /sam\s+build/,
76
+ /sam\s+deploy/,
77
+ /npm\s+run\s+build/,
78
+ /npm\s+run\s+deploy/,
79
+ ];
80
+
81
+ // Completion phrases in assistant messages
82
+ const COMPLETION_PHRASES = [
83
+ /all\s+(?:tests?\s+)?pass/i,
84
+ /successfully\s+(?:deployed|built|completed|created|updated)/i,
85
+ /changes?\s+(?:look|are)\s+good/i,
86
+ /(?:done|finished|completed)[.!]\s*$/im,
87
+ /ready\s+(?:to\s+)?(?:commit|deploy|merge|push|review)/i,
88
+ /implementation\s+is\s+(?:complete|done|ready)/i,
89
+ /that\s+(?:should\s+)?fix/i,
90
+ ];
91
+
92
+ // ── State ─────────────────────────────────────────────────────────────────────
93
+ let byteOffset = 0;
94
+ let jsonlPath = null;
95
+ let sessionId = null;
96
+ let buffer = ''; // accumulated unparsed transcript text
97
+ let pollHandle = null;
98
+ let parentCheckHandle = null;
99
+ let cooldownTimer = null;
100
+ let lastHarvestTime = Date.now();
101
+ let lastActivityTime = Date.now();
102
+ let resolutionDetected = false;
103
+
104
+ // ── Breadcrumb ────────────────────────────────────────────────────────────────
105
+
106
+ /**
107
+ * Path to the breadcrumb file written by session-start.
108
+ * Contains session_id and transcript_path, updated on /clear.
109
+ */
110
+ function breadcrumbPath() {
111
+ return path.join(PROJECT_CWD, '.mindmeld', 'current-session.json');
112
+ }
113
+
114
+ /**
115
+ * Read the breadcrumb to get current session info and transcript path.
116
+ */
117
+ async function readBreadcrumb() {
118
+ try {
119
+ const content = await fs.readFile(breadcrumbPath(), 'utf-8');
120
+ return JSON.parse(content);
121
+ } catch (err) {
122
+ return null;
123
+ }
124
+ }
125
+
126
+ // ── Transcript discovery ──────────────────────────────────────────────────────
127
+
128
+ function encodeProjectDir(cwd) {
129
+ return cwd.replace(/\//g, '-');
130
+ }
131
+
132
+ /**
133
+ * Find the active JSONL session file.
134
+ * Priority: breadcrumb transcript_path → most recently modified .jsonl
135
+ */
136
+ async function findSessionFile() {
137
+ // Check breadcrumb first
138
+ const crumb = await readBreadcrumb();
139
+ if (crumb?.transcriptPath) {
140
+ try {
141
+ await fs.access(crumb.transcriptPath);
142
+ sessionId = crumb.sessionId || sessionId;
143
+ return crumb.transcriptPath;
144
+ } catch (err) {
145
+ // Breadcrumb points to missing file, fall through
146
+ }
147
+ }
148
+
149
+ if (crumb?.sessionId) {
150
+ sessionId = crumb.sessionId;
151
+ }
152
+
153
+ // Fall back to most recently modified JSONL
154
+ const projectDir = path.join(os.homedir(), '.claude', 'projects', encodeProjectDir(PROJECT_CWD));
155
+ let files;
156
+ try {
157
+ files = await fs.readdir(projectDir);
158
+ } catch (err) {
159
+ return null;
160
+ }
161
+
162
+ const jsonlFiles = files.filter(f => f.endsWith('.jsonl'));
163
+ if (jsonlFiles.length === 0) return null;
164
+
165
+ let newest = null;
166
+ let newestMtime = 0;
167
+ for (const file of jsonlFiles) {
168
+ const fullPath = path.join(projectDir, file);
169
+ try {
170
+ const stat = await fs.stat(fullPath);
171
+ if (stat.mtimeMs > newestMtime) {
172
+ newestMtime = stat.mtimeMs;
173
+ newest = fullPath;
174
+ }
175
+ } catch (err) {
176
+ // Skip
177
+ }
178
+ }
179
+
180
+ return newest;
181
+ }
182
+
183
+ // ── JSONL parsing ─────────────────────────────────────────────────────────────
184
+
185
+ /**
186
+ * Parse JSONL entries from raw text. Returns array of parsed entry objects
187
+ * for resolution detection, plus accumulated transcript text.
188
+ */
189
+ function parseJsonlEntries(rawText) {
190
+ const lines = rawText.split('\n').filter(l => l.trim());
191
+ const entries = [];
192
+ const textParts = [];
193
+
194
+ for (const line of lines) {
195
+ try {
196
+ const entry = JSON.parse(line);
197
+ entries.push(entry);
198
+
199
+ const content = entry.message?.content || entry.content;
200
+ if (!content) continue;
201
+
202
+ if (typeof content === 'string') {
203
+ textParts.push(content);
204
+ } else if (Array.isArray(content)) {
205
+ for (const block of content) {
206
+ if (block.type === 'text' && block.text) {
207
+ textParts.push(block.text);
208
+ }
209
+ // Also capture tool_use for resolution detection
210
+ if (block.type === 'tool_use' && block.input) {
211
+ const input = typeof block.input === 'string' ? block.input : JSON.stringify(block.input);
212
+ textParts.push(input);
213
+ }
214
+ }
215
+ }
216
+ } catch (e) {
217
+ // Skip partial lines
218
+ }
219
+ }
220
+
221
+ return { entries, transcript: textParts.join('\n\n') };
222
+ }
223
+
224
+ // ── Delta reading ─────────────────────────────────────────────────────────────
225
+
226
+ async function readDelta() {
227
+ if (!jsonlPath) return null;
228
+
229
+ let stat;
230
+ try {
231
+ stat = await fs.stat(jsonlPath);
232
+ } catch (err) {
233
+ return null;
234
+ }
235
+
236
+ if (stat.size <= byteOffset) return null;
237
+ lastActivityTime = Date.now();
238
+
239
+ const readSize = Math.min(stat.size - byteOffset, MAX_DELTA_BYTES);
240
+ const buf = Buffer.alloc(readSize);
241
+
242
+ const fh = await fs.open(jsonlPath, 'r');
243
+ try {
244
+ await fh.read(buf, 0, readSize, byteOffset);
245
+ } finally {
246
+ await fh.close();
247
+ }
248
+
249
+ const raw = buf.toString('utf-8');
250
+
251
+ // Skip partial first line if mid-file
252
+ let usable = raw;
253
+ if (byteOffset > 0) {
254
+ const firstNewline = raw.indexOf('\n');
255
+ if (firstNewline > 0) {
256
+ usable = raw.substring(firstNewline + 1);
257
+ }
258
+ }
259
+
260
+ // Advance to end of last complete line
261
+ const lastNewline = raw.lastIndexOf('\n');
262
+ if (lastNewline >= 0) {
263
+ byteOffset += lastNewline + 1;
264
+ } else {
265
+ return null;
266
+ }
267
+
268
+ return usable;
269
+ }
270
+
271
+ // ── Resolution detection ──────────────────────────────────────────────────────
272
+
273
+ /**
274
+ * Scan entries for signals that the agent just finished a unit of work.
275
+ */
276
+ function detectResolution(entries, transcriptText) {
277
+ // Check for resolution command patterns in tool calls
278
+ for (const entry of entries) {
279
+ const content = entry.message?.content || entry.content;
280
+ if (!Array.isArray(content)) continue;
281
+
282
+ for (const block of content) {
283
+ if (block.type === 'tool_use' && block.name === 'Bash') {
284
+ const cmd = block.input?.command || '';
285
+ for (const pattern of RESOLUTION_COMMANDS) {
286
+ if (pattern.test(cmd)) {
287
+ return { type: 'command', signal: cmd.substring(0, 80) };
288
+ }
289
+ }
290
+ }
291
+ // TaskUpdate with status "completed"
292
+ if (block.type === 'tool_use' && block.name === 'TaskUpdate') {
293
+ const status = block.input?.status;
294
+ if (status === 'completed') {
295
+ return { type: 'task_complete', signal: block.input?.id || 'unknown' };
296
+ }
297
+ }
298
+ }
299
+ }
300
+
301
+ // Check for completion phrases in recent text
302
+ for (const phrase of COMPLETION_PHRASES) {
303
+ if (phrase.test(transcriptText)) {
304
+ return { type: 'phrase', signal: transcriptText.match(phrase)?.[0] || '' };
305
+ }
306
+ }
307
+
308
+ return null;
309
+ }
310
+
311
+ // ── Harvest ───────────────────────────────────────────────────────────────────
312
+
313
+ async function extract() {
314
+ if (!buffer || buffer.length < MIN_BUFFER_CHARS) {
315
+ buffer = '';
316
+ resolutionDetected = false;
317
+ return;
318
+ }
319
+
320
+ const harvestText = buffer.length > MAX_DELTA_BYTES
321
+ ? buffer.substring(buffer.length - MAX_DELTA_BYTES)
322
+ : buffer;
323
+
324
+ console.error(`[MindMeld:Watcher] Extracting patterns (${harvestText.length} chars)`);
325
+
326
+ try {
327
+ const result = await harvestPatterns({
328
+ sessionId: sessionId || 'unknown',
329
+ transcript: harvestText,
330
+ source: 'watcher'
331
+ });
332
+
333
+ if (result && !result.skipped) {
334
+ console.error(`[MindMeld:Watcher] Harvest: ${result.patternsDetected || 0} patterns, ${result.violations || 0} violations, ${result.reinforced || 0} reinforced`);
335
+ }
336
+ } catch (err) {
337
+ console.error(`[MindMeld:Watcher] Harvest error (non-fatal): ${err.message}`);
338
+ }
339
+
340
+ buffer = '';
341
+ resolutionDetected = false;
342
+ lastHarvestTime = Date.now();
343
+ }
344
+
345
+ // ── Transcript switch (clear detection) ───────────────────────────────────────
346
+
347
+ /**
348
+ * Check if the breadcrumb has been updated with a new transcript path.
349
+ * This happens when session-start fires again after /clear.
350
+ */
351
+ async function checkTranscriptSwitch() {
352
+ const crumb = await readBreadcrumb();
353
+ if (!crumb?.transcriptPath) return false;
354
+
355
+ // No current path yet — initial setup, not a switch
356
+ if (!jsonlPath) return false;
357
+
358
+ // Normalize for comparison
359
+ const currentBasename = path.basename(jsonlPath);
360
+ const crumbBasename = path.basename(crumb.transcriptPath);
361
+
362
+ if (currentBasename !== crumbBasename) {
363
+ console.error(`[MindMeld:Watcher] Transcript switch detected: ${currentBasename} → ${crumbBasename}`);
364
+
365
+ // Flush remaining buffer from old transcript
366
+ if (buffer.length >= MIN_BUFFER_CHARS) {
367
+ console.error('[MindMeld:Watcher] Flushing buffer from previous transcript');
368
+ await extract();
369
+ }
370
+
371
+ // Reset state for new transcript
372
+ try {
373
+ await fs.access(crumb.transcriptPath);
374
+ jsonlPath = crumb.transcriptPath;
375
+ sessionId = crumb.sessionId || sessionId;
376
+ byteOffset = 0; // Read from start of new transcript
377
+ buffer = '';
378
+ resolutionDetected = false;
379
+
380
+ // Set offset to current size if we don't want pre-existing content
381
+ const stat = await fs.stat(jsonlPath);
382
+ byteOffset = stat.size;
383
+
384
+ console.error(`[MindMeld:Watcher] Now watching: ${path.basename(jsonlPath)}`);
385
+ return true;
386
+ } catch (err) {
387
+ console.error(`[MindMeld:Watcher] New transcript not accessible: ${err.message}`);
388
+ return false;
389
+ }
390
+ }
391
+
392
+ return false;
393
+ }
394
+
395
+ // ── Parent process liveness ───────────────────────────────────────────────────
396
+
397
+ function isParentAlive() {
398
+ if (!PARENT_PID || isNaN(PARENT_PID)) return true; // Can't check, assume alive
399
+ try {
400
+ process.kill(PARENT_PID, 0); // Signal 0 = existence check
401
+ return true;
402
+ } catch (err) {
403
+ return false; // ESRCH = process gone
404
+ }
405
+ }
406
+
407
+ // ── Stop file detection ───────────────────────────────────────────────────────
408
+
409
+ function stopFilePath() {
410
+ return path.join(PROJECT_CWD, '.mindmeld', 'watcher.stop');
411
+ }
412
+
413
+ async function checkStopFile() {
414
+ try {
415
+ await fs.access(stopFilePath());
416
+ await fs.unlink(stopFilePath()).catch(() => {});
417
+ return true;
418
+ } catch (err) {
419
+ return false;
420
+ }
421
+ }
422
+
423
+ // ── PID file ──────────────────────────────────────────────────────────────────
424
+
425
+ async function writePidFile() {
426
+ try {
427
+ const pidPath = path.join(PROJECT_CWD, '.mindmeld', 'watcher.pid');
428
+ await fs.writeFile(pidPath, String(process.pid));
429
+ } catch (err) {
430
+ console.error(`[MindMeld:Watcher] Could not write PID file: ${err.message}`);
431
+ }
432
+ }
433
+
434
+ // ── Poll tick ─────────────────────────────────────────────────────────────────
435
+
436
+ async function poll() {
437
+ try {
438
+ // Check for transcript switch (breadcrumb updated by session-start after /clear)
439
+ await checkTranscriptSwitch();
440
+
441
+ // Read new lines from transcript
442
+ const delta = await readDelta();
443
+ if (delta) {
444
+ const { entries, transcript } = parseJsonlEntries(delta);
445
+ if (transcript) {
446
+ buffer += transcript + '\n\n';
447
+ }
448
+
449
+ // Check for resolution signals in new entries
450
+ if (!resolutionDetected) {
451
+ const resolution = detectResolution(entries, transcript);
452
+ if (resolution) {
453
+ resolutionDetected = true;
454
+ console.error(`[MindMeld:Watcher] Resolution detected (${resolution.type}): ${resolution.signal}`);
455
+
456
+ // Start cooldown — extract after 60s
457
+ if (cooldownTimer) clearTimeout(cooldownTimer);
458
+ cooldownTimer = setTimeout(() => {
459
+ cooldownTimer = null;
460
+ extract();
461
+ }, COOLDOWN_MS);
462
+ }
463
+ }
464
+ }
465
+
466
+ // Fallback: if no resolution detected but buffer is accumulating, harvest on timer
467
+ if (!resolutionDetected && !cooldownTimer && buffer.length >= MIN_BUFFER_CHARS) {
468
+ const sinceLastHarvest = Date.now() - lastHarvestTime;
469
+ if (sinceLastHarvest >= FALLBACK_HARVEST_MS) {
470
+ console.error('[MindMeld:Watcher] Fallback harvest (no resolution signal)');
471
+ await extract();
472
+ }
473
+ }
474
+ } catch (err) {
475
+ console.error(`[MindMeld:Watcher] Poll error (non-fatal): ${err.message}`);
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Parent liveness + idle + stop file checks.
481
+ */
482
+ async function livenessCheck() {
483
+ // Parent process died (terminal closed)
484
+ if (!isParentAlive()) {
485
+ console.error('[MindMeld:Watcher] Parent process gone, extracting and exiting');
486
+ await extract();
487
+ cleanup();
488
+ return;
489
+ }
490
+
491
+ // Stop file written by session-end
492
+ if (await checkStopFile()) {
493
+ console.error('[MindMeld:Watcher] Stop file detected, extracting and exiting');
494
+ await extract();
495
+ cleanup();
496
+ return;
497
+ }
498
+
499
+ // Idle timeout
500
+ if (Date.now() - lastActivityTime > IDLE_TIMEOUT_MS) {
501
+ console.error('[MindMeld:Watcher] Idle for 2h, extracting and exiting');
502
+ await extract();
503
+ cleanup();
504
+ return;
505
+ }
506
+ }
507
+
508
+ // ── Cleanup ───────────────────────────────────────────────────────────────────
509
+
510
+ function cleanup() {
511
+ if (pollHandle) {
512
+ clearInterval(pollHandle);
513
+ pollHandle = null;
514
+ }
515
+ if (parentCheckHandle) {
516
+ clearInterval(parentCheckHandle);
517
+ parentCheckHandle = null;
518
+ }
519
+ if (cooldownTimer) {
520
+ clearTimeout(cooldownTimer);
521
+ cooldownTimer = null;
522
+ }
523
+
524
+ // Remove PID file
525
+ try {
526
+ fsSync.unlinkSync(path.join(PROJECT_CWD, '.mindmeld', 'watcher.pid'));
527
+ } catch (err) {
528
+ // Already gone
529
+ }
530
+
531
+ process.exit(0);
532
+ }
533
+
534
+ // ── Main ──────────────────────────────────────────────────────────────────────
535
+
536
+ async function main() {
537
+ console.error(`[MindMeld:Watcher] Starting for ${path.basename(PROJECT_CWD)} (parent PID: ${PARENT_PID || 'unknown'})`);
538
+
539
+ await writePidFile();
540
+
541
+ // Find the JSONL file, retrying if it doesn't exist yet
542
+ for (let attempt = 0; attempt < STARTUP_MAX_RETRIES; attempt++) {
543
+ jsonlPath = await findSessionFile();
544
+ if (jsonlPath) break;
545
+ await new Promise(r => setTimeout(r, STARTUP_RETRY_MS));
546
+ }
547
+
548
+ if (!jsonlPath) {
549
+ console.error('[MindMeld:Watcher] Could not find session JSONL file after retries, exiting');
550
+ cleanup();
551
+ return;
552
+ }
553
+
554
+ // Read session ID from breadcrumb if not set
555
+ if (!sessionId) {
556
+ const crumb = await readBreadcrumb();
557
+ sessionId = crumb?.sessionId || 'unknown';
558
+ }
559
+
560
+ console.error(`[MindMeld:Watcher] Watching: ${path.basename(jsonlPath)} (session: ${sessionId})`);
561
+
562
+ // Start offset at current file size — only capture new activity
563
+ try {
564
+ const stat = await fs.stat(jsonlPath);
565
+ byteOffset = stat.size;
566
+ } catch (err) {
567
+ byteOffset = 0;
568
+ }
569
+
570
+ // Start polling
571
+ pollHandle = setInterval(poll, POLL_MS);
572
+ parentCheckHandle = setInterval(livenessCheck, PARENT_CHECK_MS);
573
+ }
574
+
575
+ process.on('SIGTERM', async () => {
576
+ console.error('[MindMeld:Watcher] SIGTERM received, extracting and exiting');
577
+ await extract();
578
+ cleanup();
579
+ });
580
+ process.on('SIGINT', cleanup);
581
+
582
+ main().catch(err => {
583
+ console.error(`[MindMeld:Watcher] Fatal: ${err.message}`);
584
+ cleanup();
585
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@equilateral_ai/mindmeld",
3
- "version": "3.5.1",
3
+ "version": "3.5.3",
4
4
  "description": "Intelligent standards injection for AI coding sessions - context-aware, self-documenting, scales to large codebases",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -15,6 +15,7 @@
15
15
  "scripts/harvest.js",
16
16
  "scripts/repo-analyzer.js",
17
17
  "scripts/mcp-bridge.js",
18
+ "scripts/standards.js",
18
19
  "README.md"
19
20
  ],
20
21
  "publishConfig": {
@@ -80,12 +81,12 @@
80
81
  "homepage": "https://mindmeld.dev",
81
82
  "dependencies": {
82
83
  "@aws-sdk/client-bedrock-runtime": "^3.460.0",
83
- "@aws-sdk/client-ssm": "^3.985.0",
84
+ "@aws-sdk/client-ses": "^3.985.0",
85
+ "js-yaml": "^4.1.1",
84
86
  "pg": "^8.18.0"
85
87
  },
86
88
  "devDependencies": {
87
89
  "@aws-sdk/client-s3": "^3.460.0",
88
- "@aws-sdk/client-ses": "^3.985.0",
89
90
  "jest": "^29.7.0"
90
91
  },
91
92
  "engines": {
@@ -0,0 +1,285 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * MindMeld CLI - Standards Management
4
+ *
5
+ * Usage:
6
+ * mindmeld standards # Open standards picker in browser
7
+ * mindmeld standards --show # Show current preferences
8
+ * mindmeld standards --sync # Sync preferences from server
9
+ * mindmeld standards --reset # Reset to defaults
10
+ *
11
+ * @equilateral_ai/mindmeld v3.2.0
12
+ */
13
+
14
+ const path = require('path');
15
+ const fs = require('fs').promises;
16
+ const { AuthManager } = require('../src/core/AuthManager');
17
+
18
+ const authManager = new AuthManager();
19
+ const API_BASE = process.env.MINDMELD_API_URL || 'https://api.mindmeld.dev';
20
+
21
+ /**
22
+ * Load local MindMeld config
23
+ */
24
+ async function loadConfig() {
25
+ try {
26
+ const configPath = path.join(process.cwd(), '.mindmeld', 'config.json');
27
+ const content = await fs.readFile(configPath, 'utf-8');
28
+ return JSON.parse(content);
29
+ } catch (error) {
30
+ if (error.code !== 'ENOENT' && !(error instanceof SyntaxError)) {
31
+ console.error('Unexpected error loading config:', error.message);
32
+ }
33
+ return null;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Load authentication tokens
39
+ */
40
+ async function loadAuth() {
41
+ try {
42
+ const authPath = path.join(process.env.HOME, '.mindmeld', 'auth.json');
43
+ const content = await fs.readFile(authPath, 'utf-8');
44
+ return JSON.parse(content);
45
+ } catch (error) {
46
+ if (error.code !== 'ENOENT' && !(error instanceof SyntaxError)) {
47
+ console.error('Unexpected error loading auth:', error.message);
48
+ }
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Load local preferences
55
+ */
56
+ async function loadPreferences() {
57
+ try {
58
+ const prefsPath = path.join(process.cwd(), '.mindmeld', 'preferences.json');
59
+ const content = await fs.readFile(prefsPath, 'utf-8');
60
+ return JSON.parse(content);
61
+ } catch (error) {
62
+ if (error.code !== 'ENOENT' && !(error instanceof SyntaxError)) {
63
+ console.error('Unexpected error loading preferences:', error.message);
64
+ }
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Save local preferences
71
+ */
72
+ async function savePreferences(preferences) {
73
+ const prefsPath = path.join(process.cwd(), '.mindmeld', 'preferences.json');
74
+ await fs.mkdir(path.dirname(prefsPath), { recursive: true });
75
+ await fs.writeFile(prefsPath, JSON.stringify(preferences, null, 2));
76
+ }
77
+
78
+ /**
79
+ * Fetch project standards from API
80
+ */
81
+ async function fetchProjectStandards(projectId, idToken) {
82
+ const response = await fetch(`${API_BASE}/api/projects/standards?project_id=${encodeURIComponent(projectId)}`, {
83
+ headers: {
84
+ 'Authorization': `Bearer ${idToken}`,
85
+ 'Content-Type': 'application/json'
86
+ }
87
+ });
88
+
89
+ if (!response.ok) {
90
+ throw new Error(`API error: ${response.status}`);
91
+ }
92
+
93
+ return response.json();
94
+ }
95
+
96
+ /**
97
+ * Show current preferences
98
+ */
99
+ async function showPreferences() {
100
+ const prefs = await loadPreferences();
101
+
102
+ if (!prefs) {
103
+ console.log('\nNo local preferences set. Using defaults (all standards enabled).\n');
104
+ console.log('Run `mindmeld standards` to configure.\n');
105
+ return;
106
+ }
107
+
108
+ console.log('\n📋 Current Standards Preferences\n');
109
+
110
+ const enabledCats = prefs.enabled_categories || {};
111
+ const overrides = prefs.standard_overrides || {};
112
+
113
+ console.log('Categories:');
114
+ for (const [cat, enabled] of Object.entries(enabledCats)) {
115
+ const icon = enabled ? '✅' : '❌';
116
+ console.log(` ${icon} ${cat}`);
117
+ }
118
+
119
+ if (Object.keys(overrides).length > 0) {
120
+ console.log('\nIndividual Overrides:');
121
+ for (const [standard, enabled] of Object.entries(overrides)) {
122
+ const icon = enabled ? '✅' : '❌';
123
+ console.log(` ${icon} ${standard}`);
124
+ }
125
+ }
126
+
127
+ if (prefs.synced_at) {
128
+ console.log(`\nLast synced: ${new Date(prefs.synced_at).toLocaleString()}`);
129
+ }
130
+
131
+ console.log('');
132
+ }
133
+
134
+ /**
135
+ * Sync preferences from server
136
+ */
137
+ async function syncPreferences() {
138
+ const config = await loadConfig();
139
+ const auth = await loadAuth();
140
+
141
+ if (!config) {
142
+ console.error('\n❌ Not a MindMeld project. Run `mindmeld init` first.\n');
143
+ process.exit(1);
144
+ }
145
+
146
+ if (!auth || !auth.id_token) {
147
+ console.error('\n❌ Not authenticated. Run `mindmeld login` first.\n');
148
+ process.exit(1);
149
+ }
150
+
151
+ console.log('\n🔄 Syncing preferences from server...\n');
152
+
153
+ try {
154
+ const result = await fetchProjectStandards(config.projectId, auth.id_token);
155
+
156
+ if (result.success && result.data) {
157
+ const prefs = {
158
+ enabled_categories: result.data.preferences?.enabled_categories || {},
159
+ standard_overrides: result.data.preferences?.standard_overrides || {},
160
+ critical_overrides: result.data.preferences?.critical_overrides || {},
161
+ catalog_version: result.data.catalog_version,
162
+ synced_at: new Date().toISOString()
163
+ };
164
+
165
+ await savePreferences(prefs);
166
+
167
+ console.log('✅ Preferences synced successfully!\n');
168
+ await showPreferences();
169
+ } else {
170
+ console.log('ℹ️ No custom preferences set. Using defaults.\n');
171
+ }
172
+ } catch (error) {
173
+ console.error('❌ Failed to sync:', error.message);
174
+ process.exit(1);
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Reset preferences to defaults
180
+ */
181
+ async function resetPreferences() {
182
+ try {
183
+ const prefsPath = path.join(process.cwd(), '.mindmeld', 'preferences.json');
184
+ await fs.unlink(prefsPath);
185
+ console.log('\n✅ Preferences reset to defaults (all standards enabled).\n');
186
+ } catch (error) {
187
+ if (error.code === 'ENOENT') {
188
+ console.log('\nℹ️ No preferences file to reset.\n');
189
+ } else {
190
+ console.error('Unexpected error resetting preferences:', error.message);
191
+ process.exit(1);
192
+ }
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Open standards picker in browser
198
+ */
199
+ async function openStandardsPicker() {
200
+ const config = await loadConfig();
201
+ const auth = await loadAuth();
202
+
203
+ if (!config) {
204
+ console.error('\n❌ Not a MindMeld project. Run `mindmeld init` first.\n');
205
+ process.exit(1);
206
+ }
207
+
208
+ if (!auth) {
209
+ console.error('\n❌ Not authenticated. Run `mindmeld login` first.\n');
210
+ process.exit(1);
211
+ }
212
+
213
+ const projectId = config.projectId;
214
+ const baseUrl = authManager.getAppBaseUrl();
215
+ const url = `${baseUrl}/projects/${projectId}/standards`;
216
+
217
+ console.log('\n🌐 Opening standards picker in browser...\n');
218
+ console.log(` ${url}\n`);
219
+
220
+ const opened = await authManager.openBrowser(url);
221
+ if (!opened) {
222
+ console.log('Could not open browser automatically.');
223
+ console.log(`Please visit: ${url}\n`);
224
+ }
225
+
226
+ console.log('After saving preferences in the browser, run:');
227
+ console.log(' mindmeld standards --sync\n');
228
+ }
229
+
230
+ /**
231
+ * Show help
232
+ */
233
+ function showHelp() {
234
+ console.log(`
235
+ MindMeld Standards - Configure which standards apply to this project
236
+
237
+ Usage:
238
+ mindmeld standards Open standards picker in browser
239
+ mindmeld standards --show Show current preferences
240
+ mindmeld standards --sync Sync preferences from server
241
+ mindmeld standards --reset Reset to defaults (all enabled)
242
+ mindmeld standards --help Show this help
243
+
244
+ Examples:
245
+ mindmeld standards # Configure in browser
246
+ mindmeld standards --show # See what's enabled/disabled
247
+ `);
248
+ }
249
+
250
+ /**
251
+ * Main
252
+ */
253
+ async function main() {
254
+ const args = process.argv.slice(2);
255
+
256
+ if (args.includes('--help') || args.includes('-h')) {
257
+ showHelp();
258
+ process.exit(0);
259
+ }
260
+
261
+ if (args.includes('--show')) {
262
+ await showPreferences();
263
+ process.exit(0);
264
+ }
265
+
266
+ if (args.includes('--sync')) {
267
+ await syncPreferences();
268
+ process.exit(0);
269
+ }
270
+
271
+ if (args.includes('--reset')) {
272
+ await resetPreferences();
273
+ process.exit(0);
274
+ }
275
+
276
+ // Default: open browser
277
+ await openStandardsPicker();
278
+ }
279
+
280
+ main().catch(error => {
281
+ console.error('\n❌ Error:', error.message);
282
+ process.exit(1);
283
+ });
284
+
285
+ module.exports = { showHelp };
@@ -65,14 +65,29 @@ async function updateProjectStandards({ body, pathParameters, requestContext })
65
65
 
66
66
  // Update load_bearing flag on a specific standard if provided
67
67
  if (standard_id && typeof load_bearing === 'boolean') {
68
- await executeQuery(`
68
+ const userCompany = await executeQuery(`
69
+ SELECT company_id FROM rapport.user_entitlements WHERE email_address = $1 LIMIT 1
70
+ `, [email]);
71
+ const companyId = userCompany.rows[0]?.company_id;
72
+
73
+ // Try base standards first
74
+ const baseResult = await executeQuery(`
69
75
  UPDATE rapport.standards_patterns
70
76
  SET load_bearing = $1
71
77
  WHERE pattern_id = $2
72
- AND (company_id IS NULL OR company_id = (
73
- SELECT company_id FROM rapport.user_entitlements WHERE email_address = $3 LIMIT 1
74
- ))
75
- `, [load_bearing, standard_id, email]);
78
+ AND (company_id IS NULL OR company_id = $3)
79
+ `, [load_bearing, standard_id, companyId]);
80
+
81
+ // If no base standard matched, try company overrides
82
+ if (baseResult.rowCount === 0 && companyId) {
83
+ await executeQuery(`
84
+ UPDATE rapport.company_standard_overrides
85
+ SET load_bearing = $1
86
+ WHERE (base_standard_id = $2 OR override_id::text = $3)
87
+ AND company_id = $4
88
+ AND active = TRUE
89
+ `, [load_bearing, standard_id, standard_id.replace('company-add-', ''), companyId]);
90
+ }
76
91
  }
77
92
 
78
93
  // Upsert project standards