File size: 35,659 Bytes
a6b96c2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
"use strict";
/**
 * Config β€” Planning config CRUD operations
 *
 * ADR-457 build-at-publish: the hand-written bin/lib/config.cjs collapsed
 * to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
 * from the prior hand-written .cjs; only strict types are added.
 */
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
const node_fs_1 = __importDefault(require("node:fs"));
const node_path_1 = __importDefault(require("node:path"));
const node_os_1 = __importDefault(require("node:os"));
// eslint-disable-next-line @typescript-eslint/no-require-imports
const io = require("./io.cjs");
const { output, error, ERROR_REASON } = io;
// eslint-disable-next-line @typescript-eslint/no-require-imports
const configLoader = require("./config-loader.cjs");
const { CONFIG_DEFAULTS } = configLoader;
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
// eslint-disable-next-line @typescript-eslint/no-require-imports
const planningWorkspace = require("./planning-workspace.cjs");
const { planningDir, withPlanningLock } = planningWorkspace;
// eslint-disable-next-line @typescript-eslint/no-require-imports
const modelProfiles = require("./model-profiles.cjs");
const { VALID_PROFILES, getAgentToModelMapForProfile, formatAgentToModelMapAsTable } = modelProfiles;
// eslint-disable-next-line @typescript-eslint/no-require-imports
const configSchema = require("./config-schema.cjs");
const { VALID_CONFIG_KEYS, isValidConfigKey } = configSchema;
const secrets_cjs_1 = require("./secrets.cjs");
const review_reviewer_selection_cjs_1 = require("./review-reviewer-selection.cjs");
const configuration_cjs_1 = require("./configuration.cjs");
// ─── Constants ────────────────────────────────────────────────────────────────
const CONFIG_KEY_SUGGESTIONS = {
    'workflow.nyquist_validation_enabled': 'workflow.nyquist_validation',
    'agents.nyquist_validation_enabled': 'workflow.nyquist_validation',
    'nyquist.validation_enabled': 'workflow.nyquist_validation',
    'hooks.research_questions': 'workflow.research_before_questions',
    'workflow.research_questions': 'workflow.research_before_questions',
    'workflow.codereview': 'workflow.code_review',
    'workflow.review_command': 'workflow.code_review_command',
    'workflow.review': 'workflow.code_review',
    'workflow.code_review_level': 'workflow.code_review_depth',
    'workflow.review_depth': 'workflow.code_review_depth',
    'review.model': 'review.models.<cli-name>',
    'sub_repos': 'planning.sub_repos',
    'plan_checker': 'workflow.plan_check',
};
const SHIP_PR_BODY_SECTION_KEYS = new Set(['heading', 'enabled', 'source', 'fallback', 'template']);
const SHIP_PR_BODY_TEMPLATE_TOKENS = new Set([
    'phase_number',
    'phase_name',
    'phase_dir',
    'base_branch',
    'padded_phase',
]);
const SHIP_PR_BODY_SOURCE_RE = /^(ROADMAP|PLAN|SUMMARY|VERIFICATION|STATE|REQUIREMENTS|CONTEXT)\.md\s+##\s+[^\r\n#][^\r\n]*$/;
/**
 * Schema-level defaults for well-known config keys.
 * When a key is absent from config.json and no --default flag was supplied,
 * cmdConfigGet checks here before emitting "Key not found".
 */
