@nerviq/cli 1.29.0 → 1.29.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 (80) hide show
  1. package/CHANGELOG.md +1527 -1493
  2. package/README.md +550 -538
  3. package/SECURITY.md +82 -82
  4. package/bin/cli.js +2562 -2558
  5. package/docs/api-reference.md +356 -356
  6. package/docs/audit-fix.md +109 -0
  7. package/docs/autofix.md +3 -62
  8. package/docs/getting-started.md +1 -1
  9. package/docs/index.html +592 -592
  10. package/docs/integration-contracts.md +287 -287
  11. package/docs/maintenance.md +128 -128
  12. package/docs/new-platform-guide.md +202 -202
  13. package/docs/release-process.md +63 -0
  14. package/docs/shallow-risk.md +244 -244
  15. package/docs/why-nerviq.md +82 -82
  16. package/package.json +67 -67
  17. package/src/aider/activity.js +226 -226
  18. package/src/aider/context.js +162 -162
  19. package/src/aider/freshness.js +123 -123
  20. package/src/aider/techniques.js +3465 -3465
  21. package/src/audit/layers.js +180 -180
  22. package/src/audit.js +1032 -1032
  23. package/src/benchmark.js +299 -299
  24. package/src/codex/activity.js +324 -324
  25. package/src/codex/freshness.js +142 -142
  26. package/src/codex/techniques.js +4895 -4895
  27. package/src/context.js +326 -326
  28. package/src/continuous-ops.js +11 -1
  29. package/src/convert.js +340 -340
  30. package/src/copilot/config-parser.js +280 -280
  31. package/src/copilot/context.js +218 -218
  32. package/src/copilot/freshness.js +177 -177
  33. package/src/copilot/patch.js +238 -238
  34. package/src/copilot/techniques.js +3578 -3578
  35. package/src/cursor/freshness.js +194 -194
  36. package/src/cursor/patch.js +243 -243
  37. package/src/cursor/techniques.js +3735 -3735
  38. package/src/doctor.js +201 -201
  39. package/src/fix-engine.js +511 -8
  40. package/src/formatters/csv.js +86 -86
  41. package/src/formatters/junit.js +123 -123
  42. package/src/formatters/markdown.js +164 -164
  43. package/src/formatters/otel.js +151 -151
  44. package/src/freshness.js +156 -156
  45. package/src/gemini/activity.js +402 -402
  46. package/src/gemini/context.js +290 -290
  47. package/src/gemini/freshness.js +183 -183
  48. package/src/gemini/patch.js +229 -229
  49. package/src/gemini/techniques.js +3811 -3811
  50. package/src/governance.js +533 -533
  51. package/src/harmony/audit.js +306 -306
  52. package/src/i18n.js +63 -63
  53. package/src/insights.js +119 -119
  54. package/src/integrations.js +134 -134
  55. package/src/locales/en.json +33 -33
  56. package/src/locales/es.json +33 -33
  57. package/src/migrate.js +354 -354
  58. package/src/opencode/activity.js +286 -286
  59. package/src/opencode/freshness.js +137 -137
  60. package/src/opencode/techniques.js +3450 -3450
  61. package/src/setup/analysis.js +12 -12
  62. package/src/setup.js +7 -6
  63. package/src/shallow-risk/index.js +56 -56
  64. package/src/shallow-risk/patterns/agent-config-cross-platform-drift.js +50 -50
  65. package/src/shallow-risk/patterns/agent-config-dangerous-autoapprove.js +46 -46
  66. package/src/shallow-risk/patterns/agent-config-deprecated-keys.js +46 -46
  67. package/src/shallow-risk/patterns/agent-config-missing-file.js +317 -317
  68. package/src/shallow-risk/patterns/agent-config-secret-literal.js +49 -49
  69. package/src/shallow-risk/patterns/agent-config-stack-contradiction.js +34 -34
  70. package/src/shallow-risk/patterns/hook-script-missing.js +70 -70
  71. package/src/shallow-risk/patterns/mcp-server-no-allowlist.js +52 -52
  72. package/src/shallow-risk/shared.js +648 -648
  73. package/src/source-urls.js +295 -295
  74. package/src/state-paths.js +85 -85
  75. package/src/supplemental-checks.js +805 -805
  76. package/src/telemetry.js +160 -160
  77. package/src/windsurf/context.js +359 -359
  78. package/src/windsurf/freshness.js +194 -194
  79. package/src/windsurf/patch.js +231 -231
  80. package/src/windsurf/techniques.js +3779 -3779
package/src/governance.js CHANGED
@@ -2,108 +2,108 @@ const { DOMAIN_PACKS } = require('./domain-packs');
2
2
  const { MCP_PACKS, mergeMcpServers, normalizeMcpPackKeys } = require('./mcp-packs');
3
3
  const { getCodexGovernanceSummary } = require('./codex/governance');
4
4
  const { formatTerminologyLines } = require('./terminology');
