monomind 2.7.6 → 2.7.8

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 (63) hide show
  1. package/package.json +5 -4
  2. package/packages/@monomind/cli/.claude/agents/core/coder.md +9 -0
  3. package/packages/@monomind/cli/.claude/agents/core/coordinator.md +62 -0
  4. package/packages/@monomind/cli/.claude/agents/core/planner.md +9 -0
  5. package/packages/@monomind/cli/.claude/agents/core/reviewer.md +9 -0
  6. package/packages/@monomind/cli/.claude/agents/core/tester.md +8 -0
  7. package/packages/@monomind/cli/.claude/helpers/handlers/gates-handler.cjs +180 -47
  8. package/packages/@monomind/cli/.claude/helpers/handlers/route-handler.cjs +32 -3
  9. package/packages/@monomind/cli/.claude/helpers/hook-handler.cjs +55 -3
  10. package/packages/@monomind/cli/.claude/helpers/intelligence.cjs +3 -1
  11. package/packages/@monomind/cli/.claude/helpers/statusline.cjs +27 -3
  12. package/packages/@monomind/cli/.claude/helpers/utils/monograph.cjs +104 -18
  13. package/packages/@monomind/cli/.claude/settings.json +1 -1
  14. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-server.mjs +38 -1
  15. package/packages/@monomind/cli/dist/src/browser/dashboard/server.js +6 -1
  16. package/packages/@monomind/cli/dist/src/capabilities/index.d.ts +0 -1
  17. package/packages/@monomind/cli/dist/src/capabilities/index.js +8 -1
  18. package/packages/@monomind/cli/dist/src/commands/agent-lifecycle.js +5 -1
  19. package/packages/@monomind/cli/dist/src/commands/agent-ops.js +32 -2
  20. package/packages/@monomind/cli/dist/src/commands/browse-workflow.js +30 -0
  21. package/packages/@monomind/cli/dist/src/commands/doctor-project-checks.js +8 -1
  22. package/packages/@monomind/cli/dist/src/commands/guidance.js +8 -2
  23. package/packages/@monomind/cli/dist/src/commands/hooks-extended-commands.js +10 -2
  24. package/packages/@monomind/cli/dist/src/commands/init.js +18 -4
  25. package/packages/@monomind/cli/dist/src/commands/memory-crud.js +40 -3
  26. package/packages/@monomind/cli/dist/src/commands/org-observe.js +67 -13
  27. package/packages/@monomind/cli/dist/src/commands/org.d.ts +11 -0
  28. package/packages/@monomind/cli/dist/src/commands/org.js +162 -15
  29. package/packages/@monomind/cli/dist/src/commands/performance.js +31 -5
  30. package/packages/@monomind/cli/dist/src/commands/security-misc.js +18 -3
  31. package/packages/@monomind/cli/dist/src/commands/security-scan.d.ts +30 -1
  32. package/packages/@monomind/cli/dist/src/commands/security-scan.js +182 -69
  33. package/packages/@monomind/cli/dist/src/commands/swarm.js +41 -14
  34. package/packages/@monomind/cli/dist/src/consensus/audit-writer.js +11 -10
  35. package/packages/@monomind/cli/dist/src/init/executor.js +138 -22
  36. package/packages/@monomind/cli/dist/src/init/settings-generator.js +4 -1
  37. package/packages/@monomind/cli/dist/src/knowledge/document-pipeline.d.ts +17 -0
  38. package/packages/@monomind/cli/dist/src/knowledge/document-pipeline.js +62 -3
  39. package/packages/@monomind/cli/dist/src/mcp-client.js +78 -0
  40. package/packages/@monomind/cli/dist/src/mcp-tools/embeddings-tools.js +16 -8
  41. package/packages/@monomind/cli/dist/src/mcp-tools/knowledge-tools.js +27 -13
  42. package/packages/@monomind/cli/dist/src/memory/memory-bridge.d.ts +5 -1
  43. package/packages/@monomind/cli/dist/src/memory/memory-bridge.js +26 -3
  44. package/packages/@monomind/cli/dist/src/memory/memory-read.d.ts +9 -0
  45. package/packages/@monomind/cli/dist/src/memory/memory-read.js +13 -2
  46. package/packages/@monomind/cli/dist/src/monovector/diff-classifier.js +25 -6
  47. package/packages/@monomind/cli/dist/src/orgrt/daemon.d.ts +21 -1
  48. package/packages/@monomind/cli/dist/src/orgrt/daemon.js +82 -11
  49. package/packages/@monomind/cli/dist/src/orgrt/inbox.js +53 -20
  50. package/packages/@monomind/cli/dist/src/parser.d.ts +4 -2
  51. package/packages/@monomind/cli/dist/src/parser.js +61 -25
  52. package/packages/@monomind/cli/dist/src/routing/route-layer-factory.d.ts +15 -0
  53. package/packages/@monomind/cli/dist/src/routing/route-layer-factory.js +44 -2
  54. package/packages/@monomind/cli/dist/src/services/config-file-manager.d.ts +10 -2
  55. package/packages/@monomind/cli/dist/src/services/config-file-manager.js +10 -2
  56. package/packages/@monomind/cli/dist/src/ui/collector.mjs +43 -3
  57. package/packages/@monomind/cli/dist/src/ui/dashboard.html +27 -8
  58. package/packages/@monomind/cli/dist/src/ui/server.mjs +144 -14
  59. package/packages/@monomind/cli/package.json +7 -6
  60. package/packages/@monomind/cli/dist/src/capabilities/watcher.d.ts +0 -18
  61. package/packages/@monomind/cli/dist/src/capabilities/watcher.js +0 -107
  62. package/packages/@monomind/cli/dist/src/config-adapter.d.ts +0 -16
  63. package/packages/@monomind/cli/dist/src/config-adapter.js +0 -220
