claudex-setup 1.7.0 → 1.9.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.
@@ -0,0 +1,139 @@
1
+ const MCP_PACKS = [
2
+ {
3
+ key: 'context7-docs',
4
+ label: 'Context7 Docs',
5
+ useWhen: 'Repos that benefit from live, current framework and library documentation during Claude sessions.',
6
+ adoption: 'Safe default docs pack for most application repos.',
7
+ servers: {
8
+ context7: {
9
+ command: 'npx',
10
+ args: ['-y', '@upstash/context7-mcp@latest'],
11
+ },
12
+ },
13
+ },
14
+ {
15
+ key: 'next-devtools',
16
+ label: 'Next.js Devtools',
17
+ useWhen: 'Next.js repos that need runtime-aware debugging and framework-specific tooling.',
18
+ adoption: 'Useful companion pack for frontend-ui repos running Next.js.',
19
+ servers: {
20
+ 'next-devtools': {
21
+ command: 'npx',
22
+ args: ['-y', 'next-devtools-mcp@latest'],
23
+ },
24
+ },
25
+ },
26
+ {
27
+ key: 'github-mcp',
28
+ label: 'GitHub',
29
+ useWhen: 'Repos hosted on GitHub that benefit from issue, PR, and repository context during Claude sessions.',
30
+ adoption: 'Recommended for any GitHub-hosted project. Requires GITHUB_PERSONAL_ACCESS_TOKEN env var.',
31
+ servers: {
32
+ github: {
33
+ command: 'npx',
34
+ args: ['-y', '@modelcontextprotocol/server-github'],
35
+ env: { GITHUB_PERSONAL_ACCESS_TOKEN: '${GITHUB_PERSONAL_ACCESS_TOKEN}' },
36
+ },
37
+ },
38
+ },
39
+ {
40
+ key: 'postgres-mcp',
41
+ label: 'PostgreSQL',
42
+ useWhen: 'Repos with PostgreSQL databases that benefit from schema inspection and query assistance.',
43
+ adoption: 'Useful for backend-api and data-pipeline repos. Requires DATABASE_URL env var.',
44
+ servers: {
45
+ postgres: {
46
+ command: 'npx',
47
+ args: ['-y', '@modelcontextprotocol/server-postgres'],
48
+ env: { DATABASE_URL: '${DATABASE_URL}' },
49
+ },
50
+ },
51
+ },
52
+ {
53
+ key: 'memory-mcp',
54
+ label: 'Memory / Knowledge Graph',
55
+ useWhen: 'Long-running projects that benefit from persistent entity and relationship tracking across sessions.',
56
+ adoption: 'Useful for complex projects with many interconnected concepts. Stores data locally.',
57
+ servers: {
58
+ memory: {
59
+ command: 'npx',
60
+ args: ['-y', '@modelcontextprotocol/server-memory'],
61
+ },
62
+ },
63
+ },
64
+ ];
65
+
66
+ function clone(value) {
67
+ return JSON.parse(JSON.stringify(value));
68
+ }
69
+
70
+ function getMcpPack(key) {
71
+ return MCP_PACKS.find(pack => pack.key === key) || null;
72
+ }
73
+
74
+ function normalizeMcpPackKeys(keys = []) {
75
+ return [...new Set((Array.isArray(keys) ? keys : [])
76
+ .map(key => `${key}`.trim())
77
+ .filter(Boolean))]
78
+ .filter(key => !!getMcpPack(key));
79
+ }
80
+
81
+ function mergeMcpServers(existing = {}, packKeys = []) {
82
+ const merged = clone(existing || {});
83
+ for (const key of normalizeMcpPackKeys(packKeys)) {
84
+ const pack = getMcpPack(key);
85
+ if (!pack) continue;
86
+ for (const [serverName, serverConfig] of Object.entries(pack.servers || {})) {
87
+ if (!merged[serverName]) {
88
+ merged[serverName] = clone(serverConfig);
89
+ }
90
+ }
91
+ }
92
+ return merged;
93
+ }
94
+
95
+ function recommendMcpPacks(stacks = [], domainPacks = []) {
96
+ const recommended = new Set();
97
+ const stackKeys = new Set(stacks.map(stack => stack.key));
98
+
99
+ for (const pack of domainPacks) {
100
+ for (const key of pack.recommendedMcpPacks || []) {
101
+ recommended.add(key);
102
+ }
103
+ }
104
+
105
+ if (stackKeys.has('nextjs')) {
106
+ recommended.add('next-devtools');
107
+ }
108
+ if (stackKeys.size > 0) {
109
+ recommended.add('context7-docs');
110
+ }
111
+
112
+ // GitHub MCP for repos with .github directory
113
+ const domainKeys = new Set(domainPacks.map(p => p.key));
114
+ if (domainKeys.has('oss-library') || domainKeys.has('enterprise-governed')) {
115
+ recommended.add('github-mcp');
116
+ }
117
+
118
+ // Postgres MCP for data-heavy repos
119
+ if (domainKeys.has('data-pipeline') || domainKeys.has('backend-api')) {
120
+ recommended.add('postgres-mcp');
121
+ }
122
+
123
+ // Memory MCP for complex/monorepo projects
124
+ if (domainKeys.has('monorepo') || domainKeys.has('enterprise-governed')) {
125
+ recommended.add('memory-mcp');
126
+ }
127
+
128
+ return MCP_PACKS
129
+ .filter(pack => recommended.has(pack.key))
130
+ .map(pack => clone(pack));
131
+ }
132
+
133
+ module.exports = {
134
+ MCP_PACKS,
135
+ getMcpPack,
136
+ normalizeMcpPackKeys,
137
+ mergeMcpServers,
138
+ recommendMcpPacks,
139
+ };
package/src/plans.js CHANGED
@@ -6,6 +6,7 @@ const { analyzeProject } = require('./analyze');
6
6
  const { ProjectContext } = require('./context');
