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.
@@ -6,13 +6,13 @@
6
6
  * 2. Per-section SettingsList with field display, source badges,
7
7
  * and inline editing for enum/boolean/string/number fields
8
8
  *
9
- * Source detection reads raw config files (before defaults merge) to
10
- * determine whether each field value comes from project config, user
11
- * preferences, or schema defaults.
9
+ * Source detection reads raw project config to determine whether each
10
+ * field is explicitly overridden in project config (`(project)`) or
11
+ * inherited from global baseline (`(global)`).
12
12
  *
13
- * Write-back targets the correct destination per field layer:
14
- * - L1-only project JSON, L2-only preferences JSON,
15
- * - L1+L2 user chooses destination via ctx.ui.select()
13
+ * Write-back defaults to global preferences for all editable fields.
14
+ * "Save to project override" and "Remove project override" are
15
+ * explicit actions in the destination picker.
16
16
  *
17
17
  * @module settings/tui
18
18
  */
@@ -29,21 +29,21 @@ import {
29
29
  DEFAULT_PROJECT_CONFIG,
30
30
  PROJECT_CONFIG_FILENAME,
31
31
  type TaskplaneConfig,
32
- type UserPreferences,
32
+ type GlobalPreferences,
33
33
  } from "./config-schema.ts";
34
34
  import {
35
- loadUserPreferences,
35
+ loadGlobalPreferences,
36
36
  loadProjectConfig,
37
- loadLayer1Config,
37
+ loadProjectOverrides,
38
38
  resolveConfigRoot,
39
- resolveUserPreferencesPath,
39
+ resolveGlobalPreferencesPath,
40
40
  } from "./config-loader.ts";
41
41
 
42
42
 
43
43
  // ── Types ────────────────────────────────────────────────────────────
44
44
 
45
45
  /** Source of a field's current value */
46
- export type FieldSource = "default" | "project" | "user";
46
+ export type FieldSource = "project" | "global";
47
47
 
48
48
  /** Layer assignment for a field */
49
49
  export type FieldLayer = "L1" | "L2" | "L1+L2";
@@ -67,8 +67,8 @@ export interface FieldDef {
67
67
  fieldType: "string" | "number" | "boolean" | "enum";
68
68
  /** Whether the field is optional (can be unset) */
69
69
  optional?: boolean;
70
- /** For L1+L2 fields: the user preferences key */
71
- prefsKey?: keyof UserPreferences;
70
+ /** For L1+L2 fields: the global preferences key */
71
+ prefsKey?: keyof GlobalPreferences;
72
72
  /** Description shown when selected */
73
73
  description?: string;
74
74
  }
@@ -163,7 +163,8 @@ export const SECTIONS: SectionDef[] = [
163
163
  { configPath: "taskRunner.worker.model", label: "Worker Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "workerModel", description: "Worker model (inherit = use session model)" },
164
164
  { configPath: "taskRunner.worker.tools", label: "Worker Tools", control: "input", layer: "L1", fieldType: "string", description: "Worker tool allowlist" },
165
165
  { configPath: "taskRunner.worker.thinking", label: "Worker Thinking", control: "picker", layer: "L1", fieldType: "string", description: "Worker thinking mode" },
166
- { configPath: "taskRunner.worker.spawnMode", label: "Spawn Mode", control: "toggle", layer: "L1", fieldType: "enum", values: ["subprocess"], optional: true, description: "How /task spawns workers and reviewers. Runtime V2 supports subprocess only." },
166
+ // spawnMode removed /task is deprecated, Runtime V2 is subprocess-only.
167
+ // { configPath: "taskRunner.worker.spawnMode" ... } was here.
167
168
  ],
168
169
  },
169
170
  {
@@ -187,7 +188,7 @@ export const SECTIONS: SectionDef[] = [
187
188
  ],
188
189
  },