5
-
6
- const PERMISSION_PROFILES = [
7
- {
8
- key: 'read-only',
9
- label: 'Read-Only',
10
- risk: 'low',
11
- defaultMode: 'plan',
12
- useWhen: 'Security review, discovery, and first contact with a mature repo.',
13
- behavior: 'No file writes. Safe for audits, workshops, and approval flows.',
14
- deny: ['Write(**)', 'Edit(**)', 'MultiEdit(**)', 'Bash(rm -rf *)', 'Bash(git reset --hard *)'],
15
- },
16
- {
17
- key: 'suggest-only',
18
- label: 'Suggest-Only',
19
- risk: 'low',
20
- defaultMode: 'acceptEdits',
21
- useWhen: 'Teams want structured proposals and exported plans without automatic apply.',
22
- behavior: 'Generates plans and proposal bundles, but no source changes are applied.',
23
- deny: ['Bash(rm -rf *)', 'Bash(git reset --hard *)', 'Bash(git clean *)', 'Read(./.env*)'],
24
- },
25
- {
26
- key: 'safe-write',
27
- label: 'Safe-Write',
28
- risk: 'medium',
29
- defaultMode: 'acceptEdits',
30
- useWhen: 'Starter repos or tightly scoped apply flows with visible rollback.',
31
- behavior: 'Allows creation of missing Claude artifacts while preserving existing files.',
32
- deny: ['Read(./.env*)', 'Read(./secrets/**)', 'Bash(rm -rf *)', 'Bash(git push --force *)'],
33
- },
34
- {
35
- key: 'power-user',
36
- label: 'Power-User',
37
- risk: 'medium',
38
- defaultMode: 'acceptEdits',
39
- useWhen: 'Experienced maintainers who understand the repo and want faster iteration.',
40
- behavior: 'Broader local automation with fewer prompts, still without bypass defaults.',
41
- deny: ['Read(./.env*)', 'Bash(rm -rf *)'],
42
- },
43
- {
44
- key: 'internal-research',
45
- label: 'Internal-Research',
46
- risk: 'high',
47
- defaultMode: 'bypassPermissions',
48
- useWhen: 'Internal experiments only, never as a product-facing default.',
49
- behavior: 'Maximum autonomy for research workflows, suitable only with explicit human oversight.',
50
- deny: [],
51
- },
52
- ];
53
-
54
- const HOOK_REGISTRY = [
55
- {
56
- key: 'protect-secrets',
57
- file: '.claude/hooks/protect-secrets.sh',
58
- triggerPoint: 'PreToolUse',
59
- matcher: 'Read|Write|Edit|Bash',
60
- purpose: 'Blocks direct access to secret or credential files before a tool runs.',
61
- filesTouched: [],
62
- sideEffects: ['Stops the action and returns a block decision when a secret path is targeted.'],
63
- risk: 'low',
64
- riskLevel: 'high',
65
- dryRunExample: 'Attempt to read `.env` and confirm the hook blocks the request.',
66
- rollbackPath: 'Remove the PreToolUse registration from settings.json.',
67
- },
68
- {
69
- key: 'on-edit-lint',
70
- file: '.claude/hooks/on-edit-lint.sh',
71
- triggerPoint: 'PostToolUse',
72
- matcher: 'Write|Edit',
73
- purpose: 'Runs the repo linter or formatter after file edits when tooling is available.',
74
- filesTouched: ['Working tree files targeted by eslint/ruff fixes'],
75
- sideEffects: ['May auto-fix formatting or lint issues.', 'Can modify the same files that were just edited.'],
76
- risk: 'medium',
77
- riskLevel: 'medium',
78
- dryRunExample: 'Edit a JS or Python file and inspect whether eslint or ruff would run.',
79
- rollbackPath: 'Remove the PostToolUse hook entry or delete the script.',
80
- },
81
- {
82
- key: 'log-changes',
83
- file: '.claude/hooks/log-changes.sh',
84
- triggerPoint: 'PostToolUse',
85
- matcher: 'Write|Edit',
86
- purpose: 'Appends a durable file-change log under `.claude/logs/` for later review.',
87
- filesTouched: ['.claude/logs/file-changes.log'],
88
- sideEffects: ['Creates the logs directory on first use.', 'Adds a timestamped audit line per file change.'],
89
- risk: 'low',
90
- riskLevel: 'low',
91
- dryRunExample: 'Edit one file and verify the log entry is appended.',
92
- rollbackPath: 'Remove the PostToolUse hook entry and delete the log file if desired.',
93
- },
94
- {
95
- key: 'duplicate-id-check',
96
- file: '.claude/hooks/check-duplicate-ids.sh',
97
- triggerPoint: 'PostToolUse',
98
- matcher: 'Write|Edit',
99
- purpose: 'Detects duplicate IDs in catalog or structured data files after edits.',
100
- filesTouched: [],
101
- sideEffects: ['Returns a systemMessage warning if duplicates are found.'],
102
- risk: 'low',
103
- riskLevel: 'low',
104
- dryRunExample: 'Edit a catalog file and verify duplicate check runs without blocking.',
105
- rollbackPath: 'Remove the PostToolUse hook entry from settings.',
106
- },
5
+
6
+ const PERMISSION_PROFILES = [
7
+ {
8
+ key: 'read-only',
9
+ label: 'Read-Only',
10
+ risk: 'low',
11
+ defaultMode: 'plan',
12
+ useWhen: 'Security review, discovery, and first contact with a mature repo.',
13
+ behavior: 'No file writes. Safe for audits, workshops, and approval flows.',
14
+ deny: ['Write(**)', 'Edit(**)', 'MultiEdit(**)', 'Bash(rm -rf *)', 'Bash(git reset --hard *)'],
15
+ },
16
+ {
17
+ key: 'suggest-only',
18
+ label: 'Suggest-Only',
19
+ risk: 'low',
20
+ defaultMode: 'acceptEdits',
21
+ useWhen: 'Teams want structured proposals and exported plans without automatic apply.',
22
+ behavior: 'Generates plans and proposal bundles, but no source changes are applied.',
23
+ deny: ['Bash(rm -rf *)', 'Bash(git reset --hard *)', 'Bash(git clean *)', 'Read(./.env*)'],
24
+ },
25
+ {
26
+ key: 'safe-write',
27
+ label: 'Safe-Write',
28
+ risk: 'medium',
29
+ defaultMode: 'acceptEdits',
30
+ useWhen: 'Starter repos or tightly scoped apply flows with visible rollback.',
31
+ behavior: 'Allows creation of missing Claude artifacts while preserving existing files.',
32
+ deny: ['Read(./.env*)', 'Read(./secrets/**)', 'Bash(rm -rf *)', 'Bash(git push --force *)'],
33
+ },
34
+ {
35
+ key: 'power-user',
36
+ label: 'Power-User',
37
+ risk: 'medium',
38
+ defaultMode: 'acceptEdits',
39
+ useWhen: 'Experienced maintainers who understand the repo and want faster iteration.',
40
+ behavior: 'Broader local automation with fewer prompts, still without bypass defaults.',
41
+ deny: ['Read(./.env*)', 'Bash(rm -rf *)'],
42
+ },
43
+ {
44
+ key: 'internal-research',
45
+ label: 'Internal-Research',
46
+ risk: 'high',
47
+ defaultMode: 'bypassPermissions',
48
+ useWhen: 'Internal experiments only, never as a product-facing default.',
49
+ behavior: 'Maximum autonomy for research workflows, suitable only with explicit human oversight.',
50
+ deny: [],
51
+ },
52
+ ];
53
+
54
+ const HOOK_REGISTRY = [
55
+ {
56
+ key: 'protect-secrets',
57
+ file: '.claude/hooks/protect-secrets.sh',
58
+ triggerPoint: 'PreToolUse',
59
+ matcher: 'Read|Write|Edit|Bash',
60
+ purpose: 'Blocks direct access to secret or credential files before a tool runs.',
61
+ filesTouched: [],
62
+ sideEffects: ['Stops the action and returns a block decision when a secret path is targeted.'],
63
+ risk: 'low',
64
+ riskLevel: 'high',
65
+ dryRunExample: 'Attempt to read `.env` and confirm the hook blocks the request.',
66
+ rollbackPath: 'Remove the PreToolUse registration from settings.json.',
67
+ },
68
+ {
69
+ key: 'on-edit-lint',
70
+ file: '.claude/hooks/on-edit-lint.sh',
71
+ triggerPoint: 'PostToolUse',
72
+ matcher: 'Write|Edit',
73
+ purpose: 'Runs the repo linter or formatter after file edits when tooling is available.',
74
+ filesTouched: ['Working tree files targeted by eslint/ruff fixes'],
75
+ sideEffects: ['May auto-fix formatting or lint issues.', 'Can modify the same files that were just edited.'],
76
+ risk: 'medium',
77
+ riskLevel: 'medium',
78
+ dryRunExample: 'Edit a JS or Python file and inspect whether eslint or ruff would run.',
79
+ rollbackPath: 'Remove the PostToolUse hook entry or delete the script.',
80
+ },
81
+ {
82
+ key: 'log-changes',
83
+ file: '.claude/hooks/log-changes.sh',
84
+ triggerPoint: 'PostToolUse',
85
+ matcher: 'Write|Edit',
86
+ purpose: 'Appends a durable file-change log under `.claude/logs/` for later review.',
87
+ filesTouched: ['.claude/logs/file-changes.log'],
88
+ sideEffects: ['Creates the logs directory on first use.', 'Adds a timestamped audit line per file change.'],
89
+ risk: 'low',
90
+ riskLevel: 'low',
91
+ dryRunExample: 'Edit one file and verify the log entry is appended.',
92
+ rollbackPath: 'Remove the PostToolUse hook entry and delete the log file if desired.',
93
+ },
94
+ {
95
+ key: 'duplicate-id-check',
96
+ file: '.claude/hooks/check-duplicate-ids.sh',
97
+ triggerPoint: 'PostToolUse',
98
+ matcher: 'Write|Edit',
99
+ purpose: 'Detects duplicate IDs in catalog or structured data files after edits.',
100
+ filesTouched: [],
101
+ sideEffects: ['Returns a systemMessage warning if duplicates are found.'],
102
+ risk: 'low',
103
+ riskLevel: 'low',
104
+ dryRunExample: 'Edit a catalog file and verify duplicate check runs without blocking.',
105
+ rollbackPath: 'Remove the PostToolUse hook entry from settings.',
106
+ },
107
107
  {
108
108
  key: 'injection-defense',
109
109
  file: '.claude/hooks/injection-defense.js',
@@ -117,188 +117,188 @@ const HOOK_REGISTRY = [
117
117
  dryRunExample: 'Run a WebFetch or MCP-backed tool call and verify suspicious content is logged for review.',
118
118
  rollbackPath: 'Remove the PostToolUse hook entry from settings.',
119
119
  },
120
- {
121
- key: 'trust-drift-check',
122
- file: '.claude/hooks/trust-drift-check.sh',
123
- triggerPoint: 'PostToolUse',
124
- matcher: 'Write|Edit',
125
- purpose: 'Runs trust drift validation after file changes to catch metric/docs inconsistencies.',
126
- filesTouched: [],
127
- sideEffects: ['Returns a systemMessage warning if drift is detected.'],
128
- risk: 'low',
129
- riskLevel: 'low',
130
- dryRunExample: 'Edit a product-facing file and verify drift check runs.',
131
- rollbackPath: 'Remove the PostToolUse hook entry from settings.',
132
- },
133
- {
134
- key: 'session-init',
135
- file: '.claude/hooks/session-start.sh',
136
- triggerPoint: 'SessionStart',
137
- matcher: null,
138
- purpose: 'Rotates large log files and loads workspace context at session start.',
139
- filesTouched: ['tools/change-log.txt', 'tools/failure-log.txt'],
140
- sideEffects: ['Archives logs over 500KB.', 'Returns a systemMessage with workspace info.'],
141
- risk: 'low',
142
- riskLevel: 'low',
143
- dryRunExample: 'Start a new session and verify log rotation runs.',
144
- rollbackPath: 'Remove the SessionStart hook entry from settings.',
145
- },
146
- ];
147
-
148
- /**
149
- * Classify the risk level of a hook based on its event type and characteristics.
150
- * - high: PreToolUse hooks that can block operations (exit 2)
151
- * - medium: PostToolUse hooks that modify files or warn (exit 1 or write-only)
152
- * - low: Informational hooks (PostToolUse notification/logging, SessionStart)
153
- * @param {Object} hook - A hook entry from HOOK_REGISTRY or a custom hook.
154
- * @returns {string} Risk level: 'high', 'medium', or 'low'.
155
- */
156
- function classifyHookRiskLevel(hook) {
157
- // PreToolUse hooks can block operations (exit code 2 blocks the tool call)
158
- if (hook.triggerPoint === 'PreToolUse') {
159
- return 'high';
160
- }
161
-
162
- // SessionStart hooks are informational — they run once at session init
163
- if (hook.triggerPoint === 'SessionStart') {
164
- return 'low';
165
- }
166
-
167
- // PostToolUse hooks that touch files are medium risk (they can modify working tree)
168
- if (hook.triggerPoint === 'PostToolUse') {
169
- if (Array.isArray(hook.filesTouched) && hook.filesTouched.length > 0) {
170
- // Hooks that only write to log files are low risk
171
- const onlyLogs = hook.filesTouched.every(f =>
172
- /\blog|\.log\b/i.test(f) || f.includes('logs/')
173
- );
174
- if (onlyLogs) return 'low';
175
- return 'medium';
176
- }
177
- return 'low';
178
- }
179
-
180
- // Default: unknown trigger points get medium risk
181
- return 'medium';
182
- }
183
-
184
- const POLICY_PACKS = [
185
- {
186
- key: 'baseline-engineering',
187
- label: 'Baseline Engineering',
188
- modules: ['CLAUDE.md baseline', 'commands', 'rules', 'safe-write profile'],
189
- useWhen: 'General product teams that want a pragmatic default.',
190
- },
191
- {
192
- key: 'security-sensitive',
193
- label: 'Security-Sensitive',
194
- modules: ['read-only profile', 'suggest-only mode', 'protect-secrets hook', 'approval checklist'],
195
- useWhen: 'Auth, payments, customer data, or regulated surfaces.',
196
- },
197
- {
198
- key: 'oss-friendly',
199
- label: 'OSS-Friendly',
200
- modules: ['suggest-only profile', 'minimal commands', 'light rules', 'manual merge expectations'],
201
- useWhen: 'Open-source repos with many external contributors.',
202
- },
203
- {
204
- key: 'regulated-lite',
205
- label: 'Regulated-Lite',
206
- modules: ['suggest-only or safe-write profile', 'activity artifacts', 'rollback manifests', 'benchmark evidence'],
207
- useWhen: 'Teams that need auditable change paths before broader adoption.',
208
- },
209
- ];
210
-
211
- const PILOT_ROLLOUT_KIT = {
212
- recommendedScope: [
213
- 'Pick 1-2 repos with active maintainers and low blast radius.',
214
- 'Run discover and suggest-only first; avoid direct writes on mature repos.',
215
- 'Choose one permission profile before any pilot starts.',
216
- 'Define success metrics before the first benchmark run.',
217
- ],
218
- approvals: [
219
- 'Engineering owner approves scope and rollback expectations.',
220
- 'Security owner approves the selected permission profile and hooks.',
221
- 'Pilot owner records the benchmark baseline and acceptance criteria.',
222
- ],
223
- successMetrics: [
224
- 'Score delta and organic score delta',
225
- 'Number of recommendations accepted',
226
- 'Time to first useful Claude workflow',
227
- 'Rollback-free apply rate',
228
- ],
229
- rollbackExpectations: [
230
- 'Every apply batch must emit a rollback artifact.',
231
- 'If a created artifact is rejected, delete the files listed in the rollback manifest.',
232
- 'Record the rollback event in the activity log for auditability.',
233
- ],
234
- };
235
-
236
- function clone(value) {
237
- return JSON.parse(JSON.stringify(value));
238
- }
239
-
240
- function mergeUnique(existing = [], additions = []) {
241
- return [...new Set([...(Array.isArray(existing) ? existing : []), ...additions])];
242
- }
243
-
244
- function mergeHooks(existingHooks = {}, nextHooks = {}) {
245
- const merged = clone(existingHooks || {});
246
-
247
- for (const [stage, blocks] of Object.entries(nextHooks)) {
248
- const targetBlocks = Array.isArray(merged[stage]) ? clone(merged[stage]) : [];
249
- for (const incoming of blocks) {
250
- const index = targetBlocks.findIndex(block => block.matcher === incoming.matcher);
251
- if (index === -1) {
252
- targetBlocks.push(clone(incoming));
253
- continue;
254
- }
255
-
256
- const current = targetBlocks[index];
257
- const existingCommands = new Set((current.hooks || []).map(hook => `${hook.type}:${hook.command}:${hook.timeout || ''}`));
258
- const mergedHooks = [...(current.hooks || [])];
259
- for (const hook of incoming.hooks || []) {
260
- const signature = `${hook.type}:${hook.command}:${hook.timeout || ''}`;
261
- if (!existingCommands.has(signature)) {
262
- mergedHooks.push(clone(hook));
263
- existingCommands.add(signature);
264
- }
265
- }
266
- targetBlocks[index] = { ...current, hooks: mergedHooks };
267
- }
268
- merged[stage] = targetBlocks;
269
- }
270
-
271
- return merged;
272
- }
273
-
274
- function getPermissionProfile(key = 'safe-write') {
275
- return PERMISSION_PROFILES.find(profile => profile.key === key) ||
276
- PERMISSION_PROFILES.find(profile => profile.key === 'safe-write');
277
- }
278
-
279
- function isWritableProfile(key = 'safe-write') {
280
- return ['safe-write', 'power-user', 'internal-research'].includes(getPermissionProfile(key).key);
281
- }
282
-
283
- function ensureWritableProfile(key = 'safe-write', commandName = 'apply', dryRun = false) {
284
- const profile = getPermissionProfile(key);
285
- if (!dryRun && !isWritableProfile(profile.key)) {
286
- throw new Error(`${commandName} requires a writable profile. Use --profile safe-write or --dry-run.`);
287
- }
288
- return profile;
289
- }
290
-
291
- function buildHookConfig(hookFiles, profileKey) {
292
- const profile = getPermissionProfile(profileKey);
293
- if (!isWritableProfile(profile.key)) {
294
- return {};
295
- }
296
-
297
- const uniqueFiles = [...new Set(hookFiles)].sort();
298
- if (uniqueFiles.length === 0) {
299
- return {};
300
- }
301
-
120
+ {
121
+ key: 'trust-drift-check',
122
+ file: '.claude/hooks/trust-drift-check.sh',
123
+ triggerPoint: 'PostToolUse',
124
+ matcher: 'Write|Edit',
125
+ purpose: 'Runs trust drift validation after file changes to catch metric/docs inconsistencies.',
126
+ filesTouched: [],
127
+ sideEffects: ['Returns a systemMessage warning if drift is detected.'],
128
+ risk: 'low',
129
+ riskLevel: 'low',
130
+ dryRunExample: 'Edit a product-facing file and verify drift check runs.',
131
+ rollbackPath: 'Remove the PostToolUse hook entry from settings.',
132
+ },
133
+ {
134
+ key: 'session-init',
135
+ file: '.claude/hooks/session-start.sh',
136
+ triggerPoint: 'SessionStart',
137
+ matcher: null,
138
+ purpose: 'Rotates large log files and loads workspace context at session start.',
139
+ filesTouched: ['tools/change-log.txt', 'tools/failure-log.txt'],
140
+ sideEffects: ['Archives logs over 500KB.', 'Returns a systemMessage with workspace info.'],
141
+ risk: 'low',
142
+ riskLevel: 'low',
143
+ dryRunExample: 'Start a new session and verify log rotation runs.',
144
+ rollbackPath: 'Remove the SessionStart hook entry from settings.',
145
+ },
146
+ ];
147
+
148
+ /**
149
+ * Classify the risk level of a hook based on its event type and characteristics.
150
+ * - high: PreToolUse hooks that can block operations (exit 2)
151
+ * - medium: PostToolUse hooks that modify files or warn (exit 1 or write-only)
152
+ * - low: Informational hooks (PostToolUse notification/logging, SessionStart)
153
+ * @param {Object} hook - A hook entry from HOOK_REGISTRY or a custom hook.
154
+ * @returns {string} Risk level: 'high', 'medium', or 'low'.
155
+ */
156
+ function classifyHookRiskLevel(hook) {
157
+ // PreToolUse hooks can block operations (exit code 2 blocks the tool call)
158
+ if (hook.triggerPoint === 'PreToolUse') {
159
+ return 'high';
160
+ }
161
+
162
+ // SessionStart hooks are informational — they run once at session init
163
+ if (hook.triggerPoint === 'SessionStart') {
164
+ return 'low';
165
+ }
166
+
167
+ // PostToolUse hooks that touch files are medium risk (they can modify working tree)
168
+ if (hook.triggerPoint === 'PostToolUse') {
169
+ if (Array.isArray(hook.filesTouched) && hook.filesTouched.length > 0) {
170
+ // Hooks that only write to log files are low risk
171
+ const onlyLogs = hook.filesTouched.every(f =>
172
+ /\blog|\.log\b/i.test(f) || f.includes('logs/')
173
+ );
174
+ if (onlyLogs) return 'low';
175
+ return 'medium';
176
+ }
177
+ return 'low';
178
+ }
179
+
180
+ // Default: unknown trigger points get medium risk
181
+ return 'medium';
182
+ }
183
+
184
+ const POLICY_PACKS = [
185
+ {
186
+ key: 'baseline-engineering',
187
+ label: 'Baseline Engineering',
188
+ modules: ['CLAUDE.md baseline', 'commands', 'rules', 'safe-write profile'],
189
+ useWhen: 'General product teams that want a pragmatic default.',
190
+ },
191
+ {
192
+ key: 'security-sensitive',
193
+ label: 'Security-Sensitive',
194
+ modules: ['read-only profile', 'suggest-only mode', 'protect-secrets hook', 'approval checklist'],
195
+ useWhen: 'Auth, payments, customer data, or regulated surfaces.',
196
+ },
197
+ {
198
+ key: 'oss-friendly',
199
+ label: 'OSS-Friendly',
200
+ modules: ['suggest-only profile', 'minimal commands', 'light rules', 'manual merge expectations'],
201
+ useWhen: 'Open-source repos with many external contributors.',
202
+ },
203
+ {
204
+ key: 'regulated-lite',
205
+ label: 'Regulated-Lite',
206
+ modules: ['suggest-only or safe-write profile', 'activity artifacts', 'rollback manifests', 'benchmark evidence'],
207
+ useWhen: 'Teams that need auditable change paths before broader adoption.',
208
+ },
209
+ ];
210
+
211
+ const PILOT_ROLLOUT_KIT = {
212
+ recommendedScope: [
213
+ 'Pick 1-2 repos with active maintainers and low blast radius.',
214
+ 'Run discover and suggest-only first; avoid direct writes on mature repos.',
215
+ 'Choose one permission profile before any pilot starts.',
216
+ 'Define success metrics before the first benchmark run.',
217
+ ],
218
+ approvals: [
219
+ 'Engineering owner approves scope and rollback expectations.',
220
+ 'Security owner approves the selected permission profile and hooks.',
221
+ 'Pilot owner records the benchmark baseline and acceptance criteria.',
222
+ ],
223
+ successMetrics: [
224
+ 'Score delta and organic score delta',
225
+ 'Number of recommendations accepted',
226
+ 'Time to first useful Claude workflow',
227
+ 'Rollback-free apply rate',
228
+ ],
229
+ rollbackExpectations: [
230
+ 'Every apply batch must emit a rollback artifact.',
231
+ 'If a created artifact is rejected, delete the files listed in the rollback manifest.',
232
+ 'Record the rollback event in the activity log for auditability.',
233
+ ],
234
+ };
235
+
236
+ function clone(value) {
237
+ return JSON.parse(JSON.stringify(value));
238
+ }
239
+
240
+ function mergeUnique(existing = [], additions = []) {
241
+ return [...new Set([...(Array.isArray(existing) ? existing : []), ...additions])];
242
+ }
243
+
244
+ function mergeHooks(existingHooks = {}, nextHooks = {}) {
245
+ const merged = clone(existingHooks || {});
246
+
247
+ for (const [stage, blocks] of Object.entries(nextHooks)) {
248
+ const targetBlocks = Array.isArray(merged[stage]) ? clone(merged[stage]) : [];
249
+ for (const incoming of blocks) {
250
+ const index = targetBlocks.findIndex(block => block.matcher === incoming.matcher);
251
+ if (index === -1) {
252
+ targetBlocks.push(clone(incoming));
253
+ continue;
254
+ }
255
+
256
+ const current = targetBlocks[index];
257
+ const existingCommands = new Set((current.hooks || []).map(hook => `${hook.type}:${hook.command}:${hook.timeout || ''}`));
258
+ const mergedHooks = [...(current.hooks || [])];
259
+ for (const hook of incoming.hooks || []) {
260
+ const signature = `${hook.type}:${hook.command}:${hook.timeout || ''}`;
261
+ if (!existingCommands.has(signature)) {
262
+ mergedHooks.push(clone(hook));
263
+ existingCommands.add(signature);
264
+ }
265
+ }
266
+ targetBlocks[index] = { ...current, hooks: mergedHooks };
267
+ }
268
+ merged[stage] = targetBlocks;
269
+ }
270
+
271
+ return merged;
272
+ }
273
+
274
+ function getPermissionProfile(key = 'safe-write') {
275
+ return PERMISSION_PROFILES.find(profile => profile.key === key) ||
276
+ PERMISSION_PROFILES.find(profile => profile.key === 'safe-write');
277
+ }
278
+
279
+ function isWritableProfile(key = 'safe-write') {
280
+ return ['safe-write', 'power-user', 'internal-research'].includes(getPermissionProfile(key).key);
281
+ }
282
+
283
+ function ensureWritableProfile(key = 'safe-write', commandName = 'apply', dryRun = false) {
284
+ const profile = getPermissionProfile(key);
285
+ if (!dryRun && !isWritableProfile(profile.key)) {
286
+ throw new Error(`${commandName} requires a writable profile. Use --profile safe-write or --dry-run.`);
287
+ }
288
+ return profile;
289
+ }
290
+
291
+ function buildHookConfig(hookFiles, profileKey) {
292
+ const profile = getPermissionProfile(profileKey);
293
+ if (!isWritableProfile(profile.key)) {
294
+ return {};
295
+ }
296
+
297
+ const uniqueFiles = [...new Set(hookFiles)].sort();
298
+ if (uniqueFiles.length === 0) {
299
+ return {};
300
+ }
301
+
302
302
  // Detect hook runtime: .js files use node, .sh files use bash
303
303
  const hookCommand = (file) => {
304
304
  if (file.endsWith('.js')) return `node .claude/hooks/${file}`;
@@ -317,30 +317,30 @@ function buildHookConfig(hookFiles, profileKey) {
317
317
  type: 'command',
318
318
  command: hookCommand(file),
319
319
  timeout: 10,
320
- })),
321
- }],
322
- };
323
-
324
- const secretsFile = uniqueFiles.find(isSecrets);
325
- if (secretsFile) {
326
- hookConfig.PreToolUse = [{
327
- matcher: 'Read|Write|Edit|Bash',
328
- hooks: [{
329
- type: 'command',
330
- command: hookCommand(secretsFile),
331
- timeout: 5,
332
- }],
333
- }];
334
- }
335
-
336
- const sessionFile = uniqueFiles.find(isSession);
320
+ })),
321
+ }],
322
+ };
323
+
324
+ const secretsFile = uniqueFiles.find(isSecrets);
325
+ if (secretsFile) {
326
+ hookConfig.PreToolUse = [{
327
+ matcher: 'Read|Write|Edit|Bash',
328
+ hooks: [{
329
+ type: 'command',
330
+ command: hookCommand(secretsFile),
331
+ timeout: 5,
332
+ }],
333
+ }];
334
+ }
335
+
336
+ const sessionFile = uniqueFiles.find(isSession);
337
337
  if (sessionFile) {
338
338
  hookConfig.SessionStart = [{
339
339
  matcher: '*',
340
340
  hooks: [{
341
341
  type: 'command',
342
- command: hookCommand(sessionFile),
343
- timeout: 5,
342
+ command: hookCommand(sessionFile),
343
+ timeout: 5,
344
344
  }],
345
345
  }];
346
346
  }
