monomind 2.0.3 → 2.1.0

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 (89) hide show
  1. package/package.json +9 -5
  2. package/packages/@monomind/cli/.claude/helpers/control-start.cjs +13 -20
  3. package/packages/@monomind/cli/.claude/helpers/event-logger.cjs +109 -6
  4. package/packages/@monomind/cli/.claude/helpers/graphify-freshen.cjs +19 -17
  5. package/packages/@monomind/cli/.claude/helpers/handlers/agent-start-handler.cjs +60 -28
  6. package/packages/@monomind/cli/.claude/helpers/handlers/capture-handler.cjs +75 -18
  7. package/packages/@monomind/cli/.claude/helpers/handlers/edit-handler.cjs +27 -9
  8. package/packages/@monomind/cli/.claude/helpers/handlers/gates-handler.cjs +16 -7
  9. package/packages/@monomind/cli/.claude/helpers/handlers/loops-status-handler.cjs +1 -1
  10. package/packages/@monomind/cli/.claude/helpers/handlers/route-handler.cjs +4 -4
  11. package/packages/@monomind/cli/.claude/helpers/handlers/session-handler.cjs +40 -22
  12. package/packages/@monomind/cli/.claude/helpers/handlers/session-restore-handler.cjs +0 -12
  13. package/packages/@monomind/cli/.claude/helpers/handlers/task-handler.cjs +53 -22
  14. package/packages/@monomind/cli/.claude/helpers/hook-handler.cjs +54 -4
  15. package/packages/@monomind/cli/.claude/helpers/intelligence.cjs +94 -15
  16. package/packages/@monomind/cli/.claude/helpers/session.cjs +6 -1
  17. package/packages/@monomind/cli/.claude/helpers/statusline.cjs +10 -10
  18. package/packages/@monomind/cli/.claude/helpers/token-tracker.cjs +3 -3
  19. package/packages/@monomind/cli/.claude/helpers/utils/agent-registrations.cjs +41 -0
  20. package/packages/@monomind/cli/.claude/helpers/utils/fs-helpers.cjs +183 -0
  21. package/packages/@monomind/cli/.claude/helpers/utils/micro-agents.cjs +10 -6
  22. package/packages/@monomind/cli/.claude/helpers/utils/telemetry.cjs +35 -19
  23. package/packages/@monomind/cli/bin/cli.js +10 -1
  24. package/packages/@monomind/cli/dist/src/commands/agent-ops.js +9 -15
  25. package/packages/@monomind/cli/dist/src/commands/cleanup.js +20 -4
  26. package/packages/@monomind/cli/dist/src/commands/completions.js +66 -261
  27. package/packages/@monomind/cli/dist/src/commands/doctor.js +7 -2
  28. package/packages/@monomind/cli/dist/src/commands/hooks-workers.js +7 -0
  29. package/packages/@monomind/cli/dist/src/commands/index.js +3 -0
  30. package/packages/@monomind/cli/dist/src/commands/mcp.js +11 -6
  31. package/packages/@monomind/cli/dist/src/commands/memory-admin.js +12 -6
  32. package/packages/@monomind/cli/dist/src/commands/memory-transfer.js +6 -13
  33. package/packages/@monomind/cli/dist/src/commands/monograph.js +3 -3
  34. package/packages/@monomind/cli/dist/src/commands/org.d.ts +3 -1
  35. package/packages/@monomind/cli/dist/src/commands/org.js +306 -129
  36. package/packages/@monomind/cli/dist/src/commands/start.js +142 -37
  37. package/packages/@monomind/cli/dist/src/commands/tokens.js +21 -3
  38. package/packages/@monomind/cli/dist/src/config-adapter.js +37 -17
  39. package/packages/@monomind/cli/dist/src/index.js +64 -28
  40. package/packages/@monomind/cli/dist/src/init/claudemd-generator.js +44 -11
  41. package/packages/@monomind/cli/dist/src/init/executor.js +63 -49
  42. package/packages/@monomind/cli/dist/src/mcp-client.d.ts +8 -3
  43. package/packages/@monomind/cli/dist/src/mcp-client.js +37 -3
  44. package/packages/@monomind/cli/dist/src/mcp-server.js +8 -2
  45. package/packages/@monomind/cli/dist/src/mcp-tools/agent-tools.d.ts +20 -0
  46. package/packages/@monomind/cli/dist/src/mcp-tools/agent-tools.js +10 -5
  47. package/packages/@monomind/cli/dist/src/mcp-tools/github-tools.js +30 -8
  48. package/packages/@monomind/cli/dist/src/mcp-tools/graphify-tools.js +11 -2
  49. package/packages/@monomind/cli/dist/src/mcp-tools/hive-mind-tools.js +51 -34
  50. package/packages/@monomind/cli/dist/src/mcp-tools/knowledge-tools.js +9 -1
  51. package/packages/@monomind/cli/dist/src/mcp-tools/monograph-compat.js +56 -14
  52. package/packages/@monomind/cli/dist/src/mcp-tools/monograph-tools.js +65 -43
  53. package/packages/@monomind/cli/dist/src/mcp-tools/swarm-tools.js +6 -4
  54. package/packages/@monomind/cli/dist/src/mcp-tools/system-tools.js +4 -4
  55. package/packages/@monomind/cli/dist/src/mcp-tools/types.d.ts +20 -0
  56. package/packages/@monomind/cli/dist/src/mcp-tools/types.js +36 -1
  57. package/packages/@monomind/cli/dist/src/memory/hnsw-operations.d.ts +13 -1
  58. package/packages/@monomind/cli/dist/src/memory/hnsw-operations.js +66 -14
  59. package/packages/@monomind/cli/dist/src/memory/intelligence.js +99 -3
  60. package/packages/@monomind/cli/dist/src/memory/memory-bridge.d.ts +7 -0
  61. package/packages/@monomind/cli/dist/src/memory/memory-bridge.js +20 -0
  62. package/packages/@monomind/cli/dist/src/orgrt/broker.d.ts +24 -0
  63. package/packages/@monomind/cli/dist/src/orgrt/broker.js +68 -0
  64. package/packages/@monomind/cli/dist/src/orgrt/daemon.d.ts +30 -0
  65. package/packages/@monomind/cli/dist/src/orgrt/daemon.js +96 -7
  66. package/packages/@monomind/cli/dist/src/orgrt/forwarder.d.ts +10 -5
  67. package/packages/@monomind/cli/dist/src/orgrt/forwarder.js +105 -8
  68. package/packages/@monomind/cli/dist/src/orgrt/live.html +56 -0
  69. package/packages/@monomind/cli/dist/src/orgrt/provider.js +1 -0
  70. package/packages/@monomind/cli/dist/src/orgrt/scheduler.d.ts +13 -0
  71. package/packages/@monomind/cli/dist/src/orgrt/scheduler.js +48 -0
  72. package/packages/@monomind/cli/dist/src/orgrt/server.d.ts +8 -0
  73. package/packages/@monomind/cli/dist/src/orgrt/server.js +72 -0
  74. package/packages/@monomind/cli/dist/src/orgrt/test-loop.d.ts +12 -0
  75. package/packages/@monomind/cli/dist/src/orgrt/test-loop.js +108 -0
  76. package/packages/@monomind/cli/dist/src/output.js +12 -5
  77. package/packages/@monomind/cli/dist/src/parser.d.ts +32 -0
  78. package/packages/@monomind/cli/dist/src/parser.js +130 -5
  79. package/packages/@monomind/cli/dist/src/routing/embed-worker.js +13 -1
  80. package/packages/@monomind/cli/dist/src/routing/route-layer-factory.js +31 -0
  81. package/packages/@monomind/cli/dist/src/services/config-file-manager.d.ts +21 -0
  82. package/packages/@monomind/cli/dist/src/services/config-file-manager.js +60 -7
  83. package/packages/@monomind/cli/dist/src/services/crash-reporter.js +2 -1
  84. package/packages/@monomind/cli/dist/src/ui/dashboard.html +40 -40
  85. package/packages/@monomind/cli/dist/src/ui/orgs.html +110 -11
  86. package/packages/@monomind/cli/dist/src/ui/server.mjs +304 -133
  87. package/packages/@monomind/cli/dist/src/utils/input-guards.d.ts +9 -0
  88. package/packages/@monomind/cli/dist/src/utils/input-guards.js +39 -5
  89. package/packages/@monomind/cli/package.json +6 -2
