@nerviq/cli 1.27.0 → 1.29.0

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 (45) hide show
  1. package/CHANGELOG.md +1493 -0
  2. package/README.md +2 -2
  3. package/SECURITY.md +82 -0
  4. package/contracts/audit-webhook-event.schema.json +138 -0
  5. package/contracts/pack-contract.schema.json +15 -0
  6. package/contracts/technique-contract.schema.json +18 -0
  7. package/docs/ARCHITECTURE.md +74 -0
  8. package/docs/api-reference.md +356 -0
  9. package/docs/autofix.md +64 -0
  10. package/docs/bitbucket-pipe.yml +57 -0
  11. package/docs/case-studies.md +149 -0
  12. package/docs/category-definition-kit.md +56 -0
  13. package/docs/ci-integration.md +127 -0
  14. package/docs/claude-code-style.md +24 -0
  15. package/docs/claude-maintainer-ops.md +19 -0
  16. package/docs/external-validation.md +78 -0
  17. package/docs/first-tier-integration-gate.md +59 -0
  18. package/docs/getting-started.md +119 -0
  19. package/docs/gitlab-ci-template.yml +54 -0
  20. package/docs/index.html +597 -0
  21. package/docs/integration-contracts.md +287 -0
  22. package/docs/license-faq.md +53 -0
  23. package/docs/maintenance.md +155 -0
  24. package/docs/methodology.md +236 -0
  25. package/docs/new-platform-guide.md +202 -0
  26. package/docs/open-vsx-publishing.md +46 -0
  27. package/docs/platform-change-ingestion.md +46 -0
  28. package/docs/plugins.md +101 -0
  29. package/docs/pre-commit.md +58 -0
  30. package/docs/security-model.md +63 -0
  31. package/docs/shallow-risk.md +246 -0
  32. package/docs/versioning-policy.md +63 -0
  33. package/docs/why-nerviq.md +82 -0
  34. package/package.json +7 -2
  35. package/sdk/README.md +190 -0
  36. package/src/codex/setup.js +3 -2
  37. package/src/gemini/setup.js +3 -2
  38. package/src/init.js +4 -3
  39. package/src/opencode/context.js +42 -3
  40. package/src/opencode/techniques.js +198 -142
  41. package/src/output-icons.js +44 -0
  42. package/src/setup/runtime.js +6 -5
  43. package/src/setup.js +4 -3
  44. package/src/shallow-risk/patterns/agent-config-missing-file.js +254 -9
  45. package/src/shallow-risk/shared.js +135 -7
@@ -6,6 +6,7 @@ const { STACKS } = require('../techniques');
6
6
  const { writeActivityArtifact, writeRollbackArtifact } = require('../activity');
7
7
  const { GeminiProjectContext } = require('./context');
8
8
  const { recommendGeminiMcpPacks, packToJson } = require('./mcp-packs');
9
+ const { icon } = require('../output-icons');
9
10
 
