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.
- package/package.json +5 -4
- package/packages/@monomind/cli/.claude/agents/core/coder.md +9 -0
- package/packages/@monomind/cli/.claude/agents/core/coordinator.md +62 -0
- package/packages/@monomind/cli/.claude/agents/core/planner.md +9 -0
- package/packages/@monomind/cli/.claude/agents/core/reviewer.md +9 -0
- package/packages/@monomind/cli/.claude/agents/core/tester.md +8 -0
- package/packages/@monomind/cli/.claude/helpers/handlers/gates-handler.cjs +180 -47
- package/packages/@monomind/cli/.claude/helpers/handlers/route-handler.cjs +32 -3
- package/packages/@monomind/cli/.claude/helpers/hook-handler.cjs +55 -3
- package/packages/@monomind/cli/.claude/helpers/intelligence.cjs +3 -1
- package/packages/@monomind/cli/.claude/helpers/statusline.cjs +27 -3
- package/packages/@monomind/cli/.claude/helpers/utils/monograph.cjs +104 -18
- package/packages/@monomind/cli/.claude/settings.json +1 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-server.mjs +38 -1
- package/packages/@monomind/cli/dist/src/browser/dashboard/server.js +6 -1
- package/packages/@monomind/cli/dist/src/capabilities/index.d.ts +0 -1
- package/packages/@monomind/cli/dist/src/capabilities/index.js +8 -1
- package/packages/@monomind/cli/dist/src/commands/agent-lifecycle.js +5 -1
- package/packages/@monomind/cli/dist/src/commands/agent-ops.js +32 -2
- package/packages/@monomind/cli/dist/src/commands/browse-workflow.js +30 -0
- package/packages/@monomind/cli/dist/src/commands/doctor-project-checks.js +8 -1
- package/packages/@monomind/cli/dist/src/commands/guidance.js +8 -2
- package/packages/@monomind/cli/dist/src/commands/hooks-extended-commands.js +10 -2
- package/packages/@monomind/cli/dist/src/commands/init.js +18 -4
- package/packages/@monomind/cli/dist/src/commands/memory-crud.js +40 -3
- package/packages/@monomind/cli/dist/src/commands/org-observe.js +67 -13
- package/packages/@monomind/cli/dist/src/commands/org.d.ts +11 -0
- package/packages/@monomind/cli/dist/src/commands/org.js +162 -15
- package/packages/@monomind/cli/dist/src/commands/performance.js +31 -5
- package/packages/@monomind/cli/dist/src/commands/security-misc.js +18 -3
- package/packages/@monomind/cli/dist/src/commands/security-scan.d.ts +30 -1
- package/packages/@monomind/cli/dist/src/commands/security-scan.js +182 -69
- package/packages/@monomind/cli/dist/src/commands/swarm.js +41 -14
- package/packages/@monomind/cli/dist/src/consensus/audit-writer.js +11 -10
- package/packages/@monomind/cli/dist/src/init/executor.js +138 -22
- package/packages/@monomind/cli/dist/src/init/settings-generator.js +4 -1
- package/packages/@monomind/cli/dist/src/knowledge/document-pipeline.d.ts +17 -0
- package/packages/@monomind/cli/dist/src/knowledge/document-pipeline.js +62 -3
- package/packages/@monomind/cli/dist/src/mcp-client.js +78 -0
- package/packages/@monomind/cli/dist/src/mcp-tools/embeddings-tools.js +16 -8
- package/packages/@monomind/cli/dist/src/mcp-tools/knowledge-tools.js +27 -13
- package/packages/@monomind/cli/dist/src/memory/memory-bridge.d.ts +5 -1
- package/packages/@monomind/cli/dist/src/memory/memory-bridge.js +26 -3
- package/packages/@monomind/cli/dist/src/memory/memory-read.d.ts +9 -0
- package/packages/@monomind/cli/dist/src/memory/memory-read.js +13 -2
- package/packages/@monomind/cli/dist/src/monovector/diff-classifier.js +25 -6
- package/packages/@monomind/cli/dist/src/orgrt/daemon.d.ts +21 -1
- package/packages/@monomind/cli/dist/src/orgrt/daemon.js +82 -11
- package/packages/@monomind/cli/dist/src/orgrt/inbox.js +53 -20
- package/packages/@monomind/cli/dist/src/parser.d.ts +4 -2
- package/packages/@monomind/cli/dist/src/parser.js +61 -25
- package/packages/@monomind/cli/dist/src/routing/route-layer-factory.d.ts +15 -0
- package/packages/@monomind/cli/dist/src/routing/route-layer-factory.js +44 -2
- package/packages/@monomind/cli/dist/src/services/config-file-manager.d.ts +10 -2
- package/packages/@monomind/cli/dist/src/services/config-file-manager.js +10 -2
- package/packages/@monomind/cli/dist/src/ui/collector.mjs +43 -3
- package/packages/@monomind/cli/dist/src/ui/dashboard.html +27 -8
- package/packages/@monomind/cli/dist/src/ui/server.mjs +144 -14
- package/packages/@monomind/cli/package.json +7 -6
- package/packages/@monomind/cli/dist/src/capabilities/watcher.d.ts +0 -18
- package/packages/@monomind/cli/dist/src/capabilities/watcher.js +0 -107
- package/packages/@monomind/cli/dist/src/config-adapter.d.ts +0 -16
- 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
|
-
|
|
189
|
+
raw = readFileSync(path, 'utf8');
|
|
184
190
|
}
|
|
185
|
-
catch {
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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.
|
|
424
|
-
|
|
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.
|
|
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 -
|
|
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
|
-
|
|
579
|
-
|
|
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 — ${
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|