taskplane 0.24.21 → 0.24.22

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,19 +1,16 @@
1
1
  /**
2
- * Unified config loader for taskplane-config.json with YAML fallback
3
- * and user preferences (Layer 2) merge.
2
+ * Unified config loader for taskplane-config.json with YAML fallback.
4
3
  *
5
- * Layer 1 — Project config precedence:
6
- * 1. `.pi/taskplane-config.json` exists and is valid → use it
7
- * 2. `.pi/taskplane-config.json` exists but malformed → throw with clear error
8
- * 3. `.pi/taskplane-config.json` exists but unsupported configVersion → throw
9
- * 4. JSON absent + one/both YAML files present → read YAML, map to unified shape
10
- * 5. None present → return cloned defaults
4
+ * Effective precedence:
5
+ * 1. Schema defaults (internal)
6
+ * 2. Global preferences (`~/.pi/agent/taskplane/preferences.json`)
7
+ * 3. Project overrides (`taskplane-config.json` or YAML fallback)
11
8
  *
12
- * Layer 2 User preferences:
13
- * After loading Layer 1, reads `~/.pi/agent/taskplane/preferences.json`
14
- * (or `$PI_CODING_AGENT_DIR/taskplane/preferences.json`) and applies
15
- * allowlisted user-scoped fields on top. Unknown keys are ignored.
16
- * Malformed preferences fall back to defaults silently.
9
+ * Project config is treated as sparse overrides. Missing project fields
10
+ * fall through to global preferences, then schema defaults.
11
+ *
12
+ * Global preferences parsing is allowlist-based. Unknown top-level keys are
13
+ * ignored, and malformed preferences fall back to defaults silently.
17
14
  *
18
15
  * Path resolution:
19
16
  * Resolves config paths relative to `configRoot`. Callers should pass
@@ -33,18 +30,17 @@ import {
33
30
  CONFIG_VERSION,
34
31
  PROJECT_CONFIG_FILENAME,
35
32
  DEFAULT_PROJECT_CONFIG,
36
- DEFAULT_TASK_RUNNER_SECTION,
37
- DEFAULT_ORCHESTRATOR_SECTION,
38
- DEFAULT_USER_PREFERENCES,
39
- USER_PREFERENCES_FILENAME,
40
- USER_PREFERENCES_SUBDIR,
33
+ DEFAULT_GLOBAL_PREFERENCES,
34
+ DEFAULT_BOOTSTRAP_GLOBAL_PREFERENCES,
35
+ GLOBAL_PREFERENCES_FILENAME,
36
+ GLOBAL_PREFERENCES_SUBDIR,
41
37
  } from "./config-schema.ts";
42
38
  import type {
43
39
  TaskplaneConfig,
44
40
  TaskRunnerSection,
45
41
  OrchestratorSection,
46
42
  WorkspaceSectionConfig,
47
- UserPreferences,
43
+ GlobalPreferences,
48
44
  } from "./config-schema.ts";
49
45
 
50
46
 
@@ -154,102 +150,36 @@ function normalizeInheritanceAliases(config: TaskplaneConfig): void {
154
150
  let _projectMigrationDone = false;
155
151
 
156
152
  /**
157
- * Auto-migrate legacy TMUX fields in project config.
158
- *
159
- * Precedence: if both `sessionPrefix` and `tmuxPrefix` exist, the new
160
- * key (`sessionPrefix`) wins. `tmuxPrefix` is only used when `sessionPrefix`
161
- * is absent. This matches the principle that explicit new-format config
162
- * takes priority over legacy fields.
163
- *
164
- * Writes back to disk atomically (tmp + rename) on first migration.
165
- * Idempotent — safe to call multiple times per load cycle (skips after first).
166
- *
167
- * @returns true if any migrations were applied
168
- */
169
- function migrateProjectConfig(config: TaskplaneConfig, configRoot: string): boolean {
170
- if (_projectMigrationDone) return false;
171
-
172
- let migrated = false;
173
- const orchestratorCore = config.orchestrator?.orchestrator as Record<string, unknown> | undefined;
174
- if (orchestratorCore && hasOwn(orchestratorCore, "tmuxPrefix")) {
175
- // Use tmuxPrefix if sessionPrefix is absent, undefined, or still the default.
176
- // An explicit non-default sessionPrefix takes priority over legacy tmuxPrefix.
177
- const currentPrefix = orchestratorCore.sessionPrefix;
178
- const isDefault = currentPrefix === undefined || currentPrefix === "orch";
179
- if (isDefault) {
180
- (orchestratorCore as any).sessionPrefix = orchestratorCore.tmuxPrefix;
181
- }
182
- delete orchestratorCore.tmuxPrefix;
183
- console.error(`[taskplane] Auto-migrated: orchestrator.orchestrator.tmuxPrefix → sessionPrefix`);
184
- migrated = true;
185
- }
186
- if (orchestratorCore?.spawnMode === "tmux") {
187
- (orchestratorCore as any).spawnMode = "subprocess";
188
- console.error(`[taskplane] Auto-migrated: orchestrator.orchestrator.spawnMode "tmux" → "subprocess"`);
189
- migrated = true;
190
- }
191
- const workerConfig = config.taskRunner?.worker as Record<string, unknown> | undefined;
192
- if (workerConfig?.spawnMode === "tmux") {
193
- (workerConfig as any).spawnMode = "subprocess";
194
- console.error(`[taskplane] Auto-migrated: taskRunner.worker.spawnMode "tmux" → "subprocess"`);
195
- migrated = true;
196
- }
197
-
198
- if (migrated) {
199
- // Write back atomically (tmp + rename) to prevent corruption
200
- try {
201
- const jsonPath = join(configRoot, ".pi", "taskplane-config.json");
202
- if (existsSync(jsonPath)) {
203
- const raw = JSON.parse(readFileSync(jsonPath, "utf-8"));
204
- // Apply same renames to the raw JSON (consistent precedence)
205
- if (raw.orchestrator?.orchestrator?.tmuxPrefix !== undefined) {
206
- const rawPrefix = raw.orchestrator.orchestrator.sessionPrefix;
207
- if (rawPrefix === undefined || rawPrefix === "orch") {
208
- raw.orchestrator.orchestrator.sessionPrefix = raw.orchestrator.orchestrator.tmuxPrefix;
209
- }
210
- delete raw.orchestrator.orchestrator.tmuxPrefix;
211
- }
212
- if (raw.orchestrator?.orchestrator?.spawnMode === "tmux") {
213
- raw.orchestrator.orchestrator.spawnMode = "subprocess";
214
- }
215
- if (raw.taskRunner?.worker?.spawnMode === "tmux") {
216
- raw.taskRunner.worker.spawnMode = "subprocess";
217
- }
218
- const tmpPath = jsonPath + ".migration-tmp";
219
- writeFileSync(tmpPath, JSON.stringify(raw, null, 2) + "\n");
220
- renameSync(tmpPath, jsonPath);
221
- console.error(`[taskplane] Config file updated: ${jsonPath}`);
222
- }
223
- } catch (err) {
224
- console.error(`[taskplane] Warning: could not persist config migration to disk: ${err instanceof Error ? err.message : err}`);
225
- }
226
- }
227
-
228
- _projectMigrationDone = true;
229
- return migrated;
230
- }
231
-
232
- /**
233
- * Auto-migrate legacy TMUX fields in user preferences.
153
+ * Auto-migrate legacy TMUX fields in global preferences.
234
154
  *
235
155
  * Same precedence: new key wins if both exist.
236
156
  * Writes back atomically (tmp + rename).
237
157
  *
238
158
  * @returns true if any migrations were applied
239
159
  */
