monomind 2.7.10 → 2.7.12

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 (24) hide show
  1. package/package.json +3 -3
  2. package/packages/@monomind/cli/.claude/helpers/token-tracker.cjs +95 -5
  3. package/packages/@monomind/cli/.claude/helpers/utils/monograph.cjs +16 -1
  4. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/context-signals.mjs +1 -1
  5. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/context.mjs +11 -7
  6. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/critique-storage.mjs +3 -3
  7. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detect-csp.mjs +1 -1
  8. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/fix/index.mjs +1 -1
  9. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/hook-admin.mjs +10 -10
  10. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/hook-before-edit.mjs +2 -2
  11. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/hook-lib.mjs +2 -2
  12. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live/manual-apply.mjs +3 -3
  13. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live/svelte-component.mjs +1 -1
  14. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-accept.mjs +1 -1
  15. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-commit-manual-edits.mjs +2 -2
  16. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-copy-edit-agent.mjs +1 -1
  17. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-insert.mjs +2 -2
  18. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-manual-edit-evidence.mjs +4 -4
  19. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-server.mjs +1 -1
  20. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-wrap.mjs +3 -3
  21. package/packages/@monomind/cli/bin/cli.js +59 -0
  22. package/packages/@monomind/cli/dist/src/commands/org.js +43 -0
  23. package/packages/@monomind/cli/package.json +6 -6
  24. package/scripts/build-fs.mjs +75 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "monomind",
3
- "version": "2.7.10",
3
+ "version": "2.7.12",
4
4
  "description": "Open-source CLI extension for Claude Code. Adds an MCP server with a codebase knowledge graph, persistent memory, multi-agent coordination, and reusable slash commands. MIT licensed, runs locally, no data leaves your machine.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -64,7 +64,7 @@
