@nerviq/cli 1.26.0 → 1.27.1

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 (59) hide show
  1. package/CHANGELOG.md +1407 -0
  2. package/README.md +4 -4
  3. package/SECURITY.md +82 -0
  4. package/bin/cli.js +13 -1
  5. package/contracts/audit-webhook-event.schema.json +138 -0
  6. package/contracts/pack-contract.schema.json +15 -0
  7. package/contracts/technique-contract.schema.json +18 -0
  8. package/docs/ARCHITECTURE.md +74 -0
  9. package/docs/api-reference.md +356 -0
  10. package/docs/autofix.md +64 -0
  11. package/docs/bitbucket-pipe.yml +57 -0
  12. package/docs/case-studies.md +149 -0
  13. package/docs/category-definition-kit.md +56 -0
  14. package/docs/ci-integration.md +127 -0
  15. package/docs/claude-code-style.md +24 -0
  16. package/docs/claude-maintainer-ops.md +19 -0
  17. package/docs/external-validation.md +78 -0
  18. package/docs/first-tier-integration-gate.md +59 -0
  19. package/docs/getting-started.md +119 -0
  20. package/docs/gitlab-ci-template.yml +54 -0
  21. package/docs/index.html +597 -0
  22. package/docs/integration-contracts.md +287 -0
  23. package/docs/license-faq.md +53 -0
  24. package/docs/maintenance.md +155 -0
  25. package/docs/methodology.md +236 -0
  26. package/docs/new-platform-guide.md +202 -0
  27. package/docs/open-vsx-publishing.md +46 -0
  28. package/docs/platform-change-ingestion.md +46 -0
  29. package/docs/plugins.md +101 -0
  30. package/docs/pre-commit.md +58 -0
  31. package/docs/security-model.md +63 -0
  32. package/docs/shallow-risk.md +246 -0
  33. package/docs/versioning-policy.md +63 -0
  34. package/docs/why-nerviq.md +82 -0
  35. package/package.json +7 -2
  36. package/sdk/README.md +190 -0
  37. package/src/audit/layers.js +180 -179
  38. package/src/audit.js +118 -48
  39. package/src/codex/setup.js +3 -2
  40. package/src/formatters/csv.js +86 -85
  41. package/src/formatters/junit.js +123 -103
  42. package/src/formatters/markdown.js +164 -135
  43. package/src/gemini/setup.js +3 -2
  44. package/src/init.js +4 -3
  45. package/src/opencode/context.js +42 -3
  46. package/src/opencode/techniques.js +198 -142
  47. package/src/output-icons.js +44 -0
  48. package/src/setup/runtime.js +6 -5
  49. package/src/setup.js +4 -3
  50. package/src/shallow-risk/index.js +56 -0
  51. package/src/shallow-risk/patterns/agent-config-cross-platform-drift.js +50 -0
  52. package/src/shallow-risk/patterns/agent-config-dangerous-autoapprove.js +46 -0
  53. package/src/shallow-risk/patterns/agent-config-deprecated-keys.js +46 -0
  54. package/src/shallow-risk/patterns/agent-config-missing-file.js +72 -0
  55. package/src/shallow-risk/patterns/agent-config-secret-literal.js +49 -0
  56. package/src/shallow-risk/patterns/agent-config-stack-contradiction.js +34 -0
  57. package/src/shallow-risk/patterns/hook-script-missing.js +70 -0
  58. package/src/shallow-risk/patterns/mcp-server-no-allowlist.js +52 -0
  59. package/src/shallow-risk/shared.js +520 -0
@@ -163,15 +163,69 @@ function hasCommandMention(content, category) {
163
163
  return false;
164
164
  }
165
165
 
