taskplane 0.3.1 → 0.4.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.
@@ -0,0 +1,860 @@
1
+ /**
2
+ * Unified config loader for taskplane-config.json with YAML fallback
3
+ * and user preferences (Layer 2) merge.
4
+ *
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
11
+ *
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.
17
+ *
18
+ * Path resolution:
19
+ * Resolves config paths relative to `configRoot`. Callers should pass
20
+ * the project root (or TASKPLANE_WORKSPACE_ROOT fallback) as `configRoot`.
21
+ *
22
+ * All returned objects are deep-cloned from defaults — no cross-call mutation.
23
+ *
24
+ * @module config/loader
25
+ */
26
+
27
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
28
+ import { join } from "path";
29
+ import { homedir } from "os";
30
+ import { parse as yamlParse } from "yaml";
31
+
32
+ import {
33
+ CONFIG_VERSION,
34
+ PROJECT_CONFIG_FILENAME,
35
+ DEFAULT_PROJECT_CONFIG,
36
+ DEFAULT_TASK_RUNNER_SECTION,
37
+ DEFAULT_ORCHESTRATOR_SECTION,
38
+ DEFAULT_USER_PREFERENCES,
39
+ USER_PREFERENCES_FILENAME,
40
+ USER_PREFERENCES_SUBDIR,
41
+ } from "./config-schema.ts";
42
+ import type {
43
+ TaskplaneConfig,
44
+ TaskRunnerSection,
45
+ OrchestratorSection,
46
+ UserPreferences,
47
+ } from "./config-schema.ts";
48
+
49
+
50
+ // ── Error Types ──────────────────────────────────────────────────────
51
+
52
+ /**
53
+ * Error codes for config loading failures.
54
+ *
55
+ * - CONFIG_JSON_MALFORMED: File exists but is not valid JSON
56
+ * - CONFIG_VERSION_UNSUPPORTED: configVersion is not supported by this version
57
+ * - CONFIG_VERSION_MISSING: configVersion field is missing from JSON
58
+ */
59
+ export type ConfigLoadErrorCode =
60
+ | "CONFIG_JSON_MALFORMED"
61
+ | "CONFIG_VERSION_UNSUPPORTED"
62
+ | "CONFIG_VERSION_MISSING";
63
+
64
+ export class ConfigLoadError extends Error {
65
+ code: ConfigLoadErrorCode;
66
+
67
+ constructor(code: ConfigLoadErrorCode, message: string) {
68
+ super(message);
69
+ this.name = "ConfigLoadError";
70
+ this.code = code;
71
+ }
72
+ }
73
+
74
+
75
+ // ── Deep Clone Helper ────────────────────────────────────────────────
76
+
77
+ /** Deep clone a config object to avoid cross-call mutation. */
78
+ function deepClone<T>(obj: T): T {
79
+ return JSON.parse(JSON.stringify(obj));
80
+ }
81
+
82
+
83
+ // ── Deep Merge Helper ────────────────────────────────────────────────
84
+
85
+ /**
86
+ * Deep merge `source` into `target`. Arrays are replaced, not merged.
87
+ * Only merges plain objects (not arrays, dates, etc).
88
+ * Returns `target` for chaining.
89
+ */
90
+ function deepMerge<T extends Record<string, any>>(target: T, source: Record<string, any>): T {
91
+ for (const key of Object.keys(source)) {
92
+ const srcVal = source[key];
93
+ const tgtVal = (target as any)[key];
94
+ if (
95
+ srcVal !== null &&
96
+ srcVal !== undefined &&
97
+ typeof srcVal === "object" &&
98
+ !Array.isArray(srcVal) &&
99
+ tgtVal !== null &&
100
+ tgtVal !== undefined &&
101
+ typeof tgtVal === "object" &&
102
+ !Array.isArray(tgtVal)
103
+ ) {
104
+ deepMerge(tgtVal, srcVal);
105
+ } else if (srcVal !== undefined) {
106
+ (target as any)[key] = srcVal;
107
+ }
108
+ }
109
+ return target;
110
+ }
111
+
112
+
113
+ // ── YAML snake_case → camelCase Mapping ──────────────────────────────
114
+
115
+ /**
116
+ * Convert a snake_case key to camelCase.
117
+ * e.g., "max_worker_iterations" → "maxWorkerIterations"
118
+ */
119
+ function snakeToCamel(s: string): string {
120
+ return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
121
+ }
122
+
123
+ /**
124
+ * Convert structural keys from snake_case to camelCase, recursively.
125
+ * Used for sections where ALL keys are structural schema keys (no
126
+ * user-defined dictionary keys).
127
+ */
128
+ function convertStructuralKeys(obj: any): any {
129
+ if (obj === null || obj === undefined) return obj;
130
+ if (Array.isArray(obj)) return obj.map(convertStructuralKeys);
131
+ if (typeof obj !== "object") return obj;
132
+
133
+ const result: Record<string, any> = {};
134
+ for (const [key, val] of Object.entries(obj)) {
135
+ const camelKey = snakeToCamel(key);
136
+ if (val !== null && typeof val === "object" && !Array.isArray(val)) {
137
+ result[camelKey] = convertStructuralKeys(val);
138
+ } else if (Array.isArray(val)) {
139
+ result[camelKey] = val.map(convertStructuralKeys);
140
+ } else {
141
+ result[camelKey] = val;
142
+ }
143
+ }
144
+ return result;
145
+ }
146
+
147
+ /**
148
+ * Convert a record/dictionary section where outer keys are user-defined
149
+ * identifiers (preserve verbatim) but inner keys are structural (convert).
150
+ */
151
+ function convertRecordSection(obj: any): any {
152
+ if (obj === null || obj === undefined) return obj;
153
+ if (typeof obj !== "object" || Array.isArray(obj)) return obj;
154
+
155
+ const result: Record<string, any> = {};
156
+ for (const [key, val] of Object.entries(obj)) {
157
+ // Preserve user-defined key verbatim, convert structural inner keys
158
+ if (val !== null && typeof val === "object" && !Array.isArray(val)) {
159
+ result[key] = convertStructuralKeys(val);
160
+ } else {
161
+ result[key] = val;
162
+ }
163
+ }
164
+ return result;
165
+ }
166
+
167
+ /**
168
+ * Convert a flat record/dictionary where both keys and values are
169
+ * user-defined (preserve everything verbatim). Used for sections like
170
+ * `reference_docs`, `self_doc_targets`, `testing.commands` where
171
+ * keys are identifiers and values are strings.
172
+ */
173
+ function preserveRecord(obj: any): any {
174
+ if (obj === null || obj === undefined) return obj;
175
+ if (typeof obj !== "object" || Array.isArray(obj)) return obj;
176
+ return { ...obj };
177
+ }
178
+
179
+ // ── Section-aware YAML mapping ───────────────────────────────────────
180
+
181
+ /**
182
+ * Map a raw task-runner YAML object to the camelCase TaskRunnerSection shape.
183
+ *
184
+ * Knows which sections contain user-defined record keys vs. structural keys:
185
+ * - Structural-only: project, paths, worker, reviewer, context, standards
186
+ * - Record with structural inner keys: task_areas, standards_overrides
187
+ * - Flat record (preserve all keys): testing.commands, reference_docs,
188
+ * self_doc_targets
189
+ * - Array (preserve): never_load, protected_docs
190
+ */
191
+ function mapTaskRunnerYaml(raw: any): Partial<TaskRunnerSection> {
192
+ const result: any = {};
193
+
194
+ // Structural sections — all keys are schema-defined
195
+ if (raw.project) result.project = convertStructuralKeys(raw.project);
196
+ if (raw.paths) result.paths = convertStructuralKeys(raw.paths);
197
+ if (raw.worker) result.worker = convertStructuralKeys(raw.worker);
198
+ if (raw.reviewer) result.reviewer = convertStructuralKeys(raw.reviewer);
199
+ if (raw.context) result.context = convertStructuralKeys(raw.context);
200
+ if (raw.standards) result.standards = convertStructuralKeys(raw.standards);
201
+
202
+ // Testing: commands is a flat user-defined record
203
+ if (raw.testing) {
204
+ result.testing = {};
205
+ if (raw.testing.commands) {
206
+ result.testing.commands = preserveRecord(raw.testing.commands);
207
+ }
208
+ }
209
+
210
+ // Record sections with structural inner keys
211
+ if (raw.task_areas) result.taskAreas = convertRecordSection(raw.task_areas);
212
+ if (raw.standards_overrides) result.standardsOverrides = convertRecordSection(raw.standards_overrides);
213
+
214
+ // Flat record sections (keys are identifiers, values are strings)
215
+ if (raw.reference_docs) result.referenceDocs = preserveRecord(raw.reference_docs);
216
+ if (raw.self_doc_targets) result.selfDocTargets = preserveRecord(raw.self_doc_targets);
217
+
218
+ // Array sections (preserve verbatim)
219
+ if (raw.never_load) result.neverLoad = [...raw.never_load];
220
+ if (raw.protected_docs) result.protectedDocs = [...raw.protected_docs];
221
+
222
+ return result;
223
+ }
224
+
225
+ /**
226
+ * Map a raw orchestrator YAML object to the camelCase OrchestratorSection shape.
227
+ *
228
+ * Knows which sections contain user-defined record keys:
229
+ * - Structural: orchestrator, dependencies, merge, failure, monitoring
230
+ * - Record with structural inner keys: (none)
231
+ * - Flat record (preserve keys): pre_warm.commands, assignment.size_weights
232
+ */
233
+ function mapOrchestratorYaml(raw: any): Partial<OrchestratorSection> {
234
+ const result: any = {};
235
+
236
+ // Structural sections
237
+ if (raw.orchestrator) result.orchestrator = convertStructuralKeys(raw.orchestrator);
238
+ if (raw.dependencies) result.dependencies = convertStructuralKeys(raw.dependencies);
239
+ if (raw.merge) result.merge = convertStructuralKeys(raw.merge);
240
+ if (raw.failure) result.failure = convertStructuralKeys(raw.failure);
241
+ if (raw.monitoring) result.monitoring = convertStructuralKeys(raw.monitoring);
242
+
243
+ // assignment: strategy is structural, size_weights is a user-defined record
244
+ if (raw.assignment) {
245
+ result.assignment = {};
246
+ if (raw.assignment.strategy !== undefined) result.assignment.strategy = raw.assignment.strategy;
247
+ if (raw.assignment.size_weights) result.assignment.sizeWeights = preserveRecord(raw.assignment.size_weights);
248
+ }
249
+
250
+ // pre_warm: auto_detect is structural, commands is user-defined, always is array
251
+ if (raw.pre_warm) {
252
+ result.preWarm = {};
253
+ if (raw.pre_warm.auto_detect !== undefined) result.preWarm.autoDetect = raw.pre_warm.auto_detect;
254
+ if (raw.pre_warm.commands) result.preWarm.commands = preserveRecord(raw.pre_warm.commands);
255
+ if (raw.pre_warm.always) result.preWarm.always = [...raw.pre_warm.always];
256
+ }
257
+
258
+ return result;
259
+ }
260
+
261
+
262
+ // ── Config File Path Resolution ──────────────────────────────────────
263
+
264
+ /**
265
+ * Resolve the path to a config file under the given root.
266
+ *
267
+ * Supports two directory layouts:
268
+ * 1. Standard layout: `<root>/.pi/<filename>` — used by repo mode and
269
+ * workspace root, where config files live under the `.pi/` subdirectory.
270
+ * 2. Flat layout: `<root>/<filename>` — used by pointer-resolved config
271
+ * roots (e.g., `<configRepo>/.taskplane/task-runner.yaml`), where
272
+ * `taskplane init` scaffolds files directly in the config path.
273
+ *
274
+ * Standard layout is checked first for backward compatibility. If neither
275
+ * exists, returns the standard-layout path (callers check existence).
276
+ */
277
+ function resolveConfigFilePath(configRoot: string, filename: string): string {
278
+ const standardPath = join(configRoot, ".pi", filename);
279
+ if (existsSync(standardPath)) return standardPath;
280
+
281
+ const flatPath = join(configRoot, filename);
282
+ if (existsSync(flatPath)) return flatPath;
283
+
284
+ // Default to standard path — callers handle non-existence
285
+ return standardPath;
286
+ }
287
+
288
+ // ── JSON Loading ─────────────────────────────────────────────────────
289
+
290
+ /**
291
+ * Attempt to load and validate `taskplane-config.json`.
292
+ *
293
+ * Checks both standard layout (`<root>/.pi/taskplane-config.json`) and
294
+ * flat layout (`<root>/taskplane-config.json`) — see `resolveConfigFilePath`.
295
+ *
296
+ * Returns the parsed config or null if the file doesn't exist.
297
+ * Throws ConfigLoadError for malformed JSON or unsupported versions.
298
+ */
299
+ function loadJsonConfig(configRoot: string): TaskplaneConfig | null {
300
+ const jsonPath = resolveConfigFilePath(configRoot, PROJECT_CONFIG_FILENAME);
301
+ if (!existsSync(jsonPath)) return null;
302
+
303
+ let raw: string;
304
+ try {
305
+ raw = readFileSync(jsonPath, "utf-8");
306
+ } catch {
307
+ return null; // Can't read file — treat as absent
308
+ }
309
+
310
+ let parsed: any;
311
+ try {
312
+ parsed = JSON.parse(raw);
313
+ } catch (e: any) {
314
+ throw new ConfigLoadError(
315
+ "CONFIG_JSON_MALFORMED",
316
+ `Failed to parse ${jsonPath}: ${e.message ?? "invalid JSON"}`,
317
+ );
318
+ }
319
+
320
+ // Validate configVersion
321
+ if (parsed.configVersion === undefined || parsed.configVersion === null) {
322
+ throw new ConfigLoadError(
323
+ "CONFIG_VERSION_MISSING",
324
+ `${jsonPath} is missing required field "configVersion". ` +
325
+ `Expected configVersion: ${CONFIG_VERSION}.`,
326
+ );
327
+ }
328
+
329
+ if (parsed.configVersion !== CONFIG_VERSION) {
330
+ throw new ConfigLoadError(
331
+ "CONFIG_VERSION_UNSUPPORTED",
332
+ `${jsonPath} has configVersion ${parsed.configVersion}, but this version of Taskplane ` +
333
+ `only supports configVersion ${CONFIG_VERSION}. Please upgrade Taskplane.`,
334
+ );
335
+ }
336
+
337
+ // Deep merge with cloned defaults
338
+ const config = deepClone(DEFAULT_PROJECT_CONFIG);
339
+ if (parsed.taskRunner) {
340
+ deepMerge(config.taskRunner, parsed.taskRunner);
341
+ }
342
+ if (parsed.orchestrator) {
343
+ deepMerge(config.orchestrator, parsed.orchestrator);
344
+ }
345
+
346
+ return config;
347
+ }
348
+
349
+
350
+ // ── YAML Loading ─────────────────────────────────────────────────────
351
+
352
+ /**
353
+ * Load task-runner settings from `task-runner.yaml`.
354
+ *
355
+ * Checks both standard layout (`<root>/.pi/task-runner.yaml`) and
356
+ * flat layout (`<root>/task-runner.yaml`) — see `resolveConfigFilePath`.
357
+ * Maps snake_case YAML keys to the camelCase TaskRunnerSection shape.
358
+ * Uses section-aware mapping that preserves user-defined record keys.
359
+ * Returns cloned defaults if the file doesn't exist or is malformed.
360
+ */
361
+ function loadTaskRunnerYaml(configRoot: string): TaskRunnerSection {
362
+ const yamlPath = resolveConfigFilePath(configRoot, "task-runner.yaml");
363
+ if (!existsSync(yamlPath)) return deepClone(DEFAULT_TASK_RUNNER_SECTION);
364
+
365
+ try {
366
+ const raw = readFileSync(yamlPath, "utf-8");
367
+ const loaded = yamlParse(raw) as any;
368
+ if (!loaded || typeof loaded !== "object") return deepClone(DEFAULT_TASK_RUNNER_SECTION);
369
+
370
+ // Section-aware mapping: structural keys → camelCase, record keys → preserved
371
+ const mapped = mapTaskRunnerYaml(loaded);
372
+
373
+ // Deep merge with cloned defaults
374
+ const section = deepClone(DEFAULT_TASK_RUNNER_SECTION);
375
+ deepMerge(section, mapped);
376
+
377
+ // Post-process taskAreas: trim repoId, drop whitespace-only values
378
+ // (matches legacy loadTaskRunnerConfig behavior from config.ts)
379
+ if (section.taskAreas) {
380
+ for (const area of Object.values(section.taskAreas)) {
381
+ if (area.repoId !== undefined) {
382
+ const trimmed = typeof area.repoId === "string" ? area.repoId.trim() : "";
383
+ if (trimmed) {
384
+ area.repoId = trimmed;
385
+ } else {
386
+ delete area.repoId;
387
+ }
388
+ }
389
+ }
390
+ }
391
+
392
+ return section;
393
+ } catch {
394
+ return deepClone(DEFAULT_TASK_RUNNER_SECTION);
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Load orchestrator settings from `task-orchestrator.yaml`.
400
+ *
401
+ * Checks both standard layout (`<root>/.pi/task-orchestrator.yaml`) and
402
+ * flat layout (`<root>/task-orchestrator.yaml`) — see `resolveConfigFilePath`.
403
+ * Maps snake_case YAML keys to the camelCase OrchestratorSection shape.
404
+ * Uses section-aware mapping that preserves user-defined record keys.
405
+ * Returns cloned defaults if the file doesn't exist or is malformed.
406
+ */
407
+ function loadOrchestratorYaml(configRoot: string): OrchestratorSection {
408
+ const yamlPath = resolveConfigFilePath(configRoot, "task-orchestrator.yaml");
409
+ if (!existsSync(yamlPath)) return deepClone(DEFAULT_ORCHESTRATOR_SECTION);
410
+
411
+ try {
412
+ const raw = readFileSync(yamlPath, "utf-8");
413
+ const loaded = yamlParse(raw) as any;
414
+ if (!loaded || typeof loaded !== "object") return deepClone(DEFAULT_ORCHESTRATOR_SECTION);
415
+
416
+ // Section-aware mapping: structural keys → camelCase, record keys → preserved
417
+ const mapped = mapOrchestratorYaml(loaded);
418
+
419
+ // Deep merge with cloned defaults
420
+ const section = deepClone(DEFAULT_ORCHESTRATOR_SECTION);
421
+ deepMerge(section, mapped);
422
+
423
+ return section;
424
+ } catch {
425
+ return deepClone(DEFAULT_ORCHESTRATOR_SECTION);
426
+ }
427
+ }
428
+
429
+
430
+ // ── User Preferences (Layer 2) ───────────────────────────────────────
431
+
432
+ /**
433
+ * Resolve the absolute path to the user preferences file.
434
+ *
435
+ * Resolution order:
436
+ * 1. `PI_CODING_AGENT_DIR` env → `<value>/taskplane/preferences.json`
437
+ * 2. `os.homedir()/.pi/agent/taskplane/preferences.json`
438
+ *
439
+ * Uses `os.homedir()` for cross-platform home resolution
440
+ * (USERPROFILE on Windows, HOME on Unix) and `path.join()` for separators.
441
+ */
442
+ export function resolveUserPreferencesPath(): string {
443
+ const agentDir = process.env.PI_CODING_AGENT_DIR;
444
+ if (agentDir) {
445
+ return join(agentDir, USER_PREFERENCES_SUBDIR, USER_PREFERENCES_FILENAME);
446
+ }
447
+ return join(homedir(), ".pi", "agent", USER_PREFERENCES_SUBDIR, USER_PREFERENCES_FILENAME);
448
+ }
449
+
450
+ /**
451
+ * Load user preferences from `~/.pi/agent/taskplane/preferences.json`.
452
+ *
453
+ * Behavior:
454
+ * - If file doesn't exist: auto-create with empty defaults `{}`, return defaults
455
+ * - If file is malformed JSON: log warning, return defaults (non-destructive)
456
+ * - Unknown keys are silently ignored (only allowlisted fields extracted)
457
+ * - Returns a fresh UserPreferences object on each call
458
+ *
459
+ * @returns Parsed UserPreferences (only recognized fields)
460
+ */
461
+ export function loadUserPreferences(): UserPreferences {
462
+ const prefsPath = resolveUserPreferencesPath();
463
+
464
+ if (!existsSync(prefsPath)) {
465
+ // Auto-create with empty defaults on first access
466
+ try {
467
+ const dir = join(prefsPath, "..");
468
+ mkdirSync(dir, { recursive: true });
469
+ writeFileSync(prefsPath, JSON.stringify(DEFAULT_USER_PREFERENCES, null, 2) + "\n", "utf-8");
470
+ } catch {
471
+ // Best-effort; if we can't create, just return defaults
472
+ }
473
+ return { ...DEFAULT_USER_PREFERENCES };
474
+ }
475
+
476
+ let raw: string;
477
+ try {
478
+ raw = readFileSync(prefsPath, "utf-8");
479
+ } catch {
480
+ return { ...DEFAULT_USER_PREFERENCES };
481
+ }
482
+
483
+ let parsed: any;
484
+ try {
485
+ parsed = JSON.parse(raw);
486
+ } catch {
487
+ // Malformed JSON — return defaults without overwriting (non-destructive)
488
+ return { ...DEFAULT_USER_PREFERENCES };
489
+ }
490
+
491
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
492
+ return { ...DEFAULT_USER_PREFERENCES };
493
+ }
494
+
495
+ // Extract only allowlisted fields — unknown keys are ignored
496
+ return extractAllowlistedPreferences(parsed);
497
+ }
498
+
499
+ /**
500
+ * Extract only recognized/allowlisted fields from a raw parsed object.
501
+ * Unknown keys are silently dropped — this is the Layer 2 boundary guardrail.
502
+ */
503
+ function extractAllowlistedPreferences(raw: Record<string, any>): UserPreferences {
504
+ const prefs: UserPreferences = {};
505
+
506
+ if (typeof raw.operatorId === "string") prefs.operatorId = raw.operatorId;
507
+ if (typeof raw.tmuxPrefix === "string") prefs.tmuxPrefix = raw.tmuxPrefix;
508
+ if (raw.spawnMode === "tmux" || raw.spawnMode === "subprocess") prefs.spawnMode = raw.spawnMode;
509
+ if (typeof raw.workerModel === "string") prefs.workerModel = raw.workerModel;
510
+ if (typeof raw.reviewerModel === "string") prefs.reviewerModel = raw.reviewerModel;
511
+ if (typeof raw.mergeModel === "string") prefs.mergeModel = raw.mergeModel;
512
+ if (typeof raw.dashboardPort === "number" && Number.isFinite(raw.dashboardPort)) {
513
+ prefs.dashboardPort = raw.dashboardPort;
514
+ }
515
+
516
+ return prefs;
517
+ }
518
+
519
+ /**
520
+ * Apply user preferences (Layer 2) onto a project config (Layer 1).
521
+ *
522
+ * Only allowlisted fields are applied. User preferences win for Layer 2
523
+ * fields; all other config fields (Layer 1) are left untouched.
524
+ *
525
+ * Mutates `config` in place and returns it for chaining.
526
+ *
527
+ * Empty-string preference values are treated as "not set" and do NOT
528
+ * override the project config value. This lets users clear a preference
529
+ * by deleting the field or setting it to "".
530
+ *
531
+ * Mapping table:
532
+ * prefs.operatorId → config.orchestrator.orchestrator.operatorId
533
+ * prefs.tmuxPrefix → config.orchestrator.orchestrator.tmuxPrefix
534
+ * prefs.spawnMode → config.orchestrator.orchestrator.spawnMode
535
+ * prefs.workerModel → config.taskRunner.worker.model
536
+ * prefs.reviewerModel → config.taskRunner.reviewer.model
537
+ * prefs.mergeModel → config.orchestrator.merge.model
538
+ * prefs.dashboardPort → (no config target yet — stored only)
539
+ */
540
+ export function applyUserPreferences(config: TaskplaneConfig, prefs: UserPreferences): TaskplaneConfig {
541
+ // Helper: only apply non-empty string values
542
+ const applyStr = (val: string | undefined, setter: (v: string) => void) => {
543
+ if (val !== undefined && val !== "") setter(val);
544
+ };
545
+
546
+ applyStr(prefs.operatorId, (v) => { config.orchestrator.orchestrator.operatorId = v; });
547
+ applyStr(prefs.tmuxPrefix, (v) => { config.orchestrator.orchestrator.tmuxPrefix = v; });
548
+ applyStr(prefs.workerModel, (v) => { config.taskRunner.worker.model = v; });
549
+ applyStr(prefs.reviewerModel, (v) => { config.taskRunner.reviewer.model = v; });
550
+ applyStr(prefs.mergeModel, (v) => { config.orchestrator.merge.model = v; });
551
+
552
+ // spawnMode: enum — apply if defined (not a string-empty check)
553
+ if (prefs.spawnMode !== undefined) {
554
+ config.orchestrator.orchestrator.spawnMode = prefs.spawnMode;
555
+ }
556
+
557
+ // dashboardPort: no config schema target yet — intentionally not applied
558
+ // It can be read directly from loadUserPreferences() by consumers that need it.
559
+
560
+ return config;
561
+ }
562
+
563
+
564
+ // ── Unified Loader ───────────────────────────────────────────────────
565
+
566
+ /**
567
+ * Check whether any config files exist under the given root.
568
+ *
569
+ * Supports both standard layout (`<root>/.pi/<file>`) and flat layout
570
+ * (`<root>/<file>`). Returns true if any recognized config file is found
571
+ * in either location. This allows pointer-resolved roots (e.g.,
572
+ * `<configRepo>/.taskplane/`) where files are scaffolded directly
573
+ * without a `.pi/` subdirectory.
574
+ */
575
+ function hasConfigFiles(root: string): boolean {
576
+ const files = [PROJECT_CONFIG_FILENAME, "task-runner.yaml", "task-orchestrator.yaml"];
577
+ for (const f of files) {
578
+ if (existsSync(join(root, ".pi", f)) || existsSync(join(root, f))) return true;
579
+ }
580
+ return false;
581
+ }
582
+
583
+ /**
584
+ * Resolve the config root directory.
585
+ *
586
+ * In workspace mode, workers run in repo worktrees — not the workspace root.
587
+ * TASKPLANE_WORKSPACE_ROOT tells us where config files actually live.
588
+ * The pointer file (`taskplane-pointer.json`) can redirect config loading
589
+ * to a specific repo's config path.
590
+ *
591
+ * Resolution order:
592
+ * 1. If `cwd` has actual config files → use cwd (local override wins)
593
+ * 2. If `pointerConfigRoot` is set and has config files → use it (pointer redirect)
594
+ * 3. If TASKPLANE_WORKSPACE_ROOT is set and has config files → use it (legacy fallback)
595
+ * 4. Fall back to cwd (loaders will return defaults)
596
+ *
597
+ * We check for actual config files — not just the `.pi/` directory —
598
+ * because worktrees may have a sidecar `.pi` without config files.
599
+ *
600
+ * @param cwd - Current working directory (project root or worktree)
601
+ * @param pointerConfigRoot - Resolved config root from pointer file (optional, workspace mode only)
602
+ */
603
+ export function resolveConfigRoot(cwd: string, pointerConfigRoot?: string): string {
604
+ // Prefer cwd if it has actual config files (local override always wins)
605
+ if (hasConfigFiles(cwd)) return cwd;
606
+
607
+ // Pointer-resolved config root — workspace mode with valid pointer
608
+ if (pointerConfigRoot && hasConfigFiles(pointerConfigRoot)) return pointerConfigRoot;
609
+
610
+ // Workspace mode fallback — check for actual config files at workspace root
611
+ const wsRoot = process.env.TASKPLANE_WORKSPACE_ROOT;
612
+ if (wsRoot && hasConfigFiles(wsRoot)) return wsRoot;
613
+
614
+ // Fall back to cwd even without config files — loaders will return defaults
615
+ return cwd;
616
+ }
617
+
618
+ /**
619
+ * Load the unified project configuration.
620
+ *
621
+ * Precedence (layered):
622
+ * Layer 1 — Project config:
623
+ * 1. `.pi/taskplane-config.json` — JSON-first (new format)
624
+ * 2. `.pi/task-runner.yaml` + `.pi/task-orchestrator.yaml` — YAML fallback
625
+ * 3. Defaults — if no config files exist
626
+ *
627
+ * Layer 2 — User preferences (applied on top of Layer 1):
628
+ * Reads `~/.pi/agent/taskplane/preferences.json` and overrides only
629
+ * allowlisted user-scoped fields. See `applyUserPreferences()` for
630
+ * the field mapping.
631
+ *
632
+ * Config root resolution order:
633
+ * 1. cwd has config files → use cwd (local override)
634
+ * 2. pointerConfigRoot has config files → use it (pointer redirect, workspace mode)
635
+ * 3. TASKPLANE_WORKSPACE_ROOT has config files → use it (legacy fallback)
636
+ * 4. Fall back to cwd (loaders will return defaults)
637
+ *
638
+ * @param cwd - Current working directory (project root or worktree)
639
+ * @param pointerConfigRoot - Resolved config root from pointer file (optional).
640
+ * Callers in workspace mode should resolve the pointer via `resolvePointer()`
641
+ * and pass `result.configRoot` here. In repo mode, omit or pass undefined.
642
+ * @returns Unified TaskplaneConfig — always a fresh deep-cloned object
643
+ * @throws ConfigLoadError if JSON exists but is malformed or has unsupported version
644
+ */
645
+ export function loadProjectConfig(cwd: string, pointerConfigRoot?: string): TaskplaneConfig {
646
+ const configRoot = resolveConfigRoot(cwd, pointerConfigRoot);
647
+
648
+ // Layer 1: Project config
649
+ let config: TaskplaneConfig;
650
+
651
+ // Try JSON first
652
+ const jsonConfig = loadJsonConfig(configRoot);
653
+ if (jsonConfig !== null) {
654
+ config = jsonConfig;
655
+ } else {
656
+ // Fall back to YAML
657
+ const taskRunner = loadTaskRunnerYaml(configRoot);
658
+ const orchestrator = loadOrchestratorYaml(configRoot);
659
+ config = {
660
+ configVersion: CONFIG_VERSION,
661
+ taskRunner,
662
+ orchestrator,
663
+ };
664
+ }
665
+
666
+ // Layer 2: User preferences (allowlisted fields only)
667
+ const prefs = loadUserPreferences();
668
+ applyUserPreferences(config, prefs);
669
+
670
+ return config;
671
+ }
672
+
673
+ /**
674
+ * Load Layer 1 config only (project config without user preferences).
675
+ *
676
+ * Returns the project config merged with defaults, but WITHOUT applying
677
+ * Layer 2 user preferences. Used by the settings TUI write-back to
678
+ * bootstrap a JSON config file from YAML-only projects without
679
+ * accidentally embedding user preferences into the project config.
680
+ *
681
+ * @param cwd - Current working directory (project root or worktree)
682
+ * @param pointerConfigRoot - Optional pointer-resolved config root (workspace mode)
683
+ * @returns Layer 1 TaskplaneConfig — always a fresh deep-cloned object
684
+ * @throws ConfigLoadError if JSON exists but is malformed or has unsupported version
685
+ */
686
+ export function loadLayer1Config(cwd: string, pointerConfigRoot?: string): TaskplaneConfig {
687
+ const configRoot = resolveConfigRoot(cwd, pointerConfigRoot);
688
+
689
+ // Try JSON first
690
+ const jsonConfig = loadJsonConfig(configRoot);
691
+ if (jsonConfig !== null) {
692
+ return jsonConfig;
693
+ }
694
+
695
+ // Fall back to YAML
696
+ const taskRunner = loadTaskRunnerYaml(configRoot);
697
+ const orchestrator = loadOrchestratorYaml(configRoot);
698
+ return {
699
+ configVersion: CONFIG_VERSION,
700
+ taskRunner,
701
+ orchestrator,
702
+ };
703
+ }
704
+
705
+
706
+ // ── Backward-Compatible Adapters ─────────────────────────────────────
707
+
708
+ // The following adapter functions convert the unified camelCase config
709
+ // back to the snake_case shapes expected by existing consumers.
710
+
711
+ /**
712
+ * Adapter: produce the legacy `OrchestratorConfig` (snake_case) from unified config.
713
+ *
714
+ * Uses explicit field mapping instead of generic recursive key conversion
715
+ * to preserve record/dictionary keys verbatim (e.g., sizeWeights S/M/L,
716
+ * preWarm.commands keys, etc.).
717
+ */
718
+ export function toOrchestratorConfig(config: TaskplaneConfig): import("./types.ts").OrchestratorConfig {
719
+ const o = config.orchestrator;
720
+ return {
721
+ orchestrator: {
722
+ max_lanes: o.orchestrator.maxLanes,
723
+ worktree_location: o.orchestrator.worktreeLocation,
724
+ worktree_prefix: o.orchestrator.worktreePrefix,
725
+ batch_id_format: o.orchestrator.batchIdFormat,
726
+ spawn_mode: o.orchestrator.spawnMode,
727
+ tmux_prefix: o.orchestrator.tmuxPrefix,
728
+ operator_id: o.orchestrator.operatorId,
729
+ },
730
+ dependencies: {
731
+ source: o.dependencies.source,
732
+ cache: o.dependencies.cache,
733
+ },
734
+ assignment: {
735
+ strategy: o.assignment.strategy,
736
+ // Preserve dictionary keys verbatim (S, M, L, XL, etc.)
737
+ size_weights: { ...o.assignment.sizeWeights },
738
+ },
739
+ pre_warm: {
740
+ auto_detect: o.preWarm.autoDetect,
741
+ // Preserve user-defined command keys verbatim
742
+ commands: { ...o.preWarm.commands },
743
+ always: [...o.preWarm.always],
744
+ },
745
+ merge: {
746
+ model: o.merge.model,
747
+ tools: o.merge.tools,
748
+ verify: [...o.merge.verify],
749
+ order: o.merge.order,
750
+ timeout_minutes: o.merge.timeoutMinutes ?? 10,
751
+ },
752
+ failure: {
753
+ on_task_failure: o.failure.onTaskFailure,
754
+ on_merge_failure: o.failure.onMergeFailure,
755
+ stall_timeout: o.failure.stallTimeout,
756
+ max_worker_minutes: o.failure.maxWorkerMinutes,
757
+ abort_grace_period: o.failure.abortGracePeriod,
758
+ },
759
+ monitoring: {
760
+ poll_interval: o.monitoring.pollInterval,
761
+ },
762
+ };
763
+ }
764
+
765
+ /**
766
+ * Adapter: produce the legacy `TaskRunnerConfig` (snake_case subset) from unified config.
767
+ *
768
+ * The orchestrator's `TaskRunnerConfig` is a subset: { task_areas, reference_docs }.
769
+ * This adapter maps the unified shape back to that contract.
770
+ *
771
+ * Special handling for `repoId`: whitespace-only values are treated as undefined,
772
+ * and non-empty values are trimmed — matching the original YAML loader behavior.
773
+ */
774
+ export function toTaskRunnerConfig(config: TaskplaneConfig): import("./types.ts").TaskRunnerConfig {
775
+ // task_areas needs snake_case keys inside each area too (repoId → repo_id)
776
+ const taskAreas: Record<string, import("./types.ts").TaskArea> = {};
777
+ for (const [name, area] of Object.entries(config.taskRunner.taskAreas)) {
778
+ const ta: import("./types.ts").TaskArea = {
779
+ path: area.path,
780
+ prefix: area.prefix,
781
+ context: area.context,
782
+ };
783
+ // repoId: only set if non-empty after trim (matches original YAML loader)
784
+ if (area.repoId && typeof area.repoId === "string" && area.repoId.trim()) {
785
+ ta.repoId = area.repoId.trim();
786
+ }
787
+ taskAreas[name] = ta;
788
+ }
789
+
790
+ return {
791
+ task_areas: taskAreas,
792
+ reference_docs: { ...config.taskRunner.referenceDocs },
793
+ };
794
+ }
795
+
796
+ /**
797
+ * Adapter: produce the legacy task-runner `TaskConfig` (snake_case) from unified config.
798
+ *
799
+ * The task-runner extension has its own `TaskConfig` interface with snake_case keys.
800
+ * This adapter maps the unified shape back to that contract.
801
+ */
802
+ export function toTaskConfig(config: TaskplaneConfig): {
803
+ project: { name: string; description: string };
804
+ paths: { tasks: string; architecture?: string };
805
+ testing: { commands: Record<string, string> };
806
+ standards: { docs: string[]; rules: string[] };
807
+ standards_overrides: Record<string, { docs?: string[]; rules?: string[] }>;
808
+ task_areas: Record<string, { path: string; [key: string]: any }>;
809
+ worker: { model: string; tools: string; thinking: string; spawn_mode?: "subprocess" | "tmux" };
810
+ reviewer: { model: string; tools: string; thinking: string };
811
+ context: {
812
+ worker_context_window: number;
813
+ warn_percent: number;
814
+ kill_percent: number;
815
+ max_worker_iterations: number;
816
+ max_review_cycles: number;
817
+ no_progress_limit: number;
818
+ max_worker_minutes?: number;
819
+ };
820
+ } {
821
+ const tr = config.taskRunner;
822
+
823
+ // Build standards_overrides with snake_case outer structure
824
+ const stdOverrides: Record<string, { docs?: string[]; rules?: string[] }> = {};
825
+ for (const [key, val] of Object.entries(tr.standardsOverrides)) {
826
+ stdOverrides[key] = { docs: val.docs, rules: val.rules };
827
+ }
828
+
829
+ // Build task_areas
830
+ const taskAreas: Record<string, { path: string; [key: string]: any }> = {};
831
+ for (const [key, val] of Object.entries(tr.taskAreas)) {
832
+ taskAreas[key] = { path: val.path, prefix: val.prefix, context: val.context };
833
+ if (val.repoId) (taskAreas[key] as any).repo_id = val.repoId;
834
+ }
835
+
836
+ return {
837
+ project: { ...tr.project },
838
+ paths: { ...tr.paths },
839
+ testing: { commands: { ...tr.testing.commands } },
840
+ standards: { docs: [...tr.standards.docs], rules: [...tr.standards.rules] },
841
+ standards_overrides: stdOverrides,
842
+ task_areas: taskAreas,
843
+ worker: {
844
+ model: tr.worker.model,
845
+ tools: tr.worker.tools,
846
+ thinking: tr.worker.thinking,
847
+ spawn_mode: tr.worker.spawnMode,
848
+ },
849
+ reviewer: { model: tr.reviewer.model, tools: tr.reviewer.tools, thinking: tr.reviewer.thinking },
850
+ context: {
851
+ worker_context_window: tr.context.workerContextWindow,
852
+ warn_percent: tr.context.warnPercent,
853
+ kill_percent: tr.context.killPercent,
854
+ max_worker_iterations: tr.context.maxWorkerIterations,
855
+ max_review_cycles: tr.context.maxReviewCycles,
856
+ no_progress_limit: tr.context.noProgressLimit,
857
+ max_worker_minutes: tr.context.maxWorkerMinutes,
858
+ },
859
+ };
860
+ }