240
- function migrateUserPreferences(raw: Record<string, any>, prefsPath: string): boolean {
160
+ function migrateGlobalPreferences(raw: Record<string, any>, prefsPath: string): boolean {
241
161
  let migrated = false;
242
162
  if (hasOwn(raw, "tmuxPrefix")) {
243
163
  if (!hasOwn(raw, "sessionPrefix") || raw.sessionPrefix === undefined) {
244
164
  raw.sessionPrefix = raw.tmuxPrefix;
245
165
  }
246
166
  delete raw.tmuxPrefix;
247
- console.error(`[taskplane] Auto-migrated user preference: tmuxPrefix → sessionPrefix`);
167
+ console.error(`[taskplane] Auto-migrated global preference: tmuxPrefix → sessionPrefix`);
248
168
  migrated = true;
249
169
  }
250
170
  if (raw.spawnMode === "tmux") {
251
171
  raw.spawnMode = "subprocess";
252
- console.error(`[taskplane] Auto-migrated user preference: spawnMode "tmux" → "subprocess"`);
172
+ console.error(`[taskplane] Auto-migrated global preference: spawnMode "tmux" → "subprocess"`);
173
+ migrated = true;
174
+ }
175
+ if (raw.orchestrator?.orchestrator?.spawnMode === "tmux") {
176
+ raw.orchestrator.orchestrator.spawnMode = "subprocess";
177
+ console.error(`[taskplane] Auto-migrated global preference: orchestrator.orchestrator.spawnMode "tmux" → "subprocess"`);
178
+ migrated = true;
179
+ }
180
+ if (raw.taskRunner?.worker?.spawnMode === "tmux") {
181
+ raw.taskRunner.worker.spawnMode = "subprocess";
182
+ console.error(`[taskplane] Auto-migrated global preference: taskRunner.worker.spawnMode "tmux" → "subprocess"`);
253
183
  migrated = true;
254
184
  }
255
185
  if (migrated) {
@@ -532,7 +462,7 @@ function resolveConfigFilePath(configRoot: string, filename: string): string {
532
462
  * Returns the parsed config or null if the file doesn't exist.
533
463
  * Throws ConfigLoadError for malformed JSON or unsupported versions.
534
464
  */
535
- function loadJsonConfig(configRoot: string): TaskplaneConfig | null {
465
+ function loadJsonConfig(configRoot: string): Partial<TaskplaneConfig> | null {
536
466
  const jsonPath = resolveConfigFilePath(configRoot, PROJECT_CONFIG_FILENAME);
537
467
  if (!existsSync(jsonPath)) return null;
538
468
 
@@ -570,22 +500,21 @@ function loadJsonConfig(configRoot: string): TaskplaneConfig | null {
570
500
  );
571
501
  }
572
502
 
573
- // Deep merge with cloned defaults
574
- const config = deepClone(DEFAULT_PROJECT_CONFIG);
575
- if (parsed.taskRunner) {
576
- deepMerge(config.taskRunner, parsed.taskRunner);
503
+ const overrides: Partial<TaskplaneConfig> = {};
504
+ if (parsed.taskRunner && typeof parsed.taskRunner === "object" && !Array.isArray(parsed.taskRunner)) {
505
+ overrides.taskRunner = deepClone(parsed.taskRunner);
577
506
  }
578
- if (parsed.orchestrator) {
579
- deepMerge(config.orchestrator, parsed.orchestrator);
507
+ if (parsed.orchestrator && typeof parsed.orchestrator === "object" && !Array.isArray(parsed.orchestrator)) {
508
+ overrides.orchestrator = deepClone(parsed.orchestrator);
580
509
  }
581
510
  if (parsed.workspace) {
582
511
  const normalizedWorkspace = normalizeWorkspaceSection(parsed.workspace, jsonPath);
583
512
  if (normalizedWorkspace) {
584
- config.workspace = normalizedWorkspace;
513
+ overrides.workspace = normalizedWorkspace;
585
514
  }
586
515
  }
587
516
 
588
- return config;
517
+ return overrides;
589
518
  }
590
519
 
591
520
 
@@ -598,28 +527,24 @@ function loadJsonConfig(configRoot: string): TaskplaneConfig | null {
598
527
  * flat layout (`<root>/task-runner.yaml`) — see `resolveConfigFilePath`.
599
528
  * Maps snake_case YAML keys to the camelCase TaskRunnerSection shape.
600
529
  * Uses section-aware mapping that preserves user-defined record keys.
601
- * Returns cloned defaults if the file doesn't exist or is malformed.
530
+ * Returns sparse overrides (empty object when missing/malformed).
602
531
  */
603
- function loadTaskRunnerYaml(configRoot: string): TaskRunnerSection {
532
+ function loadTaskRunnerYaml(configRoot: string): Partial<TaskRunnerSection> {
604
533
  const yamlPath = resolveConfigFilePath(configRoot, "task-runner.yaml");
605
- if (!existsSync(yamlPath)) return deepClone(DEFAULT_TASK_RUNNER_SECTION);
534
+ if (!existsSync(yamlPath)) return {};
606
535
 
607
536
  try {
608
537
  const raw = readFileSync(yamlPath, "utf-8");
609
538
  const loaded = yamlParse(raw) as any;
610
- if (!loaded || typeof loaded !== "object") return deepClone(DEFAULT_TASK_RUNNER_SECTION);
539
+ if (!loaded || typeof loaded !== "object") return {};
611
540
 
612
541
  // Section-aware mapping: structural keys → camelCase, record keys → preserved
613
542
  const mapped = mapTaskRunnerYaml(loaded);
614
543
 
615
- // Deep merge with cloned defaults
616
- const section = deepClone(DEFAULT_TASK_RUNNER_SECTION);
617
- deepMerge(section, mapped);
618
-
619
544
  // Post-process taskAreas: trim repoId, drop whitespace-only values
620
545
  // (matches legacy loadTaskRunnerConfig behavior from config.ts)
621
- if (section.taskAreas) {
622
- for (const area of Object.values(section.taskAreas)) {
546
+ if (mapped.taskAreas) {
547
+ for (const area of Object.values(mapped.taskAreas)) {
623
548
  if (area.repoId !== undefined) {
624
549
  const trimmed = typeof area.repoId === "string" ? area.repoId.trim() : "";
625
550
  if (trimmed) {
@@ -631,9 +556,9 @@ function loadTaskRunnerYaml(configRoot: string): TaskRunnerSection {
631
556
  }
632
557
  }
633
558
 
634
- return section;
559
+ return mapped;
635
560
  } catch {
636
- return deepClone(DEFAULT_TASK_RUNNER_SECTION);
561
+ return {};
637
562
  }
638
563
  }
639
564
 
@@ -644,27 +569,21 @@ function loadTaskRunnerYaml(configRoot: string): TaskRunnerSection {
644
569
  * flat layout (`<root>/task-orchestrator.yaml`) — see `resolveConfigFilePath`.
645
570
  * Maps snake_case YAML keys to the camelCase OrchestratorSection shape.
646
571
  * Uses section-aware mapping that preserves user-defined record keys.
647
- * Returns cloned defaults if the file doesn't exist or is malformed.
572
+ * Returns sparse overrides (empty object when missing/malformed).
648
573
  */
649
- function loadOrchestratorYaml(configRoot: string): OrchestratorSection {
574
+ function loadOrchestratorYaml(configRoot: string): Partial<OrchestratorSection> {
650
575
  const yamlPath = resolveConfigFilePath(configRoot, "task-orchestrator.yaml");
651
- if (!existsSync(yamlPath)) return deepClone(DEFAULT_ORCHESTRATOR_SECTION);
576
+ if (!existsSync(yamlPath)) return {};
652
577
 
653
578
  try {
654
579
  const raw = readFileSync(yamlPath, "utf-8");
655
580
  const loaded = yamlParse(raw) as any;
656
- if (!loaded || typeof loaded !== "object") return deepClone(DEFAULT_ORCHESTRATOR_SECTION);
581
+ if (!loaded || typeof loaded !== "object") return {};
657
582
 
658
583
  // Section-aware mapping: structural keys → camelCase, record keys → preserved
659
- const mapped = mapOrchestratorYaml(loaded);
660
-
661
- // Deep merge with cloned defaults
662
- const section = deepClone(DEFAULT_ORCHESTRATOR_SECTION);
663
- deepMerge(section, mapped);
664
-
665
- return section;
584
+ return mapOrchestratorYaml(loaded);
666
585
  } catch {
667
- return deepClone(DEFAULT_ORCHESTRATOR_SECTION);
586
+ return {};
668
587
  }
669
588
  }
670
589
 
@@ -692,10 +611,10 @@ function loadWorkspaceYaml(configRoot: string): WorkspaceSectionConfig | undefin
692
611
  }
693
612
 
694
613
 
695
- // ── User Preferences (Layer 2) ───────────────────────────────────────
614
+ // ── Global Preferences (Layer 2) ─────────────────────────────────────
696
615
 
697
616
  /**
698
- * Resolve the absolute path to the user preferences file.
617
+ * Resolve the absolute path to the global preferences file.
699
618
  *
700
619
  * Resolution order:
701
620
  * 1. `PI_CODING_AGENT_DIR` env → `<value>/taskplane/preferences.json`
@@ -704,61 +623,104 @@ function loadWorkspaceYaml(configRoot: string): WorkspaceSectionConfig | undefin
704
623
  * Uses `os.homedir()` for cross-platform home resolution
705
624
  * (USERPROFILE on Windows, HOME on Unix) and `path.join()` for separators.
706
625
  */
707
- export function resolveUserPreferencesPath(): string {
626
+ export function resolveGlobalPreferencesPath(): string {
708
627
  const agentDir = process.env.PI_CODING_AGENT_DIR;
709
628
  if (agentDir) {
710
- return join(agentDir, USER_PREFERENCES_SUBDIR, USER_PREFERENCES_FILENAME);
629
+ return join(agentDir, GLOBAL_PREFERENCES_SUBDIR, GLOBAL_PREFERENCES_FILENAME);
630
+ }
631
+ return join(homedir(), ".pi", "agent", GLOBAL_PREFERENCES_SUBDIR, GLOBAL_PREFERENCES_FILENAME);
632
+ }
633
+
634
+ /** Result envelope for global preferences loading. */
635
+ export interface GlobalPreferencesLoadResult {
636
+ preferences: GlobalPreferences;
637
+ wasBootstrapped: boolean;
638
+ }
639
+
640
+ /** Persist preferences JSON atomically (temp file + rename). */
641
+ function writePreferencesAtomically(prefsPath: string, prefs: GlobalPreferences): void {
642
+ const tmpPath = `${prefsPath}.tmp-${process.pid}-${Date.now()}`;
643
+ writeFileSync(tmpPath, JSON.stringify(prefs, null, 2) + "\n", "utf-8");
644
+ renameSync(tmpPath, prefsPath);
645
+ }
646
+
647
+ /**
648
+ * Write first-install bootstrap preferences to disk and return the in-memory seed.
649
+ */
650
+ function bootstrapGlobalPreferencesFile(prefsPath: string): GlobalPreferences {
651
+ const bootstrapPrefs = deepClone(DEFAULT_BOOTSTRAP_GLOBAL_PREFERENCES);
652
+ try {
653
+ const dir = join(prefsPath, "..");
654
+ mkdirSync(dir, { recursive: true });
655
+ writePreferencesAtomically(prefsPath, bootstrapPrefs);
656
+ } catch {
657
+ // Best-effort; if we can't create, still return bootstrap defaults in-memory.
711
658
  }
712
- return join(homedir(), ".pi", "agent", USER_PREFERENCES_SUBDIR, USER_PREFERENCES_FILENAME);
659
+ return bootstrapPrefs;
713
660
  }
714
661
 
715
662
  /**
716
- * Load user preferences from `~/.pi/agent/taskplane/preferences.json`.
663
+ * Load global preferences plus bootstrap metadata.
717
664
  *
718
665
  * Behavior:
719
- * - If file doesn't exist: auto-create with empty defaults `{}`, return defaults
720
- * - If file is malformed JSON: log warning, return defaults (non-destructive)
721
- * - Unknown keys are silently ignored (only allowlisted fields extracted)
722
- * - Returns a fresh UserPreferences object on each call
723
- *
724
- * @returns Parsed UserPreferences (only recognized fields)
666
+ * - If file doesn't exist: bootstrap preferences on disk and mark bootstrapped
667
+ * - If file is empty/malformed/invalid: re-bootstrap preferences and mark bootstrapped
668
+ * - Unknown keys are silently ignored (allowlist extraction)
725
669
  */
726
- export function loadUserPreferences(): UserPreferences {
727
- const prefsPath = resolveUserPreferencesPath();
670
+ export function loadGlobalPreferencesWithMeta(): GlobalPreferencesLoadResult {
671
+ const prefsPath = resolveGlobalPreferencesPath();
728
672
 
729
673
  if (!existsSync(prefsPath)) {
730
- // Auto-create with empty defaults on first access
731
- try {
732
- const dir = join(prefsPath, "..");
733
- mkdirSync(dir, { recursive: true });
734
- writeFileSync(prefsPath, JSON.stringify(DEFAULT_USER_PREFERENCES, null, 2) + "\n", "utf-8");
735
- } catch {
736
- // Best-effort; if we can't create, just return defaults
737
- }
738
- return { ...DEFAULT_USER_PREFERENCES };
674
+ return {
675
+ preferences: bootstrapGlobalPreferencesFile(prefsPath),
676
+ wasBootstrapped: true,
677
+ };
739
678
  }
740
679
 
741
680
  let raw: string;
742
681
  try {
743
682
  raw = readFileSync(prefsPath, "utf-8");
744
683
  } catch {
745
- return { ...DEFAULT_USER_PREFERENCES };
684
+ return { preferences: deepClone(DEFAULT_GLOBAL_PREFERENCES), wasBootstrapped: false };
685
+ }
686
+
687
+ if (!raw.trim()) {
688
+ return {
689
+ preferences: bootstrapGlobalPreferencesFile(prefsPath),
690
+ wasBootstrapped: true,
691
+ };
746
692
  }
747
693
 
748
694
  let parsed: any;
749
695
  try {
750
696
  parsed = JSON.parse(raw);
751
697
  } catch {
752
- // Malformed JSON — return defaults without overwriting (non-destructive)
753
- return { ...DEFAULT_USER_PREFERENCES };
698
+ return {
699
+ preferences: bootstrapGlobalPreferencesFile(prefsPath),
700
+ wasBootstrapped: true,
701
+ };
754
702
  }
755
703
 
756
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
757
- return { ...DEFAULT_USER_PREFERENCES };
704
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || Object.keys(parsed).length === 0) {
705
+ return {
706
+ preferences: bootstrapGlobalPreferencesFile(prefsPath),
707
+ wasBootstrapped: true,
708
+ };
758
709
  }
759
710
 
760
- // Extract only allowlisted fields — unknown keys are ignored
761
- return extractAllowlistedPreferences(parsed, prefsPath);
711
+ return {
712
+ preferences: extractAllowlistedPreferences(parsed, prefsPath),
713
+ wasBootstrapped: false,
714
+ };
715
+ }
716
+
717
+ /**
718
+ * Load global preferences from `~/.pi/agent/taskplane/preferences.json`.
719
+ *
720
+ * @returns Parsed GlobalPreferences (only recognized fields)
721
+ */
722
+ export function loadGlobalPreferences(): GlobalPreferences {
723
+ return loadGlobalPreferencesWithMeta().preferences;
762
724
  }
763
725
 
764
726
  /**
@@ -767,18 +729,21 @@ export function loadUserPreferences(): UserPreferences {
767
729
  */
768
730
  function normalizePreferenceThinkingMode(value: unknown): string {
769
731
  const cleaned = String(value ?? "").trim().toLowerCase();
770
- if (cleaned === "on") return "on";
771
- if (cleaned === "off") return "off";
732
+ if (!cleaned || cleaned === "inherit") return "";
733
+ if (cleaned === "on") return "high";
734
+ if (["off", "minimal", "low", "medium", "high", "xhigh"].includes(cleaned)) {
735
+ return cleaned;
736
+ }
772
737
  return "";
773
738
  }
774
739
 
775
- function extractInitAgentDefaults(rawInitDefaults: unknown): UserPreferences["initAgentDefaults"] | undefined {
740
+ function extractInitAgentDefaults(rawInitDefaults: unknown): GlobalPreferences["initAgentDefaults"] | undefined {
776
741
  if (!rawInitDefaults || typeof rawInitDefaults !== "object" || Array.isArray(rawInitDefaults)) {
777
742
  return undefined;
778
743
  }
779
744
 
780
745
  const raw = rawInitDefaults as Record<string, unknown>;
781
- const extracted: NonNullable<UserPreferences["initAgentDefaults"]> = {};
746
+ const extracted: NonNullable<GlobalPreferences["initAgentDefaults"]> = {};
782
747
 
783
748
  if (typeof raw.workerModel === "string") extracted.workerModel = raw.workerModel;
784
749
  if (typeof raw.reviewerModel === "string") extracted.reviewerModel = raw.reviewerModel;
@@ -790,11 +755,34 @@ function extractInitAgentDefaults(rawInitDefaults: unknown): UserPreferences["in
790
755
  return Object.keys(extracted).length > 0 ? extracted : undefined;
791
756
  }
792
757
 
793
- function extractAllowlistedPreferences(raw: Record<string, any>, prefsPath: string): UserPreferences {
794
- migrateUserPreferences(raw, prefsPath);
758
+ function extractConfigOverrideSection(rawSection: unknown): Record<string, any> | undefined {
759
+ if (!rawSection || typeof rawSection !== "object" || Array.isArray(rawSection)) {
760
+ return undefined;
761
+ }
762
+ return deepClone(rawSection as Record<string, any>);
763
+ }
764
+
765
+ function extractAllowlistedPreferences(raw: Record<string, any>, prefsPath: string): GlobalPreferences {
766
+ migrateGlobalPreferences(raw, prefsPath);
795
767
 
796
- const prefs: UserPreferences = {};
768
+ const prefs: GlobalPreferences = {};
797
769
 
770
+ const taskRunnerOverrides = extractConfigOverrideSection(raw.taskRunner);
771
+ if (taskRunnerOverrides) {
772
+ prefs.taskRunner = taskRunnerOverrides as GlobalPreferences["taskRunner"];
773
+ }
774
+
775
+ const orchestratorOverrides = extractConfigOverrideSection(raw.orchestrator);
776
+ if (orchestratorOverrides) {
777
+ prefs.orchestrator = orchestratorOverrides as GlobalPreferences["orchestrator"];
778
+ }
779
+
780
+ const workspaceOverrides = extractConfigOverrideSection(raw.workspace);
781
+ if (workspaceOverrides) {
782
+ prefs.workspace = workspaceOverrides as GlobalPreferences["workspace"];
783
+ }
784
+
785
+ // Legacy flat aliases (backward compatibility for existing preferences.json files)
798
786
  if (typeof raw.operatorId === "string") prefs.operatorId = raw.operatorId;
799
787
  if (typeof raw.sessionPrefix === "string") {
800
788
  prefs.sessionPrefix = raw.sessionPrefix;
@@ -807,6 +795,8 @@ function extractAllowlistedPreferences(raw: Record<string, any>, prefsPath: stri
807
795
  if (typeof raw.mergeModel === "string") prefs.mergeModel = raw.mergeModel;
808
796
  if (typeof raw.mergeThinking === "string") prefs.mergeThinking = raw.mergeThinking;
809
797
  if (typeof raw.supervisorModel === "string") prefs.supervisorModel = raw.supervisorModel;
798
+
799
+ // Preferences-only fields (intentionally not merged into runtime config)
810
800
  if (typeof raw.dashboardPort === "number" && Number.isFinite(raw.dashboardPort)) {
811
801
  prefs.dashboardPort = raw.dashboardPort;
812
802
  }
@@ -819,34 +809,23 @@ function extractAllowlistedPreferences(raw: Record<string, any>, prefsPath: stri
819
809
  }
820
810
 
821
811
  /**
822
- * Apply user preferences (Layer 2) onto a project config (Layer 1).
812
+ * Apply global preferences (Layer 2) onto a project config (Layer 1).
823
813
  *
824
- * Only allowlisted fields are applied. User preferences win for Layer 2
825
- * fields; all other config fields (Layer 1) are left untouched.
814
+ * Merge order inside Layer 2:
815
+ * 1. Legacy flat aliases (for backward compatibility)
816
+ * 2. Config-shaped nested overrides (`taskRunner` / `orchestrator` / `workspace`)
817
+ * Nested overrides intentionally win when both styles are present.
826
818
  *
827
- * Mutates `config` in place and returns it for chaining.
828
- *
829
- * Empty-string preference values are treated as "not set" and do NOT
830
- * override the project config value. This lets users clear a preference
831
- * by deleting the field or setting it to "".
832
- *
833
- * Mapping table:
834
- * prefs.operatorId → config.orchestrator.orchestrator.operatorId
835
- * prefs.sessionPrefix → config.orchestrator.orchestrator.sessionPrefix
836
- * prefs.spawnMode → config.orchestrator.orchestrator.spawnMode
837
- * prefs.workerModel → config.taskRunner.worker.model
838
- * prefs.reviewerModel → config.taskRunner.reviewer.model
839
- * prefs.mergeModel → config.orchestrator.merge.model
840
- * prefs.supervisorModel → config.orchestrator.supervisor.model
841
- * prefs.dashboardPort → (no config target yet — stored only)
842
- * prefs.initAgentDefaults → (preferences-only; consumed by CLI init flow)
819
+ * Preferences-only fields (`dashboardPort`, `initAgentDefaults`) are preserved
820
+ * in `GlobalPreferences` but intentionally not merged into runtime config.
843
821
  */
844
- export function applyUserPreferences(config: TaskplaneConfig, prefs: UserPreferences): TaskplaneConfig {
822
+ export function applyGlobalPreferences(config: TaskplaneConfig, prefs: GlobalPreferences): TaskplaneConfig {
845
823
  // Helper: only apply non-empty string values
846
824
  const applyStr = (val: string | undefined, setter: (v: string) => void) => {
847
825
  if (val !== undefined && val !== "") setter(val);
848
826
  };
849
827
 
828
+ // 1) Legacy flat aliases
850
829
  applyStr(prefs.operatorId, (v) => { config.orchestrator.orchestrator.operatorId = v; });
851
830
  applyStr(prefs.sessionPrefix, (v) => { config.orchestrator.orchestrator.sessionPrefix = v; });
852
831
  applyStr(prefs.workerModel, (v) => { config.taskRunner.worker.model = v; });
@@ -864,8 +843,29 @@ export function applyUserPreferences(config: TaskplaneConfig, prefs: UserPrefere
864
843
  config.orchestrator.orchestrator.spawnMode = prefs.spawnMode;
865
844
  }
866
845
 
867
- // dashboardPort: no config schema target yet — intentionally not applied
868
- // It can be read directly from loadUserPreferences() by consumers that need it.
846
+ // 2) Config-shaped nested overrides
847
+ if (prefs.taskRunner) {
848
+ deepMerge(config.taskRunner as Record<string, any>, prefs.taskRunner as Record<string, any>);
849
+ }
850
+ if (prefs.orchestrator) {
851
+ deepMerge(config.orchestrator as Record<string, any>, prefs.orchestrator as Record<string, any>);
852
+ }
853
+ if (prefs.workspace) {
854
+ if (!config.workspace || typeof config.workspace !== "object") {
855
+ config.workspace = {} as TaskplaneConfig["workspace"];
856
+ }
857
+ deepMerge(config.workspace as Record<string, any>, prefs.workspace as Record<string, any>);
858
+ }
859
+
860
+ // Runtime safety: nested legacy values may arrive through config-shaped overrides.
861
+ if ((config.orchestrator.orchestrator as Record<string, any>).spawnMode === "tmux") {
862
+ config.orchestrator.orchestrator.spawnMode = "subprocess";
863
+ console.error(`[taskplane] Auto-migrated runtime global preference: orchestrator.orchestrator.spawnMode "tmux" → "subprocess"`);
864
+ }
865
+ if ((config.taskRunner.worker as Record<string, any>).spawnMode === "tmux") {
866
+ config.taskRunner.worker.spawnMode = "subprocess";
867
+ console.error(`[taskplane] Auto-migrated runtime global preference: taskRunner.worker.spawnMode "tmux" → "subprocess"`);
868
+ }
869
869
 
870
870
  return config;
871
871
  }
@@ -932,105 +932,142 @@ export function resolveConfigRoot(cwd: string, pointerConfigRoot?: string): stri
932
932
  return cwd;
933
933
  }
934
934
 
935
+ function mergeProjectOverrides(config: TaskplaneConfig, overrides: Partial<TaskplaneConfig>): void {
936
+ if (overrides.taskRunner) {
937
+ deepMerge(config.taskRunner as Record<string, any>, overrides.taskRunner as Record<string, any>);
938
+ }
939
+ if (overrides.orchestrator) {
940
+ deepMerge(config.orchestrator as Record<string, any>, overrides.orchestrator as Record<string, any>);
941
+ }
942
+ if (overrides.workspace) {
943
+ if (!config.workspace || typeof config.workspace !== "object") {
944
+ config.workspace = {} as TaskplaneConfig["workspace"];
945
+ }
946
+ deepMerge(config.workspace as Record<string, any>, overrides.workspace as Record<string, any>);
947
+ }
948
+ }
949
+
950
+ function migrateProjectOverrides(overrides: Partial<TaskplaneConfig>, configRoot: string): boolean {
951
+ if (_projectMigrationDone) return false;
952
+
953
+ let migrated = false;
954
+ const orchestratorCore = overrides.orchestrator?.orchestrator as Record<string, unknown> | undefined;
955
+ if (orchestratorCore && hasOwn(orchestratorCore, "tmuxPrefix")) {
956
+ const currentPrefix = orchestratorCore.sessionPrefix;
957
+ const isDefault = currentPrefix === undefined || currentPrefix === "orch";
958
+ if (isDefault) {
959
+ (orchestratorCore as any).sessionPrefix = orchestratorCore.tmuxPrefix;
960
+ }
961
+ delete orchestratorCore.tmuxPrefix;
962
+ console.error(`[taskplane] Auto-migrated: orchestrator.orchestrator.tmuxPrefix → sessionPrefix`);
963
+ migrated = true;
964
+ }
965
+ if (orchestratorCore?.spawnMode === "tmux") {
966
+ (orchestratorCore as any).spawnMode = "subprocess";
967
+ console.error(`[taskplane] Auto-migrated: orchestrator.orchestrator.spawnMode "tmux" → "subprocess"`);
968
+ migrated = true;
969
+ }
970
+
971
+ const workerConfig = overrides.taskRunner?.worker as Record<string, unknown> | undefined;
972
+ if (workerConfig?.spawnMode === "tmux") {
973
+ (workerConfig as any).spawnMode = "subprocess";
974
+ console.error(`[taskplane] Auto-migrated: taskRunner.worker.spawnMode "tmux" → "subprocess"`);
975
+ migrated = true;
976
+ }
977
+
978
+ if (migrated) {
979
+ try {
980
+ const jsonPath = resolveConfigFilePath(configRoot, PROJECT_CONFIG_FILENAME);
981
+ if (existsSync(jsonPath)) {
982
+ const raw = JSON.parse(readFileSync(jsonPath, "utf-8"));
983
+ if (raw.orchestrator?.orchestrator?.tmuxPrefix !== undefined) {
984
+ const rawPrefix = raw.orchestrator.orchestrator.sessionPrefix;
985
+ if (rawPrefix === undefined || rawPrefix === "orch") {
986
+ raw.orchestrator.orchestrator.sessionPrefix = raw.orchestrator.orchestrator.tmuxPrefix;
987
+ }
988
+ delete raw.orchestrator.orchestrator.tmuxPrefix;
989
+ }
990
+ if (raw.orchestrator?.orchestrator?.spawnMode === "tmux") {
991
+ raw.orchestrator.orchestrator.spawnMode = "subprocess";
992
+ }
993
+ if (raw.taskRunner?.worker?.spawnMode === "tmux") {
994
+ raw.taskRunner.worker.spawnMode = "subprocess";
995
+ }
996
+
997
+ const tmpPath = jsonPath + ".migration-tmp";
998
+ writeFileSync(tmpPath, JSON.stringify(raw, null, 2) + "\n");
999
+ renameSync(tmpPath, jsonPath);
1000
+ console.error(`[taskplane] Config file updated: ${jsonPath}`);
1001
+ }
1002
+ } catch (err) {
1003
+ console.error(`[taskplane] Warning: could not persist config migration to disk: ${err instanceof Error ? err.message : err}`);
1004
+ }
1005
+ }
1006
+
1007
+ _projectMigrationDone = true;
1008
+ return migrated;
1009
+ }
1010
+
1011
+ export function loadProjectOverrides(configRoot: string): Partial<TaskplaneConfig> {
1012
+ const jsonOverrides = loadJsonConfig(configRoot);
1013
+ if (jsonOverrides !== null) {
1014
+ return jsonOverrides;
1015
+ }
1016
+
1017
+ const taskRunner = loadTaskRunnerYaml(configRoot);
1018
+ const orchestrator = loadOrchestratorYaml(configRoot);
1019
+ const workspace = loadWorkspaceYaml(configRoot);
1020
+
1021
+ const overrides: Partial<TaskplaneConfig> = {};
1022
+ if (Object.keys(taskRunner).length > 0) overrides.taskRunner = taskRunner;
1023
+ if (Object.keys(orchestrator).length > 0) overrides.orchestrator = orchestrator;
1024
+ if (workspace) overrides.workspace = workspace;
1025
+ return overrides;
1026
+ }
1027
+
935
1028
  /**
936
1029
  * Load the unified project configuration.
937
1030
  *
938
1031
  * Precedence (layered):
939
- * Layer 1 Project config:
940
- * 1. `.pi/taskplane-config.json` — JSON-first (new format)
941
- * 2. `.pi/task-runner.yaml` + `.pi/task-orchestrator.yaml` YAML fallback
942
- * (+ optional `.pi/taskplane-workspace.yaml` workspace section mapping)
943
- * 3. Defaults — if no config files exist
944
- *
945
- * Layer 2 — User preferences (applied on top of Layer 1):
946
- * Reads `~/.pi/agent/taskplane/preferences.json` and overrides only
947
- * allowlisted user-scoped fields. See `applyUserPreferences()` for
948
- * the field mapping.
1032
+ * 1. Schema defaults
1033
+ * 2. Global preferences (`~/.pi/agent/taskplane/preferences.json`)
1034
+ * 3. Project overrides (`taskplane-config.json` or YAML fallback)
949
1035
  *
950
- * Config root resolution order:
951
- * 1. cwd has config files use cwd (local override)
952
- * 2. pointerConfigRoot has config files → use it (pointer redirect, workspace mode)
953
- * 3. TASKPLANE_WORKSPACE_ROOT has config files → use it (legacy fallback)
954
- * 4. Fall back to cwd (loaders will return defaults)
955
- *
956
- * @param cwd - Current working directory (project root or worktree)
957
- * @param pointerConfigRoot - Resolved config root from pointer file (optional).
958
- * Callers in workspace mode should resolve the pointer via `resolvePointer()`
959
- * and pass `result.configRoot` here. In repo mode, omit or pass undefined.
960
- * @returns Unified TaskplaneConfig — always a fresh deep-cloned object
961
- * @throws ConfigLoadError if JSON exists but is malformed or has unsupported version
1036
+ * Project config is treated as sparse overrides. Missing fields in project
1037
+ * config fall through to global preferences, then schema defaults.
962
1038
  */
963
1039
  export function loadProjectConfig(cwd: string, pointerConfigRoot?: string): TaskplaneConfig {
964
1040
  const configRoot = resolveConfigRoot(cwd, pointerConfigRoot);
1041
+ const config = deepClone(DEFAULT_PROJECT_CONFIG);
965
1042
 
966
- // Layer 1: Project config
967
- let config: TaskplaneConfig;
968
-
969
- // Try JSON first
970
- const jsonConfig = loadJsonConfig(configRoot);
971
- if (jsonConfig !== null) {
972
- config = jsonConfig;
973
- } else {
974
- // Fall back to YAML
975
- const taskRunner = loadTaskRunnerYaml(configRoot);
976
- const orchestrator = loadOrchestratorYaml(configRoot);
977
- const workspace = loadWorkspaceYaml(configRoot);
978
- config = {
979
- configVersion: CONFIG_VERSION,
980
- taskRunner,
981
- orchestrator,
982
- ...(workspace ? { workspace } : {}),
983
- };
984
- }
985
-
986
- _projectMigrationDone = false; // Reset guard for each top-level load
987
- migrateProjectConfig(config, configRoot);
1043
+ // Layer 2 baseline: global preferences on top of defaults
1044
+ const prefs = loadGlobalPreferences();
1045
+ applyGlobalPreferences(config, prefs);
988
1046
 
989
- // Layer 2: User preferences (allowlisted fields only)
990
- const prefs = loadUserPreferences();
991
- applyUserPreferences(config, prefs);
992
- // No second migrateProjectConfig call needed — idempotency guard + prefs
993
- // can’t re-introduce tmux fields (migrateUserPreferences already ran).
1047
+ // Layer 1 project overrides: sparse config merged on top
1048
+ const overrides = loadProjectOverrides(configRoot);
1049
+ _projectMigrationDone = false;
1050
+ migrateProjectOverrides(overrides, configRoot);
1051
+ mergeProjectOverrides(config, overrides);
994
1052
 
995
1053
  normalizeInheritanceAliases(config);
996
1054
  return config;
997
1055
  }
998
1056
 
999
1057
  /**
1000
- * Load Layer 1 config only (project config without user preferences).
1001
- *
1002
- * Returns the project config merged with defaults, but WITHOUT applying
1003
- * Layer 2 user preferences. Used by the settings TUI write-back to
1004
- * bootstrap a JSON config file from YAML-only projects without
1005
- * accidentally embedding user preferences into the project config.
1006
- *
1007
- * @param cwd - Current working directory (project root or worktree)
1008
- * @param pointerConfigRoot - Optional pointer-resolved config root (workspace mode)
1009
- * @returns Layer 1 TaskplaneConfig — always a fresh deep-cloned object
1010
- * @throws ConfigLoadError if JSON exists but is malformed or has unsupported version
1058
+ * Load project overrides merged with schema defaults, without applying
1059
+ * global preferences. Used by settings write-back code paths that must
1060
+ * avoid embedding global baseline values into project config.
1011
1061
  */
1012
1062
  export function loadLayer1Config(cwd: string, pointerConfigRoot?: string): TaskplaneConfig {
1013
1063
  const configRoot = resolveConfigRoot(cwd, pointerConfigRoot);
1064
+ const config = deepClone(DEFAULT_PROJECT_CONFIG);
1065
+ const overrides = loadProjectOverrides(configRoot);
1014
1066
 
1015
- // Try JSON first
1016
- const jsonConfig = loadJsonConfig(configRoot);
1017
- if (jsonConfig !== null) {
1018
- migrateProjectConfig(jsonConfig, configRoot);
1019
- normalizeInheritanceAliases(jsonConfig);
1020
- return jsonConfig;
1021
- }
1067
+ _projectMigrationDone = false;
1068
+ migrateProjectOverrides(overrides, configRoot);
1069
+ mergeProjectOverrides(config, overrides);
1022
1070
 
1023
- // Fall back to YAML
1024
- const taskRunner = loadTaskRunnerYaml(configRoot);
1025
- const orchestrator = loadOrchestratorYaml(configRoot);
1026
- const workspace = loadWorkspaceYaml(configRoot);
1027
- const config: TaskplaneConfig = {
1028
- configVersion: CONFIG_VERSION,
1029
- taskRunner,
1030
- orchestrator,
1031
- ...(workspace ? { workspace } : {}),
1032
- };
1033
- migrateProjectConfig(config, configRoot);
1034
1071
  normalizeInheritanceAliases(config);
1035
1072
  return config;
1036
1073
  }