@shirayner/ace 0.1.0-snapshot.3 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,6 +27,15 @@ $ ace init
27
27
  Done! Your AI coding environment is ready.
28
28
  ```
29
29
 
30
+ ### Spec-driven Development
31
+
32
+ ```
33
+ $ ace spec init ./my-project
34
+ ✔ openspec config installed
35
+ ✔ spec templates installed (taxonomy, issues, procedures, evolution)
36
+ Done! Spec workflow is ready.
37
+ ```
38
+
30
39
  ## Why ace?
31
40
 
32
41
  Claude Code is powerful out of the box — but configuring rules, skills,
@@ -34,8 +43,9 @@ safety guards, and memory templates by hand is tedious and error-prone.
34
43
 
35
44
  **ace solves this in one command:**
36
45
 
37
- - **Rules** — 7 cognitive and code-quality rules (deep thinking, clean code, ...)
46
+ - **Rules** — 8 cognitive and code-quality rules (deep thinking, clean code, ...)
38
47
  - **Skills** — 4 AI skills with namespace isolation (`ace:auto-goal`, `ace:coding`, ...)
48
+ - **Spec** — Spec-driven development workflow with OpenSpec integration
39
49
  - **Safety** — Hookify guards that block dangerous ops and protect secrets
40
50
  - **Memory** — Templates for cross-session memory and developer profiles
41
51
  - **Non-destructive** — Smart merge preserves your existing config; uninstall restores it
@@ -54,11 +64,12 @@ That's it. Run `ace doctor` to verify.
54
64
  | Component | Contents | Preset |
55
65
  |-----------|----------|--------|
56
66
  | **Core** | `CLAUDE.md` + `settings.json` (smart merge) | all |
57
- | **Rules** | 7 rules: thinking, clean-code, code-quality, ... | all |
67
+ | **Rules** | 8 rules: thinking, clean-code, code-quality, ... | all |
58
68
  | **Plugin** | 4 skills + 1 command (`ace:auto-goal`, `ace:coding`, ...) | all |
59
69
  | **Hookify** | 3 safety guards (block-dangerous-ops, protect-secrets, ...) | full, safe |
60
70
  | **Hooks** | Role-dependent scripts (e.g., Java compile check) | full |
61
71
  | **Memory** | MEMORY.md template + role-based developer profile | full, safe |
72
+ | **Spec** | Spec-driven workflow templates (taxonomy, issues, procedures) | `ace spec init` |
62
73
 
63
74
  ## Design Philosophy
64
75
 
package/README.zh-CN.md CHANGED
@@ -27,14 +27,22 @@ $ ace init
27
27
  Done! Your AI coding environment is ready.
28
28
  ```
29
29
 
30
- ## 为什么需要 ace?
30
+ ### Spec 驱动开发
31
+
32
+ ```
33
+ $ ace spec init ./my-project
34
+ ✔ openspec config installed
35
+ ✔ spec templates installed (taxonomy, issues, procedures, evolution)
36
+ Done! Spec workflow is ready.
37
+ ```
31
38
 
32
39
  Claude Code 开箱即用已经很强大——但手动配置规则、技能、安全守卫和记忆模板既繁琐又容易出错。
33
40
 
34
41
  **ace 用一条命令解决这个问题:**
35
42
 
36
- - **规则** — 7 条认知与代码质量规则(深度思考、Clean Code……)
43
+ - **规则** — 8 条认知与代码质量规则(深度思考、Clean Code……)
37
44
  - **技能** — 4 个 AI 技能,命名空间隔离(`ace:auto-goal`、`ace:coding`……)
45
+ - **规约** — Spec 驱动开发工作流,集成 OpenSpec
38
46
  - **安全** — Hookify 守卫,拦截危险操作并保护密钥
39
47
  - **记忆** — 跨会话记忆模板和开发者画像
40
48
  - **无损安装** — 智能合并保留你的已有配置;卸载可完整还原
@@ -53,11 +61,12 @@ ace init
53
61
  | 组件 | 内容 | 预设 |
54
62
  |------|------|------|
55
63
  | **Core** | `CLAUDE.md` + `settings.json`(智能合并) | 全部 |
56
- | **Rules** | 7 条规则:thinking、clean-code、code-quality…… | 全部 |
64
+ | **Rules** | 8 条规则:thinking、clean-code、code-quality…… | 全部 |
57
65
  | **Plugin** | 4 个技能 + 1 个命令(`ace:auto-goal`、`ace:coding`……) | 全部 |