7
7
  const { TECHNIQUES, STACKS } = require('./techniques');
8
8
  const { TEMPLATES } = require('./setup');
9
+ const { buildSettingsForProfile } = require('./governance');
9
10
  const { writeActivityArtifact, writeRollbackArtifact } = require('./activity');
10
11
 
11
12
  const TEMPLATE_DIR_MAP = {
@@ -35,6 +36,26 @@ const TEMPLATE_MODULES = {
35
36
  };
36
37
 
37
38
  const IMPACT_ORDER = { critical: 3, high: 2, medium: 1, low: 0 };
39
+ const FALLBACK_TEMPLATE_BY_KEY = {
40
+ importSyntax: 'claude-md',
41
+ verificationLoop: 'claude-md',
42
+ testCommand: 'claude-md',
43
+ lintCommand: 'claude-md',
44
+ buildCommand: 'claude-md',
45
+ securityReview: 'claude-md',
46
+ compactionAwareness: 'claude-md',
47
+ contextManagement: 'claude-md',
48
+ xmlTags: 'claude-md',
49
+ roleDefinition: 'claude-md',
50
+ constraintBlocks: 'claude-md',
51
+ claudeMdFreshness: 'claude-md',
52
+ permissionDeny: 'hooks',
53
+ secretsProtection: 'hooks',
54
+ preToolUseHook: 'hooks',
55
+ postToolUseHook: 'hooks',
56
+ sessionStartHook: 'hooks',
57
+ agentsHaveMaxTurns: 'agents',
58
+ };
38
59
 
39
60
  function previewContent(content) {
40
61
  return content.split('\n').slice(0, 12).join('\n');
@@ -46,73 +67,242 @@ function riskFromImpact(impact) {
46
67
  return 'low';
47
68
  }
48
69
 
70
+ function normalizeNewlines(content) {
71
+ return (content || '').replace(/\r\n/g, '\n');
72
+ }
73
+
74
+ function ensureTrailingNewline(content) {
75
+ const normalized = normalizeNewlines(content);
76
+ return normalized.endsWith('\n') ? normalized : `${normalized}\n`;
77
+ }
78
+
79
+ function upsertManagedBlock(content, id, block) {
80
+ const start = `<!-- claudex-setup:${id}:start -->`;
81
+ const end = `<!-- claudex-setup:${id}:end -->`;
82
+ const wrapped = `${start}\n${block.trim()}\n${end}`;
83
+ const pattern = new RegExp(`${start}[\\s\\S]*?${end}`);
84
+
85
+ if (pattern.test(content)) {
86
+ return {
87
+ changed: true,
88
+ content: content.replace(pattern, wrapped),
89
+ };
90
+ }
91
+
92
+ return {
93
+ changed: true,
94
+ content: `${content.trimEnd()}\n\n${wrapped}\n`,
95
+ };
96
+ }
97
+
98
+ function extractGeneratedBuildSection(content) {
99
+ const match = content.match(/## Build & Test\n([\s\S]*?)\n\n## Working Notes/);
100
+ return match ? `## Build & Test\n${match[1].trim()}` : null;
101
+ }
102
+
103
+ function extractGeneratedVerificationBlock(content) {
104
+ const match = content.match(/<verification>\n([\s\S]*?)\n<\/verification>/);
105
+ return match ? `<verification>\n${match[1].trim()}\n</verification>` : null;
106
+ }
107
+
108
+ function extractGeneratedWorkingNotes(content) {
109
+ const match = content.match(/## Working Notes\n([\s\S]*?)\n\n<constraints>/);
110
+ return match ? `## Working Notes\n${match[1].trim()}` : null;
111
+ }
112
+
113
+ function extractGeneratedConstraintsBlock(content) {
114
+ const match = content.match(/<constraints>\n([\s\S]*?)\n<\/constraints>/);
115
+ return match ? `<constraints>\n${match[1].trim()}\n</constraints>` : null;
116
+ }
117
+
118
+ function extractGeneratedContextSection(content) {
119
+ const match = content.match(/## Context Management\n([\s\S]*?)\n\n---/);
120
+ return match ? `## Context Management\n${match[1].trim()}` : null;
121
+ }
122
+
49
123
  function getFailedTemplateGroups(ctx, only = []) {
50
124
  const groups = new Map();
51
125
  for (const [key, technique] of Object.entries(TECHNIQUES)) {
52
126
  const passed = technique.check(ctx);
53
- if (passed !== false || !technique.template) continue;
54
- if (technique.template === 'mermaid') continue;
55
- if (only.length > 0 && !only.includes(key) && !only.includes(technique.template)) continue;
56
- if (!groups.has(technique.template)) {
57
- groups.set(technique.template, []);
127
+ const templateKey = technique.template || FALLBACK_TEMPLATE_BY_KEY[key];
128
+ if (passed !== false || !templateKey) continue;
129
+ if (templateKey === 'mermaid') continue;
130
+ if (only.length > 0 && !only.includes(key) && !only.includes(templateKey)) continue;
131
+ if (!groups.has(templateKey)) {
132
+ groups.set(templateKey, []);
58
133
  }
59
- groups.get(technique.template).push({ key, ...technique });
134
+ groups.get(templateKey).push({ key, ...technique });
60
135
  }
61
136
  return groups;
62
137
  }
63
138
 
64
- function buildHookSettings(ctx, plannedHookFiles) {
139
+ function buildClaudeMdPatchFile(ctx, stacks) {
140
+ const claudePath = ctx.fileContent('CLAUDE.md') !== null
141
+ ? 'CLAUDE.md'
142
+ : (ctx.fileContent('.claude/CLAUDE.md') !== null ? '.claude/CLAUDE.md' : null);
143
+ if (!claudePath) return null;
144
+
145
+ const existing = normalizeNewlines(ctx.fileContent(claudePath));
146
+ const generated = TEMPLATES['claude-md'](stacks, ctx);
147
+ const buildSection = extractGeneratedBuildSection(generated);
148
+ const verificationBlock = extractGeneratedVerificationBlock(generated);
149
+ const workingNotes = extractGeneratedWorkingNotes(generated);
150
+ const constraintsBlock = extractGeneratedConstraintsBlock(generated);
151
+ const contextSection = extractGeneratedContextSection(generated);
152
+ let merged = existing;
153
+ let changed = false;
154
+
155
+ const hasTest = /npm test|pytest|jest|vitest|cargo test|go test|mix test|rspec/.test(merged);
156
+ const hasLint = /eslint|prettier|ruff|black|clippy|golangci-lint|rubocop/.test(merged);
157
+ const hasBuild = /npm run build|cargo build|go build|make|tsc|gradle build|mvn compile/.test(merged);
158
+ const hasVerification = merged.includes('<verification>');
159
+ const hasSecurityWorkflow = merged.toLowerCase().includes('security') || merged.includes('/security-review');
160
+ const hasImportGuidance = merged.includes('@import');
161
+ const hasRoleDefinition = /you are|your role|act as|persona|behave as/i.test(merged);
162
+ const hasConstraintBlock = /<constraints|<rules|<requirements|<boundaries/i.test(merged);
163
+ const hasCompaction = /\/compact|compaction/i.test(merged);
164
+ const hasContextManagement = /context.*(manage|window|limit|budget|token)/i.test(merged);
165
+ const modernFeatures = ['hook', 'skill', 'agent', 'subagent', 'mcp', 'worktree'];
166
+ const hasFreshness = modernFeatures.filter(feature => merged.toLowerCase().includes(feature)).length >= 2;
167
+
168
+ if ((!hasTest || !hasLint || !hasBuild) && buildSection) {
169
+ const result = upsertManagedBlock(merged, 'build-test', buildSection);
170
+ merged = result.content;
171
+ changed = true;
172
+ }
173
+
174
+ if (!hasRoleDefinition && workingNotes) {
175
+ const result = upsertManagedBlock(merged, 'working-style', workingNotes);
176
+ merged = result.content;
177
+ changed = true;
178
+ }
179
+
180
+ if (!hasConstraintBlock && constraintsBlock) {
181
+ const result = upsertManagedBlock(merged, 'constraints', constraintsBlock);
182
+ merged = result.content;
183
+ changed = true;
184
+ }
185
+
186
+ if (!hasVerification && verificationBlock) {
187
+ const result = upsertManagedBlock(merged, 'verification', verificationBlock);
188
+ merged = result.content;
189
+ changed = true;
190
+ }
191
+
192
+ if (!hasSecurityWorkflow) {
193
+ const result = upsertManagedBlock(merged, 'security-workflow', [
194
+ '## Security Workflow',
195
+ '- Run `/security-review` when touching authentication, permissions, secrets, or customer data.',
196
+ '- Treat secret access, shell commands, and risky file operations as review-worthy changes.',
197
+ ].join('\n'));
198
+ merged = result.content;
199
+ changed = true;
200
+ }
201
+
202
+ if (!hasImportGuidance) {
203
+ const result = upsertManagedBlock(merged, 'modularity', [
204
+ '## Modularity',
205
+ '- If this file grows, split it with `@import ./docs/...` so the base instructions stay concise.',
206
+ ].join('\n'));
207
+ merged = result.content;
208
+ changed = true;
209
+ }
210
+
211
+ if ((!hasCompaction || !hasContextManagement || !hasFreshness) && contextSection) {
212
+ const result = upsertManagedBlock(merged, 'context-management', contextSection);
213
+ merged = result.content;
214
+ changed = true;
215
+ }
216
+
217
+ if (!changed) {
218
+ return null;
219
+ }
220
+
221
+ return {
222
+ path: claudePath,
223
+ action: 'patch',
224
+ currentState: 'existing CLAUDE.md is missing recommended verification or security sections',
225
+ proposedState: 'append managed sections for verification, security workflow, and modularity',
226
+ content: ensureTrailingNewline(merged),
227
+ };
228
+ }
229
+
230
+ function buildAgentPatchFiles(ctx) {
231
+ if (!ctx.hasDir('.claude/agents')) {
232
+ return [];
233
+ }
234
+
235
+ return ctx.dirFiles('.claude/agents')
236
+ .filter(file => file.endsWith('.md'))
237
+ .map((file) => {
238
+ const relativePath = `.claude/agents/${file}`;
239
+ const content = normalizeNewlines(ctx.fileContent(relativePath) || '');
240
+ if (!content.startsWith('---\n') || content.includes('\nmaxTurns:')) {
241
+ return null;
242
+ }
243
+
244
+ const updated = content.replace(/^---\n([\s\S]*?)\n---/, (match, frontmatter) => `---\n${frontmatter}\nmaxTurns: 50\n---`);
245
+ if (updated === content) {
246
+ return null;
247
+ }
248
+
249
+ return {
250
+ path: relativePath,
251
+ action: 'patch',
252
+ currentState: 'existing agent is missing a maxTurns safety limit',
253
+ proposedState: 'add maxTurns: 50 to the agent frontmatter',
254
+ content: ensureTrailingNewline(updated),
255
+ };
256
+ })
257
+ .filter(Boolean);
258
+ }
259
+
260
+ function buildHookSettings(ctx, plannedHookFiles, options = {}) {
65
261
  const existing = ctx.hasDir('.claude/hooks')
66
262
  ? ctx.dirFiles('.claude/hooks').filter(file => file.endsWith('.sh'))
67
263
  : [];
68
264
  const hookFiles = [...new Set([...existing, ...plannedHookFiles])].sort();
69
- if (hookFiles.length === 0 || ctx.fileContent('.claude/settings.json')) {
265
+ if (hookFiles.length === 0) {
70
266
  return null;
71
267
  }
268
+ const settingsPath = '.claude/settings.json';
269
+ const existingSettings = ctx.jsonFile(settingsPath);
270
+ const settings = buildSettingsForProfile({
271
+ profileKey: options.profile || 'safe-write',
272
+ hookFiles,
273
+ existingSettings,
274
+ mcpPackKeys: options.mcpPacks || [],
275
+ });
276
+ const content = `${JSON.stringify(settings, null, 2)}\n`;
277
+ const existingContent = existingSettings ? `${JSON.stringify(existingSettings, null, 2)}\n` : null;
72
278
 
73
- const settings = {
74
- permissions: {
75
- defaultMode: 'acceptEdits',
76
- deny: [
77
- 'Read(./.env*)',
78
- 'Read(./secrets/**)',
79
- 'Bash(rm -rf *)',
80
- 'Bash(git reset --hard *)',
81
- 'Bash(git checkout -- *)',
82
- 'Bash(git clean *)',
83
- 'Bash(git push --force *)',
84
- ],
85
- },
86
- hooks: {
87
- PostToolUse: [{
88
- matcher: 'Write|Edit',
89
- hooks: hookFiles.filter(file => file !== 'protect-secrets.sh').map(file => ({
90
- type: 'command',
91
- command: `bash .claude/hooks/${file}`,
92
- timeout: 10,
93
- })),
94
- }],
95
- },
96
- };
97
-
98
- if (hookFiles.includes('protect-secrets.sh')) {
99
- settings.hooks.PreToolUse = [{
100
- matcher: 'Read|Write|Edit',
101
- hooks: [{
102
- type: 'command',
103
- command: 'bash .claude/hooks/protect-secrets.sh',
104
- timeout: 5,
105
- }],
106
- }];
279
+ if (existingContent === content) {
280
+ return null;
107
281
  }
108
282
 
109
283
  return {
110
- path: '.claude/settings.json',
111
- content: `${JSON.stringify(settings, null, 2)}\n`,
284
+ path: settingsPath,
285
+ action: existingSettings ? 'patch' : 'create',
286
+ currentState: existingSettings
287
+ ? 'existing settings are missing selected profile protections or hook registrations'
288
+ : 'settings file is missing',
289
+ proposedState: existingSettings
290
+ ? `merge ${options.profile || 'safe-write'} profile protections into existing settings`
291
+ : `create settings for ${options.profile || 'safe-write'} profile and register hooks`,
292
+ content,
112
293
  };
113
294
  }
114
295
 
115
- function buildTemplateFiles(templateKey, stacks, ctx) {
296
+ function buildTemplateFiles(templateKey, stacks, ctx, triggers, options = {}) {
297
+ const patchFiles = templateKey === 'agents' ? buildAgentPatchFiles(ctx) : [];
298
+
299
+ if (templateKey === 'claude-md') {
300
+ const patchFile = buildClaudeMdPatchFile(ctx, stacks);
301
+ if (patchFile) {
302
+ return [patchFile];
303
+ }
304
+ }
305
+
116
306
  const template = TEMPLATES[templateKey];
117
307
  if (!template) return [];
118
308
 
@@ -124,10 +314,13 @@ function buildTemplateFiles(templateKey, stacks, ctx) {
124
314
  const targetDir = TEMPLATE_DIR_MAP[templateKey];
125
315
  if (!targetDir) return [];
126
316
 
127
- return Object.entries(result).map(([fileName, content]) => ({
317
+ const generatedFiles = Object.entries(result).map(([fileName, content]) => ({
128
318
  path: path.posix.join(targetDir.replace(/\\/g, '/'), fileName),
129
319
  content,
130
320
  }));
321
+
322
+ const patchedPaths = new Set(patchFiles.map(file => file.path));
323
+ return [...patchFiles, ...generatedFiles.filter(file => !patchedPaths.has(file.path))];
131
324
  }
132
325
 
133
326
  function toProposal(templateKey, triggers, templateFiles, ctx) {
@@ -139,9 +332,9 @@ function toProposal(templateKey, triggers, templateFiles, ctx) {
139
332
  const highestImpact = sortedTriggers[0]?.impact || 'medium';
140
333
  const files = templateFiles.map(file => {
141
334
  const exists = ctx.fileContent(file.path) !== null || ctx.hasDir(file.path);
142
- const action = exists ? 'manual-review' : 'create';
143
- const currentState = exists ? 'file already exists and will be preserved' : 'missing';
144
- const proposedState = exists ? 'generated baseline available for manual merge' : 'create new file';
335
+ const action = file.action || (exists ? 'manual-review' : 'create');
336
+ const currentState = file.currentState || (exists ? 'file already exists and will be preserved' : 'missing');
337
+ const proposedState = file.proposedState || (exists ? 'generated baseline available for manual merge' : 'create new file');
145
338
  const diffPreview = [
146
339
  `--- ${exists ? file.path : 'missing'}`,
147
340
  `+++ ${file.path}`,
@@ -173,7 +366,7 @@ function toProposal(templateKey, triggers, templateFiles, ctx) {
173
366
  })),
174
367
  rationale: sortedTriggers.map(trigger => trigger.fix),
175
368
  files,
176
- readyToApply: files.some(file => file.action === 'create'),
369
+ readyToApply: files.some(file => ['create', 'patch'].includes(file.action)),
177
370
  };
178
371
  }
179
372
 
@@ -185,12 +378,12 @@ async function buildProposalBundle(options) {
185
378
  const proposals = [];
186
379
 
187
380
  for (const [templateKey, triggers] of groups.entries()) {
188
- const templateFiles = buildTemplateFiles(templateKey, stacks, ctx);
381
+ const templateFiles = buildTemplateFiles(templateKey, stacks, ctx, triggers, options);
189
382
  if (templateKey === 'hooks') {
190
383
  const plannedHookFiles = templateFiles
191
384
  .map(file => path.basename(file.path))
192
385
  .filter(file => file.endsWith('.sh'));
193
- const settingsFile = buildHookSettings(ctx, plannedHookFiles);
386
+ const settingsFile = buildHookSettings(ctx, plannedHookFiles, options);
194
387
  if (settingsFile) {
195
388
  templateFiles.push(settingsFile);
196
389
  }
@@ -256,11 +449,75 @@ function writePlanFile(bundle, outFile) {
256
449
  });
257
450
  }
258
451
 
452
+ function tryParseJson(content) {
453
+ try {
454
+ return JSON.parse(content);
455
+ } catch {
456
+ return null;
457
+ }
458
+ }
459
+
460
+ function applyRuntimeSettingsOverlays(bundle, options) {
461
+ if (!bundle || !Array.isArray(bundle.proposals)) {
462
+ return bundle;
463
+ }
464
+
465
+ const ctx = new ProjectContext(options.dir);
466
+ const existingHooks = ctx.hasDir('.claude/hooks')
467
+ ? ctx.dirFiles('.claude/hooks').filter(file => file.endsWith('.sh'))
468
+ : [];
469
+
470
+ const proposals = bundle.proposals.map((proposal) => {
471
+ const settingsIndex = proposal.files.findIndex(file => file.path === '.claude/settings.json');
472
+ if (settingsIndex === -1) {
473
+ return proposal;
474
+ }
475
+
476
+ const plannedHookFiles = proposal.files
477
+ .filter(file => file.path.startsWith('.claude/hooks/') && file.path.endsWith('.sh'))
478
+ .map(file => path.basename(file.path));
479
+ const hookFiles = [...new Set([...existingHooks, ...plannedHookFiles])].sort();
480
+ const currentSettings = tryParseJson(proposal.files[settingsIndex].content) || ctx.jsonFile('.claude/settings.json') || null;
481
+ const mergedSettings = buildSettingsForProfile({
482
+ profileKey: options.profile || 'safe-write',
483
+ hookFiles,
484
+ existingSettings: currentSettings,
485
+ mcpPackKeys: options.mcpPacks || [],
486
+ });
487
+ const updatedContent = `${JSON.stringify(mergedSettings, null, 2)}\n`;
488
+ const currentFile = proposal.files[settingsIndex];
489
+
490
+ const files = [...proposal.files];
491
+ files[settingsIndex] = {
492
+ ...currentFile,
493
+ content: updatedContent,
494
+ preview: previewContent(updatedContent),
495
+ diffPreview: [
496
+ `--- ${ctx.fileContent(currentFile.path) !== null ? currentFile.path : 'missing'}`,
497
+ `+++ ${currentFile.path}`,
498
+ ...previewContent(updatedContent).split('\n').map(line => `+${line}`),
499
+ ].join('\n'),
500
+ currentState: currentFile.currentState || 'existing settings are missing runtime-selected protections or MCP packs',
501
+ proposedState: `merge ${options.profile || 'safe-write'} profile protections and requested MCP packs into settings`,
502
+ };
503
+
504
+ return {
505
+ ...proposal,
506
+ files,
507
+ };
508
+ });
509
+
510
+ return {
511
+ ...bundle,
512
+ proposals,
513
+ };
514
+ }
515
+
259
516
  function resolvePlan(bundle, options) {
260
517
  if (options.planFile) {
261
- return JSON.parse(fs.readFileSync(options.planFile, 'utf8'));
518
+ return applyRuntimeSettingsOverlays(JSON.parse(fs.readFileSync(options.planFile, 'utf8')), options);
262
519
  }
263
- return bundle;
520
+ return applyRuntimeSettingsOverlays(bundle, options);
264
521
  }
265
522
 
266
523
  async function applyProposalBundle(options) {
@@ -275,38 +532,49 @@ async function applyProposalBundle(options) {
275
532
  });
276
533
 
277
534
  const createdFiles = [];
535
+ const patchedFiles = [];
278
536
  const skippedFiles = [];
279
537
  for (const proposal of selected) {
280
538
  for (const file of proposal.files) {
281
- if (file.action !== 'create') {
539
+ if (!['create', 'patch'].includes(file.action)) {
282
540
  skippedFiles.push(file.path);
283
541
  continue;
284
542
  }
285
543
  const fullPath = path.join(options.dir, file.path);
286
- if (fs.existsSync(fullPath)) {
544
+ if (file.action === 'create' && fs.existsSync(fullPath)) {
287
545
  skippedFiles.push(file.path);
288
546
  continue;
289
547
  }
548
+ const previousContent = fs.existsSync(fullPath) ? fs.readFileSync(fullPath, 'utf8') : null;
290
549
  if (!options.dryRun) {
291
550
  fs.mkdirSync(path.dirname(fullPath), { recursive: true });
292
551
  fs.writeFileSync(fullPath, file.content, 'utf8');
293
552
  }
294
- createdFiles.push(file.path);
553
+ if (file.action === 'create') {
554
+ createdFiles.push(file.path);
555
+ } else {
556
+ patchedFiles.push({ path: file.path, previousContent });
557
+ }
295
558
  }
296
559
  }
297
560
 
298
561
  let rollback = null;
299
562
  let activity = null;
300
- if (!options.dryRun && createdFiles.length > 0) {
563
+ if (!options.dryRun && (createdFiles.length > 0 || patchedFiles.length > 0)) {
301
564
  rollback = writeRollbackArtifact(options.dir, {
302
565
  sourcePlan: options.planFile ? path.basename(options.planFile) : 'live-plan',
303
566
  createdFiles,
304
- rollbackInstructions: createdFiles.map(file => `Delete ${file}`),
567
+ patchedFiles,
568
+ rollbackInstructions: [
569
+ ...createdFiles.map(file => `Delete ${file}`),
570
+ ...patchedFiles.map(file => `Restore previous content for ${file.path} from this manifest`),
571
+ ],
305
572
  });
306
573
  activity = writeActivityArtifact(options.dir, 'apply', {
307
574
  sourcePlan: options.planFile ? path.basename(options.planFile) : 'live-plan',
308
575
  appliedProposalIds: selected.map(item => item.id),
309
576
  createdFiles,
577
+ patchedFiles: patchedFiles.map(file => file.path),
310
578
  skippedFiles,
311
579
  rollbackArtifact: rollback.relativePath,
312
580
  });
@@ -316,6 +584,7 @@ async function applyProposalBundle(options) {
316
584
  proposalCount: bundle.proposals.length,
317
585
  appliedProposalIds: selected.map(item => item.id),
318
586
  createdFiles,
587
+ patchedFiles: patchedFiles.map(file => file.path),
319
588
  skippedFiles,
320
589
  dryRun: options.dryRun === true,
321
590
  rollbackArtifact: rollback ? rollback.relativePath : null,
@@ -337,6 +606,7 @@ function printApplyResult(result, options = {}) {
337
606
  }
338
607
  console.log(` Applied proposal bundles: ${result.appliedProposalIds.join(', ') || 'none'}`);
339
608
  console.log(` Created files: ${result.createdFiles.join(', ') || 'none'}`);
609
+ console.log(` Patched files: ${result.patchedFiles.join(', ') || 'none'}`);
340
610
  if (result.rollbackArtifact) {
341
611
  console.log(` Rollback: ${result.rollbackArtifact}`);
342
612
  }