@nerviq/cli 0.9.3 → 0.9.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/src/doctor.js ADDED
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Nerviq Doctor
3
+ *
4
+ * Self-diagnostics for the nerviq CLI and the current project environment.
5
+ * Checks: Node version, dependencies, platform detection, freshness gates.
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { version } = require('../package.json');
13
+
14
+ const COLORS = {
15
+ reset: '\x1b[0m',
16
+ bold: '\x1b[1m',
17
+ dim: '\x1b[2m',
18
+ red: '\x1b[31m',
19
+ green: '\x1b[32m',
20
+ yellow: '\x1b[33m',
21
+ blue: '\x1b[36m',
22
+ };
23
+
24
+ function c(text, color) {
25
+ return `${COLORS[color] || ''}${text}${COLORS.reset}`;
26
+ }
27
+
28
+ const PLATFORM_SIGNALS = {
29
+ claude: ['CLAUDE.md', '.claude/CLAUDE.md', '.claude/settings.json'],
30
+ codex: ['AGENTS.md', '.codex/', '.codex/config.toml'],
31
+ cursor: ['.cursor/rules/', '.cursor/mcp.json', '.cursorrules'],
32
+ copilot: ['.github/copilot-instructions.md', '.github/'],
33
+ gemini: ['GEMINI.md', '.gemini/', '.gemini/settings.json'],
34
+ windsurf: ['.windsurf/', '.windsurfrules', '.windsurf/rules/'],
35
+ aider: ['.aider.conf.yml', '.aider.model.settings.yml'],
36
+ opencode: ['opencode.json', '.opencode/'],
37
+ };
38
+
39
+ const FRESHNESS_MODULES = {
40
+ claude: './freshness',
41
+ codex: './codex/freshness',
42
+ cursor: './cursor/freshness',
43
+ copilot: './copilot/freshness',
44
+ gemini: './gemini/freshness',
45
+ windsurf: './windsurf/freshness',
46
+ aider: './aider/freshness',
47
+ opencode: './opencode/freshness',
48
+ };
49
+
50
+ // ─── Individual checks ───────────────────────────────────────────────────────
51
+
52
+ function checkNodeVersion() {
53
+ const raw = process.version.replace('v', '');
54
+ const [major] = raw.split('.').map(Number);
55
+ const ok = major >= 18;
56
+ return {
57
+ label: 'Node.js version',
58
+ status: ok ? 'pass' : 'fail',
59
+ detail: `${process.version} (${ok ? 'meets' : 'below'} minimum v18)`,
60
+ fix: ok ? null : 'Upgrade Node.js to v18 or later: https://nodejs.org',
61
+ };
62
+ }
63
+
64
+ function checkDeps() {
65
+ const pkgPath = path.join(__dirname, '..', 'node_modules');
66
+ const exists = fs.existsSync(pkgPath);
67
+ return {
68
+ label: 'node_modules installed',
69
+ status: exists ? 'pass' : 'fail',
70
+ detail: exists ? `${pkgPath}` : 'node_modules missing',
71
+ fix: exists ? null : 'Run: npm install',
72
+ };
73
+ }
74
+
75
+ function checkJestInstalled() {
76
+ const jestPath = path.join(__dirname, '..', 'node_modules', 'jest', 'package.json');
77
+ const exists = fs.existsSync(jestPath);
78
+ let jestVersion = null;
79
+ if (exists) {
80
+ try {
81
+ jestVersion = require(jestPath).version;
82
+ } catch {}
83
+ }
84
+ return {
85
+ label: 'Jest test runner',
86
+ status: exists ? 'pass' : 'warn',
87
+ detail: exists ? `jest@${jestVersion}` : 'jest not found in node_modules',
88
+ fix: exists ? null : 'Run: npm install --save-dev jest',
89
+ };
90
+ }
91
+
92
+ function checkPlatformDetection(dir) {
93
+ const detected = [];
94
+ for (const [platform, signals] of Object.entries(PLATFORM_SIGNALS)) {
95
+ for (const signal of signals) {
96
+ const signalPath = path.join(dir, signal);
97
+ if (fs.existsSync(signalPath)) {
98
+ detected.push(platform);
99
+ break;
100
+ }
101
+ }
102
+ }
103
+
104
+ return {
105
+ label: 'Platform detection',
106
+ status: detected.length > 0 ? 'pass' : 'warn',
107
+ detail: detected.length > 0
108
+ ? `Detected: ${detected.join(', ')}`
109
+ : 'No platform config files found in current directory',
110
+ detected,
111
+ fix: detected.length === 0
112
+ ? 'Run `nerviq setup` to generate baseline config files for your platform'
113
+ : null,
114
+ };
115
+ }
116
+
117
+ function checkFreshnessGates() {
118
+ const results = [];
119
+ for (const [platform, modulePath] of Object.entries(FRESHNESS_MODULES)) {
120
+ try {
121
+ const freshness = require(modulePath);
122
+ const gate = freshness.checkReleaseGate({});
123
+ results.push({
124
+ platform,
125
+ status: gate.stale.length === 0 ? 'pass' : 'warn',
126
+ fresh: gate.fresh.length,
127
+ total: gate.results.length,
128
+ stale: gate.stale.length,
129
+ detail: gate.stale.length === 0
130
+ ? `All ${gate.results.length} P0 sources fresh`
131
+ : `${gate.stale.length}/${gate.results.length} P0 sources unverified/stale`,
132
+ });
133
+ } catch (e) {
134
+ results.push({ platform, status: 'error', detail: e.message });
135
+ }
136
+ }
137
+ return results;
138
+ }
139
+
140
+ function checkCliPermissions() {
141
+ const cliBin = path.join(__dirname, '..', 'bin', 'cli.js');
142
+ const exists = fs.existsSync(cliBin);
143
+ if (!exists) {
144
+ return { label: 'CLI binary (bin/cli.js)', status: 'fail', detail: 'bin/cli.js not found', fix: null };
145
+ }
146
+ return { label: 'CLI binary (bin/cli.js)', status: 'pass', detail: cliBin, fix: null };
147
+ }
148
+
149
+ function checkGitRepo(dir) {
150
+ const gitPath = path.join(dir, '.git');
151
+ const exists = fs.existsSync(gitPath);
152
+ return {
153
+ label: 'Git repository',
154
+ status: exists ? 'pass' : 'warn',
155
+ detail: exists ? '.git/ found' : 'Not a git repository',
156
+ fix: exists ? null : 'Run: git init (recommended for safety)',
157
+ };
158
+ }
159
+
160
+ // ─── Main doctor function ────────────────────────────────────────────────────
161
+
162
+ async function runDoctor({ dir = process.cwd(), json = false, verbose = false } = {}) {
163
+ const startMs = Date.now();
164
+
165
+ const checks = [
166
+ checkNodeVersion(),
167
+ checkDeps(),
168
+ checkJestInstalled(),
169
+ checkCliPermissions(),
170
+ checkGitRepo(dir),
171
+ checkPlatformDetection(dir),
172
+ ];
173
+
174
+ const freshnessChecks = checkFreshnessGates();
175
+
176
+ const totalPass = checks.filter(c => c.status === 'pass').length;
177
+ const totalWarn = checks.filter(c => c.status === 'warn').length;
178
+ const totalFail = checks.filter(c => c.status === 'fail').length;
179
+
180
+ const freshPass = freshnessChecks.filter(f => f.status === 'pass').length;
181
+ const freshWarn = freshnessChecks.filter(f => f.status !== 'pass').length;
182
+
183
+ const overallOk = totalFail === 0;
184
+ const elapsed = Date.now() - startMs;
185
+
186
+ if (json) {
187
+ return JSON.stringify({
188
+ nerviq: version,
189
+ node: process.version,
190
+ dir,
191
+ overallOk,
192
+ checks,
193
+ freshnessChecks,
194
+ totalPass,
195
+ totalWarn,
196
+ totalFail,
197
+ freshPass,
198
+ freshWarn,
199
+ elapsed,
200
+ }, null, 2);
201
+ }
202
+
203
+ const lines = [''];
204
+ lines.push(c(` nerviq doctor v${version}`, 'bold'));
205
+ lines.push(c(' ═══════════════════════════════════════', 'dim'));
206
+ lines.push('');
207
+
208
+ // Environment checks
209
+ lines.push(c(' Environment', 'bold'));
210
+ for (const chk of checks) {
211
+ const icon = chk.status === 'pass' ? c('✓', 'green') : chk.status === 'warn' ? c('⚠', 'yellow') : c('✗', 'red');
212
+ lines.push(` ${icon} ${chk.label.padEnd(32)} ${c(chk.detail, chk.status === 'pass' ? 'dim' : 'reset')}`);
213
+ if (chk.fix && (verbose || chk.status === 'fail')) {
214
+ lines.push(c(` Fix: ${chk.fix}`, 'yellow'));
215
+ }
216
+ }
217
+
218
+ // Platform detection detail
219
+ const detectedPlatforms = (checks.find(c => c.detected) || {}).detected || [];
220
+ if (detectedPlatforms.length > 0) {
221
+ lines.push('');
222
+ lines.push(c(' Detected Platforms', 'bold'));
223
+ for (const p of detectedPlatforms) {
224
+ lines.push(` ${c('✓', 'green')} ${p}`);
225
+ }
226
+ }
227
+
228
+ // Freshness
229
+ lines.push('');
230
+ lines.push(c(' Freshness Gates', 'bold'));
231
+ for (const f of freshnessChecks) {
232
+ const icon = f.status === 'pass' ? c('✓', 'green') : c('⚠', 'yellow');
233
+ const label = f.platform.padEnd(12);
234
+ lines.push(` ${icon} ${label} ${c(f.detail || f.status, f.status === 'pass' ? 'dim' : 'yellow')}`);
235
+ }
236
+
237
+ lines.push('');
238
+ lines.push(c(' Summary', 'bold'));
239
+ lines.push(` Checks: ${c(String(totalPass), 'green')} pass ${totalWarn > 0 ? c(String(totalWarn), 'yellow') + ' warn ' : ''}${totalFail > 0 ? c(String(totalFail), 'red') + ' fail' : ''}`);
240
+ lines.push(` Freshness: ${c(String(freshPass), 'green')} fresh ${freshWarn > 0 ? c(String(freshWarn), 'yellow') + ' stale/unverified' : ''}`);
241
+ lines.push(` Status: ${overallOk ? c('✓ Healthy', 'green') : c('✗ Issues found', 'red')}`);
242
+ lines.push(` Duration: ${elapsed}ms`);
243
+ lines.push('');
244
+
245
+ if (!overallOk) {
246
+ lines.push(c(' Run with --verbose for fix suggestions.', 'dim'));
247
+ lines.push('');
248
+ }
249
+
250
+ return lines.join('\n');
251
+ }
252
+
253
+ module.exports = { runDoctor };
@@ -0,0 +1,173 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const readline = require('readline');
4
+
5
+ let lastTimestamp = '';
6
+ let counter = 0;
7
+
8
+ function timestampId() {
9
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
10
+ if (ts === lastTimestamp) {
11
+ counter += 1;
12
+ return `${ts}-${counter}`;
13
+ }
14
+ lastTimestamp = ts;
15
+ counter = 0;
16
+ return ts;
17
+ }
18
+
19
+ function ensureFeedbackDir(dir) {
20
+ const feedbackDir = path.join(dir, '.claude', 'claudex-setup', 'feedback');
21
+ fs.mkdirSync(feedbackDir, { recursive: true });
22
+ return feedbackDir;
23
+ }
24
+
25
+ function writeJson(filePath, payload) {
26
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
27
+ fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf8');
28
+ }
29
+
30
+ function saveFeedback(dir, payload) {
31
+ const feedbackDir = ensureFeedbackDir(dir);
32
+ const id = timestampId();
33
+ const keySlug = String(payload.key || 'finding').replace(/[^a-z0-9_-]+/gi, '-').toLowerCase();
34
+ const filePath = path.join(feedbackDir, `${id}-${keySlug}.json`);
35
+ const envelope = {
36
+ schemaVersion: 1,
37
+ id,
38
+ createdAt: new Date().toISOString(),
39
+ ...payload,
40
+ };
41
+ writeJson(filePath, envelope);
42
+ return {
43
+ ...envelope,
44
+ filePath,
45
+ relativePath: path.relative(dir, filePath),
46
+ };
47
+ }
48
+
49
+ function getFeedbackSummary(dir) {
50
+ const feedbackDir = ensureFeedbackDir(dir);
51
+ const files = fs.readdirSync(feedbackDir).filter((name) => name.endsWith('.json'));
52
+ const entries = [];
53
+
54
+ for (const file of files) {
55
+ const filePath = path.join(feedbackDir, file);
56
+ try {
57
+ entries.push(JSON.parse(fs.readFileSync(filePath, 'utf8')));
58
+ } catch {
59
+ // Ignore malformed artifacts so one bad file does not break the summary.
60
+ }
61
+ }
62
+
63
+ const summary = {
64
+ totalEntries: entries.length,
65
+ helpful: 0,
66
+ unhelpful: 0,
67
+ byKey: {},
68
+ relativeDir: path.relative(dir, feedbackDir),
69
+ };
70
+
71
+ for (const entry of entries) {
72
+ const helpful = entry.helpful === true;
73
+ if (helpful) {
74
+ summary.helpful += 1;
75
+ } else if (entry.helpful === false) {
76
+ summary.unhelpful += 1;
77
+ }
78
+
79
+ const bucket = summary.byKey[entry.key] || { total: 0, helpful: 0, unhelpful: 0 };
80
+ bucket.total += 1;
81
+ if (helpful) {
82
+ bucket.helpful += 1;
83
+ } else if (entry.helpful === false) {
84
+ bucket.unhelpful += 1;
85
+ }
86
+ summary.byKey[entry.key] = bucket;
87
+ }
88
+
89
+ return summary;
90
+ }
91
+
92
+ function askQuestion(rl, prompt) {
93
+ return new Promise((resolve) => {
94
+ rl.question(prompt, resolve);
95
+ });
96
+ }
97
+
98
+ async function collectFeedback(dir, options = {}) {
99
+ const findings = Array.isArray(options.findings) ? options.findings : [];
100
+ const stdin = options.stdin || process.stdin;
101
+ const stdout = options.stdout || process.stdout;
102
+
103
+ if (findings.length === 0) {
104
+ return {
105
+ saved: 0,
106
+ skipped: 0,
107
+ helpful: 0,
108
+ unhelpful: 0,
109
+ entries: [],
110
+ relativeDir: path.relative(dir, ensureFeedbackDir(dir)),
111
+ };
112
+ }
113
+
114
+ if (!(stdin.isTTY && stdout.isTTY)) {
115
+ return {
116
+ mode: 'skipped-noninteractive',
117
+ saved: 0,
118
+ skipped: findings.length,
119
+ helpful: 0,
120
+ unhelpful: 0,
121
+ entries: [],
122
+ relativeDir: path.relative(dir, ensureFeedbackDir(dir)),
123
+ };
124
+ }
125
+
126
+ const rl = readline.createInterface({ input: stdin, output: stdout });
127
+ const entries = [];
128
+
129
+ try {
130
+ for (const finding of findings) {
131
+ stdout.write(`\n Feedback for ${finding.name} (${finding.key})\n`);
132
+ let answer = await askQuestion(rl, ' Was this helpful? (y/n) ');
133
+ answer = String(answer || '').trim().toLowerCase();
134
+
135
+ if (!['y', 'yes', 'n', 'no'].includes(answer)) {
136
+ continue;
137
+ }
138
+
139
+ entries.push(saveFeedback(dir, {
140
+ key: finding.key,
141
+ name: finding.name,
142
+ helpful: answer === 'y' || answer === 'yes',
143
+ platform: options.platform || null,
144
+ sourceCommand: options.sourceCommand || 'audit',
145
+ sourceUrl: finding.sourceUrl || null,
146
+ impact: finding.impact || null,
147
+ category: finding.category || null,
148
+ score: Number.isFinite(options.score) ? options.score : null,
149
+ }));
150
+ }
151
+ } finally {
152
+ rl.close();
153
+ }
154
+
155
+ const helpful = entries.filter((entry) => entry.helpful).length;
156
+ const unhelpful = entries.filter((entry) => entry.helpful === false).length;
157
+
158
+ return {
159
+ saved: entries.length,
160
+ skipped: findings.length - entries.length,
161
+ helpful,
162
+ unhelpful,
163
+ entries,
164
+ relativeDir: path.relative(dir, ensureFeedbackDir(dir)),
165
+ summary: getFeedbackSummary(dir),
166
+ };
167
+ }
168
+
169
+ module.exports = {
170
+ collectFeedback,
171
+ saveFeedback,
172
+ getFeedbackSummary,
173
+ };
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Claude Code Freshness Operationalization
3
+ *
4
+ * Release gates, recurring probes, propagation checklists,
5
+ * and staleness blocking for Claude Code surfaces.
6
+ *
7
+ * P0 sources from docs.anthropic.com, propagation for CLAUDE.md format changes.
8
+ */
9
+
10
+ const { version } = require('../package.json');
11
+
12
+ /**
13
+ * P0 sources that must be fresh before any Claude Code release claim.
14
+ */
15
+ const P0_SOURCES = [
16
+ {
17
+ key: 'claude-code-docs',
18
+ label: 'Claude Code Official Docs',
19
+ url: 'https://docs.anthropic.com/claude-code',
20
+ stalenessThresholdDays: 30,
21
+ verifiedAt: null,
22
+ },
23
+ {
24
+ key: 'claude-md-format',
25
+ label: 'CLAUDE.md Format Documentation',
26
+ url: 'https://docs.anthropic.com/claude-code/claude-md',
27
+ stalenessThresholdDays: 30,
28
+ verifiedAt: null,
29
+ },
30
+ {
31
+ key: 'claude-mcp-docs',
32
+ label: 'Claude Code MCP Documentation',
33
+ url: 'https://docs.anthropic.com/claude-code/mcp',
34
+ stalenessThresholdDays: 30,
35
+ verifiedAt: null,
36
+ },
37
+ {
38
+ key: 'claude-hooks-docs',
39
+ label: 'Claude Code Hooks Documentation',
40
+ url: 'https://docs.anthropic.com/claude-code/hooks',
41
+ stalenessThresholdDays: 14,
42
+ verifiedAt: null,
43
+ },
44
+ {
45
+ key: 'claude-security-docs',
46
+ label: 'Claude Code Security Documentation',
47
+ url: 'https://docs.anthropic.com/claude-code/security',
48
+ stalenessThresholdDays: 30,
49
+ verifiedAt: null,
50
+ },
51
+ {
52
+ key: 'claude-permissions-docs',
53
+ label: 'Claude Code Permissions Documentation',
54
+ url: 'https://docs.anthropic.com/claude-code/permissions',
55
+ stalenessThresholdDays: 14,
56
+ verifiedAt: null,
57
+ },
58
+ {
59
+ key: 'claude-settings-docs',
60
+ label: 'Claude Code Settings Documentation',
61
+ url: 'https://docs.anthropic.com/claude-code/settings',
62
+ stalenessThresholdDays: 30,
63
+ verifiedAt: null,
64
+ },
65
+ {
66
+ key: 'anthropic-changelog',
67
+ label: 'Anthropic Changelog',
68
+ url: 'https://docs.anthropic.com/changelog',
69
+ stalenessThresholdDays: 14,
70
+ verifiedAt: null,
71
+ },
72
+ ];
73
+
74
+ /**
75
+ * Propagation checklist: when a Claude Code source changes, these must update.
76
+ */
77
+ const PROPAGATION_CHECKLIST = [
78
+ {
79
+ trigger: 'CLAUDE.md format change (new fields, import syntax, hierarchy change)',
80
+ targets: [
81
+ 'src/context.js — update ProjectContext parsing',
82
+ 'src/techniques.js — update memory/context checks',
83
+ 'src/setup.js — update CLAUDE.md template generation',
84
+ ],
85
+ },
86
+ {
87
+ trigger: 'Hooks system change (event types, exit codes, schema)',
88
+ targets: [
89
+ 'src/governance.js — update hookRegistry',
90
+ 'src/techniques.js — update hooks checks',
91
+ ],
92
+ },
93
+ {
94
+ trigger: 'MCP configuration format change',
95
+ targets: [
96
+ 'src/techniques.js — update MCP checks',
97
+ 'src/mcp-packs.js — update pack projections',
98
+ 'src/context.js — update mcpConfig parsing',
99
+ ],
100
+ },
101
+ {
102
+ trigger: 'Permissions model change (allow/deny lists, operator/user split)',
103
+ targets: [
104
+ 'src/governance.js — update permissionProfiles',
105
+ 'src/techniques.js — update permission checks',
106
+ ],
107
+ },
108
+ ];
109
+
110
+ /**
111
+ * Release gate: check if all P0 sources are within staleness threshold.
112
+ */
113
+ function checkReleaseGate(sourceVerifications = {}) {
114
+ const now = new Date();
115
+ const results = P0_SOURCES.map(source => {
116
+ const verifiedAt = sourceVerifications[source.key]
117
+ ? new Date(sourceVerifications[source.key])
118
+ : source.verifiedAt ? new Date(source.verifiedAt) : null;
119
+
120
+ if (!verifiedAt) {
121
+ return { ...source, status: 'unverified', daysStale: null };
122
+ }
123
+
124
+ const daysSince = Math.floor((now - verifiedAt) / (1000 * 60 * 60 * 24));
125
+ const isStale = daysSince > source.stalenessThresholdDays;
126
+
127
+ return {
128
+ ...source,
129
+ verifiedAt: verifiedAt.toISOString(),
130
+ daysStale: daysSince,
131
+ status: isStale ? 'stale' : 'fresh',
132
+ };
133
+ });
134
+
135
+ return {
136
+ ready: results.every(r => r.status === 'fresh'),
137
+ stale: results.filter(r => r.status === 'stale' || r.status === 'unverified'),
138
+ fresh: results.filter(r => r.status === 'fresh'),
139
+ results,
140
+ };
141
+ }
142
+
143
+ function formatReleaseGate(gateResult) {
144
+ const lines = [
145
+ `Claude Code Freshness Gate (nerviq v${version})`,
146
+ '═══════════════════════════════════════',
147
+ '',
148
+ `Status: ${gateResult.ready ? 'READY' : 'BLOCKED'}`,
149
+ `Fresh: ${gateResult.fresh.length}/${gateResult.results.length}`,
150
+ '',
151
+ ];
152
+
153
+ for (const result of gateResult.results) {
154
+ const icon = result.status === 'fresh' ? '✓' : result.status === 'stale' ? '✗' : '?';
155
+ const age = result.daysStale !== null ? ` (${result.daysStale}d ago)` : ' (unverified)';
156
+ lines.push(` ${icon} ${result.label}${age} — threshold: ${result.stalenessThresholdDays}d`);
157
+ }
158
+
159
+ if (!gateResult.ready) {
160
+ lines.push('', 'Action required: verify stale/unverified sources before claiming release freshness.');
161
+ }
162
+
163
+ return lines.join('\n');
164
+ }
165
+
166
+ function getPropagationTargets(triggerKeyword) {
167
+ const keyword = triggerKeyword.toLowerCase();
168
+ return PROPAGATION_CHECKLIST.filter(item => item.trigger.toLowerCase().includes(keyword));
169
+ }
170
+
171
+ module.exports = {
172
+ P0_SOURCES,
173
+ PROPAGATION_CHECKLIST,
174
+ checkReleaseGate,
175
+ formatReleaseGate,
176
+ getPropagationTargets,
177
+ };
@@ -14,6 +14,7 @@ const os = require('os');
14
14
  const path = require('path');
15
15
  const { GeminiProjectContext } = require('./context');
16
16
  const { EMBEDDED_SECRET_PATTERNS, containsEmbeddedSecret } = require('../secret-patterns');
17
+ const { attachSourceUrls } = require('../source-urls');
17
18
 
18
19
  // ─── Shared helpers ─────────────────────────────────────────────────────────
19
20
 
@@ -2230,6 +2231,8 @@ const GEMINI_TECHNIQUES = {
2230
2231
  },
2231
2232
  };
2232
2233
 
2234
+ attachSourceUrls('gemini', GEMINI_TECHNIQUES);
2235
+
2233
2236
  module.exports = {
2234
2237
  GEMINI_TECHNIQUES,
2235
2238
  };