@@ -357,70 +357,70 @@ function buildHookConfig(hookFiles, profileKey) {
357
357
  }],
358
358
  });
359
359
  }
360
-
361
- if ((hookConfig.PostToolUse[0].hooks || []).length === 0) {
362
- delete hookConfig.PostToolUse;
363
- }
364
-
365
- return hookConfig;
366
- }
367
-
368
- function buildSettingsForProfile({ profileKey = 'safe-write', hookFiles = [], existingSettings = null, mcpPackKeys = [] } = {}) {
369
- const profile = getPermissionProfile(profileKey);
370
- const base = existingSettings ? clone(existingSettings) : {};
371
- const selectedMcpPacks = normalizeMcpPackKeys(mcpPackKeys);
372
- base.permissions = base.permissions || {};
373
- base.permissions.defaultMode = profile.defaultMode;
374
- base.permissions.deny = mergeUnique(base.permissions.deny, profile.deny);
375
-
376
- const hookConfig = buildHookConfig(hookFiles, profile.key);
377
- if (Object.keys(hookConfig).length > 0) {
378
- base.hooks = mergeHooks(base.hooks, hookConfig);
379
- }
380
-
381
- if (selectedMcpPacks.length > 0) {
382
- base.mcpServers = mergeMcpServers(base.mcpServers, selectedMcpPacks);
383
- }
384
-
385
- base.nerviqSetup = {
386
- ...(base.nerviqSetup || {}),
387
- profile: profile.key,
388
- mcpPacks: selectedMcpPacks,
389
- };
390
-
391
- return base;
392
- }
393
-
394
- /**
395
- * Return the full governance surface: permission profiles, hooks, policy packs, and pilot kit.
396
- * @returns {Object} Summary containing permissionProfiles, hookRegistry, policyPacks, domainPacks, mcpPacks, and pilotRolloutKit.
397
- */
398
- function getGovernanceSummary(platform = 'claude') {
399
- if (platform === 'codex') {
400
- return getCodexGovernanceSummary();
401
- }
402
-
403
- return {
404
- platform: 'claude',
405
- platformLabel: 'Claude',
406
- permissionProfiles: PERMISSION_PROFILES,
407
- hookRegistry: HOOK_REGISTRY,
408
- policyPacks: POLICY_PACKS,
409
- domainPacks: DOMAIN_PACKS,
410
- mcpPacks: MCP_PACKS,
411
- pilotRolloutKit: PILOT_ROLLOUT_KIT,
412
- };
413
- }
414
-
415
- function printGovernanceSummary(summary, options = {}) {
416
- if (options.json) {
417
- console.log(JSON.stringify(summary, null, 2));
418
- return;
419
- }
420
-
421
- console.log('');
422
- console.log(` nerviq ${summary.platformLabel.toLowerCase()} governance`);
423
- console.log(' ═══════════════════════════════════════');
360
+
361
+ if ((hookConfig.PostToolUse[0].hooks || []).length === 0) {
362
+ delete hookConfig.PostToolUse;
363
+ }
364
+
365
+ return hookConfig;
366
+ }
367
+
368
+ function buildSettingsForProfile({ profileKey = 'safe-write', hookFiles = [], existingSettings = null, mcpPackKeys = [] } = {}) {
369
+ const profile = getPermissionProfile(profileKey);
370
+ const base = existingSettings ? clone(existingSettings) : {};
371
+ const selectedMcpPacks = normalizeMcpPackKeys(mcpPackKeys);
372
+ base.permissions = base.permissions || {};
373
+ base.permissions.defaultMode = profile.defaultMode;
374
+ base.permissions.deny = mergeUnique(base.permissions.deny, profile.deny);
375
+
376
+ const hookConfig = buildHookConfig(hookFiles, profile.key);
377
+ if (Object.keys(hookConfig).length > 0) {
378
+ base.hooks = mergeHooks(base.hooks, hookConfig);
379
+ }
380
+
381
+ if (selectedMcpPacks.length > 0) {
382
+ base.mcpServers = mergeMcpServers(base.mcpServers, selectedMcpPacks);
383
+ }
384
+
385
+ base.nerviqSetup = {
386
+ ...(base.nerviqSetup || {}),
387
+ profile: profile.key,
388
+ mcpPacks: selectedMcpPacks,
389
+ };
390
+
391
+ return base;
392
+ }
393
+
394
+ /**
395
+ * Return the full governance surface: permission profiles, hooks, policy packs, and pilot kit.
396
+ * @returns {Object} Summary containing permissionProfiles, hookRegistry, policyPacks, domainPacks, mcpPacks, and pilotRolloutKit.
397
+ */
398
+ function getGovernanceSummary(platform = 'claude') {
399
+ if (platform === 'codex') {
400
+ return getCodexGovernanceSummary();
401
+ }
402
+
403
+ return {
404
+ platform: 'claude',
405
+ platformLabel: 'Claude',
406
+ permissionProfiles: PERMISSION_PROFILES,
407
+ hookRegistry: HOOK_REGISTRY,
408
+ policyPacks: POLICY_PACKS,
409
+ domainPacks: DOMAIN_PACKS,
410
+ mcpPacks: MCP_PACKS,
411
+ pilotRolloutKit: PILOT_ROLLOUT_KIT,
412
+ };
413
+ }
414
+
415
+ function printGovernanceSummary(summary, options = {}) {
416
+ if (options.json) {
417
+ console.log(JSON.stringify(summary, null, 2));
418
+ return;
419
+ }
420
+
421
+ console.log('');
422
+ console.log(` nerviq ${summary.platformLabel.toLowerCase()} governance`);
423
+ console.log(' ═══════════════════════════════════════');
424
424
  console.log(` Safe defaults, hook transparency, and pilot guidance for ${summary.platformLabel}.`);
425
425
  console.log('');
426
426
 
@@ -430,166 +430,166 @@ function printGovernanceSummary(summary, options = {}) {
430
430
  console.log('');
431
431
 
432
432
  console.log(' Permission Profiles');
433
- for (const profile of summary.permissionProfiles) {
434
- console.log(` - ${profile.label} [${profile.risk}]`);
435
- console.log(` ${profile.useWhen}`);
436
- console.log(` defaultMode=${profile.defaultMode}`);
437
- }
438
- console.log('');
439
-
440
- console.log(' Hook Registry');
441
- for (const hook of summary.hookRegistry) {
442
- const riskColor = hook.risk === 'low' ? '\x1b[32m' : hook.risk === 'medium' ? '\x1b[33m' : '\x1b[31m';
443
- const rl = hook.riskLevel || classifyHookRiskLevel(hook);
444
- const rlColor = rl === 'low' ? '\x1b[32m' : rl === 'medium' ? '\x1b[33m' : '\x1b[31m';
445
- console.log(` - ${hook.file} ${riskColor}[${hook.risk} risk]\x1b[0m ${rlColor}[${rl} riskLevel]\x1b[0m`);
446
- console.log(` ${hook.triggerPoint}${hook.matcher ? ` ${hook.matcher}` : ''} -> ${hook.purpose}`);
447
- }
448
- console.log('');
449
-
450
- console.log(' Policy Packs');
451
- for (const pack of summary.policyPacks) {
452
- console.log(` - ${pack.label}: ${pack.modules.join(', ')}`);
453
- }
454
- console.log('');
455
-
456
- const domainPacks = summary.domainPacks || [];
457
- const mcpPacks = summary.mcpPacks || [];
458
- const compact = !options.verbose;
459
- const COMPACT_LIMIT = 5;
460
-
461
- console.log(` Domain Packs (${domainPacks.length})`);
462
- if (domainPacks.length === 0) {
463
- console.log(' - none shipped yet for this platform');
464
- }
465
- const domainShow = compact ? domainPacks.slice(0, COMPACT_LIMIT) : domainPacks;
466
- for (const pack of domainShow) {
467
- console.log(` - ${pack.label}: ${pack.useWhen}`);
468
- }
469
- if (compact && domainPacks.length > COMPACT_LIMIT) {
470
- console.log(` ... and ${domainPacks.length - COMPACT_LIMIT} more (use --verbose to see all)`);
471
- }
472
- console.log('');
473
-
474
- console.log(` MCP Packs (${mcpPacks.length})`);
475
- if (mcpPacks.length === 0) {
476
- console.log(' - none shipped yet for this platform');
477
- }
478
- const mcpShow = compact ? mcpPacks.slice(0, COMPACT_LIMIT) : mcpPacks;
479
- for (const pack of mcpShow) {
480
- console.log(` - ${pack.label}: ${Object.keys(pack.servers).join(', ')}`);
481
- }
482
- if (compact && mcpPacks.length > COMPACT_LIMIT) {
483
- console.log(` ... and ${mcpPacks.length - COMPACT_LIMIT} more (use --verbose to see all)`);
484
- }
485
- console.log('');
486
-
487
- if (Array.isArray(summary.platformCaveats) && summary.platformCaveats.length > 0) {
488
- console.log(' Platform Caveats');
489
- for (const item of summary.platformCaveats) {
490
- console.log(` - ${item}`);
491
- }
492
- console.log('');
493
- }
494
-
495
- console.log(' Pilot Rollout Kit');
496
- for (const item of summary.pilotRolloutKit.recommendedScope) {
497
- console.log(` - ${item}`);
498
- }
499
- console.log('');
500
- }
501
-
502
- /**
503
- * Render a governance summary as a formatted markdown string.
504
- * @param {Object} summary - The summary object returned by getGovernanceSummary().
505
- * @returns {string} Markdown-formatted governance report.
506
- */
507
- function renderGovernanceMarkdown(summary) {
508
- const lines = [
509
- '# NERVIQ CLI Governance Report',
510
- '',
511
- `Platform: ${summary.platformLabel}`,
512
- '',
513
- `This report summarizes the shipped governance surface for ${summary.platformLabel} rollout, review, and pilot approval.`,
514
- '',
515
- '## Permission Profiles',
516
- ];
517
-
518
- for (const profile of summary.permissionProfiles) {
519
- lines.push(`- **${profile.label}** \`${profile.key}\` | risk: \`${profile.risk}\` | defaultMode: \`${profile.defaultMode}\``);
520
- lines.push(` - Use when: ${profile.useWhen}`);
521
- lines.push(` - Behavior: ${profile.behavior}`);
522
- if (Array.isArray(profile.deny) && profile.deny.length > 0) {
523
- lines.push(` - Deny rules: ${profile.deny.join(', ')}`);
524
- }
525
- }
526
-
527
- lines.push('', '## Hook Registry');
528
- for (const hook of summary.hookRegistry) {
529
- const rl = hook.riskLevel || classifyHookRiskLevel(hook);
530
- lines.push(`- **${hook.key}** \`${hook.triggerPoint}${hook.matcher ? ` ${hook.matcher}` : ''}\` | risk: \`${hook.risk}\` | riskLevel: \`${rl}\``);
531
- lines.push(` - File: ${hook.file}`);
532
- lines.push(` - Purpose: ${hook.purpose}`);
533
- lines.push(` - Dry run: ${hook.dryRunExample}`);
534
- lines.push(` - Rollback: ${hook.rollbackPath}`);
535
- }
536
-
537
- lines.push('', '## Policy Packs');
538
- for (const pack of summary.policyPacks) {
539
- lines.push(`- **${pack.label}**`);
540
- lines.push(` - Use when: ${pack.useWhen}`);
541
- lines.push(` - Modules: ${pack.modules.join(', ')}`);
542
- }
543
-
544
- lines.push('', `## Domain Packs (${(summary.domainPacks || []).length})`);
545
- for (const pack of summary.domainPacks || []) {
546
- lines.push(`- **${pack.label}**: ${pack.useWhen}`);
547
- }
548
- if ((summary.domainPacks || []).length === 0) {
549
- lines.push('- None shipped yet for this platform.');
550
- }
551
-
552
- lines.push('', `## MCP Packs (${(summary.mcpPacks || []).length})`);
553
- for (const pack of summary.mcpPacks || []) {
554
- lines.push(`- **${pack.label}**: ${Object.keys(pack.servers).join(', ')}`);
555
- }
556
- if ((summary.mcpPacks || []).length === 0) {
557
- lines.push('- None shipped yet for this platform.');
558
- }
559
-
560
- if (Array.isArray(summary.platformCaveats) && summary.platformCaveats.length > 0) {
561
- lines.push('', '## Platform Caveats');
562
- for (const item of summary.platformCaveats) {
563
- lines.push(`- ${item}`);
564
- }
565
- }
566
-
567
- lines.push('', '## Pilot Rollout Kit', '### Recommended Scope');
568
- for (const item of summary.pilotRolloutKit.recommendedScope) {
569
- lines.push(`- ${item}`);
570
- }
571
-
572
- lines.push('', '### Approvals');
573
- for (const item of summary.pilotRolloutKit.approvals) {
574
- lines.push(`- ${item}`);
575
- }
576
-
577
- lines.push('', '### Success Metrics');
578
- for (const item of summary.pilotRolloutKit.successMetrics) {
579
- lines.push(`- ${item}`);
580
- }
581
-
582
- lines.push('', '### Rollback Expectations');
583
- for (const item of summary.pilotRolloutKit.rollbackExpectations) {
584
- lines.push(`- ${item}`);
585
- }
586
-
587
- lines.push('');
588
- lines.push('---');
589
- lines.push(`*Generated by nerviq v${require('../package.json').version} on ${new Date().toISOString().split('T')[0]}*`);
590
- return lines.join('\n');
591
- }
592
-
433
+ for (const profile of summary.permissionProfiles) {
434
+ console.log(` - ${profile.label} [${profile.risk}]`);
435
+ console.log(` ${profile.useWhen}`);
436
+ console.log(` defaultMode=${profile.defaultMode}`);
437
+ }
438
+ console.log('');
439
+
440
+ console.log(' Hook Registry');
441
+ for (const hook of summary.hookRegistry) {
442
+ const riskColor = hook.risk === 'low' ? '\x1b[32m' : hook.risk === 'medium' ? '\x1b[33m' : '\x1b[31m';
443
+ const rl = hook.riskLevel || classifyHookRiskLevel(hook);
444
+ const rlColor = rl === 'low' ? '\x1b[32m' : rl === 'medium' ? '\x1b[33m' : '\x1b[31m';
445
+ console.log(` - ${hook.file} ${riskColor}[${hook.risk} risk]\x1b[0m ${rlColor}[${rl} riskLevel]\x1b[0m`);
446
+ console.log(` ${hook.triggerPoint}${hook.matcher ? ` ${hook.matcher}` : ''} -> ${hook.purpose}`);
447
+ }
448
+ console.log('');
449
+
450
+ console.log(' Policy Packs');
451
+ for (const pack of summary.policyPacks) {
452
+ console.log(` - ${pack.label}: ${pack.modules.join(', ')}`);
453
+ }
454
+ console.log('');
455
+
456
+ const domainPacks = summary.domainPacks || [];
457
+ const mcpPacks = summary.mcpPacks || [];
458
+ const compact = !options.verbose;
459
+ const COMPACT_LIMIT = 5;
460
+
461
+ console.log(` Domain Packs (${domainPacks.length})`);
462
+ if (domainPacks.length === 0) {
463
+ console.log(' - none shipped yet for this platform');
464
+ }
465
+ const domainShow = compact ? domainPacks.slice(0, COMPACT_LIMIT) : domainPacks;
466
+ for (const pack of domainShow) {
467
+ console.log(` - ${pack.label}: ${pack.useWhen}`);
468
+ }
469
+ if (compact && domainPacks.length > COMPACT_LIMIT) {
470
+ console.log(` ... and ${domainPacks.length - COMPACT_LIMIT} more (use --verbose to see all)`);
471
+ }
472
+ console.log('');
473
+
474
+ console.log(` MCP Packs (${mcpPacks.length})`);
475
+ if (mcpPacks.length === 0) {
476
+ console.log(' - none shipped yet for this platform');
477
+ }
478
+ const mcpShow = compact ? mcpPacks.slice(0, COMPACT_LIMIT) : mcpPacks;
479
+ for (const pack of mcpShow) {
480
+ console.log(` - ${pack.label}: ${Object.keys(pack.servers).join(', ')}`);
481
+ }
482
+ if (compact && mcpPacks.length > COMPACT_LIMIT) {
483
+ console.log(` ... and ${mcpPacks.length - COMPACT_LIMIT} more (use --verbose to see all)`);
484
+ }
485
+ console.log('');
486
+
487
+ if (Array.isArray(summary.platformCaveats) && summary.platformCaveats.length > 0) {
488
+ console.log(' Platform Caveats');
489
+ for (const item of summary.platformCaveats) {
490
+ console.log(` - ${item}`);
491
+ }
492
+ console.log('');
493
+ }
494
+
495
+ console.log(' Pilot Rollout Kit');
496
+ for (const item of summary.pilotRolloutKit.recommendedScope) {
497
+ console.log(` - ${item}`);
498
+ }
499
+ console.log('');
500
+ }
501
+
502
+ /**
503
+ * Render a governance summary as a formatted markdown string.
504
+ * @param {Object} summary - The summary object returned by getGovernanceSummary().
505
+ * @returns {string} Markdown-formatted governance report.
506
+ */
507
+ function renderGovernanceMarkdown(summary) {
508
+ const lines = [
509
+ '# NERVIQ CLI Governance Report',
510
+ '',
511
+ `Platform: ${summary.platformLabel}`,
512
+ '',
513
+ `This report summarizes the shipped governance surface for ${summary.platformLabel} rollout, review, and pilot approval.`,
514
+ '',
515
+ '## Permission Profiles',
516
+ ];
517
+
518
+ for (const profile of summary.permissionProfiles) {
519
+ lines.push(`- **${profile.label}** \`${profile.key}\` | risk: \`${profile.risk}\` | defaultMode: \`${profile.defaultMode}\``);
520
+ lines.push(` - Use when: ${profile.useWhen}`);
521
+ lines.push(` - Behavior: ${profile.behavior}`);
522
+ if (Array.isArray(profile.deny) && profile.deny.length > 0) {
523
+ lines.push(` - Deny rules: ${profile.deny.join(', ')}`);
524
+ }
525
+ }
526
+
527
+ lines.push('', '## Hook Registry');
528
+ for (const hook of summary.hookRegistry) {
529
+ const rl = hook.riskLevel || classifyHookRiskLevel(hook);
530
+ lines.push(`- **${hook.key}** \`${hook.triggerPoint}${hook.matcher ? ` ${hook.matcher}` : ''}\` | risk: \`${hook.risk}\` | riskLevel: \`${rl}\``);
531
+ lines.push(` - File: ${hook.file}`);
532
+ lines.push(` - Purpose: ${hook.purpose}`);
533
+ lines.push(` - Dry run: ${hook.dryRunExample}`);
534
+ lines.push(` - Rollback: ${hook.rollbackPath}`);
535
+ }
536
+
537
+ lines.push('', '## Policy Packs');
538
+ for (const pack of summary.policyPacks) {
539
+ lines.push(`- **${pack.label}**`);
540
+ lines.push(` - Use when: ${pack.useWhen}`);
541
+ lines.push(` - Modules: ${pack.modules.join(', ')}`);
542
+ }
543
+
544
+ lines.push('', `## Domain Packs (${(summary.domainPacks || []).length})`);
545
+ for (const pack of summary.domainPacks || []) {
546
+ lines.push(`- **${pack.label}**: ${pack.useWhen}`);
547
+ }
548
+ if ((summary.domainPacks || []).length === 0) {
549
+ lines.push('- None shipped yet for this platform.');
550
+ }
551
+
552
+ lines.push('', `## MCP Packs (${(summary.mcpPacks || []).length})`);
553
+ for (const pack of summary.mcpPacks || []) {
554
+ lines.push(`- **${pack.label}**: ${Object.keys(pack.servers).join(', ')}`);
555
+ }
556
+ if ((summary.mcpPacks || []).length === 0) {
557
+ lines.push('- None shipped yet for this platform.');
558
+ }
559
+
560
+ if (Array.isArray(summary.platformCaveats) && summary.platformCaveats.length > 0) {
561
+ lines.push('', '## Platform Caveats');
562
+ for (const item of summary.platformCaveats) {
563
+ lines.push(`- ${item}`);
564
+ }
565
+ }
566
+
567
+ lines.push('', '## Pilot Rollout Kit', '### Recommended Scope');
568
+ for (const item of summary.pilotRolloutKit.recommendedScope) {
569
+ lines.push(`- ${item}`);
570
+ }
571
+
572
+ lines.push('', '### Approvals');
573
+ for (const item of summary.pilotRolloutKit.approvals) {
574
+ lines.push(`- ${item}`);
575
+ }
576
+
577
+ lines.push('', '### Success Metrics');
578
+ for (const item of summary.pilotRolloutKit.successMetrics) {
579
+ lines.push(`- ${item}`);
580
+ }
581
+
582
+ lines.push('', '### Rollback Expectations');
583
+ for (const item of summary.pilotRolloutKit.rollbackExpectations) {
584
+ lines.push(`- ${item}`);
585
+ }
586
+
587
+ lines.push('');
588
+ lines.push('---');
589
+ lines.push(`*Generated by nerviq v${require('../package.json').version} on ${new Date().toISOString().split('T')[0]}*`);
590
+ return lines.join('\n');
591
+ }
592
+
593
593
  module.exports = {
594
594
  PERMISSION_PROFILES,
595
595
  HOOK_REGISTRY,
@@ -597,9 +597,9 @@ module.exports = {
597
597
  getPermissionProfile,
598
598
  isWritableProfile,
599
599
  ensureWritableProfile,
600
- buildSettingsForProfile,
601
- getGovernanceSummary,
602
- printGovernanceSummary,
603
- renderGovernanceMarkdown,
604
- classifyHookRiskLevel,
605
- };
600
+ buildSettingsForProfile,
601
+ getGovernanceSummary,
602
+ printGovernanceSummary,
603
+ renderGovernanceMarkdown,
604
+ classifyHookRiskLevel,
605
+ };