const SCHEMA_DEFAULTS = {
    'context_window': 200000,
    'executor.stall_detect_interval_minutes': 5,
    'executor.stall_threshold_minutes': 10,
    'git.create_tag': true,
};
// ─── Validation helpers ───────────────────────────────────────────────────────
function validateKnownConfigKeyPath(keyPath) {
    const suggested = CONFIG_KEY_SUGGESTIONS[keyPath];
    if (suggested) {
        error(`Unknown config key: ${keyPath}. Did you mean ${suggested}?`, ERROR_REASON.CONFIG_INVALID_KEY);
    }
}
function validateShipPrBodySections(value) {
    if (!Array.isArray(value)) {
        error('Invalid ship.pr_body_sections value. Expected a JSON array of section objects.');
    }
    value.forEach((section, index) => {
        const prefix = `Invalid ship.pr_body_sections[${index}]`;
        if (!section || typeof section !== 'object' || Array.isArray(section)) {
            error(`${prefix}. Expected an object.`);
        }
        const sectionObj = section;
        const unknownKeys = Object.keys(sectionObj).filter((key) => !SHIP_PR_BODY_SECTION_KEYS.has(key));
        if (unknownKeys.length > 0) {
            error(`${prefix}. Unknown field(s): ${unknownKeys.join(', ')}.`);
        }
        if (typeof sectionObj['heading'] !== 'string' || sectionObj['heading'].trim() === '') {
            error(`${prefix}. heading must be a non-empty string.`);
        }
        if (/[\r\n]/.test(sectionObj['heading'])) {
            error(`${prefix}. heading must be a single line.`);
        }
        if ('enabled' in sectionObj && typeof sectionObj['enabled'] !== 'boolean') {
            error(`${prefix}. enabled must be true or false.`);
        }
        for (const field of ['source', 'fallback', 'template']) {
            if (field in sectionObj && typeof sectionObj[field] !== 'string') {
                error(`${prefix}. ${field} must be a string.`);
            }
        }
        const hasContent = ['source', 'fallback', 'template'].some((field) => {
            const v = sectionObj[field];
            return typeof v === 'string' && v.trim() !== '';
        });
        if (!hasContent) {
            error(`${prefix}. Provide at least one of source, fallback, or template.`);
        }
        if (typeof sectionObj['source'] === 'string' && sectionObj['source'].trim() !== '') {
            const selectors = sectionObj['source'].split('||').map((selector) => selector.trim()).filter(Boolean);
            if (selectors.length === 0 || selectors.some((selector) => !SHIP_PR_BODY_SOURCE_RE.test(selector))) {
                error(`${prefix}. source must use selectors like "PLAN.md ## Risks", separated with "||".`);
            }
        }
        if (typeof sectionObj['template'] === 'string') {
            const tokens = sectionObj['template'].matchAll(/\{([a-zA-Z][a-zA-Z0-9_]*)\}/g);
            for (const match of tokens) {
                if (!SHIP_PR_BODY_TEMPLATE_TOKENS.has(match[1])) {
                    error(`${prefix}. Unsupported template token: {${match[1]}}.`);
                }
            }
        }
    });
}
// ─── Core config operations ───────────────────────────────────────────────────
/**
 * Build a fully-materialized config object for a new project.
 *
 * Merges (increasing priority):
 *   1. Hardcoded defaults β€” every key that loadConfig() resolves, plus mode/granularity
 *   2. User-level defaults from ~/.gsd/defaults.json (if present)
 *   3. userChoices β€” the settings the user explicitly selected during /gsd:new-project
 *
 * Uses the canonical `git` namespace for branching keys (consistent with VALID_CONFIG_KEYS
 * and the settings workflow). loadConfig() handles both flat and nested formats, so this
 * is backward-compatible with existing projects that have flat keys.
 *
 * Returns a plain object β€” does NOT write any files.
 */