58
66
  | **Hookify** | 3 条安全守卫(block-dangerous-ops、protect-secrets……) | full、safe |
59
67
  | **Hooks** | 角色相关脚本(如 Java 编译检查) | full |
60
68
  | **Memory** | MEMORY.md 模板 + 角色开发者画像 | full、safe |
69
+ | **Spec** | Spec 驱动工作流模板(分类、问题、流程) | `ace spec init` |
61
70
 
62
71
  ## 设计理念
63
72
 
package/bin/ace.js CHANGED
@@ -7,6 +7,7 @@ import { initCommand } from '../src/commands/init.js';
7
7
  import { doctorCommand } from '../src/commands/doctor.js';
8
8
  import { listCommand } from '../src/commands/list.js';
9
9
  import { uninstallCommand } from '../src/commands/uninstall.js';
10
+ import { specInitCommand, specDoctorCommand, specUpdateCommand } from '../src/commands/spec.js';
10
11
 
11
12
  const require = createRequire(import.meta.url);
12
13
  const pkg = require('../package.json');
@@ -43,4 +44,26 @@ program
43
44
  .option('-y, --yes', 'Skip confirmation prompt', false)
44
45
  .action(uninstallCommand);
45
46
 
47
+ const spec = program
48
+ .command('spec')
49
+ .description('Manage spec-driven development workflow (project-level)');
50
+
51
+ spec
52
+ .command('init [path]')
53
+ .description('Initialize spec workflow in a project')
54
+ .option('-f, --force', 'Overwrite existing configuration', false)
55
+ .option('--dry-run', 'Preview without making changes', false)
56
+ .option('--skip-openspec', 'Skip openspec CLI installation', false)
57
+ .action(specInitCommand);
58
+
59
+ spec
60
+ .command('doctor [path]')
61
+ .description('Check spec workflow health')
62
+ .action(specDoctorCommand);
63
+
64
+ spec
65
+ .command('update [path]')
66
+ .description('Update spec templates to latest version')
67
+ .action(specUpdateCommand);
68
+
46
69
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shirayner/ace",
3
- "version": "0.1.0-snapshot.3",
3
+ "version": "0.1.0",
4
4
  "description": "AI Coding Environment - One command to set up your Claude Code harness",
5
5
  "bin": {
6
6
  "ace": "./bin/ace.js"
@@ -30,6 +30,7 @@
30
30
  "inquirer": "^9.2.0",
31
31
  "fs-extra": "^11.2.0",
32
32
  "deepmerge": "^4.3.1",
33
+ "js-yaml": "^4.1.0",
33
34
  "ora": "^8.0.0"
34
35
  },
