@shirayner/ace 0.1.1-snapshot.5 → 0.1.1-snapshot.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shirayner/ace",
3
- "version": "0.1.1-snapshot.5",
3
+ "version": "0.1.1-snapshot.7",
4
4
  "description": "AI Coding Environment - One command to set up your Claude Code harness",
5
5
  "bin": {
6
6
  "ace": "./bin/ace.js"
@@ -1,7 +1,10 @@
1
1
  import * as p from '@clack/prompts';
2
+ import path from 'path';
3
+ import fs from 'fs-extra';
2
4
  import { createRequire } from 'module';
3
- import { PRESETS, COMPONENTS } from '../core/constants.js';
5
+ import { PRESETS, COMPONENTS, CLAUDE_DIR, TEMPLATES_DIR, isAceOwnedFile } from '../core/constants.js';
4
6
  import { Installer } from '../core/installer.js';
7
+ import { mergeClaudeMd } from '../core/merger.js';
5
8
 
6
9
  const require = createRequire(import.meta.url);
7
10
  const pkg = require('../../package.json');
@@ -19,10 +22,8 @@ export async function initCommand(options) {
19
22
  const version = pkg.version;
20
23
  const components = PRESETS['full'];
21
24
 
22
- // ─── Intro ──────────────────────────────────────────────
23
25
  p.intro(`ace v${version}`);
24
26
 
25
- // ─── Conflict detection ─────────────────────────────────
26
27
  const installer = new Installer({
27
28
  force: options.force,
28
29
  dryRun: options.dryRun,
@@ -33,89 +34,100 @@ export async function initCommand(options) {
33
34
 
34
35
  let resolutions = {};
35
36
 
37
+ // ─── Conflict detection with categorized preview ───
36
38
  if (!options.force) {
37
- const conflicts = await installer.detectConflicts();
38
- const conflictKeys = Object.keys(conflicts);
39
-
40
- if (conflictKeys.length > 0) {
41
- const totalFiles = conflictKeys.reduce((sum, k) => sum + conflicts[k].files.length, 0);
42
- const mergeComponents = conflictKeys.filter(k => conflicts[k].hasMerge);
43
-
44
- if (mergeComponents.length > 0) {
45
- p.log.info('Safe merge: CLAUDE.md, settings.json (preserves your changes)');
46
- }
47
- if (totalFiles > 0) {
48
- p.log.warn(`${totalFiles} existing file(s) found`);
39
+ const preview = await buildInstallPreview(installer, components);
40
+ const hasExisting = preview.merge.length > 0 || preview.skip.length > 0 || preview.conflict.length > 0;
41
+
42
+ if (hasExisting) {
43
+ // Show safe merge section
44
+ if (preview.merge.length > 0) {
45
+ const mergeLines = preview.merge.map(m => ` ${m.dest} — ${m.detail}`);
46
+ p.log.info(['Safe merge:', ...mergeLines].join('\n'));
49
47
  }
50
48
 
51
- const action = await p.select({
52
- message: 'How to handle existing files?',
53
- options: [
54
- { value: 'skip', label: 'Keep & merge', hint: 'recommended' },
55
- { value: 'overwrite', label: 'Overwrite all', hint: 'replace with latest' },
56
- { value: 'cancel', label: 'Cancel' },
57
- ],
58
- initialValue: 'skip',
59
- });
60
-
61
- if (p.isCancel(action) || action === 'cancel') {
62
- p.cancel('Setup cancelled.');
63
- process.exit(0);
49
+ // Show auto-skip section
50
+ if (preview.skip.length > 0) {
51
+ const skipLines = preview.skip.map(s => ` ${s} — preserves your data`);
52
+ p.log.message(['Auto-skip:', ...skipLines].join('\n'));
64
53
  }
65
54
 
66
- for (const key of conflictKeys) {
67
- resolutions[key] = action;
55
+ // Show conflict section + prompt
56
+ if (preview.conflict.length > 0) {
57
+ const conflictLines = preview.conflict.map(f => ` ${f}`);
58
+ p.log.warn([`${preview.conflict.length} existing file(s):`, ...conflictLines].join('\n'));
59
+
60
+ const action = await p.select({
61
+ message: `How to handle ${preview.conflict.length} existing files?`,
62
+ options: [
63
+ { value: 'skip', label: 'Keep existing', hint: 'recommended' },
64
+ { value: 'overwrite', label: 'Overwrite with latest' },
65
+ { value: 'cancel', label: 'Cancel' },
66
+ ],
67
+ initialValue: 'skip',
68
+ });
69
+
70
+ if (p.isCancel(action) || action === 'cancel') {
71
+ p.cancel('Setup cancelled.');
72
+ process.exit(0);
73
+ }
74
+
75
+ for (const componentName of components) {
76
+ resolutions[componentName] = action;
77
+ }
68
78
  }
69
79
  }
70
80
  }
71
81
 
72
82
  installer.resolutions = resolutions;
73
83
 
74
- // ─── Dry-run notice ─────────────────────────────────────
75
84
  if (options.dryRun) {
76
85
  p.log.warn('dry-run — no changes will be made');
77
86
  }
78
87
 
79
- // ─── Install ────────────────────────────────────────────
80
- p.log.step('Installing to ~/.claude/');
88
+ // ─── Install with single spinner ───────────────────
89
+ const s = p.spinner();
90
+ const componentResults = [];
91
+
92
+ s.start('Installing...');
81
93
 
82
94
  for (const componentName of components) {
83
95
  const component = COMPONENTS[componentName];
84
96
  if (!component) continue;
85
97
 
86
98
  const label = componentLabels[componentName] || componentName;
99
+ s.message(`Installing ${label}...`);
100
+
87
101
  const beforeInstalled = installer.results.installed.length;
88
102
  const beforeMerged = installer.results.merged.length;
89
103
  const beforeSkipped = installer.results.skipped.length;
90
104
 
91
- const s = p.spinner();
92
- s.start(label);
93
-
94
105
  try {
95
106
  await installer.installComponent(componentName, component);
96
- s.stop(label);
97
-
98
- const newInstalled = installer.results.installed.length - beforeInstalled;
99
- const newMerged = installer.results.merged.length - beforeMerged;
100
- const newSkipped = installer.results.skipped.length - beforeSkipped;
101
-
102
- if (newMerged > 0 && newInstalled === 0 && newSkipped === 0) {
103
- p.log.info(`${label} — merged`);
104
- } else if (newSkipped > 0 && newInstalled === 0 && newMerged === 0) {
105
- p.log.message(`${label} — unchanged`);
106
- } else {
107
- const count = newInstalled + newMerged;
108
- const detail = count > 0 ? `${count} file${count > 1 ? 's' : ''}` : '';
109
- p.log.success(`${label} — ${detail}`);
110
- }
107
+ componentResults.push({
108
+ label,
109
+ installed: installer.results.installed.length - beforeInstalled,
110
+ merged: installer.results.merged.length - beforeMerged,
111
+ skipped: installer.results.skipped.length - beforeSkipped,
112
+ error: null,
113
+ });
111
114
  } catch (err) {
112
- s.stop(label);
113
- p.log.error(`${label} — ${err.message}`);
115
+ componentResults.push({
116
+ label,
117
+ installed: installer.results.installed.length - beforeInstalled,
118
+ merged: installer.results.merged.length - beforeMerged,
119
+ skipped: installer.results.skipped.length - beforeSkipped,
120
+ error: err.message,
121
+ });
114
122
  installer.results.errors.push({ component: componentName, error: err.message });
115
123
  }
116
124
  }
117
125
 
118
- // ─── Summary ────────────────────────────────────────────
126
+ s.stop('Installed to ~/.claude/');
127
+
128
+ // ─── Summary table ─────────────────────────────────
129
+ p.log.message(formatSummaryTable(componentResults));
130
+
119
131
  const { installed, merged, skipped, errors } = installer.results;
120
132
  const parts = [];
121
133
  if (installed.length > 0) parts.push(`${installed.length} installed`);
@@ -128,12 +140,12 @@ export async function initCommand(options) {
128
140
  p.log.warn(`${parts.join(', ')}, ${errors.length} failed`);
129
141
  }
130
142
 
131
- // ─── Next Steps ─────────────────────────────────────────
143
+ // ─── Next Steps ────────────────────────────────────
132
144
  p.note(
133
145
  [
134
146
  'Get started',
135
147
  ' 1. cd <your-project> && ace spec init',
136
- ' 2. Open Claude Code, type: /opsx:propose 创建需求提案',
148
+ ' 2. Open Claude Code, type: /opsx:propose',
137
149
  '',
138
150
  'Customize',
139
151
  ' Change role edit ~/.claude/memory/user_profile.md',
@@ -144,10 +156,98 @@ export async function initCommand(options) {
144
156
  'Next steps'
145
157
  );
146
158
 
147
- // ─── Outro ──────────────────────────────────────────────
148
159
  if (errors.length === 0) {
149
160
  p.outro('Done. Go to your project and run ace spec init.');
150
161
  } else {
151
162
  p.outro('Done with errors. Run ace doctor to diagnose.');
152
163
  }
153
164
  }
165
+
166
+ // ─── Helpers ───────────────────────────────────────────
167
+
168
+ /**
169
+ * Scan target directory and categorize existing files by handling strategy.
170
+ */
171
+ async function buildInstallPreview(installer, components) {
172
+ const preview = { merge: [], skip: [], conflict: [] };
173
+
174
+ for (const componentName of components) {
175
+ const component = COMPONENTS[componentName];
176
+ if (!component || component.isPlugin) continue;
177
+
178
+ if (component.files) {
179
+ for (const file of component.files) {
180
+ const destPath = path.join(installer.targetDir, file.dest);
181
+ if (await fs.pathExists(destPath)) {
182
+ if (file.merge === 'claude-md' || file.merge === 'settings-json') {
183
+ preview.merge.push({ src: file.src, dest: file.dest, strategy: file.merge });
184
+ } else if (file.merge === 'skip-existing') {
185
+ preview.skip.push(file.dest);
186
+ } else {
187
+ preview.conflict.push(file.dest);
188
+ }
189
+ }
190
+ }
191
+ }
192
+
193
+ if (component.rulesDir) {
194
+ const srcDir = path.join(installer.templatesDir, component.rulesDir);
195
+ const destDir = path.join(installer.targetDir, component.rulesDir);
196
+ if (await fs.pathExists(srcDir)) {
197
+ const files = (await fs.readdir(srcDir)).filter(f => f.endsWith('.md'));
198
+ for (const f of files) {
199
+ const relativePath = path.join(component.rulesDir, f).replace(/\\/g, '/');
200
+ // ACE-owned files are overwritten directly, not shown as conflicts
201
+ if (await fs.pathExists(path.join(destDir, f)) && !isAceOwnedFile(relativePath)) {
202
+ preview.conflict.push(relativePath);
203
+ }
204
+ }
205
+ }
206
+ }
207
+
208
+ if (component.conditional) {
209
+ for (const file of component.conditional) {
210
+ if (file.roles?.includes(installer.role)) {
211
+ const destPath = path.join(installer.targetDir, file.dest);
212
+ // ACE-owned files are overwritten directly, not shown as conflicts
213
+ if (await fs.pathExists(destPath) && !isAceOwnedFile(file.dest)) {
214
+ preview.conflict.push(file.dest);
215
+ }
216
+ }
217
+ }
218
+ }
219
+ }
220
+
221
+ // Enrich merge files with detail
222
+ for (const item of preview.merge) {
223
+ if (item.strategy === 'claude-md') {
224
+ try {
225
+ const existing = await fs.readFile(path.join(installer.targetDir, item.dest), 'utf-8');
226
+ const template = await fs.readFile(path.join(installer.templatesDir, item.src), 'utf-8');
227
+ const { added } = mergeClaudeMd(existing, template);
228
+ item.detail = added.length > 0 ? `adds ${added.length} new @references` : 'up to date';
229
+ } catch {
230
+ item.detail = 'will merge';
231
+ }
232
+ } else if (item.strategy === 'settings-json') {
233
+ item.detail = 'merges permissions & plugins';
234
+ }
235
+ }
236
+
237
+ return preview;
238
+ }
239
+
240
+ /**
241
+ * Format component results into an aligned summary table.
242
+ */
243
+ function formatSummaryTable(results) {
244
+ const maxLen = Math.max(...results.map(r => r.label.length));
245
+ return results.map(r => {
246
+ const padded = r.label.padEnd(maxLen);
247
+ if (r.error) return `\u25A0 ${padded} ${r.error}`;
248
+ if (r.merged > 0 && r.installed === 0 && r.skipped === 0) return `\u25C6 ${padded} merged`;
249
+ if (r.skipped > 0 && r.installed === 0 && r.merged === 0) return `\u2502 ${padded} unchanged`;
250
+ const count = r.installed + r.merged;
251
+ return `\u25C6 ${padded} ${count} file${count > 1 ? 's' : ''}`;
252
+ }).join('\n');
253
+ }
@@ -67,6 +67,36 @@ export const SPEC_TEMPLATE_FILES = [
67
67
  'retrospective-template.md',
68
68
  ];
69
69
 
70
+ /**
71
+ * Patterns for files owned by ACE - these are overwritten directly on init without prompting.
72
+ * Used to identify ACE-owned content in rules/, hooks/, and hookify/.
73
+ */
74
+ export const ACE_OWNED_PATTERNS = [
75
+ /^rules\/ace\//, // rules/ace/*.md
76
+ /^hooks\/ace\./, // hooks/ace.*.sh
77
+ /^hookify\.ace\./, // hookify.ace.*.local.md
78
+ ];
79
+
80
+ /**
81
+ * Check if a file path (relative to ~/.claude/) is owned by ACE.
82
+ * @param {string} relativePath - Path like 'rules/ace/thinking.md' or 'hooks/ace.java-compile-check.sh'
83
+ * @returns {boolean}
84
+ */
85
+ export function isAceOwnedFile(relativePath) {
86
+ return ACE_OWNED_PATTERNS.some(pattern => pattern.test(relativePath));
87
+ }
88
+
89
+ /**
90
+ * Check if an @reference path is owned by ACE.
91
+ * @param {string} refPath - Reference path like '~/.claude/rules/ace/thinking.md' or '~/.claude/hooks/ace.java-compile-check.sh'
92
+ * @returns {boolean}
93
+ */
94
+ export function isAceOwnedRef(refPath) {
95
+ // Remove the ~/.claude/ prefix if present
96
+ const relativePath = refPath.replace(/^~\/\.claude\//, '');
97
+ return isAceOwnedFile(relativePath);
98
+ }
99
+
70
100
  export const COMPONENTS = {
71
101
  core: {
72
102
  description: 'Core config (CLAUDE.md + settings.json)',
@@ -97,13 +127,13 @@ export const COMPONENTS = {
97
127
  description: 'Safety guard rules (block dangerous ops, protect secrets, safe git, code quality, require verification)',
98
128
  required: false,
99
129
  files: [
100
- { src: 'hookify/ace.hookify.block-dangerous-ops.local.md', dest: 'hooks/ace.hookify.block-dangerous-ops.local.md' },
101
- { src: 'hookify/ace.hookify.protect-secrets.local.md', dest: 'hooks/ace.hookify.protect-secrets.local.md' },
102
- { src: 'hookify/ace.hookify.safe-git-commands.local.md', dest: 'hooks/ace.hookify.safe-git-commands.local.md' },
103
- { src: 'hookify/ace.hookify.code-quality-gate.local.md', dest: 'hooks/ace.hookify.code-quality-gate.local.md' },
104
- { src: 'hookify/ace.hookify.require-verification.local.md', dest: 'hooks/ace.hookify.require-verification.local.md' },
105
- { src: 'hookify/hookify.dangerous-commands.local.md', dest: 'hooks/hookify.dangerous-commands.local.md' },
106
- { src: 'hookify/hookify.sensitive-data.local.md', dest: 'hooks/hookify.sensitive-data.local.md' },
130
+ { src: 'hookify/hookify.ace.block-dangerous-ops.local.md', dest: 'hookify.ace.block-dangerous-ops.local.md' },
131
+ { src: 'hookify/hookify.ace.protect-secrets.local.md', dest: 'hookify.ace.protect-secrets.local.md' },
132
+ { src: 'hookify/hookify.ace.safe-git-commands.local.md', dest: 'hookify.ace.safe-git-commands.local.md' },
133
+ { src: 'hookify/hookify.ace.code-quality-gate.local.md', dest: 'hookify.ace.code-quality-gate.local.md' },
134
+ { src: 'hookify/hookify.ace.require-verification.local.md', dest: 'hookify.ace.require-verification.local.md' },
135
+ { src: 'hookify/hookify.ace.dangerous-commands.local.md', dest: 'hookify.ace.dangerous-commands.local.md' },
136
+ { src: 'hookify/hookify.ace.sensitive-data.local.md', dest: 'hookify.ace.sensitive-data.local.md' },
107
137
  ],
108
138
  },
109
139
  memory: {
@@ -3,7 +3,7 @@ import path from 'path';
3
3
  import chalk from 'chalk';
4
4
  import ora from 'ora';
5
5
  import {
6
- CLAUDE_DIR, TEMPLATES_DIR, COMPONENTS,
6
+ CLAUDE_DIR, TEMPLATES_DIR, COMPONENTS, isAceOwnedFile,
7
7
  PLUGIN_SRC_DIR, PLUGIN_CACHE_DIR, INSTALLED_PLUGINS_FILE,
8
8
  KNOWN_MARKETPLACES_FILE, MARKETPLACE_DIR, MARKETPLACE_NAME,
9
9
  PLUGIN_KEY, PLUGIN_NAME,
@@ -255,22 +255,25 @@ export class Installer {
255
255
  }
256
256
 
257
257
  if (exists && !this.force) {
258
- if (fileSpec.merge === 'claude-md') {
258
+ // ACE-owned files are always overwritten (no user prompt needed)
259
+ if (isAceOwnedFile(fileSpec.dest)) {
260
+ // fall through to install (overwrite)
261
+ } else if (fileSpec.merge === 'claude-md') {
259
262
  await this.mergeClaudeMdFile(srcPath, destPath, fileSpec);
260
263
  return;
261
- }
262
- if (fileSpec.merge === 'settings-json') {
264
+ } else if (fileSpec.merge === 'settings-json') {
263
265
  await this.mergeSettingsJsonFile(srcPath, destPath, fileSpec);
264
266
  return;
265
- }
266
- // Check per-component resolution
267
- const resolution = this.resolutions[componentName];
268
- if (resolution === 'overwrite') {
269
- // fall through to install
270
267
  } else {
271
- // default: skip
272
- this.results.skipped.push(fileSpec.dest);
273
- return;
268
+ // Check per-component resolution
269
+ const resolution = this.resolutions[componentName];
270
+ if (resolution === 'overwrite') {
271
+ // fall through to install
272
+ } else {
273
+ // default: skip
274
+ this.results.skipped.push(fileSpec.dest);
275
+ return;
276
+ }
274
277
  }
275
278
  }
276
279
 
@@ -1,19 +1,146 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import deepmerge from 'deepmerge';
4
+ import { isAceOwnedRef } from './constants.js';
4
5
 
5
6
  /**
6
- * Merge CLAUDE.md: append missing @references from template into existing file.
7
- * Preserves all existing content, only adds new @references.
7
+ * Marker constants for ACE-managed sections in CLAUDE.md
8
+ */
9
+ const ACE_MANAGED_START = '<!-- ace:managed:start -->';
10
+ const ACE_MANAGED_END = '<!-- ace:managed:end -->';
11
+
12
+ /**
13
+ * Merge CLAUDE.md using marker-based section replacement.
14
+ *
15
+ * Strategy:
16
+ * 1. If both existing and template have ace:managed markers, replace the section
17
+ * between markers with template content, while preserving content outside markers.
18
+ * 2. Clean up any ACE-owned references outside the managed section (obsolete refs).
19
+ * 3. If markers are missing or incomplete, fall back to append-only strategy for
20
+ * backward compatibility.
21
+ *
22
+ * @param {string} existingContent - Current CLAUDE.md content
23
+ * @param {string} templateContent - Template CLAUDE.md content
24
+ * @returns {{content: string, added: string[], removed: string[]}} Merged content and change list
8
25
  */
9
26
  export function mergeClaudeMd(existingContent, templateContent) {
27
+ // Check if both files have complete marker sections
28
+ const existingHasMarkers = hasCompleteMarkers(existingContent);
29
+ const templateHasMarkers = hasCompleteMarkers(templateContent);
30
+
31
+ if (existingHasMarkers && templateHasMarkers) {
32
+ return mergeWithMarkers(existingContent, templateContent);
33
+ }
34
+
35
+ // Fall back to legacy append strategy
36
+ return mergeWithAppend(existingContent, templateContent);
37
+ }
38
+
39
+ /**
40
+ * Check if content has both start and end markers.
41
+ */
42
+ function hasCompleteMarkers(content) {
43
+ const hasStart = content.includes(ACE_MANAGED_START);
44
+ const hasEnd = content.includes(ACE_MANAGED_END);
45
+ return hasStart && hasEnd;
46
+ }
47
+
48
+ /**
49
+ * Merge using marker-based section replacement.
50
+ * Replaces content between ace:managed markers and cleans up obsolete ACE refs.
51
+ */
52
+ function mergeWithMarkers(existingContent, templateContent) {
53
+ // Extract the managed section from template
54
+ const templateManaged = extractManagedSection(templateContent);
55
+
56
+ // Get all ACE-owned refs from template (these are the current/active ones)
57
+ const templateRefs = extractRefs(templateManaged);
58
+
59
+ // Replace the managed section in existing content
60
+ let result = replaceManagedSection(existingContent, templateManaged);
61
+
62
+ // Clean up any obsolete ACE refs outside the managed section
63
+ const removed = [];
64
+ const lines = result.split('\n');
65
+ const cleanedLines = lines.map(line => {
66
+ const refs = extractRefs(line);
67
+ const hasObsoleteAceRef = refs.some(ref => {
68
+ // Check if this is an ACE-owned ref that's NOT in the new template
69
+ if (isAceOwnedRef(ref)) {
70
+ const refWithAt = `@${ref}`;
71
+ if (!templateRefs.includes(refWithAt)) {
72
+ removed.push(ref);
73
+ return true; // This line has an obsolete ref
74
+ }
75
+ }
76
+ return false;
77
+ });
78
+
79
+ // Return null to mark for removal, otherwise keep line
80
+ return hasObsoleteAceRef ? null : line;
81
+ }).filter(line => line !== null);
82
+
83
+ result = cleanedLines.join('\n');
84
+
85
+ // Get the new refs that were added (in the managed section)
86
+ const existingRefs = extractRefs(existingContent);
87
+ const added = templateRefs
88
+ .map(ref => ref.replace(/^@/, ''))
89
+ .filter(ref => !existingRefs.includes(ref));
90
+
91
+ return { content: result, added, removed };
92
+ }
93
+
94
+ /**
95
+ * Extract content between ace:managed markers.
96
+ */
97
+ function extractManagedSection(content) {
98
+ const startIdx = content.indexOf(ACE_MANAGED_START);
99
+ const endIdx = content.indexOf(ACE_MANAGED_END);
100
+
101
+ if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) {
102
+ return content;
103
+ }
104
+
105
+ return content.slice(startIdx, endIdx + ACE_MANAGED_END.length);
106
+ }
107
+
108
+ /**
109
+ * Replace the managed section in existing content with new managed content.
110
+ */
111
+ function replaceManagedSection(existingContent, newManagedContent) {
112
+ const startIdx = existingContent.indexOf(ACE_MANAGED_START);
113
+ const endIdx = existingContent.indexOf(ACE_MANAGED_END);
114
+
115
+ if (startIdx === -1 || endIdx === -1) {
116
+ return existingContent;
117
+ }
118
+
119
+ const before = existingContent.slice(0, startIdx);
120
+ const after = existingContent.slice(endIdx + ACE_MANAGED_END.length);
121
+
122
+ // Normalize whitespace: ensure single newline separation
123
+ return before.trimEnd() + '\n' + newManagedContent + '\n' + after.trimStart();
124
+ }
125
+
126
+ /**
127
+ * Legacy merge strategy: append missing @references from template.
128
+ * Used for backward compatibility when markers are not present.
129
+ */
130
+ function mergeWithAppend(existingContent, templateContent) {
10
131
  const existingRefs = extractRefs(existingContent);
11
132
  const templateRefs = extractRefs(templateContent);
12
133
 
13
- const missingRefs = templateRefs.filter(ref => !existingRefs.includes(ref));
134
+ // Filter out ACE-owned refs from existing (we'll add current ones from template)
135
+ // This provides some cleanup even in legacy mode
136
+ const userRefs = existingRefs.filter(ref => !isAceOwnedRef(ref));
137
+ const templateRefsBare = templateRefs.map(ref => ref.replace(/^@/, ''));
138
+
139
+ // Find refs in template but not in user's refs
140
+ const missingRefs = templateRefsBare.filter(ref => !userRefs.includes(ref));
14
141
 
15
142
  if (missingRefs.length === 0) {
16
- return { content: existingContent, added: [] };
143
+ return { content: existingContent, added: [], removed: [] };
17
144
  }
18
145
 
19
146
  // Append missing references at the end with a section marker
@@ -32,15 +159,22 @@ export function mergeClaudeMd(existingContent, templateContent) {
32
159
  return {
33
160
  content: existingContent.trimEnd() + '\n' + appendSection,
34
161
  added: missingRefs,
162
+ removed: [],
35
163
  };
36
164
  }
37
165
 
166
+ /**
167
+ * Extract @reference paths from content.
168
+ */
38
169
  function extractRefs(content) {
39
170
  const refPattern = /@~?\/?\.?claude\/[^\s)]+/g;
40
171
  const matches = content.match(refPattern) || [];
41
- return matches.map(ref => ref.replace(/^@/, ''));
172
+ return matches;
42
173
  }
43
174
 
175
+ /**
176
+ * Find the full line containing a reference.
177
+ */
44
178
  function findRefLine(content, ref) {
45
179
  const lines = content.split('\n');
46
180
  return lines.find(line => line.includes(ref));
@@ -64,11 +64,6 @@ export class SpecInstaller {
64
64
  async runOpenspecInit() {
65
65
  if (this.skipOpenspec) return;
66
66
 
67
- if (await fs.pathExists(this.openspecDir) && !this.force) {
68
- this.results.skipped.push('openspec/ (already exists)');
69
- return;
70
- }
71
-
72
67
  if (this.dryRun) {
73
68
  this.results.installed.push('openspec/ (via openspec init)');
74
69
  return;
@@ -1,5 +1,6 @@
1
1
  # 全局配置索引
2
2
 
3
+ <!-- ace:managed:start -->
3
4
  ## 核心原则
4
5
  - @~/.claude/rules/ace/thinking.md - 深度思考原则(序验深广辨简)
5
6
 
@@ -15,11 +16,6 @@
15
16
  ## 质量控制
16
17
  - @~/.claude/rules/ace/memory-policy.md - Memory 质量策略
17
18
 
18
- ## Hookify 规则
19
- - @~/.claude/hooks/ace.hookify.block-dangerous-ops.local.md - 阻止危险操作
20
- - @~/.claude/hooks/ace.hookify.protect-secrets.local.md - 保护敏感信息
21
- - @~/.claude/hooks/ace.hookify.safe-git-commands.local.md - Git 安全命令
22
- - @~/.claude/hooks/ace.hookify.code-quality-gate.local.md - 代码质量门禁
23
-
24
19
  ## 交互规则
25
20
  - @~/.claude/rules/ace/interactive-clarify.md - 交互式澄清规则
21
+ <!-- ace:managed:end -->