function buildNewProjectConfig(userChoices) {
    const choices = userChoices || {};
    const homedir = node_os_1.default.homedir();
    // Detect API key availability
    const braveKeyFile = node_path_1.default.join(homedir, '.gsd', 'brave_api_key');
    const hasBraveSearch = !!(process.env['BRAVE_API_KEY'] || node_fs_1.default.existsSync(braveKeyFile));
    const firecrawlKeyFile = node_path_1.default.join(homedir, '.gsd', 'firecrawl_api_key');
    const hasFirecrawl = !!(process.env['FIRECRAWL_API_KEY'] || node_fs_1.default.existsSync(firecrawlKeyFile));
    const exaKeyFile = node_path_1.default.join(homedir, '.gsd', 'exa_api_key');
    const hasExaSearch = !!(process.env['EXA_API_KEY'] || node_fs_1.default.existsSync(exaKeyFile));
    const tavilyKeyFile = node_path_1.default.join(homedir, '.gsd', 'tavily_api_key');
    const hasTavilySearch = !!(process.env['TAVILY_API_KEY'] || node_fs_1.default.existsSync(tavilyKeyFile));
    const refKeyFile = node_path_1.default.join(homedir, '.gsd', 'ref_api_key');
    const hasRefSearch = !!(process.env['REF_API_KEY'] || node_fs_1.default.existsSync(refKeyFile));
    const perplexityKeyFile = node_path_1.default.join(homedir, '.gsd', 'perplexity_api_key');
    const hasPerplexity = !!(process.env['PERPLEXITY_API_KEY'] || node_fs_1.default.existsSync(perplexityKeyFile));
    const jinaKeyFile = node_path_1.default.join(homedir, '.gsd', 'jina_api_key');
    const hasJina = !!(process.env['JINA_API_KEY'] || node_fs_1.default.existsSync(jinaKeyFile));
    // Load user-level defaults from ~/.gsd/defaults.json if available
    const globalDefaultsPath = node_path_1.default.join(homedir, '.gsd', 'defaults.json');
    let userDefaults = {};
    try {
        if (node_fs_1.default.existsSync(globalDefaultsPath)) {
            userDefaults = JSON.parse(node_fs_1.default.readFileSync(globalDefaultsPath, 'utf-8'));
            // Migrate deprecated "depth" key to "granularity"
            if ('depth' in userDefaults && !('granularity' in userDefaults)) {
                const depthToGranularity = { quick: 'coarse', standard: 'standard', comprehensive: 'fine' };
                userDefaults['granularity'] = depthToGranularity[userDefaults['depth']] || userDefaults['depth'];
                delete userDefaults['depth'];
                try {
                    (0, shell_command_projection_cjs_1.platformWriteSync)(globalDefaultsPath, JSON.stringify(userDefaults, null, 2));
                }
                catch { /* intentionally empty */ }
            }
        }
    }
    catch {
        // Ignore malformed global defaults
    }
    const hardcoded = {
        model_profile: CONFIG_DEFAULTS.model_profile,
        commit_docs: CONFIG_DEFAULTS.commit_docs,
        parallelization: CONFIG_DEFAULTS.parallelization,
        search_gitignored: CONFIG_DEFAULTS.search_gitignored,
        brave_search: hasBraveSearch,
        firecrawl: hasFirecrawl,
        exa_search: hasExaSearch,
        tavily_search: hasTavilySearch,
        ref_search: hasRefSearch,
        perplexity: hasPerplexity,
        jina: hasJina,
        git: {
            branching_strategy: CONFIG_DEFAULTS.branching_strategy,
            create_tag: true,
            phase_branch_template: CONFIG_DEFAULTS.phase_branch_template,
            milestone_branch_template: CONFIG_DEFAULTS.milestone_branch_template,
            quick_branch_template: CONFIG_DEFAULTS.quick_branch_template,
        },
        workflow: {
            research: true,
            plan_check: true,
            verifier: true,
            nyquist_validation: true,
            auto_advance: false,
            node_repair: true,
            node_repair_budget: 2,
            ui_phase: true,
            ui_safety_gate: true,
            ai_integration_phase: true,
            human_verify_mode: 'end-of-phase',
            text_mode: false,
            research_before_questions: false,
            discuss_mode: 'discuss',
            skip_discuss: false,
            code_review: true,
            code_review_depth: 'standard',
            code_review_command: null,
            pattern_mapper: true,
            plan_bounce: false,
            plan_bounce_script: null,
            plan_bounce_passes: 2,
            auto_prune_state: false,
            post_planning_gaps: CONFIG_DEFAULTS.post_planning_gaps,
            security_enforcement: CONFIG_DEFAULTS.security_enforcement,
            security_asvs_level: CONFIG_DEFAULTS.security_asvs_level,
            security_block_on: CONFIG_DEFAULTS.security_block_on,
        },
        ship: {
            pr_body_sections: [],
        },
        hooks: {
            context_warnings: true,
        },
        project_code: null,
        phase_naming: 'sequential',
        agent_skills: {},
        claude_md_path: './.claude/CLAUDE.md',
        plan_review: {
            source_grounding: true,
            source_grounding_authority: 'grep',
        },
    };
    const ud = userDefaults;
    const ch = choices;
    const hd = hardcoded;
    // Three-level deep merge: hardcoded <- userDefaults <- choices
    const config = {
        ...hardcoded,
        ...userDefaults,
        ...choices,
        git: {
            ...hd['git'],
            ...(ud['git'] || {}),
            ...(ch['git'] || {}),
        },
        workflow: {
            ...hd['workflow'],
            ...(ud['workflow'] || {}),
            ...(ch['workflow'] || {}),
        },
        ship: {
            ...hd['ship'],
            ...(ud['ship'] || {}),
            ...(ch['ship'] || {}),
        },
        hooks: {
            ...hd['hooks'],
            ...(ud['hooks'] || {}),
            ...(ch['hooks'] || {}),
        },
        agent_skills: {
            ...hd['agent_skills'],
            ...(ud['agent_skills'] || {}),
            ...(ch['agent_skills'] || {}),
        },
        plan_review: {
            ...hd['plan_review'],
            ...(ud['plan_review'] || {}),
            ...(ch['plan_review'] || {}),
        },
    };
    validateShipPrBodySections(config['ship']['pr_body_sections']);
    return config;
}
/**
 * Command: create a fully-materialized .planning/config.json for a new project.
 *
 * Accepts user-chosen settings as a JSON string (the keys the user explicitly
 * configured during /gsd:new-project). All remaining keys are filled from
 * hardcoded defaults and optional ~/.gsd/defaults.json.
 *
 * Idempotent: if config.json already exists, returns { created: false }.
 */