10
11
  function detectScripts(ctx) {
11
12
  const pkg = ctx.jsonFile('package.json');
@@ -622,14 +623,14 @@ async function setupGemini(options) {
622
623
  const fullPath = path.join(options.dir, file.path);
623
624
  if (fs.existsSync(fullPath)) {
624
625
  preservedFiles.push(file.path);
625
- log(` \x1b[2m⏭️ Skipped ${file.path} (already exists your version is kept)\x1b[0m`);
626
+ log(` \x1b[2m${icon('skip')} Skipped ${file.path} (already exists - your version is kept)\x1b[0m`);
626
627
  continue;
627
628
  }
628
629
 
629
630
  fs.mkdirSync(path.dirname(fullPath), { recursive: true });
630
631
  fs.writeFileSync(fullPath, file.content, 'utf8');
631
632
  writtenFiles.push(file.path);
632
- log(` \x1b[32m✅\x1b[0m Created ${file.path}`);
633
+ log(` \x1b[32m${icon('ok')}\x1b[0m Created ${file.path}`);
633
634
  }
634
635
 
635
636
  const skippedSet = new Set(preservedFiles);
package/src/init.js CHANGED
@@ -6,6 +6,7 @@ const { audit } = require('./audit');
6
6
  const { setup } = require('./setup');
7
7
  const { ProjectContext } = require('./context');
8
8
  const { STACKS } = require('./techniques');
9
+ const { icon } = require('./output-icons');
9
10
 
10
11
  const PLATFORM_LABELS = {
11
12
  claude: 'Claude Code',
@@ -136,10 +137,10 @@ async function runInit(dir, flags) {
136
137
  });
137
138
 
138
139
  for (const f of setupResult.writtenFiles) {
139
- console.log(` ${green}✅${reset} Created ${f}`);
140
+ console.log(` ${green}${icon('ok')}${reset} Created ${f}`);
140
141
  }
141
142
  for (const f of setupResult.preservedFiles) {
142
- console.log(` ${dim}⏭️ Kept ${f} (already exists)${reset}`);
143
+ console.log(` ${dim}${icon('skip')} Kept ${f} (already exists)${reset}`);
143
144
  }
144
145
 
145
146
  // --- Run additional platform setups ---
@@ -153,7 +154,7 @@ async function runInit(dir, flags) {
153
154
  mcpPacks: [],
154
155
  });
155
156
  for (const f of extraResult.writtenFiles) {
156
- console.log(` ${green}✅${reset} Created ${f}`);
157
+ console.log(` ${green}${icon('ok')}${reset} Created ${f}`);
157
158
  }
158
159
  } catch {
159
160
  // Platform setup not available, skip
@@ -203,12 +203,51 @@ class OpenCodeProjectContext extends ProjectContext {
203
203
  }
204
204
 
205
205
  skillDirs() {
206
- const skillsDir = path.join(this.dir, '.opencode', 'commands');
207
- return listDirs(skillsDir).map(entry => entry.name);
206
+ const names = new Set();
207
+ const roots = [
208
+ path.join('.opencode', 'skills'),
209
+ path.join('.opencode', 'skill'),
210
+ path.join('.claude', 'skills'),
211
+ path.join('.agents', 'skills'),
212
+ ];
213
+
214
+ for (const root of roots) {
215
+ const fullRoot = path.join(this.dir, root);
216
+ for (const entry of listDirs(fullRoot)) {
217
+ if (this.fileContent(path.join(root, entry.name, 'SKILL.md'))) {
218
+ names.add(entry.name);
219
+ }
220
+ }
221
+ }
222
+
223
+ // Legacy NERVIQ compatibility: older generated fixtures placed skills
224
+ // under .opencode/commands/<name>/SKILL.md before OpenCode's native
225
+ // .opencode/skills/ path was verified.
226
+ const legacyCommandsRoot = path.join(this.dir, '.opencode', 'commands');
227
+ for (const entry of listDirs(legacyCommandsRoot)) {
228
+ if (this.fileContent(path.join('.opencode', 'commands', entry.name, 'SKILL.md'))) {
229
+ names.add(entry.name);
230
+ }
231
+ }
232
+
233
+ return [...names];
208
234
  }
209
235
 
210
236
  skillMetadata(name) {
211
- return this.fileContent(path.join('.opencode', 'commands', name, 'SKILL.md'));
237
+ const candidates = [
238
+ path.join('.opencode', 'skills', name, 'SKILL.md'),
239
+ path.join('.opencode', 'skill', name, 'SKILL.md'),
240
+ path.join('.claude', 'skills', name, 'SKILL.md'),
241
+ path.join('.agents', 'skills', name, 'SKILL.md'),
242
+ path.join('.opencode', 'commands', name, 'SKILL.md'),
243
+ ];
244
+
245
+ for (const candidate of candidates) {
246
+ const content = this.fileContent(candidate);
247
+ if (content) return content;
248
+ }
249
+
250
+ return null;
212
251
  }
213
252
 
214
253
  themeFiles() {
@@ -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) {