189
190
  {
190
- name: "User Preferences",
191
+ name: "Global Preferences",
191
192
  fields: [
192
193
  { configPath: "preferences.dashboardPort", label: "Dashboard Port", control: "input", layer: "L2", fieldType: "number", prefsKey: "dashboardPort", optional: true, description: "Dashboard server port" },
193
194
  ],
@@ -321,10 +322,10 @@ function snakeKeysToCamel(obj: Record<string, any>): Record<string, any> {
321
322
  }
322
323
 
323
324
  /**
324
- * Read the raw user preferences JSON.
325
+ * Read the raw global preferences JSON.
325
326
  */
326
327
  function readRawPreferences(): Record<string, any> | null {
327
- const prefsPath = resolveUserPreferencesPath();
328
+ const prefsPath = resolveGlobalPreferencesPath();
328
329
  if (!existsSync(prefsPath)) return null;
329
330
  try {
330
331
  const raw = readFileSync(prefsPath, "utf-8");
@@ -361,23 +362,33 @@ function setNestedValue(obj: Record<string, any>, path: string, value: any): voi
361
362
  }
362
363
  }
363
364
 
365
+ function pruneEmptyObjects(node: unknown): boolean {
366
+ if (!node || typeof node !== "object" || Array.isArray(node)) return false;
367
+ const obj = node as Record<string, any>;
368
+ for (const key of Object.keys(obj)) {
369
+ const child = obj[key];
370
+ if (child && typeof child === "object" && !Array.isArray(child)) {
371
+ if (pruneEmptyObjects(child)) {
372
+ delete obj[key];
373
+ }
374
+ }
375
+ }
376
+ return Object.keys(obj).length === 0;
377
+ }
378
+
379
+ function toGlobalPreferencePath(field: FieldDef): string {
380
+ if (field.configPath.startsWith("preferences.")) {
381
+ return field.configPath.slice("preferences.".length);
382
+ }
383
+ return field.configPath;
384
+ }
385
+
364
386
  /**
365
- * Write a value to the project config JSON (Layer 1).
366
- *
367
- * Writes to the resolved config root using the active layout:
368
- * - standard: `<configRoot>/.pi/taskplane-config.json`
369
- * - flat: `<configRoot>/taskplane-config.json` (pointer `.taskplane/` roots)
370
- *
371
- * When no JSON config exists (YAML-only scenario), bootstraps the new
372
- * JSON file from the full current Layer 1 config (YAML values + defaults).
373
- * This preserves ALL existing YAML-set values — because JSON takes
374
- * precedence on next load, a partial skeleton would silently reset
375
- * non-edited fields to defaults.
387
+ * Write a single project override field to `taskplane-config.json`.
376
388
  *
377
- * YAML files are preserved alongside the new JSON; the loader's
378
- * JSON-first precedence means the JSON file is authoritative going forward.
379
- *
380
- * Uses atomic tmp+rename write pattern to prevent partial writes.
389
+ * This performs sparse writes only: when no JSON file exists yet, a new
390
+ * file is created with `{ configVersion }` plus the specific override path.
391
+ * It does NOT bootstrap full config values from YAML/global/default layers.
381
392
  */
382
393
  export function writeProjectConfigField(
383
394
  configRoot: string,
@@ -402,22 +413,14 @@ export function writeProjectConfigField(
402
413
  : join(resolvedRoot, ".pi", PROJECT_CONFIG_FILENAME);
403
414
  const tmpPath = jsonPath + ".tmp";
404
415
 
405
- // Ensure parent directory exists
406
416
  mkdirSync(dirname(jsonPath), { recursive: true });
407
417
 
408
- // Load existing JSON config, or bootstrap from full L1 config.
409
- // When YAML-only, we seed from loadLayer1Config to preserve all
410
- // YAML-sourced values (since JSON takes precedence on next load
411
- // and YAML values would otherwise be lost).
412
418
  let configObj: Record<string, any>;
413
419
  if (existsSync(jsonPath)) {
414
420
  try {
415
421
  const raw = readFileSync(jsonPath, "utf-8");
416
422
  configObj = JSON.parse(raw);
417
423
  } catch (e: any) {
418
- // Malformed JSON — cannot safely bootstrap because loadLayer1Config
419
- // would also fail on the same corrupt file. Surface the error so the
420
- // user can fix/delete the malformed JSON file first.
421
424
  throw new Error(
422
425
  `Cannot write settings: ${jsonPath} contains malformed JSON. ` +
423
426
  `Please fix or delete the file and try again. ` +
@@ -425,49 +428,42 @@ export function writeProjectConfigField(
425
428
  );
426
429
  }
427
430
  } else {
428
- // No JSON exists (possibly YAML-only) — bootstrap from full L1 config.
429
- // Deep-clone via JSON roundtrip since we mutate the object below.
430
- configObj = JSON.parse(JSON.stringify(loadLayer1Config(configRoot, pointerConfigRoot)));
431
+ const yamlSeed = loadProjectOverrides(resolvedRoot);
432
+ configObj = {
433
+ configVersion: CONFIG_VERSION,
434
+ ...JSON.parse(JSON.stringify(yamlSeed)),
435
+ };
431
436
  }
432
437
 
433
- // Set the value at the config path
434
438
  setNestedValue(configObj, configPath, value);
439
+ pruneEmptyObjects(configObj);
440
+ if (configObj.configVersion === undefined) {
441
+ configObj.configVersion = CONFIG_VERSION;
442
+ }
435
443
 
436
- // Atomic write: tmp + rename
437
444
  const json = JSON.stringify(configObj, null, 2) + "\n";
438
445
  writeFileSync(tmpPath, json, "utf-8");
439
446
  try {
440
447
  renameSync(tmpPath, jsonPath);
441
448
  } catch {
442
- // Windows fallback: direct write if rename fails
443
449
  writeFileSync(jsonPath, json, "utf-8");
444
450
  try { if (existsSync(tmpPath)) unlinkSync(tmpPath); } catch { /* cleanup best-effort */ }
445
451
  }
446
452
  }
447
453
 
448
454
  /**
449
- * Write a value to the user preferences JSON (Layer 2).
450
- *
451
- * Writes to `resolveUserPreferencesPath()`.
452
- * If `value` is undefined, deletes the key from the preferences file
453
- * (for clearing preferences).
454
- *
455
- * Uses atomic tmp+rename write pattern to prevent partial writes.
455
+ * Write a global preference at a dot-path (e.g. `taskRunner.worker.model`).
456
+ * Uses sparse JSON updates and prunes empty objects on delete.
456
457
  */
457
- export function writeUserPreference(
458
- prefsKey: keyof import("./config-schema.ts").UserPreferences,
459
- value: any,
460
- ): void {
461
- const prefsPath = resolveUserPreferencesPath();
458
+ export function writeGlobalPreference(path: string, value: any): void {
459
+ const prefsPath = resolveGlobalPreferencesPath();
462
460
  const tmpPath = prefsPath + ".tmp";
463
461
 
464
- // Ensure directory exists
465
462
  const prefsDir = dirname(prefsPath);
466
463
  if (!existsSync(prefsDir)) {
467
464
  mkdirSync(prefsDir, { recursive: true });
468
465
  }
469
466
 
470
- // Load existing prefs or create empty object
471
467
  let prefsObj: Record<string, any> = {};
472
468
  if (existsSync(prefsPath)) {
473
469
  try {
@@ -477,25 +473,18 @@ export function writeUserPreference(
477
473
  prefsObj = parsed;
478
474
  }
479
475
  } catch {
480
- // Malformed — start fresh (preserve what we can't parse)
481
476
  prefsObj = {};
482
477
  }
483
478
  }
484
479
 
485
- // Set or delete the value
486
- if (value === undefined) {
487
- delete prefsObj[prefsKey];
488
- } else {
489
- prefsObj[prefsKey] = value;
490
- }
480
+ setNestedValue(prefsObj, path, value);
481
+ pruneEmptyObjects(prefsObj);
491
482
 
492
- // Atomic write: tmp + rename
493
483
  const json = JSON.stringify(prefsObj, null, 2) + "\n";
494
484
  writeFileSync(tmpPath, json, "utf-8");
495
485
  try {
496
486
  renameSync(tmpPath, prefsPath);
497
487
  } catch {
498
- // Windows fallback: direct write if rename fails
499
488
  writeFileSync(prefsPath, json, "utf-8");
500
489
  try { if (existsSync(tmpPath)) unlinkSync(tmpPath); } catch { /* cleanup best-effort */ }
501
490
  }
@@ -512,7 +501,7 @@ export function writeUserPreference(
512
501
  */
513
502
  export function coerceValueForWrite(field: FieldDef, rawValue: string): any {
514
503
  // Strip source badge if present
515
- const cleaned = rawValue.replace(/\s+\((?:default|project|user)\)$/, "").trim();
504
+ const cleaned = rawValue.replace(/\s+\((?:default|project|global)\)$/, "").trim();
516
505
 
517
506
  // Unset / inherit → undefined (delete key)
518
507
  if (cleaned === "(not set)" || cleaned === "(inherit)") {
@@ -534,35 +523,20 @@ export function coerceValueForWrite(field: FieldDef, rawValue: string): any {
534
523
  }
535
524
 
536
525
  /**
537
- * Determine the write destination for a field change.
526
+ * Write destinations for settings edits.
538
527
  *
539
- * - L1-only "project"
540
- * - L2-only → "prefs"
541
- * - L1+L2 must be chosen by the user (returns null to signal "ask user")
528
+ * - `prefs`: write to global preferences (default)
529
+ * - `project`: write a project-specific override
530
+ * - `remove-project`: delete an existing project override (revert to global)
542
531
  */
543
- export type WriteDestination = "project" | "prefs";
532
+ export type WriteDestination = "project" | "prefs" | "remove-project";
544
533
 
545
- export function getDefaultWriteDestination(field: FieldDef): WriteDestination | null {
546
- if (field.layer === "L1") return "project";
547
- if (field.layer === "L2") return "prefs";
548
- // L1+L2 → user must choose
549
- return null;
534
+ export function getDefaultWriteDestination(_field: FieldDef): WriteDestination {
535
+ return "prefs";
550
536
  }
551
537
 
552
538
  /**
553
539
  * Resolve the write action for a field change.
554
- *
555
- * Encapsulates the destination + confirmation decision tree from
556
- * showSectionSettingsLoop as a pure function for testability.
557
- *
558
- * @param field - The field being edited
559
- * @param destinationChoice - For L1+L2 fields: the user's choice from the
560
- * destination select ("User preferences (personal)", "Project config (shared)",
561
- * "Cancel", or null). Ignored for L1-only and L2-only fields.
562
- * @param projectConfirmed - For project-destination writes: whether the user
563
- * confirmed the project config change. Ignored for prefs-destination writes.
564
- * @returns The resolved destination ("project" | "prefs") or "skip" if the
565
- * user cancelled or declined confirmation.
566
540
  */
567
541
  export function resolveWriteAction(
568
542
  field: FieldDef,
@@ -570,18 +544,17 @@ export function resolveWriteAction(
570
544
  projectConfirmed: boolean,
571
545
  ): WriteDestination | "skip" {
572
546
  const defaultDest = getDefaultWriteDestination(field);
573
- let dest: WriteDestination | null = defaultDest;
574
547
 
575
- // L1+L2 fields: resolve from user's destination choice
576
- if (dest === null) {
577
- if (!destinationChoice || destinationChoice === "Cancel") return "skip";
578
- dest = destinationChoice.startsWith("User") ? "prefs" : "project";
579
- }
548
+ // L2-only fields have no project layer
549
+ if (field.layer === "L2") return "prefs";
580
550
 
581
- // Confirmation gate for project config writes
582
- if (dest === "project" && !projectConfirmed) return "skip";
551
+ if (!destinationChoice || destinationChoice === "Cancel") return "skip";
552
+ if (destinationChoice.startsWith("Global") || destinationChoice.startsWith("User")) return "prefs";
553
+ if (destinationChoice.startsWith("Remove project override")) return "remove-project";
554
+ if (destinationChoice.startsWith("Project") && !projectConfirmed) return "skip";
555
+ if (destinationChoice.startsWith("Project")) return "project";
583
556
 
584
- return dest;
557
+ return defaultDest;
585
558
  }
586
559
 
587
560
 
@@ -604,47 +577,21 @@ function getNestedValue(obj: any, path: string): any {
604
577
  /**
605
578
  * Determine the source of a field's current value.
606
579
  *
607
- * Implements the source-badge rules from Step 1:
608
- * - For L1+L2 fields: check user prefs first (type-specific "is set" rules)
609
- * - Then check raw project config
610
- * - Fallback to default
580
+ * Source badge policy:
581
+ * - `(project)` when the field is explicitly present in project config JSON/YAML
582
+ * - `(global)` otherwise (global preferences baseline + schema defaults)
611
583
  */
612
584
  export function detectFieldSource(
613
585
  field: FieldDef,
614
586
  rawProjectConfig: Record<string, any> | null,
615
- rawPrefs: Record<string, any> | null,
587
+ _rawPrefs: Record<string, any> | null,
616
588
  ): FieldSource {
617
- // L2 check for dual-layer and L2-only fields.
618
- // Type guards MUST match extractAllowlistedPreferences() in config-loader.ts
619
- // to avoid showing "(user)" for values that the merge layer would reject.
620
- if ((field.layer === "L1+L2" || field.layer === "L2") && field.prefsKey && rawPrefs) {
621
- const prefVal = rawPrefs[field.prefsKey];
622
- if (field.fieldType === "string") {
623
- // String rule: must be typeof string, non-empty → (user)
624
- // Matches: `typeof raw.X === "string"` AND applyUserPreferences `val !== "" `
625
- if (typeof prefVal === "string" && prefVal !== "") return "user";
626
- } else if (field.fieldType === "enum") {
627
- // Enum rule: must be a valid enum value from the field's values array.
628
- // Matches extractAllowlistedPreferences which checks exact enum membership
629
- // (e.g., raw.spawnMode === "subprocess").
630
- if (prefVal !== undefined && field.values && field.values.includes(String(prefVal))) return "user";
631
- } else if (field.fieldType === "number") {
632
- // Number rule: must be typeof number and finite → (user)
633
- // Matches: `typeof raw.X === "number" && Number.isFinite(raw.X)`
634
- if (typeof prefVal === "number" && Number.isFinite(prefVal)) return "user";
635
- }
636
- }
637
-
638
- // L2-only fields have no project layer
639
- if (field.layer === "L2") return "default";
640
-
641
- // L1 check: look in raw project config
642
- if (rawProjectConfig) {
589
+ if (field.layer !== "L2" && rawProjectConfig) {
643
590
  const val = getNestedValue(rawProjectConfig, field.configPath);
644
591
  if (val !== undefined) return "project";
645
592
  }
646
593
 
647
- return "default";
594
+ return "global";
648
595
  }
649
596
 
650
597
 
@@ -656,7 +603,7 @@ export function detectFieldSource(
656
603
  export function getFieldDisplayValue(
657
604
  field: FieldDef,
658
605
  mergedConfig: TaskplaneConfig,
659
- prefs: UserPreferences,
606
+ prefs: GlobalPreferences,
660
607
  ): string {
661
608
  // Special case: dashboardPort (L2-only, not in merged config)
662
609
  if (field.configPath === "preferences.dashboardPort") {
@@ -668,9 +615,6 @@ export function getFieldDisplayValue(
668
615
 
669
616
  // Optional fields may be undefined
670
617
  if (val === undefined) {
671
- if (field.optional && field.configPath === "taskRunner.worker.spawnMode") {
672
- return "(inherit)";
673
- }
674
618
  return "(not set)";
675
619
  }
676
620
 
@@ -1003,18 +947,25 @@ async function pickModel(ctx: ExtensionContext, currentModel: string): Promise<s
1003
947
  }
1004
948
  }
1005
949
 
1006
- type ThinkingModeValue = "" | "on" | "off";
950
+ type ThinkingModeValue = "" | "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
1007
951
 
1008
952
  const THINKING_MODE_OPTIONS: Array<{ value: ThinkingModeValue; label: string }> = [
1009
953
  { value: "", label: "inherit (use session thinking)" },
1010
- { value: "on", label: "on" },
1011
954
  { value: "off", label: "off" },
955
+ { value: "minimal", label: "minimal" },
956
+ { value: "low", label: "low" },
957
+ { value: "medium", label: "medium" },
958
+ { value: "high", label: "high" },
959
+ { value: "xhigh", label: "xhigh" },
1012
960
  ];
1013
961
 
1014
962
  function normalizeThinkingMode(value: unknown): ThinkingModeValue {
1015
963
  const cleaned = String(value ?? "").trim().toLowerCase();
1016
- if (cleaned === "on") return "on";
1017
- if (cleaned === "off") return "off";
964
+ if (!cleaned || cleaned === "inherit") return "";
965
+ if (cleaned === "on") return "high";
966
+ if (["off", "minimal", "low", "medium", "high", "xhigh"].includes(cleaned)) {
967
+ return cleaned as ThinkingModeValue;
968
+ }
1018
969
  return "";
1019
970
  }
1020
971
 
@@ -1023,11 +974,12 @@ async function pickThinkingMode(
1023
974
  currentThinking: string,
1024
975
  ): Promise<ThinkingModeValue | undefined> {
1025
976
  const current = normalizeThinkingMode(currentThinking);
977
+ const resolvedCurrent: ThinkingModeValue = current || "high";
1026
978
  const optionToValue = new Map<string, ThinkingModeValue>();
1027
979
  const optionLabels: string[] = [];
1028
980
 
1029
981
  for (const option of THINKING_MODE_OPTIONS) {
1030
- const label = `${option.label}${option.value === current ? " ✓ current" : ""}`;
982
+ const label = `${option.label}${option.value === resolvedCurrent ? " ✓ current" : ""}`;
1031
983
  optionLabels.push(label);
1032
984
  optionToValue.set(label, option.value);
1033
985
  }
@@ -1043,6 +995,12 @@ const MODEL_THINKING_PATH_MAP: Record<string, { thinkingPath: string; label: str
1043
995
  "orchestrator.merge.model": { thinkingPath: "orchestrator.merge.thinking", label: "Merge" },
1044
996
  };
1045
997
 
998
+ const THINKING_MODEL_PATH_MAP: Record<string, { modelPath: string; label: string }> = {
999
+ "taskRunner.worker.thinking": { modelPath: "taskRunner.worker.model", label: "Worker" },
1000
+ "taskRunner.reviewer.thinking": { modelPath: "taskRunner.reviewer.model", label: "Reviewer" },
1001
+ "orchestrator.merge.thinking": { modelPath: "orchestrator.merge.model", label: "Merge" },
1002
+ };
1003
+
1046
1004
  function resolveModelRecord(ctx: ExtensionContext, modelRef: string): any | undefined {
1047
1005
  const trimmed = modelRef.trim();
1048
1006
  if (!trimmed) return undefined;
@@ -1096,6 +1054,10 @@ export function modelSupportsThinking(model: any): boolean {
1096
1054
  for (const candidate of candidateObjects) {
1097
1055
  for (const key of boolFlags) {
1098
1056
  if (typeof candidate[key] === "boolean" && candidate[key]) return true;
1057
+ if (typeof candidate[key] === "string") {
1058
+ const normalized = candidate[key].trim().toLowerCase();
1059
+ if (["yes", "true", "on", "supported"].includes(normalized)) return true;
1060
+ }
1099
1061
  }
1100
1062
  for (const key of capabilityKeys) {
1101
1063
  if (candidate[key] !== undefined && candidate[key] !== null) return true;
@@ -1123,9 +1085,26 @@ export function buildThinkingSuggestionForModelChange(
1123
1085
  if (!modelRecord || !modelSupportsThinking(modelRecord)) return null;
1124
1086
 
1125
1087
  const currentThinking = normalizeThinkingMode(getNestedValue(mergedConfig, mapping.thinkingPath));
1126
- if (currentThinking === "on") return null;
1088
+ if (currentThinking === "high") return null;
1127
1089
 
1128
- return `${mapping.label} model supports thinking. Consider setting ${mapping.label} Thinking to \"on\".`;
1090
+ return `${mapping.label} model supports thinking. Consider setting ${mapping.label} Thinking to \"high\".`;
1091
+ }
1092
+
1093
+ export function buildThinkingUnsupportedNoteForThinkingField(
1094
+ ctx: ExtensionContext,
1095
+ field: FieldDef,
1096
+ mergedConfig: TaskplaneConfig,
1097
+ ): string | null {
1098
+ const mapping = THINKING_MODEL_PATH_MAP[field.configPath];
1099
+ if (!mapping) return null;
1100
+
1101
+ const modelRef = String(getNestedValue(mergedConfig, mapping.modelPath) ?? "").trim();
1102
+ if (!modelRef) return null;
1103
+
1104
+ const modelRecord = resolveModelRecord(ctx, modelRef);
1105
+ if (!modelRecord || modelSupportsThinking(modelRecord)) return null;
1106
+
1107
+ return `${mapping.label} model does not advertise thinking support. You can still set thinking; unsupported models ignore it at runtime.`;
1129
1108
  }
1130
1109
 
1131
1110
  /**
@@ -1198,14 +1177,14 @@ export async function openSettingsTui(
1198
1177
  */
1199
1178
  function loadConfigState(configRoot: string, pointerConfigRoot?: string): {
1200
1179
  mergedConfig: TaskplaneConfig;
1201
- prefs: UserPreferences;
1180
+ prefs: GlobalPreferences;
1202
1181
  rawProject: Record<string, any> | null;
1203
1182
  rawPrefs: Record<string, any> | null;
1204
1183
  } {
1205
1184
  const resolvedRoot = resolveConfigRoot(configRoot, pointerConfigRoot);
1206
1185
  return {
1207
1186
  mergedConfig: loadProjectConfig(configRoot, pointerConfigRoot),
1208
- prefs: loadUserPreferences(),
1187
+ prefs: loadGlobalPreferences(),
1209
1188
  rawProject: readRawProjectJson(resolvedRoot) || readRawYamlConfigs(resolvedRoot),
1210
1189
  rawPrefs: readRawPreferences(),
1211
1190
  };
@@ -1340,9 +1319,8 @@ async function showAdvancedSection(
1340
1319
  */
1341
1320
  function formatSourceBadge(source: FieldSource): string {
1342
1321
  switch (source) {
1343
- case "default": return "(default)";
1344
1322
  case "project": return "(project)";
1345
- case "user": return "(user)";
1323
+ case "global": return "(global)";
1346
1324
  }
1347
1325
  }
1348
1326
 
@@ -1378,7 +1356,7 @@ async function showSectionSettingsLoop(
1378
1356
  if (result.rawValue === "__EDIT_REQUESTED__" && (field.control === "input" || field.control === "picker")) {
1379
1357
  const state = loadConfigState(configRoot, pointerConfigRoot);
1380
1358
  const currentDisplay = getFieldDisplayValue(field, state.mergedConfig, state.prefs);
1381
- const currentClean = String(currentDisplay).replace(/\s+\((?:default|project|user)\)$/, "");
1359
+ const currentClean = String(currentDisplay).replace(/\s+\((?:default|project|global)\)$/, "");
1382
1360
  const normalizedCurrent = currentClean === "(inherit)" ? "" : currentClean;
1383
1361
 
1384
1362
  // Model fields: use interactive provider → model picker instead of free-text
@@ -1388,6 +1366,8 @@ async function showSectionSettingsLoop(
1388
1366
  if (selected === undefined) continue; // Cancelled
1389
1367
  result.rawValue = selected;
1390
1368
  } else if (field.control === "picker" && field.configPath.endsWith(".thinking")) {
1369
+ const note = buildThinkingUnsupportedNoteForThinkingField(ctx, field, state.mergedConfig);
1370
+ if (note) ctx.ui.notify(note, "info");
1391
1371
  const selected = await pickThinkingMode(ctx, normalizedCurrent);
1392
1372
  if (selected === undefined) continue; // Cancelled
1393
1373
  result.rawValue = selected;
@@ -1421,30 +1401,28 @@ async function showSectionSettingsLoop(
1421
1401
  }
1422
1402
 
1423
1403
  const typedValue = coerceValueForWrite(field, result.rawValue);
1404
+ const hasProjectOverride =
1405
+ field.layer !== "L2" &&
1406
+ !!state.rawProject &&
1407
+ getNestedValue(state.rawProject, field.configPath) !== undefined;
1424
1408
 
1425
1409
  // Collect UI answers for the write-decision contract
1426
1410
  let destinationChoice: string | null = null;
1427
- if (getDefaultWriteDestination(field) === null) {
1428
- // L1+L2 fields: ask user where to save
1429
- destinationChoice = await ctx.ui.select(
1430
- "Save this change to:",
1431
- [
1432
- "User preferences (personal)",
1433
- "Project config (shared)",
1434
- "Cancel",
1435
- ],
1436
- );
1411
+ if (field.layer !== "L2") {
1412
+ const options = [
1413
+ "Global preferences (default)",
1414
+ "Project override (this project only)",
1415
+ ...(hasProjectOverride ? ["Remove project override (revert to global)"] : []),
1416
+ "Cancel",
1417
+ ];
1418
+ destinationChoice = await ctx.ui.select("Save this change to:", options);
1437
1419
  }
1438
1420
 
1439
1421
  let projectConfirmed = true;
1440
- // Only ask for confirmation if the resolved dest will be "project"
1441
- const needsProjectConfirm =
1442
- (field.layer === "L1") ||
1443
- (field.layer === "L1+L2" && destinationChoice?.startsWith("Project"));
1444
- if (needsProjectConfirm) {
1422
+ if (destinationChoice?.startsWith("Project override")) {
1445
1423
  projectConfirmed = await ctx.ui.confirm(
1446
- "Confirm project config change",
1447
- "This writes to .pi/taskplane-config.json (shared project config). Continue?",
1424
+ "Confirm project override",
1425
+ "This writes to .pi/taskplane-config.json as a project override. Continue?",
1448
1426
  );
1449
1427
  }
1450
1428
 
@@ -1455,11 +1433,10 @@ async function showSectionSettingsLoop(
1455
1433
  try {
1456
1434
  if (dest === "project") {
1457
1435
  writeProjectConfigField(configRoot, field.configPath, typedValue, pointerConfigRoot);
1436
+ } else if (dest === "remove-project") {
1437
+ writeProjectConfigField(configRoot, field.configPath, undefined, pointerConfigRoot);
1458
1438
  } else {
1459
- // L2 write — use prefsKey
1460
- if (field.prefsKey) {
1461
- writeUserPreference(field.prefsKey, typedValue);
1462
- }
1439
+ writeGlobalPreference(toGlobalPreferencePath(field), typedValue);
1463
1440
  }
1464
1441
  ctx.ui.notify(
1465
1442
  `✅ ${field.label} updated.\n` +
@@ -1500,7 +1477,7 @@ async function showSectionSettingsOnce(
1500
1477
  ctx: ExtensionContext,
1501
1478
  section: SectionDef,
1502
1479
  mergedConfig: TaskplaneConfig,
1503
- prefs: UserPreferences,
1480
+ prefs: GlobalPreferences,
1504
1481
  rawProject: Record<string, any> | null,
1505
1482
  rawPrefs: Record<string, any> | null,
1506
1483
  ): Promise<PendingChange | null> {
@@ -1597,7 +1574,7 @@ function createInputSubmenu(
1597
1574
  done: (selectedValue?: string) => void,
1598
1575
  ): any {
1599
1576
  // Strip source badge from current value for editing
1600
- const cleanValue = currentValue.replace(/\s+\((?:default|project|user)\)$/, "");
1577
+ const cleanValue = currentValue.replace(/\s+\((?:default|project|global)\)$/, "");
1601
1578
  let inputBuffer = cleanValue === "(not set)" || cleanValue === "(inherit)" ? "" : cleanValue;
1602
1579
  let errorMsg = "";
1603
1580
  let cursorPos = inputBuffer.length;
@@ -1845,7 +1845,7 @@ export function presentBatchSummary(
1845
1845
  export type SupervisorAutonomyLevel = "interactive" | "supervised" | "autonomous";
1846
1846
 
1847
1847
  /**
1848
- * Supervisor configuration resolved from project config + user preferences.
1848
+ * Supervisor configuration resolved from project config + global preferences.
1849
1849
  *
1850
1850
  * @since TP-041
1851
1851
  */
@@ -3070,7 +3070,7 @@ export function registerSupervisorPromptHook(
3070
3070
  * Resolve supervisor configuration from available sources.
3071
3071
  *
3072
3072
  * Resolution order (highest precedence first):
3073
- * 1. User preferences (supervisorModel → orchestrator.supervisor.model)
3073
+ * 1. Global preferences (supervisorModel → orchestrator.supervisor.model)
3074
3074
  * 2. Project config (orchestrator.supervisor section in taskplane-config.json)
3075
3075
  * 3. Defaults (model="" = inherit session model, autonomy="supervised")
3076
3076
  *
@@ -282,7 +282,7 @@ export const DEFAULT_ORCHESTRATOR_CONFIG: OrchestratorConfig = {
282
282
  merge: {
283
283
  model: "",
284
284
  tools: "read,write,edit,bash,grep,find,ls",
285
- thinking: "",
285
+ thinking: "off",
286
286
  verify: [],
287
287
  order: "fewest-files-first",
288
288
  timeout_minutes: 90,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.24.21",
3
+ "version": "0.24.22",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",