function cmdConfigNewProject(cwd, choicesJson, raw) {
    const planningBase = planningDir(cwd);
    const configPath = node_path_1.default.join(planningBase, 'config.json');
    // Idempotent: don't overwrite existing config
    if (node_fs_1.default.existsSync(configPath)) {
        output({ created: false, reason: 'already_exists' }, raw, 'exists');
        return;
    }
    // Parse user choices
    let userChoices = {};
    if (choicesJson && choicesJson.trim() !== '') {
        try {
            userChoices = JSON.parse(choicesJson);
        }
        catch (err) {
            error('Invalid JSON for config-new-project: ' + err.message);
        }
    }
    // Ensure .planning directory exists
    try {
        (0, shell_command_projection_cjs_1.platformEnsureDir)(planningBase);
    }
    catch (err) {
        error('Failed to create .planning directory: ' + err.message);
    }
    const config = buildNewProjectConfig(userChoices);
    try {
        (0, shell_command_projection_cjs_1.platformWriteSync)(configPath, JSON.stringify(config, null, 2));
        output({ created: true, path: '.planning/config.json' }, raw, 'created');
    }
    catch (err) {
        error('Failed to write config.json: ' + err.message);
    }
}
/**
 * Ensures the config file exists (creates it if needed).
 *
 * Does not call `output()`, so can be used as one step in a command without triggering `exit(0)` in
 * the happy path. But note that `error()` will still `exit(1)` out of the process.
 */
function ensureConfigFile(cwd) {
    const planningBase = planningDir(cwd);
    const configPath = node_path_1.default.join(planningBase, 'config.json');
    // Ensure .planning directory exists
    try {
        (0, shell_command_projection_cjs_1.platformEnsureDir)(planningBase);
    }
    catch (err) {
        error('Failed to create .planning directory: ' + err.message);
    }
    // Check if config already exists
    if (node_fs_1.default.existsSync(configPath)) {
        return { created: false, reason: 'already_exists' };
    }
    const config = buildNewProjectConfig({});
    try {
        (0, shell_command_projection_cjs_1.platformWriteSync)(configPath, JSON.stringify(config, null, 2));
        return { created: true, path: '.planning/config.json' };
    }
    catch (err) {
        error('Failed to create config.json: ' + err.message);
    }
}
/**
 * Command to ensure the config file exists (creates it if needed).
 *
 * Note that this exits the process (via `output()`) even in the happy path; use
 * `ensureConfigFile()` directly if you need to avoid this.
 */
function cmdConfigEnsureSection(cwd, raw) {
    const ensureConfigFileResult = ensureConfigFile(cwd);
    if (ensureConfigFileResult && ensureConfigFileResult.created) {
        output(ensureConfigFileResult, raw, 'created');
    }
    else {
        output(ensureConfigFileResult, raw, 'exists');
    }
}
/**
 * Shared helper: write a single key-path into an in-memory config object.
 *
 * Prototype-pollution guard: reject dangerous segments via inline literal
 * comparisons on the exact key used to index `current`, immediately before
 * each write. The inline comparison is the barrier CodeQL's
 * js/prototype-pollution-utility query recognises β€” the previous Set-based
 * pre-loop check was functionally correct but not traced through, so
 * code-scanning alert #26 kept firing. Behaviour is unchanged from #663.
 *
 * Returns the previous value at the leaf key (undefined if absent).
 * Never writes to disk β€” callers handle persistence.
 * Calls error() (process.exit(1)) on prototype-pollution attempts.
 */
