cc-devflow 4.4.1 → 4.5.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 (65) hide show
  1. package/.claude/skills/cc-act/CHANGELOG.md +6 -0
  2. package/.claude/skills/cc-act/SKILL.md +9 -1
  3. package/.claude/skills/cc-act/assets/PR_BRIEF_TEMPLATE.md +4 -0
  4. package/.claude/skills/cc-act/assets/RELEASE_NOTE_TEMPLATE.md +4 -0
  5. package/.claude/skills/cc-act/scripts/cc-act-common.sh +5 -0
  6. package/.claude/skills/cc-act/scripts/render-pr-brief.sh +5 -0
  7. package/.claude/skills/cc-act/scripts/sync-act-docs.sh +14 -1
  8. package/.claude/skills/cc-check/CHANGELOG.md +5 -0
  9. package/.claude/skills/cc-check/SKILL.md +9 -1
  10. package/.claude/skills/cc-check/assets/REPORT_CARD_TEMPLATE.json +3 -0
  11. package/.claude/skills/cc-do/CHANGELOG.md +5 -0
  12. package/.claude/skills/cc-do/SKILL.md +9 -1
  13. package/.claude/skills/cc-investigate/CHANGELOG.md +5 -0
  14. package/.claude/skills/cc-investigate/SKILL.md +9 -1
  15. package/.claude/skills/cc-investigate/assets/ANALYSIS_TEMPLATE.md +1 -0
  16. package/.claude/skills/cc-investigate/assets/TASKS_TEMPLATE.md +1 -0
  17. package/.claude/skills/cc-investigate/assets/TASK_MANIFEST_TEMPLATE.json +3 -0
  18. package/.claude/skills/cc-plan/CHANGELOG.md +5 -0
  19. package/.claude/skills/cc-plan/SKILL.md +9 -1
  20. package/.claude/skills/cc-plan/assets/DESIGN_TEMPLATE.md +1 -0
  21. package/.claude/skills/cc-plan/assets/TASKS_TEMPLATE.md +1 -0
  22. package/.claude/skills/cc-plan/assets/TASK_MANIFEST_TEMPLATE.json +3 -0
  23. package/.claude/skills/cc-plan/assets/TINY_DESIGN_TEMPLATE.md +1 -0
  24. package/.claude/skills/cc-roadmap/CHANGELOG.md +5 -0
  25. package/.claude/skills/cc-roadmap/SKILL.md +9 -1
  26. package/.claude/skills/cc-roadmap/assets/BACKLOG_TEMPLATE.md +1 -0
  27. package/.claude/skills/cc-roadmap/assets/ROADMAP_TEMPLATE.md +1 -0
  28. package/.claude/skills/cc-roadmap/assets/TRACKING_TEMPLATE.json +4 -1
  29. package/.claude/skills/cc-spec-init/CHANGELOG.md +5 -0
  30. package/.claude/skills/cc-spec-init/SKILL.md +9 -1
  31. package/.claude/skills/cc-spec-init/assets/CAPABILITY_TEMPLATE.md +1 -0
  32. package/.claude/skills/cc-spec-init/assets/CHANGE_META_TEMPLATE.json +3 -0
  33. package/.claude/skills/cc-spec-init/assets/INDEX_TEMPLATE.md +1 -0
  34. package/CHANGELOG.md +19 -0
  35. package/README.md +43 -0
  36. package/README.zh-CN.md +43 -0
  37. package/bin/cc-devflow-cli.js +226 -0
  38. package/config/schema/cc-devflow-config.schema.json +45 -0
  39. package/config/user-config.template.yml +16 -0
  40. package/docs/examples/example-bindings.json +8 -8
  41. package/docs/examples/full-design-blocked/BACKLOG.md +1 -1
  42. package/docs/examples/full-design-blocked/README.md +1 -1
  43. package/docs/examples/full-design-blocked/ROADMAP.md +1 -1
  44. package/docs/examples/full-design-blocked/changes/REQ-002-bulk-invite-import/planning/design.md +1 -1
  45. package/docs/examples/full-design-blocked/changes/REQ-002-bulk-invite-import/planning/tasks.md +1 -1
  46. package/docs/examples/full-design-blocked/roadmap-tracking.json +1 -1
  47. package/docs/examples/local-handoff/BACKLOG.md +1 -1
  48. package/docs/examples/local-handoff/README.md +1 -1
  49. package/docs/examples/local-handoff/ROADMAP.md +1 -1
  50. package/docs/examples/local-handoff/changes/REQ-003-audit-log-export/planning/design.md +1 -1
  51. package/docs/examples/local-handoff/changes/REQ-003-audit-log-export/planning/tasks.md +1 -1
  52. package/docs/examples/local-handoff/roadmap-tracking.json +1 -1
  53. package/docs/examples/pdca-loop/BACKLOG.md +1 -1
  54. package/docs/examples/pdca-loop/README.md +1 -1
  55. package/docs/examples/pdca-loop/ROADMAP.md +1 -1
  56. package/docs/examples/pdca-loop/changes/REQ-001-copy-invite-link/planning/design.md +1 -1
  57. package/docs/examples/pdca-loop/changes/REQ-001-copy-invite-link/planning/task-manifest.json +2 -2
  58. package/docs/examples/pdca-loop/changes/REQ-001-copy-invite-link/planning/tasks.md +1 -1
  59. package/docs/examples/pdca-loop/roadmap-tracking.json +1 -1
  60. package/lib/skill-runtime/__tests__/cli-bootstrap.integration.test.js +112 -2
  61. package/lib/skill-runtime/__tests__/config.test.js +161 -0
  62. package/lib/skill-runtime/__tests__/runtime.integration.test.js +2 -0
  63. package/lib/skill-runtime/config.js +379 -0
  64. package/lib/skill-runtime/index.js +2 -0
  65. package/package.json +1 -1