64
64
  "dependencies": {
65
65
  "@anthropic-ai/claude-agent-sdk": "^0.3.207",
66
66
  "@monoes/monobrowse": "^1.0.6",
67
- "@monoes/monodesign": "^1.2.1",
67
+ "@monoes/monodesign": "^1.2.2",
68
68
  "@monoes/monograph": "^1.5.4",
69
69
  "@noble/ed25519": "^2.1.0",
70
70
  "mammoth": "^1.12.0",
@@ -109,7 +109,7 @@
109
109
  "@protobufjs/utf8": ">=1.1.1",
110
110
  "@grpc/grpc-js": ">=1.14.4",
111
111
  "vite": ">=8.0.16",
112
- "postcss": ">=8.5.10",
112
+ "postcss": ">=8.5.18",
113
113
  "fast-uri": ">=4.1.1",
114
114
  "form-data": ">=4.0.6",
115
115
  "ip-address": ">=10.1.1",
@@ -403,6 +403,26 @@ function parseAllSessions(dateStart, dateEnd) {
403
403
 
404
404
  var jsonlFiles = collectJsonlFiles(dirPath);
405
405
  for (var j = 0; j < jsonlFiles.length; j++) {
406
+ // Skip transcripts last written before the window opened. Sessions are
407
+ // append-only, so a file whose mtime predates dateStart cannot hold an
408
+ // entry inside the range — reading and JSON.parsing it is pure waste.
409
+ //
410
+ // `tokens today` was parsing every transcript on the machine: ~1GB over
411
+ // ~2,200 files here, of which ~300 had been touched that day. That took
412
+ // ~10s, which is what made it unusable as a SessionStart hook (#42) —
413
+ // the documented hook sets a 10s timeout and `npx` adds its own
414
+ // registry-check overhead on top.
415
+ //
416
+ // Safe with respect to the seenMsgIds dedupe: parseSessionFile applies
417
+ // the date filter BEFORE groupAndClassify ever consults that set, so
418
+ // out-of-range entries never registered a message id in the first place.
419
+ // Skipping the file is exactly equivalent to reading it and discarding
420
+ // every line. Only applied when a start bound was actually requested.
421
+ if (dateStart) {
422
+ var fstat;
423
+ try { fstat = fs.statSync(jsonlFiles[j]); } catch (_) { fstat = null; }
424
+ if (fstat && fstat.mtime < dateStart) continue;
425
+ }
406
426
  var session = parseSessionFile(jsonlFiles[j], dirName, seenMsgIds, dateStart, dateEnd);
407
427
  if (session && session.apiCalls > 0) {
408
428
  if (!projectMap[dirName]) {
@@ -543,14 +563,79 @@ function _computeQuickTotals() {
543
563
  return { todayCost: todayCost, todayCalls: todayCalls, monthCost: monthCost, monthCalls: monthCalls };
544
564
  }
545
565
 
566
+ function _formatQuickTotals(d) {
567
+ return '[TOKEN_USAGE] Today: ' + fmt$(d.todayCost) + ' (' + d.todayCalls + ' calls) | Month: ' + fmt$(d.monthCost) + ' (' + d.monthCalls + ' calls)';
568
+ }
569
+
570
+ // The quick summary needs a MONTH window, and every transcript on an active
571
+ // machine has been touched this month — so unlike the day view it cannot be
572
+ // narrowed by mtime and inherently costs a full parse (~9.4s over ~1GB here).
573
+ //
574
+ // That is what broke `monomind tokens today` as a SessionStart hook (#42): the
575
+ // documented hook allows 10s, so the computation alone straddles the timeout
576
+ // before `npx` has even checked the registry, and Claude Code hangs waiting.
577
+ //
578
+ // A cost read-out does not need to be to-the-second, so serve it from cache and
579
+ // refresh out of band: a session start returns instantly, and the next one
580
+ // picks up the newly written value. A cold cache prints nothing rather than
581
+ // blocking the editor — never make the user wait on a status line.
582
+ var QUICK_CACHE_TTL_MS = 10 * 60 * 1000;
583
+
584
+ function _quickCachePath() {
585
+ return path.join(path.dirname(getClaudeProjectsDir()), '.monomind-token-summary.json');
586
+ }
587
+
588
+ function _readQuickCache() {
589
+ try {
590
+ var raw = JSON.parse(fs.readFileSync(_quickCachePath(), 'utf8'));
591
+ if (!raw || typeof raw.computedAt !== 'number' || !raw.totals) return null;
592
+ return raw;
593
+ } catch (_) { return null; }
594
+ }
595
+
596
+ function _writeQuickCache(totals) {
597
+ try {
598
+ var tmp = _quickCachePath() + '.' + process.pid + '.tmp';
599
+ fs.writeFileSync(tmp, JSON.stringify({ computedAt: Date.now(), totals: totals }));
600
+ fs.renameSync(tmp, _quickCachePath()); // atomic: never expose a half-written cache
601
+ } catch (_) { /* cache is best-effort */ }
602
+ }
603
+
604
+ /** Recompute and persist the cached totals. Used by the background refresh. */
605
+ function refreshQuickSummary() {
606
+ var d = _computeQuickTotals();
607
+ if (d) _writeQuickCache(d);
608
+ return d;
609
+ }
610
+
611
+ function _spawnQuickRefresh() {
612
+ try {
613
+ var child = require('child_process').spawn(
614
+ process.execPath, [__filename, 'refresh-summary'],
615
+ { detached: true, stdio: 'ignore' },
616
+ );
617
+ child.unref(); // must not hold the caller's event loop open
618
+ } catch (_) { /* best effort */ }
619
+ }
620
+
546
621
  /**
547
622
  * Returns a one-line token usage summary for the current day and month.
548
- * Called at session-restore.
623
+ * Called at session-restore. Cache-first and non-blocking — see the note above.
549
624
  */
550
625
  function quickSummary() {
551
- var d = _computeQuickTotals();
552
- if (!d) return null;
553
- return '[TOKEN_USAGE] Today: ' + fmt$(d.todayCost) + ' (' + d.todayCalls + ' calls) | Month: ' + fmt$(d.monthCost) + ' (' + d.monthCalls + ' calls)';
626
+ var cached = _readQuickCache();
627
+ var fresh = cached && (Date.now() - cached.computedAt) < QUICK_CACHE_TTL_MS;
628
+ if (fresh) return _formatQuickTotals(cached.totals);
629
+ // Stale or missing: never recompute inline, or we reintroduce #42. Serve the
630
+ // stale figure (clearly better than nothing) and refresh for next time.
631
+ _spawnQuickRefresh();
632
+ return cached ? _formatQuickTotals(cached.totals) : null;
633
+ }
634
+
635
+ /** Synchronous full computation. For `tokens` CLI views, which may block. */
636
+ function quickSummaryBlocking() {
637
+ var d = refreshQuickSummary();
638
+ return d ? _formatQuickTotals(d) : null;
554
639
  }
555
640
 
556
641
  /**
@@ -913,6 +998,8 @@ function runInteractive() {
913
998
  module.exports = {
914
999
  parseAllSessions: parseAllSessions,
915
1000
  quickSummary: quickSummary,
1001
+ quickSummaryBlocking: quickSummaryBlocking,
1002
+ refreshQuickSummary: refreshQuickSummary,
916
1003
  quickSummaryData: quickSummaryData,
917
1004
  renderDashboard: renderDashboard,
918
1005
  runInteractive: runInteractive,
@@ -925,7 +1012,10 @@ module.exports = {
925
1012
  if (require.main === module) {
926
1013
  var args = process.argv.slice(2);
927
1014
  var cmd = args[0] || 'dashboard';
928
- if (cmd === 'summary') {
1015
+ if (cmd === 'refresh-summary') {
1016
+ // Detached background refresh spawned by quickSummary(). Silent by design.
1017
+ refreshQuickSummary();
1018
+ } else if (cmd === 'summary') {
929
1019
  var s = quickSummary();
930
1020
  process.stdout.write((s || 'No token data available') + '\n');
931
1021
  } else if (cmd === 'report') {
@@ -368,8 +368,23 @@ function _graphGateWriteSessions(sessions) {
368
368
  // one is reclaimed, we still perform an atomic-rename write — no worse than the
369
369
  // old behavior, and never a hang. Hooks run on every tool call, so the total
370
370
  // wait is deliberately tiny.
371
- var _GRAPH_GATE_LOCK_TIMEOUT_MS = 250;
371
+ // The acquire budget MUST exceed the stale threshold. It used to be 250ms
372
+ // against a 2000ms stale window, which had two consequences:
373
+ //
374
+ // 1. A writer that could not get the lock within 250ms gave up and wrote
375
+ // UNLOCKED — precisely the lost-update this lock exists to prevent. CI
376
+ // landed 9 of 16 concurrent writers; a fast machine hides it, because the
377
+ // critical section is microseconds and the budget is never exhausted.
378
+ // 2. The stale-reclaim branch below was nearly unreachable. Reclaiming needs
379
+ // the lock to be older than 2000ms, but we stopped waiting after 250ms, so
380
+ // a crashed holder's lock was almost never actually reclaimed.
381
+ //
382
+ // With the budget above the stale window both paths work: a live holder is
383
+ // waited out (its critical section is a single read-modify-write), and a dead
384
+ // holder's lock is reclaimed at 2s and then taken. The waiting only happens
385
+ // under real contention — an uncontended acquire is one mkdir.
372
386
  var _GRAPH_GATE_LOCK_STALE_MS = 2000;
387
+ var _GRAPH_GATE_LOCK_TIMEOUT_MS = 3000;
373
388
 
374
389
  function _sleepSync(ms) {
375
390
  try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch (e) { /* no SAB */ }
@@ -93,7 +93,7 @@ function latestCritique(cwd) {
93
93
  p0: numKey('p0', 'p0_count'),
94
94
  p1: numKey('p1', 'p1_count'),
95
95
  timestamp: get('timestamp'),
96
- file: path.relative(cwd, path.join(dir, newest)),
96
+ file: path.relative(cwd, path.join(dir, newest)).split(path.sep).join('/'),
97
97
  trend,
98
98
  openP0,
99
99
  };
@@ -75,10 +75,10 @@ export function loadContext(cwd = process.cwd(), options = {}) {
75
75
  return {
76
76
  hasProduct: !!product,
77
77
  product,
78
- productPath: productPath ? path.relative(absCwd, productPath) : null,
78
+ productPath: productPath ? path.relative(absCwd, productPath).split(path.sep).join('/') : null,
79
79
  hasDesign: !!design,
80
80
  design,
81
- designPath: designPath ? path.relative(absCwd, designPath) : null,
81
+ designPath: designPath ? path.relative(absCwd, designPath).split(path.sep).join('/') : null,
82
82
  contextDir: resolved.contextDir,
83
83
  productContextDir: productPath ? path.dirname(productPath) : null,
84
84
  designContextDir: designPath ? path.dirname(designPath) : null,
@@ -186,7 +186,7 @@ function resolveProject(cwd = process.cwd(), options = {}) {
186
186
  }
187
187
 
188
188
  function isPathInside(candidate, root) {
189
- const rel = path.relative(root, candidate);
189
+ const rel = path.relative(root, candidate).split(path.sep).join('/');
190
190
  return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
191
191
  }
192
192
 
@@ -339,9 +339,9 @@ function contextSourceStatus(filePath, repoRoot, projectRoot) {
339
339
 
340
340
  function contextSourcePath(filePath, repoRoot) {
341
341
  if (!filePath) return null;
342
- const rel = path.relative(repoRoot, filePath);
342
+ const rel = path.relative(repoRoot, filePath).split(path.sep).join('/');
343
343
  if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
344
- return rel.split(path.sep).join('/');
344
+ return rel;
345
345
  }
346
346
  return filePath;
347
347
  }
@@ -447,9 +447,13 @@ function findTargetExample(repoRoot, projectRoot) {
447
447
  }
448
448
 
449
449
  function resolveWorkspaceProjectRoot(repoRoot, targetDir) {
450
- const rel = path.relative(repoRoot, targetDir);
450
+ const rel = path.relative(repoRoot, targetDir).split(path.sep).join('/');
451
451
  if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot;
452
- const relSegments = rel.split(path.sep).filter(Boolean);
452
+ // rel is POSIX-normalised above, so split on '/' — splitting on path.sep here
453
+ // returned the whole string as a single segment on Windows, no workspace
454
+ // pattern ever matched, and every child project silently resolved to the
455
+ // repo root instead.
456
+ const relSegments = rel.split('/').filter(Boolean);
453
457
  const patterns = readWorkspacePatterns(repoRoot);
454
458
  const excluded = isExcludedByWorkspacePattern(relSegments, patterns);
455
459
  if (!excluded) {
@@ -64,7 +64,7 @@ export function slugFromTarget(resolved, { cwd = process.cwd() } = {}) {
64
64
  // File path. Make it project-relative so two devs critiquing the same
65
65
  // checkout get the same slug regardless of where their repo is cloned.
66
66
  const abs = path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
67
- let rel = path.relative(cwd, abs);
67
+ let rel = path.relative(cwd, abs).split(path.sep).join('/');
68
68
  // If the target is outside cwd, fall back to the basename so we still
69
69
  // produce a stable slug (vs the absolute path, which would include
70
70
  // home dirs / usernames).
@@ -231,7 +231,7 @@ export function mirrorToMemory({ slug, meta = {}, filePath, cwd = process.cwd(),
231
231
  p1: asFiniteNumber(meta.p1_count ?? meta.p1),
232
232
  date: now.toISOString(),
233
233
  slug,
234
- path: filePath ? path.relative(cwd, filePath) : null,
234
+ path: filePath ? path.relative(cwd, filePath).split(path.sep).join('/') : null,
235
235
  };
236
236
  const project = kebab(path.basename(path.resolve(cwd))) || 'project';
237
237
  const key = `${project}-${slug}`;
@@ -340,7 +340,7 @@ export function formatRecall(recall, { cwd = process.cwd() } = {}) {
340
340
  if (!issues.p0.length && !issues.p1.length) {
341
341
  lines.push('- No open P0/P1 lines found in the latest snapshot.');
342
342
  }
343
- lines.push(`- Snapshot: ${path.relative(cwd, latest.path)}`);
343
+ lines.push(`- Snapshot: ${path.relative(cwd, latest.path).split(path.sep).join('/')}`);
344
344
  return lines.join('\n');
345
345
  }
346
346
 
@@ -186,7 +186,7 @@ function walk(root, dir, depth, visit) {
186
186
  body = buf.slice(0, n).toString('utf-8');
187
187
  } finally { fs.closeSync(fd); }
188
188
  } catch { continue; }
189
- visit(abs, path.relative(root, abs), body);
189
+ visit(abs, path.relative(root, abs).split(path.sep).join('/'), body);
190
190
  }
191
191
  }
192
192
 
@@ -257,7 +257,7 @@ async function runFix(targets, options = {}) {
257
257
  const diffs = [];
258
258
  if (dryRun) {
259
259
  for (const [p, b] of changedFiles) {
260
- diffs.push({ file: p, diff: unifiedDiff(b.original, b.content, path.relative(cwd, p) || p) });
260
+ diffs.push({ file: p, diff: unifiedDiff(b.original, b.content, path.relative(cwd, p).split(path.sep).join('/') || p) });
261
261
  }
262
262
  } else {
263
263
  for (const [p, b] of changedFiles) writeFileAtomic(p, b.content);
@@ -275,9 +275,9 @@ function statusReport(cwd) {
275
275
  const cfg = readConfig(cwd);
276
276
  const envKill = process.env.MONODESIGN_HOOK_DISABLED;
277
277
  const envState = envKill ? `MONODESIGN_HOOK_DISABLED=${envKill}` : 'unset';
278
- const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.monodesign/config.json';
279
- const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.monodesign/config.local.json';
280
- const cachePath = path.relative(cwd, getCachePath(cwd)) || '.monodesign/hook.cache.json';
278
+ const cfgPath = path.relative(cwd, getConfigPath(cwd)).split(path.sep).join('/') || '.monodesign/config.json';
279
+ const localPath = path.relative(cwd, getLocalConfigPath(cwd)).split(path.sep).join('/') || '.monodesign/config.local.json';
280
+ const cachePath = path.relative(cwd, getCachePath(cwd)).split(path.sep).join('/') || '.monodesign/hook.cache.json';
281
281
  const fileState = (info, relPath, absent) => {
282
282
  if (info.malformed) return `${relPath} (malformed; ignored)`;
283
283
  if (info.exists) return relPath;
@@ -306,14 +306,14 @@ function setEnabled(cwd, value) {
306
306
  config.enabled = value;
307
307
  const target = writeHookConfig(cwd, config);
308
308
  if (!value) {
309
- return `Design hook disabled for this project (wrote ${path.relative(cwd, target) || target}).`;
309
+ return `Design hook disabled for this project (wrote ${path.relative(cwd, target).split(path.sep).join('/') || target}).`;
310
310
  }
311
311
 
312
312
  const localTarget = writeHookConfig(cwd, { consent: 'accepted' }, { local: true });
313
313
  const repaired = repairHookManifests(cwd);
314
314
  const parts = [
315
- `Design hook enabled for this project (wrote ${path.relative(cwd, target) || target}).`,
316
- `Recorded local hook consent in ${path.relative(cwd, localTarget) || localTarget}.`,
315
+ `Design hook enabled for this project (wrote ${path.relative(cwd, target).split(path.sep).join('/') || target}).`,
316
+ `Recorded local hook consent in ${path.relative(cwd, localTarget).split(path.sep).join('/') || localTarget}.`,
317
317
  ];
318
318
  if (repaired.written.length > 0) {
319
319
  parts.push(`Installed or repaired hook manifests for: ${repaired.written.join(', ')}.`);
@@ -323,7 +323,7 @@ function setEnabled(cwd, value) {
323
323
  parts.push('No installed provider skill folders found to repair.');
324
324
  }
325
325
  if (repaired.backups.length > 0) {
326
- parts.push(`Backed up malformed manifest(s): ${repaired.backups.map((filePath) => path.relative(cwd, filePath) || filePath).join(', ')}.`);
326
+ parts.push(`Backed up malformed manifest(s): ${repaired.backups.map((filePath) => path.relative(cwd, filePath).split(path.sep).join('/') || filePath).join(', ')}.`);
327
327
  }
328
328
  return parts.join(' ');
329
329
  }
@@ -596,7 +596,7 @@ function addIgnoreValue(cwd, args) {
596
596
 
597
597
  const target = writeDetectorConfig(cwd, config, { local });
598
598
  const scope = local ? 'local detector.ignoreValues' : 'shared detector.ignoreValues';
599
- return `Added ${parsed.rule}=${parsed.value} to ${scope} (${path.relative(cwd, target) || target}).`;
599
+ return `Added ${parsed.rule}=${parsed.value} to ${scope} (${path.relative(cwd, target).split(path.sep).join('/') || target}).`;
600
600
  }
601
601
 
602
602
  function reset(cwd) {
@@ -613,7 +613,7 @@ function reset(cwd) {
613
613
  } else {
614
614
  fs.writeFileSync(filePath, JSON.stringify(rest, null, 2) + '\n');
615
615
  }
616
- removed.push(path.relative(cwd, filePath) || filePath);
616
+ removed.push(path.relative(cwd, filePath).split(path.sep).join('/') || filePath);
617
617
  } catch { /* ignore */ }
618
618
  }
619
619
  // State files are wholly ours; delete outright.
@@ -621,7 +621,7 @@ function reset(cwd) {
621
621
  try {
622
622
  if (fs.existsSync(filePath)) {
623
623
  fs.unlinkSync(filePath);
624
- removed.push(path.relative(cwd, filePath) || filePath);
624
+ removed.push(path.relative(cwd, filePath).split(path.sep).join('/') || filePath);
625
625
  }
626
626
  } catch { /* ignore */ }
627
627
  }
@@ -320,7 +320,7 @@ function escapeRegExp(value) {
320
320
 
321
321
  function relativePath(filePath, cwd) {
322
322
  try {
323
- const rel = path.relative(cwd, filePath);
323
+ const rel = path.relative(cwd, filePath).split(path.sep).join('/');
324
324
  if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return filePath;
325
325
  return rel.split(path.sep).join('/');
326
326
  } catch {
@@ -330,7 +330,7 @@ function relativePath(filePath, cwd) {
330
330
 
331
331
  function isInsideProject(filePath, cwd) {
332
332
  try {
333
- const rel = path.relative(cwd, filePath);
333
+ const rel = path.relative(cwd, filePath).split(path.sep).join('/');
334
334
  return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
335
335
  } catch {
336
336
  return false;
@@ -1015,7 +1015,7 @@ function quoteCommandArg(value) {
1015
1015
 
1016
1016
  function relativize(filePath, cwd) {
1017
1017
  try {
1018
- const rel = path.relative(cwd, filePath);
1018
+ const rel = path.relative(cwd, filePath).split(path.sep).join('/');
1019
1019
  if (!rel || rel.startsWith('..')) return filePath;
1020
1020
  return rel.split(path.sep).join('/');
1021
1021
  } catch {
@@ -1226,7 +1226,7 @@ function hasPathTraversal(filePath) {
1226
1226
  function isInsideProject(filePath, projectCwd) {
1227
1227
  if (!filePath || !projectCwd || hasPathTraversal(filePath)) return false;
1228
1228
  try {
1229
- const rel = path.relative(projectCwd, filePath);
1229
+ const rel = path.relative(projectCwd, filePath).split(path.sep).join('/');
1230
1230
  return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
1231
1231
  } catch {
1232
1232
  return false;
@@ -338,7 +338,7 @@ export function normalizeManualApplyEvidencePath(evidencePath, cwd = process.cwd
338
338
  if (!evidencePath || typeof evidencePath !== 'string') return null;
339
339
  const fullPath = path.isAbsolute(evidencePath) ? evidencePath : path.resolve(cwd, evidencePath);
340
340
  const evidenceDir = manualApplyEvidenceDir(cwd);
341
- const relative = path.relative(evidenceDir, fullPath);
341
+ const relative = path.relative(evidenceDir, fullPath).split(path.sep).join('/');
342
342
  if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
343
343
  if (path.extname(relative) !== '.json') return null;
344
344
  return fullPath;
@@ -837,7 +837,7 @@ export function collectManualApplyFiles(batch, extraFiles = [], cwd = process.cw
837
837
  function normalizeProjectFile(file, cwd = process.cwd()) {
838
838
  if (!file || typeof file !== 'string') return null;
839
839
  const absolute = path.isAbsolute(file) ? file : path.resolve(cwd, file);
840
- const relative = path.relative(cwd, absolute);
840
+ const relative = path.relative(cwd, absolute).split(path.sep).join('/');
841
841
  if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
842
842
  return relative;
843
843
  }
@@ -927,7 +927,7 @@ export function summarizeManualDiagnostics(items, cwd = process.cwd()) {
927
927
  export function summarizeManualLogFile(file, cwd = process.cwd()) {
928
928
  if (!file || typeof file !== 'string') return undefined;
929
929
  if (!path.isAbsolute(file)) return file;
930
- const relative = path.relative(cwd, file);
930
+ const relative = path.relative(cwd, file).split(path.sep).join('/');
931
931
  return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : file;
932
932
  }
933
933
 
@@ -265,7 +265,7 @@ export function resolveSourceFile(sourceFile, cwd = process.cwd()) {
265
265
  throw new Error('Invalid svelte-component source file');
266
266
  }
267
267
  const full = path.resolve(cwd, sourceFile);
268
- const rel = path.relative(cwd, full);
268
+ const rel = path.relative(cwd, full).split(path.sep).join('/');
269
269
  if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
270
270
  throw new Error('Svelte-component source file escapes project root');
271
271
  }
@@ -119,7 +119,7 @@ Output (JSON):
119
119
  }
120
120
 
121
121
  const { file: targetFile, content, lines } = found;
122
- const relFile = path.relative(process.cwd(), targetFile);
122
+ const relFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
123
123
  const previewBlock = findMarkerBlock(id, lines);
124
124
  const sourceShadowPreview = previewBlock
125
125
  ? readSourceShadowPreviewMeta(content, id)
@@ -224,7 +224,7 @@ function buildRepairBatch(batch, repair) {
224
224
  function normalizeProjectSourcePath(cwd, file, opts = {}) {
225
225
  if (!file || typeof file !== 'string') return null;
226
226
  const absolute = path.isAbsolute(file) ? file : path.resolve(cwd, file);
227
- const relative = path.relative(cwd, absolute);
227
+ const relative = path.relative(cwd, absolute).split(path.sep).join('/');
228
228
  if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
229
229
  if (opts.requireExists && !fs.existsSync(absolute)) return null;
230
230
  if (isGeneratedFile(absolute, { cwd })) return null;
@@ -622,7 +622,7 @@ function scanRollbackDir(dir, cwd, out, seenDirs, seenFiles, depth) {
622
622
  try { realFile = fs.realpathSync(absolute); } catch { continue; }
623
623
  if (seenFiles.has(realFile)) continue;
624
624
  seenFiles.add(realFile);
625
- const relative = path.relative(cwd, absolute);
625
+ const relative = path.relative(cwd, absolute).split(path.sep).join('/');
626
626
  if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) continue;
627
627
  out.push(relative);
628
628
  }
@@ -534,7 +534,7 @@ function runAgentProcess(command, args, stdin, { cwd, env, logPath, timeoutMs, m
534
534
  }
535
535
 
536
536
  function isPathInsideOrEqual(cwd, file) {
537
- const relative = path.relative(path.resolve(cwd), path.resolve(file));
537
+ const relative = path.relative(path.resolve(cwd), path.resolve(file)).split(path.sep).join('/');
538
538
  return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
539
539
  }
540
540
 
@@ -166,7 +166,7 @@ Output (JSON):
166
166
  console.error(JSON.stringify({
167
167
  error: 'file_is_generated',
168
168
  fallback: 'agent-driven',
169
- file: path.relative(process.cwd(), path.resolve(process.cwd(), targetFile)),
169
+ file: path.relative(process.cwd(), path.resolve(process.cwd(), targetFile)).split(path.sep).join('/'),
170
170
  }));
171
171
  process.exit(1);
172
172
  }
@@ -179,7 +179,7 @@ Output (JSON):
179
179
  console.error(JSON.stringify({
180
180
  error: 'element_ambiguous',
181
181
  fallback: 'agent-driven',
182
- file: path.relative(process.cwd(), targetFile),
182
+ file: path.relative(process.cwd(), targetFile).split(path.sep).join('/'),
183
183
  candidates: resolved.candidates.map((c) => ({
184
184
  startLine: c.startLine + 1,
185
185
  endLine: c.endLine + 1,
@@ -64,7 +64,7 @@ export function buildManualEditEvidence({ cwd = process.cwd(), pageUrl = null }
64
64
  ops,
65
65
  context: {
66
66
  cwd,
67
- bufferPath: path.relative(cwd, getBufferPath(cwd)),
67
+ bufferPath: path.relative(cwd, getBufferPath(cwd)).split(path.sep).join('/'),
68
68
  totalEntries: entries.length,
69
69
  totalOps: opCount,
70
70
  },
@@ -157,7 +157,7 @@ function analyzeSourceHint(op, cwd) {
157
157
  const hint = normalizeSourceHint(op.sourceHint);
158
158
  if (!hint.file) return null;
159
159
  const file = path.resolve(cwd, hint.file);
160
- const relativeFile = path.relative(cwd, file);
160
+ const relativeFile = path.relative(cwd, file).split(path.sep).join('/');
161
161
  if (!isPathInsideOrEqual(cwd, file)) {
162
162
  return { ...hint, status: 'outside_cwd', relativeFile: hint.file };
163
163
  }
@@ -254,7 +254,7 @@ function maybeAddSearchFile(file, cwd, seenFiles, out) {
254
254
  if (isGeneratedFile(file, { cwd })) return;
255
255
  let content;
256
256
  try { content = fs.readFileSync(file, 'utf-8'); } catch { return; }
257
- out.push({ file, relativeFile: path.relative(cwd, file), content, lines: content.split('\n') });
257
+ out.push({ file, relativeFile: path.relative(cwd, file).split(path.sep).join('/'), content, lines: content.split('\n') });
258
258
  }
259
259
 
260
260
  function findLiteralMatches(searchFiles, needle, { max }) {
@@ -340,7 +340,7 @@ function matchForIndex(file, index, kind, needle) {
340
340
  }
341
341
 
342
342
  function isPathInsideOrEqual(cwd, file) {
343
- const rel = path.relative(path.resolve(cwd), path.resolve(file));
343
+ const rel = path.relative(path.resolve(cwd), path.resolve(file)).split(path.sep).join('/');
344
344
  return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
345
345
  }
346
346
 
@@ -789,7 +789,7 @@ function sessionFileMetadataFromPollReply(file) {
789
789
  let full;
790
790
  try {
791
791
  full = path.resolve(process.cwd(), normalized);
792
- const rel = path.relative(process.cwd(), full);
792
+ const rel = path.relative(process.cwd(), full).split(path.sep).join('/');
793
793
  if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base;
794
794
  } catch {
795
795
  return base;
@@ -102,7 +102,7 @@ The agent should insert variant HTML at insertLine.`);
102
102
  console.error(JSON.stringify({
103
103
  error: 'element_not_in_source',
104
104
  fallback: 'agent-driven',
105
- generatedMatch: path.relative(process.cwd(), generatedHit),
105
+ generatedMatch: path.relative(process.cwd(), generatedHit).split(path.sep).join('/'),
106
106
  hint: 'Element found only in a generated file. See "Handle fallback" in live.md.',
107
107
  }));
108
108
  } else {
@@ -119,7 +119,7 @@ The agent should insert variant HTML at insertLine.`);
119
119
  console.error(JSON.stringify({
120
120
  error: 'file_is_generated',
121
121
  fallback: 'agent-driven',
122
- file: path.relative(process.cwd(), path.resolve(process.cwd(), targetFile)),
122
+ file: path.relative(process.cwd(), path.resolve(process.cwd(), targetFile)).split(path.sep).join('/'),
123
123
  hint: 'Explicit --file points at a generated file. Writing here gets wiped by the next build. See "Handle fallback" in live.md.',
124
124
  }));
125
125
  process.exit(1);
@@ -172,7 +172,7 @@ The agent should insert variant HTML at insertLine.`);
172
172
  console.error(JSON.stringify({
173
173
  error: 'element_ambiguous',
174
174
  fallback: 'agent-driven',
175
- file: path.relative(process.cwd(), targetFile),
175
+ file: path.relative(process.cwd(), targetFile).split(path.sep).join('/'),
176
176
  candidates: filtered.map((c) => ({
177
177
  startLine: c.startLine + 1,
178
178
  endLine: c.endLine + 1,
@@ -264,13 +264,72 @@ if (isMCPMode) {
264
264
  }
265
265
  process.exit(1);
266
266
  };
267
+ // Not every uncaught error is a bug in monomind, and filing a PUBLIC GitHub
268
+ // issue for one that isn't is worse than useless — it leaks the user's paths
269
+ // into a tracker and buries real crashes in noise. Two classes are user
270
+ // environment or normal usage, never a product defect:
271
+ //
272
+ // EPIPE / ERR_STREAM_DESTROYED — the reader closed the pipe first. This is
273
+ // what `monomind hooks worker list | head` does every single time, and
274
+ // what `| less` does when you quit early. Completely normal (issue #41).
275
+ //
276
+ // ERR_MODULE_NOT_FOUND — a dependency is missing from the install: a
277
+ // partial/corrupt node_modules, or the CLI being run straight out of an
278
+ // extracted tarball. The user needs an actionable message, not a bug
279
+ // report filed on their behalf (issues #46, #47).
280
+ //
281
+ // Anything else still reports as before.
282
+ const classifyFault = (err) => {
283
+ const code = err && err.code;
284
+ if (code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED') return 'broken-pipe';
285
+ if (code === 'ERR_MODULE_NOT_FOUND') return 'missing-dependency';
286
+ return 'crash';
287
+ };
288
+
289
+ /** Handles the non-bug classes. Returns true if it fully handled the error. */
290
+ const handleExpectedFault = (err) => {
291
+ switch (classifyFault(err)) {
292
+ case 'broken-pipe':
293
+ // Downstream went away — there is nothing left to say and nowhere to
294
+ // say it. Exiting 0 keeps `monomind ... | head` from looking failed.
295
+ process.exit(0);
296
+ return true;
297
+ case 'missing-dependency': {
298
+ const pkg = /Cannot find package '([^']+)'/.exec(safeMsg(err && err.message))?.[1];
299
+ console.error(
300
+ pkg
301
+ ? `[monomind] Missing dependency: ${pkg}\n` +
302
+ ` This is an install problem, not a crash — nothing was reported.\n` +
303
+ ` Try: npm install ${pkg} (or reinstall monomind: npm i -g monomind@latest)`
304
+ : `[monomind] A dependency could not be resolved: ${safeMsg(err && err.message)}\n` +
305
+ ` This is an install problem, not a crash — nothing was reported.`,
306
+ );
307
+ if (process.env.DEBUG) console.error(err && err.stack);
308
+ process.exit(1);
309
+ return true;
310
+ }
311
+ default:
312
+ return false;
313
+ }
314
+ };
315
+
316
+ // Belt and braces for the pipe case: handling 'error' on the streams keeps a
317
+ // mid-write EPIPE from becoming an uncaughtException at all.
318
+ for (const stream of [process.stdout, process.stderr]) {
319
+ stream.on('error', (err) => {
320
+ if (classifyFault(err) === 'broken-pipe') process.exit(0);
321
+ });
322
+ }
323
+
267
324
  process.on('uncaughtException', (err) => {
325
+ if (handleExpectedFault(err)) return;
268
326
  console.error(`[${new Date().toISOString()}] FATAL [monomind] uncaughtException: ${safeMsg(err && err.message)}`);
269
327
  if (process.env.DEBUG) console.error(err && err.stack);
270
328
  reportAndExit(safeMsg(err && err.message) || 'uncaughtException', err && err.stack);
271
329
  return;
272
330
  });
273
331
  process.on('unhandledRejection', (reason) => {
332
+ if (reason instanceof Error && handleExpectedFault(reason)) return;
274
333
  const msg = reason instanceof Error ? reason.message : String(reason);
275
334
  console.error(`[${new Date().toISOString()}] FATAL [monomind] unhandledRejection: ${safeMsg(msg)}`);
276
335
  if (process.env.DEBUG && reason instanceof Error) console.error(reason.stack);
@@ -300,6 +300,49 @@ const serveAction = async (ctx) => {
300
300
  };
301
301
  process.on('uncaughtException', (err) => { crashExit('uncaughtException', err); process.exit(1); });
302
302
  process.on('unhandledRejection', (err) => { crashExit('unhandledRejection', err); process.exit(1); });
303
+ // Termination diagnostics (#45). The two handlers above only cover errors
304
+ // raised *inside* the daemon. A report of the daemon vanishing after hours
305
+ // had a log holding nothing but its startup lines, because the ways a daemon
306
+ // usually dies were all unhandled:
307
+ //
308
+ // - a signal (SIGTERM from a supervisor/OS, SIGHUP when a terminal closes)
309
+ // - the event loop simply draining, which exits 0 and says nothing at all
310
+ //
311
+ // Both now announce themselves. Note what this deliberately cannot cover:
312
+ // SIGKILL, which is what the OOM killer sends, is uncatchable by design — no
313
+ // in-process handler can ever log it. That case is instead made *inferable*:
314
+ // every shutdown path below prints a terminal line, so a log that starts and
315
+ // then stops with no such line means the process was killed from outside
316
+ // (OOM being the usual culprit, and the reporter's org logs did show memory
317
+ // pressure). Absence of a shutdown line is now evidence, not ambiguity.
318
+ let shuttingDown = false;
319
+ const announceExit = (reason) => {
320
+ if (shuttingDown)
321
+ return;
322
+ shuttingDown = true;
323
+ try {
324
+ console.error(`[org serve] shutting down: ${reason}`);
325
+ }
326
+ catch { /* stderr gone */ }
327
+ };
328
+ for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP']) {
329
+ process.on(sig, () => {
330
+ announceExit(`received ${sig}`);
331
+ try {
332
+ daemon.persistCrashStateAll();
333
+ daemon.clearHeartbeat();
334
+ }
335
+ catch { /* best effort */ }
336
+ // A daemon holds ref'd timers, so it will not drain on its own; an
337
+ // explicit exit is required here and is the intended signal semantics.
338
+ process.exit(sig === 'SIGTERM' || sig === 'SIGINT' ? 0 : 1);
339
+ });
340
+ }
341
+ process.on('exit', (code) => {
342
+ // Last word on the way out. Reached for the "event loop drained" case,
343
+ // which previously produced a completely silent disappearance.
344
+ announceExit(`process exiting with code ${code}`);
345
+ });
303
346
  // Heartbeat: write every 30s so `org status` can tell "alive but busy" from
304
347
  // "daemon gone" without relying on pid liveness alone.
305
348
  daemon.writeHeartbeat();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monoes/monomindcli",
3
- "version": "2.7.10",
3
+ "version": "2.7.12",
4
4
  "type": "module",
5
5
  "description": "CLI engine for Monomind \u2014 an open-source MCP server that extends Claude Code with a codebase knowledge graph (tree-sitter + SQLite), persistent memory, multi-agent task coordination, and session hooks. MIT licensed, fully local.",
6
6
  "main": "dist/src/index.js",
@@ -81,13 +81,13 @@
81
81
  "README.md"
82
82
  ],
83
83
  "scripts": {
84
- "prebuild": "rm -rf dist tsconfig.tsbuildinfo",
85
- "build": "tsc && mkdir -p dist/src/browser/dashboard && cp src/browser/dashboard/ui.html dist/src/browser/dashboard/ui.html && mkdir -p dist/src/ui && cp src/ui/dashboard.html src/ui/server.mjs src/ui/collector.mjs src/ui/orgs.html src/ui/orgs-files.js src/ui/sse-manager.mjs src/ui/mastermind-diagram-fallback.html dist/src/ui/ && rm -rf dist/src/ui/data && cp -r src/ui/data dist/src/ui/data",
86
- "build:loose": "tsc --noEmitOnError false || true && mkdir -p dist/src/browser/dashboard && cp src/browser/dashboard/ui.html dist/src/browser/dashboard/ui.html && mkdir -p dist/src/ui && cp src/ui/dashboard.html src/ui/server.mjs src/ui/collector.mjs src/ui/orgs.html src/ui/orgs-files.js src/ui/sse-manager.mjs src/ui/mastermind-diagram-fallback.html dist/src/ui/ && rm -rf dist/src/ui/data && cp -r src/ui/data dist/src/ui/data",
84
+ "prebuild": "node ../../../scripts/build-fs.mjs clean dist tsconfig.tsbuildinfo",
85
+ "build": "tsc && node ../../../scripts/build-fs.mjs copy-into dist/src/browser/dashboard src/browser/dashboard/ui.html && node ../../../scripts/build-fs.mjs copy-into dist/src/ui src/ui/dashboard.html src/ui/server.mjs src/ui/collector.mjs src/ui/orgs.html src/ui/orgs-files.js src/ui/sse-manager.mjs src/ui/mastermind-diagram-fallback.html && node ../../../scripts/build-fs.mjs clean dist/src/ui/data && node ../../../scripts/build-fs.mjs copy-dir src/ui/data dist/src/ui/data",
86
+ "build:loose": "tsc --noEmitOnError false || true && node ../../../scripts/build-fs.mjs copy-into dist/src/browser/dashboard src/browser/dashboard/ui.html && node ../../../scripts/build-fs.mjs copy-into dist/src/ui src/ui/dashboard.html src/ui/server.mjs src/ui/collector.mjs src/ui/orgs.html src/ui/orgs-files.js src/ui/sse-manager.mjs src/ui/mastermind-diagram-fallback.html && node ../../../scripts/build-fs.mjs clean dist/src/ui/data && node ../../../scripts/build-fs.mjs copy-dir src/ui/data dist/src/ui/data",
87
87
  "test": "vitest run",
88
88
  "test:coverage": "vitest run --coverage",
89
89
  "test:pattern-store": "npx tsx src/transfer/store/tests/standalone-test.ts",
90
- "prepublishOnly": "node ../../@monoes/monodesign/scripts/sync-skill.mjs && cp ../../../README.md ./README.md && npm run build",
90
+ "prepublishOnly": "node ../../@monoes/monodesign/scripts/sync-skill.mjs && node ../../../scripts/build-fs.mjs copy-into . ../../../README.md && npm run build",
91
91
  "release": "npm version prerelease --preid=alpha && npm run publish:all",
92
92
  "publish:all": "./scripts/publish.sh"
93
93
  },
@@ -99,7 +99,7 @@
99
99
  "dependencies": {
100
100
  "@anthropic-ai/claude-agent-sdk": "^0.3.207",
101
101
  "@monoes/monobrowse": "^1.0.6",
102
- "@monoes/monodesign": "^1.2.1",
102
+ "@monoes/monodesign": "^1.2.2",
103
103
  "@monoes/monograph": "^1.5.4",
104
104
  "@noble/ed25519": "^2.1.0",
105
105
  "mammoth": "^1.12.0",
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Cross-platform filesystem primitives for npm build scripts.
4
+ *
5
+ * Every package's build used POSIX shell directly — recursive delete, mkdir -p,
6
+ * cp -r. npm runs scripts through cmd.exe on Windows, which does not understand
7
+ * any of them and answers "The syntax of the command is incorrect". Seven of
8
+ * eight packages had this shape, so monomind could not be BUILT on Windows at
9
+ * all. That does not affect users installing from npm (they get prebuilt dist
10
+ * output), but it locks out Windows contributors entirely.
11
+ *
12
+ * Node's own fs covers all of it, so this adds no dependency — rimraf, shx and
13
+ * friends would each be a new supply-chain edge for something the stdlib
14
+ * already does. Node >= 20 is the floor, where rmSync and cpSync are both
15
+ * available.
16
+ *
17
+ * Subcommands are deliberately explicit rather than mirroring `cp`'s
18
+ * file-vs-directory inference, so a caller can never get directory semantics by
19
+ * accident:
20
+ *
21
+ * clean <path...> recursive delete, no error if absent
22
+ * copy-into <destDir> <src...> copy each src INTO destDir, keeping basenames
23
+ * copy-dir <src> <dest> recursive directory copy
24
+ */
25
+ import { rmSync, mkdirSync, cpSync, existsSync, statSync } from 'node:fs';
26
+ import { basename, join } from 'node:path';
27
+
28
+ const [cmd, ...args] = process.argv.slice(2);
29
+
30
+ const fail = (msg) => {
31
+ console.error(`build-fs: ${msg}`);
32
+ process.exit(1);
33
+ };
34
+
35
+ switch (cmd) {
36
+ case 'clean': {
37
+ if (args.length === 0) fail('clean needs at least one path');
38
+ for (const target of args) {
39
+ rmSync(target, { recursive: true, force: true });
40
+ }
41
+ break;
42
+ }
43
+
44
+ case 'copy-into': {
45
+ const [destDir, ...sources] = args;
46
+ if (!destDir || sources.length === 0) {
47
+ fail('copy-into needs a destination directory and at least one source');
48
+ }
49
+ mkdirSync(destDir, { recursive: true });
50
+ for (const src of sources) {
51
+ // Fail loudly on a missing source. The shell `cp` this replaces would
52
+ // also error, and a build that silently omits a file produces a dist
53
+ // that looks fine until something 404s at runtime.
54
+ if (!existsSync(src)) fail(`source does not exist: ${src}`);
55
+ cpSync(src, join(destDir, basename(src)), {
56
+ recursive: statSync(src).isDirectory(),
57
+ });
58
+ }
59
+ break;
60
+ }
61
+
62
+ case 'copy-dir': {
63
+ const [src, dest] = args;
64
+ if (!src || !dest) fail('copy-dir needs a source and a destination');
65
+ if (!existsSync(src)) fail(`source does not exist: ${src}`);
66
+ cpSync(src, dest, { recursive: true });
67
+ break;
68
+ }
69
+
70
+ default:
71
+ fail(
72
+ `unknown subcommand ${cmd ? `"${cmd}"` : '(none given)'} — ` +
73
+ 'expected clean, copy-into, or copy-dir',
74
+ );
75
+ }