function _setNestedValue(config, keyPath, parsedValue) {
    const keys = keyPath.split('.');
    let current = config;
    for (let i = 0; i < keys.length - 1; i++) {
        const key = keys[i];
        if (key === '__proto__' || key === 'prototype' || key === 'constructor') {
            error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED);
        }
        const existingChild = current[key];
        if (existingChild === undefined || existingChild === null || typeof existingChild !== 'object' || Array.isArray(existingChild)) {
            current[key] = {};
        }
        current = current[key];
    }
    const lastKey = keys[keys.length - 1];
    if (lastKey === '__proto__' || lastKey === 'prototype' || lastKey === 'constructor') {
        error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED);
    }
    const previousValue = current[lastKey];
    current[lastKey] = parsedValue;
    return previousValue;
}
/**
 * Sets a value in the config file, allowing nested values via dot notation (e.g.,
 * "workflow.research").
 *
 * Does not call `output()`, so can be used as one step in a command without triggering `exit(0)` in
 * the happy path. But note that `error()` will still `exit(1)` out of the process.
 */
function setConfigValue(cwd, keyPath, parsedValue) {
    const configPath = node_path_1.default.join(planningDir(cwd), 'config.json');
    return withPlanningLock(cwd, () => {
        // Load existing config or start with empty object
        let config = {};
        try {
            if (node_fs_1.default.existsSync(configPath)) {
                config = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf-8'));
            }
        }
        catch (err) {
            error('Failed to read config.json: ' + err.message, ERROR_REASON.CONFIG_PARSE_FAILED);
        }
        const previousValue = _setNestedValue(config, keyPath, parsedValue);
        // Write back
        try {
            (0, shell_command_projection_cjs_1.platformWriteSync)(configPath, JSON.stringify(config, null, 2));
            return { updated: true, key: keyPath, value: parsedValue, previousValue };
        }
        catch (err) {
            error('Failed to write config.json: ' + err.message);
        }
    });
}
/**
 * Batched sibling of setConfigValue: apply multiple key-path writes in a
 * single load β†’ set-all β†’ write cycle inside ONE withPlanningLock call.
 *
 * Returns { updated: true, results: SetConfigValueResult[] } on success.
 * An empty entries array is a no-op and returns { updated: false, results: [] }.
 *
 * Prototype-pollution guards are enforced per entry (identical inline-literal
 * guards as setConfigValue β€” CodeQL barrier requirement).
 */
function setConfigValues(cwd, entries) {
    if (entries.length === 0) {
        return { updated: false, results: [] };
    }
    const configPath = node_path_1.default.join(planningDir(cwd), 'config.json');
    return withPlanningLock(cwd, () => {
        // Load existing config or start with empty object
        let config = {};
        try {
            if (node_fs_1.default.existsSync(configPath)) {
                config = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf-8'));
            }
        }
        catch (err) {
            error('Failed to read config.json: ' + err.message, ERROR_REASON.CONFIG_PARSE_FAILED);
        }
        const results = [];
        for (const entry of entries) {
            const previousValue = _setNestedValue(config, entry.keyPath, entry.value);
            results.push({ updated: true, key: entry.keyPath, value: entry.value, previousValue });
        }
        // Write back once for all entries
        try {
            (0, shell_command_projection_cjs_1.platformWriteSync)(configPath, JSON.stringify(config, null, 2));
            return { updated: true, results };
        }
        catch (err) {
            error('Failed to write config.json: ' + err.message);
        }
    });
}
/**
 * Command to set a value in the config file, allowing nested values via dot notation (e.g.,
 * "workflow.research").
 *
 * Note that this exits the process (via `output()`) even in the happy path; use `setConfigValue()`
 * directly if you need to avoid this.
 */
