@nerviq/cli 1.8.1 → 1.8.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nerviq/cli",
3
- "version": "1.8.1",
3
+ "version": "1.8.4",
4
4
  "description": "The intelligent nervous system for AI coding agents — 2,431 checks across 8 platforms, 10 languages, and 62 domain packs. Audit, align, and amplify.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/activity.js CHANGED
@@ -557,12 +557,13 @@ function loadSnapshotPayload(dir, indexEntry) {
557
557
  * Analyze check health by comparing the two most recent audit snapshots.
558
558
  * Detects checks that regressed (passed → failed), improved (failed → passed),
559
559
  * and flags sudden drops that may indicate platform format changes.
560
+ * When more than 2 snapshots exist, also computes per-check pass rates.
560
561
  *
561
562
  * @param {string} dir - Project root directory.
562
563
  * @returns {Object|null} Health report, or null if fewer than 2 audit snapshots exist.
563
564
  */
564
565
  function checkHealth(dir) {
565
- const history = getHistory(dir, 2);
566
+ const history = getHistory(dir, 20);
566
567
  if (history.length < 2) return null;
567
568
 
568
569
  const currentPayload = loadSnapshotPayload(dir, history[0]);
@@ -627,15 +628,20 @@ function checkHealth(dir) {
627
628
  }
628
629
  }
629
630
 
631
+ // Per-check pass rates across all snapshots
632
+ const passRates = computePassRates(dir, history);
633
+
630
634
  return {
631
635
  currentDate: history[0].createdAt,
632
636
  previousDate: history[1].createdAt,
637
+ snapshotsAnalyzed: history.length,
633
638
  scoreDelta: (currentPayload.score || 0) - (previousPayload.score || 0),
634
639
  regressions,
635
640
  improvements,
636
641
  newChecks,
637
642
  removedChecks,
638
643
  platformAlerts,
644
+ passRates,
639
645
  summary: {
640
646
  regressionsCount: regressions.length,
641
647
  improvementsCount: improvements.length,
@@ -646,6 +652,66 @@ function checkHealth(dir) {
646
652
  };
647
653
  }
648
654
 
655
+ /**
656
+ * Compute per-check pass rates across all snapshots.
657
+ * Returns { declining, consistentlyFailing, consistentlyPassing, overallHealth }.
658
+ */
659
+ function computePassRates(dir, history) {
660
+ // key → { passes, total, recentResults: [bool...] (newest first) }
661
+ const stats = {};
662
+ for (const entry of history) {
663
+ const payload = loadSnapshotPayload(dir, entry);
664
+ if (!payload || !payload.results) continue;
665
+ for (const r of payload.results) {
666
+ if (!r.key || r.passed === null || r.passed === undefined) continue;
667
+ if (!stats[r.key]) stats[r.key] = { name: r.name, passes: 0, total: 0, recentResults: [] };
668
+ stats[r.key].total++;
669
+ if (r.passed) stats[r.key].passes++;
670
+ stats[r.key].recentResults.push(!!r.passed);
671
+ }
672
+ }
673
+
674
+ const declining = [];
675
+ const consistentlyFailing = [];
676
+ let consistentlyPassingCount = 0;
677
+ let totalChecks = 0;
678
+ let totalPasses = 0;
679
+ let totalAppearances = 0;
680
+
681
+ for (const [key, s] of Object.entries(stats)) {
682
+ const rate = s.total > 0 ? s.passes / s.total : 0;
683
+ totalChecks++;
684
+ totalPasses += s.passes;
685
+ totalAppearances += s.total;
686
+
687
+ if (s.total >= 2 && rate === 0) {
688
+ consistentlyFailing.push({ key, name: s.name, runs: s.total });
689
+ } else if (rate === 1) {
690
+ consistentlyPassingCount++;
691
+ } else if (s.total >= 2) {
692
+ // Check if declining: earlier results passed, recent ones failed
693
+ const half = Math.ceil(s.recentResults.length / 2);
694
+ const recentHalf = s.recentResults.slice(0, half);
695
+ const olderHalf = s.recentResults.slice(half);
696
+ const recentRate = recentHalf.filter(Boolean).length / recentHalf.length;
697
+ const olderRate = olderHalf.length > 0 ? olderHalf.filter(Boolean).length / olderHalf.length : recentRate;
698
+ if (olderRate > recentRate) {
699
+ const failStreak = s.recentResults.findIndex(v => v === true);
700
+ declining.push({
701
+ key, name: s.name,
702
+ oldRate: Math.round(olderRate * 100),
703
+ newRate: Math.round(recentRate * 100),
704
+ failingRuns: failStreak === -1 ? s.recentResults.length : failStreak,
705
+ });
706
+ }
707
+ }
708
+ }
709
+
710
+ const overallHealth = totalAppearances > 0 ? Math.round((totalPasses / totalAppearances) * 100) : 100;
711
+
712
+ return { declining, consistentlyFailing, consistentlyPassingCount, overallHealth };
713
+ }
714
+
649
715
  /**
650
716
  * Format check-health report for CLI display.
651
717
  */
@@ -653,11 +719,12 @@ function formatCheckHealth(healthReport) {
653
719
  if (!healthReport) return 'Need at least 2 audit snapshots. Run `nerviq audit --snapshot` twice.';
654
720
 
655
721
  const lines = [];
656
- const { scoreDelta, regressions, improvements, platformAlerts, newChecks, summary } = healthReport;
722
+ const { scoreDelta, regressions, improvements, platformAlerts, newChecks, passRates } = healthReport;
657
723
  const sign = scoreDelta >= 0 ? '+' : '';
658
724
 
659
725
  lines.push(` Check Health Report`);
660
- lines.push(` ─────────────────────────────────────`);
726
+ lines.push(` ═══════════════════════════════════════`);
727
+ lines.push(` Snapshots analyzed: ${healthReport.snapshotsAnalyzed}`);
661
728
  lines.push(` Period: ${healthReport.previousDate?.split('T')[0]} → ${healthReport.currentDate?.split('T')[0]}`);
662
729
  lines.push(` Score delta: ${sign}${scoreDelta}`);
663
730
  lines.push('');
@@ -671,6 +738,23 @@ function formatCheckHealth(healthReport) {
671
738
  lines.push('');
672
739
  }
673
740
 
741
+ if (passRates && passRates.declining.length > 0) {
742
+ lines.push(` Checks with declining pass rate:`);
743
+ for (const d of passRates.declining) {
744
+ const detail = d.failingRuns > 0 ? `(failing in last ${d.failingRuns} runs)` : '';
745
+ lines.push(` ⚠ ${d.key.padEnd(22)} ${d.oldRate}% → ${d.newRate}% ${detail}`);
746
+ }
747
+ lines.push('');
748
+ }
749
+
750
+ if (passRates && passRates.consistentlyFailing.length > 0) {
751
+ lines.push(` Consistently failing (0% pass rate):`);
752
+ for (const f of passRates.consistentlyFailing) {
753
+ lines.push(` ✗ ${f.key.padEnd(22)} 0/${f.runs} runs`);
754
+ }
755
+ lines.push('');
756
+ }
757
+
674
758
  if (regressions.length > 0) {
675
759
  lines.push(` 🔴 Regressions (${regressions.length} checks now failing)`);
676
760
  for (const r of regressions) {
@@ -692,11 +776,21 @@ function formatCheckHealth(healthReport) {
692
776
  lines.push('');
693
777
  }
694
778
 
779
+ if (passRates && passRates.consistentlyPassingCount > 0) {
780
+ lines.push(` Consistently passing (100%):`);
781
+ lines.push(` ✓ ${passRates.consistentlyPassingCount} checks at 100% pass rate`);
782
+ lines.push('');
783
+ }
784
+
695
785
  if (regressions.length === 0 && platformAlerts.length === 0) {
696
786
  lines.push(` ✅ All checks stable. No regressions detected.`);
697
787
  lines.push('');
698
788
  }
699
789
 
790
+ if (passRates) {
791
+ lines.push(` Overall health: ${passRates.overallHealth}%`);
792
+ }
793
+
700
794
  return lines.join('\n');
701
795
  }
702
796
 
package/src/audit.js CHANGED
@@ -953,9 +953,13 @@ function printLiteAudit(result, dir) {
953
953
 
954
954
  console.log(colorize(' Top 3 things to fix right now:', 'magenta'));
955
955
  console.log('');
956
+ let usagePatterns;
957
+ try { usagePatterns = require('./usage-patterns'); } catch { usagePatterns = null; }
956
958
  result.liteSummary.topNextActions.forEach((item, index) => {
957
959
  const tier = item.impact === 'critical' ? '🔴' : item.impact === 'high' ? '🟡' : '🔵';
958
- console.log(` ${index + 1}. ${tier} ${colorize(item.name, 'bold')}`);
960
+ const suppressed = usagePatterns && usagePatterns.getPriorityAdjustment(dir, item.key) === 'suppress';
961
+ const suffix = suppressed ? colorize(' (suppressed)', 'dim') : '';
962
+ console.log(` ${index + 1}. ${tier} ${colorize(item.name, 'bold')}${suffix}`);
959
963
  console.log(colorize(` ${item.fix}`, 'dim'));
960
964
  });
961
965
  console.log('');
@@ -0,0 +1,101 @@
1
+ const { loadPatterns } = require('./usage-patterns');
2
+ const { readSnapshotIndex } = require('./activity');
3
+
4
+ const MIN_EVENTS = 2;
5
+ const SUPPRESS_THRESHOLD = 3;
6
+ const RECENT_AUDITS = 10;
7
+
8
+ /**
9
+ * Analyze usage patterns and audit history to suggest rules.
10
+ */
11
+ function analyzeSuggestions(dir) {
12
+ const patterns = loadPatterns(dir);
13
+ const snapshots = readSnapshotIndex(dir);
14
+
15
+ const keys = Object.keys(patterns);
16
+ const totalEvents = keys.reduce((sum, k) => {
17
+ const e = patterns[k];
18
+ return sum + e.accepted + e.rejected + e.skipped;
19
+ }, 0);
20
+
21
+ // Checks always accepted -> suggest as required
22
+ const suggestedRules = keys
23
+ .filter(k => {
24
+ const e = patterns[k];
25
+ return e.accepted >= MIN_EVENTS && e.rejected === 0;
26
+ })
27
+ .map(k => ({ key: k, accepted: patterns[k].accepted, total: patterns[k].accepted }));
28
+
29
+ // Checks always rejected -> suggest suppressing
30
+ const suggestedSuppressions = keys
31
+ .filter(k => {
32
+ const e = patterns[k];
33
+ return e.rejected >= SUPPRESS_THRESHOLD && e.accepted === 0;
34
+ })
35
+ .map(k => ({ key: k, rejected: patterns[k].rejected, total: patterns[k].rejected }));
36
+
37
+ // From audit snapshots: checks that repeatedly appear in topActionKeys (failing)
38
+ const auditSnapshots = snapshots
39
+ .filter(s => s.snapshotKind === 'audit' && s.summary && Array.isArray(s.summary.topActionKeys))
40
+ .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
41
+ .slice(0, RECENT_AUDITS);
42
+
43
+ const failCounts = {};
44
+ for (const snap of auditSnapshots) {
45
+ for (const key of snap.summary.topActionKeys) {
46
+ failCounts[key] = (failCounts[key] || 0) + 1;
47
+ }
48
+ }
49
+
50
+ const suggestedPriorities = Object.entries(failCounts)
51
+ .filter(([, count]) => count >= 2)
52
+ .sort((a, b) => b[1] - a[1])
53
+ .map(([key, count]) => ({ key, failCount: count, auditCount: auditSnapshots.length }));
54
+
55
+ return { totalEvents, auditCount: auditSnapshots.length, suggestedRules, suggestedSuppressions, suggestedPriorities };
56
+ }
57
+
58
+ /**
59
+ * Format suggestions for CLI output.
60
+ */
61
+ function formatSuggestions(suggestions) {
62
+ const { totalEvents, auditCount, suggestedRules, suggestedSuppressions, suggestedPriorities } = suggestions;
63
+
64
+ if (totalEvents === 0 && auditCount === 0) {
65
+ return ' No usage data yet. Run nerviq fix or nerviq audit to build pattern history.';
66
+ }
67
+
68
+ const sources = [];
69
+ if (totalEvents > 0) sources.push(`${totalEvents} pattern events`);
70
+ if (auditCount > 0) sources.push(`${auditCount} audit snapshots`);
71
+ const lines = [` Auto-Suggested Rules (based on ${sources.join(', ')}):`];
72
+
73
+ if (suggestedRules.length > 0) {
74
+ lines.push('', ' Suggested as required (always accepted):');
75
+ for (const r of suggestedRules) {
76
+ lines.push(` + ${r.key.padEnd(20)} — accepted ${r.accepted}/${r.total} times, consider making mandatory`);
77
+ }
78
+ }
79
+
80
+ if (suggestedSuppressions.length > 0) {
81
+ lines.push('', ' Suggested to suppress (always rejected):');
82
+ for (const s of suggestedSuppressions) {
83
+ lines.push(` - ${s.key.padEnd(20)} — rejected ${s.rejected}/${s.total} times, may not fit your workflow`);
84
+ }
85
+ }
86
+
87
+ if (suggestedPriorities.length > 0) {
88
+ lines.push('', ' Priority focus (failing repeatedly):');
89
+ for (const p of suggestedPriorities) {
90
+ lines.push(` ! ${p.key.padEnd(20)} — failed in ${p.failCount}/${p.auditCount} recent audits`);
91
+ }
92
+ }
93
+
94
+ if (suggestedRules.length === 0 && suggestedSuppressions.length === 0 && suggestedPriorities.length === 0) {
95
+ lines.push('', ' No strong patterns detected yet. Keep using nerviq fix and audit to build history.');
96
+ }
97
+
98
+ return lines.join('\n');
99
+ }
100
+
101
+ module.exports = { analyzeSuggestions, formatSuggestions };
@@ -0,0 +1,122 @@
1
+ /**
2
+ * AI-generated fix prompts for checks without template auto-fixes.
3
+ * Each key maps to a check key from techniques.js.
4
+ * These prompts are designed to be copy-pasted into an AI coding agent.
5
+ */
6
+
7
+ const FIX_PROMPTS = {
8
+ importSyntax:
9
+ 'Refactor CLAUDE.md to use @path imports for modularity. Split large sections into separate files (e.g. @docs/coding-style.md, @docs/architecture.md) and reference them with @path syntax. Also consider using .claude/rules/ for path-specific rules.',
10
+
11
+ underlines200:
12
+ 'Refactor CLAUDE.md to be under 200 lines. Move detailed sections into separate files using @import or .claude/rules/ for path-specific rules. Keep only essential project overview, build commands, and key conventions in the main file.',
13
+
14
+ verificationLoop:
15
+ 'Add a verification section to CLAUDE.md with commands Claude should run after making changes. Include test, lint, and build commands. Example:\n\n## Verification\nAfter every change, run:\n- `npm test` to verify tests pass\n- `npm run lint` to check code style\n- `npm run build` to verify compilation',
16
+
17
+ testCommand:
18
+ 'Add an explicit test command to CLAUDE.md. Example: "Run `npm test` before committing." or "Run `pytest` to verify changes." Place it in a ## Commands or ## Verification section.',
19
+
20
+ lintCommand:
21
+ 'Add a lint command to CLAUDE.md so the AI agent auto-checks code style. Example: "Run `npm run lint` or `eslint .` before committing." Place it in a ## Commands section.',
22
+
23
+ buildCommand:
24
+ 'Add a build command to CLAUDE.md so the AI agent can verify compilation. Example: "Run `npm run build` or `tsc` to verify the project compiles." Place it in a ## Commands section.',
25
+
26
+ settingsPermissions:
27
+ 'Create or update .claude/settings.json with permission configuration. Add "permissions": { "allow": ["Read", "Write src/**"], "deny": ["Write .env", "Write **/secrets/**"] } to control which tools and paths the AI agent can access.',
28
+
29
+ permissionDeny:
30
+ 'Add deny rules to .claude/settings.json under permissions.deny to block dangerous operations. Example entries: "rm -rf /", "DROP TABLE", "Write .env", "Write **/*.pem", "Write **/secrets/**".',
31
+
32
+ noBypassPermissions:
33
+ 'Remove bypassPermissions from your .claude/settings.json defaultMode. Instead, use explicit allow rules in permissions.allow to grant only the access needed.',
34
+
35
+ secretsProtection:
36
+ 'Add permissions.deny rules in .claude/settings.json to block reading sensitive files. Add entries like: ".env", ".env.*", "**/.env", "**/*.pem", "**/secrets/**" to the deny array.',
37
+
38
+ securityReview:
39
+ 'Add a /security-review command or mention security review in CLAUDE.md. Create .claude/commands/security-review.md with: "Review the codebase for OWASP Top 10 vulnerabilities. Check for: SQL injection, XSS, CSRF, insecure dependencies, hardcoded secrets, and misconfigured permissions."',
40
+
41
+ preToolUseHook:
42
+ 'Add a PreToolUse hook in .claude/settings.json to validate tool calls before execution. Example: add a hook that blocks writes to protected files or validates file paths. See hooks documentation for the event schema.',
43
+
44
+ postToolUseHook:
45
+ 'Add a PostToolUse hook in .claude/settings.json for automated actions after tool calls. Example: auto-run linting after file writes, or validate output format after code generation.',
46
+
47
+ sessionStartHook:
48
+ 'Add a SessionStart hook in .claude/settings.json for initialization tasks. Example: load project state, rotate logs, or display a welcome message with project status at the start of each session.',
49
+
50
+ deployCommand:
51
+ 'Create .claude/commands/deploy.md with deployment instructions. Include: pre-deploy checks (tests, lint, build), deployment steps for your platform (Vercel, AWS, etc.), and post-deploy verification.',
52
+
53
+ reviewCommand:
54
+ 'Create .claude/commands/review.md with code review instructions. Include: check for security issues, verify test coverage, review naming conventions, check for code duplication, and validate error handling.',
55
+
56
+ compactionAwareness:
57
+ 'Add compaction guidance to CLAUDE.md. Add a line like: "Run /compact when context gets heavy or before large operations." This helps the AI agent manage its context window effectively.',
58
+
59
+ contextManagement:
60
+ 'Add context management tips to CLAUDE.md. Include: "Use /compact proactively at 70% capacity. Prefer targeted file reads over broad searches. Keep conversation focused on one task at a time."',
61
+
62
+ mcpServers:
63
+ 'Create .mcp.json at the project root to configure MCP servers. Example:\n{\n "mcpServers": {\n "memory": { "command": "npx", "args": ["-y", "@anthropic/mcp-memory"] }\n }\n}\nUse `claude mcp add <name>` to add servers interactively.',
64
+
65
+ context7Mcp:
66
+ 'Add the Context7 MCP server for real-time documentation lookup. Add to .mcp.json:\n"context7": { "command": "npx", "args": ["-y", "@anthropic/context7-mcp"] }\nThis provides always-up-to-date library documentation.',
67
+
68
+ xmlTags:
69
+ 'Add XML-tagged sections to CLAUDE.md for structured rules. Wrap critical rules in tags like <constraints>, <validation>, or <rules>. Example:\n<constraints>\n- Never modify package-lock.json manually\n- Always run tests before committing\n</constraints>',
70
+
71
+ fewShotExamples:
72
+ 'Add code examples to CLAUDE.md showing preferred patterns. Include 2-3 examples of your coding style: naming conventions, error handling patterns, file structure. Use fenced code blocks with the appropriate language tag.',
73
+
74
+ roleDefinition:
75
+ 'Add a role definition to the top of CLAUDE.md. Example: "You are a senior backend engineer working on a Node.js microservices platform. Prioritize type safety, comprehensive error handling, and test coverage."',
76
+
77
+ constraintBlocks:
78
+ 'Add XML constraint blocks to CLAUDE.md for critical rules. Wrap must-follow rules in <constraints> tags for ~40% better adherence. Example:\n<constraints>\n- Never delete database migrations\n- Always use parameterized queries\n- Run the full test suite before committing\n</constraints>',
79
+
80
+ readme:
81
+ 'Create a README.md with: project name and description, installation/setup instructions, usage examples, configuration options, and contribution guidelines.',
82
+
83
+ changelog:
84
+ 'Create a CHANGELOG.md following Keep a Changelog format. Include sections: Added, Changed, Deprecated, Removed, Fixed, Security. Start with your current version.',
85
+
86
+ contributing:
87
+ 'Create a CONTRIBUTING.md with: how to set up the dev environment, coding standards and style guide, pull request process, issue reporting guidelines, and code of conduct reference.',
88
+
89
+ editorconfig:
90
+ 'Create a .editorconfig file at the project root with consistent formatting rules:\n[*]\nindent_style = space\nindent_size = 2\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true',
91
+
92
+ ciPipeline:
93
+ 'Add a CI pipeline for automated testing. For GitHub Actions, create .github/workflows/ci.yml with steps: checkout, setup Node/Python, install dependencies, run lint, run tests, run build.',
94
+
95
+ dockerfile:
96
+ 'Create a Dockerfile for the project. Use a multi-stage build: stage 1 installs dependencies and builds, stage 2 copies only production artifacts. Use a slim base image and set a non-root user.',
97
+
98
+ noSecretsInClaude:
99
+ 'Remove any API keys, tokens, or secrets from CLAUDE.md. Replace them with environment variable references (e.g. $API_KEY or process.env.API_KEY). Store actual values in .env files that are gitignored.',
100
+ };
101
+
102
+ /**
103
+ * Format a fix prompt for display in the terminal.
104
+ */
105
+ function formatFixPrompt(key, prompt) {
106
+ const divider = '\u2500'.repeat(38);
107
+ const lines = [
108
+ '',
109
+ ` No auto-fix for '${key}'. Here's a prompt for your AI agent:`,
110
+ '',
111
+ ` ${divider}`,
112
+ ];
113
+ for (const line of prompt.split('\n')) {
114
+ lines.push(` ${line}`);
115
+ }
116
+ lines.push(` ${divider}`);
117
+ lines.push('');
118
+ lines.push(' Copy and paste this into Claude Code, Cursor, or your preferred AI agent.');
119
+ return lines.join('\n');
120
+ }
121
+
122
+ module.exports = { FIX_PROMPTS, formatFixPrompt };
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Team profiles — save and share org-specific check weights and preferences.
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+
8
+ const PROFILES_DIR = 'profiles';
9
+
10
+ function profilesDir(dir) {
11
+ return path.join(dir, '.nerviq', PROFILES_DIR);
12
+ }
13
+
14
+ function profilePath(dir, name) {
15
+ return path.join(profilesDir(dir), `${name}.json`);
16
+ }
17
+
18
+ function validateProfileName(name) {
19
+ if (!name || typeof name !== 'string') throw new Error('Profile name is required.');
20
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
21
+ throw new Error(`Invalid profile name '${name}'. Use only letters, numbers, hyphens, and underscores.`);
22
+ }
23
+ if (name.length > 64) throw new Error('Profile name must be 64 characters or fewer.');
24
+ }
25
+
26
+ function saveProfile(dir, profileName, options = {}) {
27
+ validateProfileName(profileName);
28
+ const profileDir = profilesDir(dir);
29
+ fs.mkdirSync(profileDir, { recursive: true });
30
+
31
+ const profile = {
32
+ name: profileName,
33
+ created: new Date().toISOString().split('T')[0],
34
+ platforms: options.platforms || ['claude'],
35
+ threshold: options.threshold != null ? Number(options.threshold) : 70,
36
+ suppressedChecks: options.suppressedChecks || [],
37
+ priorityBoosts: options.priorityBoosts || [],
38
+ customWeights: options.customWeights || {},
39
+ description: options.description || '',
40
+ };
41
+
42
+ const filePath = profilePath(dir, profileName);
43
+ fs.writeFileSync(filePath, JSON.stringify(profile, null, 2), 'utf8');
44
+ return { saved: true, path: filePath, profile };
45
+ }
46
+
47
+ function loadProfile(dir, profileName) {
48
+ validateProfileName(profileName);
49
+ const filePath = profilePath(dir, profileName);
50
+ if (!fs.existsSync(filePath)) {
51
+ throw new Error(`Profile '${profileName}' not found. Run 'nerviq profile list' to see available profiles.`);
52
+ }
53
+ const raw = fs.readFileSync(filePath, 'utf8');
54
+ let profile;
55
+ try {
56
+ profile = JSON.parse(raw);
57
+ } catch {
58
+ throw new Error(`Profile '${profileName}' contains invalid JSON.`);
59
+ }
60
+ return profile;
61
+ }
62
+
63
+ function listProfiles(dir) {
64
+ const profDir = profilesDir(dir);
65
+ if (!fs.existsSync(profDir)) return [];
66
+ return fs.readdirSync(profDir)
67
+ .filter(f => f.endsWith('.json'))
68
+ .map(f => {
69
+ const name = f.replace(/\.json$/, '');
70
+ try {
71
+ const data = JSON.parse(fs.readFileSync(path.join(profDir, f), 'utf8'));
72
+ return { name, description: data.description || '', platforms: data.platforms || [], threshold: data.threshold };
73
+ } catch {
74
+ return { name, description: '(invalid)', platforms: [], threshold: null };
75
+ }
76
+ });
77
+ }
78
+
79
+ function exportProfile(dir, profileName) {
80
+ const profile = loadProfile(dir, profileName);
81
+ return JSON.stringify(profile, null, 2);
82
+ }
83
+
84
+ function applyProfileToOptions(profile, options) {
85
+ const merged = { ...options };
86
+ if (profile.threshold != null && merged.threshold == null) {
87
+ merged.threshold = profile.threshold;
88
+ }
89
+ if (profile.platforms && profile.platforms.length > 0 && !options.platform) {
90
+ merged.platform = profile.platforms[0];
91
+ }
92
+ merged.suppressedChecks = profile.suppressedChecks || [];
93
+ merged.priorityBoosts = profile.priorityBoosts || [];
94
+ merged.customWeights = profile.customWeights || {};
95
+ return merged;
96
+ }
97
+
98
+ function formatProfileList(profiles) {
99
+ if (profiles.length === 0) return ' No profiles found. Create one with: nerviq profile save <name>';
100
+ const lines = profiles.map(p => {
101
+ const desc = p.description ? ` — ${p.description}` : '';
102
+ const plats = p.platforms.length > 0 ? ` [${p.platforms.join(', ')}]` : '';
103
+ return ` ${p.name}${plats}${desc}`;
104
+ });
105
+ return lines.join('\n');
106
+ }
107
+
108
+ function formatProfile(profile) {
109
+ const lines = [
110
+ ` Name: ${profile.name}`,
111
+ ` Created: ${profile.created || 'unknown'}`,
112
+ ` Platforms: ${(profile.platforms || []).join(', ') || 'any'}`,
113
+ ` Threshold: ${profile.threshold != null ? profile.threshold : 'default'}`,
114
+ ` Suppressed: ${(profile.suppressedChecks || []).join(', ') || 'none'}`,
115
+ ` Boosted: ${(profile.priorityBoosts || []).join(', ') || 'none'}`,
116
+ ` Description: ${profile.description || '(none)'}`,
117
+ ];
118
+ return lines.join('\n');
119
+ }
120
+
121
+ module.exports = { saveProfile, loadProfile, listProfiles, exportProfile, applyProfileToOptions, formatProfileList, formatProfile, validateProfileName };
@@ -0,0 +1,99 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { ensureProjectStateDir, resolveProjectStateReadPath } = require('./state-paths');
4
+
5
+ const PATTERNS_FILE = 'patterns.json';
6
+ const SUPPRESS_THRESHOLD = 3;
7
+
8
+ function patternsPath(dir, writable) {
9
+ if (writable) {
10
+ const feedbackDir = ensureProjectStateDir(dir, 'feedback');
11
+ return path.join(feedbackDir, PATTERNS_FILE);
12
+ }
13
+ const feedbackDir = resolveProjectStateReadPath(dir, 'feedback');
14
+ return path.join(feedbackDir, PATTERNS_FILE);
15
+ }
16
+
17
+ function loadPatterns(dir) {
18
+ const filePath = patternsPath(dir, false);
19
+ if (!fs.existsSync(filePath)) return {};
20
+ try {
21
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
22
+ } catch {
23
+ return {};
24
+ }
25
+ }
26
+
27
+ function savePatterns(dir, patterns) {
28
+ const filePath = patternsPath(dir, true);
29
+ fs.writeFileSync(filePath, JSON.stringify(patterns, null, 2), 'utf8');
30
+ }
31
+
32
+ function recordPattern(dir, checkKey, action) {
33
+ if (!['accepted', 'rejected', 'skipped'].includes(action)) return;
34
+ const patterns = loadPatterns(dir);
35
+ if (!patterns[checkKey]) {
36
+ patterns[checkKey] = { accepted: 0, rejected: 0, skipped: 0, lastAction: null, lastDate: null };
37
+ }
38
+ patterns[checkKey][action] += 1;
39
+ patterns[checkKey].lastAction = action;
40
+ patterns[checkKey].lastDate = new Date().toISOString();
41
+ savePatterns(dir, patterns);
42
+ }
43
+
44
+ function getPriorityAdjustment(dir, checkKey) {
45
+ const patterns = loadPatterns(dir);
46
+ const entry = patterns[checkKey];
47
+ if (!entry) return null;
48
+ const total = entry.accepted + entry.rejected;
49
+ if (total < 2) return null;
50
+ if (entry.accepted > 0 && entry.rejected === 0) return 'boost';
51
+ if (entry.rejected >= SUPPRESS_THRESHOLD && entry.accepted === 0) return 'suppress';
52
+ return null;
53
+ }
54
+
55
+ function getUsageSummary(dir) {
56
+ const patterns = loadPatterns(dir);
57
+ const keys = Object.keys(patterns);
58
+ const totalEvents = keys.reduce((sum, k) => {
59
+ const e = patterns[k];
60
+ return sum + e.accepted + e.rejected + e.skipped;
61
+ }, 0);
62
+
63
+ const withRates = keys
64
+ .filter(k => (patterns[k].accepted + patterns[k].rejected) > 0)
65
+ .map(k => {
66
+ const e = patterns[k];
67
+ const total = e.accepted + e.rejected;
68
+ return { key: k, accepted: e.accepted, rejected: e.rejected, total, rate: total > 0 ? e.accepted / total : 0 };
69
+ });
70
+
71
+ withRates.sort((a, b) => b.rate - a.rate || b.total - a.total);
72
+ const topAccepted = withRates.filter(e => e.rate > 0).slice(0, 5);
73
+ const topRejected = withRates.filter(e => e.rate < 1).sort((a, b) => a.rate - b.rate || b.total - a.total).slice(0, 5);
74
+
75
+ return { totalEvents, topAccepted, topRejected, patterns };
76
+ }
77
+
78
+ function formatUsageSummary(dir) {
79
+ const summary = getUsageSummary(dir);
80
+ if (summary.totalEvents === 0) return ' No usage patterns recorded yet.\n Patterns are tracked when you run nerviq fix.';
81
+
82
+ const lines = [` Usage Patterns (${summary.totalEvents} events recorded):`];
83
+ if (summary.topAccepted.length > 0) {
84
+ lines.push('', ' Most accepted:');
85
+ summary.topAccepted.forEach((e, i) => {
86
+ lines.push(` ${i + 1}. ${e.key.padEnd(20)} ${e.accepted}/${e.total} (${Math.round(e.rate * 100)}%)`);
87
+ });
88
+ }
89
+ if (summary.topRejected.length > 0) {
90
+ lines.push('', ' Most rejected:');
91
+ summary.topRejected.forEach((e, i) => {
92
+ const hint = e.rate === 0 && e.total >= SUPPRESS_THRESHOLD ? ' -- consider suppressing' : '';
93
+ lines.push(` ${i + 1}. ${e.key.padEnd(20)} ${e.accepted}/${e.total} (${Math.round(e.rate * 100)}%)${hint}`);
94
+ });
95
+ }
96
+ return lines.join('\n');
97
+ }
98
+
99
+ module.exports = { loadPatterns, recordPattern, getPriorityAdjustment, getUsageSummary, formatUsageSummary };