pi-usereq 0.12.0 → 0.13.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,7 +1,7 @@
1
1
  /**
2
2
  * @file
3
- * @brief Loads, normalizes, and persists pi-usereq project configuration.
4
- * @details Defines the configuration schema, default directory conventions, JSON serialization helpers, and prompt placeholder expansion paths. Runtime is dominated by filesystem reads and writes plus linear normalization over configured entries. Side effects include config-file persistence under `.pi-usereq.json`.
3
+ * @brief Loads, normalizes, merges, and persists pi-usereq configuration.
4
+ * @details Defines the effective configuration schema, split local/global persistence contracts, JSON serialization helpers, and prompt placeholder expansion paths. Runtime is dominated by filesystem reads and writes plus linear normalization over configured entries. Side effects include config-file persistence under `.pi-usereq.json` and `~/.config/pi-usereq/config.json`.
5
5
  */
6
6
 
7
7
  import fs from "node:fs";
@@ -12,6 +12,7 @@ import {
12
12
  buildRuntimePathFacts,
13
13
  formatRuntimePathForDisplay,
14
14
  getConfigPath,
15
+ getGlobalConfigPath as resolveGlobalConfigPath,
15
16
  normalizeRelativeDirContract,
16
17
  } from "./path-context.js";
17
18
  import {
@@ -73,8 +74,8 @@ export interface StaticCheckLanguageConfig {
73
74
  }
74
75
 
75
76
  /**
76
- * @brief Defines the persisted pi-usereq project configuration schema.
77
- * @details Captures documentation paths, source/test directory selection, per-language static-check enablement and checker configuration, prompt-command worktree settings, enabled startup tools, and notification settings while excluding runtime-derived path metadata. The interface is compile-time only and introduces no runtime side effects.
77
+ * @brief Defines the effective pi-usereq configuration schema.
78
+ * @details Captures the merged runtime view produced from project-local and cross-project persisted scopes, including documentation paths, source/test directory selection, per-language static-check enablement plus checker configuration, prompt-command worktree settings, enabled startup tools, and notification settings while excluding runtime-derived path metadata. The interface is compile-time only and introduces no runtime side effects.
78
79
  */
79
80
  export interface UseReqConfig {
80
81
  "docs-dir": string;
@@ -116,6 +117,74 @@ export interface UseReqConfig {
116
117
  PI_NOTIFY_SOUND_HIGH_CMD: string;
117
118
  }
118
119
 
120
+ /**
121
+ * @brief Defines one persisted local static-check language configuration.
122
+ * @details Stores only the project-local enable flag so checker command definitions can live exclusively in global configuration. The interface is compile-time only and introduces no runtime cost.
123
+ */
124
+ interface LocalStaticCheckLanguageConfig {
125
+ enabled: StaticCheckEnabled;
126
+ }
127
+
128
+ /**
129
+ * @brief Defines one persisted global static-check language configuration.
130
+ * @details Stores only the cross-project checker-entry array so project-local files do not duplicate command definitions. The interface is compile-time only and introduces no runtime cost.
131
+ */
132
+ interface GlobalStaticCheckLanguageConfig {
133
+ checkers: StaticCheckEntry[];
134
+ }
135
+
136
+ /**
137
+ * @brief Defines the persisted local pi-usereq configuration schema.
138
+ * @details Captures project-scoped directory, debug, and static-check enablement fields written to `<base-path>/.pi-usereq.json` while excluding global notification, tool, git, and checker-command settings. The interface is compile-time only and introduces no runtime side effects.
139
+ */
140
+ interface UseReqLocalConfig {
141
+ "docs-dir": string;
142
+ "tests-dir": string;
143
+ "src-dir": string[];
144
+ "static-check": Record<string, LocalStaticCheckLanguageConfig>;
145
+ DEBUG_ENABLED: "enable" | "disable";
146
+ DEBUG_LOG_FILE: string;
147
+ DEBUG_STATUS_CHANGES: "enable" | "disable";
148
+ DEBUG_WORKFLOW_EVENTS: "enable" | "disable";
149
+ DEBUG_LOG_ON_STATUS: "any" | "idle" | "checking" | "running" | "merging" | "error";
150
+ DEBUG_ENABLED_TOOLS: string[];
151
+ DEBUG_ENABLED_PROMPTS: string[];
152
+ }
153
+
154
+ /**
155
+ * @brief Defines the persisted global pi-usereq configuration schema.
156
+ * @details Captures cross-project static-check checker commands, enabled tool names, git automation fields, and notification settings written to `~/.config/pi-usereq/config.json` while excluding project-local directory and debug fields. The interface is compile-time only and introduces no runtime side effects.
157
+ */
158
+ interface UseReqGlobalConfig {
159
+ "static-check": Record<string, GlobalStaticCheckLanguageConfig>;
160
+ "enabled-tools": string[];
161
+ AUTO_GIT_COMMIT: "enable" | "disable";
162
+ GIT_WORKTREE_ENABLED: "enable" | "disable";
163
+ GIT_WORKTREE_PREFIX: string;
164
+ "notify-enabled": boolean;
165
+ "notify-on-completed": boolean;
166
+ "notify-on-interrupted": boolean;
167
+ "notify-on-failed": boolean;
168
+ "notify-sound": "none" | "low" | "mid" | "high";
169
+ "notify-sound-on-completed": boolean;
170
+ "notify-sound-on-interrupted": boolean;
171
+ "notify-sound-on-failed": boolean;
172
+ "notify-sound-toggle-shortcut": string;
173
+ "notify-pushover-enabled": boolean;
174
+ "notify-pushover-on-completed": boolean;
175
+ "notify-pushover-on-interrupted": boolean;
176
+ "notify-pushover-on-failed": boolean;
177
+ "notify-pushover-user-key": string;
178
+ "notify-pushover-api-token": string;
179
+ "notify-pushover-priority": 0 | 1;
180
+ "notify-pushover-title": string;
181
+ "notify-pushover-text": string;
182
+ PI_NOTIFY_CMD: string;
183
+ PI_NOTIFY_SOUND_LOW_CMD: string;
184
+ PI_NOTIFY_SOUND_MID_CMD: string;
185
+ PI_NOTIFY_SOUND_HIGH_CMD: string;
186
+ }
187
+
119
188
  /**
120
189
  * @brief Defines the default documentation directory relative to the project root.
121
190
  * @details Used when no persisted `docs-dir` value exists or normalization yields an empty string. Lookup complexity is O(1).
@@ -261,18 +330,102 @@ export function createStaticCheckLanguageConfig(
261
330
  }
262
331
 
263
332
  /**
264
- * @brief Returns the documented default static-check configuration.
265
- * @details Emits one per-language config object for every supported language, enabling only languages with documented default checker entries and leaving all remaining languages disabled with empty checker lists. Runtime is O(l + c). No external state is mutated.
266
- * @return {Record<string, StaticCheckLanguageConfig>} Fresh default static-check config.
267
- * @satisfies REQ-249, REQ-250, REQ-251, REQ-252
333
+ * @brief Builds one persisted local static-check language configuration object.
334
+ * @details Stores only the normalized enable flag so local project files do not duplicate checker command definitions. Runtime is O(1). No external state is mutated.
335
+ * @param[in] enabled {StaticCheckEnabled} Canonical per-language enable flag.
336
+ * @return {LocalStaticCheckLanguageConfig} Persistable local static-check language config.
268
337
  */
269
- export function getDefaultStaticCheckConfig(): Record<string, StaticCheckLanguageConfig> {
338
+ function createLocalStaticCheckLanguageConfig(
339
+ enabled: StaticCheckEnabled,
340
+ ): LocalStaticCheckLanguageConfig {
341
+ return { enabled };
342
+ }
343
+
344
+ /**
345
+ * @brief Builds one persisted global static-check language configuration object.
346
+ * @details Clones the supplied checker entries so global configuration retains only stable module, command, and parameter fields. Runtime is O(c + p). No external state is mutated.
347
+ * @param[in] checkers {StaticCheckEntry[]} Ordered checker entries.
348
+ * @return {GlobalStaticCheckLanguageConfig} Persistable global static-check language config.
349
+ */
350
+ function createGlobalStaticCheckLanguageConfig(
351
+ checkers: StaticCheckEntry[],
352
+ ): GlobalStaticCheckLanguageConfig {
353
+ return {
354
+ checkers: checkers.map(cloneStaticCheckEntry),
355
+ };
356
+ }
357
+
358
+ /**
359
+ * @brief Returns the documented default global static-check checker map.
360
+ * @details Emits one checker-array object for every supported language, preserving documented Command entries for default-enabled languages and `[]` for every other language. Runtime is O(l + c). No external state is mutated.
361
+ * @return {Record<string, GlobalStaticCheckLanguageConfig>} Fresh default global checker map.
362
+ */
363
+ function getDefaultGlobalStaticCheckConfig(): Record<string, GlobalStaticCheckLanguageConfig> {
270
364
  return Object.fromEntries(DEFAULT_STATIC_CHECK_LANGUAGES.map((language) => [
271
365
  language,
272
- createStaticCheckLanguageConfig(DEFAULT_STATIC_CHECK_CHECKERS[language] ?? []),
366
+ createGlobalStaticCheckLanguageConfig(DEFAULT_STATIC_CHECK_CHECKERS[language] ?? []),
367
+ ]));
368
+ }
369
+
370
+ /**
371
+ * @brief Returns the documented default local static-check enable map.
372
+ * @details Derives each per-language enable flag from the supplied global checker definitions using the rule `enable` when `checkers` is non-empty and `disable` otherwise. Runtime is O(l). No external state is mutated.
373
+ * @param[in] globalStaticCheckConfig {Record<string, GlobalStaticCheckLanguageConfig>} Global checker map used to derive default enablement.
374
+ * @return {Record<string, LocalStaticCheckLanguageConfig>} Fresh default local enable map.
375
+ */
376
+ function getDefaultLocalStaticCheckConfig(
377
+ globalStaticCheckConfig: Record<string, GlobalStaticCheckLanguageConfig>,
378
+ ): Record<string, LocalStaticCheckLanguageConfig> {
379
+ return Object.fromEntries(Object.entries(globalStaticCheckConfig).map(([language, languageConfig]) => [
380
+ language,
381
+ createLocalStaticCheckLanguageConfig(
382
+ languageConfig.checkers.length > 0 ? "enable" : "disable",
383
+ ),
273
384
  ]));
274
385
  }
275
386
 
387
+ /**
388
+ * @brief Merges local enable flags with global checker definitions into the effective static-check map.
389
+ * @details Unifies all language keys present in either persisted scope, applies the default-enable rule when the local scope omits a language, and clones checker entries into the effective runtime config. Runtime is O(l + c + p). No external state is mutated.
390
+ * @param[in] localStaticCheckConfig {Record<string, LocalStaticCheckLanguageConfig>} Persisted local enable map.
391
+ * @param[in] globalStaticCheckConfig {Record<string, GlobalStaticCheckLanguageConfig>} Persisted global checker map.
392
+ * @return {Record<string, StaticCheckLanguageConfig>} Effective per-language static-check config.
393
+ */
394
+ function mergeStaticCheckConfig(
395
+ localStaticCheckConfig: Record<string, LocalStaticCheckLanguageConfig>,
396
+ globalStaticCheckConfig: Record<string, GlobalStaticCheckLanguageConfig>,
397
+ ): Record<string, StaticCheckLanguageConfig> {
398
+ const languages = new Set<string>([
399
+ ...Object.keys(globalStaticCheckConfig),
400
+ ...Object.keys(localStaticCheckConfig),
401
+ ]);
402
+ return Object.fromEntries([...languages].map((language) => {
403
+ const globalLanguageConfig = globalStaticCheckConfig[language]
404
+ ?? createGlobalStaticCheckLanguageConfig([]);
405
+ const checkers = globalLanguageConfig.checkers.map(cloneStaticCheckEntry);
406
+ const defaultEnabled = checkers.length > 0 ? "enable" : "disable";
407
+ const enabled = normalizeStaticCheckEnabled(
408
+ localStaticCheckConfig[language]?.enabled,
409
+ defaultEnabled,
410
+ );
411
+ return [language, createStaticCheckLanguageConfig(checkers, enabled)];
412
+ }));
413
+ }
414
+
415
+ /**
416
+ * @brief Returns the documented default static-check configuration.
417
+ * @details Emits one effective per-language config object for every supported language by merging documented global checker defaults with derived local enable defaults. Runtime is O(l + c). No external state is mutated.
418
+ * @return {Record<string, StaticCheckLanguageConfig>} Fresh default effective static-check config.
419
+ * @satisfies REQ-249, REQ-250, REQ-251, REQ-252, REQ-316
420
+ */
421
+ export function getDefaultStaticCheckConfig(): Record<string, StaticCheckLanguageConfig> {
422
+ const globalStaticCheckConfig = getDefaultGlobalStaticCheckConfig();
423
+ return mergeStaticCheckConfig(
424
+ getDefaultLocalStaticCheckConfig(globalStaticCheckConfig),
425
+ globalStaticCheckConfig,
426
+ );
427
+ }
428
+
276
429
  /**
277
430
  * @brief Normalizes one persisted per-language static-check enable flag.
278
431
  * @details Accepts only the documented `enable|disable` values and falls back to the supplied default when the candidate is absent or invalid. Runtime is O(1). No external state is mutated.
@@ -309,8 +462,8 @@ export function getActiveStaticCheckEntries(
309
462
 
310
463
  /**
311
464
  * @brief Defines the default automatic git-commit prompt mode.
312
- * @details New project configs enable bundled commit-instruction injection unless the persisted project config explicitly disables it. Access complexity is O(1).
313
- * @satisfies CTN-001, REQ-212
465
+ * @details New global configs enable bundled commit-instruction injection unless the persisted cross-project config explicitly disables it. Access complexity is O(1).
466
+ * @satisfies CTN-018, REQ-212
314
467
  */
315
468
  export const DEFAULT_AUTO_GIT_COMMIT = "enable" as const;
316
469
  /**
@@ -325,8 +478,8 @@ export function normalizeAutoGitCommit(value: unknown): "enable" | "disable" {
325
478
  }
326
479
  /**
327
480
  * @brief Defines the default worktree orchestration mode.
328
- * @details New project configs enable prompt-command worktree orchestration unless the persisted project config explicitly disables it. Access complexity is O(1).
329
- * @satisfies CTN-001, REQ-204
481
+ * @details New global configs enable prompt-command worktree orchestration unless the persisted cross-project config explicitly disables it. Access complexity is O(1).
482
+ * @satisfies CTN-018, REQ-204
330
483
  */
331
484
  export const DEFAULT_GIT_WORKTREE_ENABLED = "enable" as const;
332
485
  /**
@@ -355,8 +508,8 @@ export function resolveEffectiveGitWorktreeEnabled(
355
508
  }
356
509
  /**
357
510
  * @brief Defines the default static prefix used by generated prompt-command worktree names.
358
- * @details The prefix is concatenated verbatim ahead of the repository basename inside slash-command-owned worktree-name generation. Access complexity is O(1).
359
- * @satisfies CTN-001, REQ-205
511
+ * @details The prefix is concatenated verbatim ahead of the repository basename inside slash-command-owned worktree-name generation and is persisted in global configuration. Access complexity is O(1).
512
+ * @satisfies CTN-018, REQ-205
360
513
  */
361
514
  export const DEFAULT_GIT_WORKTREE_PREFIX = "PI-useReq-";
362
515
  /**
@@ -374,32 +527,38 @@ export function normalizeGitWorktreePrefix(value: unknown): string {
374
527
  return trimmedValue === "" ? DEFAULT_GIT_WORKTREE_PREFIX : trimmedValue;
375
528
  }
376
529
  /**
377
- * @brief Computes the per-project config file path.
378
- * @details Joins the project base with `.pi-usereq.json`, producing the canonical persistence location used by CLI and extension code. Time complexity is O(1). No I/O side effects occur.
530
+ * @brief Computes the per-project local config file path.
531
+ * @details Joins the project base with `.pi-usereq.json`, producing the canonical local persistence location used by CLI and extension code. Time complexity is O(1). No I/O side effects occur.
379
532
  * @param[in] projectBase {string} Absolute project root path.
380
- * @return {string} Absolute config file path.
533
+ * @return {string} Absolute local config file path.
381
534
  */
382
535
  export function getProjectConfigPath(projectBase: string): string {
383
536
  return getConfigPath(projectBase);
384
537
  }
385
538
 
386
539
  /**
387
- * @brief Builds the default project configuration.
388
- * @details Populates canonical docs/test/source directories, documented per-language static-check defaults, default prompt-command worktree settings, default debug fields including dedicated workflow-event logging, the default startup tool set, default command-notify, sound, and Pushover fields, and excludes runtime-derived path metadata. Time complexity is O(n) in default selector count plus default static-check entry count. No filesystem side effects occur.
389
- * @param[in] projectBase {string} Absolute project root path.
390
- * @return {UseReqConfig} Fresh default configuration object.
391
- * @satisfies CTN-001, CTN-012, CTN-013, REQ-066, REQ-146, REQ-163, REQ-174, REQ-178, REQ-184, REQ-185, REQ-196, REQ-204, REQ-205, REQ-212, REQ-236, REQ-237, REQ-238, REQ-239, REQ-249, REQ-250, REQ-251, REQ-252, REQ-277
540
+ * @brief Computes the cross-project global config file path.
541
+ * @details Resolves `~/.config/pi-usereq/config.json` through the shared runtime path helper so CLI and extension code use one canonical global persistence location. Time complexity is O(1). No I/O side effects occur.
542
+ * @return {string} Absolute global config file path.
392
543
  */
393
- export function getDefaultConfig(_projectBase: string): UseReqConfig {
544
+ export function getGlobalConfigPath(): string {
545
+ return resolveGlobalConfigPath();
546
+ }
547
+
548
+ /**
549
+ * @brief Builds the default persisted local configuration.
550
+ * @details Populates canonical docs/test/source directories, derives local static-check enable defaults from the supplied global checker definitions, and seeds documented debug defaults without any cross-project fields. Runtime is O(l). No filesystem side effects occur.
551
+ * @param[in] globalStaticCheckConfig {Record<string, GlobalStaticCheckLanguageConfig>} Global checker definitions used to derive local enable defaults.
552
+ * @return {UseReqLocalConfig} Fresh default local configuration object.
553
+ */
554
+ function getDefaultLocalConfig(
555
+ globalStaticCheckConfig: Record<string, GlobalStaticCheckLanguageConfig>,
556
+ ): UseReqLocalConfig {
394
557
  return {
395
558
  "docs-dir": DEFAULT_DOCS_DIR,
396
559
  "tests-dir": DEFAULT_TESTS_DIR,
397
560
  "src-dir": [...DEFAULT_SRC_DIRS],
398
- "static-check": getDefaultStaticCheckConfig(),
399
- "enabled-tools": normalizeEnabledPiUsereqTools(undefined),
400
- AUTO_GIT_COMMIT: DEFAULT_AUTO_GIT_COMMIT,
401
- GIT_WORKTREE_ENABLED: DEFAULT_GIT_WORKTREE_ENABLED,
402
- GIT_WORKTREE_PREFIX: DEFAULT_GIT_WORKTREE_PREFIX,
561
+ "static-check": getDefaultLocalStaticCheckConfig(globalStaticCheckConfig),
403
562
  DEBUG_ENABLED: DEFAULT_DEBUG_ENABLED,
404
563
  DEBUG_LOG_FILE: DEFAULT_DEBUG_LOG_FILE,
405
564
  DEBUG_STATUS_CHANGES: DEFAULT_DEBUG_STATUS_CHANGES,
@@ -407,6 +566,21 @@ export function getDefaultConfig(_projectBase: string): UseReqConfig {
407
566
  DEBUG_LOG_ON_STATUS: DEFAULT_DEBUG_LOG_ON_STATUS,
408
567
  DEBUG_ENABLED_TOOLS: [],
409
568
  DEBUG_ENABLED_PROMPTS: [],
569
+ };
570
+ }
571
+
572
+ /**
573
+ * @brief Builds the default persisted global configuration.
574
+ * @details Populates documented cross-project static-check checker commands, enabled tools, git automation fields, and notification defaults without any project-local directory or debug fields. Runtime is O(l + c). No filesystem side effects occur.
575
+ * @return {UseReqGlobalConfig} Fresh default global configuration object.
576
+ */
577
+ function getDefaultGlobalConfig(): UseReqGlobalConfig {
578
+ return {
579
+ "static-check": getDefaultGlobalStaticCheckConfig(),
580
+ "enabled-tools": normalizeEnabledPiUsereqTools(undefined),
581
+ AUTO_GIT_COMMIT: DEFAULT_AUTO_GIT_COMMIT,
582
+ GIT_WORKTREE_ENABLED: DEFAULT_GIT_WORKTREE_ENABLED,
583
+ GIT_WORKTREE_PREFIX: DEFAULT_GIT_WORKTREE_PREFIX,
410
584
  "notify-enabled": false,
411
585
  "notify-on-completed": true,
412
586
  "notify-on-interrupted": false,
@@ -432,6 +606,96 @@ export function getDefaultConfig(_projectBase: string): UseReqConfig {
432
606
  };
433
607
  }
434
608
 
609
+ /**
610
+ * @brief Merges persisted local and global configuration scopes into the effective runtime config.
611
+ * @details Normalizes local directories, combines local static-check enable flags with global checker arrays, resolves effective worktree disablement when automatic git commit is off, normalizes debug and notification fields, and disables Pushover until both credentials are populated. Runtime is O(l + c + p). No external state is mutated.
612
+ * @param[in] localConfig {UseReqLocalConfig} Persisted local configuration.
613
+ * @param[in] globalConfig {UseReqGlobalConfig} Persisted global configuration.
614
+ * @return {UseReqConfig} Effective merged configuration.
615
+ */
616
+ function mergeConfigScopes(
617
+ localConfig: UseReqLocalConfig,
618
+ globalConfig: UseReqGlobalConfig,
619
+ ): UseReqConfig {
620
+ const docsDir = normalizeRelativeDirContract(localConfig["docs-dir"]) || DEFAULT_DOCS_DIR;
621
+ const testsDir = normalizeRelativeDirContract(localConfig["tests-dir"]) || DEFAULT_TESTS_DIR;
622
+ const srcDir = localConfig["src-dir"]
623
+ .map((entry) => normalizeRelativeDirContract(entry))
624
+ .filter((entry) => entry !== "");
625
+ const autoGitCommit = normalizeAutoGitCommit(globalConfig.AUTO_GIT_COMMIT);
626
+ const gitWorktreeEnabled = resolveEffectiveGitWorktreeEnabled(
627
+ autoGitCommit,
628
+ normalizeGitWorktreeEnabled(globalConfig.GIT_WORKTREE_ENABLED),
629
+ );
630
+ const pushoverUserKey = normalizePiNotifyPushoverCredential(globalConfig["notify-pushover-user-key"]);
631
+ const pushoverApiToken = normalizePiNotifyPushoverCredential(globalConfig["notify-pushover-api-token"]);
632
+ const pushoverEnabled = globalConfig["notify-pushover-enabled"] === true
633
+ && hasPiNotifyPushoverCredentials({
634
+ "notify-pushover-user-key": pushoverUserKey,
635
+ "notify-pushover-api-token": pushoverApiToken,
636
+ });
637
+ return {
638
+ "docs-dir": docsDir,
639
+ "tests-dir": testsDir,
640
+ "src-dir": srcDir.length > 0 ? srcDir : [...DEFAULT_SRC_DIRS],
641
+ "static-check": mergeStaticCheckConfig(localConfig["static-check"], globalConfig["static-check"]),
642
+ "enabled-tools": normalizeEnabledPiUsereqTools(globalConfig["enabled-tools"]),
643
+ AUTO_GIT_COMMIT: autoGitCommit,
644
+ GIT_WORKTREE_ENABLED: gitWorktreeEnabled,
645
+ GIT_WORKTREE_PREFIX: normalizeGitWorktreePrefix(globalConfig.GIT_WORKTREE_PREFIX),
646
+ DEBUG_ENABLED: normalizeDebugEnabled(localConfig.DEBUG_ENABLED),
647
+ DEBUG_LOG_FILE: normalizeDebugLogFile(localConfig.DEBUG_LOG_FILE),
648
+ DEBUG_STATUS_CHANGES: normalizeDebugStatusChanges(localConfig.DEBUG_STATUS_CHANGES),
649
+ DEBUG_WORKFLOW_EVENTS: normalizeDebugWorkflowEvents(localConfig.DEBUG_WORKFLOW_EVENTS),
650
+ DEBUG_LOG_ON_STATUS: normalizeDebugLogOnStatus(localConfig.DEBUG_LOG_ON_STATUS),
651
+ DEBUG_ENABLED_TOOLS: normalizeDebugEnabledTools(localConfig.DEBUG_ENABLED_TOOLS),
652
+ DEBUG_ENABLED_PROMPTS: normalizeDebugEnabledPrompts(localConfig.DEBUG_ENABLED_PROMPTS),
653
+ "notify-enabled": globalConfig["notify-enabled"] === true,
654
+ "notify-on-completed": globalConfig["notify-on-completed"] !== false,
655
+ "notify-on-interrupted": globalConfig["notify-on-interrupted"] === true,
656
+ "notify-on-failed": globalConfig["notify-on-failed"] === true,
657
+ "notify-sound": normalizePiNotifySoundLevel(globalConfig["notify-sound"]),
658
+ "notify-sound-on-completed": globalConfig["notify-sound-on-completed"] !== false,
659
+ "notify-sound-on-interrupted": globalConfig["notify-sound-on-interrupted"] === true,
660
+ "notify-sound-on-failed": globalConfig["notify-sound-on-failed"] === true,
661
+ "notify-sound-toggle-shortcut": normalizePiNotifyShortcut(globalConfig["notify-sound-toggle-shortcut"]),
662
+ "notify-pushover-enabled": pushoverEnabled,
663
+ "notify-pushover-on-completed": globalConfig["notify-pushover-on-completed"] !== false,
664
+ "notify-pushover-on-interrupted": globalConfig["notify-pushover-on-interrupted"] === true,
665
+ "notify-pushover-on-failed": globalConfig["notify-pushover-on-failed"] === true,
666
+ "notify-pushover-user-key": pushoverUserKey,
667
+ "notify-pushover-api-token": pushoverApiToken,
668
+ "notify-pushover-priority": normalizePiNotifyPushoverPriority(globalConfig["notify-pushover-priority"]),
669
+ "notify-pushover-title": normalizePiNotifyTemplateValue(
670
+ globalConfig["notify-pushover-title"],
671
+ DEFAULT_PI_NOTIFY_PUSHOVER_TITLE,
672
+ ),
673
+ "notify-pushover-text": normalizePiNotifyTemplateValue(
674
+ globalConfig["notify-pushover-text"],
675
+ DEFAULT_PI_NOTIFY_PUSHOVER_TEXT,
676
+ ),
677
+ PI_NOTIFY_CMD: normalizePiNotifyCommand(globalConfig.PI_NOTIFY_CMD, DEFAULT_PI_NOTIFY_CMD),
678
+ PI_NOTIFY_SOUND_LOW_CMD: normalizePiNotifyCommand(globalConfig.PI_NOTIFY_SOUND_LOW_CMD, DEFAULT_PI_NOTIFY_SOUND_LOW_CMD),
679
+ PI_NOTIFY_SOUND_MID_CMD: normalizePiNotifyCommand(globalConfig.PI_NOTIFY_SOUND_MID_CMD, DEFAULT_PI_NOTIFY_SOUND_MID_CMD),
680
+ PI_NOTIFY_SOUND_HIGH_CMD: normalizePiNotifyCommand(globalConfig.PI_NOTIFY_SOUND_HIGH_CMD, DEFAULT_PI_NOTIFY_SOUND_HIGH_CMD),
681
+ };
682
+ }
683
+
684
+ /**
685
+ * @brief Builds the default effective configuration.
686
+ * @details Composes documented local and global defaults, then merges them into the effective runtime config consumed by CLI and extension code. Time complexity is O(l + c). No filesystem side effects occur.
687
+ * @param[in] _projectBase {string} Absolute project root path retained for stable call sites.
688
+ * @return {UseReqConfig} Fresh default effective configuration object.
689
+ * @satisfies CTN-001, CTN-012, CTN-013, CTN-018, REQ-066, REQ-137, REQ-146, REQ-163, REQ-174, REQ-178, REQ-184, REQ-185, REQ-196, REQ-204, REQ-205, REQ-212, REQ-236, REQ-237, REQ-238, REQ-239, REQ-249, REQ-250, REQ-251, REQ-252, REQ-277, REQ-315, REQ-316
690
+ */
691
+ export function getDefaultConfig(_projectBase: string): UseReqConfig {
692
+ const globalConfig = getDefaultGlobalConfig();
693
+ return mergeConfigScopes(
694
+ getDefaultLocalConfig(globalConfig["static-check"]),
695
+ globalConfig,
696
+ );
697
+ }
698
+
435
699
  /**
436
700
  * @brief Normalizes one raw checker-entry array from persisted config.
437
701
  * @details Accepts only object entries with a non-empty module string, trims optional command text, filters blank params, and drops malformed records without applying any legacy schema migrations. Runtime is O(c + p). No external state is mutated.
@@ -461,58 +725,118 @@ function normalizeStaticCheckEntries(value: unknown): StaticCheckEntry[] {
461
725
  }
462
726
 
463
727
  /**
464
- * @brief Normalizes the persisted per-language static-check configuration map.
465
- * @details Accepts only object-valued language entries using the new `{ enabled, checkers }` schema, normalizes missing or invalid `enabled` values from checker-list presence, and drops malformed or legacy non-object language payloads without migration. Runtime is O(l + c + p). No external state is mutated.
466
- * @param[in] value {unknown} Candidate persisted static-check payload.
467
- * @return {Record<string, StaticCheckLanguageConfig>} Normalized per-language static-check map.
468
- * @satisfies REQ-249
728
+ * @brief Reads and validates one persisted config payload.
729
+ * @details Returns `undefined` when the target file does not exist. Otherwise parses UTF-8 JSON, rejects array or primitive payloads, and surfaces deterministic `ReqError` diagnostics keyed by the exact path. Runtime is O(n) in file size. Side effects are limited to filesystem reads.
730
+ * @param[in] configPath {string} Absolute config file path.
731
+ * @return {Record<string, unknown> | undefined} Parsed object payload or `undefined` when the file is absent.
732
+ * @throws {ReqError} Throws with exit code `11` when the config file contains invalid JSON or a non-object payload.
733
+ */
734
+ function readConfigPayload(configPath: string): Record<string, unknown> | undefined {
735
+ if (!fs.existsSync(configPath)) {
736
+ return undefined;
737
+ }
738
+
739
+ let payload: unknown;
740
+ try {
741
+ payload = JSON.parse(fs.readFileSync(configPath, "utf8"));
742
+ } catch {
743
+ throw new ReqError(`Error: invalid ${configPath}`, 11);
744
+ }
745
+
746
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
747
+ throw new ReqError(`Error: invalid ${configPath}`, 11);
748
+ }
749
+ return payload as Record<string, unknown>;
750
+ }
751
+
752
+ /**
753
+ * @brief Normalizes the persisted local static-check enable map.
754
+ * @details Starts from the supplied default enable map, accepts only object-valued language entries, reads only `enabled`, and ignores misplaced checker arrays or legacy non-object payloads without migration. Runtime is O(l). No external state is mutated.
755
+ * @param[in] value {unknown} Candidate persisted local static-check payload.
756
+ * @param[in] defaultConfig {Record<string, LocalStaticCheckLanguageConfig>} Default enable map derived from global checker definitions.
757
+ * @return {Record<string, LocalStaticCheckLanguageConfig>} Normalized local static-check enable map.
758
+ * @satisfies REQ-249, REQ-316
469
759
  */
470
- function normalizeStaticCheckConfig(
760
+ function normalizeLocalStaticCheckConfig(
471
761
  value: unknown,
472
- ): Record<string, StaticCheckLanguageConfig> {
762
+ defaultConfig: Record<string, LocalStaticCheckLanguageConfig>,
763
+ ): Record<string, LocalStaticCheckLanguageConfig> {
764
+ const config: Record<string, LocalStaticCheckLanguageConfig> = Object.fromEntries(
765
+ Object.entries(defaultConfig).map(([language, languageConfig]) => [
766
+ language,
767
+ createLocalStaticCheckLanguageConfig(languageConfig.enabled),
768
+ ]),
769
+ );
473
770
  if (!value || typeof value !== "object" || Array.isArray(value)) {
474
- return {};
771
+ return config;
475
772
  }
476
- const config: Record<string, StaticCheckLanguageConfig> = {};
477
773
  for (const [language, languageValue] of Object.entries(value as Record<string, unknown>)) {
478
774
  if (!languageValue || typeof languageValue !== "object" || Array.isArray(languageValue)) {
479
775
  continue;
480
776
  }
481
777
  const languageRecord = languageValue as Record<string, unknown>;
482
- const checkers = normalizeStaticCheckEntries(languageRecord.checkers);
483
- config[language] = createStaticCheckLanguageConfig(
484
- checkers,
485
- normalizeStaticCheckEnabled(languageRecord.enabled, checkers.length > 0 ? "enable" : "disable"),
778
+ const defaultEnabled = config[language]?.enabled ?? "disable";
779
+ config[language] = createLocalStaticCheckLanguageConfig(
780
+ normalizeStaticCheckEnabled(languageRecord.enabled, defaultEnabled),
486
781
  );
487
782
  }
488
783
  return config;
489
784
  }
490
785
 
491
786
  /**
492
- * @brief Loads and sanitizes the persisted project configuration.
493
- * @details Returns defaults when the config file does not exist. Otherwise parses JSON, validates directory and per-language static-check object shapes, normalizes enabled tool names plus prompt-command worktree, debug, notify, sound, and Pushover fields including dedicated workflow-event logging, preserves non-empty template text verbatim, forces the effective worktree mode off when automatic git commit is disabled, forces effective Pushover disablement until both credentials are populated, applies documented per-flag defaults for missing payloads, and ignores removed, malformed, or runtime-derived path metadata without legacy schema migration. Runtime is O(n) in config size. Side effects are limited to filesystem reads.
494
- * @param[in] projectBase {string} Absolute project root path.
495
- * @return {UseReqConfig} Sanitized effective configuration.
496
- * @throws {ReqError} Throws with exit code `11` when the config file contains invalid JSON or a non-object payload.
497
- * @satisfies CTN-012, CTN-013, REQ-066, REQ-146, REQ-163, REQ-174, REQ-178, REQ-184, REQ-185, REQ-196, REQ-204, REQ-205, REQ-212, REQ-215, REQ-234, REQ-235, REQ-236, REQ-237, REQ-238, REQ-239, REQ-249, REQ-277
787
+ * @brief Normalizes the persisted global static-check checker map.
788
+ * @details Starts from documented global checker defaults, accepts only object-valued language entries, reads only `checkers`, and ignores misplaced local enable flags or legacy non-object payloads without migration. Runtime is O(l + c + p). No external state is mutated.
789
+ * @param[in] value {unknown} Candidate persisted global static-check payload.
790
+ * @return {Record<string, GlobalStaticCheckLanguageConfig>} Normalized global static-check checker map.
791
+ * @satisfies REQ-249, REQ-250, REQ-251, REQ-252
498
792
  */
499
- export function loadConfig(projectBase: string): UseReqConfig {
500
- const configPath = getProjectConfigPath(projectBase);
501
- if (!fs.existsSync(configPath)) {
502
- return getDefaultConfig(projectBase);
793
+ function normalizeGlobalStaticCheckConfig(
794
+ value: unknown,
795
+ ): Record<string, GlobalStaticCheckLanguageConfig> {
796
+ const config = getDefaultGlobalStaticCheckConfig();
797
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
798
+ return config;
503
799
  }
504
-
505
- let payload: unknown;
506
- try {
507
- payload = JSON.parse(fs.readFileSync(configPath, "utf8"));
508
- } catch (error) {
509
- throw new ReqError(`Error: invalid ${configPath}`, 11);
800
+ for (const [language, languageValue] of Object.entries(value as Record<string, unknown>)) {
801
+ if (!languageValue || typeof languageValue !== "object" || Array.isArray(languageValue)) {
802
+ continue;
803
+ }
804
+ const languageRecord = languageValue as Record<string, unknown>;
805
+ config[language] = createGlobalStaticCheckLanguageConfig(
806
+ normalizeStaticCheckEntries(languageRecord.checkers),
807
+ );
510
808
  }
809
+ return config;
810
+ }
511
811
 
512
- if (!payload || typeof payload !== "object") {
513
- throw new ReqError(`Error: invalid ${configPath}`, 11);
812
+ /**
813
+ * @brief Loads and sanitizes the persisted local configuration.
814
+ * @details Returns defaults when `<base-path>/.pi-usereq.json` is absent. Otherwise parses the local JSON payload, normalizes project-scoped directory, debug, and static-check enable fields, and ignores misplaced global keys without migration. Runtime is O(n) in file size. Side effects are limited to filesystem reads.
815
+ * @param[in] projectBase {string} Absolute project root path.
816
+ * @param[in] defaultStaticCheckConfig {Record<string, LocalStaticCheckLanguageConfig>} Local static-check enable defaults derived from the current global checker map.
817
+ * @return {UseReqLocalConfig} Sanitized local configuration.
818
+ */
819
+ function loadLocalConfig(
820
+ projectBase: string,
821
+ defaultStaticCheckConfig: Record<string, LocalStaticCheckLanguageConfig>,
822
+ ): UseReqLocalConfig {
823
+ const configPath = getProjectConfigPath(projectBase);
824
+ const data = readConfigPayload(configPath);
825
+ if (!data) {
826
+ return {
827
+ "docs-dir": DEFAULT_DOCS_DIR,
828
+ "tests-dir": DEFAULT_TESTS_DIR,
829
+ "src-dir": [...DEFAULT_SRC_DIRS],
830
+ "static-check": normalizeLocalStaticCheckConfig(undefined, defaultStaticCheckConfig),
831
+ DEBUG_ENABLED: DEFAULT_DEBUG_ENABLED,
832
+ DEBUG_LOG_FILE: DEFAULT_DEBUG_LOG_FILE,
833
+ DEBUG_STATUS_CHANGES: DEFAULT_DEBUG_STATUS_CHANGES,
834
+ DEBUG_WORKFLOW_EVENTS: DEFAULT_DEBUG_WORKFLOW_EVENTS,
835
+ DEBUG_LOG_ON_STATUS: DEFAULT_DEBUG_LOG_ON_STATUS,
836
+ DEBUG_ENABLED_TOOLS: [],
837
+ DEBUG_ENABLED_PROMPTS: [],
838
+ };
514
839
  }
515
- const data = payload as Record<string, unknown>;
516
840
  const docsDirCandidate = typeof data["docs-dir"] === "string"
517
841
  ? normalizeRelativeDirContract(data["docs-dir"])
518
842
  : "";
@@ -525,134 +849,112 @@ export function loadConfig(projectBase: string): UseReqConfig {
525
849
  .map((item) => normalizeRelativeDirContract(item))
526
850
  .filter((item) => item !== "")
527
851
  : [];
528
- const docsDir = docsDirCandidate || DEFAULT_DOCS_DIR;
529
- const testsDir = testsDirCandidate || DEFAULT_TESTS_DIR;
530
- const srcDir = srcDirCandidate.length > 0 ? srcDirCandidate : [...DEFAULT_SRC_DIRS];
531
- const staticCheck = normalizeStaticCheckConfig(data["static-check"]);
532
- const enabledTools = normalizeEnabledPiUsereqTools(data["enabled-tools"]);
533
- const autoGitCommit = normalizeAutoGitCommit(data.AUTO_GIT_COMMIT);
534
- const gitWorktreeEnabled = resolveEffectiveGitWorktreeEnabled(
535
- autoGitCommit,
536
- normalizeGitWorktreeEnabled(data.GIT_WORKTREE_ENABLED),
537
- );
538
- const gitWorktreePrefix = normalizeGitWorktreePrefix(data.GIT_WORKTREE_PREFIX);
539
- const debugEnabled = normalizeDebugEnabled(data.DEBUG_ENABLED);
540
- const debugLogFile = normalizeDebugLogFile(data.DEBUG_LOG_FILE);
541
- const debugStatusChanges = normalizeDebugStatusChanges(data.DEBUG_STATUS_CHANGES);
542
- const debugWorkflowEvents = normalizeDebugWorkflowEvents(data.DEBUG_WORKFLOW_EVENTS);
543
- const debugLogOnStatus = normalizeDebugLogOnStatus(data.DEBUG_LOG_ON_STATUS);
544
- const debugEnabledTools = normalizeDebugEnabledTools(data.DEBUG_ENABLED_TOOLS);
545
- const debugEnabledPrompts = normalizeDebugEnabledPrompts(data.DEBUG_ENABLED_PROMPTS);
546
- const notifyEnabled = data["notify-enabled"] === true;
547
- const notifyOnCompleted = data["notify-on-completed"] !== false;
548
- const notifyOnInterrupted = data["notify-on-interrupted"] === true;
549
- const notifyOnFailed = data["notify-on-failed"] === true;
550
- const notifySound = normalizePiNotifySoundLevel(data["notify-sound"]);
551
- const notifySoundOnCompleted = data["notify-sound-on-completed"] !== false;
552
- const notifySoundOnInterrupted = data["notify-sound-on-interrupted"] === true;
553
- const notifySoundOnFailed = data["notify-sound-on-failed"] === true;
554
- const notifySoundToggleShortcut = normalizePiNotifyShortcut(data["notify-sound-toggle-shortcut"]);
555
- const pushoverOnCompleted = data["notify-pushover-on-completed"] !== false;
556
- const pushoverOnInterrupted = data["notify-pushover-on-interrupted"] === true;
557
- const pushoverOnFailed = data["notify-pushover-on-failed"] === true;
558
- const pushoverUserKey = normalizePiNotifyPushoverCredential(data["notify-pushover-user-key"]);
559
- const pushoverApiToken = normalizePiNotifyPushoverCredential(data["notify-pushover-api-token"]);
560
- const pushoverEnabled = data["notify-pushover-enabled"] === true
561
- && hasPiNotifyPushoverCredentials({
562
- "notify-pushover-user-key": pushoverUserKey,
563
- "notify-pushover-api-token": pushoverApiToken,
564
- });
565
- const pushoverPriority = normalizePiNotifyPushoverPriority(data["notify-pushover-priority"]);
566
- const pushoverTitle = normalizePiNotifyTemplateValue(
567
- data["notify-pushover-title"],
568
- DEFAULT_PI_NOTIFY_PUSHOVER_TITLE,
569
- );
570
- const pushoverText = normalizePiNotifyTemplateValue(
571
- data["notify-pushover-text"],
572
- DEFAULT_PI_NOTIFY_PUSHOVER_TEXT,
573
- );
574
- const notifyCommand = normalizePiNotifyCommand(data.PI_NOTIFY_CMD, DEFAULT_PI_NOTIFY_CMD);
575
- const lowSoundCommand = normalizePiNotifyCommand(data.PI_NOTIFY_SOUND_LOW_CMD, DEFAULT_PI_NOTIFY_SOUND_LOW_CMD);
576
- const midSoundCommand = normalizePiNotifyCommand(data.PI_NOTIFY_SOUND_MID_CMD, DEFAULT_PI_NOTIFY_SOUND_MID_CMD);
577
- const highSoundCommand = normalizePiNotifyCommand(data.PI_NOTIFY_SOUND_HIGH_CMD, DEFAULT_PI_NOTIFY_SOUND_HIGH_CMD);
852
+ return {
853
+ "docs-dir": docsDirCandidate || DEFAULT_DOCS_DIR,
854
+ "tests-dir": testsDirCandidate || DEFAULT_TESTS_DIR,
855
+ "src-dir": srcDirCandidate.length > 0 ? srcDirCandidate : [...DEFAULT_SRC_DIRS],
856
+ "static-check": normalizeLocalStaticCheckConfig(data["static-check"], defaultStaticCheckConfig),
857
+ DEBUG_ENABLED: normalizeDebugEnabled(data.DEBUG_ENABLED),
858
+ DEBUG_LOG_FILE: normalizeDebugLogFile(data.DEBUG_LOG_FILE),
859
+ DEBUG_STATUS_CHANGES: normalizeDebugStatusChanges(data.DEBUG_STATUS_CHANGES),
860
+ DEBUG_WORKFLOW_EVENTS: normalizeDebugWorkflowEvents(data.DEBUG_WORKFLOW_EVENTS),
861
+ DEBUG_LOG_ON_STATUS: normalizeDebugLogOnStatus(data.DEBUG_LOG_ON_STATUS),
862
+ DEBUG_ENABLED_TOOLS: normalizeDebugEnabledTools(data.DEBUG_ENABLED_TOOLS),
863
+ DEBUG_ENABLED_PROMPTS: normalizeDebugEnabledPrompts(data.DEBUG_ENABLED_PROMPTS),
864
+ };
865
+ }
578
866
 
867
+ /**
868
+ * @brief Loads and sanitizes the persisted global configuration.
869
+ * @details Returns defaults when `~/.config/pi-usereq/config.json` is absent. Otherwise parses the global JSON payload, normalizes cross-project checker, tool, git, and notification fields, and ignores misplaced local keys without migration. Runtime is O(n) in file size. Side effects are limited to filesystem reads.
870
+ * @return {UseReqGlobalConfig} Sanitized global configuration.
871
+ */
872
+ function loadGlobalConfig(): UseReqGlobalConfig {
873
+ const configPath = getGlobalConfigPath();
874
+ const data = readConfigPayload(configPath);
875
+ if (!data) {
876
+ return getDefaultGlobalConfig();
877
+ }
579
878
  return {
580
- "docs-dir": docsDir,
581
- "tests-dir": testsDir,
582
- "src-dir": srcDir,
583
- "static-check": staticCheck,
584
- "enabled-tools": enabledTools,
585
- AUTO_GIT_COMMIT: autoGitCommit,
586
- GIT_WORKTREE_ENABLED: gitWorktreeEnabled,
587
- GIT_WORKTREE_PREFIX: gitWorktreePrefix,
588
- DEBUG_ENABLED: debugEnabled,
589
- DEBUG_LOG_FILE: debugLogFile,
590
- DEBUG_STATUS_CHANGES: debugStatusChanges,
591
- DEBUG_WORKFLOW_EVENTS: debugWorkflowEvents,
592
- DEBUG_LOG_ON_STATUS: debugLogOnStatus,
593
- DEBUG_ENABLED_TOOLS: debugEnabledTools,
594
- DEBUG_ENABLED_PROMPTS: debugEnabledPrompts,
595
- "notify-enabled": notifyEnabled,
596
- "notify-on-completed": notifyOnCompleted,
597
- "notify-on-interrupted": notifyOnInterrupted,
598
- "notify-on-failed": notifyOnFailed,
599
- "notify-sound": notifySound,
600
- "notify-sound-on-completed": notifySoundOnCompleted,
601
- "notify-sound-on-interrupted": notifySoundOnInterrupted,
602
- "notify-sound-on-failed": notifySoundOnFailed,
603
- "notify-sound-toggle-shortcut": notifySoundToggleShortcut,
604
- "notify-pushover-enabled": pushoverEnabled,
605
- "notify-pushover-on-completed": pushoverOnCompleted,
606
- "notify-pushover-on-interrupted": pushoverOnInterrupted,
607
- "notify-pushover-on-failed": pushoverOnFailed,
608
- "notify-pushover-user-key": pushoverUserKey,
609
- "notify-pushover-api-token": pushoverApiToken,
610
- "notify-pushover-priority": pushoverPriority,
611
- "notify-pushover-title": pushoverTitle,
612
- "notify-pushover-text": pushoverText,
613
- PI_NOTIFY_CMD: notifyCommand,
614
- PI_NOTIFY_SOUND_LOW_CMD: lowSoundCommand,
615
- PI_NOTIFY_SOUND_MID_CMD: midSoundCommand,
616
- PI_NOTIFY_SOUND_HIGH_CMD: highSoundCommand,
879
+ "static-check": normalizeGlobalStaticCheckConfig(data["static-check"]),
880
+ "enabled-tools": normalizeEnabledPiUsereqTools(data["enabled-tools"]),
881
+ AUTO_GIT_COMMIT: normalizeAutoGitCommit(data.AUTO_GIT_COMMIT),
882
+ GIT_WORKTREE_ENABLED: normalizeGitWorktreeEnabled(data.GIT_WORKTREE_ENABLED),
883
+ GIT_WORKTREE_PREFIX: normalizeGitWorktreePrefix(data.GIT_WORKTREE_PREFIX),
884
+ "notify-enabled": data["notify-enabled"] === true,
885
+ "notify-on-completed": data["notify-on-completed"] !== false,
886
+ "notify-on-interrupted": data["notify-on-interrupted"] === true,
887
+ "notify-on-failed": data["notify-on-failed"] === true,
888
+ "notify-sound": normalizePiNotifySoundLevel(data["notify-sound"]),
889
+ "notify-sound-on-completed": data["notify-sound-on-completed"] !== false,
890
+ "notify-sound-on-interrupted": data["notify-sound-on-interrupted"] === true,
891
+ "notify-sound-on-failed": data["notify-sound-on-failed"] === true,
892
+ "notify-sound-toggle-shortcut": normalizePiNotifyShortcut(data["notify-sound-toggle-shortcut"]),
893
+ "notify-pushover-enabled": data["notify-pushover-enabled"] === true,
894
+ "notify-pushover-on-completed": data["notify-pushover-on-completed"] !== false,
895
+ "notify-pushover-on-interrupted": data["notify-pushover-on-interrupted"] === true,
896
+ "notify-pushover-on-failed": data["notify-pushover-on-failed"] === true,
897
+ "notify-pushover-user-key": normalizePiNotifyPushoverCredential(data["notify-pushover-user-key"]),
898
+ "notify-pushover-api-token": normalizePiNotifyPushoverCredential(data["notify-pushover-api-token"]),
899
+ "notify-pushover-priority": normalizePiNotifyPushoverPriority(data["notify-pushover-priority"]),
900
+ "notify-pushover-title": normalizePiNotifyTemplateValue(
901
+ data["notify-pushover-title"],
902
+ DEFAULT_PI_NOTIFY_PUSHOVER_TITLE,
903
+ ),
904
+ "notify-pushover-text": normalizePiNotifyTemplateValue(
905
+ data["notify-pushover-text"],
906
+ DEFAULT_PI_NOTIFY_PUSHOVER_TEXT,
907
+ ),
908
+ PI_NOTIFY_CMD: normalizePiNotifyCommand(data.PI_NOTIFY_CMD, DEFAULT_PI_NOTIFY_CMD),
909
+ PI_NOTIFY_SOUND_LOW_CMD: normalizePiNotifyCommand(data.PI_NOTIFY_SOUND_LOW_CMD, DEFAULT_PI_NOTIFY_SOUND_LOW_CMD),
910
+ PI_NOTIFY_SOUND_MID_CMD: normalizePiNotifyCommand(data.PI_NOTIFY_SOUND_MID_CMD, DEFAULT_PI_NOTIFY_SOUND_MID_CMD),
911
+ PI_NOTIFY_SOUND_HIGH_CMD: normalizePiNotifyCommand(data.PI_NOTIFY_SOUND_HIGH_CMD, DEFAULT_PI_NOTIFY_SOUND_HIGH_CMD),
617
912
  };
618
913
  }
619
914
 
620
915
  /**
621
- * @brief Builds the persisted configuration payload that excludes runtime-derived fields.
622
- * @details Copies only the canonical persisted configuration keys into a fresh object so runtime-derived metadata such as `base-path` and `git-path` can never be written to disk, serializes per-language static-check objects with key order `enabled` then `checkers`, normalizes `GIT_WORKTREE_PREFIX` plus debug fields including dedicated workflow-event logging, forces persisted worktree disablement when automatic git commit is disabled, forces persisted Pushover disablement until both credentials are populated, and preserves the remaining notification and Pushover settings. Runtime is O(n) in config size. No external state is mutated.
916
+ * @brief Loads and sanitizes the effective merged configuration.
917
+ * @details Loads global configuration first so local static-check enable defaults can be derived from the active global checker map, then merges both scopes into the effective runtime config without applying legacy single-file migrations. Runtime is O(n) in combined local and global config size. Side effects are limited to filesystem reads.
918
+ * @param[in] projectBase {string} Absolute project root path.
919
+ * @return {UseReqConfig} Sanitized effective configuration.
920
+ * @throws {ReqError} Throws with exit code `11` when either persisted config file contains invalid JSON or a non-object payload.
921
+ * @satisfies CTN-012, CTN-013, CTN-018, REQ-066, REQ-137, REQ-146, REQ-163, REQ-174, REQ-178, REQ-184, REQ-185, REQ-196, REQ-204, REQ-205, REQ-212, REQ-215, REQ-234, REQ-235, REQ-236, REQ-237, REQ-238, REQ-239, REQ-249, REQ-277, REQ-315, REQ-316
922
+ */
923
+ export function loadConfig(projectBase: string): UseReqConfig {
924
+ const globalConfig = loadGlobalConfig();
925
+ const localConfig = loadLocalConfig(
926
+ projectBase,
927
+ getDefaultLocalStaticCheckConfig(globalConfig["static-check"]),
928
+ );
929
+ return mergeConfigScopes(localConfig, globalConfig);
930
+ }
931
+
932
+ /**
933
+ * @brief Builds the persisted local configuration payload.
934
+ * @details Copies only project-scoped keys into a fresh object so runtime-derived metadata plus global checker, tool, git, and notification fields never reach `.pi-usereq.json`. Runtime is O(n) in config size. No external state is mutated.
623
935
  * @param[in] config {UseReqConfig} Effective configuration object.
624
- * @return {UseReqConfig} Persistable configuration payload.
625
- * @satisfies CTN-012, CTN-013, REQ-146, REQ-163, REQ-204, REQ-205, REQ-212, REQ-215, REQ-234, REQ-236, REQ-237, REQ-238, REQ-239, REQ-249, REQ-277
936
+ * @return {UseReqLocalConfig} Persistable local configuration payload.
937
+ * @satisfies CTN-012, CTN-013, REQ-104, REQ-146, REQ-249, REQ-316, REQ-277
626
938
  */
627
- function buildPersistedConfig(config: UseReqConfig): UseReqConfig {
939
+ function buildPersistedLocalConfig(config: UseReqConfig): UseReqLocalConfig {
940
+ const normalizedSrcDir = config["src-dir"]
941
+ .map((entry) => normalizeRelativeDirContract(entry))
942
+ .filter((entry) => entry !== "");
628
943
  return {
629
944
  "docs-dir": normalizeRelativeDirContract(config["docs-dir"]) || DEFAULT_DOCS_DIR,
630
945
  "tests-dir": normalizeRelativeDirContract(config["tests-dir"]) || DEFAULT_TESTS_DIR,
631
- "src-dir": (() => {
632
- const normalizedSrcDir = config["src-dir"]
633
- .map((entry) => normalizeRelativeDirContract(entry))
634
- .filter((entry) => entry !== "");
635
- return normalizedSrcDir.length > 0 ? normalizedSrcDir : [...DEFAULT_SRC_DIRS];
636
- })(),
946
+ "src-dir": normalizedSrcDir.length > 0 ? normalizedSrcDir : [...DEFAULT_SRC_DIRS],
637
947
  "static-check": Object.fromEntries(
638
948
  Object.entries(config["static-check"]).map(([language, languageConfig]) => [
639
949
  language,
640
- {
641
- enabled: normalizeStaticCheckEnabled(
950
+ createLocalStaticCheckLanguageConfig(
951
+ normalizeStaticCheckEnabled(
642
952
  languageConfig.enabled,
643
953
  languageConfig.checkers.length > 0 ? "enable" : "disable",
644
954
  ),
645
- checkers: languageConfig.checkers.map(cloneStaticCheckEntry),
646
- },
955
+ ),
647
956
  ]),
648
957
  ),
649
- "enabled-tools": [...config["enabled-tools"]],
650
- AUTO_GIT_COMMIT: normalizeAutoGitCommit(config.AUTO_GIT_COMMIT),
651
- GIT_WORKTREE_ENABLED: resolveEffectiveGitWorktreeEnabled(
652
- normalizeAutoGitCommit(config.AUTO_GIT_COMMIT),
653
- config.GIT_WORKTREE_ENABLED,
654
- ),
655
- GIT_WORKTREE_PREFIX: normalizeGitWorktreePrefix(config.GIT_WORKTREE_PREFIX),
656
958
  DEBUG_ENABLED: normalizeDebugEnabled(config.DEBUG_ENABLED),
657
959
  DEBUG_LOG_FILE: normalizeDebugLogFile(config.DEBUG_LOG_FILE),
658
960
  DEBUG_STATUS_CHANGES: normalizeDebugStatusChanges(config.DEBUG_STATUS_CHANGES),
@@ -660,6 +962,32 @@ function buildPersistedConfig(config: UseReqConfig): UseReqConfig {
660
962
  DEBUG_LOG_ON_STATUS: normalizeDebugLogOnStatus(config.DEBUG_LOG_ON_STATUS),
661
963
  DEBUG_ENABLED_TOOLS: normalizeDebugEnabledTools(config.DEBUG_ENABLED_TOOLS),
662
964
  DEBUG_ENABLED_PROMPTS: normalizeDebugEnabledPrompts(config.DEBUG_ENABLED_PROMPTS),
965
+ };
966
+ }
967
+
968
+ /**
969
+ * @brief Builds the persisted global configuration payload.
970
+ * @details Copies only cross-project keys into a fresh object so local directory and debug fields never reach `~/.config/pi-usereq/config.json`, while forcing persisted worktree disablement when automatic git commit is disabled and forcing persisted Pushover disablement until both credentials are populated. Runtime is O(n) in config size. No external state is mutated.
971
+ * @param[in] config {UseReqConfig} Effective configuration object.
972
+ * @return {UseReqGlobalConfig} Persistable global configuration payload.
973
+ * @satisfies REQ-137, REQ-163, REQ-174, REQ-178, REQ-184, REQ-196, REQ-204, REQ-205, REQ-212, REQ-234, REQ-249, REQ-315
974
+ */
975
+ function buildPersistedGlobalConfig(config: UseReqConfig): UseReqGlobalConfig {
976
+ const autoGitCommit = normalizeAutoGitCommit(config.AUTO_GIT_COMMIT);
977
+ return {
978
+ "static-check": Object.fromEntries(
979
+ Object.entries(config["static-check"]).map(([language, languageConfig]) => [
980
+ language,
981
+ createGlobalStaticCheckLanguageConfig(languageConfig.checkers),
982
+ ]),
983
+ ),
984
+ "enabled-tools": normalizeEnabledPiUsereqTools(config["enabled-tools"]),
985
+ AUTO_GIT_COMMIT: autoGitCommit,
986
+ GIT_WORKTREE_ENABLED: resolveEffectiveGitWorktreeEnabled(
987
+ autoGitCommit,
988
+ config.GIT_WORKTREE_ENABLED,
989
+ ),
990
+ GIT_WORKTREE_PREFIX: normalizeGitWorktreePrefix(config.GIT_WORKTREE_PREFIX),
663
991
  "notify-enabled": config["notify-enabled"],
664
992
  "notify-on-completed": config["notify-on-completed"],
665
993
  "notify-on-interrupted": config["notify-on-interrupted"],
@@ -669,8 +997,7 @@ function buildPersistedConfig(config: UseReqConfig): UseReqConfig {
669
997
  "notify-sound-on-interrupted": config["notify-sound-on-interrupted"],
670
998
  "notify-sound-on-failed": config["notify-sound-on-failed"],
671
999
  "notify-sound-toggle-shortcut": config["notify-sound-toggle-shortcut"],
672
- "notify-pushover-enabled": config["notify-pushover-enabled"]
673
- && hasPiNotifyPushoverCredentials(config),
1000
+ "notify-pushover-enabled": config["notify-pushover-enabled"] && hasPiNotifyPushoverCredentials(config),
674
1001
  "notify-pushover-on-completed": config["notify-pushover-on-completed"],
675
1002
  "notify-pushover-on-interrupted": config["notify-pushover-on-interrupted"],
676
1003
  "notify-pushover-on-failed": config["notify-pushover-on-failed"],
@@ -687,17 +1014,51 @@ function buildPersistedConfig(config: UseReqConfig): UseReqConfig {
687
1014
  }
688
1015
 
689
1016
  /**
690
- * @brief Persists the project configuration to disk.
691
- * @details Creates the base directory path when necessary, strips runtime-derived fields from the serialized payload, and writes formatted JSON terminated by a newline to `.pi-usereq.json`. Runtime is O(n) in serialized config size. Side effects include directory creation and file overwrite.
1017
+ * @brief Writes one normalized config payload to disk.
1018
+ * @details Creates the parent directory when required, formats JSON with two-space indentation, and terminates the file with a newline. Runtime is O(n) in serialized payload size. Side effects include directory creation and file overwrite.
1019
+ * @param[in] configPath {string} Absolute destination config path.
1020
+ * @param[in] payload {object} Persistable config payload.
1021
+ * @return {void} No return value.
1022
+ */
1023
+ function writeConfigFile(configPath: string, payload: object): void {
1024
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
1025
+ fs.writeFileSync(configPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
1026
+ }
1027
+
1028
+ /**
1029
+ * @brief Persists the local configuration scope to disk.
1030
+ * @details Serializes only project-scoped fields into `<base-path>/.pi-usereq.json`, excluding runtime-derived metadata and every global-scope configuration key. Runtime is O(n) in serialized local config size. Side effects include directory creation and file overwrite.
1031
+ * @param[in] projectBase {string} Absolute project root path.
1032
+ * @param[in] config {UseReqConfig} Effective configuration object to persist.
1033
+ * @return {void} No return value.
1034
+ * @satisfies CTN-012, REQ-104, REQ-146
1035
+ */
1036
+ export function saveLocalConfig(projectBase: string, config: UseReqConfig): void {
1037
+ writeConfigFile(getProjectConfigPath(projectBase), buildPersistedLocalConfig(config));
1038
+ }
1039
+
1040
+ /**
1041
+ * @brief Persists the global configuration scope to disk.
1042
+ * @details Serializes only cross-project fields into `~/.config/pi-usereq/config.json`, excluding every project-local directory and debug setting. Runtime is O(n) in serialized global config size. Side effects include directory creation and file overwrite.
1043
+ * @param[in] config {UseReqConfig} Effective configuration object to persist.
1044
+ * @return {void} No return value.
1045
+ * @satisfies CTN-012, CTN-018, REQ-137, REQ-146, REQ-315
1046
+ */
1047
+ export function saveGlobalConfig(config: UseReqConfig): void {
1048
+ writeConfigFile(getGlobalConfigPath(), buildPersistedGlobalConfig(config));
1049
+ }
1050
+
1051
+ /**
1052
+ * @brief Persists the effective configuration to local and global config files.
1053
+ * @details Splits the effective runtime config into project-scoped and cross-project payloads, then writes both files with normalized JSON formatting. Runtime is O(n) in combined serialized config size. Side effects include directory creation and file overwrite in both persistence locations.
692
1054
  * @param[in] projectBase {string} Absolute project root path.
693
- * @param[in] config {UseReqConfig} Configuration object to persist.
1055
+ * @param[in] config {UseReqConfig} Effective configuration object to persist.
694
1056
  * @return {void} No return value.
695
- * @satisfies CTN-012, REQ-146
1057
+ * @satisfies CTN-012, CTN-018, REQ-137, REQ-146, REQ-315
696
1058
  */
697
1059
  export function saveConfig(projectBase: string, config: UseReqConfig): void {
698
- const configPath = getProjectConfigPath(projectBase);
699
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
700
- fs.writeFileSync(configPath, `${JSON.stringify(buildPersistedConfig(config), null, 2)}\n`, "utf8");
1060
+ saveLocalConfig(projectBase, config);
1061
+ saveGlobalConfig(config);
701
1062
  }
702
1063
 
703
1064
  /**