@@ -406,7 +406,15 @@ export async function bridgeSearchEntries(options) {
406
406
  const startTime = Date.now();
407
407
  let results = [];
408
408
  let searchMethod = 'keyword';
409
+ // Reported to callers so "(semantic)" can never be printed over keyword hits.
410
+ // The two reasons for skipping the vector path are distinct and must not be
411
+ // conflated: a healthy model given an empty query is not a missing model.
412
+ let fallbackReason = !_embedder ? 'no-embedding-model'
413
+ : queryStr.length === 0 ? 'empty-query'
414
+ : undefined;
415
+ let semanticAttempted = false;
409
416
  if (_embedder && queryStr.length > 0) {
417
+ semanticAttempted = true;
410
418
  try {
411
419
  const queryEmbedding = await _embedder(queryStr);
412
420
  const searchResults = await backend.search(queryEmbedding, {
@@ -431,8 +439,14 @@ export async function bridgeSearchEntries(options) {
431
439
  };
432
440
  }).sort((a, b) => b.score - a.score);
433
441
  searchMethod = 'semantic';
442
+ fallbackReason = undefined;
443
+ }
444
+ catch (e) {
445
+ // fall through to keyword search — but never claim this was semantic
446
+ fallbackReason = 'embedding-failed';
447
+ if (process.env.DEBUG || process.env.MONOMIND_DEBUG)
448
+ console.error('[memory-bridge] semantic search failed — falling back to keyword matching:', e);
434
449
  }
435
- catch { /* fall through to keyword search */ }
436
450
  }
437
451
  // Keyword fallback — scan all entries in namespace (not just first 100)
438
452
  // to avoid missing documents that were ingested later in the batch.
@@ -463,14 +477,22 @@ export async function bridgeSearchEntries(options) {
463
477
  id: e.id,
464
478
  key: e.key,
465
479
  content: e.content || '',
466
- score: Math.min(0.9, 0.3 + score * 0.6),
480
+ // Raw token-overlap fraction, NOT rescaled to look like a cosine.
481
+ // The old `min(0.9, 0.3 + score*0.6)` floor/ceiling made a weak
482
+ // keyword hit outrank a genuine cosine match (0.90 vs 0.63 for the
483
+ // same entry) and fed cosine-calibrated gates (memory-kg dedup)
484
+ // scores that never came from a vector.
485
+ score,
467
486
  namespace: e.namespace,
468
487
  provenance: `keyword:${score.toFixed(2)}`,
469
488
  tags: e.tags ?? [],
470
489
  _createdAt: e.createdAt || 0,
471
490
  }));
472
491
  }
473
- searchMethod = 'keyword';
492
+ // The vector path ran and simply matched nothing — still not semantic.
493
+ searchMethod = semanticAttempted ? 'keyword-fallback' : 'keyword';
494
+ if (semanticAttempted && !fallbackReason)
495
+ fallbackReason = 'no-semantic-matches';
474
496
  }
475
497
  // Filter stale entries based on automem config — skip for knowledge
476
498
  // namespaces (documents should remain searchable indefinitely)
@@ -494,6 +516,7 @@ export async function bridgeSearchEntries(options) {
494
516
  results,
495
517
  searchTime: Date.now() - startTime,
496
518
  searchMethod,
519
+ ...(searchMethod === 'semantic' ? {} : { fallbackReason }),
497
520
  };
498
521
  }