function cmdConfigSet(cwd, keyPath, value, raw) {
    if (!keyPath) {
        error('Usage: config-set <key.path> <value>', ERROR_REASON.USAGE);
    }
    // #3593: reject the "key without value" form (e.g. `config-set
    // model_profile` with args[2] === undefined). Without this guard the
    // value passes through as undefined, the number/boolean/json branches
    // all fall through, and the write either silently strips the key
    // (JSON.stringify drops undefined values) or writes a corrupt entry.
    // Typed reason so the negative-matrix test can assert on it instead
    // of greppinng prose.
    if (value === undefined) {
        error('Usage: config-set <key.path> <value>', ERROR_REASON.USAGE);
    }
    // After the two error() guards above, keyPath and value are narrowed to string.
    // TypeScript doesn't always infer never-return narrowing through error(), so we assert.
    const kp = keyPath;
    const val = value;
    validateKnownConfigKeyPath(kp);
    if (!isValidConfigKey(kp)) {
        error(`Unknown config key: "${kp}". Valid keys: ${[...VALID_CONFIG_KEYS].sort().join(', ')}, agent_skills.<agent-type>, features.<feature_name>`, ERROR_REASON.CONFIG_INVALID_KEY);
    }
    // Parse value (handle booleans, numbers, and JSON arrays/objects)
    let parsedValue = val;
    if (val === 'true')
        parsedValue = true;
    else if (val === 'false')
        parsedValue = false;
    else if (!isNaN(Number(val)) && val !== '')
        parsedValue = Number(val);
    else if (typeof val === 'string' && (val.startsWith('[') || val.startsWith('{'))) {
        try {
            parsedValue = JSON.parse(val);
        }
        catch { /* keep as string */ }
    }
    const VALID_CONTEXT_VALUES = ['dev', 'research', 'review'];
    if (kp === 'context' && !VALID_CONTEXT_VALUES.includes(String(parsedValue))) {
        error(`Invalid context value '${val}'. Valid values: ${VALID_CONTEXT_VALUES.join(', ')}`);
    }
    // Codebase drift detector (#2003)
    const VALID_DRIFT_ACTIONS = ['warn', 'auto-remap'];
    if (kp === 'workflow.drift_action' && !VALID_DRIFT_ACTIONS.includes(String(parsedValue))) {
        error(`Invalid workflow.drift_action '${val}'. Valid values: ${VALID_DRIFT_ACTIONS.join(', ')}`);
    }
    if (kp === 'workflow.drift_threshold') {
        if (typeof parsedValue !== 'number' || !Number.isInteger(parsedValue) || parsedValue < 1) {
            error(`Invalid workflow.drift_threshold '${val}'. Must be a positive integer.`);
        }
    }
    // Post-planning gap checker (#2493)
    if (kp === 'workflow.post_planning_gaps') {
        if (typeof parsedValue !== 'boolean') {
            error(`Invalid workflow.post_planning_gaps '${val}'. Must be a boolean (true or false).`);
        }
    }
    // #3086 β€” git.create_tag: boolean only
    if (kp === 'git.create_tag') {
        if (typeof parsedValue !== 'boolean') {
            error(`Invalid git.create_tag '${val}'. Must be a boolean (true or false).`);
        }
    }
    if (kp === 'ship.pr_body_sections') {
        validateShipPrBodySections(parsedValue);
    }
    // Human verification checkpoint mode (#3309)
    const VALID_HUMAN_VERIFY_MODES = ['mid-flight', 'end-of-phase'];
    if (kp === 'workflow.human_verify_mode' && !VALID_HUMAN_VERIFY_MODES.includes(String(parsedValue))) {
        error(`Invalid workflow.human_verify_mode '${val}'. Valid values: ${VALID_HUMAN_VERIFY_MODES.join(', ')}`);
    }
    // Context position enum validation (#2937)
    const VALID_CONTEXT_POSITIONS = ['front', 'end'];
    if (kp === 'statusline.context_position' && !VALID_CONTEXT_POSITIONS.includes(String(parsedValue))) {
        error(`Invalid statusline.context_position '${val}'. Valid values: ${VALID_CONTEXT_POSITIONS.join(', ')}`);
    }
    // Fallow scope + profile enum validation (#3424)
    const VALID_FALLOW_SCOPES = ['phase', 'repo'];
    if (kp === 'code_quality.fallow.scope' && !VALID_FALLOW_SCOPES.includes(String(parsedValue))) {
        error(`Invalid code_quality.fallow.scope '${val}'. Valid values: ${VALID_FALLOW_SCOPES.join(', ')}`);
    }
    const VALID_FALLOW_PROFILES = ['minimal', 'standard', 'strict'];
    if (kp === 'code_quality.fallow.profile' && !VALID_FALLOW_PROFILES.includes(String(parsedValue))) {
        error(`Invalid code_quality.fallow.profile '${val}'. Valid values: ${VALID_FALLOW_PROFILES.join(', ')}`);
    }
    // plan_review.source_grounding (#22) β€” boolean only
    if (kp === 'plan_review.source_grounding') {
        if (typeof parsedValue !== 'boolean') {
            error(`Invalid plan_review.source_grounding '${val}'. Must be a boolean (true or false).`);
        }
    }
    // plan_review.source_grounding_authority (#22) β€” enum
    const VALID_SOURCE_GROUNDING_AUTHORITIES = ['grep', 'intel', 'treesitter', 'lsp', 'scip'];
    if (kp === 'plan_review.source_grounding_authority' && !VALID_SOURCE_GROUNDING_AUTHORITIES.includes(String(parsedValue))) {
        error(`Invalid plan_review.source_grounding_authority '${val}'. Valid values: ${VALID_SOURCE_GROUNDING_AUTHORITIES.join(', ')}`);
    }
    if (kp === 'review.default_reviewers') {
        const normalized = (0, review_reviewer_selection_cjs_1.normalizeConfiguredDefaultReviewers)(parsedValue);
        if (normalized.errors.length > 0) {
            error(normalized.errors[0]);
        }
        parsedValue = normalized.values;
    }
    const setConfigValueResult = setConfigValue(cwd, kp, parsedValue);
    // Mask secrets in both JSON and text output. The plaintext is written
    // to config.json (that's where secrets live on disk); the CLI output
    // must never echo it. See lib/secrets.cjs.
    if ((0, secrets_cjs_1.isSecretKey)(kp)) {
        // parsedValue is unknown at this point; maskSecret accepts MaskableValue
        const masked = (0, secrets_cjs_1.maskSecret)(parsedValue);
        const maskedPrev = setConfigValueResult.previousValue === undefined
            ? undefined
            : (0, secrets_cjs_1.maskSecret)(setConfigValueResult.previousValue);
        const maskedResult = {
            ...setConfigValueResult,
            value: masked,
            previousValue: maskedPrev,
            masked: true,
        };
        output(maskedResult, raw, `${kp}=${masked}`);
        return;
    }
    output(setConfigValueResult, raw, `${kp}=${String(parsedValue)}`);
}
function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
    const configPath = node_path_1.default.join(planningDir(cwd), 'config.json');
    const hasDefault = defaultValue !== undefined;
    if (!keyPath) {
        error('Usage: config-get <key.path> [--default <value>]');
    }
    // After the error() guard, keyPath is narrowed to string.
    const kp = keyPath;
    let config = {};
    try {
        if (node_fs_1.default.existsSync(configPath)) {
            config = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf-8'));
        }
        else if (hasDefault) {
            // eslint-disable-next-line @typescript-eslint/no-base-to-string
            output(defaultValue, raw, String(defaultValue));
            return;
        }
        else if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, kp)) {
            const def = SCHEMA_DEFAULTS[kp];
            output(def, raw, String(def));
            return;
        }
        else {
            error('No config.json found at ' + configPath, ERROR_REASON.CONFIG_NO_FILE);
        }
    }
    catch (err) {
        if (err.message.startsWith('No config.json'))
            throw err;
        error('Failed to read config.json: ' + err.message, ERROR_REASON.CONFIG_PARSE_FAILED);
    }
    // Traverse dot-notation path (e.g., "workflow.auto_advance")
    const keys = kp.split('.');
    let current = config;
    for (const key of keys) {
        if (current === undefined || current === null || typeof current !== 'object') {
            // eslint-disable-next-line @typescript-eslint/no-base-to-string
            if (hasDefault) {
                output(defaultValue, raw, String(defaultValue));
                return;
            }
            if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, kp)) {
                const def = SCHEMA_DEFAULTS[kp];
                output(def, raw, String(def));
                return;
            }
            error(`Key not found: ${kp}`, ERROR_REASON.CONFIG_KEY_NOT_FOUND);
        }
        current = current[key];
    }
    if (current === undefined) {
        // eslint-disable-next-line @typescript-eslint/no-base-to-string
        if (hasDefault) {
            output(defaultValue, raw, String(defaultValue));
            return;
        }
        if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, kp)) {
            const def = SCHEMA_DEFAULTS[kp];
            output(def, raw, String(def));
            return;
        }
        error(`Key not found: ${kp}`, ERROR_REASON.CONFIG_KEY_NOT_FOUND);
    }
    // Never echo plaintext for sensitive keys via config-get. Plaintext lives
    // in config.json on disk; the CLI surface always shows the masked form.
    if ((0, secrets_cjs_1.isSecretKey)(kp)) {
        const masked = (0, secrets_cjs_1.maskSecret)(current);
        output(masked, raw, masked);
        return;
    }
    output(current, raw, String(current));
}
/**
 * Command to set the model profile in the config file.
 *
 * Note that this exits the process (via `output()`) even in the happy path.
 */