@@ -32,8 +32,13 @@ const SECRET_PATTERNS = [
32
32
  /(?:api[_-]?key|apikey)\s*[:=]\s*['"][^'"]{8,}['"]/gi,
33
33
  /(?:secret|password|passwd|pwd)\s*[:=]\s*['"][^'"]{8,}['"]/gi,
34
34
  /(?:token|bearer)\s*[:=]\s*['"][^'"]{10,}['"]/gi,
35
+ // Unquoted variants — env-style KEY equals value with no surrounding
36
+ // quotes (the single most common real leak pattern; the quoted patterns
37
+ // above never match a bare assignment in a shell export or .env file).
38
+ /(?:api[_-]?key|apikey|token|secret|password|passwd|pwd)\s*[:=]\s*[^\s'"]{8,}/gi,
35
39
  /-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----/g,
36
- /sk-[a-zA-Z0-9]{20,}/g,
40
+ /sk-ant-[a-zA-Z0-9_-]{20,}/g,
41
+ /sk-[a-zA-Z0-9_-]{20,}/g,
37
42
  /ghp_[a-zA-Z0-9]{36}/g,
38
43
  /npm_[a-zA-Z0-9]{36}/g,
39
44
  /AKIA[0-9A-Z]{16}/g,
@@ -92,11 +97,14 @@ function handlePreBash(hCtx) {
92
97
 
93
98
  var result = checkDestructive(cmd);
94
99
  if (result.triggered) {
95
- // Output block decision and set exit code 2 both required by Claude Code PreToolUse protocol
96
- console.log(JSON.stringify({
100
+ // Set exit code 2 to block, and write the reason to STDERR per Claude
101
+ // Code's PreToolUse hook protocol, stdout JSON is only parsed when exit
102
+ // code is 0; at exit code 2 the caller reads the block reason from
103
+ // stderr instead, so putting it on stdout here would make it invisible.
104
+ process.stderr.write(JSON.stringify({
97
105
  decision: 'block',
98
106
  reason: '[gates] ' + result.reason,
99
- }));
107
+ }) + '\n');
100
108
  process.exitCode = 2;
101
109
  }
102
110
  }
@@ -120,11 +128,12 @@ function handlePreWrite(hCtx) {
120
128
 
121
129
  var result = checkSecrets(content);
122
130
  if (result.triggered) {
123
- // Output block decision and set exit code 2 both required by Claude Code PreToolUse protocol
124
- console.log(JSON.stringify({
131
+ // Set exit code 2 to block, and write the reason to STDERR see the
132
+ // matching comment in handlePreBash for why stdout is the wrong stream.
133
+ process.stderr.write(JSON.stringify({
125
134
  decision: 'block',
126
135
  reason: '[gates] ' + result.reason,
127
- }));
136
+ }) + '\n');
128
137
  process.exitCode = 2;
129
138
  }
130
139
  }
@@ -10,7 +10,7 @@ module.exports = {
10
10
  var loopsDir = path.join(CWD, '.monomind', 'loops');
11
11
  if (!fs.existsSync(loopsDir)) { console.log('No loops directory.'); return; }
12
12
  var files = fs.readdirSync(loopsDir).filter(function(f) {
13
- return f.endsWith('.json') && !f.includes('-hil') && !f.endsWith('.stop');
13
+ return f.endsWith('.json') && !f.includes('-hil') && !f.endsWith('.stop') && !f.startsWith('._');
14
14
  }).slice(0, 200); // cap to prevent DoS via many files
15
15
  var STALE_MS = 6 * 60 * 60 * 1000;
16
16
  var now = Date.now();
@@ -327,15 +327,15 @@ module.exports = {
327
327
  }
328
328
  }
329
329
  }
330
- // Performance metrics (daemon optimize worker)
330
+ // Performance metrics (optimize worker — a one-shot process, not a daemon)
331
331
  var perfFile = path.join(metricsDir, 'performance.json');
332
332
  if (fs.existsSync(perfFile) && fs.statSync(perfFile).size < 32768) {
333
333
  var perfData = JSON.parse(fs.readFileSync(perfFile, 'utf-8'));
334
- if (perfData && perfData.memoryUsage) {
335
- var rssBytes = perfData.memoryUsage.rss || 0;
334
+ if (perfData && perfData.workerProcessMemoryUsage) {
335
+ var rssBytes = perfData.workerProcessMemoryUsage.rss || 0;
336
336
  var rssMB = Math.round(rssBytes / (1024 * 1024));
337
337
  if (rssMB > 512) {
338
- console.log('[PERF] Daemon RSS ' + rssMB + 'MB (>512MB threshold). Consider restarting daemon or reducing worker concurrency');
338
+ console.log('[PERF] optimize worker RSS ' + rssMB + 'MB (>512MB threshold) at last run reflects that one-shot process, not a persistent daemon');
339
339
  }
340
340
  }
341
341
  }
@@ -89,17 +89,48 @@ module.exports = {
89
89
  // Only write the success flag when derived from actual evidence (commits, outcomes)
90
90
  if (typeof sessionSuccess === 'boolean') feedbackEntry.intelligenceFeedback = sessionSuccess;
91
91
  fs.appendFileSync(feedbackPath, JSON.stringify(feedbackEntry) + '\n', 'utf-8');
92
- // Rotate: keep last 1000 lines to prevent unbounded growth
92
+ // Rotate: keep last 1000 lines to prevent unbounded growth.
93
93
  try {
94
94
  var MAX_FEEDBACK = 512 * 1024; // 512 KiB
95
- if (fs.existsSync(feedbackPath) && fs.statSync(feedbackPath).size > MAX_FEEDBACK) {
96
- // File too large emergency trim without full read
97
- throw new Error('skip-rotation');
98
- }
99
- var raw = fs.readFileSync(feedbackPath, 'utf-8');
100
- var lines = raw.split('\n').filter(Boolean);
101
- if (lines.length > 1000) {
102
- fs.writeFileSync(feedbackPath, lines.slice(-1000).join('\n') + '\n', 'utf-8');
95
+ var feedbackStat = fs.existsSync(feedbackPath) ? fs.statSync(feedbackPath) : null;
96
+ if (feedbackStat && feedbackStat.size > MAX_FEEDBACK) {
97
+ // Emergency tail-trim: the file is too large to comfortably read+split
98
+ // in full, so seek to a byte offset near the end, read only that tail,
99
+ // drop the first (possibly partial) line, and keep the rest — instead
100
+ // of the previous behavior which threw here and skipped trimming
101
+ // entirely, leaving the file to grow forever and permanently disabling
102
+ // consumers (router.cjs, route-handler.cjs) that bail out on oversized
103
+ // files.
104
+ var TAIL_BYTES = 256 * 1024; // ~256 KiB of tail content
105
+ var fd = fs.openSync(feedbackPath, 'r');
106
+ try {
107
+ var readSize = Math.min(TAIL_BYTES, feedbackStat.size);
108
+ var startOffset = feedbackStat.size - readSize;
109
+ var buf = Buffer.alloc(readSize);
110
+ fs.readSync(fd, buf, 0, readSize, startOffset);
111
+ var tailRaw = buf.toString('utf-8');
112
+ var tailLines = tailRaw.split('\n');
113
+ // Drop the first line if we didn't start at byte 0 — it's likely partial.
114
+ if (startOffset > 0) tailLines.shift();
115
+ var validTailLines = tailLines.filter(function(l) {
116
+ if (!l) return false;
117
+ try { JSON.parse(l); return true; } catch (eParse) { return false; }
118
+ });
119
+ var trimmedTail = validTailLines.slice(-1000);
120
+ var tmpFeedbackPath = feedbackPath + '.' + process.pid + '.tmp';
121
+ fs.writeFileSync(tmpFeedbackPath, trimmedTail.join('\n') + (trimmedTail.length > 0 ? '\n' : ''), 'utf-8');
122
+ fs.renameSync(tmpFeedbackPath, feedbackPath);
123
+ } finally {
124
+ fs.closeSync(fd);
125
+ }
126
+ } else {
127
+ var raw = fs.readFileSync(feedbackPath, 'utf-8');
128
+ var lines = raw.split('\n').filter(Boolean);
129
+ if (lines.length > 1000) {
130
+ var tmpFeedbackPath2 = feedbackPath + '.' + process.pid + '.tmp';
131
+ fs.writeFileSync(tmpFeedbackPath2, lines.slice(-1000).join('\n') + '\n', 'utf-8');
132
+ fs.renameSync(tmpFeedbackPath2, feedbackPath);
133
+ }
103
134
  }
104
135
  } catch (e2) { /* rotation is best-effort */ }
105
136
  }
@@ -141,19 +172,6 @@ module.exports = {
141
172
 
142
173
  // Memory Palace tombstone writes removed — redundant with raw session JSONL
143
174
 
144
- // ── Context Persistence Auto-Archive ─────────────────────────────────
145
- // Archive conversation context so it survives compaction and new sessions
146
- try {
147
- var cpHook = await import('file://' + path.join(__dirname, '..', 'context-persistence-hook.mjs'));
148
- if (cpHook && cpHook.archive) {
149
- await hCtx.runWithTimeout(function() { return cpHook.archive(); }, 'context-persistence.archive()');
150
- console.log('[CONTEXT_PERSIST] Session transcript archived');
151
- } else if (cpHook && cpHook.default && cpHook.default.archive) {
152
- await hCtx.runWithTimeout(function() { return cpHook.default.archive(); }, 'context-persistence.archive()');
153
- console.log('[CONTEXT_PERSIST] Session transcript archived');
154
- }
155
- } catch (e) { /* non-fatal — context-persistence may not export archive() */ }
156
-
157
175
  // ── Auto-populate: write session episode for every session ─────
158
176
  // Even solo sessions (no subagents) produce an episode so the
159
177
  // route handler can surface relevant past sessions.
@@ -206,18 +206,6 @@ module.exports = {
206
206
  }
207
207
  } catch (e) { /* non-fatal — hooks workers unavailable */ }
208
208
 
209
- // Context Persistence Auto-Restore
210
- try {
211
- var cpHook = await import('file://' + path.join(helpersDir, 'context-persistence-hook.mjs'));
212
- var restoreFn = (cpHook && cpHook.restore) || (cpHook && cpHook.default && cpHook.default.restore);
213
- if (restoreFn) {
214
- var restored = await runWithTimeout(function() { return restoreFn(); }, 'context-persistence.restore()');
215
- if (restored && restored.turns > 0) {
216
- console.log('[CONTEXT_RESTORED] ' + restored.turns + ' turns from previous session');
217
- }
218
- }
219
- } catch (e) { /* non-fatal */ }
220
-
221
209
  // AgentKnowledgeBase — preload shared knowledge context on session restore.
222
210
  try {
223
211
  var knowledgeDir = path.join(CWD, '.monomind', 'knowledge');
@@ -5,6 +5,8 @@
5
5
 
6
6
  const path = require('path');
7
7
  const fs = require('fs');
8
+ const { purgeStaleRegistrations } = require('../utils/agent-registrations.cjs');
9
+ const { atomicWriteFileSync, withLock } = require('../utils/fs-helpers.cjs');
8
10
 
9
11
  module.exports = {
10
12
  handlePreTask: async function(hCtx) {
@@ -50,36 +52,65 @@ module.exports = {
50
52
  intelligence.feedback(taskSuccess);
51
53
  } catch (e) { /* non-fatal */ }
52
54
  }
53
- // Each TeammateIdle/TaskCompleted = one agent done → remove oldest registration (FIFO)
55
+ // Each TeammateIdle/TaskCompleted = one agent done → remove its registration.
54
56
  const regDir = path.join(CWD, '.monomind', 'agents', 'registrations');
55
57
  try {
56
58
  if (fs.existsSync(regDir)) {
57
59
  const files = fs.readdirSync(regDir).filter(f => f.endsWith('.json'));
58
- if (files.length > 0) {
59
- // Sort by mtime ascending (oldest first) and remove the oldest one
60
+ // P3-15: post-task (TeammateIdle/TaskCompleted) also fires for the MAIN
61
+ // session's own tasks e.g. the team lead completing a self-assigned
62
+ // TaskList item — which never wrote an agent-start registration in the
63
+ // first place. Blindly popping the oldest registration on EVERY
64
+ // post-task event therefore deregistered an unrelated, still-running
65
+ // agent whenever the lead (not a subagent) finished a task, silently
66
+ // drifting the count negative-equivalent (undercounting active agents).
67
+ //
68
+ // Registrations now carry an `agentType` (agent-start-handler.cjs), and
69
+ // this hook's payload may carry the same identifying field under one of
70
+ // several possible names — mirroring the precedence agent-start-handler
71
+ // already uses to derive it. If this event carries NO such field at
72
+ // all, there's no evidence it corresponds to a real agent completion
73
+ // (as opposed to the main session's own task), so we skip touching
74
+ // registrations entirely rather than guessing via FIFO. When the field
75
+ // IS present, prefer removing the registration recorded with the SAME
76
+ // type; fall back to oldest-of-all only if no type match is found
77
+ // (e.g. a legacy registration written before this fix, with no
78
+ // agentType stored).
79
+ const completingType = hookInput.subagent_type || hookInput.agentType || hookInput.agent_type
80
+ || hookInput.agentSlug || hookInput.agent_slug || '';
81
+ if (files.length > 0 && completingType) {
60
82
  const sorted = files
61
- .map(f => ({ f, mtime: (() => { try { return fs.statSync(path.join(regDir, f)).mtimeMs; } catch { return 0; } })() }))
83
+ .map(f => {
84
+ let mtime = 0, agentType = null;
85
+ try { mtime = fs.statSync(path.join(regDir, f)).mtimeMs; } catch { /* ignore */ }
86
+ try { agentType = JSON.parse(fs.readFileSync(path.join(regDir, f), 'utf-8')).agentType || null; } catch { /* ignore */ }
87
+ return { f, mtime, agentType };
88
+ })
62
89
  .sort((a, b) => a.mtime - b.mtime);
63
- try { fs.unlinkSync(path.join(regDir, sorted[0].f)); } catch { /* ignore */ }
90
+ const typeMatch = sorted.find(r => r.agentType === completingType);
91
+ const toRemove = typeMatch || sorted[0];
92
+ try { fs.unlinkSync(path.join(regDir, toRemove.f)); } catch { /* ignore */ }
64
93
  }
65
- // Also purge any stragglers older than 30 min
66
- const now = Date.now();
67
- for (const f of fs.readdirSync(regDir).filter(f => f.endsWith('.json'))) {
68
- try { if (now - fs.statSync(path.join(regDir, f)).mtimeMs > 30 * 60 * 1000) fs.unlinkSync(path.join(regDir, f)); } catch { /* ignore */ }
69
- }
70
- const remaining = fs.readdirSync(regDir).filter(f => f.endsWith('.json')).length;
94
+ // Also purge any stragglers older than 30 min (shared with agent-start-handler.cjs)
95
+ const remaining = purgeStaleRegistrations(regDir) || 0;
96
+ // P2-21: shares a lock with agent-start-handler.cjs's swarm-activity.json
97
+ // writer (same path + '.lock') so the read-prevLastActive-then-write
98
+ // cycle can't race with that handler's concurrent write and silently
99
+ // drop whichever one wrote last — see that file for full rationale.
71
100
  const _actPath = path.join(CWD, '.monomind', 'metrics', 'swarm-activity.json');
72
- let _prevLastActive = 0;
73
- try { var _actSt = fs.statSync(_actPath); if (_actSt.size < 65536) { _prevLastActive = (JSON.parse(fs.readFileSync(_actPath, 'utf-8'))?.swarm?.lastActive) || 0; } } catch { /* ignore */ }
74
- fs.writeFileSync(_actPath, JSON.stringify({
75
- timestamp: new Date().toISOString(),
76
- swarm: {
77
- active: remaining > 0,
78
- agent_count: remaining,
79
- coordination_active: remaining > 0,
80
- lastActive: Math.max(remaining, _prevLastActive), // preserve peak across completion
81
- },
82
- }));
101
+ withLock(_actPath + '.lock', function() {
102
+ let _prevLastActive = 0;
103
+ try { var _actSt = fs.statSync(_actPath); if (_actSt.size < 65536) { _prevLastActive = (JSON.parse(fs.readFileSync(_actPath, 'utf-8'))?.swarm?.lastActive) || 0; } } catch { /* ignore */ }
104
+ atomicWriteFileSync(_actPath, JSON.stringify({
105
+ timestamp: new Date().toISOString(),
106
+ swarm: {
107
+ active: remaining > 0,
108
+ agent_count: remaining,
109
+ coordination_active: remaining > 0,
110
+ lastActive: Math.max(remaining, _prevLastActive), // preserve peak across completion
111
+ },
112
+ }));
113
+ }, 5000);
83
114
  }
84
115
  } catch (e) { /* non-fatal */ }
85
116
 
@@ -136,9 +136,49 @@ async function _ensureHooksModule() {
136
136
  _hooksModule = null; // not built — bridge stays a fail-soft no-op
137
137
  }
138
138
  }
139
+ if (!_hooksModule) _warnHooksBridgeUnavailable();
139
140
  return _hooksModule;
140
141
  }
141
142
 
143
+ // ── Visible one-time warning when the @monomind/hooks bridge fully fails ───
144
+ // Both load paths above (bare specifier + dev-repo file:// fallback) failed:
145
+ // the 15 background workers (metrics, security scan, DDD tracking, etc.) are
146
+ // unavailable for the rest of this session. This is almost always because
147
+ // @monomind/hooks was never resolvable from npm (it ships with
148
+ // optionalDependencies:"*", which npm silently skips) — NOT a problem with
149
+ // the user's own project setup. Historically this failed completely
150
+ // silently (see docs/AUDIT-BACKLOG.md P1-1), burning debugging time on the
151
+ // wrong target. Warned once per session (file-marker keyed by session id,
152
+ // same pattern as the once-per-day update check below) since every hook
153
+ // event is its own fresh `node` process and would otherwise spam this on
154
+ // every single tool call.
155
+ function _warnHooksBridgeUnavailable() {
156
+ try {
157
+ var sessId = String((hookInput && (hookInput.sessionId || hookInput.session_id)) || '');
158
+ var flagFile = path.join(CWD, '.monomind', 'hooks-bridge-warned.json');
159
+ var alreadyWarned = false;
160
+ if (sessId && fs.existsSync(flagFile)) {
161
+ try {
162
+ var flagData = JSON.parse(fs.readFileSync(flagFile, 'utf-8'));
163
+ if (flagData && flagData.sessionId === sessId) alreadyWarned = true;
164
+ } catch (e) { /* corrupt/unreadable flag file — warn again to be safe */ }
165
+ }
166
+ if (alreadyWarned) return;
167
+ process.stderr.write(
168
+ '[WARN] @monomind/hooks could not be loaded (bare specifier and dev-repo fallback both failed) — ' +
169
+ 'background workers (metrics, security scan, DDD tracking, etc.) are unavailable this session. ' +
170
+ 'This is not a problem with your project configuration — the package is not resolvable via npm ' +
171
+ 'from this install. See docs/AUDIT-BACKLOG.md P1-1 or https://github.com/monoes/monomind/issues.\n'
172
+ );
173
+ if (sessId) {
174
+ try {
175
+ fs.mkdirSync(path.join(CWD, '.monomind'), { recursive: true });
176
+ fs.writeFileSync(flagFile, JSON.stringify({ sessionId: sessId, warnedAt: new Date().toISOString() }), 'utf-8');
177
+ } catch (e) { /* non-fatal — worst case we warn again next event */ }
178
+ }
179
+ } catch (e) { /* never let the warning itself break a hook event */ }
180
+ }
181
+
142
182
  // ── Intelligence timeout protection (fixes #1530, #1531) ───────────────────
143
183
  var INTELLIGENCE_TIMEOUT_MS = 1500;
144
184
  function runWithTimeout(fn, label) {
@@ -351,7 +391,20 @@ const handlers = {
351
391
  h.handle(hCtx);
352
392
  },
353
393
 
354
- 'pre-bash': () => {
394
+ 'pre-bash': async () => {
395
+ // SECURITY GATE FIRST — destructive-ops enforcement must never be
396
+ // starved by the slower enrichment work below (monograph hint lookups).
397
+ // Previously this ran LAST, after up to 10 monograph SQLite strategies,
398
+ // AND was fired without awaiting its promise — meaning the block
399
+ // decision (process.exitCode) could still be unset when the process
400
+ // exited under the global 5s safety timer (see main()'s
401
+ // `process.exit(0)`), i.e. the gate could fail OPEN under time
402
+ // pressure. Awaiting it first guarantees the block/allow decision is
403
+ // computed and set before any enrichment work even starts.
404
+ var gates = require('./handlers/gates-handler.cjs');
405
+ await gates.handlePreBash(hCtx);
406
+ if (process.exitCode === 2) return; // blocked — skip enrichment entirely
407
+
355
408
  var cmd = (hCtx.toolInput && (hCtx.toolInput.command || hCtx.toolInput.cmd)) || '';
356
409
  var isGrep = /\b(?:grep|rg|ag)\b/.test(cmd);
357
410
  var isFind = /\b(?:find|fd)\b/.test(cmd) && !isGrep;
@@ -596,9 +649,6 @@ const handlers = {
596
649
  else if (isGrep) _recordGraphTelemetry('bash_grep_call');
597
650
  else _recordGraphTelemetry('bash_find_call');
598
651
  }
599
- // Enforcement gate: destructive operations
600
- var gates = require('./handlers/gates-handler.cjs');
601
- gates.handlePreBash(hCtx);
602
652
  },
603
653
 
604
654
  'pre-write': () => {
@@ -13,6 +13,7 @@
13
13
 
14
14
  const path = require('path');
15
15
  const fs = require('fs');
16
+ const { claimLock, releaseLock } = require('./utils/fs-helpers.cjs');
16
17
 
17
18
  // Resolve base dir at require-time so tests can inject CLAUDE_PROJECT_DIR
18
19
  // per fresh require() call.
@@ -35,6 +36,76 @@ function ensureDataDir() {
35
36
  try { fs.mkdirSync(DATA_DIR, { recursive: true }); } catch (_) {}
36
37
  }
37
38
 
39
+ // ── multi-process-safe store flush (P1-14) ──────────────────────────────────
40
+ // STORE_FILE (auto-memory-store.json) is read once into `_entries` at init()
41
+ // and can be written by more than one code path — consolidate() and
42
+ // bootstrapFromDb() — each of which may be running in a *different* process
43
+ // (e.g. the long-lived MCP server process and a short-lived CJS hook
44
+ // subprocess launched concurrently). Without a lock + re-read-before-write,
45
+ // whichever process flushes last silently erases whatever the other process
46
+ // added since its own load. mergeAndWriteStore() re-reads the current
47
+ // on-disk file immediately before writing, merges the caller's updates in by
48
+ // `id` (keeping whichever record has the newer `ts`), and writes atomically
49
+ // via tmp+rename, guarded by a short advisory lock.
50
+
51
+ const STORE_LOCK_FILE = STORE_FILE + '.lock';
52
+ const STORE_LOCK_STALE_MS = 10 * 1000; // single JSON write — 10s is generous
53
+
54
+ // Uses fs-helpers.cjs's claimLock/releaseLock: unlike an unlink-then-recreate
55
+ // stale-break, it reclaims via an atomic rename, so two processes that both
56
+ // decide the lock is stale can't both end up believing they hold it (a real
57
+ // TOCTOU race a previous unlink-then-create version of this had — best-effort
58
+ // either way, since a missed lock still merges via mergeAndWriteStore's
59
+ // re-read, this just narrows the window further).
60
+ function claimStoreLock() {
61
+ return claimLock(STORE_LOCK_FILE, STORE_LOCK_STALE_MS);
62
+ }
63
+
64
+ function releaseStoreLock() {
65
+ releaseLock(STORE_LOCK_FILE);
66
+ }
67
+
68
+ /**
69
+ * Merge `updates` (new or changed entries) into the on-disk store and write
70
+ * the result atomically. Re-reads STORE_FILE immediately before writing so a
71
+ * concurrent flush from another process is merged, not clobbered. When both
72
+ * sides have an entry with the same `id`, the one with the newer `ts` wins.
73
+ */
74
+ function mergeAndWriteStore(updates, capTo) {
75
+ var acquired = claimStoreLock();
76
+ if (!acquired) acquired = claimStoreLock(); // one retry — lock is short-lived
77
+
78
+ try {
79
+ var diskRaw = safeReadJson(STORE_FILE);
80
+ var diskEntries = Array.isArray(diskRaw) ? diskRaw : [];
81
+ var merged = new Map();
82
+ for (var i = 0; i < diskEntries.length; i++) {
83
+ var d = diskEntries[i];
84
+ if (d && d.id) merged.set(String(d.id), d);
85
+ }
86
+ for (var j = 0; j < (updates || []).length; j++) {
87
+ var u = updates[j];
88
+ if (!u || !u.id) continue;
89
+ var key = String(u.id);
90
+ var existing = merged.get(key);
91
+ if (!existing || (u.ts || 0) >= (existing.ts || 0)) {
92
+ merged.set(key, u);
93
+ }
94
+ }
95
+ var result = Array.from(merged.values());
96
+ if (capTo && result.length > capTo) {
97
+ result.sort(function(a, b) { return (b.ts || 0) - (a.ts || 0); });
98
+ result = result.slice(0, capTo);
99
+ }
100
+ var tmpPath = STORE_FILE + '.' + process.pid + '.tmp';
101
+ fs.writeFileSync(tmpPath, JSON.stringify(result, null, 2), 'utf-8');
102
+ fs.renameSync(tmpPath, STORE_FILE);
103
+ return result;
104
+ } finally {
105
+ if (acquired) releaseStoreLock();
106
+ }
107
+ }
108
+
38
109
  function safeReadJson(filePath) {
39
110
  try {
40
111
  if (!fs.existsSync(filePath)) return null;
@@ -274,7 +345,11 @@ function consolidate() {
274
345
  }
275
346
 
276
347
  if (newStoreEntries.length > 0) {
277
- // Merge with existing store entries
348
+ // Decide which of newStoreEntries are worth adding, based on a read of
349
+ // the store as of "now". The actual write below re-reads immediately
350
+ // before flushing (mergeAndWriteStore), so a concurrent process's writes
351
+ // in between are merged rather than clobbered — this initial read is
352
+ // only used for the near-duplicate decision, not as the write's base.
278
353
  var existing = safeReadJson(STORE_FILE);
279
354
  var store = Array.isArray(existing) ? existing : [];
280
355
  var existingIds = new Set(store.map(function(e) { return e && e.id; }));
@@ -283,25 +358,23 @@ function consolidate() {
283
358
  var fileSetKey = function(e) { return (e.files || []).slice().sort().join('|'); };
284
359
  var seenSummaries = new Set(store.map(function(e) { return e && e.summary; }));
285
360
  var seenFileSets = new Set(store.filter(function(e) { return e && Array.isArray(e.files) && e.files.length > 0; }).map(fileSetKey));
361
+ var toAdd = [];
286
362
  var addedCount = 0;
287
363
  for (var j = 0; j < newStoreEntries.length; j++) {
288
364
  var ne = newStoreEntries[j];
289
365
  if (existingIds.has(ne.id)) continue;
290
366
  if (seenSummaries.has(ne.summary)) continue;
291
367
  if (ne.files && ne.files.length > 0 && seenFileSets.has(fileSetKey(ne))) continue;
292
- store.push(ne);
368
+ toAdd.push(ne);
293
369
  seenSummaries.add(ne.summary);
294
370
  if (ne.files && ne.files.length > 0) seenFileSets.add(fileSetKey(ne));
295
371
  addedCount++;
296
372
  }
297
- // Cap store at 200 entries to prevent unbounded growth
298
- if (store.length > 200) {
299
- store.sort(function(a, b) { return (b.ts || 0) - (a.ts || 0); });
300
- store = store.slice(0, 200);
373
+ if (toAdd.length > 0) {
374
+ try {
375
+ mergeAndWriteStore(toAdd, 200);
376
+ } catch (_) {}
301
377
  }
302
- try {
303
- fs.writeFileSync(STORE_FILE, JSON.stringify(store, null, 2), 'utf-8');
304
- } catch (_) {}
305
378
  count += addedCount;
306
379
  }
307
380
 
@@ -395,19 +468,20 @@ function bootstrapFromDb(db) {
395
468
  if (!db || _entries.length >= 5) return 0;
396
469
  try {
397
470
  var hubs = db.prepare(
398
- "SELECT n.name, n.label, n.file, COUNT(e.id) AS deg " +
399
- "FROM nodes n JOIN edges e ON (e.source = n.id OR e.target = n.id) " +
400
- "WHERE n.label IN ('File','Function','Class') AND n.file NOT LIKE '%node_modules%' AND n.file NOT LIKE '%dist/%' " +
471
+ "SELECT n.name, n.label, n.file_path AS file, COUNT(e.id) AS deg " +
472
+ "FROM nodes n JOIN edges e ON (e.source_id = n.id OR e.target_id = n.id) " +
473
+ "WHERE n.label IN ('File','Function','Class') AND n.file_path NOT LIKE '%node_modules%' AND n.file_path NOT LIKE '%dist/%' " +
401
474
  "GROUP BY n.id ORDER BY deg DESC LIMIT 10"
402
475
  ).all();
403
476
  if (hubs.length === 0) return 0;
404
477
  var existingIds = new Set(_entries.map(function(e) { return e.id; }));
478
+ var newHubEntries = [];
405
479
  var added = 0;
406
480
  for (var hi = 0; hi < hubs.length; hi++) {
407
481
  var h = hubs[hi];
408
482
  var bId = 'bootstrap-hub-' + hi;
409
483
  if (existingIds.has(bId)) continue;
410
- _entries.push({
484
+ var hubEntry = {
411
485
  id: bId,
412
486
  type: 'hub',
413
487
  content: h.name + ' (' + h.label + ') — ' + (h.file || '').replace(CWD + '/', '') + ' (' + h.deg + ' connections)',
@@ -415,12 +489,17 @@ function bootstrapFromDb(db) {
415
489
  confidence: 0.4,
416
490
  files: h.file ? [h.file] : [],
417
491
  ts: Date.now(),
418
- });
492
+ };
493
+ _entries.push(hubEntry);
494
+ newHubEntries.push(hubEntry);
419
495
  added++;
420
496
  }
421
497
  if (added > 0) {
422
498
  ensureDataDir();
423
- try { fs.writeFileSync(STORE_FILE, JSON.stringify(_entries, null, 2), 'utf-8'); } catch (_) {}
499
+ // mergeAndWriteStore re-reads STORE_FILE immediately before writing, so
500
+ // this only ever adds these hub entries into whatever is currently on
501
+ // disk — it never overwrites entries a concurrent process added.
502
+ try { mergeAndWriteStore(newHubEntries, 200); } catch (_) {}
424
503
  }
425
504
  return added;
426
505
  } catch (_) { return 0; }
@@ -9,6 +9,7 @@
9
9
 
10
10
  const path = require('path');
11
11
  const fs = require('fs');
12
+ const { atomicWriteFileSync } = require('./utils/fs-helpers.cjs');
12
13
 
13
14
  const CWD = process.env.CLAUDE_PROJECT_DIR || process.cwd();
14
15
 
@@ -54,7 +55,11 @@ function readCurrent() {
54
55
  function writeCurrent(data) {
55
56
  ensureDir();
56
57
  try {
57
- fs.writeFileSync(CURRENT_FILE, JSON.stringify(data, null, 2), 'utf-8');
58
+ // P2-21: tmp+rename — current.json is read+written by metric()/update()
59
+ // from multiple concurrent hook processes (pre-task, post-edit, etc. can
60
+ // all fire in one batched tool-call message); a plain writeFileSync lets
61
+ // a concurrent reader observe a partially-written file.
62
+ atomicWriteFileSync(CURRENT_FILE, JSON.stringify(data, null, 2), 'utf-8');
58
63
  } catch (_) {}
59
64
  }
60
65
 
@@ -18,6 +18,7 @@ const fs = require('fs');
18
18
  const path = require('path');
19
19
  const { execSync, spawnSync } = require('child_process');
20
20
  const os = require('os');
21
+ const { cleanEntries } = require('./utils/fs-helpers.cjs');
21
22
 
22
23
  // Configuration
23
24
  const CONFIG = {
@@ -197,8 +198,7 @@ function getModelFromSessionJSONL() {
197
198
  if (!fs.existsSync(projectsDir)) return null;
198
199
 
199
200
  // Most recently modified JSONL = current (or latest) session
200
- const files = fs.readdirSync(projectsDir)
201
- .filter(f => f.endsWith('.jsonl'))
201
+ const files = cleanEntries(projectsDir, f => f.endsWith('.jsonl'))
202
202
  .map(f => ({ f, mt: (() => { try { return fs.statSync(path.join(projectsDir, f)).mtimeMs; } catch { return 0; } })() }))
203
203
  .sort((a, b) => b.mt - a.mt);
204
204
  if (files.length === 0) return null;
@@ -303,7 +303,7 @@ function getLearningStats() {
303
303
  try {
304
304
  const sessDir = path.join(CWD, '.claude', 'sessions');
305
305
  if (fs.existsSync(sessDir)) {
306
- sessions = fs.readdirSync(sessDir).filter(f => f.endsWith('.json')).length;
306
+ sessions = cleanEntries(sessDir, f => f.endsWith('.json')).length;
307
307
  }
308
308
  } catch { /* ignore */ }
309
309
 
@@ -357,7 +357,7 @@ function getSecurityStatus() {
357
357
  try {
358
358
  const scanDir = path.join(CWD, '.claude', 'security-scans');
359
359
  if (fs.existsSync(scanDir)) {
360
- scanCount = fs.readdirSync(scanDir).filter(f => f.endsWith('.json')).length;
360
+ scanCount = cleanEntries(scanDir, f => f.endsWith('.json')).length;
361
361
  }
362
362
  } catch { /* ignore */ }
363
363
 
@@ -379,7 +379,7 @@ function getSwarmStatus() {
379
379
  const regDir = path.join(CWD, '.monomind', 'agents', 'registrations');
380
380
  if (fs.existsSync(regDir)) {
381
381
  try {
382
- const files = fs.readdirSync(regDir).filter(f => f.endsWith('.json'));
382
+ const files = cleanEntries(regDir, f => f.endsWith('.json'));
383
383
  const liveCount = files.filter(f => {
384
384
  try {
385
385
  return (now - fs.statSync(path.join(regDir, f)).mtimeMs) < agentRegTtlMs;
@@ -488,7 +488,7 @@ function getADRStatus() {
488
488
  for (const adrPath of adrPaths) {
489
489
  try {
490
490
  if (fs.existsSync(adrPath)) {
491
- const files = fs.readdirSync(adrPath).filter(f =>
491
+ const files = cleanEntries(adrPath, f =>
492
492
  f.endsWith('.md') && (f.startsWith('ADR-') || f.startsWith('adr-') || /^\d{4}-/.test(f))
493
493
  );
494
494
  // Report actual count — don't guess compliance without reading files
@@ -523,7 +523,7 @@ function getHooksStatus() {
523
523
  try {
524
524
  const hooksDir = path.join(CWD, '.claude', 'hooks');
525
525
  if (fs.existsSync(hooksDir)) {
526
- const hookFiles = fs.readdirSync(hooksDir).filter(f => f.endsWith('.js') || f.endsWith('.sh')).length;
526
+ const hookFiles = cleanEntries(hooksDir, f => f.endsWith('.js') || f.endsWith('.sh')).length;
527
527
  total = Math.max(total, hookFiles);
528
528
  enabled = Math.max(enabled, hookFiles);
529
529
  }
@@ -870,7 +870,7 @@ function getLoopStatus() {
870
870
  const now = Date.now();
871
871
  const loops = [];
872
872
  try {
873
- const files = fs.readdirSync(loopsDir).filter(f =>
873
+ const files = cleanEntries(loopsDir, f =>
874
874
  f.endsWith('.json') && !f.includes('-hil') && !f.endsWith('.stop'));
875
875
  for (const f of files) {
876
876
  const d = readJSON(path.join(loopsDir, f));
@@ -895,7 +895,7 @@ function getHILPending() {
895
895
  if (!fs.existsSync(loopsDir)) return { pending: 0 };
896
896
  let pending = 0;
897
897
  try {
898
- const files = fs.readdirSync(loopsDir).filter(f => f.endsWith('-hil.md'));
898
+ const files = cleanEntries(loopsDir, f => f.endsWith('-hil.md'));
899
899
  for (const f of files) {
900
900
  try {
901
901
  const txt = fs.readFileSync(path.join(loopsDir, f), 'utf-8');
@@ -932,7 +932,7 @@ function getActiveOrgs() {
932
932
  const runsDir = path.join(orgsDir, orgName, 'runs');
933
933
  if (!fs.existsSync(runsDir)) continue;
934
934
  try {
935
- const files = fs.readdirSync(runsDir).filter(f => f.endsWith('.jsonl'));
935
+ const files = cleanEntries(runsDir, f => f.endsWith('.jsonl'));
936
936
  if (!files.length) continue;
937
937
  files.sort();
938
938
  const latest = files[files.length - 1];