monomind 2.0.2 → 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 (90) hide show
  1. package/package.json +9 -5
  2. package/packages/@monomind/cli/.claude/commands/mastermind/runorg.md +4 -0
  3. package/packages/@monomind/cli/.claude/helpers/control-start.cjs +50 -5
  4. package/packages/@monomind/cli/.claude/helpers/event-logger.cjs +109 -6
  5. package/packages/@monomind/cli/.claude/helpers/graphify-freshen.cjs +19 -17
  6. package/packages/@monomind/cli/.claude/helpers/handlers/agent-start-handler.cjs +60 -28
  7. package/packages/@monomind/cli/.claude/helpers/handlers/capture-handler.cjs +75 -18
  8. package/packages/@monomind/cli/.claude/helpers/handlers/edit-handler.cjs +27 -9
  9. package/packages/@monomind/cli/.claude/helpers/handlers/gates-handler.cjs +16 -7
  10. package/packages/@monomind/cli/.claude/helpers/handlers/loops-status-handler.cjs +1 -1
  11. package/packages/@monomind/cli/.claude/helpers/handlers/route-handler.cjs +4 -4
  12. package/packages/@monomind/cli/.claude/helpers/handlers/session-handler.cjs +40 -22
  13. package/packages/@monomind/cli/.claude/helpers/handlers/session-restore-handler.cjs +0 -12
  14. package/packages/@monomind/cli/.claude/helpers/handlers/task-handler.cjs +53 -22
  15. package/packages/@monomind/cli/.claude/helpers/hook-handler.cjs +54 -4
  16. package/packages/@monomind/cli/.claude/helpers/intelligence.cjs +94 -15
  17. package/packages/@monomind/cli/.claude/helpers/session.cjs +6 -1
  18. package/packages/@monomind/cli/.claude/helpers/statusline.cjs +10 -10
  19. package/packages/@monomind/cli/.claude/helpers/token-tracker.cjs +3 -3
  20. package/packages/@monomind/cli/.claude/helpers/utils/agent-registrations.cjs +41 -0
  21. package/packages/@monomind/cli/.claude/helpers/utils/fs-helpers.cjs +183 -0
  22. package/packages/@monomind/cli/.claude/helpers/utils/micro-agents.cjs +10 -6
  23. package/packages/@monomind/cli/.claude/helpers/utils/telemetry.cjs +35 -19
  24. package/packages/@monomind/cli/bin/cli.js +10 -1
  25. package/packages/@monomind/cli/dist/src/commands/agent-ops.js +9 -15
  26. package/packages/@monomind/cli/dist/src/commands/cleanup.js +20 -4
  27. package/packages/@monomind/cli/dist/src/commands/completions.js +66 -261
  28. package/packages/@monomind/cli/dist/src/commands/doctor.js +7 -2
  29. package/packages/@monomind/cli/dist/src/commands/hooks-workers.js +7 -0
  30. package/packages/@monomind/cli/dist/src/commands/index.js +3 -0
  31. package/packages/@monomind/cli/dist/src/commands/mcp.js +11 -6
  32. package/packages/@monomind/cli/dist/src/commands/memory-admin.js +12 -6
  33. package/packages/@monomind/cli/dist/src/commands/memory-transfer.js +6 -13
  34. package/packages/@monomind/cli/dist/src/commands/monograph.js +3 -3
  35. package/packages/@monomind/cli/dist/src/commands/org.d.ts +3 -1
  36. package/packages/@monomind/cli/dist/src/commands/org.js +306 -129
  37. package/packages/@monomind/cli/dist/src/commands/start.js +142 -37
  38. package/packages/@monomind/cli/dist/src/commands/tokens.js +21 -3
  39. package/packages/@monomind/cli/dist/src/config-adapter.js +37 -17
  40. package/packages/@monomind/cli/dist/src/index.js +64 -28
  41. package/packages/@monomind/cli/dist/src/init/claudemd-generator.js +44 -11
  42. package/packages/@monomind/cli/dist/src/init/executor.js +63 -49
  43. package/packages/@monomind/cli/dist/src/mcp-client.d.ts +8 -3
  44. package/packages/@monomind/cli/dist/src/mcp-client.js +37 -3
  45. package/packages/@monomind/cli/dist/src/mcp-server.js +8 -2
  46. package/packages/@monomind/cli/dist/src/mcp-tools/agent-tools.d.ts +20 -0
  47. package/packages/@monomind/cli/dist/src/mcp-tools/agent-tools.js +10 -5
  48. package/packages/@monomind/cli/dist/src/mcp-tools/github-tools.js +30 -8
  49. package/packages/@monomind/cli/dist/src/mcp-tools/graphify-tools.js +11 -2
  50. package/packages/@monomind/cli/dist/src/mcp-tools/hive-mind-tools.js +51 -34
  51. package/packages/@monomind/cli/dist/src/mcp-tools/knowledge-tools.js +9 -1
  52. package/packages/@monomind/cli/dist/src/mcp-tools/monograph-compat.js +56 -14
  53. package/packages/@monomind/cli/dist/src/mcp-tools/monograph-tools.js +65 -43
  54. package/packages/@monomind/cli/dist/src/mcp-tools/swarm-tools.js +6 -4
  55. package/packages/@monomind/cli/dist/src/mcp-tools/system-tools.js +4 -4
  56. package/packages/@monomind/cli/dist/src/mcp-tools/types.d.ts +20 -0
  57. package/packages/@monomind/cli/dist/src/mcp-tools/types.js +36 -1
  58. package/packages/@monomind/cli/dist/src/memory/hnsw-operations.d.ts +13 -1
  59. package/packages/@monomind/cli/dist/src/memory/hnsw-operations.js +66 -14
  60. package/packages/@monomind/cli/dist/src/memory/intelligence.js +99 -3
  61. package/packages/@monomind/cli/dist/src/memory/memory-bridge.d.ts +7 -0
  62. package/packages/@monomind/cli/dist/src/memory/memory-bridge.js +20 -0
  63. package/packages/@monomind/cli/dist/src/orgrt/broker.d.ts +24 -0
  64. package/packages/@monomind/cli/dist/src/orgrt/broker.js +68 -0
  65. package/packages/@monomind/cli/dist/src/orgrt/daemon.d.ts +30 -0
  66. package/packages/@monomind/cli/dist/src/orgrt/daemon.js +96 -7
  67. package/packages/@monomind/cli/dist/src/orgrt/forwarder.d.ts +10 -5
  68. package/packages/@monomind/cli/dist/src/orgrt/forwarder.js +105 -8
  69. package/packages/@monomind/cli/dist/src/orgrt/live.html +56 -0
  70. package/packages/@monomind/cli/dist/src/orgrt/provider.js +1 -0
  71. package/packages/@monomind/cli/dist/src/orgrt/scheduler.d.ts +13 -0
  72. package/packages/@monomind/cli/dist/src/orgrt/scheduler.js +48 -0
  73. package/packages/@monomind/cli/dist/src/orgrt/server.d.ts +8 -0
  74. package/packages/@monomind/cli/dist/src/orgrt/server.js +72 -0
  75. package/packages/@monomind/cli/dist/src/orgrt/test-loop.d.ts +12 -0
  76. package/packages/@monomind/cli/dist/src/orgrt/test-loop.js +108 -0
  77. package/packages/@monomind/cli/dist/src/output.js +12 -5
  78. package/packages/@monomind/cli/dist/src/parser.d.ts +32 -0
  79. package/packages/@monomind/cli/dist/src/parser.js +130 -5
  80. package/packages/@monomind/cli/dist/src/routing/embed-worker.js +13 -1
  81. package/packages/@monomind/cli/dist/src/routing/route-layer-factory.js +31 -0
  82. package/packages/@monomind/cli/dist/src/services/config-file-manager.d.ts +21 -0
  83. package/packages/@monomind/cli/dist/src/services/config-file-manager.js +60 -7
  84. package/packages/@monomind/cli/dist/src/services/crash-reporter.js +2 -1
  85. package/packages/@monomind/cli/dist/src/ui/dashboard.html +40 -40
  86. package/packages/@monomind/cli/dist/src/ui/orgs.html +110 -11
  87. package/packages/@monomind/cli/dist/src/ui/server.mjs +304 -133
  88. package/packages/@monomind/cli/dist/src/utils/input-guards.d.ts +9 -0
  89. package/packages/@monomind/cli/dist/src/utils/input-guards.js +39 -5
  90. package/packages/@monomind/cli/package.json +6 -2
