monomind 2.7.7 → 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 (54) hide show
  1. package/package.json +2 -1
  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/dist/src/browser/dashboard/server.js +6 -1
  15. package/packages/@monomind/cli/dist/src/capabilities/index.d.ts +0 -1
  16. package/packages/@monomind/cli/dist/src/capabilities/index.js +8 -1
  17. package/packages/@monomind/cli/dist/src/commands/agent-lifecycle.js +5 -1
  18. package/packages/@monomind/cli/dist/src/commands/doctor-project-checks.js +8 -1
  19. package/packages/@monomind/cli/dist/src/commands/guidance.js +8 -2
  20. package/packages/@monomind/cli/dist/src/commands/init.js +18 -4
  21. package/packages/@monomind/cli/dist/src/commands/memory-crud.js +7 -1
  22. package/packages/@monomind/cli/dist/src/commands/org-observe.js +17 -7
  23. package/packages/@monomind/cli/dist/src/commands/org.d.ts +11 -0
  24. package/packages/@monomind/cli/dist/src/commands/org.js +162 -15
  25. package/packages/@monomind/cli/dist/src/commands/security-scan.d.ts +30 -1
  26. package/packages/@monomind/cli/dist/src/commands/security-scan.js +182 -69
  27. package/packages/@monomind/cli/dist/src/commands/swarm.js +41 -14
  28. package/packages/@monomind/cli/dist/src/consensus/audit-writer.js +11 -10
  29. package/packages/@monomind/cli/dist/src/init/executor.js +138 -22
  30. package/packages/@monomind/cli/dist/src/init/settings-generator.js +4 -1
  31. package/packages/@monomind/cli/dist/src/knowledge/document-pipeline.d.ts +17 -0
  32. package/packages/@monomind/cli/dist/src/knowledge/document-pipeline.js +62 -3
  33. package/packages/@monomind/cli/dist/src/mcp-tools/embeddings-tools.js +16 -8
  34. package/packages/@monomind/cli/dist/src/mcp-tools/knowledge-tools.js +27 -13
  35. package/packages/@monomind/cli/dist/src/memory/memory-bridge.d.ts +1 -1
  36. package/packages/@monomind/cli/dist/src/memory/memory-bridge.js +5 -1
  37. package/packages/@monomind/cli/dist/src/memory/memory-read.d.ts +7 -2
  38. package/packages/@monomind/cli/dist/src/memory/memory-read.js +10 -2
  39. package/packages/@monomind/cli/dist/src/monovector/diff-classifier.js +25 -6
  40. package/packages/@monomind/cli/dist/src/orgrt/daemon.d.ts +21 -1
  41. package/packages/@monomind/cli/dist/src/orgrt/daemon.js +82 -11
  42. package/packages/@monomind/cli/dist/src/orgrt/inbox.js +53 -20
  43. package/packages/@monomind/cli/dist/src/parser.d.ts +4 -2
  44. package/packages/@monomind/cli/dist/src/parser.js +61 -25
  45. package/packages/@monomind/cli/dist/src/services/config-file-manager.d.ts +10 -2
  46. package/packages/@monomind/cli/dist/src/services/config-file-manager.js +10 -2
  47. package/packages/@monomind/cli/dist/src/ui/collector.mjs +43 -3
  48. package/packages/@monomind/cli/dist/src/ui/dashboard.html +27 -8
  49. package/packages/@monomind/cli/dist/src/ui/server.mjs +144 -14
  50. package/packages/@monomind/cli/package.json +3 -3
  51. package/packages/@monomind/cli/dist/src/capabilities/watcher.d.ts +0 -18
  52. package/packages/@monomind/cli/dist/src/capabilities/watcher.js +0 -107
  53. package/packages/@monomind/cli/dist/src/config-adapter.d.ts +0 -16
  54. package/packages/@monomind/cli/dist/src/config-adapter.js +0 -220
