@nerviq/cli 1.22.0 → 1.23.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.
package/README.md CHANGED
@@ -223,8 +223,8 @@ All successful operational responses are wrapped in a JSON envelope:
223
223
  {
224
224
  "data": {},
225
225
  "meta": {
226
- "version": "1.22.0",
227
- "timestamp": "2026-04-14T22:00:00.000Z"
226
+ "version": "1.23.0",
227
+ "timestamp": "2026-04-15T02:00:00.000Z"
228
228
  }
229
229
  }
230
230
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nerviq/cli",
3
- "version": "1.22.0",
3
+ "version": "1.23.0",
4
4
  "description": "The intelligent nervous system for AI coding agents — 2,441 checks (8 platforms × ~300 governance rules), 10 languages, 62 domain packs. Audit, align, and amplify.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Evidence resolver — CTO-04 trust-recovery depth.
3
+ *
4
+ * Given a failed check key and a ProjectContext, returns
5
+ * `{ file, line, snippet }` when the finding lives at a specific
6
+ * file location in the repo, or `null` when the check is genuinely
7
+ * not file-level (e.g. "absence of a file" or meta-property).
8
+ *
9
+ * This is a post-hoc resolver: many technique definitions do not
10
+ * declare `file`/`line` themselves, so we fill evidence in from here
11
+ * using the cached file content already loaded by ProjectContext.
12
+ * No extra filesystem scans are performed.
13
+ *
14
+ * Snippet is a 2-5 line excerpt centered on `line`, length-capped
15
+ * at 300 chars, using `\n` as line separator.
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const SNIPPET_RADIUS = 2; // 2 lines before + match + 2 lines after = up to 5 lines
21
+ const SNIPPET_MAX_CHARS = 300;
22
+
23
+ function sliceSnippet(content, line) {
24
+ if (!content || !Number.isFinite(line) || line < 1) return null;
25
+ const lines = content.split(/\r?\n/);
26
+ const start = Math.max(0, line - 1 - SNIPPET_RADIUS);
27
+ const end = Math.min(lines.length, line + SNIPPET_RADIUS);
28
+ let snippet = lines.slice(start, end).join('\n');
29
+ if (snippet.length > SNIPPET_MAX_CHARS) {
30
+ snippet = snippet.slice(0, SNIPPET_MAX_CHARS - 3) + '...';
31
+ }
32
+ return snippet || null;
33
+ }
34
+
35
+ function buildEvidence(ctx, file, line) {
36
+ if (!file) return null;
37
+ const content = typeof ctx.fileContent === 'function' ? ctx.fileContent(file) : null;
38
+ if (!content) return { file, line: line || null, snippet: null };
39
+ const resolvedLine = Number.isFinite(line) && line >= 1 ? line : 1;
40
+ const snippet = sliceSnippet(content, resolvedLine);
41
+ return { file, line: resolvedLine, snippet };
42
+ }
43
+
44
+ // Locate the best-match CLAUDE.md file (root or .claude/).
45
+ function claudeMdPath(ctx) {
46
+ if (typeof ctx.fileContent !== 'function') return null;
47
+ if (ctx.fileContent('CLAUDE.md') !== null) return 'CLAUDE.md';
48
+ if (ctx.fileContent('.claude/CLAUDE.md') !== null) return '.claude/CLAUDE.md';
49
+ return null;
50
+ }
51
+
52
+ function agentsMdPath(ctx) {
53
+ if (typeof ctx.fileContent !== 'function') return null;
54
+ if (ctx.fileContent('AGENTS.md') !== null) return 'AGENTS.md';
55
+ return null;
56
+ }
57
+
58
+ // Resolvers return { file, line } or null (category c).
59
+ // `file` MUST be a real path that exists; otherwise return null so we do not
60
+ // produce misleading evidence.
61
+ const RESOLVERS = {
62
+ // --- CLAUDE.md content checks (backport: file=CLAUDE.md, line=1) ---
63
+ claudeMd: (ctx) => {
64
+ const p = claudeMdPath(ctx);
65
+ return p ? { file: p, line: 1 } : null; // absent → null (category c)
66
+ },
67
+ importSyntax: (ctx) => {
68
+ const p = claudeMdPath(ctx);
69
+ return p ? { file: p, line: 1 } : null;
70
+ },
71
+ roleDefinition: (ctx) => {
72
+ const p = claudeMdPath(ctx);
73
+ return p ? { file: p, line: 1 } : null;
74
+ },
75
+ mermaidArchitecture: (ctx) => {
76
+ const p = claudeMdPath(ctx);
77
+ return p ? { file: p, line: 1 } : null;
78
+ },
79
+ underlines200: (ctx) => {
80
+ const p = claudeMdPath(ctx);
81
+ return p ? { file: p, line: 1 } : null;
82
+ },
83
+ xmlTags: (ctx) => {
84
+ const p = claudeMdPath(ctx);
85
+ return p ? { file: p, line: 1 } : null;
86
+ },
87
+ fewShotExamples: (ctx) => {
88
+ const p = claudeMdPath(ctx);
89
+ return p ? { file: p, line: 1 } : null;
90
+ },
91
+ outputStyleGuidance: (ctx) => {
92
+ const p = claudeMdPath(ctx);
93
+ return p ? { file: p, line: 1 } : null;
94
+ },
95
+ constraintBlocks: (ctx) => {
96
+ const p = claudeMdPath(ctx);
97
+ return p ? { file: p, line: 1 } : null;
98
+ },
99
+
100
+ // CLAUDE.local.md — genuinely absent → null
101
+ claudeLocalMd: (ctx) => {
102
+ if (typeof ctx.fileContent !== 'function') return null;
103
+ return ctx.fileContent('CLAUDE.local.md') !== null
104
+ ? { file: 'CLAUDE.local.md', line: 1 }
105
+ : null;
106
+ },
107
+
108
+ // --- .claude/settings.json shape checks ---
109
+ settingsPermissions: (ctx) => {
110
+ if (typeof ctx.fileContent !== 'function') return null;
111
+ if (ctx.fileContent('.claude/settings.json') === null) return null;
112
+ const line = typeof ctx.lineNumber === 'function'
113
+ ? (ctx.lineNumber('.claude/settings.json', /"permissions"/) || 1)
114
+ : 1;
115
+ return { file: '.claude/settings.json', line };
116
+ },
117
+ permissionDeny: (ctx) => {
118
+ if (typeof ctx.fileContent !== 'function') return null;
119
+ if (ctx.fileContent('.claude/settings.json') === null) return null;
120
+ const line = typeof ctx.lineNumber === 'function'
121
+ ? (ctx.lineNumber('.claude/settings.json', /"deny"/) || 1)
122
+ : 1;
123
+ return { file: '.claude/settings.json', line };
124
+ },
125
+ noBypassPermissions: (ctx) => {
126
+ if (typeof ctx.fileContent !== 'function') return null;
127
+ if (ctx.fileContent('.claude/settings.json') === null) return null;
128
+ const line = typeof ctx.lineNumber === 'function'
129
+ ? (ctx.lineNumber('.claude/settings.json', /bypassPermissions|permissionMode/) || 1)
130
+ : 1;
131
+ return { file: '.claude/settings.json', line };
132
+ },
133
+ secretsProtection: (ctx) => {
134
+ if (typeof ctx.fileContent !== 'function') return null;
135
+ if (ctx.fileContent('.claude/settings.json') === null) return null;
136
+ return { file: '.claude/settings.json', line: 1 };
137
+ },
138
+
139
+ // --- Hooks files ---
140
+ hooks: (ctx) => {
141
+ if (typeof ctx.fileContent !== 'function') return null;
142
+ if (ctx.fileContent('.claude/settings.json') === null) return null;
143
+ const line = typeof ctx.lineNumber === 'function'
144
+ ? (ctx.lineNumber('.claude/settings.json', /"hooks"/) || 1)
145
+ : 1;
146
+ return { file: '.claude/settings.json', line };
147
+ },
148
+ stopFailureHook: (ctx) => {
149
+ if (typeof ctx.fileContent !== 'function') return null;
150
+ if (ctx.fileContent('.claude/settings.json') === null) return null;
151
+ return { file: '.claude/settings.json', line: 1 };
152
+ },
153
+ hooksNotificationEvent: (ctx) => {
154
+ if (typeof ctx.fileContent !== 'function') return null;
155
+ if (ctx.fileContent('.claude/settings.json') === null) return null;
156
+ return { file: '.claude/settings.json', line: 1 };
157
+ },
158
+ subagentStopHook: (ctx) => {
159
+ if (typeof ctx.fileContent !== 'function') return null;
160
+ if (ctx.fileContent('.claude/settings.json') === null) return null;
161
+ return { file: '.claude/settings.json', line: 1 };
162
+ },
163
+
164
+ // --- AGENTS.md (Codex surface) ---
165
+ codexAgentsMd: (ctx) => {
166
+ const p = agentsMdPath(ctx);
167
+ return p ? { file: p, line: 1 } : null;
168
+ },
169
+ codexAgentsMdSubstantive: (ctx) => {
170
+ const p = agentsMdPath(ctx);
171
+ return p ? { file: p, line: 1 } : null;
172
+ },
173
+ codexAgentsVerificationCommands: (ctx) => {
174
+ const p = agentsMdPath(ctx);
175
+ return p ? { file: p, line: 1 } : null;
176
+ },
177
+ codexAgentsArchitecture: (ctx) => {
178
+ const p = agentsMdPath(ctx);
179
+ return p ? { file: p, line: 1 } : null;
180
+ },
181
+
182
+ // --- Auto-memory / governance ---
183
+ autoMemoryAwareness: (ctx) => {
184
+ const p = claudeMdPath(ctx);
185
+ return p ? { file: p, line: 1 } : null;
186
+ },
187
+ loopSafetyBoundaries: (ctx) => {
188
+ const p = claudeMdPath(ctx);
189
+ return p ? { file: p, line: 1 } : null;
190
+ },
191
+
192
+ // --- CLAUDE.md content-awareness checks (backport ~10 more keys) ---
193
+ compactionAwareness: (ctx) => {
194
+ const p = claudeMdPath(ctx);
195
+ return p ? { file: p, line: 1 } : null;
196
+ },
197
+ contextManagement: (ctx) => {
198
+ const p = claudeMdPath(ctx);
199
+ return p ? { file: p, line: 1 } : null;
200
+ },
201
+ effortLevelConfigured: (ctx) => {
202
+ const p = claudeMdPath(ctx);
203
+ return p ? { file: p, line: 1 } : null;
204
+ },
205
+ channelsAwareness: (ctx) => {
206
+ const p = claudeMdPath(ctx);
207
+ return p ? { file: p, line: 1 } : null;
208
+ },
209
+ worktreeAwareness: (ctx) => {
210
+ const p = claudeMdPath(ctx);
211
+ return p ? { file: p, line: 1 } : null;
212
+ },
213
+ instinctToSkillProgression: (ctx) => {
214
+ const p = claudeMdPath(ctx);
215
+ return p ? { file: p, line: 1 } : null;
216
+ },
217
+ sandboxAwareness: (ctx) => {
218
+ const p = claudeMdPath(ctx);
219
+ return p ? { file: p, line: 1 } : null;
220
+ },
221
+ gitAttributionDecision: (ctx) => {
222
+ const p = claudeMdPath(ctx);
223
+ return p ? { file: p, line: 1 } : null;
224
+ },
225
+
226
+ // --- MCP ---
227
+ mcpHasEnvConfig: (ctx) => {
228
+ if (typeof ctx.fileContent !== 'function') return null;
229
+ if (ctx.fileContent('.mcp.json') !== null) return { file: '.mcp.json', line: 1 };
230
+ if (ctx.fileContent('.claude/settings.json') !== null) {
231
+ const line = typeof ctx.lineNumber === 'function'
232
+ ? (ctx.lineNumber('.claude/settings.json', /"mcpServers"|"mcp"/) || 1)
233
+ : 1;
234
+ return { file: '.claude/settings.json', line };
235
+ }
236
+ return null;
237
+ },
238
+ };
239
+
240
+ /**
241
+ * Resolve file/line/snippet evidence for a failed check.
242
+ *
243
+ * @param {string} key Check key (e.g. 'importSyntax').
244
+ * @param {Object} ctx ProjectContext instance.
245
+ * @param {Object} [existing] Pre-existing { file, line } from the check itself.
246
+ * @returns {{file:string, line:number|null, snippet:string|null}|null}
247
+ */
248
+ function resolveEvidence(key, ctx, existing = null) {
249
+ // If the check already emitted a real file, enrich with snippet only.
250
+ if (existing && existing.file) {
251
+ return buildEvidence(ctx, existing.file, existing.line);
252
+ }
253
+ const resolver = RESOLVERS[key];
254
+ if (!resolver) return null;
255
+ let guess = null;
256
+ try {
257
+ guess = resolver(ctx);
258
+ } catch {
259
+ return null;
260
+ }
261
+ if (!guess || !guess.file) return null;
262
+ return buildEvidence(ctx, guess.file, guess.line);
263
+ }
264
+
265
+ module.exports = {
266
+ resolveEvidence,
267
+ sliceSnippet,
268
+ SNIPPET_MAX_CHARS,
269
+ EVIDENCE_RESOLVER_KEYS: Object.keys(RESOLVERS),
270
+ };
package/src/audit.js CHANGED
@@ -35,6 +35,7 @@ const { collectAuditTerminology, formatTerminologyLines } = require('./terminolo
35
35
  const { loadPlugins, mergePluginChecks } = require('./plugins');
36
36
  const { detectDeprecationWarnings } = require('./deprecation');
37
37
  const { buildWorkspaceHint, formatCount, guardSkippedInstructionFiles, inspectInstructionFiles } = require('./audit/instruction-files');
38
+ const { resolveEvidence } = require('./audit/evidence');
38
39
  const {
39
40
  WEIGHTS,
40
41
  buildScoreCoaching,
@@ -412,13 +413,24 @@ async function audit(options) {
412
413
  }
413
414
 
414
415
  const passed = technique.check(ctx);
415
- const file = typeof technique.file === 'function' ? (technique.file(ctx) ?? null) : (technique.file ?? null);
416
- const line = typeof technique.line === 'function' ? (technique.line(ctx) ?? null) : (technique.line ?? null);
416
+ let file = typeof technique.file === 'function' ? (technique.file(ctx) ?? null) : (technique.file ?? null);
417
+ let line = typeof technique.line === 'function' ? (technique.line(ctx) ?? null) : (technique.line ?? null);
418
+ let snippet = null;
419
+ // CTO-04: only compute evidence on failed checks (cheap, and only where it adds trust).
420
+ if (passed === false) {
421
+ const evidence = resolveEvidence(key, ctx, { file, line });
422
+ if (evidence) {
423
+ file = evidence.file;
424
+ line = evidence.line;
425
+ snippet = evidence.snippet;
426
+ }
427
+ }
417
428
  results.push({
418
429
  key,
419
430
  ...technique,
420
431
  file,
421
432
  line: Number.isFinite(line) ? line : null,
433
+ snippet,
422
434
  passed,
423
435
  });
424
436
  }
@@ -493,6 +505,35 @@ async function audit(options) {
493
505
  const organicScore = maxScore > 0 ? Math.round((organicEarned / maxScore) * 100) : 0;
494
506
  const quickWins = getQuickWins(failed, { platform: spec.platform });
495
507
  const topNextActions = buildTopNextActions(failed, 5, outcomeSummary.byKey, { platform: spec.platform, fpFeedbackByKey: fpFeedback.byKey });
508
+
509
+ // CTO-04: enrich top actions with file/line/snippet from the corresponding
510
+ // result record (evidence was resolved above during the check loop).
511
+ // CTO-05: project score-after-fix per action.
512
+ const resultByKey = new Map(results.map((r) => [r.key, r]));
513
+ for (const action of topNextActions) {
514
+ const source = resultByKey.get(action.key);
515
+ if (source) {
516
+ if (source.file && !action.file) action.file = source.file;
517
+ if (source.line && !action.line) action.line = source.line;
518
+ if (source.snippet) action.snippet = source.snippet;
519
+ }
520
+ // Projected score delta: if this single failed check flipped to passed.
521
+ const weight = WEIGHTS[action.impact] || 0;
522
+ if (maxScore > 0 && weight > 0) {
523
+ const projectedScoreAfter = Math.round(((earnedScore + weight) / maxScore) * 100);
524
+ action.projectedScoreDelta = projectedScoreAfter - score;
525
+ action.projectedScoreAfter = projectedScoreAfter;
526
+ const isScaffolded = scaffoldedKeys.has(action.key);
527
+ const projectedOrganicAfter = isScaffolded
528
+ ? organicScore
529
+ : Math.round(((organicEarned + weight) / maxScore) * 100);
530
+ action.projectedOrganicScoreDelta = projectedOrganicAfter - organicScore;
531
+ } else {
532
+ action.projectedScoreDelta = 0;
533
+ action.projectedScoreAfter = score;
534
+ action.projectedOrganicScoreDelta = 0;
535
+ }
536
+ }
496
537
  const categoryScores = computeCategoryScores(applicable, passed);
497
538
  const platformScopeNote = getPlatformScopeNote(spec, ctx);
498
539
  const platformCaveats = getPlatformCaveats(spec, ctx);
@@ -815,7 +856,10 @@ async function audit(options) {
815
856
  console.log(colorize(' ⚡ Top 5 Next Actions', 'magenta'));
816
857
  for (let i = 0; i < topNextActions.length; i++) {
817
858
  const item = topNextActions[i];
818
- console.log(` ${i + 1}. ${colorize(item.name, 'bold')}`);
859
+ const delta = Number.isFinite(item.projectedScoreDelta) && item.projectedScoreDelta > 0
860
+ ? colorize(` (+${item.projectedScoreDelta} pts → ${item.projectedScoreAfter}/100)`, 'green')
861
+ : '';
862
+ console.log(` ${i + 1}. ${colorize(item.name, 'bold')}${delta}`);
819
863
  console.log(colorize(` Why: ${item.why}`, 'dim'));
820
864
  console.log(colorize(` Trace: ${item.signals.join(' | ')}`, 'dim'));
821
865
  console.log(colorize(` Risk: ${item.risk} | Confidence: ${item.confidence}`, 'dim'));
@@ -28,6 +28,8 @@ const COLUMNS = [
28
28
  'line',
29
29
  'sourceUrl',
30
30
  'fix',
31
+ 'projectedScoreDelta',
32
+ 'projectedScoreAfter',
31
33
  ];
32
34
 
33
35
  function csvEscape(value) {
@@ -39,8 +41,9 @@ function csvEscape(value) {
39
41
  return s;
40
42
  }
41
43
 
42
- function rowFor(r) {
44
+ function rowFor(r, projections = null) {
43
45
  const severity = r.severity || r.impact || '';
46
+ const proj = projections && projections.get(r.key);
44
47
  const cells = [
45
48
  r.key ?? '',
46
49
  r.id ?? '',
@@ -53,15 +56,23 @@ function rowFor(r) {
53
56
  r.line ?? '',
54
57
  r.sourceUrl ?? '',
55
58
  r.fix ?? '',
59
+ proj && Number.isFinite(proj.projectedScoreDelta) ? String(proj.projectedScoreDelta) : '',
60
+ proj && Number.isFinite(proj.projectedScoreAfter) ? String(proj.projectedScoreAfter) : '',
56
61
  ];
57
62
  return cells.map(csvEscape).join(',');
58
63
  }
59
64
 
60
65
  function formatCsv(auditResult) {
61
66
  const results = Array.isArray(auditResult.results) ? auditResult.results : [];
67
+ const projections = new Map();
68
+ if (Array.isArray(auditResult.topNextActions)) {
69
+ for (const item of auditResult.topNextActions) {
70
+ if (item && item.key) projections.set(item.key, item);
71
+ }
72
+ }
62
73
  const lines = [COLUMNS.join(',')];
63
74
  for (const r of results) {
64
- lines.push(rowFor(r));
75
+ lines.push(rowFor(r, projections));
65
76
  }
66
77
  return lines.join('\n');
67
78
  }
@@ -76,6 +76,7 @@ function formatJUnit(auditResult) {
76
76
  let body = `${r.name || ''}`;
77
77
  if (r.file) body += ` at ${r.file}${r.line ? ':' + r.line : ''}`;
78
78
  if (r.sourceUrl) body += ` (${r.sourceUrl})`;
79
+ if (r.snippet) body += `\n---\n${r.snippet}`;
79
80
  lines.push(` <testcase classname="${classname}" name="${name}" time="0">`);
80
81
  lines.push(` <failure message="${msg}" type="${type}">${escapeXml(body)}</failure>`);
81
82
  lines.push(` </testcase>`);
@@ -74,11 +74,23 @@ function formatMarkdown(auditResult, options = {}) {
74
74
  if (item.file) {
75
75
  loc = ` — \`${escapeInline(item.file)}${item.line ? ':' + item.line : ''}\``;
76
76
  }
77
- lines.push(`- [ ] **[${sev}] ${title}** (\`${key}\`)${loc}`);
77
+ let delta = '';
78
+ if (Number.isFinite(item.projectedScoreDelta) && item.projectedScoreDelta > 0) {
79
+ delta = ` (+${item.projectedScoreDelta} pts → ${item.projectedScoreAfter}/100)`;
80
+ }
81
+ lines.push(`- [ ] **[${sev}] ${title}** (\`${key}\`)${loc}${delta}`);
78
82
  const hint = item.fix || item.hint || '';
79
83
  if (hint) {
80
84
  lines.push(` - ${escapeInline(hint)}`);
81
85
  }
86
+ if (item.snippet) {
87
+ lines.push('');
88
+ lines.push(' ```');
89
+ for (const snipLine of String(item.snippet).split(/\r?\n/)) {
90
+ lines.push(` ${snipLine}`);
91
+ }
92
+ lines.push(' ```');
93
+ }
82
94
  }
83
95
  lines.push('');
84
96
  }