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
@@ -178,17 +178,44 @@ export const reportAction = async (ctx, name) => {
178
178
  }
179
179
  return { success: true };
180
180
  };
181
+ /** Read questions.json. A MISSING file legitimately means "no questions" → [].
182
+ * Any other failure (unreadable, malformed — e.g. a partial daemon write) THROWS:
183
+ * answerAction rewrites this file from what this returns, so silently coercing a
184
+ * failed read to [] would atomically replace every recorded question with one. */
181
185
  const readQuestions = (cwd, name) => {
186
+ const path = join(cwd, ORG_DIR, name, 'questions.json');
187
+ let raw;
182
188
  try {
183
- return JSON.parse(readFileSync(join(cwd, ORG_DIR, name, 'questions.json'), 'utf8')).questions ?? [];
189
+ raw = readFileSync(path, 'utf8');
184
190
  }
185
- catch {
186
- return [];
191
+ catch (err) {
192
+ if (err.code === 'ENOENT')
193
+ return [];
194
+ throw new Error(`cannot read ${path}: ${err instanceof Error ? err.message : String(err)}`);
195
+ }
196
+ let parsed;
197
+ try {
198
+ parsed = JSON.parse(raw);
187
199
  }
200
+ catch (err) {
201
+ throw new Error(`${path} is not valid JSON (${err instanceof Error ? err.message : String(err)})`);
202
+ }
203
+ if (parsed?.questions === undefined || parsed.questions === null)
204
+ return [];
205
+ if (!Array.isArray(parsed.questions))
206
+ throw new Error(`${path}: "questions" is not an array`);
207
+ return parsed.questions;
188
208
  };
189
209
  /** `org questions <name> [--all]` — list pending (or all) ask_human questions. */
190
210
  export const questionsAction = async (ctx, name) => {
191
- const all = readQuestions(ctx.cwd, name);
211
+ let all;
212
+ try {
213
+ all = readQuestions(ctx.cwd, name);
214
+ }
215
+ catch (err) {
216
+ log(output.error(`Cannot read questions for org ${name}: ${err instanceof Error ? err.message : String(err)}`));
217
+ return { success: false, message: 'questions.json unreadable' };
218
+ }
192
219
  const shown = ctx.flags['all'] === true ? all : all.filter(q => q.answer === null);
193
220
  if (!shown.length) {
194
221
  log(output.info(all.length ? `No pending questions for org ${name} (${all.length} answered — use --all).` : `No questions recorded for org ${name}.`));
@@ -212,7 +239,14 @@ export const answerAction = async (ctx, name) => {
212
239
  const answer = ctx.args.slice(2).join(' ').trim();
213
240
  if (!questionId || !answer)
214
241
  return { success: false, message: `usage: monomind org answer ${name} <question-id> "answer text"` };
215
- const questions = readQuestions(ctx.cwd, name);
242
+ let questions;
243
+ try {
244
+ questions = readQuestions(ctx.cwd, name);
245
+ }
246
+ catch (err) {
247
+ log(output.error(`Cannot read questions for org ${name}: ${err instanceof Error ? err.message : String(err)}`));
248
+ return { success: false, message: 'questions.json unreadable — answer not recorded' };
249
+ }
216
250
  const q = questions.find(x => x.questionId === questionId);
217
251
  if (!q) {
218
252
  log(output.error(`Question "${questionId}" not found for org ${name} — list with: monomind org questions ${name}`));
@@ -246,11 +280,38 @@ export const answerAction = async (ctx, name) => {
246
280
  // snapshot can be up to 10s stale (live-delivery timeout), and rewriting
247
281
  // from it would delete questions the daemon appended meanwhile and revert
248
282
  // answers it recorded (atomic rename prevents torn writes, not lost updates).
249
- const fresh = readQuestions(ctx.cwd, name);
283
+ // A FAILED re-read must abort the write: rewriting from [] would atomically
284
+ // rename a single-question file over every other recorded question.
285
+ let fresh;
286
+ try {
287
+ fresh = readQuestions(ctx.cwd, name);
288
+ }
289
+ catch (err) {
290
+ log(output.error(`Refusing to rewrite questions.json — ${err instanceof Error ? err.message : String(err)}`));
291
+ log(output.warning(`The answer was NOT recorded. Fix or restore ${join(ctx.cwd, ORG_DIR, name, 'questions.json')}, then retry.`));
292
+ return { success: false, message: 'questions.json unreadable — answer not recorded' };
293
+ }
250
294
  const freshQ = fresh.find(x => x.questionId === questionId);
251
295
  if (freshQ && freshQ.answer !== null) {
252
296
  return { success: false, message: `question "${questionId}" was answered while this command was running` };
253
297
  }
298
+ // Queue BEFORE marking answered (same rule as daemon.answerQuestion): if the
299
+ // append fails, the question must stay pending and answerable. Marking first meant
300
+ // a failed queueMessage recorded the answer as delivered while nothing was queued,
301
+ // and the `already answered` guard then rejected every retry.
302
+ const { queueMessage } = await import('../orgrt/inbox.js');
303
+ try {
304
+ queueMessage(ctx.cwd, name, {
305
+ fromQualified: 'human', toRole: q.role,
306
+ subject: `answer:${questionId}`,
307
+ body: `question: ${q.question}\n\nanswer: ${answer}`,
308
+ ts: Date.now(),
309
+ });
310
+ }
311
+ catch (err) {
312
+ log(output.error(`Could not queue the answer for ${name}:${q.role} (${err instanceof Error ? err.message : String(err)}) — answer NOT recorded, retry it.`));
313
+ return { success: false, message: 'queueing failed — answer not recorded' };
314
+ }
254
315
  const merged = fresh.some(x => x.questionId === questionId)
255
316
  ? fresh.map(x => x.questionId === questionId ? { ...x, answer, answeredAt: Date.now() } : x)
256
317
  : [...fresh, { ...q, answer, answeredAt: Date.now() }];
@@ -259,13 +320,6 @@ export const answerAction = async (ctx, name) => {
259
320
  writeFileSync(tmp, JSON.stringify({ questions: merged }, null, 2));
260
321
  const { renameSync } = await import('node:fs');
261
322
  renameSync(tmp, dest);
262
- const { queueMessage } = await import('../orgrt/inbox.js');
263
- queueMessage(ctx.cwd, name, {
264
- fromQualified: 'human', toRole: q.role,
265
- subject: `answer:${questionId}`,
266
- body: `question: ${q.question}\n\nanswer: ${answer}`,
267
- ts: Date.now(),
268
- });
269
323
  log(output.success(`Answer recorded — ${name}:${q.role} receives it when the org next runs.`));
270
324
  return { success: true };
271
325
  };
@@ -1,4 +1,5 @@
1
1
  import type { Command, CommandResult } from '../types.js';
2
+ import { OrgDaemon } from '../orgrt/daemon.js';
2
3
  export declare function validateOrgName(name: string | undefined): {
3
4
  ok: true;
4
5
  name: string;
@@ -9,6 +10,16 @@ export declare function validateOrgName(name: string | undefined): {
9
10
  export declare function listOrgConfigFiles(orgsDir: string): string[];
10
11
  /** Remove a lingering stopfile so a fresh `org run` doesn't self-terminate. */
11
12
  export declare const clearStopfile: (cwd: string, name: string) => void;
13
+ /** One pass of the `org serve` stopfile poll.
14
+ *
15
+ * `monomind org stop <name>` writes `.monomind/orgs/<name>/stop`. `org run` has always
16
+ * polled that file; `org serve` did not — so against a serve daemon `org stop` was a
17
+ * silent no-op that still printed "daemon exits within 2s" and exited 0 while the org
18
+ * kept running. Stops every running org whose stopfile is present, then clears the
19
+ * stopfile so the next scheduled iteration isn't killed on sight.
20
+ *
21
+ * Returns the names it stopped (awaited), so callers/tests don't have to guess. */
22
+ export declare const pollStopfiles: (cwd: string, daemon: OrgDaemon) => Promise<string[]>;
12
23
  export declare const orgCommand: Command;
13
24
  export default orgCommand;
14
25
  //# sourceMappingURL=org.d.ts.map
@@ -1,5 +1,5 @@
1
1
  // packages/@monomind/cli/src/commands/org.ts
2
- import { readFileSync, existsSync, unlinkSync, rmSync, readdirSync } from 'node:fs';
2
+ import { readFileSync, writeFileSync, existsSync, unlinkSync, rmSync, readdirSync, mkdirSync } from 'node:fs';
3
3
  import { join, resolve } from 'node:path';
4
4
  import { output } from '../output.js';
5
5
  import { OrgDaemon } from '../orgrt/daemon.js';
@@ -162,9 +162,35 @@ const stopAction = async (ctx) => {
162
162
  log(output.error(`Org not found: ${name}`));
163
163
  return { success: false, message: 'org not found' };
164
164
  }
165
- const { writeFileSync, mkdirSync } = await import('node:fs');
165
+ // The stopfile is only meaningful to a process that polls it (`org run` and, since
166
+ // this fix, `org serve`). Writing it for an org that nothing is running was a silent
167
+ // no-op that still reported "daemon exits within 2s" — say what's actually true.
168
+ let rt;
169
+ try {
170
+ rt = JSON.parse(readFileSync(join(ctx.cwd, ORG_DIR, name, 'runtime.json'), 'utf8'));
171
+ }
172
+ catch { /* never run */ }
173
+ if (rt?.status !== 'running') {
174
+ log(output.warning(`Org "${name}" is not running (runtime state: ${rt?.status ?? 'never run'}) — nothing to stop.`));
175
+ return { success: false, message: 'org not running' };
176
+ }
177
+ if (rt.pid) {
178
+ let alive = true;
179
+ try {
180
+ process.kill(rt.pid, 0);
181
+ }
182
+ catch {
183
+ alive = false;
184
+ }
185
+ if (!alive) {
186
+ log(output.warning(`Org "${name}" is not running — runtime.json says running but pid ${rt.pid} is gone (crashed daemon).`));
187
+ log(output.info(`Clear the stale record with: monomind org mark-complete ${name}`));
188
+ return { success: false, message: 'org crashed — use mark-complete' };
189
+ }
190
+ }
166
191
  mkdirSync(join(ctx.cwd, ORG_DIR, name), { recursive: true });
167
192
  writeFileSync(join(ctx.cwd, ORG_DIR, name, 'stop'), new Date().toISOString());
193
+ log(output.info(`Stop requested for "${name}" (pid ${rt.pid}) — the daemon picks it up within ~2s.`));
168
194
  return { success: true, message: `stop requested for ${name} (daemon exits within 2s)` };
169
195
  };
170
196
  const statusAction = async (ctx) => {
@@ -198,12 +224,26 @@ const statusAction = async (ctx) => {
198
224
  }
199
225
  // A "running" record whose pid is gone means the daemon died without its
200
226
  // stopOrg cleanup — surface that instead of reporting it as still running.
201
- if (state.status === 'running' && state.pid) {
202
- try {
203
- process.kill(state.pid, 0);
204
- }
205
- catch {
206
- log(output.warning(`${t}: crashed (runtime.json says running but pid ${state.pid} is gone)${state.run ? ` — run ${state.run}` : ''} — close it out with "monomind org mark-complete ${t}"`));
227
+ if ((state.status === 'running' || state.status === 'crashed') && state.pid) {
228
+ const pidGone = state.status === 'crashed' || (() => {
229
+ try {
230
+ process.kill(state.pid, 0);
231
+ return false;
232
+ }
233
+ catch {
234
+ return true;
235
+ }
236
+ })();
237
+ if (pidGone) {
238
+ let heartbeatHint = '';
239
+ try {
240
+ const hb = JSON.parse(readFileSync(join(ctx.cwd, '.monomind', 'serve-heartbeat.json'), 'utf8'));
241
+ heartbeatHint = ` (last heartbeat: ${hb.updatedAt})`;
242
+ }
243
+ catch { /* no heartbeat file — daemon predates this change or was already cleaned up */ }
244
+ const closedBy = state.closedBy;
245
+ const label = closedBy === 'crash-handler' ? 'crashed (caught by crash handler)' : `crashed (runtime.json says ${state.status} but pid ${state.pid} is gone)`;
246
+ log(output.warning(`${t}: ${label}${heartbeatHint}${state.run ? ` — run ${state.run}` : ''} — close it out with "monomind org mark-complete ${t}"`));
207
247
  continue;
208
248
  }
209
249
  }
@@ -211,6 +251,34 @@ const statusAction = async (ctx) => {
211
251
  }
212
252
  return { success: true };
213
253
  };
254
+ /** One pass of the `org serve` stopfile poll.
255
+ *
256
+ * `monomind org stop <name>` writes `.monomind/orgs/<name>/stop`. `org run` has always
257
+ * polled that file; `org serve` did not — so against a serve daemon `org stop` was a
258
+ * silent no-op that still printed "daemon exits within 2s" and exited 0 while the org
259
+ * kept running. Stops every running org whose stopfile is present, then clears the
260
+ * stopfile so the next scheduled iteration isn't killed on sight.
261
+ *
262
+ * Returns the names it stopped (awaited), so callers/tests don't have to guess. */
263
+ export const pollStopfiles = async (cwd, daemon) => {
264
+ const stopped = [];
265
+ for (const name of daemon.listRunning()) {
266
+ if (!existsSync(join(cwd, ORG_DIR, name, 'stop')))
267
+ continue;
268
+ log(output.info(`org ${name}: stop requested — shutting it down`));
269
+ try {
270
+ await daemon.stopOrg(name);
271
+ stopped.push(name);
272
+ }
273
+ catch (err) {
274
+ console.error(`org ${name}: stop failed:`, err);
275
+ }
276
+ finally {
277
+ clearStopfile(cwd, name);
278
+ }
279
+ }
280
+ return stopped;
281
+ };
214
282
  const serveAction = async (ctx) => {
215
283
  const crossProcess = ctx.flags['crossProcess'] !== false;
216
284
  const daemon = new OrgDaemon(ctx.cwd, { crossProcess });
@@ -219,6 +287,24 @@ const serveAction = async (ctx) => {
219
287
  srv = await startOrgServer(daemon, 0);
220
288
  daemon.setInboxUrl(`http://127.0.0.1:${srv.port}`);
221
289
  }
290
+ // Crash handlers: log the reason and persist crashed state so `org status`
291
+ // shows what happened instead of a silent "pid is gone".
292
+ const crashExit = (label, err) => {
293
+ try {
294
+ console.error(`[org serve] ${label}:`, err);
295
+ }
296
+ catch { /* stderr gone */ }
297
+ daemon.persistCrashStateAll();
298
+ daemon.clearHeartbeat();
299
+ process.exitCode = 1;
300
+ };
301
+ process.on('uncaughtException', (err) => { crashExit('uncaughtException', err); process.exit(1); });
302
+ process.on('unhandledRejection', (err) => { crashExit('unhandledRejection', err); process.exit(1); });
303
+ // Heartbeat: write every 30s so `org status` can tell "alive but busy" from
304
+ // "daemon gone" without relying on pid liveness alone.
305
+ daemon.writeHeartbeat();
306
+ const heartbeatInterval = setInterval(() => { daemon.writeHeartbeat(); }, 30_000);
307
+ heartbeatInterval.unref?.();
222
308
  log(output.info('org daemon serving — Ctrl-C to stop'));
223
309
  // schedule orgs whose definition declares an interval (e.g. "15m", "2h")
224
310
  const { OrgScheduler, parseSchedule } = await import('../orgrt/scheduler.js');
@@ -270,9 +356,14 @@ const serveAction = async (ctx) => {
270
356
  }
271
357
  }
272
358
  }
359
+ const stopPoll = setInterval(() => { void pollStopfiles(ctx.cwd, daemon); }, 2000);
360
+ stopPoll.unref?.();
273
361
  await new Promise(r => { process.once('SIGINT', () => r()); process.once('SIGTERM', () => r()); });
362
+ clearInterval(stopPoll);
363
+ clearInterval(heartbeatInterval);
274
364
  sched.stop();
275
365
  await daemon.stopAll();
366
+ daemon.clearHeartbeat();
276
367
  srv?.close();
277
368
  return { success: true };
278
369
  };
@@ -393,6 +484,36 @@ const deleteAction = async (ctx) => {
393
484
  log(output.success(`Org "${orgName}" deleted (${removed} file(s) removed).`));
394
485
  return { success: true };
395
486
  };
487
+ /** Clear a stale `running` record from runtime.json. This is the state `org status`
488
+ * reads, so mark-complete MUST touch it — the dashboard's run:complete event alone
489
+ * left `org status` reporting the same "crashed" line it had just told the user to
490
+ * fix with this exact command. Refuses when the recorded pid is still alive: a live
491
+ * daemon would just rewrite the file, and `org stop` is the right command there. */
492
+ const clearStaleRuntime = (cwd, name) => {
493
+ const rtPath = join(cwd, ORG_DIR, name, 'runtime.json');
494
+ if (!existsSync(rtPath))
495
+ return { cleared: false, reason: 'absent' };
496
+ let rt;
497
+ try {
498
+ rt = JSON.parse(readFileSync(rtPath, 'utf8'));
499
+ }
500
+ catch (err) {
501
+ return { cleared: false, reason: 'unreadable', detail: err instanceof Error ? err.message : String(err) };
502
+ }
503
+ if (rt.status !== 'running' && rt.status !== 'crashed')
504
+ return { cleared: false, reason: 'not-running' };
505
+ if (rt.status === 'running' && rt.pid) {
506
+ try {
507
+ process.kill(rt.pid, 0);
508
+ return { cleared: false, reason: 'alive', detail: String(rt.pid) };
509
+ }
510
+ catch { /* pid is gone — this is exactly the stale case mark-complete exists for */ }
511
+ }
512
+ // Same shape stopOrg's persistState() writes, so every reader (org status,
513
+ // isOrgRunning, the mastermind-org* skills' jq checks) sees a stopped org.
514
+ writeFileSync(rtPath, JSON.stringify({ status: 'stopped', run: rt.run, pid: rt.pid, updated: new Date().toISOString(), closedBy: 'mark-complete' }, null, 2));
515
+ return { cleared: true, run: rt.run };
516
+ };
396
517
  const markCompleteAction = async (ctx) => {
397
518
  const orgName = ctx.args[0];
398
519
  if (!orgName || !ORG_NAME_RE.test(orgName)) {
@@ -400,6 +521,30 @@ const markCompleteAction = async (ctx) => {
400
521
  return { success: false, message: 'valid org name required' };
401
522
  }
402
523
  const cwd = resolve(ctx.cwd || process.cwd());
524
+ // Reject an org that does not exist, using the same check as runAction. Without
525
+ // it `org mark-complete nosuchorg` printed "local state was cleared" and exited
526
+ // 0 — a typo looked like a successful cleanup.
527
+ const orgsDir = join(cwd, ORG_DIR);
528
+ if (!existsSync(join(orgsDir, `${orgName}.json`))) {
529
+ const known = existsSync(orgsDir) ? listOrgConfigFiles(orgsDir).map(f => f.replace(/\.json$/, '')) : [];
530
+ log(output.error(`Org not found: ${orgName}${known.length ? ` — available: ${known.join(', ')}` : ''}`));
531
+ return { success: false, message: 'org not found' };
532
+ }
533
+ // 1) Local runtime.json — the state `org status` actually reads. Done first and
534
+ // independently of the dashboard so the recommended remedy works with no server.
535
+ const local = clearStaleRuntime(cwd, orgName);
536
+ if (!local.cleared && local.reason === 'alive') {
537
+ log(output.error(`Org "${orgName}" is still running (pid ${local.detail}) — stop it with "monomind org stop ${orgName}" instead.`));
538
+ return { success: false, message: 'org is running' };
539
+ }
540
+ if (local.cleared)
541
+ log(output.success(`Cleared stale runtime state for "${orgName}"${local.run ? ` (run ${local.run})` : ''}.`));
542
+ else if (local.reason === 'unreadable')
543
+ log(output.warning(`runtime.json for "${orgName}" is unreadable (${local.detail}) — left untouched.`));
544
+ else
545
+ log(output.info(`No stale runtime state for "${orgName}" (runtime.json ${local.reason === 'absent' ? 'absent' : 'already not running'}).`));
546
+ // 2) Dashboard run:complete event — best effort. A missing/unauthorized dashboard
547
+ // must not make the command fail after the local state was already cleared.
403
548
  let ctrlUrl = 'http://localhost:4242';
404
549
  try {
405
550
  const ctl = JSON.parse(readFileSync(join(cwd, '.monomind', 'control.json'), 'utf8'));
@@ -420,17 +565,19 @@ const markCompleteAction = async (ctx) => {
420
565
  });
421
566
  const body = await res.json().catch(() => ({}));
422
567
  if (!res.ok) {
423
- log(output.error(`mark-complete failed (${res.status}): ${body.error || 'unknown error'}`));
424
- return { success: false, message: 'server rejected mark-complete' };
568
+ log(output.warning(`Dashboard not updated (${res.status}: ${body.error || 'unknown error'}) — ${local.cleared ? 'local state was cleared' : 'there was no local state to clear'}.`));
569
+ }
570
+ else {
571
+ const runId = body.runId;
572
+ log(output.success(`Dashboard run marked complete for "${orgName}"${runId ? ` (run ${runId})` : ''}.`));
425
573
  }
426
- const runId = body.runId;
427
- log(output.success(`Run marked complete for org "${orgName}"${runId ? ` (run ${runId})` : ''}.`));
428
- return { success: true };
429
574
  }
430
575
  catch (err) {
431
- log(output.error(`Dashboard server unreachable at ${ctrlUrl} — is it running? (${err instanceof Error ? err.message : 'error'})`));
432
- return { success: false, message: 'server unreachable' };
576
+ log(output.warning(`Dashboard unreachable at ${ctrlUrl} (${err instanceof Error ? err.message : 'error'}) — ${local.cleared ? 'local state was cleared' : 'there was no local state to clear'}.`));
433
577
  }
578
+ return local.cleared
579
+ ? { success: true, message: `run marked complete for ${orgName}` }
580
+ : { success: true, message: `nothing to clear for ${orgName}` };
434
581
  };
435
582
  const migrateAction = async (ctx) => {
436
583
  const validated = validateOrgName(ctx.args[0]);
@@ -521,12 +521,24 @@ const bottleneckCommand = {
521
521
  ],
522
522
  examples: [
523
523
  { command: 'monomind performance bottleneck', description: 'Find bottlenecks' },
524
- { command: 'monomind performance bottleneck -d full', description: 'Full analysis' },
524
+ { command: 'monomind performance bottleneck -c network', description: 'Only the Network component' },
525
525
  ],
526
526
  action: async (ctx) => {
527
527
  output.writeln();
528
528
  output.writeln(output.bold('Bottleneck Analysis'));
529
529
  output.writeln(output.dim('─'.repeat(50)));
530
+ // Components this command actually knows how to inspect. Used both to
531
+ // validate --component and to tell the user what they could have asked for.
532
+ const ANALYZED_COMPONENTS = ['Runtime', 'Vector Search', 'Traces', 'Memory DB', 'Network'];
533
+ // Only a single depth of analysis is implemented. Accepting `--depth full`
534
+ // and quietly running the quick analysis discards the user's request with
535
+ // no indication it was ignored, so say so instead.
536
+ const depthRaw = ctx.flags.depth || 'quick';
537
+ if (depthRaw !== 'quick') {
538
+ output.writeln(output.warning(`--depth ${depthRaw} is not implemented — running the "quick" analysis. ` +
539
+ `Only "quick" is supported today.`));
540
+ }
541
+ const componentFilter = ctx.flags.component?.trim();
530
542
  const spinner = output.createSpinner({ text: 'Analyzing system...', spinner: 'dots' });
531
543
  spinner.start();
532
544
  const fs = await import('node:fs');
@@ -575,10 +587,24 @@ const bottleneckCommand = {
575
587
  if (!network.batching) {
576
588
  findings.push({ component: 'Network', bottleneck: 'No request batching', severity: output.info('Low'), solution: 'Run: monomind performance optimize --apply -t latency' });
577
589
  }
578
- if (findings.length === 0) {
579
- findings.push({ component: 'System', bottleneck: 'No bottlenecks detected', severity: output.success('None'), solution: 'System is performing well' });
590
+ let results = findings;
591
+ if (componentFilter) {
592
+ const known = ANALYZED_COMPONENTS.find(c => c.toLowerCase() === componentFilter.toLowerCase())
593
+ ?? ANALYZED_COMPONENTS.find(c => c.toLowerCase().includes(componentFilter.toLowerCase()));
594
+ if (!known) {
595
+ spinner.fail(`Unknown component "${componentFilter}"`);
596
+ output.writeln(output.warning(`Analyzed components: ${ANALYZED_COMPONENTS.join(', ')}.`));
597
+ return { success: false, message: `Unknown component: ${componentFilter}`, exitCode: 1 };
598
+ }
599
+ results = findings.filter(f => f.component === known);
600
+ if (results.length === 0) {
601
+ results = [{ component: known, bottleneck: 'No bottlenecks detected', severity: output.success('None'), solution: `${known} is performing well` }];
602
+ }
603
+ }
604
+ else if (results.length === 0) {
605
+ results = [{ component: 'System', bottleneck: 'No bottlenecks detected', severity: output.success('None'), solution: 'System is performing well' }];
580
606
  }
581
- spinner.succeed(`Analysis complete — ${findings.length} finding(s)`);
607
+ spinner.succeed(`Analysis complete — ${results.length} finding(s)`);
582
608
  output.writeln();
583
609
  output.printTable({
584
610
  columns: [
@@ -587,7 +613,7 @@ const bottleneckCommand = {
587
613
  { key: 'severity', header: 'Severity', width: 12 },
588
614
  { key: 'solution', header: 'Solution', width: 50 },
589
615
  ],
590
- data: findings,
616
+ data: results,
591
617
  });
592
618
  return { success: true };
593
619
  },
@@ -11,10 +11,11 @@ export const auditCommand = {
11
11
  options: [
12
12
  { name: 'action', short: 'a', type: 'string', description: 'Action: list (only supported value — log/export/clear are not implemented)', default: 'list' },
13
13
  { name: 'limit', short: 'l', type: 'number', description: 'Number of entries to show', default: '20' },
14
- { name: 'filter', short: 'f', type: 'string', description: 'Filter by event type' },
14
+ { name: 'filter', short: 'f', type: 'string', description: 'Filter by event type (substring, case-insensitive)' },
15
15
  ],
16
16
  examples: [
17
17
  { command: 'monomind security audit --action list', description: 'List audit logs' },
18
+ { command: 'monomind security audit --filter SWARM', description: 'Only swarm activity events' },
18
19
  ],
19
20
  action: async (ctx) => {
20
21
  const requestedAction = ctx.flags.action || 'list';
@@ -54,7 +55,21 @@ export const auditCommand = {
54
55
  const now = new Date().toISOString().replace('T', ' ').substring(0, 19);
55
56
  auditEntries.push({ timestamp: now, event: 'AUDIT_RUN', user: 'cli', status: output.success('Success') });
56
57
  auditEntries.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
57
- if (auditEntries.length === 0) {
58
+ // --filter was previously parsed and discarded, so every invocation
59
+ // returned the full log regardless of what the user asked for.
60
+ const eventFilter = ctx.flags.filter?.trim();
61
+ let visibleEntries = auditEntries;
62
+ if (eventFilter) {
63
+ const needle = eventFilter.toLowerCase();
64
+ visibleEntries = auditEntries.filter(e => e.event.toLowerCase().includes(needle));
65
+ if (visibleEntries.length === 0) {
66
+ const seen = [...new Set(auditEntries.map(e => e.event))].sort();
67
+ output.writeln(output.warning(`No audit events match "${eventFilter}".`));
68
+ output.writeln(output.dim(`Event types present: ${seen.join(', ') || 'none'}`));
69
+ return { success: true, data: { entries: [], filter: eventFilter } };
70
+ }
71
+ }
72
+ if (visibleEntries.length === 0) {
58
73
  output.writeln(output.dim('No audit events found. Initialize a project first: monomind init'));
59
74
  }
60
75
  else {
@@ -65,7 +80,7 @@ export const auditCommand = {
65
80
  { key: 'user', header: 'User', width: 15 },
66
81
  { key: 'status', header: 'Status', width: 12 },
67
82
  ],
68
- data: auditEntries.slice(0, parseInt(ctx.flags.limit || '20', 10)),
83
+ data: visibleEntries.slice(0, parseInt(ctx.flags.limit || '20', 10)),
69
84
  });
70
85
  }
71
86
  return { success: true };
@@ -12,7 +12,36 @@ export type SecretFinding = {
12
12
  location: string;
13
13
  description: string;
14
14
  };
15
- export declare function findSecretsInDir(dir: string, depthLimit: number, baseDir: string, findings: SecretFinding[]): void;
15
+ /**
16
+ * Records what the scanner could NOT look at.
17
+ *
18
+ * Without this the scanner swallowed unreadable directories and stopped at its
19
+ * depth limit, then printed "No secrets found." — an error presented as a clean
20
+ * result. Callers must consult `scanWasIncomplete()` before reporting a clean
21
+ * bill of health.
22
+ */
23
+ export interface ScanCoverage {
24
+ /** Directories that could not be listed (permissions, I/O). Real failures. */
25
+ unreadableDirs: string[];
26
+ /** Files that could not be read or stat'd. Real failures. */
27
+ unreadableFiles: string[];
28
+ /** Directories not descended into because the depth limit was reached. */
29
+ depthTruncatedDirs: string[];
30
+ /** Files skipped because they exceed the 1MB per-file cap. */
31
+ oversizedFiles: string[];
32
+ /** Files actually opened and pattern-matched. */
33
+ filesScanned: number;
34
+ /** Directories actually listed. */
35
+ dirsScanned: number;
36
+ }
37
+ export declare function createScanCoverage(): ScanCoverage;
38
+ /** True when some part of the tree was not examined, for any reason. */
39
+ export declare function scanWasIncomplete(c: ScanCoverage): boolean;
40
+ /** True when the scanner hit a hard failure (not merely a configured limit). */
41
+ export declare function scanHadErrors(c: ScanCoverage): boolean;
42
+ /** Human-readable lines describing every gap in coverage. Empty when complete. */
43
+ export declare function describeScanGaps(c: ScanCoverage): string[];
44
+ export declare function findSecretsInDir(dir: string, depthLimit: number, baseDir: string, findings: SecretFinding[], coverage?: ScanCoverage): void;
16
45
  export declare const scanCommand: Command;
17
46
  export declare const secretsCommand: Command;
18
47
  //# sourceMappingURL=security-scan.d.ts.map