@@ -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]);
@@ -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
@@ -12,45 +12,97 @@ export const SECRET_PATTERNS = [
12
12
  { pattern: /['"]xox[baprs]-[a-zA-Z0-9-]+['"]/g, type: 'Slack Token' },
13
13
  { pattern: /password\s*[:=]\s*['"][^'"]{8,}['"]/gi, type: 'Hardcoded Password' },
14
14
  ];
15
- export function findSecretsInDir(dir, depthLimit, baseDir, findings) {
16
- if (depthLimit <= 0)
15
+ export function createScanCoverage() {
16
+ return {
17
+ unreadableDirs: [],
18
+ unreadableFiles: [],
19
+ depthTruncatedDirs: [],
20
+ oversizedFiles: [],
21
+ filesScanned: 0,
22
+ dirsScanned: 0,
23
+ };
24
+ }
25
+ /** True when some part of the tree was not examined, for any reason. */
26
+ export function scanWasIncomplete(c) {
27
+ return (c.unreadableDirs.length > 0 ||
28
+ c.unreadableFiles.length > 0 ||
29
+ c.depthTruncatedDirs.length > 0 ||
30
+ c.oversizedFiles.length > 0);
31
+ }
32
+ /** True when the scanner hit a hard failure (not merely a configured limit). */
33
+ export function scanHadErrors(c) {
34
+ return c.unreadableDirs.length > 0 || c.unreadableFiles.length > 0;
35
+ }
36
+ /** Human-readable lines describing every gap in coverage. Empty when complete. */
37
+ export function describeScanGaps(c) {
38
+ const lines = [];
39
+ if (c.unreadableDirs.length > 0) {
40
+ lines.push(`${c.unreadableDirs.length} directory(ies) could not be read (e.g. ${c.unreadableDirs[0]})`);
41
+ }
42
+ if (c.unreadableFiles.length > 0) {
43
+ lines.push(`${c.unreadableFiles.length} file(s) could not be read (e.g. ${c.unreadableFiles[0]})`);
44
+ }
45
+ if (c.depthTruncatedDirs.length > 0) {
46
+ lines.push(`${c.depthTruncatedDirs.length} directory(ies) not scanned — depth limit reached (use --depth deep)`);
47
+ }
48
+ if (c.oversizedFiles.length > 0) {
49
+ lines.push(`${c.oversizedFiles.length} file(s) skipped — larger than 1MB`);
50
+ }
51
+ return lines;
52
+ }
53
+ export function findSecretsInDir(dir, depthLimit, baseDir, findings, coverage = createScanCoverage()) {
54
+ if (depthLimit <= 0) {
55
+ coverage.depthTruncatedDirs.push(relative(baseDir, dir) || dir);
17
56
  return;
57
+ }
58
+ let entries;
18
59
  try {
19
- const entries = readdirSync(dir, { withFileTypes: true });
20
- for (const entry of entries) {
21
- const isDotEnv = /^\.env(\..+)?$/.test(entry.name);
22
- if ((entry.name.startsWith('.') && !isDotEnv) || entry.name === 'node_modules' || entry.name === 'dist')
60
+ entries = readdirSync(dir, { withFileTypes: true });
61
+ }
62
+ catch {
63
+ coverage.unreadableDirs.push(relative(baseDir, dir) || dir);
64
+ return;
65
+ }
66
+ coverage.dirsScanned++;
67
+ for (const entry of entries) {
68
+ const isDotEnv = /^\.env(\..+)?$/.test(entry.name);
69
+ if ((entry.name.startsWith('.') && !isDotEnv) || entry.name === 'node_modules' || entry.name === 'dist')
70
+ continue;
71
+ const fullPath = join(dir, entry.name);
72
+ if (entry.isDirectory()) {
73
+ findSecretsInDir(fullPath, depthLimit - 1, baseDir, findings, coverage);
74
+ }
75
+ else if (entry.isFile() && (/\.(ts|js|json|yml|yaml)$/.test(entry.name) || isDotEnv) && !entry.name.endsWith('.d.ts')) {
76
+ let content;
77
+ try {
78
+ if (statSync(fullPath).size > 1024 * 1024) {
79
+ coverage.oversizedFiles.push(relative(baseDir, fullPath));
80
+ continue;
81
+ }
82
+ content = readFileSync(fullPath, 'utf-8');
83
+ }
84
+ catch {
85
+ coverage.unreadableFiles.push(relative(baseDir, fullPath));
23
86
  continue;
24
- const fullPath = join(dir, entry.name);
25
- if (entry.isDirectory()) {
26
- findSecretsInDir(fullPath, depthLimit - 1, baseDir, findings);
27
87
  }
28
- else if (entry.isFile() && (/\.(ts|js|json|yml|yaml)$/.test(entry.name) || isDotEnv) && !entry.name.endsWith('.d.ts')) {
29
- try {
30
- if (statSync(fullPath).size > 1024 * 1024)
31
- continue;
32
- const content = readFileSync(fullPath, 'utf-8');
33
- const lines = content.split('\n');
34
- for (let i = 0; i < lines.length; i++) {
35
- for (const { pattern, type } of SECRET_PATTERNS) {
36
- pattern.lastIndex = 0;
37
- let m;
38
- while ((m = pattern.exec(lines[i])) !== null) {
39
- findings.push({
40
- severity: output.warning('HIGH'),
41
- type: 'Hardcoded Secret',
42
- location: `${relative(baseDir, fullPath)}:${i + 1}`,
43
- description: type,
44
- });
45
- }
46
- }
88
+ coverage.filesScanned++;
89
+ const lines = content.split('\n');
90
+ for (let i = 0; i < lines.length; i++) {
91
+ for (const { pattern, type } of SECRET_PATTERNS) {
92
+ pattern.lastIndex = 0;
93
+ let m;
94
+ while ((m = pattern.exec(lines[i])) !== null) {
95
+ findings.push({
96
+ severity: output.warning('HIGH'),
97
+ type: 'Hardcoded Secret',
98
+ location: `${relative(baseDir, fullPath)}:${i + 1}`,
99
+ description: type,
100
+ });
47
101
  }
48
102
  }
49
- catch { /* file read error */ }
50
103
  }
51
104
  }
52
105
  }
53
- catch { /* dir read error */ }
54
106
  }
55
107
  // ─── scan subcommand ─────────────────────────────────────────────────────────
56
108
  export const scanCommand = {
@@ -92,6 +144,7 @@ export const scanCommand = {
92
144
  const spinner = output.createSpinner({ text: `Scanning ${target}...`, spinner: 'dots' });
93
145
  spinner.start();
94
146
  const findings = [];
147
+ const coverage = createScanCoverage();
95
148
  let criticalCount = 0, highCount = 0, mediumCount = 0, lowCount = 0;
96
149
  try {
97
150
  const fs = await import('fs');
@@ -156,7 +209,7 @@ export const scanCommand = {
156
209
  spinner.setText('Scanning for hardcoded secrets...');
157
210
  const scanDepth = depth === 'deep' ? 10 : depth === 'standard' ? 5 : 3;
158
211
  const prevCount = findings.length;
159
- findSecretsInDir(path.resolve(target), scanDepth, path.resolve(target), findings);
212
+ findSecretsInDir(path.resolve(target), scanDepth, path.resolve(target), findings, coverage);
160
213
  highCount += findings.length - prevCount;
161
214
  }
162
215
  if ((scanType === 'all' || scanType === 'code') && depth !== 'quick') {
@@ -168,53 +221,74 @@ export const scanCommand = {
168
221
  { pattern: /child_process.*exec[^S]/g, type: 'Command Injection', severity: 'high', desc: 'Possible command injection' },
169
222
  { pattern: /\$\{.*\}.*sql|sql.*\$\{/gi, type: 'SQL Injection', severity: 'high', desc: 'Possible SQL injection' },
170
223
  ];
224
+ // Same coverage accounting as findSecretsInDir: gaps are recorded, never
225
+ // swallowed, so an unreadable tree cannot masquerade as a clean one.
226
+ const codeBase = path.resolve(target);
171
227
  const scanCodeDir = (dir, depthLimit) => {
172
- if (depthLimit <= 0)
228
+ if (depthLimit <= 0) {
229
+ coverage.depthTruncatedDirs.push(path.relative(codeBase, dir) || dir);
173
230
  return;
231
+ }
232
+ let entries;
174
233
  try {
175
- const entries = fs.readdirSync(dir, { withFileTypes: true });
176
- for (const entry of entries) {
177
- if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'dist')
234
+ entries = fs.readdirSync(dir, { withFileTypes: true });
235
+ }
236
+ catch {
237
+ coverage.unreadableDirs.push(path.relative(codeBase, dir) || dir);
238
+ return;
239
+ }
240
+ for (const entry of entries) {
241
+ if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'dist')
242
+ continue;
243
+ const fullPath = path.join(dir, entry.name);
244
+ if (entry.isDirectory()) {
245
+ scanCodeDir(fullPath, depthLimit - 1);
246
+ }
247
+ else if (entry.isFile() && /\.(ts|js|tsx|jsx)$/.test(entry.name) && !entry.name.endsWith('.d.ts')) {
248
+ let content;
249
+ try {
250
+ if (fs.statSync(fullPath).size > 1024 * 1024) {
251
+ coverage.oversizedFiles.push(path.relative(codeBase, fullPath));
252
+ continue;
253
+ }
254
+ content = fs.readFileSync(fullPath, 'utf-8');
255
+ }
256
+ catch {
257
+ coverage.unreadableFiles.push(path.relative(codeBase, fullPath));
178
258
  continue;
179
- const fullPath = path.join(dir, entry.name);
180
- if (entry.isDirectory()) {
181
- scanCodeDir(fullPath, depthLimit - 1);
182
259
  }
183
- else if (entry.isFile() && /\.(ts|js|tsx|jsx)$/.test(entry.name) && !entry.name.endsWith('.d.ts')) {
184
- try {
185
- if (fs.statSync(fullPath).size > 1024 * 1024)
186
- continue;
187
- const content = fs.readFileSync(fullPath, 'utf-8');
188
- const lines = content.split('\n');
189
- for (let i = 0; i < lines.length; i++) {
190
- for (const { pattern, type, severity, desc } of codePatterns) {
191
- pattern.lastIndex = 0;
192
- let m;
193
- while ((m = pattern.exec(lines[i])) !== null) {
194
- if (severity === 'high')
195
- highCount++;
196
- else
197
- mediumCount++;
198
- findings.push({
199
- severity: severity === 'high' ? output.warning('HIGH') : output.warning('MEDIUM'),
200
- type,
201
- location: `${path.relative(target, fullPath)}:${i + 1}`,
202
- description: desc,
203
- });
204
- }
205
- }
260
+ const lines = content.split('\n');
261
+ for (let i = 0; i < lines.length; i++) {
262
+ for (const { pattern, type, severity, desc } of codePatterns) {
263
+ pattern.lastIndex = 0;
264
+ let m;
265
+ while ((m = pattern.exec(lines[i])) !== null) {
266
+ if (severity === 'high')
267
+ highCount++;
268
+ else
269
+ mediumCount++;
270
+ findings.push({
271
+ severity: severity === 'high' ? output.warning('HIGH') : output.warning('MEDIUM'),
272
+ type,
273
+ location: `${path.relative(target, fullPath)}:${i + 1}`,
274
+ description: desc,
275
+ });
206
276
  }
207
277
  }
208
- catch { /* file read error */ }
209
278
  }
210
279
  }
211
280
  }
212
- catch { /* dir read error */ }
213
281
  };
214
282
  const scanDepth = depth === 'deep' ? 10 : 5;
215
283
  scanCodeDir(path.resolve(target), scanDepth);
216
284
  }
217
- spinner.succeed('Scan complete');
285
+ const gaps = describeScanGaps(coverage);
286
+ if (gaps.length > 0) {
287
+ spinner.stop(output.warning('Scan finished with INCOMPLETE coverage'));
288
+ }
289
+ else {
290
+ spinner.succeed('Scan complete');
291
+ }
218
292
  output.writeln();
219
293
  if (findings.length > 0) {
220
294
  output.printTable({
@@ -229,9 +303,19 @@ export const scanCommand = {
229
303
  if (findings.length > 20)
230
304
  output.writeln(output.dim(`... and ${findings.length - 20} more issues`));
231
305
  }
306
+ else if (gaps.length > 0) {
307
+ // Never present an incomplete scan as a clean bill of health.
308
+ output.writeln(output.warning('No security issues found in the parts that could be scanned — coverage was INCOMPLETE (see below).'));
309
+ }
232
310
  else {
233
311
  output.writeln(output.success('No security issues found!'));
234
312
  }
313
+ if (gaps.length > 0) {
314
+ output.writeln();
315
+ output.writeln(output.warning('Incomplete coverage:'));
316
+ for (const g of gaps)
317
+ output.writeln(output.warning(` - ${g}`));
318
+ }
235
319
  output.writeln();
236
320
  output.printBox([
237
321
  `Target: ${target}`,
@@ -240,6 +324,9 @@ export const scanCommand = {
240
324
  ``,
241
325
  `Critical: ${criticalCount} High: ${highCount} Medium: ${mediumCount} Low: ${lowCount}`,
242
326
  `Total Issues: ${findings.length}`,
327
+ ``,
328
+ `Coverage: ${coverage.filesScanned} file(s) in ${coverage.dirsScanned} dir(s) scanned`,
329
+ `Coverage status: ${gaps.length === 0 ? 'complete' : `INCOMPLETE (${gaps.length} gap type(s))`}`,
243
330
  ].join('\n'), 'Scan Summary');
244
331
  if (fix && criticalCount + highCount > 0) {
245
332
  const resolvedTarget = realpathSync(path.resolve(target));
@@ -263,6 +350,11 @@ export const scanCommand = {
263
350
  fixSpinner.fail('Some fixes could not be applied automatically');
264
351
  }
265
352
  }
353
+ // A scan that hit real read errors cannot certify anything — fail loudly.
354
+ // Depth truncation is a configured limit, not an error, so it is reported
355
+ // above but does not by itself flip the exit status.
356
+ if (scanHadErrors(coverage))
357
+ return { success: false };
266
358
  return { success: findings.length === 0 || (criticalCount === 0 && highCount === 0) };
267
359
  }
268
360
  catch (error) {
@@ -307,11 +399,21 @@ export const secretsCommand = {
307
399
  const spinner = output.createSpinner({ text: `Scanning ${targetPath}...`, spinner: 'dots' });
308
400
  spinner.start();
309
401
  const findings = [];
402
+ const coverage = createScanCoverage();
310
403
  const scanDepth = depth === 'deep' ? 10 : depth === 'standard' ? 5 : 3;
311
- findSecretsInDir(resolve(targetPath), scanDepth, resolve(targetPath), findings);
312
- spinner.succeed('Scan complete');
404
+ findSecretsInDir(resolve(targetPath), scanDepth, resolve(targetPath), findings, coverage);
405
+ const gaps = describeScanGaps(coverage);
406
+ if (gaps.length > 0) {
407
+ spinner.stop(output.warning('Scan finished with INCOMPLETE coverage'));
408
+ }
409
+ else {
410
+ spinner.succeed('Scan complete');
411
+ }
313
412
  output.writeln();
314
- if (findings.length === 0) {
413
+ if (findings.length === 0 && gaps.length > 0) {
414
+ output.writeln(output.warning('No secrets found in the parts that could be scanned — coverage was INCOMPLETE.'));
415
+ }
416
+ else if (findings.length === 0) {
315
417
  output.writeln(output.success('No secrets found.'));
316
418
  }
317
419
  else {
@@ -326,9 +428,20 @@ export const secretsCommand = {
326
428
  if (findings.length > 20)
327
429
  output.writeln(output.dim(`... and ${findings.length - 20} more`));
328
430
  }
431
+ if (gaps.length > 0) {
432
+ output.writeln();
433
+ output.writeln(output.warning('Incomplete coverage:'));
434
+ for (const g of gaps)
435
+ output.writeln(output.warning(` - ${g}`));
436
+ }
329
437
  output.writeln();
330
- output.writeln(output.bold('Summary: ') + `${findings.length} secret(s) found in ${targetPath}`);
331
- return { success: findings.length === 0 };
438
+ output.writeln(output.bold('Summary: ') +
439
+ `${findings.length} secret(s) found in ${targetPath} ` +
440
+ `(${coverage.filesScanned} file(s) in ${coverage.dirsScanned} dir(s) scanned, ` +
441
+ `coverage ${gaps.length === 0 ? 'complete' : 'INCOMPLETE'})`);
442
+ // Read errors mean the tree was not fully examined — "no secrets" is not
443
+ // a result we can stand behind, so do not exit 0 on it.
444
+ return { success: findings.length === 0 && !scanHadErrors(coverage) };
332
445
  },
333
446
  };
334
447
  //# sourceMappingURL=security-scan.js.map