@@ -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];
@@ -351,7 +351,7 @@ function parseSessionFile(filePath, project, seenMsgIds, dateStart, dateEnd) {
351
351
  function collectJsonlFiles(dirPath) {
352
352
  var files = [];
353
353
  var entries;
354
- try { entries = fs.readdirSync(dirPath); } catch (e) { return files; }
354
+ try { entries = fs.readdirSync(dirPath).filter(function(f) { return !f.startsWith('._'); }); } catch (e) { return files; }
355
355
  for (var i = 0; i < entries.length; i++) {
356
356
  var e = entries[i];
357
357
  if (e.endsWith('.jsonl')) {
@@ -360,7 +360,7 @@ function collectJsonlFiles(dirPath) {
360
360
  // Check subagents
361
361
  var subDir = path.join(dirPath, e, 'subagents');
362
362
  var subFiles;
363
- try { subFiles = fs.readdirSync(subDir); } catch (_) { continue; }
363
+ try { subFiles = fs.readdirSync(subDir).filter(function(f) { return !f.startsWith('._'); }); } catch (_) { continue; }
364
364
  for (var j = 0; j < subFiles.length; j++) {
365
365
  if (subFiles[j].endsWith('.jsonl')) {
366
366
  files.push(path.join(subDir, subFiles[j]));
@@ -389,7 +389,7 @@ function unsanitize(name) {
389
389
  function parseAllSessions(dateStart, dateEnd) {
390
390
  var projectsDir = getClaudeProjectsDir();
391
391
  var projectDirs;
392
- try { projectDirs = fs.readdirSync(projectsDir); } catch (e) { return []; }
392
+ try { projectDirs = fs.readdirSync(projectsDir).filter(function(f) { return !f.startsWith('._'); }); } catch (e) { return []; }
393
393
 
394
394
  var seenMsgIds = new Set();
395
395
  var projectMap = {};
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+ // Shared agent-registration purge logic.
3
+ //
4
+ // .monomind/agents/registrations/*.json accumulate one file per subagent
5
+ // spawn. The only purge used to be the FIFO-oldest-removal + stale sweep in
6
+ // task-handler.cjs's handlePostTask, which only fires on TeammateIdle/
7
+ // TaskCompleted — so any session that spawns agents but never emits those
8
+ // events (crashes, single-shot Task calls outside a team, etc.) leaked
9
+ // registrations forever. agent-start-handler.cjs already reads this
10
+ // directory on every subagent start, so it also runs this same stale sweep.
11
+
12
+ const path = require('path');
13
+ const fs = require('fs');
14
+ const { cleanEntries } = require('./fs-helpers.cjs');
15
+
16
+ const DEFAULT_MAX_AGE_MS = 30 * 60 * 1000; // 30 minutes
17
+
18
+ /**
19
+ * Delete registration files older than maxAgeMs. Returns the number of
20
+ * registrations remaining after the purge (or null if regDir doesn't exist
21
+ * or an error occurred).
22
+ */
23
+ function purgeStaleRegistrations(regDir, maxAgeMs) {
24
+ maxAgeMs = maxAgeMs || DEFAULT_MAX_AGE_MS;
25
+ try {
26
+ if (!fs.existsSync(regDir)) return null;
27
+ const now = Date.now();
28
+ for (const f of cleanEntries(regDir, f => f.endsWith('.json'))) {
29
+ try {
30
+ if (now - fs.statSync(path.join(regDir, f)).mtimeMs > maxAgeMs) {
31
+ fs.unlinkSync(path.join(regDir, f));
32
+ }
33
+ } catch { /* ignore */ }
34
+ }
35
+ return cleanEntries(regDir, f => f.endsWith('.json')).length;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ module.exports = { purgeStaleRegistrations, DEFAULT_MAX_AGE_MS };
@@ -0,0 +1,183 @@
1
+ 'use strict';
2
+ // Shared fs helpers for hook handlers.
3
+ //
4
+ // exFAT / macOS AppleDouble junk files (`._foo.json`, `.__protocol.md`, etc.)
5
+ // get created whenever files touch an exFAT volume (external drives, some
6
+ // network shares) and then get picked up by naive `readdirSync().filter(...)`
7
+ // calls as if they were real data — corrupting counts, getting parsed as
8
+ // JSON and failing, or (worst case) surfacing as garbage entries in
9
+ // Claude Code's own skill/command list. Every readdir in the hook handlers
10
+ // should route through `cleanEntries()` so this is filtered exactly once.
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ /**
16
+ * Read a directory and filter out exFAT AppleDouble junk (`._*`) before
17
+ * applying the caller's own filter predicate.
18
+ * @param {string} dir - directory to read
19
+ * @param {(name: string) => boolean} [filterFn] - optional additional filter
20
+ * @returns {string[]} entry names (not full paths), junk-free
21
+ */
22
+ function cleanEntries(dir, filterFn) {
23
+ let entries;
24
+ try {
25
+ entries = fs.readdirSync(dir);
26
+ } catch {
27
+ return [];
28
+ }
29
+ entries = entries.filter(f => !f.startsWith('._'));
30
+ return filterFn ? entries.filter(filterFn) : entries;
31
+ }
32
+
33
+ /**
34
+ * Same as cleanEntries but returns full paths.
35
+ */
36
+ function cleanEntryPaths(dir, filterFn) {
37
+ return cleanEntries(dir, filterFn).map(f => path.join(dir, f));
38
+ }
39
+
40
+ /**
41
+ * Synchronous sleep (blocks the event loop) — used for tiny lock-retry
42
+ * backoffs where an async setTimeout would require restructuring a
43
+ * short-lived hook script into a promise chain for no real benefit.
44
+ * Falls back to a busy-wait if Atomics.wait is unavailable.
45
+ */
46
+ function sleepSync(ms) {
47
+ try {
48
+ const sab = new SharedArrayBuffer(4);
49
+ Atomics.wait(new Int32Array(sab), 0, 0, ms);
50
+ } catch {
51
+ const end = Date.now() + ms;
52
+ while (Date.now() < end) { /* spin */ }
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Write a file atomically: write to a unique temp path then rename into
58
+ * place. `fs.renameSync` is atomic on POSIX and Windows (same volume), so
59
+ * concurrent readers of `filePath` never observe a partially-written file
60
+ * (torn read) — they see either the old complete file or the new complete
61
+ * file, never a half-written one. Does not by itself prevent lost updates
62
+ * from concurrent read-modify-write cycles — pair with `claimLock` when
63
+ * that matters (see hook-latency.json / swarm-activity.json writers).
64
+ */
65
+ function atomicWriteFileSync(filePath, data, encoding) {
66
+ const dir = path.dirname(filePath);
67
+ try { fs.mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
68
+ const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
69
+ fs.writeFileSync(tmp, data, encoding);
70
+ fs.renameSync(tmp, filePath);
71
+ }
72
+
73
+ /**
74
+ * Claim an exclusive lock file for short-lived critical sections (e.g. a
75
+ * rebuild-cooldown or spawn-once guard). Uses `wx` (exclusive create) so
76
+ * the claim itself is atomic — no read-check-write window.
77
+ *
78
+ * On contention, a lock older than `staleMs` is treated as abandoned (the
79
+ * holder crashed or never released it) and broken via an atomic RENAME
80
+ * (never unlink-then-create): renaming the stale lock path away is only
81
+ * ever won by exactly one racing process, so a concurrent process that
82
+ * also decided the lock was stale cannot delete *this* process's
83
+ * freshly-reclaimed lock out from under it (the TOCTOU race an
84
+ * unlink-then-create approach is vulnerable to). The loser of the rename
85
+ * race backs off and retries the whole claim sequence from the top rather
86
+ * than assuming it owns anything.
87
+ *
88
+ * Returns true if the lock was claimed (caller must eventually call
89
+ * `releaseLock`), false if another live/fresh owner holds it.
90
+ */
91
+ function claimLock(lockPath, staleMs, maxAttempts) {
92
+ staleMs = staleMs || 30000;
93
+ maxAttempts = maxAttempts || 3;
94
+ try { fs.mkdirSync(path.dirname(lockPath), { recursive: true }); } catch { /* ignore */ }
95
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
96
+ try {
97
+ fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx' });
98
+ return true;
99
+ } catch {
100
+ let stat;
101
+ try {
102
+ stat = fs.statSync(lockPath);
103
+ } catch {
104
+ // Lock vanished between our failed wx-create and this stat check —
105
+ // retry the create immediately.
106
+ continue;
107
+ }
108
+ if (Date.now() - stat.mtimeMs < staleMs) return false; // held by a live/fresh owner
109
+
110
+ const claimedName = `${lockPath}.${process.pid}.${Date.now()}.stale`;
111
+ try {
112
+ fs.renameSync(lockPath, claimedName);
113
+ } catch {
114
+ // Someone else already renamed the stale lock away — they're
115
+ // claiming it. Back off briefly and retry from the top rather than
116
+ // proceeding as if we own it.
117
+ sleepSync(5);
118
+ continue;
119
+ }
120
+ try { fs.unlinkSync(claimedName); } catch { /* ignore */ }
121
+ // We won the rename race — loop back and attempt the wx-create fresh.
122
+ }
123
+ }
124
+ return false;
125
+ }
126
+
127
+ /** Release a lock previously claimed by this process via `claimLock`. */
128
+ function releaseLock(lockPath) {
129
+ try {
130
+ if (Number(fs.readFileSync(lockPath, 'utf-8')) === process.pid) fs.unlinkSync(lockPath);
131
+ } catch { /* ignore */ }
132
+ }
133
+
134
+ /**
135
+ * Run `fn` while holding a lock (see `claimLock`) so a read-modify-write
136
+ * cycle is protected end-to-end, not just the final write. If the lock
137
+ * can't be claimed after retries, `fn` still runs (never block a hook) but
138
+ * without lost-update protection — best-effort, matching this codebase's
139
+ * "hooks must not block the session" rule.
140
+ */
141
+ function withLock(lockPath, fn, staleMs, maxAttempts) {
142
+ const acquired = claimLock(lockPath, staleMs, maxAttempts);
143
+ try {
144
+ return fn();
145
+ } finally {
146
+ if (acquired) releaseLock(lockPath);
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Append a JSONL line and rotate the file down to `maxLines` (keep the
152
+ * most recent lines) when it exceeds that count — mirrors the size-cap
153
+ * pattern already used for intelligence-outcomes.jsonl (500 lines) and
154
+ * episodes.jsonl. Prevents unbounded JSONL growth. Rotation write is
155
+ * atomic (tmp+rename).
156
+ */
157
+ function appendJsonlWithRotation(filePath, line, maxLines) {
158
+ maxLines = maxLines || 500;
159
+ try { fs.mkdirSync(path.dirname(filePath), { recursive: true }); } catch { /* ignore */ }
160
+ fs.appendFileSync(filePath, line.endsWith('\n') ? line : line + '\n');
161
+ try {
162
+ const st = fs.statSync(filePath);
163
+ // Cheap heuristic to skip reading huge files line-by-line on every
164
+ // append: only bother counting lines once the file is already
165
+ // suspiciously large relative to a plausible maxLines * avg-line-size.
166
+ if (st.size < 4096) return;
167
+ const lines = fs.readFileSync(filePath, 'utf-8').split('\n').filter(Boolean);
168
+ if (lines.length > maxLines) {
169
+ atomicWriteFileSync(filePath, lines.slice(-maxLines).join('\n') + '\n', 'utf-8');
170
+ }
171
+ } catch { /* non-fatal — rotation is best-effort */ }
172
+ }
173
+
174
+ module.exports = {
175
+ cleanEntries,
176
+ cleanEntryPaths,
177
+ sleepSync,
178
+ atomicWriteFileSync,
179
+ claimLock,
180
+ releaseLock,
181
+ withLock,
182
+ appendJsonlWithRotation,
183
+ };
@@ -5,6 +5,7 @@ const path = require('path');
5
5
  const fs = require('fs');
6
6
 
7
7
  const { _openMonographDb } = require('./monograph.cjs');
8
+ const { cleanEntries, atomicWriteFileSync } = require('./fs-helpers.cjs');
8
9
 
9
10
  const CWD = process.env.CLAUDE_PROJECT_DIR || process.cwd();
10
11
 
@@ -82,7 +83,7 @@ function _triggerCollectMdFiles(dir, _depth) {
82
83
  if (depth > 5) return []; // cap recursion depth to prevent stack overflow DoS
83
84
  var results = [];
84
85
  try {
85
- var entries = fs.readdirSync(dir);
86
+ var entries = cleanEntries(dir);
86
87
  for (var i = 0; i < entries.length; i++) {
87
88
  if (results.length >= 200) break; // cap total file count to prevent DoS
88
89
  var full = path.join(dir, entries[i]);
@@ -135,8 +136,11 @@ function scanMicroAgentTriggers(prompt) {
135
136
  if (!cacheLoaded) {
136
137
  patterns = _triggerBuildIndex(agentDir);
137
138
  try {
138
- fs.mkdirSync(path.join(CWD, '.monomind'), { recursive: true });
139
- fs.writeFileSync(indexPath, JSON.stringify({ patterns: patterns, builtAt: new Date().toISOString(), totalAgentsScanned: patterns.length }));
139
+ // P2-21: tmp+rename multiple concurrent hook processes (route-handler
140
+ // invocations fire per prompt/subagent) can all miss the cache at once
141
+ // and rebuild+write trigger-index.json simultaneously; atomic write
142
+ // prevents a concurrent reader from parsing a half-written file.
143
+ atomicWriteFileSync(indexPath, JSON.stringify({ patterns: patterns, builtAt: new Date().toISOString(), totalAgentsScanned: patterns.length }));
140
144
  } catch (e) {}
141
145
  }
142
146
 
@@ -334,9 +338,9 @@ function _autoIndexKnowledge(knowledgeDir) {
334
338
  } catch (e) { /* graph not available yet */ }
335
339
 
336
340
  try {
337
- fs.mkdirSync(knowledgeDir, { recursive: true });
338
- fs.writeFileSync(chunksFile, newLines.length > 0 ? newLines.join('\n') + '\n' : '', 'utf-8');
339
- fs.writeFileSync(hashFile, contentHash, 'utf-8');
341
+ // Same torn-read hazard as trigger-index.json above atomic write.
342
+ atomicWriteFileSync(chunksFile, newLines.length > 0 ? newLines.join('\n') + '\n' : '', 'utf-8');
343
+ atomicWriteFileSync(hashFile, contentHash, 'utf-8');
340
344
  } catch (e) {}
341
345
  return newLines.length;
342
346
  }
@@ -4,6 +4,7 @@
4
4
 
5
5
  const path = require('path');
6
6
  const fs = require('fs');
7
+ const { atomicWriteFileSync, withLock, appendJsonlWithRotation } = require('./fs-helpers.cjs');
7
8
 
8
9
  const CWD = process.env.CLAUDE_PROJECT_DIR || process.cwd();
9
10
 
@@ -19,7 +20,11 @@ function _recordRecentEdit(filePath) {
19
20
  d.edits = d.edits.filter(function(e) { return e.file !== storedPath; });
20
21
  d.edits.unshift({ file: storedPath, editedAt: Date.now() });
21
22
  if (d.edits.length > 10) d.edits = d.edits.slice(0, 10);
22
- fs.writeFileSync(f, JSON.stringify(d));
23
+ // P2-21: tmp+rename so a concurrent reader of recent-edits.json (fired by
24
+ // another PostToolUse hook process in the same batched Edit) never sees a
25
+ // partially-written file. Last-writer-wins on the merge itself is
26
+ // acceptable here — losing one recent-edit entry is low-stakes.
27
+ atomicWriteFileSync(f, JSON.stringify(d));
23
28
  } catch (e) { /* non-fatal */ }
24
29
  }
25
30
 
@@ -45,7 +50,8 @@ function _recordToolCall(signature) {
45
50
  d = { startedAt: Date.now(), calls: {} };
46
51
  }
47
52
  d.calls[signature] = (d.calls[signature] || 0) + 1;
48
- fs.writeFileSync(f, JSON.stringify(d));
53
+ // P2-21: atomic write — see _recordRecentEdit above for rationale.
54
+ atomicWriteFileSync(f, JSON.stringify(d));
49
55
  return d.calls[signature];
50
56
  } catch (e) { return 0; }
51
57
  }
@@ -77,8 +83,8 @@ function _getBudgetStatus() {
77
83
  monthlyLimit = Math.max(monthlyLimit || 0, Math.ceil(dailyAvg * 1.5 * 30));
78
84
  autoTuned = true;
79
85
  try {
80
- fs.mkdirSync(path.dirname(budgetFile), { recursive: true });
81
- fs.writeFileSync(budgetFile, JSON.stringify({
86
+ // P2-21: atomic write — see _recordRecentEdit above for rationale.
87
+ atomicWriteFileSync(budgetFile, JSON.stringify({
82
88
  dailyLimit: dailyLimit, monthlyLimit: monthlyLimit,
83
89
  autoTuned: true, tunedAt: now.toISOString(),
84
90
  basis: 'rolling avg $' + dailyAvg.toFixed(2) + '/day × 1.5',
@@ -106,20 +112,28 @@ function _getBudgetStatus() {
106
112
  }
107
113
 
108
114
  function _recordHookLatency(handlerName, durationMs) {
115
+ var f = path.join(CWD, '.monomind', 'metrics', 'hook-latency.json');
116
+ // P2-21: hook-latency.json's per-handler count/total/max are aggregated
117
+ // counters written by every hook invocation — a plain read-modify-write
118
+ // silently drops whichever concurrent process wrote last (lost update),
119
+ // not just torn reads. Wrap the whole cycle in a lock (see
120
+ // utils/fs-helpers.cjs claimLock/withLock, same TOCTOU-safe pattern used
121
+ // for the rebuild/spawn locks) so the read and the write happen as one
122
+ // critical section instead of racing.
109
123
  try {
110
- var f = path.join(CWD, '.monomind', 'metrics', 'hook-latency.json');
111
- fs.mkdirSync(path.dirname(f), { recursive: true });
112
- var d = {};
113
- try { d = JSON.parse(fs.readFileSync(f, 'utf-8')); } catch (_) {}
114
- if (typeof d !== 'object' || d === null) d = {};
115
- var entry = d[handlerName] || { count: 0, total: 0, max: 0 };
116
- entry.count++;
117
- entry.total += durationMs;
118
- entry.max = Math.max(entry.max, durationMs);
119
- entry.mean = Math.round(entry.total / entry.count);
120
- d[handlerName] = entry;
121
- d.lastUpdated = Date.now();
122
- fs.writeFileSync(f, JSON.stringify(d));
124
+ withLock(f + '.lock', function() {
125
+ var d = {};
126
+ try { d = JSON.parse(fs.readFileSync(f, 'utf-8')); } catch (_) {}
127
+ if (typeof d !== 'object' || d === null) d = {};
128
+ var entry = d[handlerName] || { count: 0, total: 0, max: 0 };
129
+ entry.count++;
130
+ entry.total += durationMs;
131
+ entry.max = Math.max(entry.max, durationMs);
132
+ entry.mean = Math.round(entry.total / entry.count);
133
+ d[handlerName] = entry;
134
+ d.lastUpdated = Date.now();
135
+ atomicWriteFileSync(f, JSON.stringify(d));
136
+ }, 5000);
123
137
  } catch (e) {}
124
138
  }
125
139
 
@@ -130,9 +144,11 @@ function _recordDecisionMarkers(promptText) {
130
144
  if (!matches || matches.length === 0) return;
131
145
  try {
132
146
  var f = path.join(CWD, '.monomind', 'decisions.jsonl');
133
- fs.mkdirSync(path.dirname(f), { recursive: true });
134
147
  var entry = JSON.stringify({ ts: Date.now(), excerpts: matches.slice(0, 3), prompt: promptText.slice(0, 400) });
135
- fs.appendFileSync(f, entry + '\n');
148
+ // P2-26: decisions.jsonl was a pure appendFileSync with no rotation —
149
+ // unlike intelligence-outcomes.jsonl (capped at 500 lines). Cap it the
150
+ // same way so it can't grow unbounded across a long project history.
151
+ appendJsonlWithRotation(f, entry, 500);
136
152
  } catch (e) {}
137
153
  }
138
154
 
@@ -241,8 +241,17 @@ if (isMCPMode) {
241
241
 
242
242
  const { CLI } = await import('../dist/src/index.js');
243
243
  const cli = new CLI();
244
+ // A detached daemon child (`start --daemon --foreground-worker-internal`,
245
+ // see src/commands/start.ts) intentionally keeps a ref'd setInterval alive
246
+ // after its action resolves — that's what holds the process open as a real
247
+ // daemon. Unconditionally calling process.exit() here would kill it the
248
+ // instant the action's promise resolves, defeating the whole point.
249
+ const isDaemonChild = cliArgs.includes('--foreground-worker-internal');
244
250
  cli.run().then(() => {
245
- process.exit(process.exitCode ?? 0);
251
+ if (!isDaemonChild) {
252
+ process.exit(process.exitCode ?? 0);
253
+ }
254
+ // Daemon child: let the event loop stay alive on its own ref'd interval.
246
255
  }).catch((error) => {
247
256
  console.error('Fatal error:', safeMsg(error && error.message));
248
257
  process.exit(1);
@@ -8,11 +8,7 @@ import { formatStatus } from './agent-lifecycle.js';
8
8
  export const metricsCommand = {
9
9
  name: 'metrics',
10
10
  description: 'Show agent performance metrics',
11
- options: [
12
- { name: 'period', short: 'p', description: 'Time period (1h, 24h, 7d, 30d)', type: 'string', default: '24h' },
13
- ],
14
11
  action: async (ctx) => {
15
- const period = ctx.flags.period;
16
12
  const { existsSync, readFileSync, readdirSync, statSync } = await import('fs');
17
13
  const { join } = await import('path');
18
14
  let totalAgents = 0;
@@ -61,13 +57,12 @@ export const metricsCommand = {
61
57
  catch { /* ignore */ }
62
58
  }
63
59
  let vectorCount = 0;
64
- const dbPath = join(swarmDir, 'memory.db');
65
- if (existsSync(dbPath)) {
66
- try {
67
- vectorCount = Math.floor(statSync(dbPath).size / 2048);
68
- }
69
- catch { /* ignore */ }
60
+ try {
61
+ const { bridgeGetBackendStats } = await import('../memory/memory-bridge.js');
62
+ const backendStats = await bridgeGetBackendStats();
63
+ vectorCount = backendStats?.totalEntries ?? 0;
70
64
  }
65
+ catch { /* backend unavailable */ }
71
66
  const byType = Object.entries(typeCounts).map(([type, data]) => ({
72
67
  type, count: data.count, tasks: data.tasks,
73
68
  successRate: data.tasks > 0 ? `${Math.round((data.success / data.tasks) * 100)}%` : 'N/A',
@@ -76,20 +71,19 @@ export const metricsCommand = {
76
71
  ? `${Math.round(Object.values(typeCounts).reduce((a, d) => a + d.success, 0) / tasksCompleted * 100)}%`
77
72
  : 'N/A';
78
73
  const metrics = {
79
- period,
80
74
  summary: {
81
75
  totalAgents, activeAgents, tasksCompleted, avgSuccessRate, vectorCount,
82
76
  note: totalAgents === 0 ? 'No agents spawned yet. Use: agent spawn -t coder' : undefined,
83
77
  },
84
78
  byType,
85
- performance: { memoryVectors: `${vectorCount} vectors`, searchBackend: vectorCount > 0 ? 'HNSW-indexed' : 'none' },
79
+ performance: { memoryEntries: `${vectorCount} entries`, searchBackend: vectorCount > 0 ? 'LanceDB' : 'none' },
86
80
  };
87
81
  if (ctx.flags.format === 'json') {
88
82
  output.printJson(metrics);
89
83
  return { success: true, data: metrics };
90
84
  }
91
85
  output.writeln();
92
- output.writeln(output.bold(`Agent Metrics (${period})`));
86
+ output.writeln(output.bold('Agent Metrics'));
93
87
  output.writeln();
94
88
  output.printTable({
95
89
  columns: [
@@ -101,7 +95,7 @@ export const metricsCommand = {
101
95
  { metric: 'Active Agents', value: metrics.summary.activeAgents },
102
96
  { metric: 'Tasks Completed', value: metrics.summary.tasksCompleted },
103
97
  { metric: 'Success Rate', value: metrics.summary.avgSuccessRate },
104
- { metric: 'Memory Vectors', value: metrics.summary.vectorCount },
98
+ { metric: 'Memory Entries', value: metrics.summary.vectorCount },
105
99
  ],
106
100
  });
107
101
  output.writeln();
@@ -122,7 +116,7 @@ export const metricsCommand = {
122
116
  output.writeln();
123
117
  output.writeln(output.bold('Memory'));
124
118
  output.printList([
125
- `Vectors: ${output.success(metrics.performance.memoryVectors)}`,
119
+ `Entries: ${output.success(metrics.performance.memoryEntries)}`,
126
120
  `Backend: ${output.success(metrics.performance.searchBackend)}`,
127
121
  ]);
128
122
  return { success: true, data: metrics };
@@ -5,8 +5,8 @@
5
5
  * github.com/monoes/monomind
6
6
  */
7
7
  import { output } from '../output.js';
8
- import { existsSync, lstatSync, rmSync, readdirSync } from 'fs';
9
- import { join } from 'path';
8
+ import { existsSync, lstatSync, rmSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
9
+ import { join, dirname } from 'path';
10
10
  /**
11
11
  * Artifact directories and files that monomind/monomind may create
12
12
  */
@@ -190,13 +190,29 @@ export const cleanupCommand = {
190
190
  // Actually delete
191
191
  try {
192
192
  const fullPath = join(cwd, item.path);
193
- if (item.type === 'dir') {
193
+ // Special-case: `.claude/` is scanned and removed as a single unit,
194
+ // but KEEP_CONFIG_PATHS promises `.claude/settings.json` survives
195
+ // --keep-config. Back the file up, wipe the directory, then
196
+ // restore just that one file — the rest of `.claude/` is still
197
+ // removed as normal.
198
+ const settingsPath = join(cwd, '.claude', 'settings.json');
199
+ const isClaudeDirWithPreservedSettings = item.type === 'dir' && item.path === '.claude' && keepConfig && existsSync(settingsPath);
200
+ if (isClaudeDirWithPreservedSettings) {
201
+ const settingsBackup = readFileSync(settingsPath);
194
202
  rmSync(fullPath, { recursive: true, force: true });
203
+ mkdirSync(dirname(settingsPath), { recursive: true });
204
+ writeFileSync(settingsPath, settingsBackup);
205
+ output.writeln(output.success(` [removed] ${typeLabel} ${item.path} (${sizeStr}) - ${item.description}`));
206
+ output.writeln(output.dim(` [kept] file .claude/settings.json - preserved (--keep-config)`));
207
+ }
208
+ else if (item.type === 'dir') {
209
+ rmSync(fullPath, { recursive: true, force: true });
210
+ output.writeln(output.success(` [removed] ${typeLabel} ${item.path} (${sizeStr}) - ${item.description}`));
195
211
  }
196
212
  else {
197
213
  rmSync(fullPath, { force: true });
214
+ output.writeln(output.success(` [removed] ${typeLabel} ${item.path} (${sizeStr}) - ${item.description}`));
198
215
  }
199
- output.writeln(output.success(` [removed] ${typeLabel} ${item.path} (${sizeStr}) - ${item.description}`));
200
216
  removedCount++;
201
217
  removedSize += item.size;
202
218
  }