@ktpartners/dgs-platform 2.6.3 → 2.7.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.
@@ -1,11 +1,15 @@
1
1
  /**
2
- * Config Planning config CRUD operations
2
+ * Config -- Planning config CRUD operations
3
3
  *
4
- * Uses getConfigPath(cwd, mode) for all config file path resolution:
5
- * - 'read' mode: prefers dgs.config.json, falls back to config.json (dual-read)
6
- * - 'write' mode: always targets dgs.config.json (write-forward migration)
4
+ * Two-file config layout:
5
+ * - config.json (tracked): shared team settings (mode, workflows, git, model_profile, etc.)
6
+ * - config.local.json (gitignored): per-machine state (current_project, planningRoot, hint flags)
7
7
  *
8
- * Zero hardcoded .planning/config.json paths all resolved via getPlanningRoot.
8
+ * LOCAL_KEYS defines which keys route to config.local.json.
9
+ * writeConfigField routes writes to the correct file based on isLocalKey().
10
+ * loadConfig (in core.cjs) merges both files with local overriding shared.
11
+ *
12
+ * Zero hardcoded .planning/config.json paths -- all resolved via getPlanningRoot.
9
13
  */
10
14
 
11
15
  const fs = require('fs');
@@ -13,6 +17,17 @@ const path = require('path');
13
17
  const { output, error } = require('./core.cjs');
14
18
  const { getPlanningRoot } = require('./paths.cjs');
15
19
 
