@nerviq/cli 1.10.0 → 1.12.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 (57) hide show
  1. package/README.md +176 -47
  2. package/bin/cli.js +842 -287
  3. package/package.json +2 -2
  4. package/src/activity.js +225 -59
  5. package/src/adoption-advisor.js +299 -0
  6. package/src/aider/freshness.js +28 -25
  7. package/src/aider/techniques.js +16 -11
  8. package/src/analyze.js +131 -1
  9. package/src/anti-patterns.js +17 -2
  10. package/src/audit.js +197 -96
  11. package/src/behavioral-drift.js +801 -0
  12. package/src/benchmark.js +15 -10
  13. package/src/continuous-ops.js +681 -0
  14. package/src/cost-tracking.js +61 -0
  15. package/src/cursor/techniques.js +17 -12
  16. package/src/deep-review.js +83 -0
  17. package/src/diff-only.js +280 -0
  18. package/src/doctor.js +118 -55
  19. package/src/governance.js +72 -50
  20. package/src/hook-validation.js +342 -0
  21. package/src/index.js +7 -1
  22. package/src/integrations.js +144 -60
  23. package/src/mcp-validation.js +337 -0
  24. package/src/opencode/techniques.js +12 -7
  25. package/src/operating-profile.js +574 -0
  26. package/src/org.js +97 -13
  27. package/src/permission-rules.js +218 -0
  28. package/src/plans.js +192 -8
  29. package/src/platform-change-manifest.js +86 -0
  30. package/src/policy-layers.js +210 -0
  31. package/src/profiles.js +4 -1
  32. package/src/prompt-injection.js +74 -0
  33. package/src/repo-archetype.js +386 -0
  34. package/src/secret-patterns.js +9 -0
  35. package/src/server.js +398 -3
  36. package/src/setup.js +36 -2
  37. package/src/source-urls.js +132 -132
  38. package/src/supplemental-checks.js +13 -12
  39. package/src/techniques/api.js +407 -0
  40. package/src/techniques/automation.js +316 -0
  41. package/src/techniques/compliance.js +257 -0
  42. package/src/techniques/hygiene.js +294 -0
  43. package/src/techniques/instructions.js +243 -0
  44. package/src/techniques/observability.js +226 -0
  45. package/src/techniques/optimization.js +142 -0
  46. package/src/techniques/quality.js +317 -0
  47. package/src/techniques/security.js +237 -0
  48. package/src/techniques/shared.js +443 -0
  49. package/src/techniques/stacks.js +2294 -0
  50. package/src/techniques/tools.js +106 -0
  51. package/src/techniques/workflow.js +413 -0
  52. package/src/techniques.js +78 -5611
  53. package/src/terminology.js +73 -0
  54. package/src/token-estimate.js +35 -0
  55. package/src/watch.js +18 -0
  56. package/src/windsurf/techniques.js +17 -12
  57. package/src/workspace.js +105 -8
package/src/org.js CHANGED
@@ -1,48 +1,119 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
+ const { resolvePolicyLayers, applyPolicyLayersToOptions } = require('./policy-layers');
3
4
 