166
- function docsBundle(ctx) {
167
- return `${agentsContent(ctx)}\n${ctx.fileContent('README.md') || ''}`;
168
- }
169
-
170
- function repoLooksRegulated(ctx) {
171
- const filenames = ctx.files.join('\n');
172
- const packageJson = ctx.fileContent('package.json') || '';
173
- const readme = ctx.fileContent('README.md') || '';
174
- const combined = `${filenames}\n${packageJson}\n${readme}`;
166
+ function docsBundle(ctx) {
167
+ return `${agentsContent(ctx)}\n${ctx.fileContent('README.md') || ''}`;
168
+ }
169
+
170
+ function hasExplicitModelSetting(value) {
171
+ if (!value || typeof value !== 'object') return false;
172
+ if (Array.isArray(value)) return value.some((item) => hasExplicitModelSetting(item));
173
+ if (typeof value.model === 'string' && value.model.trim()) return true;
174
+ return Object.values(value).some((item) => hasExplicitModelSetting(item));
175
+ }
176
+
177
+ function configuredPluginRefs(ctx) {
178
+ const config = ctx.configJson();
179
+ if (!config.ok || !config.data) return [];
180
+ const refs = config.data.plugin || config.data.plugins || [];
181
+ return Array.isArray(refs) ? refs.filter(Boolean) : [];
182
+ }
183
+
184
+ function extractSkillDescription(content) {
185
+ if (!content) return null;
186
+ const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
187
+ if (!frontmatter) return null;
188
+
189
+ const lines = frontmatter[1].split(/\r?\n/);
190
+ for (let index = 0; index < lines.length; index++) {
191
+ const line = lines[index];
192
+ const match = line.match(/^description:\s*(.*)$/);
193
+ if (!match) continue;
194
+
195
+ const rawValue = match[1].trim();
196
+ if (rawValue === '|' || rawValue === '>') {
197
+ const block = [];
198
+ for (let next = index + 1; next < lines.length; next++) {
199
+ const candidate = lines[next];
200
+ if (candidate.trim() && !/^\s/.test(candidate)) break;
201
+ block.push(candidate.replace(/^\s{2}/, ''));
202
+ }
203
+ return block.join('\n').trim();
204
+ }
205
+
206
+ return rawValue.replace(/^['"]|['"]$/g, '').trim();
207
+ }
208
+
209
+ return null;
210
+ }
211
+
212
+ function preferredSkillRoot(ctx) {
213
+ const candidates = [
214
+ '.opencode/skills',
215
+ '.opencode/skill',
216
+ '.claude/skills',
217
+ '.agents/skills',
218
+ '.opencode/commands',
219
+ ];
220
+ const found = candidates.find((candidate) => ctx.hasDir(candidate));
221
+ return `${(found || '.opencode/skills').replace(/\\/g, '/')}/`;
222
+ }
223
+
224
+ function repoLooksRegulated(ctx) {
225
+ const filenames = ctx.files.join('\n');
226
+ const packageJson = ctx.fileContent('package.json') || '';
227
+ const readme = ctx.fileContent('README.md') || '';
228
+ const combined = `${filenames}\n${packageJson}\n${readme}`;
175
229
 
176
230
  const strongSignals = /\bhipaa\b|\bphi\b|\bpci\b|\bsoc2\b|\biso[- ]?27001\b|\bcompliance\b|\bhealth(?:care)?\b|\bmedical\b|\bbank(?:ing)?\b|\bpayments?\b|\bfintech\b/i;
177
231
  if (strongSignals.test(combined)) return true;
@@ -364,35 +418,35 @@ const OPENCODE_TECHNIQUES = {
364
418
  line: () => 1,
365
419
  },
366
420
 
367
- opencodeModelExplicit: {
368
- id: 'OC-B04',
369
- name: 'model is set explicitly (not relying on silent default)',
370
- check: (ctx) => {
371
- const config = ctx.configJson();
372
- if (!config.ok || !config.data) return null;
373
- return Boolean(config.data.model);
374
- },
375
- impact: 'medium',
376
- rating: 3,
377
- category: 'config',
378
- fix: 'Set "model" explicitly in opencode.json to avoid relying on silent provider defaults.',
421
+ opencodeModelExplicit: {
422
+ id: 'OC-B04',
423
+ name: 'model is set explicitly (not relying on silent default)',
424
+ check: (ctx) => {
425
+ const config = ctx.configJson();
426
+ if (!config.ok || !config.data) return null;
427
+ return hasExplicitModelSetting(config.data) ? true : null;
428
+ },
429
+ impact: 'medium',
430
+ rating: 3,
431
+ category: 'config',
432
+ fix: 'Set "model" explicitly in opencode.json to avoid relying on silent provider defaults.',
379
433
  template: 'opencode-config',
380
434
  file: (ctx) => configFileName(ctx),
381
435
  line: () => null,
382
436
  },
383
437
 
384
- opencodeSmallModelSet: {
385
- id: 'OC-B05',
386
- name: 'small_model is set for task delegation',
387
- check: (ctx) => {
388
- const config = ctx.configJson();
389
- if (!config.ok || !config.data) return null;
390
- return Boolean(config.data.small_model);
391
- },
392
- impact: 'medium',
393
- rating: 3,
394
- category: 'config',
395
- fix: 'Set "small_model" in opencode.json for efficient task delegation and cost control.',
438
+ opencodeSmallModelSet: {
439
+ id: 'OC-B05',
440
+ name: 'small_model is set for task delegation',
441
+ check: (ctx) => {
442
+ const config = ctx.configJson();
443
+ if (!config.ok || !config.data) return null;
444
+ return config.data.small_model ? true : null;
445
+ },
446
+ impact: 'medium',
447
+ rating: 3,
448
+ category: 'config',
449
+ fix: 'Set "small_model" in opencode.json for efficient task delegation and cost control.',
396
450
  template: 'opencode-config',
397
451
  file: (ctx) => configFileName(ctx),
398
452
  line: () => null,
@@ -1104,22 +1158,22 @@ const OPENCODE_TECHNIQUES = {
1104
1158
  // I. Skills (5 checks)
1105
1159
  // ==============================
1106
1160
 
1107
- opencodeSkillDirsExist: {
1108
- id: 'OC-I01',
1109
- name: 'Skill directories exist (.opencode/commands/ subdirs with SKILL.md)',
1110
- check: (ctx) => {
1111
- const skillDirs = ctx.skillDirs();
1112
- if (skillDirs.length === 0) return null;
1113
- return skillDirs.length > 0;
1114
- },
1115
- impact: 'medium',
1116
- rating: 3,
1117
- category: 'skills',
1118
- fix: 'Create skill directories under .opencode/commands/ with SKILL.md files.',
1119
- template: 'opencode-skills',
1120
- file: () => '.opencode/commands/',
1121
- line: () => null,
1122
- },
1161
+ opencodeSkillDirsExist: {
1162
+ id: 'OC-I01',
1163
+ name: 'Skill directories exist in supported OpenCode paths',
1164
+ check: (ctx) => {
1165
+ const skillDirs = ctx.skillDirs();
1166
+ if (skillDirs.length === 0) return null;
1167
+ return skillDirs.length > 0;
1168
+ },
1169
+ impact: 'medium',
1170
+ rating: 3,
1171
+ category: 'skills',
1172
+ fix: 'Create skill directories under `.opencode/skills/` with `SKILL.md` files. `.claude/skills/` and `.agents/skills/` remain compatible, and older `.opencode/commands/<name>/SKILL.md` layouts are still tolerated for backwards compatibility.',
1173
+ template: 'opencode-skills',
1174
+ file: (ctx) => preferredSkillRoot(ctx),
1175
+ line: () => null,
1176
+ },
1123
1177
 
1124
1178
  opencodeSkillFrontmatter: {
1125
1179
  id: 'OC-I02',
@@ -1133,15 +1187,15 @@ const OPENCODE_TECHNIQUES = {
1133
1187
  if (!/^#\s+/m.test(content)) return false;
1134
1188
  }
1135
1189
  return true;
1136
- },
1137
- impact: 'high',
1138
- rating: 4,
1139
- category: 'skills',
1140
- fix: 'Each SKILL.md needs a title (# heading) and description for skill invocation.',
1141
- template: 'opencode-skills',
1142
- file: () => '.opencode/commands/',
1143
- line: () => null,
1144
- },
1190
+ },
1191
+ impact: 'high',
1192
+ rating: 4,
1193
+ category: 'skills',
1194
+ fix: 'Each SKILL.md needs a title (# heading) and description for skill invocation.',
1195
+ template: 'opencode-skills',
1196
+ file: (ctx) => preferredSkillRoot(ctx),
1197
+ line: () => null,
1198
+ },
1145
1199
 
1146
1200
  opencodeSkillKebabCase: {
1147
1201
  id: 'OC-I03',
@@ -1151,54 +1205,56 @@ const OPENCODE_TECHNIQUES = {
1151
1205
  if (skillDirs.length === 0) return null;
1152
1206
  return skillDirs.every(name => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name));
1153
1207
  },
1154
- impact: 'medium',
1155
- rating: 2,
1156
- category: 'skills',
1157
- fix: 'Prefer kebab-case for skill names, but treat it as a style recommendation rather than a hard runtime requirement. Current runtime still discovered underscore-based names.',
1158
- template: 'opencode-skills',
1159
- file: () => '.opencode/commands/',
1160
- line: () => null,
1161
- },
1162
-
1163
- opencodeSkillDescriptionBounded: {
1164
- id: 'OC-I04',
1165
- name: 'Skill descriptions are bounded for implicit invocation context cost',
1166
- check: (ctx) => {
1167
- const skillDirs = ctx.skillDirs();
1168
- if (skillDirs.length === 0) return null;
1169
- for (const name of skillDirs) {
1170
- const content = ctx.skillMetadata(name);
1171
- if (!content) continue;
1172
- if (content.length > 3000) return false;
1173
- }
1174
- return true;
1175
- },
1176
- impact: 'medium',
1177
- rating: 3,
1178
- category: 'skills',
1179
- fix: 'Keep SKILL.md descriptions under 3000 characters to manage implicit invocation context cost.',
1180
- template: 'opencode-skills',
1181
- file: () => '.opencode/commands/',
1182
- line: () => null,
1183
- },
1184
-
1185
- opencodeSkillCompatPaths: {
1186
- id: 'OC-I05',
1187
- name: 'OpenCode skill discovery accepts either .opencode/commands or .claude/skills',
1188
- check: (ctx) => {
1189
- const hasClaudeSkills = ctx.hasDir('.claude/skills');
1190
- const hasOpencodeCommands = ctx.hasDir('.opencode/commands');
1191
- if (!hasClaudeSkills && !hasOpencodeCommands) return null;
1192
- return hasClaudeSkills || hasOpencodeCommands;
1193
- },
1194
- impact: 'medium',
1195
- rating: 3,
1196
- category: 'skills',
1197
- fix: 'Use `.opencode/commands/` for native OpenCode skills when you need them, but do not require a duplicate tree just to mirror `.claude/skills/`. Current runtime discovered `.claude/skills/` compatibility successfully.',
1198
- template: 'opencode-skills',
1199
- file: () => '.opencode/commands/',
1200
- line: () => null,
1201
- },
1208
+ impact: 'medium',
1209
+ rating: 2,
1210
+ category: 'skills',
1211
+ fix: 'Prefer kebab-case for skill names, but treat it as a style recommendation rather than a hard runtime requirement. Current runtime still discovered underscore-based names.',
1212
+ template: 'opencode-skills',
1213
+ file: (ctx) => preferredSkillRoot(ctx),
1214
+ line: () => null,
1215
+ },
1216
+
1217
+ opencodeSkillDescriptionBounded: {
1218
+ id: 'OC-I04',
1219
+ name: 'Skill descriptions are bounded for implicit invocation context cost',
1220
+ check: (ctx) => {
1221
+ const skillDirs = ctx.skillDirs();
1222
+ if (skillDirs.length === 0) return null;
1223
+ for (const name of skillDirs) {
1224
+ const content = ctx.skillMetadata(name);
1225
+ if (!content) continue;
1226
+ const description = extractSkillDescription(content);
1227
+ if (description && description.length > 3000) return false;
1228
+ }
1229
+ return true;
1230
+ },
1231
+ impact: 'medium',
1232
+ rating: 3,
1233
+ category: 'skills',
1234
+ fix: 'Keep SKILL.md descriptions under 3000 characters to manage implicit invocation context cost.',
1235
+ template: 'opencode-skills',
1236
+ file: (ctx) => preferredSkillRoot(ctx),
1237
+ line: () => null,
1238
+ },
1239
+
1240
+ opencodeSkillCompatPaths: {
1241
+ id: 'OC-I05',
1242
+ name: 'OpenCode skill discovery accepts native and compatible skill trees',
1243
+ check: (ctx) => {
1244
+ const hasNativeSkillTree = ctx.hasDir('.opencode/skills') || ctx.hasDir('.opencode/skill');
1245
+ const hasCompatibleSkillTree = ctx.hasDir('.claude/skills') || ctx.hasDir('.agents/skills');
1246
+ const hasLegacyCommandsSkills = ctx.hasDir('.opencode/commands') && ctx.skillDirs().length > 0;
1247
+ if (!hasNativeSkillTree && !hasCompatibleSkillTree && !hasLegacyCommandsSkills) return null;
1248
+ return hasNativeSkillTree || hasCompatibleSkillTree || hasLegacyCommandsSkills;
1249
+ },
1250
+ impact: 'medium',
1251
+ rating: 3,
1252
+ category: 'skills',
1253
+ fix: 'Use `.opencode/skills/` for native OpenCode skills. `.claude/skills/` and `.agents/skills/` remain compatible, and older `.opencode/commands/<name>/SKILL.md` layouts are kept as legacy compatibility only.',
1254
+ template: 'opencode-skills',
1255
+ file: (ctx) => preferredSkillRoot(ctx),
1256
+ line: () => null,
1257
+ },
1202
1258
 
1203
1259
  // ==============================
1204
1260
  // J. Agents & Subagents (4 checks)
@@ -1498,42 +1554,42 @@ const OPENCODE_TECHNIQUES = {
1498
1554
  line: () => null,
1499
1555
  },
1500
1556
 
1501
- opencodeConfigKeysFresh: {
1502
- id: 'OC-N02',
1503
- name: 'Config references current OpenCode features (no removed or renamed keys)',
1504
- check: (ctx) => {
1505
- const docs = docsBundle(ctx);
1506
- const config = ctx.configContent();
1507
- if (!docs.trim() && !config) return null;
1508
- const combined = `${docs}\n${config || ''}`;
1509
- return !/\bconfig\.json\b|\.well-known\/opencode|mode\s*->\s*agent|CLAUDE\.md fallback/i.test(combined);
1510
- },
1511
- impact: 'medium',
1512
- rating: 3,
1513
- category: 'release-freshness',
1514
- fix: 'Update stale OpenCode references. Use `opencode.json`/`opencode.jsonc`, keep `mode` guidance version-scoped, and treat `.well-known/opencode` plus `CLAUDE.md` fallback claims as unvalidated until you have fresh runtime proof.',
1515
- template: 'opencode-config',
1516
- file: (ctx) => configFileName(ctx),
1517
- line: () => null,
1518
- },
1519
-
1520
- opencodePropagationCompleteness: {
1521
- id: 'OC-N03',
1522
- name: 'No dangling surface references (plugins, skills, MCP mentioned but not defined)',
1523
- check: (ctx) => {
1524
- const agents = agentsContent(ctx);
1525
- if (!agents) return null;
1526
- const issues = [];
1527
- if (/\bplugins?\b/i.test(agents) && ctx.pluginFiles().length === 0) {
1528
- issues.push('plugins referenced but .opencode/plugins/ empty');
1529
- }
1530
- if (/\bskills?\b/i.test(agents) && !ctx.hasDir('.opencode/commands')) {
1531
- issues.push('skills referenced but .opencode/commands/ missing');
1532
- }
1533
- const config = ctx.configJson();
1534
- if (config.ok && config.data && /\bmcp\b/i.test(agents)) {
1535
- const mcp = config.data.mcp || {};
1536
- if (Object.keys(mcp).length === 0) {
1557
+ opencodeConfigKeysFresh: {
1558
+ id: 'OC-N02',
1559
+ name: 'Config references current OpenCode features (no removed or renamed keys)',
1560
+ check: (ctx) => {
1561
+ const docs = docsBundle(ctx);
1562
+ const config = ctx.configContent();
1563
+ if (!docs.trim() && !config) return null;
1564
+ const combined = `${docs}\n${config || ''}`;
1565
+ return !/(?:^|[\s`"'(])~\/\.opencode\.json\b|(?:^|[\s`"'(])\.opencode\/config\.json\b|mode\s*->\s*agent|CLAUDE\.md fallback/i.test(combined);
1566
+ },
1567
+ impact: 'medium',
1568
+ rating: 3,
1569
+ category: 'release-freshness',
1570
+ fix: 'Update stale OpenCode references. Use `opencode.json`/`opencode.jsonc` plus the current `.opencode/{agents,commands,plugins,skills}/` directory layout. Treat legacy `~/.opencode.json`, `.opencode/config.json`, and `CLAUDE.md` fallback claims as stale unless you have fresh runtime proof.',
1571
+ template: 'opencode-config',
1572
+ file: (ctx) => configFileName(ctx),
1573
+ line: () => null,
1574
+ },
1575
+
1576
+ opencodePropagationCompleteness: {
1577
+ id: 'OC-N03',
1578
+ name: 'No dangling surface references (plugins, skills, MCP mentioned but not defined)',
1579
+ check: (ctx) => {
1580
+ const agents = agentsContent(ctx);
1581
+ if (!agents) return null;
1582
+ const issues = [];
1583
+ if (/\bplugins?\b/i.test(agents) && ctx.pluginFiles().length === 0 && configuredPluginRefs(ctx).length === 0) {
1584
+ issues.push('plugins referenced but .opencode/plugins/ empty');
1585
+ }
1586
+ if (/\bskills?\b/i.test(agents) && ctx.skillDirs().length === 0) {
1587
+ issues.push('skills referenced but no supported skill tree found (.opencode/skills, .claude/skills, .agents/skills, or legacy .opencode/commands/<name>/SKILL.md)');
1588
+ }
1589
+ const config = ctx.configJson();
1590
+ if (config.ok && config.data && /\bmcp\b/i.test(agents)) {
1591
+ const mcp = config.data.mcp || {};
1592
+ if (Object.keys(mcp).length === 0) {
1537
1593
  issues.push('MCP referenced in AGENTS.md but no MCP servers in config');
1538
1594
  }
1539
1595
  }
@@ -0,0 +1,44 @@
1
+ const ASCII_ENV = 'NERVIQ_ASCII_OUTPUT';
2
+
3
+ const ASCII_TRUE = new Set(['1', 'true', 'yes', 'on']);
4
+ const ASCII_FALSE = new Set(['0', 'false', 'no', 'off']);
5
+
6
+ const ICONS = {
7
+ ok: { emoji: '✅', ascii: '[OK]' },
8
+ fail: { emoji: '❌', ascii: '[FAIL]' },
9
+ warn: { emoji: '⚠', ascii: '[WARN]' },
10
+ skip: { emoji: '⏭️', ascii: '[SKIP]' },
11
+ delete: { emoji: '🗑️', ascii: '[DEL]' },
12
+ };
13
+
14
+ function parseAsciiOverride(env = process.env) {
15
+ const raw = env && env[ASCII_ENV];
16
+ if (typeof raw !== 'string') return null;
17
+ const normalized = raw.trim().toLowerCase();
18
+ if (ASCII_TRUE.has(normalized)) return true;
19
+ if (ASCII_FALSE.has(normalized)) return false;
20
+ return null;
21
+ }
22
+
23
+ function shouldUseAsciiOutput(options = {}) {
24
+ const env = options.env || process.env;
25
+ const platform = options.platform || process.platform;
26
+ const stream = options.stream || process.stdout;
27
+ const override = parseAsciiOverride(env);
28
+ if (override !== null) return override;
29
+ if (!stream || stream.isTTY === false) return true;
30
+ return platform === 'win32';
31
+ }
32
+
33
+ function icon(name, options = {}) {
34
+ const token = ICONS[name];
35
+ if (!token) {
36
+ throw new Error(`Unknown icon token: ${name}`);
37
+ }
38
+ return shouldUseAsciiOutput(options) ? token.ascii : token.emoji;
39
+ }
40
+
41
+ module.exports = {
42
+ icon,
43
+ shouldUseAsciiOutput,
44
+ };
@@ -1,6 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { buildSettingsForProfile } = require('../governance');
4
+ const { icon } = require('../output-icons');
4
5
 
5
6
  function snapshotSettingsBeforeSetup(dir) {
6
7
  const settingsPath = path.join(dir, '.claude/settings.json');
@@ -53,11 +54,11 @@ function applyTemplateResults({ dir, failedWithTemplates, stacks, ctx, templates
53
54
  if (!fs.existsSync(fullPath)) {
54
55
  fs.writeFileSync(fullPath, result, 'utf8');
55
56
  writtenFiles.push(filePath);
56
- log(` \x1b[32mגœ…\x1b[0m Created ${filePath}`);
57
+ log(` \x1b[32m${icon('ok')}\x1b[0m Created ${filePath}`);
57
58
  created++;
58
59
  } else {
59
60
  preservedFiles.push(filePath);
60
- log(` \x1b[2mג­ן¸ Skipped ${filePath} (already exists ג€” your version is kept)\x1b[0m`);
61
+ log(` \x1b[2m${icon('skip')} Skipped ${filePath} (already exists - your version is kept)\x1b[0m`);
61
62
  skipped++;
62
63
  }
63
64
  } else if (typeof result === 'object') {
@@ -84,7 +85,7 @@ function applyTemplateResults({ dir, failedWithTemplates, stacks, ctx, templates
84
85
  if (!fs.existsSync(filePath)) {
85
86
  fs.writeFileSync(filePath, content, 'utf8');
86
87
  writtenFiles.push(path.relative(dir, filePath));
87
- log(` \x1b[32mגœ…\x1b[0m Created ${path.relative(dir, filePath)}`);
88
+ log(` \x1b[32m${icon('ok')}\x1b[0m Created ${path.relative(dir, filePath)}`);
88
89
  created++;
89
90
  } else {
90
91
  preservedFiles.push(path.relative(dir, filePath));
@@ -151,10 +152,10 @@ function mergeGeneratedHookSettings({ dir, profile, mcpPacks, writtenFiles, pres
151
152
  fs.writeFileSync(settingsPath, JSON.stringify(existingSettings, null, 2), 'utf8');
152
153
  if (!writtenFiles.includes('.claude/settings.json') && !preservedFiles.includes('.claude/settings.json')) {
153
154
  writtenFiles.push('.claude/settings.json');
154
- log(` \x1b[32mגœ…\x1b[0m Updated .claude/settings.json (hooks registered)`);
155
+ log(` \x1b[32m${icon('ok')}\x1b[0m Updated .claude/settings.json (hooks registered)`);
155
156
  created++;
156
157
  } else {
157
- log(` \x1b[32mגœ…\x1b[0m Merged hooks into existing .claude/settings.json`);
158
+ log(` \x1b[32m${icon('ok')}\x1b[0m Merged hooks into existing .claude/settings.json`);
158
159
  }
159
160
 
160
161
  return {
package/src/setup.js CHANGED
@@ -9,8 +9,9 @@ const { TECHNIQUES, STACKS } = require('./techniques');
9
9
  const { ProjectContext } = require('./context');
10
10
  const { getMcpPackPreflight } = require('./mcp-packs');
11
11
  const { writeRollbackArtifact } = require('./activity');
12
- const { setupCodex } = require('./codex/setup');
13
- const { detectDependencies, detectMainDirs, detectProjectMetadata, detectScripts, generateMermaid, getFrameworkInstructions } = require('./setup/analysis');
12
+ const { setupCodex } = require('./codex/setup');
13
+ const { icon } = require('./output-icons');
14
+ const { detectDependencies, detectMainDirs, detectProjectMetadata, detectScripts, generateMermaid, getFrameworkInstructions } = require('./setup/analysis');
14
15
  const { applyTemplateResults, collectFailedSetupTemplates, mergeGeneratedHookSettings, snapshotSettingsBeforeSetup } = require('./setup/runtime');
15
16
 
16
17
  // ============================================================
@@ -617,7 +618,7 @@ async function setup(options) {
617
618
  preservedFiles = settingsMerge.preservedFiles;
618
619
  log('');
619
620
  if (created === 0 && skipped > 0) {
620
- log(' \x1b[32m✅\x1b[0m Your project is already well configured!');
621
+ log(` \x1b[32m${icon('ok')}\x1b[0m Your project is already well configured!`);
621
622
  log(` \x1b[2m ${skipped} files already exist and were preserved.\x1b[0m`);
622
623
  log(' \x1b[2m We never overwrite your existing config — your setup is kept.\x1b[0m');
623
624
  } else if (created > 0) {
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+
3
+ const { buildFinding, SHALLOW_RISK_BANNER, SHALLOW_RISK_BANNER_LINES } = require('./shared');
4
+
5
+ const patterns = [
6
+ require('./patterns/agent-config-missing-file'),
7
+ require('./patterns/agent-config-stack-contradiction'),
8
+ require('./patterns/agent-config-cross-platform-drift'),
9
+ require('./patterns/mcp-server-no-allowlist'),
10
+ require('./patterns/hook-script-missing'),
11
+ require('./patterns/agent-config-secret-literal'),
12
+ require('./patterns/agent-config-deprecated-keys'),
13
+ require('./patterns/agent-config-dangerous-autoapprove'),
14
+ ];
15
+
16
+ function runShallowRisk(ctx) {
17
+ if (!ctx || process.env.NERVIQ_SHALLOW_RISK === 'off') {
18
+ return [];
19
+ }
20
+
21
+ const findings = [];
22
+ const seen = new Set();
23
+
24
+ for (const pattern of patterns) {
25
+ let emitted = [];
26
+ try {
27
+ const next = pattern.run(ctx);
28
+ emitted = Array.isArray(next) ? next : [];
29
+ } catch {
30
+ emitted = [];
31
+ }
32
+
33
+ for (const finding of emitted) {
34
+ const normalized = buildFinding(pattern, ctx, finding || {});
35
+ const dedupeKey = [
36
+ normalized.key,
37
+ normalized.file || '',
38
+ normalized.line || '',
39
+ normalized.fix || '',
40
+ ].join('|');
41
+
42
+ if (seen.has(dedupeKey)) continue;
43
+ seen.add(dedupeKey);
44
+ findings.push(normalized);
45
+ }
46
+ }
47
+
48
+ return findings;
49
+ }
50
+
51
+ module.exports = {
52
+ patterns,
53
+ runShallowRisk,
54
+ SHALLOW_RISK_BANNER,
55
+ SHALLOW_RISK_BANNER_LINES,
56
+ };
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ SHALLOW_RISK_DOC_URL,
5
+ collectStackClaims,
6
+ } = require('../shared');
7
+
8
+ module.exports = {
9
+ key: 'agent-config-cross-platform-drift',
10
+ name: 'Cross-platform stack drift detected',
11
+ severity: 'high',
12
+ layer: 'shallow-risk',
13
+ sourceUrl: SHALLOW_RISK_DOC_URL,
14
+ run(ctx) {
15
+ const claims = collectStackClaims(ctx).filter((claim) => claim.platform !== 'agent');
16
+ if (claims.length < 2) return [];
17
+
18
+ const byPlatform = new Map();
19
+ for (const claim of claims) {
20
+ const bucket = byPlatform.get(claim.platform) || [];
21
+ bucket.push(claim);
22
+ byPlatform.set(claim.platform, bucket);
23
+ }
24
+
25
+ const representatives = [];
26
+ for (const bucket of byPlatform.values()) {
27
+ const uniqueKeys = [...new Set(bucket.map((claim) => claim.key))];
28
+ if (uniqueKeys.length !== 1) continue;
29
+ representatives.push(bucket[0]);
30
+ }
31
+
32
+ representatives.sort((left, right) => left.file.localeCompare(right.file));
33
+
34
+ for (let index = 0; index < representatives.length; index++) {
35
+ for (let inner = index + 1; inner < representatives.length; inner++) {
36
+ const first = representatives[index];
37
+ const second = representatives[inner];
38
+ if (first.key === second.key) continue;
39
+
40
+ return [{
41
+ file: first.file,
42
+ line: first.line,
43
+ fix: `Drift detected: ${first.file} declares "${first.label}" while ${second.file} declares "${second.label}". Align the shared primary-language guidance or document an intentional platform-specific override.`,
44
+ }];
45
+ }
46
+ }
47
+
48
+ return [];
49
+ },
50
+ };
@@ -0,0 +1,46 @@
1
+ 'use strict';
2
+
3
+ const { SHALLOW_RISK_DOC_URL, escapeRegExp } = require('../shared');
4
+
5
+ const DANGEROUS_ALLOW_PATTERNS = [
6
+ /\brm\b[\s\S]{0,40}-r/i,
7
+ /\bgit\s+push\s+--force\b/i,
8
+ /\bdrop\s+(?:database|table)\b/i,
9
+ /\btruncate\s+table\b/i,
10
+ /\bdelete\s+from\b/i,
11
+ ];
12
+
13
+ function isDangerousAllowRule(rule) {
14
+ if (typeof rule !== 'string') return false;
15
+ if (/\bdelete\s+from\b/i.test(rule)) {
16
+ return !/\bwhere\b/i.test(rule) || /\bwhere\s*1\s*=\s*1\b/i.test(rule);
17
+ }
18
+ return DANGEROUS_ALLOW_PATTERNS.some((pattern) => {
19
+ pattern.lastIndex = 0;
20
+ return pattern.test(rule);
21
+ });
22
+ }
23
+
24
+ module.exports = {
25
+ key: 'agent-config-dangerous-autoapprove',
26
+ name: 'Agent config auto-approves destructive commands',
27
+ severity: 'critical',
28
+ layer: 'shallow-risk',
29
+ sourceUrl: SHALLOW_RISK_DOC_URL,
30
+ run(ctx) {
31
+ const file = '.claude/settings.json';
32
+ const config = ctx.jsonFile(file);
33
+ const allowRules = config && config.permissions && Array.isArray(config.permissions.allow)
34
+ ? config.permissions.allow
35
+ : [];
36
+ if (allowRules.length === 0) return [];
37
+
38
+ return allowRules
39
+ .filter(isDangerousAllowRule)
40
+ .map((rule) => ({
41
+ file,
42
+ line: ctx.lineNumber(file, new RegExp(escapeRegExp(rule))) || 1,
43
+ fix: `${file} pre-approves the destructive rule \`${rule}\`. Remove it from the allow-list so destructive commands always require explicit review.`,
44
+ }));
45
+ },
46
+ };