20
+ /**
21
+ * Keys that belong in config.local.json (per-machine state).
22
+ * These are stored as top-level keys in config.local.json.
23
+ */
24
+ const LOCAL_KEYS = new Set([
25
+ 'current_project',
26
+ 'planningRoot',
27
+ 'v2_hint_shown',
28
+ 'sync_hint_shown',
29
+ ]);
30
+
16
31
  const VALID_CONFIG_KEYS = new Set([
17
32
  'mode', 'granularity', 'parallelization', 'commit_docs', 'model_profile',
18
33
  'search_gitignored', 'brave_search',
@@ -20,31 +35,144 @@ const VALID_CONFIG_KEYS = new Set([
20
35
  'workflow.nyquist_validation', 'workflow.ui_phase', 'workflow.ui_safety_gate',
21
36
  'workflow._auto_chain_active', 'workflow.discipline',
22
37
  'git.base_branch', 'git.branching_strategy', 'git.phase_branch_template', 'git.milestone_branch_template',
38
+ 'git.sync', 'git.sync_push', 'git.sync_pull',
23
39
  'planning.commit_docs', 'planning.search_gitignored',
24
40
  ]);
25
41
 
26
42
  /**
43
+ * Get the path to the shared (tracked) config file.
44
+ *
45
+ * @param {string} cwd - Working directory
46
+ * @returns {string} Absolute path to config.json
47
+ */
48
+ function getSharedConfigPath(cwd) {
49
+ return path.join(getPlanningRoot(cwd), 'config.json');
50
+ }
51
+
52
+ /**
53
+ * Get the path to the local (gitignored) config file.
54
+ *
55
+ * @param {string} cwd - Working directory
56
+ * @returns {string} Absolute path to config.local.json
57
+ */
58
+ function getLocalConfigPath(cwd) {
59
+ return path.join(getPlanningRoot(cwd), 'config.local.json');
60
+ }
61
+
62
+ /**
63
+ * @deprecated Use getSharedConfigPath or getLocalConfigPath instead.
27
64
  * Resolve the config file path for the given cwd and mode.
65
+ * Now returns config.json (shared) for both read and write modes.
66
+ * Falls back to legacy dgs.config.json for read mode.
28
67
  *
29
68
  * @param {string} cwd - Working directory
30
- * @param {'read'|'write'} mode - 'read' for dual-check (dgs.config.json first, config.json fallback),
31
- * 'write' for always dgs.config.json
69
+ * @param {'read'|'write'} mode - 'read' or 'write'
32
70
  * @returns {string} Absolute path to the config file
33
71
  */
34
72
  function getConfigPath(cwd, mode) {
35
73
  const root = getPlanningRoot(cwd);
36
- const newPath = path.join(root, 'dgs.config.json');
37
- if (mode === 'write') return newPath;
38
- // Read mode: prefer dgs.config.json, fall back to config.json
39
- if (fs.existsSync(newPath)) return newPath;
40
- const legacyPath = path.join(root, 'config.json');
74
+ const sharedPath = path.join(root, 'config.json');
75
+ if (mode === 'write') return sharedPath;
76
+ // Read mode: prefer config.json, fall back to legacy dgs.config.json
77
+ if (fs.existsSync(sharedPath)) return sharedPath;
78
+ const legacyPath = path.join(root, 'dgs.config.json');
41
79
  if (fs.existsSync(legacyPath)) return legacyPath;
42
- return newPath; // default when neither exists
80
+ return sharedPath; // default when neither exists
81
+ }
82
+
83
+ /**
84
+ * Check if a key path should be stored in config.local.json.
85
+ *
86
+ * @param {string} keyPath - Dot-notation key (e.g., 'current_project', 'workflow.research')
87
+ * @returns {boolean} True if the key belongs in config.local.json
88
+ */
89
+ function isLocalKey(keyPath) {
90
+ // Check the key itself or its top-level segment
91
+ if (LOCAL_KEYS.has(keyPath)) return true;
92
+ const topLevel = keyPath.split('.')[0];
93
+ return LOCAL_KEYS.has(topLevel);
94
+ }
95
+
96
+ /**
97
+ * Read a JSON file safely.
98
+ * @param {string} filePath
99
+ * @returns {object}
100
+ */
101
+ function _readJsonSafe(filePath) {
102
+ try {
103
+ if (fs.existsSync(filePath)) {
104
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
105
+ }
106
+ } catch { /* ignore */ }
107
+ return {};
108
+ }
109
+
110
+ /**
111
+ * Write a JSON object to a file, creating parent directories as needed.
112
+ * @param {string} filePath
113
+ * @param {object} data
114
+ */
115
+ function _writeJson(filePath, data) {
116
+ const dir = path.dirname(filePath);
117
+ if (!fs.existsSync(dir)) {
118
+ fs.mkdirSync(dir, { recursive: true });
119
+ }
120
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
121
+ }
122
+
123
+ /**
124
+ * Perform a one-time migration of legacy dgs.config.json to the two-file layout.
125
+ * Separates local keys from shared keys and writes to the appropriate files.
126
+ *
127
+ * @param {string} cwd - Working directory
128
+ * @returns {boolean} True if migration was performed
129
+ */
130
+ function _migrateLegacyConfig(cwd) {
131
+ const root = getPlanningRoot(cwd);
132
+ const legacyPath = path.join(root, 'dgs.config.json');
133
+ const sharedPath = path.join(root, 'config.json');
134
+ const localPath = path.join(root, 'config.local.json');
135
+
136
+ if (!fs.existsSync(legacyPath)) return false;
137
+ if (fs.existsSync(sharedPath) || fs.existsSync(localPath)) return false;
138
+
139
+ try {
140
+ const legacy = JSON.parse(fs.readFileSync(legacyPath, 'utf-8'));
141
+ const shared = {};
142
+ const local = {};
143
+
144
+ for (const [key, value] of Object.entries(legacy)) {
145
+ if (LOCAL_KEYS.has(key)) {
146
+ local[key] = value;
147
+ } else if (key === 'git' && typeof value === 'object') {
148
+ // Split git section: sync_hint_shown is local, rest is shared
149
+ const gitShared = {};
150
+ for (const [gk, gv] of Object.entries(value)) {
151
+ if (gk === 'sync_hint_shown') {
152
+ local.sync_hint_shown = gv;
153
+ } else {
154
+ gitShared[gk] = gv;
155
+ }
156
+ }
157
+ if (Object.keys(gitShared).length > 0) {
158
+ shared.git = gitShared;
159
+ }
160
+ } else {
161
+ shared[key] = value;
162
+ }
163
+ }
164
+
165
+ if (Object.keys(shared).length > 0) _writeJson(sharedPath, shared);
166
+ if (Object.keys(local).length > 0) _writeJson(localPath, local);
167
+ return true;
168
+ } catch {
169
+ return false;
170
+ }
43
171
  }
44
172
 
45
173
  function cmdConfigEnsureSection(cwd, raw) {
46
- const writePath = getConfigPath(cwd, 'write');
47
- const parentDir = path.dirname(writePath);
174
+ const sharedPath = getSharedConfigPath(cwd);
175
+ const parentDir = path.dirname(sharedPath);
48
176
 
49
177
  // Ensure parent directory exists
50
178
  try {
@@ -55,9 +183,18 @@ function cmdConfigEnsureSection(cwd, raw) {
55
183
  error('Failed to create config directory: ' + err.message);
56
184
  }
57
185
 
58
- // Check if config already exists (either dgs.config.json or legacy config.json)
59
- const readPath = getConfigPath(cwd, 'read');
60
- if (fs.existsSync(readPath)) {
186
+ // Check if config already exists (shared config.json or legacy dgs.config.json)
187
+ if (fs.existsSync(sharedPath)) {
188
+ const result = { created: false, reason: 'already_exists' };
189
+ output(result, raw, 'exists');
190
+ return;
191
+ }
192
+
193
+ // Check for legacy dgs.config.json and migrate if found
194
+ const root = getPlanningRoot(cwd);
195
+ const legacyPath = path.join(root, 'dgs.config.json');
196
+ if (fs.existsSync(legacyPath)) {
197
+ _migrateLegacyConfig(cwd);
61
198
  const result = { created: false, reason: 'already_exists' };
62
199
  output(result, raw, 'exists');
63
200
  return;
@@ -87,6 +224,7 @@ function cmdConfigEnsureSection(cwd, raw) {
87
224
  }
88
225
 
89
226
  // Create default config (user-level defaults override hardcoded defaults)
227
+ // Only shared settings go to config.json -- no local keys
90
228
  const hardcoded = {
91
229
  model_profile: 'balanced',
92
230
  commit_docs: true,
@@ -102,6 +240,10 @@ function cmdConfigEnsureSection(cwd, raw) {
102
240
  nyquist_validation: true,
103
241
  discipline: true,
104
242
  },
243
+ git: {
244
+ sync_push: 'prompt',
245
+ sync_pull: 'prompt',
246
+ },
105
247
  parallelization: true,
106
248
  brave_search: hasBraveSearch,
107
249
  };
@@ -109,14 +251,20 @@ function cmdConfigEnsureSection(cwd, raw) {
109
251
  ...hardcoded,
110
252
  ...userDefaults,
111
253
  workflow: { ...hardcoded.workflow, ...(userDefaults.workflow || {}) },
254
+ git: { ...hardcoded.git, ...(userDefaults.git || {}) },
112
255
  };
113
256
 
257
+ // Remove any local keys that might have leaked in from userDefaults
258
+ for (const lk of LOCAL_KEYS) {
259
+ delete defaults[lk];
260
+ }
261
+
114
262
  try {
115
- fs.writeFileSync(writePath, JSON.stringify(defaults, null, 2), 'utf-8');
116
- const result = { created: true, path: path.relative(cwd, writePath) || writePath };
263
+ fs.writeFileSync(sharedPath, JSON.stringify(defaults, null, 2), 'utf-8');
264
+ const result = { created: true, path: path.relative(cwd, sharedPath) || sharedPath };
117
265
  output(result, raw, 'created');
118
266
  } catch (err) {
119
- error('Failed to create dgs.config.json: ' + err.message);
267
+ error('Failed to create config.json: ' + err.message);
120
268
  }
121
269
  }
122
270
 
@@ -129,21 +277,38 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
129
277
  error(`Unknown config key: "${keyPath}". Valid keys: ${[...VALID_CONFIG_KEYS].sort().join(', ')}`);
130
278
  }
131
279
 
280
+ // Validate sync mode values
281
+ const VALID_SYNC_MODES = new Set(['off', 'prompt', 'auto']);
282
+ if ((keyPath === 'git.sync_push' || keyPath === 'git.sync_pull' || keyPath === 'git.sync') && !VALID_SYNC_MODES.has(value)) {
283
+ error(`Invalid sync mode: "${value}". Valid values: off, prompt, auto`);
284
+ }
285
+
132
286
  // Parse value (handle booleans and numbers)
133
287
  let parsedValue = value;
134
288
  if (value === 'true') parsedValue = true;
135
289
  else if (value === 'false') parsedValue = false;
136
290
  else if (!isNaN(value) && value !== '') parsedValue = Number(value);
137
291
 
138
- // Load existing config from read path (may be legacy config.json)
139
- const readPath = getConfigPath(cwd, 'read');
140
- let config = {};
141
- try {
142
- if (fs.existsSync(readPath)) {
143
- config = JSON.parse(fs.readFileSync(readPath, 'utf-8'));
292
+ // Load existing shared config
293
+ const sharedPath = getSharedConfigPath(cwd);
294
+ let config = _readJsonSafe(sharedPath);
295
+
296
+ // git.sync is a virtual shorthand -- sets both sync_push and sync_pull in shared config
297
+ if (keyPath === 'git.sync') {
298
+ if (!config.git || typeof config.git !== 'object') {
299
+ config.git = {};
144
300
  }
145
- } catch (err) {
146
- error('Failed to read config: ' + err.message);
301
+ config.git.sync_push = parsedValue;
302
+ config.git.sync_pull = parsedValue;
303
+
304
+ try {
305
+ _writeJson(sharedPath, config);
306
+ const result = { updated: true, key: keyPath, value: parsedValue, expanded: ['git.sync_push', 'git.sync_pull'] };
307
+ output(result, raw, `git.sync_push=${parsedValue}, git.sync_pull=${parsedValue}`);
308
+ } catch (err) {
309
+ error('Failed to write config.json: ' + err.message);
310
+ }
311
+ return; // output() calls process.exit, but guard with return
147
312
  }
148
313
 
149
314
  // Set nested value using dot notation (e.g., "workflow.research")
@@ -158,14 +323,13 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
158
323
  }
159
324
  current[keys[keys.length - 1]] = parsedValue;
160
325
 
161
- // Write to dgs.config.json (write-forward migration)
162
- const writePath = getConfigPath(cwd, 'write');
326
+ // Write to shared config.json (all VALID_CONFIG_KEYS are shared)
163
327
  try {
164
- fs.writeFileSync(writePath, JSON.stringify(config, null, 2), 'utf-8');
328
+ _writeJson(sharedPath, config);
165
329
  const result = { updated: true, key: keyPath, value: parsedValue };
166
330
  output(result, raw, `${keyPath}=${parsedValue}`);
167
331
  } catch (err) {
168
- error('Failed to write dgs.config.json: ' + err.message);
332
+ error('Failed to write config.json: ' + err.message);
169
333
  }
170
334
  }
171
335
 
@@ -174,17 +338,21 @@ function cmdConfigGet(cwd, keyPath, raw) {
174
338
  error('Usage: config-get <key.path>');
175
339
  }
176
340
 
177
- const readPath = getConfigPath(cwd, 'read');
178
- let config = {};
179
- try {
180
- if (fs.existsSync(readPath)) {
181
- config = JSON.parse(fs.readFileSync(readPath, 'utf-8'));
182
- } else {
183
- error('No config file found at ' + readPath);
341
+ // Read both files and merge (local overrides shared)
342
+ const shared = _readJsonSafe(getSharedConfigPath(cwd));
343
+ const local = _readJsonSafe(getLocalConfigPath(cwd));
344
+
345
+ // Fall back to legacy dgs.config.json if neither new file has data
346
+ let config;
347
+ if (Object.keys(shared).length === 0 && Object.keys(local).length === 0) {
348
+ const root = getPlanningRoot(cwd);
349
+ const legacyPath = path.join(root, 'dgs.config.json');
350
+ config = _readJsonSafe(legacyPath);
351
+ if (Object.keys(config).length === 0) {
352
+ error('No config file found at ' + getSharedConfigPath(cwd));
184
353
  }
185
- } catch (err) {
186
- if (err.message.startsWith('No config file')) throw err;
187
- error('Failed to read config: ' + err.message);
354
+ } else {
355
+ config = { ...shared, ...local };
188
356
  }
189
357
 
190
358
  // Traverse dot-notation path (e.g., "workflow.auto_advance")
@@ -206,28 +374,46 @@ function cmdConfigGet(cwd, keyPath, raw) {
206
374
 
207
375
  /**
208
376
  * Programmatically set a config field. Uses same dot-notation as cmdConfigSet.
209
- * Does NOT call output()/process.exit() returns the updated config.
377
+ * Does NOT call output()/process.exit() -- returns the updated merged config.
210
378
  *
211
- * Reads from legacy config.json if it exists, writes to dgs.config.json (auto-migration).
379
+ * Routes writes to the correct file based on isLocalKey():
380
+ * - Local keys (current_project, planningRoot, v2_hint_shown, sync_hint_shown) -> config.local.json
381
+ * - Shared keys (everything else) -> config.json
212
382
  *
213
383
  * @param {string} cwd - Working directory
214
384
  * @param {string} keyPath - Dot-notation key (e.g., "product_name" or "workflow.research")
215
385
  * @param {*} value - Value to set
216
- * @returns {object} Updated config object
386
+ * @returns {object} Updated merged config object
217
387
  */
218
388
  function writeConfigField(cwd, keyPath, value) {
219
- const readPath = getConfigPath(cwd, 'read');
220
- let config = {};
221
- try {
222
- if (fs.existsSync(readPath)) {
223
- config = JSON.parse(fs.readFileSync(readPath, 'utf-8'));
389
+ // Attempt migration if needed
390
+ const sharedPath = getSharedConfigPath(cwd);
391
+ const localPath = getLocalConfigPath(cwd);
392
+ if (!fs.existsSync(sharedPath) && !fs.existsSync(localPath)) {
393
+ _migrateLegacyConfig(cwd);
394
+ }
395
+
396
+ // git.sync is a virtual shorthand -- sets both sync_push and sync_pull in shared config
397
+ if (keyPath === 'git.sync') {
398
+ let config = _readJsonSafe(sharedPath);
399
+ if (!config.git || typeof config.git !== 'object') {
400
+ config.git = {};
224
401
  }
225
- } catch {
226
- // Start with empty config if parse fails
402
+ config.git.sync_push = value;
403
+ config.git.sync_pull = value;
404
+ _writeJson(sharedPath, config);
405
+ // Return merged config
406
+ const local = _readJsonSafe(localPath);
407
+ return { ...config, ...local };
227
408
  }
228
409
 
410
+ // Determine target file
411
+ const targetPath = isLocalKey(keyPath) ? localPath : sharedPath;
412
+ let targetConfig = _readJsonSafe(targetPath);
413
+
414
+ // Set the value using dot-notation
229
415
  const keys = keyPath.split('.');
230
- let current = config;
416
+ let current = targetConfig;
231
417
  for (let i = 0; i < keys.length - 1; i++) {
232
418
  const key = keys[i];
233
419
  if (current[key] === undefined || typeof current[key] !== 'object') {
@@ -237,9 +423,12 @@ function writeConfigField(cwd, keyPath, value) {
237
423
  }
238
424
  current[keys[keys.length - 1]] = value;
239
425
 
240
- const writePath = getConfigPath(cwd, 'write');
241
- fs.writeFileSync(writePath, JSON.stringify(config, null, 2), 'utf-8');
242
- return config;
426
+ _writeJson(targetPath, targetConfig);
427
+
428
+ // Return merged config for backward compat
429
+ const shared = _readJsonSafe(sharedPath);
430
+ const local = _readJsonSafe(localPath);
431
+ return { ...shared, ...local };
243
432
  }
244
433
 
245
434
  /**
@@ -360,8 +549,8 @@ function checkConfigGitignore(cwd) {
360
549
  }
361
550
 
362
551
  /**
363
- * Ensure config files and review-keys.json are in .gitignore.
364
- * Appends entries for dgs.config.json, config.json, and review-keys.json if not already present.
552
+ * Ensure config.local.json and review-keys.json are in .gitignore.
553
+ * config.json is TRACKED (shared settings), so it is NOT added to .gitignore.
365
554
  *
366
555
  * @param {string} cwd - Working directory
367
556
  * @returns {object} { added: boolean, reason?: string }
@@ -378,22 +567,21 @@ function ensureConfigGitignored(cwd) {
378
567
  // If unreadable, start fresh
379
568
  }
380
569
 
381
- // Check if already present (either legacy or new name)
570
+ // Check if already present
382
571
  const lines = content.split('\n').map((l) => l.trim());
383
- const hasLegacy = lines.some((line) => line === '.planning/config.json');
384
- const hasNew = lines.some((line) => line === '.planning/dgs.config.json' || line === 'dgs.config.json');
572
+ const hasLocalConfig = lines.some((line) => line === 'config.local.json' || line === '.planning/config.local.json');
385
573
  const hasReviewKeys = lines.some((line) => line === 'review-keys.json' || line === '.planning/review-keys.json');
386
574
 
387
- if (hasLegacy && hasNew && hasReviewKeys) {
575
+ if (hasLocalConfig && hasReviewKeys) {
388
576
  return { added: false, reason: 'already_present' };
389
577
  }
390
578
 
391
579
  // Append missing entries
392
580
  let entry = '';
393
- if (!hasNew || !hasLegacy) {
394
- entry += '\n# DGS config (contains API keys)\n';
395
- if (!hasNew) entry += 'dgs.config.json\n';
396
- if (!hasLegacy) entry += '.planning/config.json\n';
581
+ if (!hasLocalConfig) {
582
+ entry += '\n# DGS local config (per-machine state)\n';
583
+ entry += 'config.local.json\n';
584
+ entry += '.planning/config.local.json\n';
397
585
  }
398
586
  if (!hasReviewKeys) {
399
587
  entry += '\n# DGS review keys (contains API keys)\n';
@@ -405,7 +593,11 @@ function ensureConfigGitignored(cwd) {
405
593
  }
406
594
 
407
595
  module.exports = {
596
+ LOCAL_KEYS,
408
597
  getConfigPath,
598
+ getSharedConfigPath,
599
+ getLocalConfigPath,
600
+ isLocalKey,
409
601
  getReviewKeysPath,
410
602
  cmdConfigEnsureSection,
411
603
  cmdConfigSet,
@@ -67,9 +67,6 @@ function safeReadFile(filePath) {
67
67
 
68
68
  function loadConfig(cwd) {
69
69
  const root = getPlanningRoot(cwd);
70
- const newConfigPath = path.join(root, 'dgs.config.json');
71
- const legacyConfigPath = path.join(root, 'config.json');
72
- const configPath = fs.existsSync(newConfigPath) ? newConfigPath : legacyConfigPath;
73
70
  const defaults = {
74
71
  model_profile: 'balanced',
75
72
  commit_docs: true,
@@ -78,6 +75,8 @@ function loadConfig(cwd) {
78
75
  phase_branch_template: 'dgs/{project}/phase-{phase}-{slug}',
79
76
  milestone_branch_template: 'dgs/{project}/{milestone}-{slug}',
80
77
  base_branch: 'main',
78
+ sync_push: 'off',
79
+ sync_pull: 'off',
81
80
  research: true,
82
81
  plan_checker: true,
83
82
  verifier: true,
@@ -85,16 +84,55 @@ function loadConfig(cwd) {
85
84
  brave_search: false,
86
85
  };
87
86
 
87
+ // Read shared config (config.json)
88
+ let shared = {};
89
+ const sharedPath = path.join(root, 'config.json');
88
90
  try {
89
- const raw = fs.readFileSync(configPath, 'utf-8');
90
- const parsed = JSON.parse(raw);
91
+ if (fs.existsSync(sharedPath)) {
92
+ shared = JSON.parse(fs.readFileSync(sharedPath, 'utf-8'));
93
+ }
94
+ } catch { /* ignore */ }
95
+
96
+ // Read local config (config.local.json)
97
+ let local = {};
98
+ const localPath = path.join(root, 'config.local.json');
99
+ try {
100
+ if (fs.existsSync(localPath)) {
101
+ local = JSON.parse(fs.readFileSync(localPath, 'utf-8'));
102
+ }
103
+ } catch { /* ignore */ }
91
104
 
105
+ // Fall back to legacy dgs.config.json if neither new file exists
106
+ let hasNewConfig = Object.keys(shared).length > 0 || Object.keys(local).length > 0;
107
+ if (!hasNewConfig) {
108
+ const legacyPath = path.join(root, 'dgs.config.json');
109
+ try {
110
+ if (fs.existsSync(legacyPath)) {
111
+ shared = JSON.parse(fs.readFileSync(legacyPath, 'utf-8'));
112
+ hasNewConfig = true;
113
+ }
114
+ } catch { /* ignore */ }
115
+ }
116
+
117
+ if (!hasNewConfig) {
118
+ return { ...defaults, current_project: null, v2_hint_shown: false, sync_hint_shown: false };
119
+ }
120
+
121
+ // Merge: local overrides shared for overlapping keys
122
+ const parsed = { ...shared, ...local };
123
+
124
+ try {
92
125
  // Migrate deprecated "depth" key to "granularity" with value mapping
93
126
  if ('depth' in parsed && !('granularity' in parsed)) {
94
127
  const depthToGranularity = { quick: 'coarse', standard: 'standard', comprehensive: 'fine' };
95
128
  parsed.granularity = depthToGranularity[parsed.depth] || parsed.depth;
96
129
  delete parsed.depth;
97
- try { fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2), 'utf-8'); } catch {}
130
+ // Write back to shared config if that's where depth was
131
+ if ('depth' in shared) {
132
+ shared.granularity = parsed.granularity;
133
+ delete shared.depth;
134
+ try { fs.writeFileSync(sharedPath, JSON.stringify(shared, null, 2), 'utf-8'); } catch {}
135
+ }
98
136
  }
99
137
 
100
138
  const get = (key, nested) => {
@@ -120,18 +158,21 @@ function loadConfig(cwd) {
120
158
  phase_branch_template: get('phase_branch_template', { section: 'git', field: 'phase_branch_template' }) ?? defaults.phase_branch_template,
121
159
  milestone_branch_template: get('milestone_branch_template', { section: 'git', field: 'milestone_branch_template' }) ?? defaults.milestone_branch_template,
122
160
  base_branch: get('base_branch', { section: 'git', field: 'base_branch' }) ?? defaults.base_branch,
161
+ sync_push: get('sync_push', { section: 'git', field: 'sync_push' }) ?? defaults.sync_push,
162
+ sync_pull: get('sync_pull', { section: 'git', field: 'sync_pull' }) ?? defaults.sync_pull,
163
+ sync_hint_shown: get('sync_hint_shown', { section: 'git', field: 'sync_hint_shown' }) ?? false,
123
164
  research: get('research', { section: 'workflow', field: 'research' }) ?? defaults.research,
124
165
  plan_checker: get('plan_checker', { section: 'workflow', field: 'plan_check' }) ?? defaults.plan_checker,
125
166
  verifier: get('verifier', { section: 'workflow', field: 'verifier' }) ?? defaults.verifier,
126
167
  parallelization,
127
168
  brave_search: get('brave_search') ?? defaults.brave_search,
128
169
  model_overrides: parsed.model_overrides || null,
129
- // v2 fields passed through directly from config
170
+ // v2 fields -- passed through directly from config (now from config.local.json)
130
171
  current_project: get('current_project') ?? null,
131
172
  v2_hint_shown: get('v2_hint_shown') ?? false,
132
173
  };
133
174
  } catch {
134
- return { ...defaults, current_project: null, v2_hint_shown: false };
175
+ return { ...defaults, current_project: null, v2_hint_shown: false, sync_hint_shown: false };
135
176
  }
136
177
  }
137
178