function cmdConfigSetModelProfile(cwd, profile, raw) {
    if (!profile) {
        error(`Usage: config-set-model-profile <${VALID_PROFILES.join('|')}>`);
    }
    const normalizedProfile = profile.toLowerCase().trim();
    if (!VALID_PROFILES.includes(normalizedProfile)) {
        error(`Invalid profile '${String(profile)}'. Valid profiles: ${VALID_PROFILES.join(', ')}`);
    }
    // Ensure config exists (create if needed)
    ensureConfigFile(cwd);
    // Set the model profile in the config
    const { previousValue } = setConfigValue(cwd, 'model_profile', normalizedProfile);
    const previousProfile = typeof previousValue === 'string' ? previousValue : 'balanced';
    // Build result value / message and return
    const agentToModelMap = getAgentToModelMapForProfile(normalizedProfile);
    const result = {
        updated: true,
        profile: normalizedProfile,
        previousProfile,
        agentToModelMap,
    };
    const rawValue = getCmdConfigSetModelProfileResultMessage(normalizedProfile, previousProfile, agentToModelMap);
    output(result, raw, rawValue);
}
/**
 * Returns the message to display for the result of the `config-set-model-profile` command when
 * displaying raw output.
 */
function getCmdConfigSetModelProfileResultMessage(normalizedProfile, previousProfile, agentToModelMap) {
    const agentToModelTable = formatAgentToModelMapAsTable(agentToModelMap);
    const didChange = previousProfile !== normalizedProfile;
    const paragraphs = didChange
        ? [
            `βœ“ Model profile set to: ${normalizedProfile} (was: ${previousProfile})`,
            'Agents will now use:',
            agentToModelTable,
            'Next spawned agents will use the new profile.',
        ]
        : [
            `βœ“ Model profile is already set to: ${normalizedProfile}`,
            'Agents are using:',
            agentToModelTable,
        ];
    return paragraphs.join('\n\n');
}
/**
 * Print the resolved config.json path (workstream-aware). Used by settings.md
 * so the workflow writes/reads the correct file when a workstream is active (#2282).
 */