499
522
  catch {
@@ -25,6 +25,15 @@ export declare function searchEntries(options: {
25
25
  namespace: string;
26
26
  }[];
27
27
  searchTime: number;
28
+ /** What actually ran — propagated from the bridge so callers can report the
29
+ * real method instead of echoing the requested one.
30
+ *
31
+ * 'hash-vector'/'hash-hybrid' are the sql.js paths running on
32
+ * generateEmbedding()'s deterministic hash fallback: a vector search was
33
+ * performed, but over hashes with no semantic content, so neither may be
34
+ * reported as 'semantic'/'hybrid'. */
35
+ searchMethod?: 'semantic' | 'keyword' | 'keyword-fallback' | 'hybrid' | 'hash-vector' | 'hash-hybrid';
36
+ fallbackReason?: string;
28
37
  error?: string;
29
38
  }>;
30
39
  /**
@@ -59,6 +59,11 @@ export async function searchEntries(options) {
59
59
  }
60
60
  const queryEmb = await generateEmbedding(query);
61
61
  const queryEmbedding = queryEmb.embedding;
62
+ // generateEmbedding() never fails — when no ONNX model can be loaded it
63
+ // silently returns generateHashEmbedding() output tagged 'hash-fallback'.
64
+ // Cosine over those hashes is a deterministic lexical trick with no
65
+ // semantic content, so the methods below must not claim otherwise.
66
+ const realVectors = queryEmb.model !== 'hash-fallback';
62
67
  // Try HNSW search first (150x faster)
63
68
  const hnswResults = await searchHNSWIndex(queryEmbedding, { k: limit, namespace: effectiveNamespace });
64
69
  if (hnswResults && hnswResults.length > 0) {
@@ -66,7 +71,9 @@ export async function searchEntries(options) {
66
71
  return {
67
72
  success: true,
68
73
  results: filtered,
69
- searchTime: Date.now() - startTime
74
+ searchTime: Date.now() - startTime,
75
+ searchMethod: realVectors ? 'semantic' : 'hash-vector',
76
+ ...(realVectors ? {} : { fallbackReason: 'no-embedding-model' })
70
77
  };
71
78
  }
72
79
  // Fall back to brute-force SQLite search
@@ -127,7 +134,11 @@ export async function searchEntries(options) {
127
134
  return {
128
135
  success: true,
129
136
  results: results.slice(0, limit),
130
- searchTime: Date.now() - startTime
137
+ searchTime: Date.now() - startTime,
138
+ // Per-row: cosine when the entry had a vector, keyword overlap otherwise.
139
+ // With hash-fallback vectors the cosine half is not semantic either.
140
+ searchMethod: realVectors ? 'hybrid' : 'hash-hybrid',
141
+ ...(realVectors ? {} : { fallbackReason: 'no-embedding-model' })
131
142
  };
132
143
  }
133
144
  catch (error) {
@@ -352,12 +352,15 @@ export function getGitDiffNumstat(ref = 'HEAD') {
352
352
  try {
353
353
  // SECURITY: Use execFileSync with args array instead of shell string
354
354
  // This prevents command injection via the ref parameter
355
+ // stdio must be fully piped: execFileSync otherwise forwards git's stderr
356
+ // straight to the parent's terminal AND leaves err.stderr null, so the
357
+ // thrown error below would carry no explanation of what git objected to.
355
358
  const numstatOutput = execFileSync('git', [
356
359
  'diff', '--numstat', '--diff-filter=ACDMRTUXB', ref
357
- ], { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
360
+ ], { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] });
358
361
  const statusOutput = execFileSync('git', [
359
362
  'diff', '--name-status', ref
360
- ], { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
363
+ ], { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] });
361
364
  const output = numstatOutput + '---STATUS---' + statusOutput;
362
365
  const [numstatPart, statusPart] = output.split('---STATUS---');
363
366
  // Parse status (usually smaller, parse first)
@@ -411,10 +414,25 @@ export function getGitDiffNumstat(ref = 'HEAD') {
411
414
  diffCache.set(cacheKey, { files, timestamp: Date.now() });
412
415
  return files;
413
416
  }
414
- catch {
415
- return [];
417
+ catch (e) {
418
+ // Do NOT return [] here. An empty array is indistinguishable from a genuinely
419
+ // clean diff, so a broken git invocation (not a repo, bad ref, git missing)
420
+ // used to surface as "low risk, 0 files changed". Callers (analyze_* MCP
421
+ // tools) already convert a throw into an explicit error response.
422
+ throw new Error(`git diff failed for ref "${ref}": ${describeGitError(e)}`);
416
423
  }
417
424
  }
425
+ /**
426
+ * Extract a useful message from an execFile error, preferring git's own stderr.
427
+ */
428
+ function describeGitError(e) {
429
+ const err = e;
430
+ const stderr = err && typeof err.stderr === 'string' ? err.stderr.trim() : '';
431
+ if (stderr)
432
+ return stderr.split('\n')[0].slice(0, 300);
433
+ const msg = err && typeof err.message === 'string' ? err.message : String(e);
434
+ return msg.slice(0, 300);
435
+ }
418
436
  /**
419
437
  * Async version of getGitDiffNumstat for non-blocking operation
420
438
  */
@@ -480,8 +498,9 @@ export async function getGitDiffNumstatAsync(ref = 'HEAD') {
480
498
  diffCache.set(cacheKey, { files, timestamp: Date.now() });
481
499
  return files;
482
500
  }
483
- catch {
484
- return [];
501
+ catch (e) {
502
+ // See getGitDiffNumstat: a git failure must not look like a clean diff.
503
+ throw new Error(`git diff failed for ref "${ref}": ${describeGitError(e)}`);
485
504
  }
486
505
  }
487
506
  /**
@@ -53,6 +53,9 @@ export declare class OrgDaemon {
53
53
  subscribe(fn: (e: BusEvent) => void): () => void;
54
54
  listOrgs(): RunningOrg[];
55
55
  getOrg(name: string): RunningOrg | undefined;
56
+ /** Names of the orgs this daemon currently has running. Snapshot — safe to
57
+ * iterate while stopOrg() mutates the underlying map. */
58
+ listRunning(): string[];
56
59
  startOrg(name: string, taskOverride?: string): Promise<RunningOrg>;
57
60
  /** A role that failed its resource gate at boot isn't abandoned — keep polling
58
61
  * for capacity in the background (bounded) and spawn it the moment resources
@@ -96,7 +99,16 @@ export declare class OrgDaemon {
96
99
  * running, pushes straight into the role's live mailbox (picked up on its very next
97
100
  * generator tick — see Mailbox.stream()). If the org has since stopped, queues the
98
101
  * answer via the same offline fallback deliver()/receiveRemote() already use
99
- * (inbox.ts + autoWake) and it's delivered when the org next starts. */
102
+ * (inbox.ts + autoWake) and it's delivered when the org next starts.
103
+ *
104
+ * PERSIST-AFTER-DELIVERY: questions.json is only marked answered once delivery has
105
+ * actually happened (mailbox push, or the message landing in inbox.jsonl). Marking it
106
+ * first meant a rejected delivery — unknown role, mailbox closed mid-shutdown, a
107
+ * queueMessage that threw on a full/read-only disk — left the question recorded as
108
+ * answered while nobody ever received the answer, and the `already answered` guard
109
+ * then refused every retry. The answer was simply gone. The inverse failure (crash
110
+ * between delivery and the write) merely re-shows the question as pending, which a
111
+ * human can act on; a silently swallowed answer is not recoverable. */
100
112
  answerQuestion(org: string, role: string, questionId: string, answer: string): Promise<{
101
113
  ok: true;
102
114
  } | {
@@ -150,6 +162,14 @@ export declare class OrgDaemon {
150
162
  * future runs) can find it by meaning, not just recency. Best-effort. */
151
163
  private storeRunMemory;
152
164
  private persistState;
165
+ /** Mark every currently-running org as crashed in runtime.json.
166
+ * Called from process-level crash handlers — must be synchronous and best-effort. */
167
+ persistCrashStateAll(): void;
168
+ private heartbeatPath;
169
+ /** Write a heartbeat file so `org status` can distinguish "daemon alive" from
170
+ * "daemon gone" even when runtime.json still says running. */
171
+ writeHeartbeat(): void;
172
+ clearHeartbeat(): void;
153
173
  }
154
174
  export {};
155
175
  //# sourceMappingURL=daemon.d.ts.map
@@ -1,6 +1,6 @@
1
1
  // packages/@monomind/cli/src/orgrt/daemon.ts
2
2
  // monolean: single-process inter-org — upgrade path = daemon-to-daemon HTTP when multi-host is real
3
- import { readFileSync, mkdirSync, writeFileSync, existsSync, renameSync } from 'node:fs';
3
+ import { readFileSync, mkdirSync, writeFileSync, existsSync, renameSync, unlinkSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { reapOrphanedSdkProcesses } from '../utils/resource-governor.js';
6
6
  import { OrgBus } from './bus.js';
@@ -36,6 +36,9 @@ export class OrgDaemon {
36
36
  }
37
37
  listOrgs() { return [...this.orgs.values()]; }
38
38
  getOrg(name) { return this.orgs.get(name); }
39
+ /** Names of the orgs this daemon currently has running. Snapshot — safe to
40
+ * iterate while stopOrg() mutates the underlying map. */
41
+ listRunning() { return [...this.orgs.keys()]; }
39
42
  async startOrg(name, taskOverride) {
40
43
  if (this.orgs.has(name))
41
44
  throw new Error(`org ${name} already running`);
@@ -500,7 +503,16 @@ export class OrgDaemon {
500
503
  * running, pushes straight into the role's live mailbox (picked up on its very next
501
504
  * generator tick — see Mailbox.stream()). If the org has since stopped, queues the
502
505
  * answer via the same offline fallback deliver()/receiveRemote() already use
503
- * (inbox.ts + autoWake) and it's delivered when the org next starts. */
506
+ * (inbox.ts + autoWake) and it's delivered when the org next starts.
507
+ *
508
+ * PERSIST-AFTER-DELIVERY: questions.json is only marked answered once delivery has
509
+ * actually happened (mailbox push, or the message landing in inbox.jsonl). Marking it
510
+ * first meant a rejected delivery — unknown role, mailbox closed mid-shutdown, a
511
+ * queueMessage that threw on a full/read-only disk — left the question recorded as
512
+ * answered while nobody ever received the answer, and the `already answered` guard
513
+ * then refused every retry. The answer was simply gone. The inverse failure (crash
514
+ * between delivery and the write) merely re-shows the question as pending, which a
515
+ * human can act on; a silently swallowed answer is not recoverable. */
504
516
  async answerQuestion(org, role, questionId, answer) {
505
517
  const data = this.readQuestions(org);
506
518
  const idx = data.questions.findIndex(q => q.questionId === questionId);
@@ -508,8 +520,19 @@ export class OrgDaemon {
508
520
  return { ok: false, error: `question "${questionId}" not found for org "${org}"` };
509
521
  if (data.questions[idx].answer !== null)
510
522
  return { ok: false, error: `question "${questionId}" already answered` };
511
- data.questions[idx] = { ...data.questions[idx], answer, answeredAt: Date.now() };
512
- this.writeQuestions(org, data);
523
+ const question = data.questions[idx].question;
524
+ // Applied to questions.json ONLY after the delivery below succeeds.
525
+ const markAnswered = () => {
526
+ // Re-read so a question the daemon appended (or answered) meanwhile isn't
527
+ // clobbered by this stale snapshot — merge by questionId, never replace.
528
+ const fresh = this.readQuestions(org);
529
+ const fIdx = fresh.questions.findIndex(q => q.questionId === questionId);
530
+ if (fIdx === -1)
531
+ fresh.questions.push({ ...data.questions[idx], answer, answeredAt: Date.now() });
532
+ else
533
+ fresh.questions[fIdx] = { ...fresh.questions[fIdx], answer, answeredAt: Date.now() };
534
+ this.writeQuestions(org, fresh);
535
+ };
513
536
  const running = this.orgs.get(org);
514
537
  if (running) {
515
538
  // Org IS running — deliver or report a real error, but never fall through to the
@@ -523,20 +546,32 @@ export class OrgDaemon {
523
546
  return { ok: false, error: `role "${role}" not found in org "${org}"` };
524
547
  if (agent.mailbox.isClosed)
525
548
  return { ok: false, error: `role "${role}" in org "${org}" is shutting down — answer not delivered` };
549
+ try {
550
+ agent.mailbox.push(`[answer from human] question: ${question}\n\nanswer: ${answer}`);
551
+ }
552
+ catch (err) {
553
+ return { ok: false, error: `delivery to "${role}" in org "${org}" failed — answer not recorded (${err instanceof Error ? err.message : String(err)})` };
554
+ }
555
+ markAnswered();
526
556
  running.bus.emit({ type: 'status', from: role, msg: 'question answered', data: { questionId } });
527
- agent.mailbox.push(`[answer from human] question: ${data.questions[idx].question}\n\nanswer: ${answer}`);
528
557
  return { ok: true };
529
558
  }
530
559
  // Org not running at all — queue for delivery on next start, matching deliver()'s
531
560
  // existing offline fallback exactly (inbox.ts + autoWake).
532
561
  if (!this.hasOrgDef(org))
533
562
  return { ok: false, error: `org "${org}" not found (no saved definition)` };
534
- queueMessage(this.root, org, {
535
- fromQualified: 'human', toRole: role,
536
- subject: `answer:${questionId}`,
537
- body: `question: ${data.questions[idx].question}\n\nanswer: ${answer}`,
538
- ts: Date.now(),
539
- });
563
+ try {
564
+ queueMessage(this.root, org, {
565
+ fromQualified: 'human', toRole: role,
566
+ subject: `answer:${questionId}`,
567
+ body: `question: ${question}\n\nanswer: ${answer}`,
568
+ ts: Date.now(),
569
+ });
570
+ }
571
+ catch (err) {
572
+ return { ok: false, error: `could not queue answer for org "${org}" — answer not recorded (${err instanceof Error ? err.message : String(err)})` };
573
+ }
574
+ markAnswered();
540
575
  this.autoWake(org);
541
576
  return { ok: true };
542
577
  }
@@ -890,5 +925,41 @@ export class OrgDaemon {
890
925
  const p = join(this.root, ORG_DIR, name, 'runtime.json');
891
926
  writeFileSync(p, JSON.stringify({ status, run, pid: process.pid, updated: new Date().toISOString() }, null, 2));
892
927
  }
928
+ /** Mark every currently-running org as crashed in runtime.json.
929
+ * Called from process-level crash handlers — must be synchronous and best-effort. */
930
+ persistCrashStateAll() {
931
+ for (const [name, org] of this.orgs) {
932
+ try {
933
+ const p = join(this.root, ORG_DIR, name, 'runtime.json');
934
+ writeFileSync(p, JSON.stringify({
935
+ status: 'crashed', run: org.run, pid: process.pid,
936
+ updated: new Date().toISOString(), closedBy: 'crash-handler',
937
+ }, null, 2));
938
+ }
939
+ catch { /* best effort — filesystem may be unavailable */ }
940
+ }
941
+ }
942
+ heartbeatPath() {
943
+ return join(this.root, '.monomind', 'serve-heartbeat.json');
944
+ }
945
+ /** Write a heartbeat file so `org status` can distinguish "daemon alive" from
946
+ * "daemon gone" even when runtime.json still says running. */
947
+ writeHeartbeat() {
948
+ try {
949
+ const p = this.heartbeatPath();
950
+ mkdirSync(join(this.root, '.monomind'), { recursive: true });
951
+ writeFileSync(p, JSON.stringify({
952
+ pid: process.pid, updatedAt: new Date().toISOString(),
953
+ running: this.listRunning(),
954
+ }, null, 2));
955
+ }
956
+ catch { /* best effort */ }
957
+ }
958
+ clearHeartbeat() {
959
+ try {
960
+ unlinkSync(this.heartbeatPath());
961
+ }
962
+ catch { /* already gone or never written */ }
963
+ }
893
964
  }
894
965
  //# sourceMappingURL=daemon.js.map
@@ -1,7 +1,7 @@
1
1
  // packages/@monomind/cli/src/orgrt/inbox.ts
2
2
  // Persistent message queue for offline orgs. Messages that can't be delivered
3
3
  // (target org not running) are spooled here and drained when the org starts.
4
- import { appendFileSync, readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'node:fs';
4
+ import { appendFileSync, readFileSync, renameSync, unlinkSync, existsSync, mkdirSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
6
  import { ORG_DIR } from './types.js';
7
7
  function inboxPath(root, orgName) {
@@ -12,37 +12,70 @@ export function queueMessage(root, orgName, msg) {
12
12
  mkdirSync(dir, { recursive: true });
13
13
  appendFileSync(inboxPath(root, orgName), JSON.stringify(msg) + '\n');
14
14
  }
15
+ function parseLines(raw) {
16
+ const msgs = [];
17
+ for (const line of raw.trim().split('\n')) {
18
+ if (!line)
19
+ continue;
20
+ try {
21
+ msgs.push(JSON.parse(line));
22
+ }
23
+ catch { /* skip corrupt lines */ }
24
+ }
25
+ return msgs;
26
+ }
15
27
  export function drainInbox(root, orgName) {
16
28
  const path = inboxPath(root, orgName);
29
+ const draining = `${path}.draining`;
30
+ const msgs = [];
31
+ // Recover a .draining file left behind by a mid-drain crash. Without this the
32
+ // rename below would overwrite it and lose exactly the messages the rename-then-read
33
+ // scheme exists to protect.
34
+ if (existsSync(draining)) {
35
+ try {
36
+ msgs.push(...parseLines(readFileSync(draining, 'utf8')));
37
+ unlinkSync(draining);
38
+ }
39
+ catch (e) {
40
+ if (process.env.DEBUG || process.env.MONOMIND_DEBUG)
41
+ console.error('[inbox] drainInbox recovery of .draining failed:', e);
42
+ return msgs; // don't rename over a file we couldn't consume
43
+ }
44
+ }
17
45
  if (!existsSync(path))
18
- return [];
46
+ return msgs;
19
47
  // Rename-then-read: if the process crashes after rename but before we finish
20
- // reading, the .draining file survives for manual recovery. A plain
21
- // read-then-truncate would lose messages on a mid-drain crash.
22
- const draining = `${path}.draining`;
48
+ // reading, the .draining file survives (and is recovered above on the next
49
+ // drain). A plain read-then-truncate would lose messages on a mid-drain crash.
23
50
  try {
24
51
  renameSync(path, draining);
25
52
  }
26
53
  catch (e) {
27
54
  if (process.env.DEBUG || process.env.MONOMIND_DEBUG)
28
55
  console.error('[inbox] drainInbox rename failed:', e);
29
- return [];
56
+ return msgs;
30
57
  }
31
- const raw = readFileSync(draining, 'utf8').trim();
32
- if (!raw) {
33
- writeFileSync(draining, '');
34
- renameSync(draining, path);
35
- return [];
58
+ let raw = '';
59
+ try {
60
+ raw = readFileSync(draining, 'utf8');
36
61
  }
37
- // Clear the draining file — messages are now the caller's responsibility
38
- writeFileSync(draining, '');
39
- renameSync(draining, path);
40
- const msgs = [];
41
- for (const line of raw.split('\n')) {
42
- try {
43
- msgs.push(JSON.parse(line));
44
- }
45
- catch { /* skip corrupt lines */ }
62
+ catch (e) {
63
+ if (process.env.DEBUG || process.env.MONOMIND_DEBUG)
64
+ console.error('[inbox] drainInbox read failed:', e);
65
+ return msgs;
66
+ }
67
+ msgs.push(...parseLines(raw));
68
+ // Unlink the drained snapshot rather than truncating it and renaming it back over
69
+ // `path`. A sender that appended between the rename above and this point created a
70
+ // fresh `path` (queueMessage mkdir+appends), and renaming the emptied snapshot back
71
+ // would clobber it — destroying messages whose sender already got a "queued" receipt.
72
+ // Those messages now simply stay in `path` and are picked up by the next drain.
73
+ try {
74
+ unlinkSync(draining);
75
+ }
76
+ catch (e) {
77
+ if (process.env.DEBUG || process.env.MONOMIND_DEBUG)
78
+ console.error('[inbox] drainInbox unlink failed:', e);
46
79
  }
47
80
  return msgs;
48
81
  }
@@ -74,12 +74,14 @@ export declare class CommandParser {
74
74
  * fixing collisions where multiple subcommands use the same short flag (e.g. -t).
75
75
  */
76
76
  private buildScopedAliases;
77
+ /** Normalize a single command or a resolved chain into an array. */
78
+ private static asChain;
77
79
  /**
78
- * Get boolean flags scoped to a specific command/subcommand.
80
+ * Get boolean flags scoped to a specific command/subcommand chain.
79
81
  */
80
82
  private getScopedBooleanFlags;
81
83
  /**
82
- * Get flags declared `type: 'array'`, scoped to a specific command/subcommand.
84
+ * Get flags declared `type: 'array'`, scoped to a specific command/subcommand chain.
83
85
  */
84
86
  private getScopedArrayFlags;
85
87
  private getBooleanFlags;
@@ -166,26 +166,51 @@ export class CommandParser {
166
166
  positional: [],
167
167
  raw: [...args]
168
168
  };
169
- // Pass 1: Identify command and subcommand (skip flags)
169
+ // Pass 1: Identify the command and its subcommand chain (skip flags).
170
+ //
171
+ // This walks the WHOLE tree, not just one subcommand level. It used to
172
+ // stop after depth 1 (`resolvedCmd` + `resolvedSub`), so options declared
173
+ // on a subcommand nested 2+ levels deep were invisible to the alias and
174
+ // boolean scans below: `hooks worker run -n audit` resolved `-n` from the
175
+ // global last-write-wins alias pool (where it happened to mean `--limit`)
176
+ // instead of `run`'s own `-n, --name`, and the command then failed with
177
+ // "Worker name is required" while `--name audit` worked.
178
+ const scopeChain = [];
170
179
  let resolvedCmd;
171
- let resolvedSub;
172
180
  for (const arg of args) {
173
181
  if (arg.startsWith('-'))
174
182
  continue;
175
- if (!resolvedCmd && this.commands.has(arg)) {
176
- resolvedCmd = this.commands.get(arg);
183
+ if (!resolvedCmd) {
184
+ const top = this.commands.get(arg);
185
+ if (top) {
186
+ resolvedCmd = top;
187
+ scopeChain.push(top);
188
+ }
189
+ continue;
177
190
  }
178
- else if (resolvedCmd && !resolvedSub && resolvedCmd.subcommands) {
179
- resolvedSub = resolvedCmd.subcommands.find(sc => sc.name === arg || sc.aliases?.includes(arg));
191
+ const next = resolvedCmd.subcommands?.find(sc => sc.name === arg || sc.aliases?.includes(arg));
192
+ if (next) {
193
+ resolvedCmd = next;
194
+ scopeChain.push(next);
180
195
  }
181
196
  }
182
- // Pass 2: Build aliases scoped to the resolved subcommand
183
- // Subcommand-specific aliases take priority over global ones
184
- const aliases = this.buildScopedAliases(resolvedSub || resolvedCmd);
185
- const booleanFlags = this.getScopedBooleanFlags(resolvedSub || resolvedCmd);
186
- const arrayFlags = this.getScopedArrayFlags(resolvedSub || resolvedCmd);
197
+ // Pass 2: Build aliases scoped to the resolved subcommand chain.
198
+ // Subcommand-specific aliases take priority over global ones, and the
199
+ // deepest subcommand wins over its ancestors. The top-level command is
200
+ // excluded when it has a resolved subcommand (preserving the previous
201
+ // `resolvedSub || resolvedCmd` precedence exactly for depth <= 1).
202
+ const scope = scopeChain.length > 1 ? scopeChain.slice(1) : scopeChain;
203
+ const aliases = this.buildScopedAliases(scope);
204
+ const booleanFlags = this.getScopedBooleanFlags(scope);
205
+ const arrayFlags = this.getScopedArrayFlags(scope);
187
206
  let i = 0;
188
207
  let parsingFlags = true;
208
+ // Once a non-flag token has been seen that is NOT a registered command,
209
+ // no later token may be promoted to "the command". Without this, an
210
+ // unrecognised first token was silently discarded and the SECOND token
211
+ // dispatched instead — `monomind typo status` ran `status`. A mistyped
212
+ // command must never run something the user did not ask for.
213
+ let commandSearchClosed = false;
189
214
  while (i < args.length) {
190
215
  const arg = args[i];
191
216
  // Check for end of flags marker
@@ -208,7 +233,7 @@ export class CommandParser {
208
233
  continue;
209
234
  }
210
235
  // Handle positional arguments
211
- if (result.command.length === 0 && this.commands.has(arg)) {
236
+ if (result.command.length === 0 && !commandSearchClosed && this.commands.has(arg)) {
212
237
  // This is a command
213
238
  result.command.push(arg);
214
239
  // Check for subcommand (level 1)
@@ -245,7 +270,11 @@ export class CommandParser {
245
270
  }
246
271
  }
247
272
  else {
248
- // Positional argument
273
+ // Positional argument. If we haven't resolved a command yet, this
274
+ // token was the user's attempt at one — close the search so a later
275
+ // token can't be silently dispatched in its place.
276
+ if (result.command.length === 0)
277
+ commandSearchClosed = true;
249
278
  result.positional.push(arg);
250
279
  result.flags._.push(arg);
251
280
  }
@@ -444,12 +473,13 @@ export class CommandParser {
444
473
  * The resolved command's short flags take priority over global ones,
445
474
  * fixing collisions where multiple subcommands use the same short flag (e.g. -t).
446
475
  */
447
- buildScopedAliases(resolvedCmd) {
476
+ buildScopedAliases(scope) {
448
477
  // Start with global aliases as base
449
478
  const aliases = this.buildAliases();
450
- // Override with the resolved command's own options (these take priority)
451
- if (resolvedCmd?.options) {
452
- for (const opt of resolvedCmd.options) {
479
+ // Override with the resolved chain's own options (these take priority);
480
+ // deepest subcommand last, so it wins over its ancestors.
481
+ for (const cmd of CommandParser.asChain(scope)) {
482
+ for (const opt of cmd.options ?? []) {
453
483
  if (opt.short) {
454
484
  aliases[opt.short] = opt.name;
455
485
  }
@@ -457,13 +487,19 @@ export class CommandParser {
457
487
  }
458
488
  return aliases;
459
489
  }
490
+ /** Normalize a single command or a resolved chain into an array. */
491
+ static asChain(scope) {
492
+ if (!scope)
493
+ return [];
494
+ return Array.isArray(scope) ? scope : [scope];
495
+ }
460
496
  /**
461
- * Get boolean flags scoped to a specific command/subcommand.
497
+ * Get boolean flags scoped to a specific command/subcommand chain.
462
498
  */
463
- getScopedBooleanFlags(resolvedCmd) {
499
+ getScopedBooleanFlags(scope) {
464
500
  const flags = this.getBooleanFlags();
465
- if (resolvedCmd?.options) {
466
- for (const opt of resolvedCmd.options) {
501
+ for (const cmd of CommandParser.asChain(scope)) {
502
+ for (const opt of cmd.options ?? []) {
467
503
  if (opt.type === 'boolean') {
468
504
  flags.add(this.normalizeKey(opt.name));
469
505
  }
@@ -472,12 +508,12 @@ export class CommandParser {
472
508
  return flags;
473
509
  }
474
510
  /**
475
- * Get flags declared `type: 'array'`, scoped to a specific command/subcommand.
511
+ * Get flags declared `type: 'array'`, scoped to a specific command/subcommand chain.
476
512
  */
477
- getScopedArrayFlags(resolvedCmd) {
513
+ getScopedArrayFlags(scope) {
478
514
  const flags = new Set();
479
- if (resolvedCmd?.options) {
480
- for (const opt of resolvedCmd.options) {
515
+ for (const cmd of CommandParser.asChain(scope)) {
516
+ for (const opt of cmd.options ?? []) {
481
517
  if (opt.type === 'array') {
482
518
  flags.add(this.normalizeKey(opt.name));
483
519
  }
@@ -1,3 +1,18 @@
1
+ /**
2
+ * Pick the argv prefix used to spawn the embedding worker.
3
+ *
4
+ * dist/: `embed-worker.js` exists → `node embed-worker.js <task>`, byte-identical
5
+ * to the original behaviour, no extra flags, no extra resolution.
6
+ *
7
+ * src/ (tsx / vitest): only `embed-worker.ts` exists. Node's
8
+ * `--experimental-strip-types` cannot run it — the worker imports
9
+ * `./embedder.js`, and type-stripping does no `.js`→`.ts` extension rewriting,
10
+ * so it dies with ERR_MODULE_NOT_FOUND. tsx does rewrite, so the source path
11
+ * runs through tsx's CLI (a devDependency, never needed by dist/).
12
+ *
13
+ * Exported for tests.
14
+ */
15
+ export declare function resolveWorkerArgv(): string[];
1
16
  type RouteResult = any;
2
17
  export interface ConfiguredRouteLayer {
3
18
  route: (taskDescription: string) => Promise<RouteResult>;