@@ -15,10 +15,14 @@ const REPO_ROOT = path.resolve(__dirname, '../../..');
15
15
  const CLI_BIN = path.join(REPO_ROOT, 'bin/cc-devflow-cli.js');
16
16
  const TEMPLATE_ROOT = path.join(REPO_ROOT, '.claude');
17
17
 
18
- function runCli(args, cwd) {
18
+ function runCli(args, cwd, env = {}) {
19
19
  const result = spawnSync(process.execPath, [CLI_BIN, ...args], {
20
20
  cwd,
21
- encoding: 'utf8'
21
+ encoding: 'utf8',
22
+ env: {
23
+ ...process.env,
24
+ ...env
25
+ }
22
26
  });
23
27
 
24
28
  if (result.error) {
@@ -76,6 +80,84 @@ describe('cc-devflow cli distribution bootstrap', () => {
76
80
  expect(fs.existsSync(path.join(repoRoot, '.claude', 'tsc-cache'))).toBe(false);
77
81
  });
78
82
 
83
+ test('init does not bake project YAML output policy into managed Claude skills', () => {
84
+ const customSkillDir = path.join(repoRoot, '.claude', 'skills', 'custom-skill');
85
+ fs.mkdirSync(customSkillDir, { recursive: true });
86
+ fs.writeFileSync(path.join(customSkillDir, 'SKILL.md'), '# Custom Skill\n');
87
+ fs.mkdirSync(path.join(repoRoot, '.cc-devflow'), { recursive: true });
88
+ fs.writeFileSync(
89
+ path.join(repoRoot, '.cc-devflow', 'config.yml'),
90
+ [
91
+ 'version: 1',
92
+ 'output:',
93
+ ' document_language: zh-CN',
94
+ 'agent_preferences:',
95
+ ' general:',
96
+ ' - 先给结论。',
97
+ ''
98
+ ].join('\n')
99
+ );
100
+
101
+ const result = runCli(['init', '--dir', repoRoot], repoRoot);
102
+ expect(result.status).toBe(0);
103
+
104
+ const skillContent = fs.readFileSync(
105
+ path.join(repoRoot, '.claude', 'skills', 'cc-plan', 'SKILL.md'),
106
+ 'utf8'
107
+ );
108
+
109
+ expect(skillContent).not.toContain('<!-- CC-DEVFLOW OUTPUT POLICY START -->');
110
+ expect(skillContent).not.toContain('文档语言: zh-CN');
111
+ expect(skillContent).toContain('cc-devflow config resolve --format policy');
112
+ expect(fs.readFileSync(path.join(customSkillDir, 'SKILL.md'), 'utf8')).toBe('# Custom Skill\n');
113
+ });
114
+
115
+ test('config commands manage and diagnose the effective output policy', () => {
116
+ fs.mkdirSync(path.join(repoRoot, '.cc-devflow'), { recursive: true });
117
+ fs.writeFileSync(
118
+ path.join(repoRoot, '.cc-devflow', 'config.yml'),
119
+ [
120
+ 'version: 1',
121
+ 'output:',
122
+ ' document_language: zh-CN',
123
+ ''
124
+ ].join('\n')
125
+ );
126
+
127
+ const resolveResult = runCli(
128
+ ['config', 'resolve', '--cwd', repoRoot, '--format', 'policy', '--trace'],
129
+ repoRoot
130
+ );
131
+ const getResult = runCli(['config', 'get', 'output.document_language', '--cwd', repoRoot], repoRoot);
132
+ const doctorResult = runCli(['config', 'doctor', '--cwd', repoRoot], repoRoot);
133
+
134
+ expect(resolveResult.status).toBe(0);
135
+ expect(resolveResult.stdout).toContain('CC-DevFlow Output Policy');
136
+ expect(resolveResult.stdout).toContain('Output language: zh-CN');
137
+ expect(resolveResult.stdout).toContain('Trace:');
138
+ expect(resolveResult.stdout).toContain('project');
139
+ expect(getResult.status).toBe(0);
140
+ expect(getResult.stdout.trim()).toBe('zh-CN');
141
+ expect(doctorResult.status).toBe(0);
142
+ expect(doctorResult.stdout).toContain('Config OK');
143
+
144
+ const setResult = runCli(
145
+ ['config', 'set', 'output.document_language', 'en', '--cwd', repoRoot, '--project'],
146
+ repoRoot
147
+ );
148
+ expect(setResult.status).toBe(0);
149
+ expect(runCli(['config', 'get', 'output.document_language', '--cwd', repoRoot], repoRoot).stdout.trim()).toBe('en');
150
+
151
+ const isolatedHome = path.join(repoRoot, 'home');
152
+ const userInitResult = runCli(
153
+ ['config', 'init', '--cwd', repoRoot, '--user', '--force'],
154
+ repoRoot,
155
+ { HOME: isolatedHome }
156
+ );
157
+ expect(userInitResult.status).toBe(0);
158
+ expect(userInitResult.stdout).toContain(path.join(isolatedHome, '.cc-devflow', 'config.yml'));
159
+ });
160
+
79
161
  test('init overwrites diverged .claude files with packaged content', () => {
80
162
  expect(runCli(['init', '--dir', repoRoot], repoRoot).status).toBe(0);
81
163
 
@@ -161,6 +243,34 @@ describe('cc-devflow cli distribution bootstrap', () => {
161
243
  );
162
244
  });
163
245
 
246
+ test('adapt mirrors Codex skills without baking project YAML output policy', () => {
247
+ fs.mkdirSync(path.join(repoRoot, '.cc-devflow'), { recursive: true });
248
+ fs.writeFileSync(
249
+ path.join(repoRoot, '.cc-devflow', 'config.yml'),
250
+ [
251
+ 'version: 1',
252
+ 'output:',
253
+ ' document_language: zh-CN',
254
+ 'agent_preferences:',
255
+ ' documentation:',
256
+ ' - 输出文档默认使用中文。',
257
+ ''
258
+ ].join('\n')
259
+ );
260
+ expect(runCli(['init', '--dir', repoRoot], repoRoot).status).toBe(0);
261
+
262
+ const result = runCli(['adapt', '--cwd', repoRoot, '--platform', 'codex'], repoRoot);
263
+ expect(result.status).toBe(0);
264
+
265
+ const codexPlanSkill = fs.readFileSync(
266
+ path.join(repoRoot, '.codex', 'skills', 'cc-plan', 'SKILL.md'),
267
+ 'utf8'
268
+ );
269
+ expect(codexPlanSkill).not.toContain('<!-- CC-DEVFLOW OUTPUT POLICY START -->');
270
+ expect(codexPlanSkill).not.toContain('文档语言: zh-CN');
271
+ expect(codexPlanSkill).toContain('cc-devflow config resolve --format policy');
272
+ });
273
+
164
274
  test('adapt preserves pre-existing non-public Codex skills and does not mirror new private ones', () => {
165
275
  expect(runCli(['init', '--dir', repoRoot], repoRoot).status).toBe(0);
166
276
 
@@ -0,0 +1,161 @@
1
+ /**
2
+ * [INPUT]: 临时 home/repo 配置、env 覆盖与 CLI 等价写入操作。
3
+ * [OUTPUT]: 验证运行时个人配置解析、校验、trace、policy 与写入行为。
4
+ * [POS]: skill runtime 个人配置的单元测试。
5
+ * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+
12
+ const {
13
+ LOCAL_CONFIG_RELATIVE_PATH,
14
+ PROJECT_CONFIG_RELATIVE_PATH,
15
+ USER_CONFIG_RELATIVE_PATH,
16
+ getConfigValue,
17
+ resolveUserConfig,
18
+ setConfigValue,
19
+ writeConfigTemplate
20
+ } = require('../config.js');
21
+
22
+ describe('skill-runtime user config', () => {
23
+ let tmpRoot;
24
+ let homeDir;
25
+ let repoRoot;
26
+
27
+ beforeEach(() => {
28
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-runtime-config-'));
29
+ homeDir = path.join(tmpRoot, 'home');
30
+ repoRoot = path.join(tmpRoot, 'repo');
31
+ fs.mkdirSync(path.join(homeDir, '.cc-devflow'), { recursive: true });
32
+ fs.mkdirSync(path.join(repoRoot, '.cc-devflow'), { recursive: true });
33
+ });
34
+
35
+ afterEach(() => {
36
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
37
+ });
38
+
39
+ test('resolves document language with default, file, env, and cli precedence trace', () => {
40
+ fs.writeFileSync(
41
+ path.join(homeDir, USER_CONFIG_RELATIVE_PATH),
42
+ [
43
+ 'version: 1',
44
+ 'output:',
45
+ ' document_language: en',
46
+ 'agent_preferences:',
47
+ ' planning:',
48
+ ' - Keep plans short.',
49
+ ''
50
+ ].join('\n')
51
+ );
52
+ fs.writeFileSync(
53
+ path.join(repoRoot, PROJECT_CONFIG_RELATIVE_PATH),
54
+ [
55
+ 'version: 1',
56
+ 'output:',
57
+ ' document_language: zh-CN',
58
+ ''
59
+ ].join('\n')
60
+ );
61
+ fs.writeFileSync(
62
+ path.join(repoRoot, LOCAL_CONFIG_RELATIVE_PATH),
63
+ [
64
+ 'version: 1',
65
+ 'agent_preferences:',
66
+ ' review:',
67
+ ' order: severe-first',
68
+ ''
69
+ ].join('\n')
70
+ );
71
+
72
+ const result = resolveUserConfig({
73
+ cwd: repoRoot,
74
+ homeDir,
75
+ env: {
76
+ CC_DEVFLOW_DOCUMENT_LANGUAGE: 'en'
77
+ },
78
+ overrides: {
79
+ output: {
80
+ document_language: 'zh-CN'
81
+ }
82
+ }
83
+ });
84
+
85
+ expect(result.config.output.document_language).toBe('zh-CN');
86
+ expect(result.config.agent_preferences.planning).toEqual(['Keep plans short.']);
87
+ expect(result.config.agent_preferences.review.order).toBe('severe-first');
88
+ expect(result.sources.map((source) => source.kind)).toEqual(['user', 'project', 'local', 'env', 'cli']);
89
+ expect(result.trace.map((entry) => `${entry.source}:${entry.key}:${entry.value}`)).toEqual([
90
+ 'default:version:1',
91
+ 'default:output.document_language:en',
92
+ 'user:version:1',
93
+ 'user:output.document_language:en',
94
+ 'user:agent_preferences.planning:["Keep plans short."]',
95
+ 'project:version:1',
96
+ 'project:output.document_language:zh-CN',
97
+ 'local:version:1',
98
+ 'local:agent_preferences.review.order:severe-first',
99
+ 'env:output.document_language:en',
100
+ 'cli:output.document_language:zh-CN'
101
+ ]);
102
+ expect(result.policy).toContain('Output language: zh-CN');
103
+ expect(result.policy).toContain('Machine-enforced fields: output.document_language');
104
+ expect(result.policy).toContain('Agent advisory preferences');
105
+ });
106
+
107
+ test('rejects unsupported language and unknown top-level fields', () => {
108
+ fs.writeFileSync(
109
+ path.join(repoRoot, PROJECT_CONFIG_RELATIVE_PATH),
110
+ [
111
+ 'version: 1',
112
+ 'output:',
113
+ ' document_language: klingon',
114
+ ''
115
+ ].join('\n')
116
+ );
117
+
118
+ expect(() => resolveUserConfig({ cwd: repoRoot, homeDir })).toThrow(/output.document_language/);
119
+
120
+ fs.writeFileSync(
121
+ path.join(repoRoot, PROJECT_CONFIG_RELATIVE_PATH),
122
+ [
123
+ 'version: 1',
124
+ 'preferences:',
125
+ ' general:',
126
+ ' - non standard root',
127
+ ''
128
+ ].join('\n')
129
+ );
130
+
131
+ expect(() => resolveUserConfig({ cwd: repoRoot, homeDir })).toThrow(/Unsupported config key/);
132
+ });
133
+
134
+ test('wraps invalid YAML errors with the config path', () => {
135
+ const configPath = path.join(repoRoot, PROJECT_CONFIG_RELATIVE_PATH);
136
+ fs.writeFileSync(configPath, 'version: 1\noutput:\n document_language: [\n');
137
+
138
+ expect(() => resolveUserConfig({ cwd: repoRoot, homeDir })).toThrow(configPath);
139
+ });
140
+
141
+ test('reads and writes config values without losing advisory preferences', () => {
142
+ const configPath = writeConfigTemplate({ scope: 'project', cwd: repoRoot, homeDir });
143
+ expect(configPath).toBe(path.join(repoRoot, PROJECT_CONFIG_RELATIVE_PATH));
144
+
145
+ setConfigValue('output.document_language', 'en', { scope: 'project', cwd: repoRoot, homeDir });
146
+ setConfigValue('agent_preferences.documentation.voice', 'direct', {
147
+ scope: 'project',
148
+ cwd: repoRoot,
149
+ homeDir
150
+ });
151
+
152
+ const resolved = resolveUserConfig({ cwd: repoRoot, homeDir });
153
+
154
+ expect(getConfigValue(resolved.config, 'output.document_language')).toBe('en');
155
+ expect(getConfigValue(resolved.config, 'agent_preferences.documentation.voice')).toBe('direct');
156
+ });
157
+
158
+ test('rejects unknown config scopes instead of silently writing project config', () => {
159
+ expect(() => writeConfigTemplate({ scope: 'team', cwd: repoRoot, homeDir })).toThrow(/Unknown config scope/);
160
+ });
161
+ });
@@ -46,6 +46,8 @@ const MARK_TASK_COMPLETE = path.join(
46
46
  '.claude/skills/cc-do/scripts/mark-task-complete.sh'
47
47
  );
48
48
 
49
+ jest.setTimeout(20000);
50
+
49
51
  describe('Skill runtime', () => {
50
52
  let repoRoot;
51
53
 
@@ -0,0 +1,379 @@
1
+ /**
2
+ * [INPUT]: 用户级、项目级、本地 YAML 配置,以及 env / CLI 覆盖。
3
+ * [OUTPUT]: 运行时输出策略、key 级来源 trace、配置读写与 doctor 结果。
4
+ * [POS]: skill runtime 的个人配置真相源,避免把个人偏好烘进 Skill 文件。
5
+ * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const yaml = require('js-yaml');
12
+
13
+ const USER_CONFIG_RELATIVE_PATH = path.join('.cc-devflow', 'config.yml');
14
+ const PROJECT_CONFIG_RELATIVE_PATH = path.join('.cc-devflow', 'config.yml');
15
+ const LOCAL_CONFIG_RELATIVE_PATH = path.join('.cc-devflow', 'config.local.yml');
16
+ const CONFIG_TEMPLATE_PATH = path.resolve(__dirname, '..', '..', 'config', 'user-config.template.yml');
17
+ const SUPPORTED_DOCUMENT_LANGUAGES = new Set(['en', 'zh-CN']);
18
+
19
+ const DEFAULT_CONFIG = {
20
+ version: 1,
21
+ output: {
22
+ document_language: 'en'
23
+ },
24
+ agent_preferences: {}
25
+ };
26
+
27
+ function isPlainObject(value) {
28
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
29
+ }
30
+
31
+ function traceValue(value) {
32
+ if (Array.isArray(value) || isPlainObject(value)) {
33
+ return JSON.stringify(value);
34
+ }
35
+
36
+ return String(value);
37
+ }
38
+
39
+ function readYamlConfig(filePath) {
40
+ if (!fs.existsSync(filePath)) {
41
+ return null;
42
+ }
43
+
44
+ let parsed;
45
+ try {
46
+ const content = fs.readFileSync(filePath, 'utf8');
47
+ parsed = yaml.load(content) || {};
48
+ } catch (error) {
49
+ throw new Error(`Failed to parse cc-devflow config ${filePath}: ${error.message}`);
50
+ }
51
+
52
+ if (!isPlainObject(parsed)) {
53
+ throw new Error(`cc-devflow config must be a YAML object: ${filePath}`);
54
+ }
55
+
56
+ validateConfigShape(parsed, filePath);
57
+ return parsed;
58
+ }
59
+
60
+ function validateConfigShape(config, source = 'config') {
61
+ const allowedRootKeys = new Set(['version', 'output', 'agent_preferences']);
62
+
63
+ for (const key of Object.keys(config || {})) {
64
+ if (!allowedRootKeys.has(key)) {
65
+ throw new Error(`Unsupported config key "${key}" in ${source}. Put custom fields under agent_preferences.`);
66
+ }
67
+ }
68
+
69
+ if ('version' in config && config.version !== 1) {
70
+ throw new Error(`Unsupported config version "${config.version}" in ${source}. Expected version: 1.`);
71
+ }
72
+
73
+ if ('output' in config) {
74
+ if (!isPlainObject(config.output)) {
75
+ throw new Error(`output must be an object in ${source}`);
76
+ }
77
+
78
+ for (const key of Object.keys(config.output)) {
79
+ if (key !== 'document_language') {
80
+ throw new Error(`Unsupported output key "output.${key}" in ${source}`);
81
+ }
82
+ }
83
+
84
+ if (
85
+ 'document_language' in config.output
86
+ && !SUPPORTED_DOCUMENT_LANGUAGES.has(config.output.document_language)
87
+ ) {
88
+ throw new Error(
89
+ `Unsupported output.document_language "${config.output.document_language}" in ${source}. Use en or zh-CN.`
90
+ );
91
+ }
92
+ }
93
+
94
+ if ('agent_preferences' in config && !isPlainObject(config.agent_preferences)) {
95
+ throw new Error(`agent_preferences must be an object in ${source}`);
96
+ }
97
+ }
98
+
99
+ function pushTrace(trace, source, sourcePath, key, value) {
100
+ trace.push({
101
+ key,
102
+ value: traceValue(value),
103
+ source,
104
+ path: sourcePath || null
105
+ });
106
+ }
107
+
108
+ function applyConfigLayer(base, layer, trace, source, sourcePath, prefix = '') {
109
+ const next = Array.isArray(base) ? [...base] : { ...(base || {}) };
110
+
111
+ for (const [key, value] of Object.entries(layer || {})) {
112
+ const traceKey = prefix ? `${prefix}.${key}` : key;
113
+
114
+ if (isPlainObject(value)) {
115
+ next[key] = applyConfigLayer(
116
+ isPlainObject(next[key]) ? next[key] : {},
117
+ value,
118
+ trace,
119
+ source,
120
+ sourcePath,
121
+ traceKey
122
+ );
123
+ continue;
124
+ }
125
+
126
+ next[key] = Array.isArray(value) ? [...value] : value;
127
+ pushTrace(trace, source, sourcePath, traceKey, next[key]);
128
+ }
129
+
130
+ return next;
131
+ }
132
+
133
+ function collectConfigSources(options = {}) {
134
+ const cwd = path.resolve(options.cwd || process.cwd());
135
+ const homeDir = path.resolve(options.homeDir || os.homedir());
136
+
137
+ return [
138
+ {
139
+ kind: 'user',
140
+ path: path.join(homeDir, USER_CONFIG_RELATIVE_PATH)
141
+ },
142
+ {
143
+ kind: 'project',
144
+ path: path.join(cwd, PROJECT_CONFIG_RELATIVE_PATH)
145
+ },
146
+ {
147
+ kind: 'local',
148
+ path: path.join(cwd, LOCAL_CONFIG_RELATIVE_PATH)
149
+ }
150
+ ];
151
+ }
152
+
153
+ function envToConfig(env = process.env) {
154
+ const documentLanguage = env.CC_DEVFLOW_DOCUMENT_LANGUAGE
155
+ || env.CC_DEVFLOW_OUTPUT_DOCUMENT_LANGUAGE;
156
+
157
+ if (!documentLanguage) {
158
+ return null;
159
+ }
160
+
161
+ return {
162
+ output: {
163
+ document_language: documentLanguage
164
+ }
165
+ };
166
+ }
167
+
168
+ function resolveUserConfig(options = {}) {
169
+ const trace = [];
170
+ const sources = [];
171
+ let config = applyConfigLayer({}, DEFAULT_CONFIG, trace, 'default', null);
172
+
173
+ for (const source of collectConfigSources(options)) {
174
+ const value = readYamlConfig(source.path);
175
+ if (!value) {
176
+ continue;
177
+ }
178
+
179
+ config = applyConfigLayer(config, value, trace, source.kind, source.path);
180
+ sources.push(source);
181
+ }
182
+
183
+ const envConfig = envToConfig(options.env || process.env);
184
+ if (envConfig) {
185
+ validateConfigShape(envConfig, 'environment');
186
+ config = applyConfigLayer(config, envConfig, trace, 'env', null);
187
+ sources.push({ kind: 'env', path: null });
188
+ }
189
+
190
+ if (options.overrides && Object.keys(options.overrides).length > 0) {
191
+ validateConfigShape(options.overrides, 'CLI overrides');
192
+ config = applyConfigLayer(config, options.overrides, trace, 'cli', null);
193
+ sources.push({ kind: 'cli', path: null });
194
+ }
195
+
196
+ validateConfigShape(config, 'resolved config');
197
+
198
+ return {
199
+ enabled: sources.length > 0,
200
+ config,
201
+ sources,
202
+ trace,
203
+ policy: renderOutputPolicy(config)
204
+ };
205
+ }
206
+
207
+ function formatPreferenceLines(value, prefix = '') {
208
+ if (Array.isArray(value)) {
209
+ return value.map((item) => `- ${prefix}: ${traceValue(item)}`);
210
+ }
211
+
212
+ if (!isPlainObject(value)) {
213
+ return [`- ${prefix}: ${traceValue(value)}`];
214
+ }
215
+
216
+ return Object.entries(value).flatMap(([key, child]) => {
217
+ const nextPrefix = prefix ? `${prefix}.${key}` : key;
218
+ return formatPreferenceLines(child, nextPrefix);
219
+ });
220
+ }
221
+
222
+ function renderOutputPolicy(config = DEFAULT_CONFIG) {
223
+ const documentLanguage = config.output?.document_language || DEFAULT_CONFIG.output.document_language;
224
+ const lines = [
225
+ '## CC-DevFlow Output Policy',
226
+ '',
227
+ 'Machine-enforced fields: output.document_language',
228
+ `- Output language: ${documentLanguage}`,
229
+ `- Durable Markdown artifacts must include \`Output language: ${documentLanguage}\` in their metadata or first screen.`,
230
+ '',
231
+ 'Agent advisory preferences:'
232
+ ];
233
+
234
+ const preferences = config.agent_preferences || {};
235
+ const preferenceLines = formatPreferenceLines(preferences);
236
+
237
+ if (preferenceLines.length === 0) {
238
+ lines.push('- none');
239
+ } else {
240
+ lines.push(...preferenceLines);
241
+ }
242
+
243
+ return `${lines.join('\n')}\n`;
244
+ }
245
+
246
+ function getConfigPath(scope, options = {}) {
247
+ const cwd = path.resolve(options.cwd || process.cwd());
248
+ const homeDir = path.resolve(options.homeDir || os.homedir());
249
+
250
+ if (scope === 'user') {
251
+ return path.join(homeDir, USER_CONFIG_RELATIVE_PATH);
252
+ }
253
+
254
+ if (scope === 'local') {
255
+ return path.join(cwd, LOCAL_CONFIG_RELATIVE_PATH);
256
+ }
257
+
258
+ if (scope !== 'project') {
259
+ throw new Error(`Unknown config scope "${scope}". Use user, project, or local.`);
260
+ }
261
+
262
+ return path.join(cwd, PROJECT_CONFIG_RELATIVE_PATH);
263
+ }
264
+
265
+ function getConfigValue(config, keyPath) {
266
+ return String(keyPath || '')
267
+ .split('.')
268
+ .filter(Boolean)
269
+ .reduce((value, key) => (value == null ? undefined : value[key]), config);
270
+ }
271
+
272
+ function setNestedValue(target, keyPath, value) {
273
+ const parts = String(keyPath || '').split('.').filter(Boolean);
274
+ if (parts.length === 0) {
275
+ throw new Error('Config key is required.');
276
+ }
277
+
278
+ let cursor = target;
279
+ for (const key of parts.slice(0, -1)) {
280
+ if (!isPlainObject(cursor[key])) {
281
+ cursor[key] = {};
282
+ }
283
+ cursor = cursor[key];
284
+ }
285
+
286
+ cursor[parts[parts.length - 1]] = value;
287
+ }
288
+
289
+ function coerceConfigValue(keyPath, value) {
290
+ if (keyPath === 'output.document_language') {
291
+ if (!SUPPORTED_DOCUMENT_LANGUAGES.has(value)) {
292
+ throw new Error(`Unsupported output.document_language "${value}". Use en or zh-CN.`);
293
+ }
294
+ }
295
+
296
+ return value;
297
+ }
298
+
299
+ function writeYamlConfig(filePath, config) {
300
+ validateConfigShape(config, filePath);
301
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
302
+ fs.writeFileSync(filePath, yaml.dump(config, { lineWidth: 100, noRefs: true }), 'utf8');
303
+ }
304
+
305
+ function setConfigValue(keyPath, value, options = {}) {
306
+ if (keyPath !== 'output.document_language' && !keyPath.startsWith('agent_preferences.')) {
307
+ throw new Error('Only output.document_language and agent_preferences.* can be set.');
308
+ }
309
+
310
+ const filePath = getConfigPath(options.scope || 'project', options);
311
+ const config = readYamlConfig(filePath) || { version: 1 };
312
+ setNestedValue(config, keyPath, coerceConfigValue(keyPath, value));
313
+ writeYamlConfig(filePath, config);
314
+ return filePath;
315
+ }
316
+
317
+ function writeConfigTemplate(options = {}) {
318
+ const scope = options.scope || 'project';
319
+ const filePath = getConfigPath(scope, options);
320
+
321
+ if (fs.existsSync(filePath) && !options.force) {
322
+ return filePath;
323
+ }
324
+
325
+ const template = readYamlConfig(CONFIG_TEMPLATE_PATH);
326
+
327
+ writeYamlConfig(filePath, template);
328
+ return filePath;
329
+ }
330
+
331
+ function isLocalConfigIgnored(repoRoot) {
332
+ const gitignorePath = path.join(repoRoot, '.gitignore');
333
+ if (!fs.existsSync(gitignorePath)) {
334
+ return false;
335
+ }
336
+
337
+ const lines = fs.readFileSync(gitignorePath, 'utf8')
338
+ .split(/\r?\n/)
339
+ .map((line) => line.trim())
340
+ .filter((line) => line && !line.startsWith('#'));
341
+
342
+ return lines.includes('.cc-devflow/config.local.yml')
343
+ || lines.includes('/.cc-devflow/config.local.yml')
344
+ || lines.includes('.cc-devflow/');
345
+ }
346
+
347
+ function doctorUserConfig(options = {}) {
348
+ const cwd = path.resolve(options.cwd || process.cwd());
349
+ const resolved = resolveUserConfig(options);
350
+ const warnings = [];
351
+ const localPath = path.join(cwd, LOCAL_CONFIG_RELATIVE_PATH);
352
+
353
+ if (fs.existsSync(localPath) && !isLocalConfigIgnored(cwd)) {
354
+ warnings.push('.cc-devflow/config.local.yml exists but is not ignored by .gitignore.');
355
+ }
356
+
357
+ return {
358
+ ok: warnings.length === 0,
359
+ warnings,
360
+ resolved
361
+ };
362
+ }
363
+
364
+ module.exports = {
365
+ DEFAULT_CONFIG,
366
+ CONFIG_TEMPLATE_PATH,
367
+ LOCAL_CONFIG_RELATIVE_PATH,
368
+ PROJECT_CONFIG_RELATIVE_PATH,
369
+ SUPPORTED_DOCUMENT_LANGUAGES,
370
+ USER_CONFIG_RELATIVE_PATH,
371
+ doctorUserConfig,
372
+ getConfigPath,
373
+ getConfigValue,
374
+ readYamlConfig,
375
+ renderOutputPolicy,
376
+ resolveUserConfig,
377
+ setConfigValue,
378
+ writeConfigTemplate
379
+ };
@@ -15,6 +15,7 @@ const lifecycle = require('./lifecycle');
15
15
  const teamState = require('./team-state');
16
16
  const delegation = require('./delegation');
17
17
  const paths = require('./paths');
18
+ const config = require('./config');
18
19
  const worker = require('./operations/worker');
19
20
  const workerRun = require('./operations/worker-run');
20
21
 
@@ -29,6 +30,7 @@ module.exports = {
29
30
  ...teamState,
30
31
  ...delegation,
31
32
  ...paths,
33
+ ...config,
32
34
  ...worker,
33
35
  ...workerRun
34
36
  };