function cmdConfigPath(cwd, _raw, workstreamContext = null) {
    // Always emit as plain text β€” a file path is used via shell substitution,
    // never consumed as JSON. Passing raw=true forces plain-text output.
    const configPath = workstreamContext && workstreamContext.configPath
        ? workstreamContext.configPath
        : node_path_1.default.join(planningDir(cwd), 'config.json');
    output(configPath, true, configPath);
}
/**
 * Explicit on-disk migration of legacy config keys to canonical nested shape.
 *
 * Wraps the Configuration Module's migrateOnDisk() for the CLI surface. This
 * is the Phase 2 acceptance-criteria deliverable for opt-in migration (#3536):
 * users can run `gsd-tools migrate-config` to apply all four legacy-key
 * migrations to their .planning/config.json without having to load any config
 * implicitly via another command.
 *
 * Output: JSON object with { migrated, normalizations, wrote } or a human-readable
 * summary when --raw is set. Exits 0 in all cases (including no-op).
 *
 * Note: migrateOnDisk() is synchronous; the original CJS used async for
 * forward-compatibility but no await is needed. Dropped async per ADR-457 policy
 * (caller uses `await` which is safe on a sync return value).
 */
function cmdMigrateConfig(cwd, raw) {
    const ws = process.env['GSD_WORKSTREAM'] || null;
    const report = (0, configuration_cjs_1.migrateOnDisk)(cwd, ws || undefined);
    if (raw) {
        if (!report.migrated) {
            const msg = 'No legacy keys found β€” config is already canonical.';
            output(msg, true, msg);
        }
        else {
            const lines = [
                `Migrated: ${String(report.wrote)}`,
                ...report.normalizations.map(n => `  ${n.from} β†’ ${n.to}`),
            ].join('\n');
            output(lines, true, lines);
        }
    }
    else {
        // output() JSON.stringify's its first arg when raw=false; pass the report object.
        output(report, false, report);
    }
}
module.exports = {
    VALID_CONFIG_KEYS,
    cmdConfigEnsureSection,
    cmdConfigSet,
    cmdConfigGet,
    cmdConfigSetModelProfile,
    cmdConfigNewProject,
    cmdConfigPath,
    cmdMigrateConfig,
    // Exported for programmatic use by capability-writer and tests
    setConfigValue,
    setConfigValues,
};