4
- async function scanOrg(dirs, platform = 'claude') {
5
+ function summarizePolicyCoverage(contract) {
6
+ const validLayers = (contract?.layers || []).filter((layer) => layer.valid);
7
+ return {
8
+ layerCount: validLayers.length,
9
+ layerKeys: validLayers.map((layer) => layer.layer),
10
+ org: validLayers.some((layer) => layer.layer === 'org'),
11
+ team: validLayers.some((layer) => layer.layer === 'team'),
12
+ repo: validLayers.some((layer) => layer.layer === 'repo'),
13
+ };
14
+ }
15
+
16
+ function buildScoreBands(repos) {
17
+ const bands = {
18
+ strong: 0,
19
+ developing: 0,
20
+ bootstrap: 0,
21
+ unknown: 0,
22
+ };
23
+
24
+ for (const repo of repos) {
25
+ if (typeof repo.score !== 'number') {
26
+ bands.unknown += 1;
27
+ } else if (repo.score >= 70) {
28
+ bands.strong += 1;
29
+ } else if (repo.score >= 40) {
30
+ bands.developing += 1;
31
+ } else {
32
+ bands.bootstrap += 1;
33
+ }
34
+ }
35
+
36
+ return bands;
37
+ }
38
+
39
+ function buildTopEvidence(repos) {
40
+ const counts = new Map();
41
+ for (const repo of repos) {
42
+ const key = repo.topActionKey;
43
+ if (!key) continue;
44
+ counts.set(key, (counts.get(key) || 0) + 1);
45
+ }
46
+
47
+ return [...counts.entries()]
48
+ .map(([key, repoCount]) => ({ key, repoCount }))
49
+ .sort((a, b) => b.repoCount - a.repoCount)
50
+ .slice(0, 5);
51
+ }
52
+
53
+ async function scanOrg(dirs, options = {}) {
5
54
  const { audit } = require('./audit');
6
55
  const targets = Array.isArray(dirs) ? dirs : [];
7
56
  const repos = [];
57
+ const fallbackPlatform = options.platform || 'claude';
8
58
 
9
59
  for (const dir of targets) {
10
- const resolved = path.resolve(dir);
11
- if (!fs.existsSync(resolved)) {
60
+ const resolvedDir = path.resolve(dir);
61
+ const policyContract = resolvePolicyLayers(resolvedDir);
62
+ const policyCoverage = summarizePolicyCoverage(policyContract);
63
+
64
+ if (!fs.existsSync(resolvedDir)) {
12
65
  repos.push({
13
66
  name: path.basename(dir),
14
- dir: resolved,
15
- platform,
67
+ dir: resolvedDir,
68
+ platform: fallbackPlatform,
69
+ scoreType: 'live-repo-audit-score',
16
70
  score: null,
17
71
  passed: 0,
18
72
  total: 0,
19
73
  topAction: null,
74
+ topActionKey: null,
75
+ policyCoverage,
76
+ policyLayers: policyContract,
20
77
  error: 'directory not found',
21
78
  });
22
79
  continue;
23
80
  }
24
81
 
82
+ const repoOptions = applyPolicyLayersToOptions(policyContract, {
83
+ ...options,
84
+ dir: resolvedDir,
85
+ silent: true,
86
+ });
87
+
25
88
  try {
26
- const result = await audit({ dir: resolved, platform, silent: true });
89
+ const result = await audit(repoOptions);
27
90
  repos.push({
28
- name: path.basename(resolved),
29
- dir: resolved,
30
- platform,
91
+ name: path.basename(resolvedDir),
92
+ dir: resolvedDir,
93
+ platform: repoOptions.platform || fallbackPlatform,
94
+ scoreType: 'live-repo-audit-score',
31
95
  score: result.score,
32
96
  passed: result.passed,
33
97
  total: result.checkCount,
34
98
  topAction: result.topNextActions?.[0]?.name || null,
99
+ topActionKey: result.topNextActions?.[0]?.key || null,
100
+ policyCoverage,
101
+ policyLayers: policyContract,
35
102
  result,
36
103
  });
37
104
  } catch (error) {
38
105
  repos.push({
39
- name: path.basename(resolved),
40
- dir: resolved,
41
- platform,
106
+ name: path.basename(resolvedDir),
107
+ dir: resolvedDir,
108
+ platform: repoOptions.platform || fallbackPlatform,
109
+ scoreType: 'live-repo-audit-score',
42
110
  score: null,
43
111
  passed: 0,
44
112
  total: 0,
45
113
  topAction: null,
114
+ topActionKey: null,
115
+ policyCoverage,
116
+ policyLayers: policyContract,
46
117
  error: error.message,
47
118
  });
48
119
  }
@@ -54,11 +125,24 @@ async function scanOrg(dirs, platform = 'claude') {
54
125
  : 0;
55
126
 
56
127
  return {
57
- platform,
128
+ platform: fallbackPlatform,
58
129
  repoCount: repos.length,
59
130
  averageScore,
131
+ scoreType: 'org-live-average-score',
132
+ scoreSemantics: {
133
+ repoScoreType: 'live-repo-audit-score',
134
+ rollupScoreType: 'org-live-average-score',
135
+ note: 'Repo rows are live per-repo audits. The org average is a rollup across those live repo scores, not a snapshot score or benchmark projection.',
136
+ },
60
137
  maxScore: validScores.length > 0 ? Math.max(...validScores) : 0,
61
138
  minScore: validScores.length > 0 ? Math.min(...validScores) : 0,
139
+ scoreBands: buildScoreBands(repos),
140
+ policyCoverage: {
141
+ orgPolicyRepos: repos.filter((repo) => repo.policyCoverage.org).length,
142
+ teamPolicyRepos: repos.filter((repo) => repo.policyCoverage.team).length,
143
+ repoPolicyRepos: repos.filter((repo) => repo.policyCoverage.repo).length,
144
+ },
145
+ topEvidence: buildTopEvidence(repos),
62
146
  repos,
63
147
  };
64
148
  }
@@ -0,0 +1,218 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const PATH_ACTIONS = new Set(['read', 'write', 'edit', 'multiedit']);
5
+ const SECRET_PATH_RE = /(^|\/)(\.env(?:[^/]*)?|secrets?)(\/|$)/i;
6
+
7
+ function normalizeSlash(value) {
8
+ return String(value || '').replace(/\\/g, '/');
9
+ }
10
+
11
+ function stripWrappingQuotes(value) {
12
+ const trimmed = String(value || '').trim();
13
+ if (!trimmed) return '';
14
+ const first = trimmed[0];
15
+ const last = trimmed[trimmed.length - 1];
16
+ if ((first === '"' || first === "'") && first === last) {
17
+ return trimmed.slice(1, -1);
18
+ }
19
+ return trimmed;
20
+ }
21
+
22
+ function getProjectRoot(rootDir) {
23
+ try {
24
+ return fs.realpathSync.native(rootDir);
25
+ } catch {
26
+ return path.resolve(rootDir);
27
+ }
28
+ }
29
+
30
+ function splitPatternSegments(rawPattern, isAbsolute) {
31
+ const normalized = normalizeSlash(rawPattern);
32
+
33
+ if (/^[A-Za-z]:\//.test(normalized)) {
34
+ return normalized.slice(3).split('/').filter(Boolean);
35
+ }
36
+
37
+ if (isAbsolute && normalized.startsWith('/')) {
38
+ return normalized.slice(1).split('/').filter(Boolean);
39
+ }
40
+
41
+ return normalized.split('/').filter((segment) => segment && segment !== '.');
42
+ }
43
+
44
+ function hasGlob(segment) {
45
+ return /[*?[\]{}]/.test(segment);
46
+ }
47
+
48
+ function buildAbsolutePattern(rootDir, rawPattern) {
49
+ const normalized = stripWrappingQuotes(normalizeSlash(rawPattern).replace(/^file:\/\//i, ''));
50
+ if (!normalized) {
51
+ return {
52
+ absolutePattern: null,
53
+ normalizedInput: '',
54
+ isAbsolute: false,
55
+ traversalSegments: false,
56
+ };
57
+ }
58
+
59
+ const isAbsolute = /^[A-Za-z]:\//.test(normalized) || normalized.startsWith('/');
60
+ const traversalSegments = normalized.split('/').some((segment) => segment === '..');
61
+ const segments = splitPatternSegments(normalized, isAbsolute);
62
+ let current = isAbsolute ? path.parse(path.resolve(normalized)).root : getProjectRoot(rootDir);
63
+
64
+ for (const segment of segments) {
65
+ const candidate = path.join(current, segment);
66
+ if (hasGlob(segment)) {
67
+ current = candidate;
68
+ continue;
69
+ }
70
+
71
+ try {
72
+ current = fs.realpathSync.native(candidate);
73
+ } catch {
74
+ current = candidate;
75
+ }
76
+ }
77
+
78
+ return {
79
+ absolutePattern: current,
80
+ normalizedInput: normalized,
81
+ isAbsolute,
82
+ traversalSegments,
83
+ };
84
+ }
85
+
86
+ function normalizePathPayload(rawPayload, rootDir) {
87
+ const {
88
+ absolutePattern,
89
+ normalizedInput,
90
+ isAbsolute,
91
+ traversalSegments,
92
+ } = buildAbsolutePattern(rootDir, rawPayload);
93
+
94
+ if (!absolutePattern) {
95
+ return {
96
+ normalizedPath: '',
97
+ repoRelativePath: '',
98
+ outsideRepo: false,
99
+ invalid: true,
100
+ isAbsolute,
101
+ traversalSegments,
102
+ };
103
+ }
104
+
105
+ const projectRoot = getProjectRoot(rootDir);
106
+ const relativePath = normalizeSlash(path.relative(projectRoot, absolutePattern));
107
+ const outsideRepo = relativePath === '..' || relativePath.startsWith('../') || /^[A-Za-z]:\//.test(relativePath);
108
+ const repoRelativePath = outsideRepo ? null : relativePath || '.';
109
+ const normalizedPath = outsideRepo
110
+ ? normalizeSlash(absolutePattern)
111
+ : `./${repoRelativePath}`;
112
+
113
+ return {
114
+ normalizedPath,
115
+ repoRelativePath,
116
+ outsideRepo,
117
+ invalid: traversalSegments && outsideRepo && !isAbsolute,
118
+ isAbsolute,
119
+ traversalSegments,
120
+ normalizedInput,
121
+ };
122
+ }
123
+
124
+ function normalizeCommandPayload(rawPayload) {
125
+ return stripWrappingQuotes(rawPayload).replace(/\s+/g, ' ').trim();
126
+ }
127
+
128
+ function normalizePermissionRule(rule, rootDir) {
129
+ if (typeof rule !== 'string' || !rule.trim()) return null;
130
+ const trimmed = rule.trim();
131
+ const match = trimmed.match(/^([A-Za-z]+)\((.*)\)$/);
132
+ if (!match) {
133
+ return {
134
+ raw: trimmed,
135
+ action: null,
136
+ payload: trimmed,
137
+ normalized: trimmed,
138
+ dedupeKey: trimmed.toLowerCase(),
139
+ kind: 'raw',
140
+ invalid: false,
141
+ outsideRepo: false,
142
+ protectsSecrets: false,
143
+ };
144
+ }
145
+
146
+ const action = match[1];
147
+ const payload = match[2].trim();
148
+ const actionKey = action.toLowerCase();
149
+
150
+ if (PATH_ACTIONS.has(actionKey)) {
151
+ const details = normalizePathPayload(payload, rootDir);
152
+ const dedupeKey = `${actionKey}:${details.normalizedPath.toLowerCase()}`;
153
+ return {
154
+ raw: trimmed,
155
+ action,
156
+ payload,
157
+ normalized: `${action}(${details.normalizedPath})`,
158
+ normalizedPath: details.normalizedPath,
159
+ repoRelativePath: details.repoRelativePath,
160
+ dedupeKey,
161
+ kind: 'path',
162
+ invalid: details.invalid,
163
+ outsideRepo: details.outsideRepo,
164
+ traversalSegments: details.traversalSegments,
165
+ isAbsolute: details.isAbsolute,
166
+ protectsSecrets: !details.outsideRepo && SECRET_PATH_RE.test(details.repoRelativePath || ''),
167
+ };
168
+ }
169
+
170
+ const normalizedPayload = normalizeCommandPayload(payload);
171
+ return {
172
+ raw: trimmed,
173
+ action,
174
+ payload,
175
+ normalized: `${action}(${normalizedPayload})`,
176
+ dedupeKey: `${actionKey}:${normalizedPayload.toLowerCase()}`,
177
+ kind: 'command',
178
+ invalid: false,
179
+ outsideRepo: false,
180
+ protectsSecrets: false,
181
+ };
182
+ }
183
+
184
+ function normalizePermissionRules(rules, rootDir) {
185
+ const seen = new Set();
186
+ const normalized = [];
187
+
188
+ for (const rule of Array.isArray(rules) ? rules : []) {
189
+ const entry = normalizePermissionRule(rule, rootDir);
190
+ if (!entry || entry.invalid) continue;
191
+ if (seen.has(entry.dedupeKey)) continue;
192
+ seen.add(entry.dedupeKey);
193
+ normalized.push(entry);
194
+ }
195
+
196
+ return normalized;
197
+ }
198
+
199
+ function collectClaudeDenyRules(ctx) {
200
+ const shared = ctx.jsonFile('.claude/settings.json');
201
+ const local = ctx.jsonFile('.claude/settings.local.json');
202
+ const denyRules = []
203
+ .concat(shared?.permissions?.deny || [])
204
+ .concat(local?.permissions?.deny || []);
205
+
206
+ return normalizePermissionRules(denyRules, ctx.dir);
207
+ }
208
+
209
+ function hasSecretDenyRule(rules) {
210
+ return (Array.isArray(rules) ? rules : []).some((rule) => rule && rule.protectsSecrets);
211
+ }
212
+
213
+ module.exports = {
214
+ collectClaudeDenyRules,
215
+ hasSecretDenyRule,
216
+ normalizePermissionRule,
217
+ normalizePermissionRules,
218
+ };
package/src/plans.js CHANGED
@@ -59,6 +59,32 @@ const FALLBACK_TEMPLATE_BY_KEY = {
59
59
  agentsHaveMaxTurns: 'agents',
60
60
  };
61
61
 
62
+ const VERIFICATION_TRIGGER_KEYS = new Set([
63
+ 'verificationLoop',
64
+ 'testCommand',
65
+ 'lintCommand',
66
+ 'buildCommand',
67
+ ]);
68
+
69
+ const GOVERNANCE_TRIGGER_KEYS = new Set([
70
+ 'permissionDeny',
71
+ 'secretsProtection',
72
+ 'preToolUseHook',
73
+ 'postToolUseHook',
74
+ 'sessionStartHook',
75
+ 'hooksInSettings',
76
+ 'settingsPermissions',
77
+ 'securityReview',
78
+ ]);
79
+
80
+ const AUTOMATION_TRIGGER_KEYS = new Set([
81
+ 'customCommands',
82
+ 'skills',
83
+ 'agents',
84
+ 'multipleAgents',
85
+ 'multipleMcpServers',
86
+ ]);
87
+
62
88
  function previewContent(content) {
63
89
  return content.split('\n').slice(0, 12).join('\n');
64
90
  }
@@ -372,9 +398,140 @@ function toProposal(templateKey, triggers, templateFiles, ctx) {
372
398
  };
373
399
  }
374
400
 
401
+ function proposalMatchesTriggerKeys(proposal, keySet) {
402
+ return (proposal.triggers || []).some((trigger) => keySet.has(trigger.key));
403
+ }
404
+
405
+ function collectCampaignProposals(proposals, predicate) {
406
+ return proposals.filter((proposal) => proposal.readyToApply && predicate(proposal));
407
+ }
408
+
409
+ function buildCampaigns(bundle) {
410
+ const proposals = Array.isArray(bundle?.proposals) ? bundle.proposals : [];
411
+ const campaigns = [];
412
+ const maturity = bundle?.projectSummary?.maturity || 'unknown';
413
+
414
+ const starterBaseline = collectCampaignProposals(
415
+ proposals,
416
+ (proposal) => ['claude-md', 'commands', 'rules', 'hooks', 'agents'].includes(proposal.id),
417
+ );
418
+ if (starterBaseline.length > 0) {
419
+ campaigns.push({
420
+ key: 'starter-baseline',
421
+ label: 'Starter baseline',
422
+ summary: 'Establish the managed baseline surfaces Nerviq expects before deeper upgrades.',
423
+ proposalIds: starterBaseline.map((proposal) => proposal.id),
424
+ milestone: maturity === 'mature' ? 'pre-upgrade' : 'baseline',
425
+ focusAreas: ['config-drift', 'maturity-opportunity'],
426
+ });
427
+ }
428
+
429
+ const verificationClosure = collectCampaignProposals(
430
+ proposals,
431
+ (proposal) => proposalMatchesTriggerKeys(proposal, VERIFICATION_TRIGGER_KEYS),
432
+ );
433
+ if (verificationClosure.length > 0) {
434
+ campaigns.push({
435
+ key: 'verification-closure',
436
+ label: 'Verification closure',
437
+ summary: 'Close missing test/lint/build loops so audits can be verified continuously.',
438
+ proposalIds: verificationClosure.map((proposal) => proposal.id),
439
+ milestone: 'post-fix',
440
+ focusAreas: ['config-drift', 'maturity-opportunity'],
441
+ });
442
+ }
443
+
444
+ const governanceHardening = collectCampaignProposals(
445
+ proposals,
446
+ (proposal) => proposal.id === 'hooks' || proposalMatchesTriggerKeys(proposal, GOVERNANCE_TRIGGER_KEYS),
447
+ );
448
+ if (governanceHardening.length > 0) {
449
+ campaigns.push({
450
+ key: 'governance-hardening',
451
+ label: 'Governance hardening',
452
+ summary: 'Tighten permissions, hooks, secret protection, and reviewable safety defaults.',
453
+ proposalIds: governanceHardening.map((proposal) => proposal.id),
454
+ milestone: 'pre-upgrade',
455
+ focusAreas: ['policy-drift', 'config-drift'],
456
+ });
457
+ }
458
+
459
+ const reviewableAutomation = collectCampaignProposals(
460
+ proposals,
461
+ (proposal) => proposal.id === 'agents' || proposal.id === 'commands' || proposalMatchesTriggerKeys(proposal, AUTOMATION_TRIGGER_KEYS),
462
+ );
463
+ if (reviewableAutomation.length > 0) {
464
+ campaigns.push({
465
+ key: 'reviewable-automation',
466
+ label: 'Reviewable automation',
467
+ summary: 'Add automation surfaces that keep upgrades inspectable instead of ad-hoc.',
468
+ proposalIds: reviewableAutomation.map((proposal) => proposal.id),
469
+ milestone: 'pre-upgrade',
470
+ focusAreas: ['maturity-opportunity', 'config-drift'],
471
+ });
472
+ }
473
+
474
+ return campaigns.filter((campaign, index, all) =>
475
+ all.findIndex((item) => item.key === campaign.key) === index,
476
+ );
477
+ }
478
+
479
+ function filterBundleByCampaigns(bundle, campaignKeys = []) {
480
+ if (!Array.isArray(campaignKeys) || campaignKeys.length === 0) {
481
+ return bundle;
482
+ }
483
+
484
+ const available = new Map((bundle.campaigns || []).map((campaign) => [campaign.key, campaign]));
485
+ const missing = campaignKeys.filter((key) => !available.has(key));
486
+ if (missing.length > 0) {
487
+ throw new Error(`unknown campaign(s): ${missing.join(', ')}`);
488
+ }
489
+
490
+ const selectedIds = new Set();
491
+ for (const key of campaignKeys) {
492
+ for (const proposalId of available.get(key).proposalIds || []) {
493
+ selectedIds.add(proposalId);
494
+ }
495
+ }
496
+
497
+ return {
498
+ ...bundle,
499
+ selectedCampaigns: campaignKeys,
500
+ proposals: bundle.proposals.filter((proposal) => selectedIds.has(proposal.id)),
501
+ campaigns: (bundle.campaigns || []).filter((campaign) => campaignKeys.includes(campaign.key)),
502
+ };
503
+ }
504
+
505
+ function resolveSelectionSet(bundle, options = {}) {
506
+ const proposalIds = new Set((bundle.proposals || []).map((proposal) => proposal.id));
507
+ const onlySet = options.only && options.only.length > 0 ? new Set(options.only) : null;
508
+ const campaignSet = options.campaigns && options.campaigns.length > 0
509
+ ? new Set((bundle.campaigns || []).flatMap((campaign) => {
510
+ if (!options.campaigns.includes(campaign.key)) return [];
511
+ return campaign.proposalIds || [];
512
+ }))
513
+ : null;
514
+
515
+ if (onlySet && campaignSet) {
516
+ return new Set([...onlySet].filter((proposalId) => campaignSet.has(proposalId) && proposalIds.has(proposalId)));
517
+ }
518
+ if (onlySet) {
519
+ return new Set([...onlySet].filter((proposalId) => proposalIds.has(proposalId)));
520
+ }
521
+ if (campaignSet) {
522
+ return new Set([...campaignSet].filter((proposalId) => proposalIds.has(proposalId)));
523
+ }
524
+
525
+ return null;
526
+ }
527
+
375
528
  async function buildProposalBundle(options) {
376
529
  if (options.platform === 'codex') {
377
- return buildCodexProposalBundle(options);
530
+ const bundle = await buildCodexProposalBundle(options);
531
+ return filterBundleByCampaigns({
532
+ ...bundle,
533
+ campaigns: buildCampaigns(bundle),
534
+ }, options.campaigns);
378
535
  }
379
536
 
380
537
  const ctx = new ProjectContext(options.dir);
@@ -405,7 +562,7 @@ async function buildProposalBundle(options) {
405
562
  return impactB - impactA;
406
563
  });
407
564
 
408
- return {
565
+ return filterBundleByCampaigns({
409
566
  schemaVersion: 1,
410
567
  generatedBy: `nerviq@${version}`,
411
568
  createdAt: new Date().toISOString(),
@@ -416,7 +573,8 @@ async function buildProposalBundle(options) {
416
573
  riskNotes: report.riskNotes,
417
574
  mcpPreflightWarnings,
418
575
  proposals,
419
- };
576
+ campaigns: buildCampaigns({ proposals, projectSummary: report.projectSummary }),
577
+ }, options.campaigns);
420
578
  }
421
579
 
422
580
  function printProposalBundle(bundle, options = {}) {
@@ -429,8 +587,19 @@ function printProposalBundle(bundle, options = {}) {
429
587
  console.log(' nerviq plan');
430
588
  console.log(' ═══════════════════════════════════════');
431
589
  console.log(` ${bundle.projectSummary.name} | maturity=${bundle.projectSummary.maturity} | score=${bundle.projectSummary.score}/100`);
590
+ if (bundle.projectSummary.archetype) {
591
+ console.log(` archetype=${bundle.projectSummary.archetype} | workflow=${bundle.projectSummary.workflow || 'unknown'} | risk=${bundle.projectSummary.riskLevel || 'unknown'}`);
592
+ }
593
+ if (bundle.projectSummary.operatingProfile) {
594
+ console.log(` operating-profile=${bundle.projectSummary.operatingProfile}`);
595
+ }
432
596
  console.log('');
433
597
 
598
+ if (bundle.selectedCampaigns && bundle.selectedCampaigns.length > 0) {
599
+ console.log(` Selected campaigns: ${bundle.selectedCampaigns.join(', ')}`);
600
+ console.log('');
601
+ }
602
+
434
603
  if (bundle.mcpPreflightWarnings && bundle.mcpPreflightWarnings.length > 0) {
435
604
  console.log(' MCP Preflight Warnings');
436
605
  for (const warning of bundle.mcpPreflightWarnings) {
@@ -445,6 +614,16 @@ function printProposalBundle(bundle, options = {}) {
445
614
  return;
446
615
  }
447
616
 
617
+ if (bundle.campaigns && bundle.campaigns.length > 0) {
618
+ console.log(' Upgrade campaigns');
619
+ for (const campaign of bundle.campaigns) {
620
+ console.log(` - ${campaign.key} (${campaign.milestone})`);
621
+ console.log(` ${campaign.label} — ${campaign.summary}`);
622
+ console.log(` proposals: ${campaign.proposalIds.join(', ')}`);
623
+ }
624
+ console.log('');
625
+ }
626
+
448
627
  console.log(' Proposal Bundles');
449
628
  for (const proposal of bundle.proposals) {
450
629
  const applyState = proposal.readyToApply ? 'ready' : 'manual-review';
@@ -536,9 +715,12 @@ function applyRuntimeSettingsOverlays(bundle, options) {
536
715
 
537
716
  function resolvePlan(bundle, options) {
538
717
  if (options.planFile) {
539
- return applyRuntimeSettingsOverlays(JSON.parse(fs.readFileSync(options.planFile, 'utf8')), options);
718
+ return filterBundleByCampaigns(
719
+ applyRuntimeSettingsOverlays(JSON.parse(fs.readFileSync(options.planFile, 'utf8')), options),
720
+ options.campaigns,
721
+ );
540
722
  }
541
- return applyRuntimeSettingsOverlays(bundle, options);
723
+ return filterBundleByCampaigns(applyRuntimeSettingsOverlays(bundle, options), options.campaigns);
542
724
  }
543
725
 
544
726
  async function applyProposalBundle(options) {
@@ -546,9 +728,7 @@ async function applyProposalBundle(options) {
546
728
  const bundle = resolvePlan(liveBundle, options);
547
729
  const mcpPreflightWarnings = getMcpPackPreflight(options.mcpPacks || [])
548
730
  .filter(item => item.missingEnvVars.length > 0);
549
- const selectedIds = options.only && options.only.length > 0
550
- ? new Set(options.only)
551
- : null;
731
+ const selectedIds = resolveSelectionSet(bundle, options);
552
732
  const selected = bundle.proposals.filter(proposal => {
553
733
  if (selectedIds && !selectedIds.has(proposal.id)) return false;
554
734
  return proposal.readyToApply;
@@ -606,6 +786,7 @@ async function applyProposalBundle(options) {
606
786
  return {
607
787
  proposalCount: bundle.proposals.length,
608
788
  appliedProposalIds: selected.map(item => item.id),
789
+ selectedCampaigns: bundle.selectedCampaigns || options.campaigns || [],
609
790
  createdFiles,
610
791
  patchedFiles: patchedFiles.map(file => file.path),
611
792
  skippedFiles,
@@ -629,6 +810,9 @@ function printApplyResult(result, options = {}) {
629
810
  console.log(' Dry-run only. No files were written.');
630
811
  }
631
812
  console.log(` Applied proposal bundles: ${result.appliedProposalIds.join(', ') || 'none'}`);
813
+ if (result.selectedCampaigns && result.selectedCampaigns.length > 0) {
814
+ console.log(` Campaigns: ${result.selectedCampaigns.join(', ')}`);
815
+ }
632
816
  console.log(` Created files: ${result.createdFiles.join(', ') || 'none'}`);
633
817
  console.log(` Patched files: ${result.patchedFiles.join(', ') || 'none'}`);
634
818
  if (result.mcpPreflightWarnings && result.mcpPreflightWarnings.length > 0) {