35
36
  "files": [
@@ -2,7 +2,7 @@ import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import chalk from 'chalk';
4
4
  import {
5
- CLAUDE_DIR, COMPONENTS,
5
+ CLAUDE_DIR, COMPONENTS, TEMPLATES_DIR,
6
6
  PLUGIN_CACHE_DIR, INSTALLED_PLUGINS_FILE, PLUGIN_KEY,
7
7
  KNOWN_MARKETPLACES_FILE, MARKETPLACE_DIR, MARKETPLACE_NAME,
8
8
  } from '../core/constants.js';
@@ -19,10 +19,19 @@ export async function doctorCommand() {
19
19
  checks.push(await check('CLAUDE.md', fs.pathExists(path.join(CLAUDE_DIR, 'CLAUDE.md'))));
20
20
  checks.push(await check('settings.json', fs.pathExists(path.join(CLAUDE_DIR, 'settings.json'))));
21
21
 
22
- // 3. Check rules (in ace/ namespace subdirectory)
23
- const ruleFiles = COMPONENTS.rules.files;
24
- for (const file of ruleFiles) {
25
- checks.push(await check(`rules/ace/${path.basename(file.dest)}`, fs.pathExists(path.join(CLAUDE_DIR, file.dest))));
22
+ // 3. Check rules (dynamically scan templates directory)
23
+ const rulesDir = COMPONENTS.rules.rulesDir;
24
+ if (rulesDir) {
25
+ const templateRulesDir = path.join(TEMPLATES_DIR, rulesDir);
26
+ try {
27
+ const ruleFiles = await fs.readdir(templateRulesDir);
28
+ for (const file of ruleFiles) {
29
+ if (!file.endsWith('.md')) continue;
30
+ checks.push(await check(`rules/ace/${file}`, fs.pathExists(path.join(CLAUDE_DIR, rulesDir, file))));
31
+ }
32
+ } catch {
33
+ checks.push({ name: 'rules/ace/ directory', ok: false });
34
+ }
26
35
  }
27
36
 
28
37
  // 4. Check plugin installation
@@ -2,7 +2,7 @@ import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import chalk from 'chalk';
4
4
  import {
5
- CLAUDE_DIR, COMPONENTS,
5
+ CLAUDE_DIR, COMPONENTS, TEMPLATES_DIR,
6
6
  PLUGIN_CACHE_DIR, INSTALLED_PLUGINS_FILE, PLUGIN_KEY,
7
7
  } from '../core/constants.js';
8
8
 
@@ -59,6 +59,14 @@ async function getPluginVersion() {
59
59
  async function getComponentStatus(component) {
60
60
  const allPaths = [];
61
61
 
62
+ if (component.rulesDir) {
63
+ try {
64
+ const templateDir = path.join(TEMPLATES_DIR, component.rulesDir);
65
+ const files = await fs.readdir(templateDir);
66
+ allPaths.push(...files.filter(f => f.endsWith('.md')).map(f => path.join(CLAUDE_DIR, component.rulesDir, f)));
67
+ } catch { /* ignore */ }
68
+ }
69
+
62
70
  if (component.files) {
63
71
  allPaths.push(...component.files.map(f => path.join(CLAUDE_DIR, f.dest)));
64
72
  }
@@ -83,11 +91,21 @@ async function getComponentDetails(component) {
83
91
  const missing = [];
84
92
  const installed = [];
85
93
 
86
- const allFiles = [
94
+ const allFiles = [];
95
+
96
+ if (component.rulesDir) {
97
+ try {
98
+ const templateDir = path.join(TEMPLATES_DIR, component.rulesDir);
99
+ const files = await fs.readdir(templateDir);
100
+ allFiles.push(...files.filter(f => f.endsWith('.md')).map(f => path.join(component.rulesDir, f)));
101
+ } catch { /* ignore */ }
102
+ }
103
+
104
+ allFiles.push(
87
105
  ...(component.files || []).map(f => f.dest),
88
106
  ...(component.directories || []),
89
107
  ...(component.conditional || []).map(f => f.dest),
90
- ];
108
+ );
91
109
 
92
110
  for (const file of allFiles) {
93
111
  const exists = await fs.pathExists(path.join(CLAUDE_DIR, file));
@@ -0,0 +1,124 @@
1
+ import path from 'path';
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ import { SpecInstaller } from '../core/spec-installer.js';
5
+
6
+ export async function specInitCommand(targetPath, options) {
7
+ const targetDir = targetPath ? path.resolve(targetPath) : process.cwd();
8
+
9
+ console.log(chalk.bold('\n ace spec init — Initialize spec-driven workflow\n'));
10
+ console.log(chalk.dim(` Target: ${targetDir}`));
11
+ if (options.force) console.log(chalk.yellow(' Force mode: existing files will be overwritten'));
12
+ if (options.dryRun) console.log(chalk.cyan(' Dry-run mode: no changes will be made'));
13
+ if (options.skipOpenspec) console.log(chalk.dim(' Skipping openspec CLI installation'));
14
+ console.log();
15
+
16
+ const installer = new SpecInstaller({
17
+ targetDir,
18
+ force: options.force,
19
+ dryRun: options.dryRun,
20
+ skipOpenspec: options.skipOpenspec,
21
+ });
22
+
23
+ const spinner = ora('Setting up spec workflow...').start();
24
+
25
+ try {
26
+ const { installed, skipped, merged, errors } = await installer.run();
27
+ spinner.stop();
28
+
29
+ printSummary(installed, skipped, merged, errors);
30
+ } catch (err) {
31
+ spinner.fail(`Unexpected error: ${err.message}`);
32
+ process.exit(1);
33
+ }
34
+ }
35
+
36
+ export async function specDoctorCommand(targetPath) {
37
+ const targetDir = targetPath ? path.resolve(targetPath) : process.cwd();
38
+
39
+ console.log(chalk.bold('\n ace spec doctor — Checking spec workflow health\n'));
40
+ console.log(chalk.dim(` Target: ${targetDir}\n`));
41
+
42
+ const installer = new SpecInstaller({ targetDir });
43
+ const checks = await installer.doctor();
44
+
45
+ const passed = checks.filter(c => c.ok).length;
46
+ const failed = checks.filter(c => !c.ok).length;
47
+
48
+ checks.forEach(c => {
49
+ const icon = c.ok ? chalk.green(' pass') : chalk.red(' FAIL');
50
+ console.log(` ${icon} ${c.name}`);
51
+ });
52
+
53
+ console.log(chalk.bold(`\n ${passed} passed, ${failed} failed\n`));
54
+
55
+ if (failed > 0) {
56
+ console.log(chalk.yellow(' Run `ace spec init` to fix missing components.\n'));
57
+ } else {
58
+ console.log(chalk.green(' Spec workflow is healthy.\n'));
59
+ }
60
+ }
61
+
62
+ export async function specUpdateCommand(targetPath, options) {
63
+ const targetDir = targetPath ? path.resolve(targetPath) : process.cwd();
64
+
65
+ console.log(chalk.bold('\n ace spec update — Updating spec templates\n'));
66
+ console.log(chalk.dim(` Target: ${targetDir}`));
67
+ console.log();
68
+
69
+ const installer = new SpecInstaller({
70
+ targetDir,
71
+ force: true,
72
+ skipOpenspec: true,
73
+ });
74
+
75
+ const spinner = ora('Updating spec templates...').start();
76
+
77
+ try {
78
+ // Only run template + config installation (skip openspec init)
79
+ await installer.installTemplates();
80
+ await installer.installConfig();
81
+ spinner.stop();
82
+
83
+ const { installed, skipped, merged, errors } = installer.results;
84
+ printSummary(installed, skipped, merged, errors);
85
+ } catch (err) {
86
+ spinner.fail(`Unexpected error: ${err.message}`);
87
+ process.exit(1);
88
+ }
89
+ }
90
+
91
+ function printSummary(installed, skipped, merged, errors) {
92
+ console.log(chalk.bold('\n Summary\n'));
93
+
94
+ if (installed.length > 0) {
95
+ console.log(chalk.green(` Installed (${installed.length}):`));
96
+ installed.forEach(f => console.log(chalk.green(` + ${f}`)));
97
+ }
98
+
99
+ if (merged.length > 0) {
100
+ console.log(chalk.blue(` Merged (${merged.length}):`));
101
+ merged.forEach(m => {
102
+ const detail = m.version ? ` (v${m.version})` : '';
103
+ console.log(chalk.blue(` ~ ${m.file}${detail}`));
104
+ });
105
+ }
106
+
107
+ if (skipped.length > 0) {
108
+ console.log(chalk.yellow(` Skipped (${skipped.length}):`));
109
+ skipped.forEach(f => console.log(chalk.yellow(` - ${f}`)));
110
+ }
111
+
112
+ if (errors.length > 0) {
113
+ console.log(chalk.red(` Errors (${errors.length}):`));
114
+ errors.forEach(e => console.log(chalk.red(` ! ${e.file || e.component}: ${e.error}`)));
115
+ }
116
+
117
+ if (errors.length === 0) {
118
+ console.log(chalk.green.bold('\n Done! Spec workflow is ready.\n'));
119
+ console.log(chalk.dim(' Use /opsx:propose to start a new change.'));
120
+ console.log(chalk.dim(' Run `ace spec doctor` to verify.\n'));
121
+ } else {
122
+ console.log(chalk.yellow('\n Completed with errors. Run `ace spec doctor` to diagnose.\n'));
123
+ }
124
+ }
@@ -49,6 +49,24 @@ export const ROLES = {
49
49
  },
50
50
  };
51
51
 
52
+ // Spec (project-level) constants
53
+ export const OPENSPEC_TEMPLATES_DIR = path.join(__dirname, '..', '..', 'templates', 'openspec');
54
+ export const SPEC_TEMPLATE_FILES = [
55
+ 'taxonomy/requirement-issue-taxonomy.md',
56
+ 'taxonomy/design-issue-taxonomy.md',
57
+ 'issues/requirement-issues.md',
58
+ 'issues/design-issues.md',
59
+ 'issues/retrospective-notes.md',
60
+ 'evolution/adr.md',
61
+ 'evolution/glossary.md',
62
+ 'evolution/risk-map.md',
63
+ 'procedures/requirement-clarification-flow.md',
64
+ 'procedures/design-clarification-flow.md',
65
+ 'procedures/interactive-clarification-protocol.md',
66
+ 'procedures/evolution-system.md',
67
+ 'retrospective-template.md',
68
+ ];
69
+
52
70
  export const COMPONENTS = {
53
71
  core: {
54
72
  description: 'Core config (CLAUDE.md + settings.json)',
@@ -61,15 +79,7 @@ export const COMPONENTS = {
61
79
  rules: {
62
80
  description: 'Cognitive & code quality rules',
63
81
  required: true,
64
- files: [
65
- { src: 'rules/ace/thinking.md', dest: 'rules/ace/thinking.md' },
66
- { src: 'rules/ace/clean-code.md', dest: 'rules/ace/clean-code.md' },
67
- { src: 'rules/ace/code-quality.md', dest: 'rules/ace/code-quality.md' },
68
- { src: 'rules/ace/reporting.md', dest: 'rules/ace/reporting.md' },
69
- { src: 'rules/ace/task-recovery.md', dest: 'rules/ace/task-recovery.md' },
70
- { src: 'rules/ace/context-hygiene.md', dest: 'rules/ace/context-hygiene.md' },
71
- { src: 'rules/ace/memory-policy.md', dest: 'rules/ace/memory-policy.md' },
72
- ],
82
+ rulesDir: 'rules/ace',
73
83
  },
74
84
  plugin: {
75
85
  description: 'Ace plugin (skills: auto-goal, coding, skill-creator, skill-optimize; commands: report)',
@@ -50,6 +50,10 @@ export class Installer {
50
50
  return;
51
51
  }
52
52
 
53
+ if (component.rulesDir) {
54
+ await this.installRulesDir(component.rulesDir);
55
+ }
56
+
53
57
  if (component.files) {
54
58
  for (const file of component.files) {
55
59
  await this.installFile(file);
@@ -148,6 +152,22 @@ export class Installer {
148
152
  this.results.merged.push({ file: 'plugins/known_marketplaces.json' });
149
153
  }
150
154
 
155
+ async installRulesDir(rulesDir) {
156
+ const srcDir = path.join(this.templatesDir, rulesDir);
157
+ if (!await fs.pathExists(srcDir)) {
158
+ this.results.errors.push({ file: rulesDir, error: 'Rules directory not found' });
159
+ return;
160
+ }
161
+ const files = await fs.readdir(srcDir);
162
+ for (const file of files) {
163
+ if (!file.endsWith('.md')) continue;
164
+ await this.installFile({
165
+ src: path.join(rulesDir, file),
166
+ dest: path.join(rulesDir, file),
167
+ });
168
+ }
169
+ }
170
+
151
171
  async installFile(fileSpec) {
152
172
  const srcPath = path.join(this.templatesDir, fileSpec.src);
153
173
  const destPath = path.join(this.targetDir, fileSpec.dest);
@@ -0,0 +1,205 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import { execSync } from 'child_process';
4
+ import yaml from 'js-yaml';
5
+ import { OPENSPEC_TEMPLATES_DIR, SPEC_TEMPLATE_FILES } from './constants.js';
6
+ import { mergeSpecConfig } from './yaml-merger.js';
7
+ import { backupFile } from './merger.js';
8
+
9
+ export class SpecInstaller {
10
+ constructor(options = {}) {
11
+ this.targetDir = options.targetDir || process.cwd();
12
+ this.templatesDir = OPENSPEC_TEMPLATES_DIR;
13
+ this.force = options.force || false;
14
+ this.dryRun = options.dryRun || false;
15
+ this.skipOpenspec = options.skipOpenspec || false;
16
+ this.results = { installed: [], skipped: [], merged: [], errors: [] };
17
+ }
18
+
19
+ get openspecDir() {
20
+ return path.join(this.targetDir, 'openspec');
21
+ }
22
+
23
+ async run() {
24
+ await this.ensureOpenspecCli();
25
+ await this.runOpenspecInit();
26
+ await this.installTemplates();
27
+ await this.installConfig();
28
+ return this.results;
29
+ }
30
+
31
+ async ensureOpenspecCli() {
32
+ if (this.skipOpenspec) return;
33
+
34
+ if (this.isOpenspecInstalled()) {
35
+ this.results.skipped.push('@fission-ai/openspec (already installed)');
36
+ return;
37
+ }
38
+
39
+ if (this.dryRun) {
40
+ this.results.installed.push('@fission-ai/openspec (npm global)');
41
+ return;
42
+ }
43
+
44
+ try {
45
+ execSync('npm install -g @fission-ai/openspec', { stdio: 'pipe' });
46
+ this.results.installed.push('@fission-ai/openspec (npm global)');
47
+ } catch (err) {
48
+ this.results.errors.push({
49
+ component: 'openspec-cli',
50
+ error: `Failed to install @fission-ai/openspec: ${err.message}. Use --skip-openspec to skip.`,
51
+ });
52
+ }
53
+ }
54
+
55
+ isOpenspecInstalled() {
56
+ try {
57
+ execSync('openspec --version', { stdio: 'pipe' });
58
+ return true;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ async runOpenspecInit() {
65
+ if (this.skipOpenspec) return;
66
+
67
+ if (await fs.pathExists(this.openspecDir) && !this.force) {
68
+ this.results.skipped.push('openspec/ (already exists)');
69
+ return;
70
+ }
71
+
72
+ if (this.dryRun) {
73
+ this.results.installed.push('openspec/ (via openspec init)');
74
+ return;
75
+ }
76
+
77
+ if (!this.isOpenspecInstalled()) return;
78
+
79
+ try {
80
+ execSync('openspec init', { cwd: this.targetDir, stdio: 'inherit' });
81
+ this.results.installed.push('openspec/ (via openspec init)');
82
+ } catch (err) {
83
+ this.results.errors.push({
84
+ component: 'openspec-init',
85
+ error: `openspec init failed: ${err.message}`,
86
+ });
87
+ }
88
+ }
89
+
90
+ async installTemplates() {
91
+ for (const file of SPEC_TEMPLATE_FILES) {
92
+ const srcPath = path.join(this.templatesDir, file);
93
+ const destPath = path.join(this.openspecDir, 'templates', file);
94
+
95
+ if (!await fs.pathExists(srcPath)) {
96
+ this.results.errors.push({ file, error: 'Template not found' });
97
+ continue;
98
+ }
99
+
100
+ const exists = await fs.pathExists(destPath);
101
+ if (exists && !this.force) {
102
+ this.results.skipped.push(`openspec/templates/${file}`);
103
+ continue;
104
+ }
105
+
106
+ if (this.dryRun) {
107
+ this.results.installed.push(`openspec/templates/${file}`);
108
+ continue;
109
+ }
110
+
111
+ await fs.ensureDir(path.dirname(destPath));
112
+ await fs.copy(srcPath, destPath, { overwrite: this.force });
113
+ this.results.installed.push(`openspec/templates/${file}`);
114
+ }
115
+ }
116
+
117
+ async installConfig() {
118
+ const srcPath = path.join(this.templatesDir, 'config.yaml');
119
+ const destPath = path.join(this.openspecDir, 'config.yaml');
120
+
121
+ if (!await fs.pathExists(srcPath)) {
122
+ this.results.errors.push({ file: 'config.yaml', error: 'Template not found' });
123
+ return;
124
+ }
125
+
126
+ const exists = await fs.pathExists(destPath);
127
+
128
+ if (exists && !this.force) {
129
+ // Merge: system fields overwrite, user fields preserve
130
+ if (this.dryRun) {
131
+ this.results.merged.push({ file: 'openspec/config.yaml' });
132
+ return;
133
+ }
134
+
135
+ const existingContent = await fs.readFile(destPath, 'utf-8');
136
+ const templateContent = await fs.readFile(srcPath, 'utf-8');
137
+ const existing = yaml.load(existingContent);
138
+ const template = yaml.load(templateContent);
139
+ const merged = mergeSpecConfig(existing, template);
140
+
141
+ await backupFile(destPath);
142
+ await fs.writeFile(destPath, yaml.dump(merged, { lineWidth: -1 }), 'utf-8');
143
+ this.results.merged.push({ file: 'openspec/config.yaml', version: merged.version });
144
+ return;
145
+ }
146
+
147
+ if (this.dryRun) {
148
+ this.results.installed.push('openspec/config.yaml');
149
+ return;
150
+ }
151
+
152
+ await fs.ensureDir(path.dirname(destPath));
153
+ await fs.copy(srcPath, destPath);
154
+ this.results.installed.push('openspec/config.yaml');
155
+ }
156
+
157
+ async doctor() {
158
+ const checks = [];
159
+
160
+ // 1. Node.js version
161
+ const nodeVersion = process.version;
162
+ const major = parseInt(nodeVersion.slice(1));
163
+ checks.push({ name: 'Node.js >= 18', ok: major >= 18 });
164
+
165
+ // 2. openspec CLI
166
+ checks.push({ name: '@fission-ai/openspec CLI', ok: this.isOpenspecInstalled() });
167
+
168
+ // 3. openspec/ directory
169
+ checks.push({ name: 'openspec/ directory', ok: await fs.pathExists(this.openspecDir) });
170
+
171
+ // 4. config.yaml
172
+ const configPath = path.join(this.openspecDir, 'config.yaml');
173
+ const configExists = await fs.pathExists(configPath);
174
+ checks.push({ name: 'openspec/config.yaml', ok: configExists });
175
+
176
+ if (configExists) {
177
+ try {
178
+ const config = yaml.load(await fs.readFile(configPath, 'utf-8'));
179
+ checks.push({ name: 'config.yaml has schema field', ok: !!config?.schema });
180
+ checks.push({ name: `config.yaml version: ${config?.version || 'unknown'}`, ok: !!config?.version });
181
+ } catch {
182
+ checks.push({ name: 'config.yaml valid YAML', ok: false });
183
+ }
184
+ }
185
+
186
+ // 5. Required template files
187
+ for (const file of SPEC_TEMPLATE_FILES) {
188
+ const filePath = path.join(this.openspecDir, 'templates', file);
189
+ checks.push({
190
+ name: `template: ${file}`,
191
+ ok: await fs.pathExists(filePath),
192
+ });
193
+ }
194
+
195
+ // 6. Git
196
+ try {
197
+ execSync('git --version', { stdio: 'pipe' });
198
+ checks.push({ name: 'Git available', ok: true });
199
+ } catch {
200
+ checks.push({ name: 'Git available', ok: false });
201
+ }
202
+
203
+ return checks;
204
+ }
205
+ }
@@ -0,0 +1,45 @@
1
+ import fs from 'fs-extra';
2
+ import yaml from 'js-yaml';
3
+
4
+ const SYSTEM_FIELDS = ['schema', 'version', 'context'];
5
+
6
+ /**
7
+ * Merge spec config.yaml: system fields overwrite, user fields preserve, rules merge.
8
+ */
9
+ export function mergeSpecConfig(existing, template) {
10
+ const merged = { ...existing };
11
+
12
+ for (const field of SYSTEM_FIELDS) {
13
+ if (template[field] !== undefined) {
14
+ merged[field] = template[field];
15
+ }
16
+ }
17
+
18
+ if (template.rules) {
19
+ merged.rules = { ...(existing.rules || {}), ...template.rules };
20
+ }
21
+
22
+ return merged;
23
+ }
24
+
25
+ /**
26
+ * Read, merge, and write config.yaml with backup.
27
+ * Returns { merged: true, version } or { merged: false, reason }.
28
+ */
29
+ export async function mergeSpecConfigFile(existingPath, templatePath, backupDir) {
30
+ const existingContent = await fs.readFile(existingPath, 'utf-8');
31
+ const templateContent = await fs.readFile(templatePath, 'utf-8');
32
+
33
+ const existing = yaml.load(existingContent);
34
+ const template = yaml.load(templateContent);
35
+ const merged = mergeSpecConfig(existing, template);
36
+
37
+ if (backupDir) {
38
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
39
+ const backupPath = `${existingPath}.ace-backup.${timestamp}`;
40
+ await fs.copy(existingPath, backupPath);
41
+ }
42
+
43
+ await fs.writeFile(existingPath, yaml.dump(merged, { lineWidth: -1 }), 'utf-8');
44
+ return { merged: true, version: merged.version };
45
+ }
@@ -14,3 +14,6 @@
14
14
 
15
15
  ## 质量控制
16
16
  - @~/.claude/rules/ace/memory-policy.md - Memory 质量策略
17
+
18
+ ## 交互规则
19
+ - @~/.claude/rules/ace/interactive-clarify.md - 交互式澄清规则