taskplane 0.24.0 → 0.24.1

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,1085 +1,1158 @@
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
- WorkspaceSectionConfig,
47
- UserPreferences,
48
- } from "./config-schema.ts";
49
-
50
-
51
- // ── Error Types ──────────────────────────────────────────────────────
52
-
53
- /**
54
- * Error codes for config loading failures.
55
- *
56
- * - CONFIG_JSON_MALFORMED: File exists but is not valid JSON
57
- * - CONFIG_VERSION_UNSUPPORTED: configVersion is not supported by this version
58
- * - CONFIG_VERSION_MISSING: configVersion field is missing from JSON
59
- * - CONFIG_LEGACY_FIELD: removed TMUX-era field/value detected; migration required
60
- */
61
- export type ConfigLoadErrorCode =
62
- | "CONFIG_JSON_MALFORMED"
63
- | "CONFIG_VERSION_UNSUPPORTED"
64
- | "CONFIG_VERSION_MISSING"
65
- | "CONFIG_LEGACY_FIELD";
66
-
67
- export class ConfigLoadError extends Error {
68
- code: ConfigLoadErrorCode;
69
-
70
- constructor(code: ConfigLoadErrorCode, message: string) {
71
- super(message);
72
- this.name = "ConfigLoadError";
73
- this.code = code;
74
- }
75
- }
76
-
77
-
78
- // ── Deep Clone Helper ────────────────────────────────────────────────
79
-
80
- /** Deep clone a config object to avoid cross-call mutation. */
81
- function deepClone<T>(obj: T): T {
82
- return JSON.parse(JSON.stringify(obj));
83
- }
84
-
85
-
86
- // ── Deep Merge Helper ────────────────────────────────────────────────
87
-
88
- /**
89
- * Deep merge `source` into `target`. Arrays are replaced, not merged.
90
- * Only merges plain objects (not arrays, dates, etc).
91
- * Returns `target` for chaining.
92
- */
93
- function deepMerge<T extends Record<string, any>>(target: T, source: Record<string, any>): T {
94
- for (const key of Object.keys(source)) {
95
- const srcVal = source[key];
96
- const tgtVal = (target as any)[key];
97
- if (
98
- srcVal !== null &&
99
- srcVal !== undefined &&
100
- typeof srcVal === "object" &&
101
- !Array.isArray(srcVal) &&
102
- tgtVal !== null &&
103
- tgtVal !== undefined &&
104
- typeof tgtVal === "object" &&
105
- !Array.isArray(tgtVal)
106
- ) {
107
- deepMerge(tgtVal, srcVal);
108
- } else if (srcVal !== undefined) {
109
- (target as any)[key] = srcVal;
110
- }
111
- }
112
- return target;
113
- }
114
-
115
- function hasOwn(obj: unknown, key: string): boolean {
116
- return !!obj && typeof obj === "object" && Object.prototype.hasOwnProperty.call(obj, key);
117
- }
118
-
119
- function throwLegacyFieldError(fieldPath: string, source: string, fixHint: string): never {
120
- throw new ConfigLoadError(
121
- "CONFIG_LEGACY_FIELD",
122
- `[taskplane] ${source}: "${fieldPath}" is no longer supported under Runtime V2. ${fixHint}`,
123
- );
124
- }
125
-
126
- function assertNoLegacyTmuxProjectConfig(config: TaskplaneConfig, source: string): void {
127
- const orchestratorCore = config.orchestrator?.orchestrator as Record<string, unknown> | undefined;
128
- if (hasOwn(orchestratorCore, "tmuxPrefix")) {
129
- throwLegacyFieldError(
130
- "orchestrator.orchestrator.tmuxPrefix",
131
- source,
132
- "Use \"orchestrator.orchestrator.sessionPrefix\" instead.",
133
- );
134
- }
135
- if (orchestratorCore?.spawnMode === "tmux") {
136
- throwLegacyFieldError(
137
- "orchestrator.orchestrator.spawnMode",
138
- source,
139
- "Use \"subprocess\" instead.",
140
- );
141
- }
142
-
143
- const workerConfig = config.taskRunner?.worker as Record<string, unknown> | undefined;
144
- if (workerConfig?.spawnMode === "tmux") {
145
- throwLegacyFieldError(
146
- "taskRunner.worker.spawnMode",
147
- source,
148
- "Use \"subprocess\" or remove the field to inherit defaults.",
149
- );
150
- }
151
- }
152
-
153
- function assertNoLegacyTmuxUserPreferences(raw: Record<string, any>, prefsPath: string): void {
154
- if (hasOwn(raw, "tmuxPrefix")) {
155
- throwLegacyFieldError(
156
- "tmuxPrefix",
157
- `user preferences (${prefsPath})`,
158
- "Rename it to \"sessionPrefix\".",
159
- );
160
- }
161
- if (raw.spawnMode === "tmux") {
162
- throwLegacyFieldError(
163
- "spawnMode",
164
- `user preferences (${prefsPath})`,
165
- "Set it to \"subprocess\".",
166
- );
167
- }
168
- }
169
-
170
-
171
- // ── YAML snake_case → camelCase Mapping ──────────────────────────────
172
-
173
- /**
174
- * Convert a snake_case key to camelCase.
175
- * e.g., "max_worker_iterations" → "maxWorkerIterations"
176
- */
177
- function snakeToCamel(s: string): string {
178
- return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
179
- }
180
-
181
- /**
182
- * Convert structural keys from snake_case to camelCase, recursively.
183
- * Used for sections where ALL keys are structural schema keys (no
184
- * user-defined dictionary keys).
185
- */
186
- function convertStructuralKeys(obj: any): any {
187
- if (obj === null || obj === undefined) return obj;
188
- if (Array.isArray(obj)) return obj.map(convertStructuralKeys);
189
- if (typeof obj !== "object") return obj;
190
-
191
- const result: Record<string, any> = {};
192
- for (const [key, val] of Object.entries(obj)) {
193
- const camelKey = snakeToCamel(key);
194
- if (val !== null && typeof val === "object" && !Array.isArray(val)) {
195
- result[camelKey] = convertStructuralKeys(val);
196
- } else if (Array.isArray(val)) {
197
- result[camelKey] = val.map(convertStructuralKeys);
198
- } else {
199
- result[camelKey] = val;
200
- }
201
- }
202
- return result;
203
- }
204
-
205
- /**
206
- * Convert a record/dictionary section where outer keys are user-defined
207
- * identifiers (preserve verbatim) but inner keys are structural (convert).
208
- */
209
- function convertRecordSection(obj: any): any {
210
- if (obj === null || obj === undefined) return obj;
211
- if (typeof obj !== "object" || Array.isArray(obj)) return obj;
212
-
213
- const result: Record<string, any> = {};
214
- for (const [key, val] of Object.entries(obj)) {
215
- // Preserve user-defined key verbatim, convert structural inner keys
216
- if (val !== null && typeof val === "object" && !Array.isArray(val)) {
217
- result[key] = convertStructuralKeys(val);
218
- } else {
219
- result[key] = val;
220
- }
221
- }
222
- return result;
223
- }
224
-
225
- /**
226
- * Convert a flat record/dictionary where both keys and values are
227
- * user-defined (preserve everything verbatim). Used for sections like
228
- * `reference_docs`, `self_doc_targets`, `testing.commands` where
229
- * keys are identifiers and values are strings.
230
- */
231
- function preserveRecord(obj: any): any {
232
- if (obj === null || obj === undefined) return obj;
233
- if (typeof obj !== "object" || Array.isArray(obj)) return obj;
234
- return { ...obj };
235
- }
236
-
237
- // ── Section-aware YAML mapping ───────────────────────────────────────
238
-
239
- /**
240
- * Map a raw task-runner YAML object to the camelCase TaskRunnerSection shape.
241
- *
242
- * Knows which sections contain user-defined record keys vs. structural keys:
243
- * - Structural-only: project, paths, worker, reviewer, context, standards
244
- * - Record with structural inner keys: task_areas, standards_overrides
245
- * - Flat record (preserve all keys): testing.commands, reference_docs,
246
- * self_doc_targets
247
- * - Array (preserve): never_load, protected_docs
248
- */
249
- function mapTaskRunnerYaml(raw: any): Partial<TaskRunnerSection> {
250
- const result: any = {};
251
-
252
- // Structural sections all keys are schema-defined
253
- if (raw.project) result.project = convertStructuralKeys(raw.project);
254
- if (raw.paths) result.paths = convertStructuralKeys(raw.paths);
255
- if (raw.worker) result.worker = convertStructuralKeys(raw.worker);
256
- if (raw.reviewer) result.reviewer = convertStructuralKeys(raw.reviewer);
257
- if (raw.context) result.context = convertStructuralKeys(raw.context);
258
- if (raw.standards) result.standards = convertStructuralKeys(raw.standards);
259
-
260
- // Testing: commands is a flat user-defined record
261
- if (raw.testing) {
262
- result.testing = {};
263
- if (raw.testing.commands) {
264
- result.testing.commands = preserveRecord(raw.testing.commands);
265
- }
266
- }
267
-
268
- // Record sections with structural inner keys
269
- if (raw.task_areas) result.taskAreas = convertRecordSection(raw.task_areas);
270
- if (raw.standards_overrides) result.standardsOverrides = convertRecordSection(raw.standards_overrides);
271
-
272
- // Flat record sections (keys are identifiers, values are strings)
273
- if (raw.reference_docs) result.referenceDocs = preserveRecord(raw.reference_docs);
274
- if (raw.self_doc_targets) result.selfDocTargets = preserveRecord(raw.self_doc_targets);
275
-
276
- // Array sections (preserve verbatim)
277
- if (raw.never_load) result.neverLoad = [...raw.never_load];
278
- if (raw.protected_docs) result.protectedDocs = [...raw.protected_docs];
279
-
280
- // Quality gate (structural all keys are schema-defined)
281
- if (raw.quality_gate) result.qualityGate = convertStructuralKeys(raw.quality_gate);
282
-
283
- // Model fallback (scalar "inherit" or "fail")
284
- if (raw.model_fallback) result.modelFallback = raw.model_fallback;
285
-
286
- return result;
287
- }
288
-
289
- /**
290
- * Map a raw orchestrator YAML object to the camelCase OrchestratorSection shape.
291
- *
292
- * Knows which sections contain user-defined record keys:
293
- * - Structural: orchestrator, dependencies, merge, failure, monitoring
294
- * - Record with structural inner keys: (none)
295
- * - Flat record (preserve keys): pre_warm.commands, assignment.size_weights
296
- */
297
- function mapOrchestratorYaml(raw: any): Partial<OrchestratorSection> {
298
- const result: any = {};
299
-
300
- // Structural sections
301
- if (raw.orchestrator) result.orchestrator = convertStructuralKeys(raw.orchestrator);
302
- if (raw.dependencies) result.dependencies = convertStructuralKeys(raw.dependencies);
303
- if (raw.merge) result.merge = convertStructuralKeys(raw.merge);
304
- if (raw.failure) result.failure = convertStructuralKeys(raw.failure);
305
- if (raw.monitoring) result.monitoring = convertStructuralKeys(raw.monitoring);
306
-
307
- // assignment: strategy is structural, size_weights is a user-defined record
308
- if (raw.assignment) {
309
- result.assignment = {};
310
- if (raw.assignment.strategy !== undefined) result.assignment.strategy = raw.assignment.strategy;
311
- if (raw.assignment.size_weights) result.assignment.sizeWeights = preserveRecord(raw.assignment.size_weights);
312
- }
313
-
314
- // pre_warm: auto_detect is structural, commands is user-defined, always is array
315
- if (raw.pre_warm) {
316
- result.preWarm = {};
317
- if (raw.pre_warm.auto_detect !== undefined) result.preWarm.autoDetect = raw.pre_warm.auto_detect;
318
- if (raw.pre_warm.commands) result.preWarm.commands = preserveRecord(raw.pre_warm.commands);
319
- if (raw.pre_warm.always) result.preWarm.always = [...raw.pre_warm.always];
320
- }
321
-
322
- // verification: all keys are structural (TP-032)
323
- if (raw.verification) result.verification = convertStructuralKeys(raw.verification);
324
-
325
- // supervisor: all keys are structural (TP-041)
326
- if (raw.supervisor) result.supervisor = convertStructuralKeys(raw.supervisor);
327
-
328
- return result;
329
- }
330
-
331
- /**
332
- * Normalize a workspace section loaded from JSON/YAML into camelCase shape.
333
- *
334
- * Compatibility: if `routing.taskPacketRepo` is missing, defaults to
335
- * `routing.defaultRepo` and emits a warning message.
336
- */
337
- function normalizeWorkspaceSection(
338
- rawWorkspace: any,
339
- sourcePath: string,
340
- ): WorkspaceSectionConfig | undefined {
341
- if (!rawWorkspace || typeof rawWorkspace !== "object" || Array.isArray(rawWorkspace)) {
342
- return undefined;
343
- }
344
-
345
- const rawRepos = rawWorkspace.repos;
346
- if (!rawRepos || typeof rawRepos !== "object" || Array.isArray(rawRepos)) {
347
- return undefined;
348
- }
349
-
350
- const rawRouting = rawWorkspace.routing;
351
- if (!rawRouting || typeof rawRouting !== "object" || Array.isArray(rawRouting)) {
352
- return undefined;
353
- }
354
-
355
- const repos: WorkspaceSectionConfig["repos"] = {};
356
- for (const [repoId, repoVal] of Object.entries(rawRepos as Record<string, any>)) {
357
- if (!repoVal || typeof repoVal !== "object" || Array.isArray(repoVal)) continue;
358
- const repoObj = repoVal as Record<string, any>;
359
- if (typeof repoObj.path !== "string" || repoObj.path.trim() === "") continue;
360
- repos[repoId] = {
361
- path: repoObj.path,
362
- ...(typeof repoObj.defaultBranch === "string" && repoObj.defaultBranch.trim()
363
- ? { defaultBranch: repoObj.defaultBranch }
364
- : {}),
365
- };
366
- }
367
-
368
- const defaultRepo = typeof rawRouting.defaultRepo === "string" ? rawRouting.defaultRepo.trim() : "";
369
- const tasksRoot = typeof rawRouting.tasksRoot === "string" ? rawRouting.tasksRoot.trim() : "";
370
- let taskPacketRepo = typeof rawRouting.taskPacketRepo === "string" ? rawRouting.taskPacketRepo.trim() : "";
371
-
372
- if (!taskPacketRepo && defaultRepo) {
373
- taskPacketRepo = defaultRepo;
374
- console.error(
375
- `[taskplane] config compatibility: workspace.routing.taskPacketRepo is missing in ${sourcePath}; defaulting to workspace.routing.defaultRepo ('${defaultRepo}'). Add workspace.routing.taskPacketRepo explicitly.`,
376
- );
377
- }
378
-
379
- if (!tasksRoot || !defaultRepo || !taskPacketRepo) {
380
- return undefined;
381
- }
382
-
383
- const strict = rawRouting.strict === true;
384
-
385
- return {
386
- repos,
387
- routing: {
388
- tasksRoot,
389
- defaultRepo,
390
- taskPacketRepo,
391
- ...(strict ? { strict: true } : {}),
392
- },
393
- };
394
- }
395
-
396
-
397
- // ── Config File Path Resolution ──────────────────────────────────────
398
-
399
- /**
400
- * Resolve the path to a config file under the given root.
401
- *
402
- * Supports two directory layouts:
403
- * 1. Standard layout: `<root>/.pi/<filename>` — used by repo mode and
404
- * workspace root, where config files live under the `.pi/` subdirectory.
405
- * 2. Flat layout: `<root>/<filename>` — used by pointer-resolved config
406
- * roots (e.g., `<configRepo>/.taskplane/task-runner.yaml`), where
407
- * `taskplane init` scaffolds files directly in the config path.
408
- *
409
- * Standard layout is checked first for backward compatibility. If neither
410
- * exists, returns the standard-layout path (callers check existence).
411
- */
412
- function resolveConfigFilePath(configRoot: string, filename: string): string {
413
- const standardPath = join(configRoot, ".pi", filename);
414
- if (existsSync(standardPath)) return standardPath;
415
-
416
- const flatPath = join(configRoot, filename);
417
- if (existsSync(flatPath)) return flatPath;
418
-
419
- // Default to standard path — callers handle non-existence
420
- return standardPath;
421
- }
422
-
423
- // ── JSON Loading ─────────────────────────────────────────────────────
424
-
425
- /**
426
- * Attempt to load and validate `taskplane-config.json`.
427
- *
428
- * Checks both standard layout (`<root>/.pi/taskplane-config.json`) and
429
- * flat layout (`<root>/taskplane-config.json`) — see `resolveConfigFilePath`.
430
- *
431
- * Returns the parsed config or null if the file doesn't exist.
432
- * Throws ConfigLoadError for malformed JSON or unsupported versions.
433
- */
434
- function loadJsonConfig(configRoot: string): TaskplaneConfig | null {
435
- const jsonPath = resolveConfigFilePath(configRoot, PROJECT_CONFIG_FILENAME);
436
- if (!existsSync(jsonPath)) return null;
437
-
438
- let raw: string;
439
- try {
440
- raw = readFileSync(jsonPath, "utf-8");
441
- } catch {
442
- return null; // Can't read file treat as absent
443
- }
444
-
445
- let parsed: any;
446
- try {
447
- parsed = JSON.parse(raw);
448
- } catch (e: any) {
449
- throw new ConfigLoadError(
450
- "CONFIG_JSON_MALFORMED",
451
- `Failed to parse ${jsonPath}: ${e.message ?? "invalid JSON"}`,
452
- );
453
- }
454
-
455
- // Validate configVersion
456
- if (parsed.configVersion === undefined || parsed.configVersion === null) {
457
- throw new ConfigLoadError(
458
- "CONFIG_VERSION_MISSING",
459
- `${jsonPath} is missing required field "configVersion". ` +
460
- `Expected configVersion: ${CONFIG_VERSION}.`,
461
- );
462
- }
463
-
464
- if (parsed.configVersion !== CONFIG_VERSION) {
465
- throw new ConfigLoadError(
466
- "CONFIG_VERSION_UNSUPPORTED",
467
- `${jsonPath} has configVersion ${parsed.configVersion}, but this version of Taskplane ` +
468
- `only supports configVersion ${CONFIG_VERSION}. Please upgrade Taskplane.`,
469
- );
470
- }
471
-
472
- // Deep merge with cloned defaults
473
- const config = deepClone(DEFAULT_PROJECT_CONFIG);
474
- if (parsed.taskRunner) {
475
- deepMerge(config.taskRunner, parsed.taskRunner);
476
- }
477
- if (parsed.orchestrator) {
478
- deepMerge(config.orchestrator, parsed.orchestrator);
479
- }
480
- if (parsed.workspace) {
481
- const normalizedWorkspace = normalizeWorkspaceSection(parsed.workspace, jsonPath);
482
- if (normalizedWorkspace) {
483
- config.workspace = normalizedWorkspace;
484
- }
485
- }
486
-
487
- return config;
488
- }
489
-
490
-
491
- // ── YAML Loading ─────────────────────────────────────────────────────
492
-
493
- /**
494
- * Load task-runner settings from `task-runner.yaml`.
495
- *
496
- * Checks both standard layout (`<root>/.pi/task-runner.yaml`) and
497
- * flat layout (`<root>/task-runner.yaml`) — see `resolveConfigFilePath`.
498
- * Maps snake_case YAML keys to the camelCase TaskRunnerSection shape.
499
- * Uses section-aware mapping that preserves user-defined record keys.
500
- * Returns cloned defaults if the file doesn't exist or is malformed.
501
- */
502
- function loadTaskRunnerYaml(configRoot: string): TaskRunnerSection {
503
- const yamlPath = resolveConfigFilePath(configRoot, "task-runner.yaml");
504
- if (!existsSync(yamlPath)) return deepClone(DEFAULT_TASK_RUNNER_SECTION);
505
-
506
- try {
507
- const raw = readFileSync(yamlPath, "utf-8");
508
- const loaded = yamlParse(raw) as any;
509
- if (!loaded || typeof loaded !== "object") return deepClone(DEFAULT_TASK_RUNNER_SECTION);
510
-
511
- // Section-aware mapping: structural keys → camelCase, record keys → preserved
512
- const mapped = mapTaskRunnerYaml(loaded);
513
-
514
- // Deep merge with cloned defaults
515
- const section = deepClone(DEFAULT_TASK_RUNNER_SECTION);
516
- deepMerge(section, mapped);
517
-
518
- // Post-process taskAreas: trim repoId, drop whitespace-only values
519
- // (matches legacy loadTaskRunnerConfig behavior from config.ts)
520
- if (section.taskAreas) {
521
- for (const area of Object.values(section.taskAreas)) {
522
- if (area.repoId !== undefined) {
523
- const trimmed = typeof area.repoId === "string" ? area.repoId.trim() : "";
524
- if (trimmed) {
525
- area.repoId = trimmed;
526
- } else {
527
- delete area.repoId;
528
- }
529
- }
530
- }
531
- }
532
-
533
- return section;
534
- } catch {
535
- return deepClone(DEFAULT_TASK_RUNNER_SECTION);
536
- }
537
- }
538
-
539
- /**
540
- * Load orchestrator settings from `task-orchestrator.yaml`.
541
- *
542
- * Checks both standard layout (`<root>/.pi/task-orchestrator.yaml`) and
543
- * flat layout (`<root>/task-orchestrator.yaml`) — see `resolveConfigFilePath`.
544
- * Maps snake_case YAML keys to the camelCase OrchestratorSection shape.
545
- * Uses section-aware mapping that preserves user-defined record keys.
546
- * Returns cloned defaults if the file doesn't exist or is malformed.
547
- */
548
- function loadOrchestratorYaml(configRoot: string): OrchestratorSection {
549
- const yamlPath = resolveConfigFilePath(configRoot, "task-orchestrator.yaml");
550
- if (!existsSync(yamlPath)) return deepClone(DEFAULT_ORCHESTRATOR_SECTION);
551
-
552
- try {
553
- const raw = readFileSync(yamlPath, "utf-8");
554
- const loaded = yamlParse(raw) as any;
555
- if (!loaded || typeof loaded !== "object") return deepClone(DEFAULT_ORCHESTRATOR_SECTION);
556
-
557
- // Section-aware mapping: structural keys → camelCase, record keys → preserved
558
- const mapped = mapOrchestratorYaml(loaded);
559
-
560
- // Deep merge with cloned defaults
561
- const section = deepClone(DEFAULT_ORCHESTRATOR_SECTION);
562
- deepMerge(section, mapped);
563
-
564
- return section;
565
- } catch {
566
- return deepClone(DEFAULT_ORCHESTRATOR_SECTION);
567
- }
568
- }
569
-
570
- /**
571
- * Load optional workspace routing config from legacy `taskplane-workspace.yaml`.
572
- *
573
- * This file is fallback-only for workspace metadata when JSON `workspace`
574
- * section is not present. Malformed files are ignored here strict validation
575
- * still happens in workspace runtime loading (`workspace.ts`).
576
- */
577
- function loadWorkspaceYaml(configRoot: string): WorkspaceSectionConfig | undefined {
578
- const yamlPath = resolveConfigFilePath(configRoot, "taskplane-workspace.yaml");
579
- if (!existsSync(yamlPath)) return undefined;
580
-
581
- try {
582
- const raw = readFileSync(yamlPath, "utf-8");
583
- const loaded = yamlParse(raw) as any;
584
- if (!loaded || typeof loaded !== "object") return undefined;
585
-
586
- const converted = convertStructuralKeys(loaded);
587
- return normalizeWorkspaceSection(converted, yamlPath);
588
- } catch {
589
- return undefined;
590
- }
591
- }
592
-
593
-
594
- // ── User Preferences (Layer 2) ───────────────────────────────────────
595
-
596
- /**
597
- * Resolve the absolute path to the user preferences file.
598
- *
599
- * Resolution order:
600
- * 1. `PI_CODING_AGENT_DIR` env → `<value>/taskplane/preferences.json`
601
- * 2. `os.homedir()/.pi/agent/taskplane/preferences.json`
602
- *
603
- * Uses `os.homedir()` for cross-platform home resolution
604
- * (USERPROFILE on Windows, HOME on Unix) and `path.join()` for separators.
605
- */
606
- export function resolveUserPreferencesPath(): string {
607
- const agentDir = process.env.PI_CODING_AGENT_DIR;
608
- if (agentDir) {
609
- return join(agentDir, USER_PREFERENCES_SUBDIR, USER_PREFERENCES_FILENAME);
610
- }
611
- return join(homedir(), ".pi", "agent", USER_PREFERENCES_SUBDIR, USER_PREFERENCES_FILENAME);
612
- }
613
-
614
- /**
615
- * Load user preferences from `~/.pi/agent/taskplane/preferences.json`.
616
- *
617
- * Behavior:
618
- * - If file doesn't exist: auto-create with empty defaults `{}`, return defaults
619
- * - If file is malformed JSON: log warning, return defaults (non-destructive)
620
- * - Unknown keys are silently ignored (only allowlisted fields extracted)
621
- * - Returns a fresh UserPreferences object on each call
622
- *
623
- * @returns Parsed UserPreferences (only recognized fields)
624
- */
625
- export function loadUserPreferences(): UserPreferences {
626
- const prefsPath = resolveUserPreferencesPath();
627
-
628
- if (!existsSync(prefsPath)) {
629
- // Auto-create with empty defaults on first access
630
- try {
631
- const dir = join(prefsPath, "..");
632
- mkdirSync(dir, { recursive: true });
633
- writeFileSync(prefsPath, JSON.stringify(DEFAULT_USER_PREFERENCES, null, 2) + "\n", "utf-8");
634
- } catch {
635
- // Best-effort; if we can't create, just return defaults
636
- }
637
- return { ...DEFAULT_USER_PREFERENCES };
638
- }
639
-
640
- let raw: string;
641
- try {
642
- raw = readFileSync(prefsPath, "utf-8");
643
- } catch {
644
- return { ...DEFAULT_USER_PREFERENCES };
645
- }
646
-
647
- let parsed: any;
648
- try {
649
- parsed = JSON.parse(raw);
650
- } catch {
651
- // Malformed JSON return defaults without overwriting (non-destructive)
652
- return { ...DEFAULT_USER_PREFERENCES };
653
- }
654
-
655
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
656
- return { ...DEFAULT_USER_PREFERENCES };
657
- }
658
-
659
- // Extract only allowlisted fields — unknown keys are ignored
660
- return extractAllowlistedPreferences(parsed, prefsPath);
661
- }
662
-
663
- /**
664
- * Extract only recognized/allowlisted fields from a raw parsed object.
665
- * Unknown keys are silently dropped — this is the Layer 2 boundary guardrail.
666
- */
667
- function extractAllowlistedPreferences(raw: Record<string, any>, prefsPath: string): UserPreferences {
668
- assertNoLegacyTmuxUserPreferences(raw, prefsPath);
669
-
670
- const prefs: UserPreferences = {};
671
-
672
- if (typeof raw.operatorId === "string") prefs.operatorId = raw.operatorId;
673
- if (typeof raw.sessionPrefix === "string") {
674
- prefs.sessionPrefix = raw.sessionPrefix;
675
- }
676
- if (raw.spawnMode === "subprocess") {
677
- prefs.spawnMode = "subprocess";
678
- }
679
- if (typeof raw.workerModel === "string") prefs.workerModel = raw.workerModel;
680
- if (typeof raw.reviewerModel === "string") prefs.reviewerModel = raw.reviewerModel;
681
- if (typeof raw.mergeModel === "string") prefs.mergeModel = raw.mergeModel;
682
- if (typeof raw.supervisorModel === "string") prefs.supervisorModel = raw.supervisorModel;
683
- if (typeof raw.dashboardPort === "number" && Number.isFinite(raw.dashboardPort)) {
684
- prefs.dashboardPort = raw.dashboardPort;
685
- }
686
-
687
- return prefs;
688
- }
689
-
690
- /**
691
- * Apply user preferences (Layer 2) onto a project config (Layer 1).
692
- *
693
- * Only allowlisted fields are applied. User preferences win for Layer 2
694
- * fields; all other config fields (Layer 1) are left untouched.
695
- *
696
- * Mutates `config` in place and returns it for chaining.
697
- *
698
- * Empty-string preference values are treated as "not set" and do NOT
699
- * override the project config value. This lets users clear a preference
700
- * by deleting the field or setting it to "".
701
- *
702
- * Mapping table:
703
- * prefs.operatorId → config.orchestrator.orchestrator.operatorId
704
- * prefs.sessionPrefix → config.orchestrator.orchestrator.sessionPrefix
705
- * prefs.spawnMode → config.orchestrator.orchestrator.spawnMode
706
- * prefs.workerModel → config.taskRunner.worker.model
707
- * prefs.reviewerModel config.taskRunner.reviewer.model
708
- * prefs.mergeModel → config.orchestrator.merge.model
709
- * prefs.supervisorModel config.orchestrator.supervisor.model
710
- * prefs.dashboardPort → (no config target yet — stored only)
711
- */
712
- export function applyUserPreferences(config: TaskplaneConfig, prefs: UserPreferences): TaskplaneConfig {
713
- // Helper: only apply non-empty string values
714
- const applyStr = (val: string | undefined, setter: (v: string) => void) => {
715
- if (val !== undefined && val !== "") setter(val);
716
- };
717
-
718
- applyStr(prefs.operatorId, (v) => { config.orchestrator.orchestrator.operatorId = v; });
719
- applyStr(prefs.sessionPrefix, (v) => { config.orchestrator.orchestrator.sessionPrefix = v; });
720
- applyStr(prefs.workerModel, (v) => { config.taskRunner.worker.model = v; });
721
- applyStr(prefs.reviewerModel, (v) => { config.taskRunner.reviewer.model = v; });
722
- applyStr(prefs.mergeModel, (v) => { config.orchestrator.merge.model = v; });
723
- applyStr(prefs.supervisorModel, (v) => { config.orchestrator.supervisor.model = v; });
724
-
725
- // spawnMode: enumapply if defined (not a string-empty check)
726
- if (prefs.spawnMode !== undefined) {
727
- if (prefs.spawnMode === "tmux") {
728
- throwLegacyFieldError(
729
- "spawnMode",
730
- "user preferences (runtime)",
731
- "Set it to \"subprocess\".",
732
- );
733
- }
734
- config.orchestrator.orchestrator.spawnMode = prefs.spawnMode;
735
- }
736
-
737
- // dashboardPort: no config schema target yet — intentionally not applied
738
- // It can be read directly from loadUserPreferences() by consumers that need it.
739
-
740
- return config;
741
- }
742
-
743
- // ── Unified Loader ───────────────────────────────────────────────────
744
-
745
- /**
746
- * Check whether any config files exist under the given root.
747
- *
748
- * Supports both standard layout (`<root>/.pi/<file>`) and flat layout
749
- * (`<root>/<file>`). Returns true if any recognized config file is found
750
- * in either location. This allows pointer-resolved roots (e.g.,
751
- * `<configRepo>/.taskplane/`) where files are scaffolded directly
752
- * without a `.pi/` subdirectory.
753
- *
754
- * Includes optional workspace YAML (`taskplane-workspace.yaml`) so
755
- * workspace-only roots participate in config-root resolution.
756
- */
757
- export function hasConfigFiles(root: string): boolean {
758
- const files = [
759
- PROJECT_CONFIG_FILENAME,
760
- "task-runner.yaml",
761
- "task-orchestrator.yaml",
762
- "taskplane-workspace.yaml",
763
- ];
764
- for (const f of files) {
765
- if (existsSync(join(root, ".pi", f)) || existsSync(join(root, f))) return true;
766
- }
767
- return false;
768
- }
769
-
770
- /**
771
- * Resolve the config root directory.
772
- *
773
- * In workspace mode, workers run in repo worktrees not the workspace root.
774
- * TASKPLANE_WORKSPACE_ROOT tells us where config files actually live.
775
- * The pointer file (`taskplane-pointer.json`) can redirect config loading
776
- * to a specific repo's config path.
777
- *
778
- * Resolution order:
779
- * 1. If `cwd` has actual config files → use cwd (local override wins)
780
- * 2. If `pointerConfigRoot` is set and has config files → use it (pointer redirect)
781
- * 3. If TASKPLANE_WORKSPACE_ROOT is set and has config files → use it (legacy fallback)
782
- * 4. Fall back to cwd (loaders will return defaults)
783
- *
784
- * We check for actual config files not just the `.pi/` directory —
785
- * because worktrees may have a sidecar `.pi` without config files.
786
- *
787
- * @param cwd - Current working directory (project root or worktree)
788
- * @param pointerConfigRoot - Resolved config root from pointer file (optional, workspace mode only)
789
- */
790
- export function resolveConfigRoot(cwd: string, pointerConfigRoot?: string): string {
791
- // Prefer cwd if it has actual config files (local override always wins)
792
- if (hasConfigFiles(cwd)) return cwd;
793
-
794
- // Pointer-resolved config root workspace mode with valid pointer
795
- if (pointerConfigRoot && hasConfigFiles(pointerConfigRoot)) return pointerConfigRoot;
796
-
797
- // Workspace mode fallback — check for actual config files at workspace root
798
- const wsRoot = process.env.TASKPLANE_WORKSPACE_ROOT;
799
- if (wsRoot && hasConfigFiles(wsRoot)) return wsRoot;
800
-
801
- // Fall back to cwd even without config files — loaders will return defaults
802
- return cwd;
803
- }
804
-
805
- /**
806
- * Load the unified project configuration.
807
- *
808
- * Precedence (layered):
809
- * Layer 1 Project config:
810
- * 1. `.pi/taskplane-config.json` — JSON-first (new format)
811
- * 2. `.pi/task-runner.yaml` + `.pi/task-orchestrator.yaml` — YAML fallback
812
- * (+ optional `.pi/taskplane-workspace.yaml` workspace section mapping)
813
- * 3. Defaults — if no config files exist
814
- *
815
- * Layer 2 — User preferences (applied on top of Layer 1):
816
- * Reads `~/.pi/agent/taskplane/preferences.json` and overrides only
817
- * allowlisted user-scoped fields. See `applyUserPreferences()` for
818
- * the field mapping.
819
- *
820
- * Config root resolution order:
821
- * 1. cwd has config files use cwd (local override)
822
- * 2. pointerConfigRoot has config files use it (pointer redirect, workspace mode)
823
- * 3. TASKPLANE_WORKSPACE_ROOT has config files → use it (legacy fallback)
824
- * 4. Fall back to cwd (loaders will return defaults)
825
- *
826
- * @param cwd - Current working directory (project root or worktree)
827
- * @param pointerConfigRoot - Resolved config root from pointer file (optional).
828
- * Callers in workspace mode should resolve the pointer via `resolvePointer()`
829
- * and pass `result.configRoot` here. In repo mode, omit or pass undefined.
830
- * @returns Unified TaskplaneConfig — always a fresh deep-cloned object
831
- * @throws ConfigLoadError if JSON exists but is malformed or has unsupported version
832
- */
833
- export function loadProjectConfig(cwd: string, pointerConfigRoot?: string): TaskplaneConfig {
834
- const configRoot = resolveConfigRoot(cwd, pointerConfigRoot);
835
-
836
- // Layer 1: Project config
837
- let config: TaskplaneConfig;
838
-
839
- // Try JSON first
840
- const jsonConfig = loadJsonConfig(configRoot);
841
- if (jsonConfig !== null) {
842
- config = jsonConfig;
843
- } else {
844
- // Fall back to YAML
845
- const taskRunner = loadTaskRunnerYaml(configRoot);
846
- const orchestrator = loadOrchestratorYaml(configRoot);
847
- const workspace = loadWorkspaceYaml(configRoot);
848
- config = {
849
- configVersion: CONFIG_VERSION,
850
- taskRunner,
851
- orchestrator,
852
- ...(workspace ? { workspace } : {}),
853
- };
854
- }
855
-
856
- assertNoLegacyTmuxProjectConfig(config, `project config (${configRoot})`);
857
-
858
- // Layer 2: User preferences (allowlisted fields only)
859
- const prefs = loadUserPreferences();
860
- applyUserPreferences(config, prefs);
861
- assertNoLegacyTmuxProjectConfig(config, `project config (${configRoot}) after preferences merge`);
862
-
863
- return config;
864
- }
865
-
866
- /**
867
- * Load Layer 1 config only (project config without user preferences).
868
- *
869
- * Returns the project config merged with defaults, but WITHOUT applying
870
- * Layer 2 user preferences. Used by the settings TUI write-back to
871
- * bootstrap a JSON config file from YAML-only projects without
872
- * accidentally embedding user preferences into the project config.
873
- *
874
- * @param cwd - Current working directory (project root or worktree)
875
- * @param pointerConfigRoot - Optional pointer-resolved config root (workspace mode)
876
- * @returns Layer 1 TaskplaneConfig — always a fresh deep-cloned object
877
- * @throws ConfigLoadError if JSON exists but is malformed or has unsupported version
878
- */
879
- export function loadLayer1Config(cwd: string, pointerConfigRoot?: string): TaskplaneConfig {
880
- const configRoot = resolveConfigRoot(cwd, pointerConfigRoot);
881
-
882
- // Try JSON first
883
- const jsonConfig = loadJsonConfig(configRoot);
884
- if (jsonConfig !== null) {
885
- assertNoLegacyTmuxProjectConfig(jsonConfig, `project config (${configRoot})`);
886
- return jsonConfig;
887
- }
888
-
889
- // Fall back to YAML
890
- const taskRunner = loadTaskRunnerYaml(configRoot);
891
- const orchestrator = loadOrchestratorYaml(configRoot);
892
- const workspace = loadWorkspaceYaml(configRoot);
893
- const config: TaskplaneConfig = {
894
- configVersion: CONFIG_VERSION,
895
- taskRunner,
896
- orchestrator,
897
- ...(workspace ? { workspace } : {}),
898
- };
899
- assertNoLegacyTmuxProjectConfig(config, `project config (${configRoot})`);
900
- return config;
901
- }
902
-
903
-
904
- // ── Backward-Compatible Adapters ─────────────────────────────────────
905
-
906
- // The following adapter functions convert the unified camelCase config
907
- // back to the snake_case shapes expected by existing consumers.
908
-
909
- /**
910
- * Adapter: produce the legacy `OrchestratorConfig` (snake_case) from unified config.
911
- *
912
- * Uses explicit field mapping instead of generic recursive key conversion
913
- * to preserve record/dictionary keys verbatim (e.g., sizeWeights S/M/L,
914
- * preWarm.commands keys, etc.).
915
- */
916
- export function toOrchestratorConfig(config: TaskplaneConfig): import("./types.ts").OrchestratorConfig {
917
- const o = config.orchestrator;
918
- return {
919
- orchestrator: {
920
- max_lanes: o.orchestrator.maxLanes,
921
- worktree_location: o.orchestrator.worktreeLocation,
922
- worktree_prefix: o.orchestrator.worktreePrefix,
923
- batch_id_format: o.orchestrator.batchIdFormat,
924
- spawn_mode: o.orchestrator.spawnMode,
925
- sessionPrefix: o.orchestrator.sessionPrefix,
926
- operator_id: o.orchestrator.operatorId,
927
- integration: o.orchestrator.integration,
928
- },
929
- dependencies: {
930
- source: o.dependencies.source,
931
- cache: o.dependencies.cache,
932
- },
933
- assignment: {
934
- strategy: o.assignment.strategy,
935
- // Preserve dictionary keys verbatim (S, M, L, XL, etc.)
936
- size_weights: { ...o.assignment.sizeWeights },
937
- },
938
- pre_warm: {
939
- auto_detect: o.preWarm.autoDetect,
940
- // Preserve user-defined command keys verbatim
941
- commands: { ...o.preWarm.commands },
942
- always: [...o.preWarm.always],
943
- },
944
- merge: {
945
- model: o.merge.model,
946
- tools: o.merge.tools,
947
- verify: [...o.merge.verify],
948
- order: o.merge.order,
949
- timeout_minutes: o.merge.timeoutMinutes ?? 90,
950
- },
951
- failure: {
952
- on_task_failure: o.failure.onTaskFailure,
953
- on_merge_failure: o.failure.onMergeFailure,
954
- stall_timeout: o.failure.stallTimeout,
955
- max_worker_minutes: o.failure.maxWorkerMinutes,
956
- abort_grace_period: o.failure.abortGracePeriod,
957
- },
958
- monitoring: {
959
- poll_interval: o.monitoring.pollInterval,
960
- },
961
- verification: {
962
- enabled: o.verification.enabled,
963
- mode: o.verification.mode,
964
- flaky_reruns: o.verification.flakyReruns,
965
- },
966
- };
967
- }
968
-
969
- /**
970
- * Adapter: produce the legacy `TaskRunnerConfig` (snake_case subset) from unified config.
971
- *
972
- * The orchestrator's `TaskRunnerConfig` is a subset: { task_areas, reference_docs }.
973
- * This adapter maps the unified shape back to that contract.
974
- *
975
- * Special handling for `repoId`: whitespace-only values are treated as undefined,
976
- * and non-empty values are trimmed — matching the original YAML loader behavior.
977
- */
978
- export function toTaskRunnerConfig(config: TaskplaneConfig): import("./types.ts").TaskRunnerConfig {
979
- // task_areas needs snake_case keys inside each area too (repoId → repo_id)
980
- const taskAreas: Record<string, import("./types.ts").TaskArea> = {};
981
- for (const [name, area] of Object.entries(config.taskRunner.taskAreas)) {
982
- const ta: import("./types.ts").TaskArea = {
983
- path: area.path,
984
- prefix: area.prefix,
985
- context: area.context,
986
- };
987
- // repoId: only set if non-empty after trim (matches original YAML loader)
988
- if (area.repoId && typeof area.repoId === "string" && area.repoId.trim()) {
989
- ta.repoId = area.repoId.trim();
990
- }
991
- taskAreas[name] = ta;
992
- }
993
-
994
- // Include testing_commands for baseline fingerprinting (TP-032).
995
- // Only set the field when there are actual commands configured.
996
- const testingCommands = config.taskRunner.testing?.commands;
997
- const hasTestingCommands = testingCommands && Object.keys(testingCommands).length > 0;
998
-
999
- return {
1000
- task_areas: taskAreas,
1001
- reference_docs: { ...config.taskRunner.referenceDocs },
1002
- ...(hasTestingCommands ? { testing_commands: { ...testingCommands } } : {}),
1003
- model_fallback: config.taskRunner.modelFallback ?? "inherit",
1004
- };
1005
- }
1006
-
1007
- /**
1008
- * Adapter: produce the legacy task-runner `TaskConfig` (snake_case) from unified config.
1009
- *
1010
- * The task-runner extension has its own `TaskConfig` interface with snake_case keys.
1011
- * This adapter maps the unified shape back to that contract.
1012
- */
1013
- export function toTaskConfig(config: TaskplaneConfig): {
1014
- project: { name: string; description: string };
1015
- paths: { tasks: string; architecture?: string };
1016
- testing: { commands: Record<string, string> };
1017
- standards: { docs: string[]; rules: string[] };
1018
- standards_overrides: Record<string, { docs?: string[]; rules?: string[] }>;
1019
- task_areas: Record<string, { path: string; [key: string]: any }>;
1020
- worker: { model: string; tools: string; thinking: string; spawn_mode?: "subprocess" };
1021
- reviewer: { model: string; tools: string; thinking: string };
1022
- context: {
1023
- worker_context_window: number;
1024
- warn_percent: number;
1025
- kill_percent: number;
1026
- max_worker_iterations: number;
1027
- max_review_cycles: number;
1028
- no_progress_limit: number;
1029
- max_worker_minutes?: number;
1030
- };
1031
- quality_gate: {
1032
- enabled: boolean;
1033
- review_model: string;
1034
- max_review_cycles: number;
1035
- max_fix_cycles: number;
1036
- pass_threshold: "no_critical" | "no_important" | "all_clear";
1037
- };
1038
- } {
1039
- const tr = config.taskRunner;
1040
-
1041
- // Build standards_overrides with snake_case outer structure
1042
- const stdOverrides: Record<string, { docs?: string[]; rules?: string[] }> = {};
1043
- for (const [key, val] of Object.entries(tr.standardsOverrides)) {
1044
- stdOverrides[key] = { docs: val.docs, rules: val.rules };
1045
- }
1046
-
1047
- // Build task_areas
1048
- const taskAreas: Record<string, { path: string; [key: string]: any }> = {};
1049
- for (const [key, val] of Object.entries(tr.taskAreas)) {
1050
- taskAreas[key] = { path: val.path, prefix: val.prefix, context: val.context };
1051
- if (val.repoId) (taskAreas[key] as any).repo_id = val.repoId;
1052
- }
1053
-
1054
- return {
1055
- project: { ...tr.project },
1056
- paths: { ...tr.paths },
1057
- testing: { commands: { ...tr.testing.commands } },
1058
- standards: { docs: [...tr.standards.docs], rules: [...tr.standards.rules] },
1059
- standards_overrides: stdOverrides,
1060
- task_areas: taskAreas,
1061
- worker: {
1062
- model: tr.worker.model,
1063
- tools: tr.worker.tools,
1064
- thinking: tr.worker.thinking,
1065
- spawn_mode: tr.worker.spawnMode,
1066
- },
1067
- reviewer: { model: tr.reviewer.model, tools: tr.reviewer.tools, thinking: tr.reviewer.thinking },
1068
- context: {
1069
- worker_context_window: tr.context.workerContextWindow,
1070
- warn_percent: tr.context.warnPercent,
1071
- kill_percent: tr.context.killPercent,
1072
- max_worker_iterations: tr.context.maxWorkerIterations,
1073
- max_review_cycles: tr.context.maxReviewCycles,
1074
- no_progress_limit: tr.context.noProgressLimit,
1075
- max_worker_minutes: tr.context.maxWorkerMinutes,
1076
- },
1077
- quality_gate: {
1078
- enabled: tr.qualityGate.enabled,
1079
- review_model: tr.qualityGate.reviewModel,
1080
- max_review_cycles: tr.qualityGate.maxReviewCycles,
1081
- max_fix_cycles: tr.qualityGate.maxFixCycles,
1082
- pass_threshold: tr.qualityGate.passThreshold,
1083
- },
1084
- };
1085
- }
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, renameSync } 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
+ WorkspaceSectionConfig,
47
+ UserPreferences,
48
+ } from "./config-schema.ts";
49
+
50
+
51
+ // ── Error Types ──────────────────────────────────────────────────────
52
+
53
+ /**
54
+ * Error codes for config loading failures.
55
+ *
56
+ * - CONFIG_JSON_MALFORMED: File exists but is not valid JSON
57
+ * - CONFIG_VERSION_UNSUPPORTED: configVersion is not supported by this version
58
+ * - CONFIG_VERSION_MISSING: configVersion field is missing from JSON
59
+ * - CONFIG_LEGACY_FIELD: removed TMUX-era field/value detected; migration required
60
+ */
61
+ export type ConfigLoadErrorCode =
62
+ | "CONFIG_JSON_MALFORMED"
63
+ | "CONFIG_VERSION_UNSUPPORTED"
64
+ | "CONFIG_VERSION_MISSING"
65
+ | "CONFIG_LEGACY_FIELD";
66
+
67
+ export class ConfigLoadError extends Error {
68
+ code: ConfigLoadErrorCode;
69
+
70
+ constructor(code: ConfigLoadErrorCode, message: string) {
71
+ super(message);
72
+ this.name = "ConfigLoadError";
73
+ this.code = code;
74
+ }
75
+ }
76
+
77
+
78
+ // ── Deep Clone Helper ────────────────────────────────────────────────
79
+
80
+ /** Deep clone a config object to avoid cross-call mutation. */
81
+ function deepClone<T>(obj: T): T {
82
+ return JSON.parse(JSON.stringify(obj));
83
+ }
84
+
85
+
86
+ // ── Deep Merge Helper ────────────────────────────────────────────────
87
+
88
+ /**
89
+ * Deep merge `source` into `target`. Arrays are replaced, not merged.
90
+ * Only merges plain objects (not arrays, dates, etc).
91
+ * Returns `target` for chaining.
92
+ */
93
+ function deepMerge<T extends Record<string, any>>(target: T, source: Record<string, any>): T {
94
+ for (const key of Object.keys(source)) {
95
+ const srcVal = source[key];
96
+ const tgtVal = (target as any)[key];
97
+ if (
98
+ srcVal !== null &&
99
+ srcVal !== undefined &&
100
+ typeof srcVal === "object" &&
101
+ !Array.isArray(srcVal) &&
102
+ tgtVal !== null &&
103
+ tgtVal !== undefined &&
104
+ typeof tgtVal === "object" &&
105
+ !Array.isArray(tgtVal)
106
+ ) {
107
+ deepMerge(tgtVal, srcVal);
108
+ } else if (srcVal !== undefined) {
109
+ (target as any)[key] = srcVal;
110
+ }
111
+ }
112
+ return target;
113
+ }
114
+
115
+ function hasOwn(obj: unknown, key: string): boolean {
116
+ return !!obj && typeof obj === "object" && Object.prototype.hasOwnProperty.call(obj, key);
117
+ }
118
+
119
+ // throwLegacyFieldError removed replaced by auto-migration functions that fix config in-place
120
+
121
+ /**
122
+ * Auto-migrate legacy TMUX fields in project config.
123
+ * Renames fields in-place and writes back to disk instead of crashing.
124
+ * @returns true if any migrations were applied
125
+ */
126
+ /** Track whether project config migration has already run for this load cycle. */
127
+ let _projectMigrationDone = false;
128
+
129
+ /**
130
+ * Auto-migrate legacy TMUX fields in project config.
131
+ *
132
+ * Precedence: if both `sessionPrefix` and `tmuxPrefix` exist, the new
133
+ * key (`sessionPrefix`) wins. `tmuxPrefix` is only used when `sessionPrefix`
134
+ * is absent. This matches the principle that explicit new-format config
135
+ * takes priority over legacy fields.
136
+ *
137
+ * Writes back to disk atomically (tmp + rename) on first migration.
138
+ * Idempotent — safe to call multiple times per load cycle (skips after first).
139
+ *
140
+ * @returns true if any migrations were applied
141
+ */
142
+ function migrateProjectConfig(config: TaskplaneConfig, configRoot: string): boolean {
143
+ if (_projectMigrationDone) return false;
144
+
145
+ let migrated = false;
146
+ const orchestratorCore = config.orchestrator?.orchestrator as Record<string, unknown> | undefined;
147
+ if (orchestratorCore && hasOwn(orchestratorCore, "tmuxPrefix")) {
148
+ // Use tmuxPrefix if sessionPrefix is absent, undefined, or still the default.
149
+ // An explicit non-default sessionPrefix takes priority over legacy tmuxPrefix.
150
+ const currentPrefix = orchestratorCore.sessionPrefix;
151
+ const isDefault = currentPrefix === undefined || currentPrefix === "orch";
152
+ if (isDefault) {
153
+ (orchestratorCore as any).sessionPrefix = orchestratorCore.tmuxPrefix;
154
+ }
155
+ delete orchestratorCore.tmuxPrefix;
156
+ console.error(`[taskplane] Auto-migrated: orchestrator.orchestrator.tmuxPrefix → sessionPrefix`);
157
+ migrated = true;
158
+ }
159
+ if (orchestratorCore?.spawnMode === "tmux") {
160
+ (orchestratorCore as any).spawnMode = "subprocess";
161
+ console.error(`[taskplane] Auto-migrated: orchestrator.orchestrator.spawnMode "tmux" → "subprocess"`);
162
+ migrated = true;
163
+ }
164
+ const workerConfig = config.taskRunner?.worker as Record<string, unknown> | undefined;
165
+ if (workerConfig?.spawnMode === "tmux") {
166
+ (workerConfig as any).spawnMode = "subprocess";
167
+ console.error(`[taskplane] Auto-migrated: taskRunner.worker.spawnMode "tmux" → "subprocess"`);
168
+ migrated = true;
169
+ }
170
+
171
+ if (migrated) {
172
+ // Write back atomically (tmp + rename) to prevent corruption
173
+ try {
174
+ const jsonPath = join(configRoot, ".pi", "taskplane-config.json");
175
+ if (existsSync(jsonPath)) {
176
+ const raw = JSON.parse(readFileSync(jsonPath, "utf-8"));
177
+ // Apply same renames to the raw JSON (consistent precedence)
178
+ if (raw.orchestrator?.orchestrator?.tmuxPrefix !== undefined) {
179
+ const rawPrefix = raw.orchestrator.orchestrator.sessionPrefix;
180
+ if (rawPrefix === undefined || rawPrefix === "orch") {
181
+ raw.orchestrator.orchestrator.sessionPrefix = raw.orchestrator.orchestrator.tmuxPrefix;
182
+ }
183
+ delete raw.orchestrator.orchestrator.tmuxPrefix;
184
+ }
185
+ if (raw.orchestrator?.orchestrator?.spawnMode === "tmux") {
186
+ raw.orchestrator.orchestrator.spawnMode = "subprocess";
187
+ }
188
+ if (raw.taskRunner?.worker?.spawnMode === "tmux") {
189
+ raw.taskRunner.worker.spawnMode = "subprocess";
190
+ }
191
+ const tmpPath = jsonPath + ".migration-tmp";
192
+ writeFileSync(tmpPath, JSON.stringify(raw, null, 2) + "\n");
193
+ renameSync(tmpPath, jsonPath);
194
+ console.error(`[taskplane] Config file updated: ${jsonPath}`);
195
+ }
196
+ } catch (err) {
197
+ console.error(`[taskplane] Warning: could not persist config migration to disk: ${err instanceof Error ? err.message : err}`);
198
+ }
199
+ }
200
+
201
+ _projectMigrationDone = true;
202
+ return migrated;
203
+ }
204
+
205
+ /**
206
+ * Auto-migrate legacy TMUX fields in user preferences.
207
+ *
208
+ * Same precedence: new key wins if both exist.
209
+ * Writes back atomically (tmp + rename).
210
+ *
211
+ * @returns true if any migrations were applied
212
+ */
213
+ function migrateUserPreferences(raw: Record<string, any>, prefsPath: string): boolean {
214
+ let migrated = false;
215
+ if (hasOwn(raw, "tmuxPrefix")) {
216
+ if (!hasOwn(raw, "sessionPrefix") || raw.sessionPrefix === undefined) {
217
+ raw.sessionPrefix = raw.tmuxPrefix;
218
+ }
219
+ delete raw.tmuxPrefix;
220
+ console.error(`[taskplane] Auto-migrated user preference: tmuxPrefix → sessionPrefix`);
221
+ migrated = true;
222
+ }
223
+ if (raw.spawnMode === "tmux") {
224
+ raw.spawnMode = "subprocess";
225
+ console.error(`[taskplane] Auto-migrated user preference: spawnMode "tmux" → "subprocess"`);
226
+ migrated = true;
227
+ }
228
+ if (migrated) {
229
+ try {
230
+ const tmpPath = prefsPath + ".migration-tmp";
231
+ writeFileSync(tmpPath, JSON.stringify(raw, null, 2) + "\n");
232
+ renameSync(tmpPath, prefsPath);
233
+ console.error(`[taskplane] Preferences file updated: ${prefsPath}`);
234
+ } catch (err) {
235
+ console.error(`[taskplane] Warning: could not persist preferences migration to disk: ${err instanceof Error ? err.message : err}`);
236
+ }
237
+ }
238
+ return migrated;
239
+ }
240
+
241
+ /** Reset migration guard (for testing). @internal */
242
+ export function _resetMigrationGuard(): void { _projectMigrationDone = false; }
243
+
244
+
245
+ // ── YAML snake_case camelCase Mapping ──────────────────────────────
246
+
247
+ /**
248
+ * Convert a snake_case key to camelCase.
249
+ * e.g., "max_worker_iterations" → "maxWorkerIterations"
250
+ */
251
+ function snakeToCamel(s: string): string {
252
+ return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
253
+ }
254
+
255
+ /**
256
+ * Convert structural keys from snake_case to camelCase, recursively.
257
+ * Used for sections where ALL keys are structural schema keys (no
258
+ * user-defined dictionary keys).
259
+ */
260
+ function convertStructuralKeys(obj: any): any {
261
+ if (obj === null || obj === undefined) return obj;
262
+ if (Array.isArray(obj)) return obj.map(convertStructuralKeys);
263
+ if (typeof obj !== "object") return obj;
264
+
265
+ const result: Record<string, any> = {};
266
+ for (const [key, val] of Object.entries(obj)) {
267
+ const camelKey = snakeToCamel(key);
268
+ if (val !== null && typeof val === "object" && !Array.isArray(val)) {
269
+ result[camelKey] = convertStructuralKeys(val);
270
+ } else if (Array.isArray(val)) {
271
+ result[camelKey] = val.map(convertStructuralKeys);
272
+ } else {
273
+ result[camelKey] = val;
274
+ }
275
+ }
276
+ return result;
277
+ }
278
+
279
+ /**
280
+ * Convert a record/dictionary section where outer keys are user-defined
281
+ * identifiers (preserve verbatim) but inner keys are structural (convert).
282
+ */
283
+ function convertRecordSection(obj: any): any {
284
+ if (obj === null || obj === undefined) return obj;
285
+ if (typeof obj !== "object" || Array.isArray(obj)) return obj;
286
+
287
+ const result: Record<string, any> = {};
288
+ for (const [key, val] of Object.entries(obj)) {
289
+ // Preserve user-defined key verbatim, convert structural inner keys
290
+ if (val !== null && typeof val === "object" && !Array.isArray(val)) {
291
+ result[key] = convertStructuralKeys(val);
292
+ } else {
293
+ result[key] = val;
294
+ }
295
+ }
296
+ return result;
297
+ }
298
+
299
+ /**
300
+ * Convert a flat record/dictionary where both keys and values are
301
+ * user-defined (preserve everything verbatim). Used for sections like
302
+ * `reference_docs`, `self_doc_targets`, `testing.commands` where
303
+ * keys are identifiers and values are strings.
304
+ */
305
+ function preserveRecord(obj: any): any {
306
+ if (obj === null || obj === undefined) return obj;
307
+ if (typeof obj !== "object" || Array.isArray(obj)) return obj;
308
+ return { ...obj };
309
+ }
310
+
311
+ // ── Section-aware YAML mapping ───────────────────────────────────────
312
+
313
+ /**
314
+ * Map a raw task-runner YAML object to the camelCase TaskRunnerSection shape.
315
+ *
316
+ * Knows which sections contain user-defined record keys vs. structural keys:
317
+ * - Structural-only: project, paths, worker, reviewer, context, standards
318
+ * - Record with structural inner keys: task_areas, standards_overrides
319
+ * - Flat record (preserve all keys): testing.commands, reference_docs,
320
+ * self_doc_targets
321
+ * - Array (preserve): never_load, protected_docs
322
+ */
323
+ function mapTaskRunnerYaml(raw: any): Partial<TaskRunnerSection> {
324
+ const result: any = {};
325
+
326
+ // Structural sections all keys are schema-defined
327
+ if (raw.project) result.project = convertStructuralKeys(raw.project);
328
+ if (raw.paths) result.paths = convertStructuralKeys(raw.paths);
329
+ if (raw.worker) result.worker = convertStructuralKeys(raw.worker);
330
+ if (raw.reviewer) result.reviewer = convertStructuralKeys(raw.reviewer);
331
+ if (raw.context) result.context = convertStructuralKeys(raw.context);
332
+ if (raw.standards) result.standards = convertStructuralKeys(raw.standards);
333
+
334
+ // Testing: commands is a flat user-defined record
335
+ if (raw.testing) {
336
+ result.testing = {};
337
+ if (raw.testing.commands) {
338
+ result.testing.commands = preserveRecord(raw.testing.commands);
339
+ }
340
+ }
341
+
342
+ // Record sections with structural inner keys
343
+ if (raw.task_areas) result.taskAreas = convertRecordSection(raw.task_areas);
344
+ if (raw.standards_overrides) result.standardsOverrides = convertRecordSection(raw.standards_overrides);
345
+
346
+ // Flat record sections (keys are identifiers, values are strings)
347
+ if (raw.reference_docs) result.referenceDocs = preserveRecord(raw.reference_docs);
348
+ if (raw.self_doc_targets) result.selfDocTargets = preserveRecord(raw.self_doc_targets);
349
+
350
+ // Array sections (preserve verbatim)
351
+ if (raw.never_load) result.neverLoad = [...raw.never_load];
352
+ if (raw.protected_docs) result.protectedDocs = [...raw.protected_docs];
353
+
354
+ // Quality gate (structural — all keys are schema-defined)
355
+ if (raw.quality_gate) result.qualityGate = convertStructuralKeys(raw.quality_gate);
356
+
357
+ // Model fallback (scalar "inherit" or "fail")
358
+ if (raw.model_fallback) result.modelFallback = raw.model_fallback;
359
+
360
+ return result;
361
+ }
362
+
363
+ /**
364
+ * Map a raw orchestrator YAML object to the camelCase OrchestratorSection shape.
365
+ *
366
+ * Knows which sections contain user-defined record keys:
367
+ * - Structural: orchestrator, dependencies, merge, failure, monitoring
368
+ * - Record with structural inner keys: (none)
369
+ * - Flat record (preserve keys): pre_warm.commands, assignment.size_weights
370
+ */
371
+ function mapOrchestratorYaml(raw: any): Partial<OrchestratorSection> {
372
+ const result: any = {};
373
+
374
+ // Structural sections
375
+ if (raw.orchestrator) result.orchestrator = convertStructuralKeys(raw.orchestrator);
376
+ if (raw.dependencies) result.dependencies = convertStructuralKeys(raw.dependencies);
377
+ if (raw.merge) result.merge = convertStructuralKeys(raw.merge);
378
+ if (raw.failure) result.failure = convertStructuralKeys(raw.failure);
379
+ if (raw.monitoring) result.monitoring = convertStructuralKeys(raw.monitoring);
380
+
381
+ // assignment: strategy is structural, size_weights is a user-defined record
382
+ if (raw.assignment) {
383
+ result.assignment = {};
384
+ if (raw.assignment.strategy !== undefined) result.assignment.strategy = raw.assignment.strategy;
385
+ if (raw.assignment.size_weights) result.assignment.sizeWeights = preserveRecord(raw.assignment.size_weights);
386
+ }
387
+
388
+ // pre_warm: auto_detect is structural, commands is user-defined, always is array
389
+ if (raw.pre_warm) {
390
+ result.preWarm = {};
391
+ if (raw.pre_warm.auto_detect !== undefined) result.preWarm.autoDetect = raw.pre_warm.auto_detect;
392
+ if (raw.pre_warm.commands) result.preWarm.commands = preserveRecord(raw.pre_warm.commands);
393
+ if (raw.pre_warm.always) result.preWarm.always = [...raw.pre_warm.always];
394
+ }
395
+
396
+ // verification: all keys are structural (TP-032)
397
+ if (raw.verification) result.verification = convertStructuralKeys(raw.verification);
398
+
399
+ // supervisor: all keys are structural (TP-041)
400
+ if (raw.supervisor) result.supervisor = convertStructuralKeys(raw.supervisor);
401
+
402
+ return result;
403
+ }
404
+
405
+ /**
406
+ * Normalize a workspace section loaded from JSON/YAML into camelCase shape.
407
+ *
408
+ * Compatibility: if `routing.taskPacketRepo` is missing, defaults to
409
+ * `routing.defaultRepo` and emits a warning message.
410
+ */
411
+ function normalizeWorkspaceSection(
412
+ rawWorkspace: any,
413
+ sourcePath: string,
414
+ ): WorkspaceSectionConfig | undefined {
415
+ if (!rawWorkspace || typeof rawWorkspace !== "object" || Array.isArray(rawWorkspace)) {
416
+ return undefined;
417
+ }
418
+
419
+ const rawRepos = rawWorkspace.repos;
420
+ if (!rawRepos || typeof rawRepos !== "object" || Array.isArray(rawRepos)) {
421
+ return undefined;
422
+ }
423
+
424
+ const rawRouting = rawWorkspace.routing;
425
+ if (!rawRouting || typeof rawRouting !== "object" || Array.isArray(rawRouting)) {
426
+ return undefined;
427
+ }
428
+
429
+ const repos: WorkspaceSectionConfig["repos"] = {};
430
+ for (const [repoId, repoVal] of Object.entries(rawRepos as Record<string, any>)) {
431
+ if (!repoVal || typeof repoVal !== "object" || Array.isArray(repoVal)) continue;
432
+ const repoObj = repoVal as Record<string, any>;
433
+ if (typeof repoObj.path !== "string" || repoObj.path.trim() === "") continue;
434
+ repos[repoId] = {
435
+ path: repoObj.path,
436
+ ...(typeof repoObj.defaultBranch === "string" && repoObj.defaultBranch.trim()
437
+ ? { defaultBranch: repoObj.defaultBranch }
438
+ : {}),
439
+ };
440
+ }
441
+
442
+ const defaultRepo = typeof rawRouting.defaultRepo === "string" ? rawRouting.defaultRepo.trim() : "";
443
+ const tasksRoot = typeof rawRouting.tasksRoot === "string" ? rawRouting.tasksRoot.trim() : "";
444
+ let taskPacketRepo = typeof rawRouting.taskPacketRepo === "string" ? rawRouting.taskPacketRepo.trim() : "";
445
+
446
+ if (!taskPacketRepo && defaultRepo) {
447
+ taskPacketRepo = defaultRepo;
448
+ console.error(
449
+ `[taskplane] config compatibility: workspace.routing.taskPacketRepo is missing in ${sourcePath}; defaulting to workspace.routing.defaultRepo ('${defaultRepo}'). Add workspace.routing.taskPacketRepo explicitly.`,
450
+ );
451
+ }
452
+
453
+ if (!tasksRoot || !defaultRepo || !taskPacketRepo) {
454
+ return undefined;
455
+ }
456
+
457
+ const strict = rawRouting.strict === true;
458
+
459
+ return {
460
+ repos,
461
+ routing: {
462
+ tasksRoot,
463
+ defaultRepo,
464
+ taskPacketRepo,
465
+ ...(strict ? { strict: true } : {}),
466
+ },
467
+ };
468
+ }
469
+
470
+
471
+ // ── Config File Path Resolution ──────────────────────────────────────
472
+
473
+ /**
474
+ * Resolve the path to a config file under the given root.
475
+ *
476
+ * Supports two directory layouts:
477
+ * 1. Standard layout: `<root>/.pi/<filename>` — used by repo mode and
478
+ * workspace root, where config files live under the `.pi/` subdirectory.
479
+ * 2. Flat layout: `<root>/<filename>` — used by pointer-resolved config
480
+ * roots (e.g., `<configRepo>/.taskplane/task-runner.yaml`), where
481
+ * `taskplane init` scaffolds files directly in the config path.
482
+ *
483
+ * Standard layout is checked first for backward compatibility. If neither
484
+ * exists, returns the standard-layout path (callers check existence).
485
+ */
486
+ function resolveConfigFilePath(configRoot: string, filename: string): string {
487
+ const standardPath = join(configRoot, ".pi", filename);
488
+ if (existsSync(standardPath)) return standardPath;
489
+
490
+ const flatPath = join(configRoot, filename);
491
+ if (existsSync(flatPath)) return flatPath;
492
+
493
+ // Default to standard path — callers handle non-existence
494
+ return standardPath;
495
+ }
496
+
497
+ // ── JSON Loading ─────────────────────────────────────────────────────
498
+
499
+ /**
500
+ * Attempt to load and validate `taskplane-config.json`.
501
+ *
502
+ * Checks both standard layout (`<root>/.pi/taskplane-config.json`) and
503
+ * flat layout (`<root>/taskplane-config.json`) — see `resolveConfigFilePath`.
504
+ *
505
+ * Returns the parsed config or null if the file doesn't exist.
506
+ * Throws ConfigLoadError for malformed JSON or unsupported versions.
507
+ */
508
+ function loadJsonConfig(configRoot: string): TaskplaneConfig | null {
509
+ const jsonPath = resolveConfigFilePath(configRoot, PROJECT_CONFIG_FILENAME);
510
+ if (!existsSync(jsonPath)) return null;
511
+
512
+ let raw: string;
513
+ try {
514
+ raw = readFileSync(jsonPath, "utf-8");
515
+ } catch {
516
+ return null; // Can't read file — treat as absent
517
+ }
518
+
519
+ let parsed: any;
520
+ try {
521
+ parsed = JSON.parse(raw);
522
+ } catch (e: any) {
523
+ throw new ConfigLoadError(
524
+ "CONFIG_JSON_MALFORMED",
525
+ `Failed to parse ${jsonPath}: ${e.message ?? "invalid JSON"}`,
526
+ );
527
+ }
528
+
529
+ // Validate configVersion
530
+ if (parsed.configVersion === undefined || parsed.configVersion === null) {
531
+ throw new ConfigLoadError(
532
+ "CONFIG_VERSION_MISSING",
533
+ `${jsonPath} is missing required field "configVersion". ` +
534
+ `Expected configVersion: ${CONFIG_VERSION}.`,
535
+ );
536
+ }
537
+
538
+ if (parsed.configVersion !== CONFIG_VERSION) {
539
+ throw new ConfigLoadError(
540
+ "CONFIG_VERSION_UNSUPPORTED",
541
+ `${jsonPath} has configVersion ${parsed.configVersion}, but this version of Taskplane ` +
542
+ `only supports configVersion ${CONFIG_VERSION}. Please upgrade Taskplane.`,
543
+ );
544
+ }
545
+
546
+ // Deep merge with cloned defaults
547
+ const config = deepClone(DEFAULT_PROJECT_CONFIG);
548
+ if (parsed.taskRunner) {
549
+ deepMerge(config.taskRunner, parsed.taskRunner);
550
+ }
551
+ if (parsed.orchestrator) {
552
+ deepMerge(config.orchestrator, parsed.orchestrator);
553
+ }
554
+ if (parsed.workspace) {
555
+ const normalizedWorkspace = normalizeWorkspaceSection(parsed.workspace, jsonPath);
556
+ if (normalizedWorkspace) {
557
+ config.workspace = normalizedWorkspace;
558
+ }
559
+ }
560
+
561
+ return config;
562
+ }
563
+
564
+
565
+ // ── YAML Loading ─────────────────────────────────────────────────────
566
+
567
+ /**
568
+ * Load task-runner settings from `task-runner.yaml`.
569
+ *
570
+ * Checks both standard layout (`<root>/.pi/task-runner.yaml`) and
571
+ * flat layout (`<root>/task-runner.yaml`) see `resolveConfigFilePath`.
572
+ * Maps snake_case YAML keys to the camelCase TaskRunnerSection shape.
573
+ * Uses section-aware mapping that preserves user-defined record keys.
574
+ * Returns cloned defaults if the file doesn't exist or is malformed.
575
+ */
576
+ function loadTaskRunnerYaml(configRoot: string): TaskRunnerSection {
577
+ const yamlPath = resolveConfigFilePath(configRoot, "task-runner.yaml");
578
+ if (!existsSync(yamlPath)) return deepClone(DEFAULT_TASK_RUNNER_SECTION);
579
+
580
+ try {
581
+ const raw = readFileSync(yamlPath, "utf-8");
582
+ const loaded = yamlParse(raw) as any;
583
+ if (!loaded || typeof loaded !== "object") return deepClone(DEFAULT_TASK_RUNNER_SECTION);
584
+
585
+ // Section-aware mapping: structural keys → camelCase, record keys → preserved
586
+ const mapped = mapTaskRunnerYaml(loaded);
587
+
588
+ // Deep merge with cloned defaults
589
+ const section = deepClone(DEFAULT_TASK_RUNNER_SECTION);
590
+ deepMerge(section, mapped);
591
+
592
+ // Post-process taskAreas: trim repoId, drop whitespace-only values
593
+ // (matches legacy loadTaskRunnerConfig behavior from config.ts)
594
+ if (section.taskAreas) {
595
+ for (const area of Object.values(section.taskAreas)) {
596
+ if (area.repoId !== undefined) {
597
+ const trimmed = typeof area.repoId === "string" ? area.repoId.trim() : "";
598
+ if (trimmed) {
599
+ area.repoId = trimmed;
600
+ } else {
601
+ delete area.repoId;
602
+ }
603
+ }
604
+ }
605
+ }
606
+
607
+ return section;
608
+ } catch {
609
+ return deepClone(DEFAULT_TASK_RUNNER_SECTION);
610
+ }
611
+ }
612
+
613
+ /**
614
+ * Load orchestrator settings from `task-orchestrator.yaml`.
615
+ *
616
+ * Checks both standard layout (`<root>/.pi/task-orchestrator.yaml`) and
617
+ * flat layout (`<root>/task-orchestrator.yaml`) — see `resolveConfigFilePath`.
618
+ * Maps snake_case YAML keys to the camelCase OrchestratorSection shape.
619
+ * Uses section-aware mapping that preserves user-defined record keys.
620
+ * Returns cloned defaults if the file doesn't exist or is malformed.
621
+ */
622
+ function loadOrchestratorYaml(configRoot: string): OrchestratorSection {
623
+ const yamlPath = resolveConfigFilePath(configRoot, "task-orchestrator.yaml");
624
+ if (!existsSync(yamlPath)) return deepClone(DEFAULT_ORCHESTRATOR_SECTION);
625
+
626
+ try {
627
+ const raw = readFileSync(yamlPath, "utf-8");
628
+ const loaded = yamlParse(raw) as any;
629
+ if (!loaded || typeof loaded !== "object") return deepClone(DEFAULT_ORCHESTRATOR_SECTION);
630
+
631
+ // Section-aware mapping: structural keys → camelCase, record keys → preserved
632
+ const mapped = mapOrchestratorYaml(loaded);
633
+
634
+ // Deep merge with cloned defaults
635
+ const section = deepClone(DEFAULT_ORCHESTRATOR_SECTION);
636
+ deepMerge(section, mapped);
637
+
638
+ return section;
639
+ } catch {
640
+ return deepClone(DEFAULT_ORCHESTRATOR_SECTION);
641
+ }
642
+ }
643
+
644
+ /**
645
+ * Load optional workspace routing config from legacy `taskplane-workspace.yaml`.
646
+ *
647
+ * This file is fallback-only for workspace metadata when JSON `workspace`
648
+ * section is not present. Malformed files are ignored here — strict validation
649
+ * still happens in workspace runtime loading (`workspace.ts`).
650
+ */
651
+ function loadWorkspaceYaml(configRoot: string): WorkspaceSectionConfig | undefined {
652
+ const yamlPath = resolveConfigFilePath(configRoot, "taskplane-workspace.yaml");
653
+ if (!existsSync(yamlPath)) return undefined;
654
+
655
+ try {
656
+ const raw = readFileSync(yamlPath, "utf-8");
657
+ const loaded = yamlParse(raw) as any;
658
+ if (!loaded || typeof loaded !== "object") return undefined;
659
+
660
+ const converted = convertStructuralKeys(loaded);
661
+ return normalizeWorkspaceSection(converted, yamlPath);
662
+ } catch {
663
+ return undefined;
664
+ }
665
+ }
666
+
667
+
668
+ // ── User Preferences (Layer 2) ───────────────────────────────────────
669
+
670
+ /**
671
+ * Resolve the absolute path to the user preferences file.
672
+ *
673
+ * Resolution order:
674
+ * 1. `PI_CODING_AGENT_DIR` env → `<value>/taskplane/preferences.json`
675
+ * 2. `os.homedir()/.pi/agent/taskplane/preferences.json`
676
+ *
677
+ * Uses `os.homedir()` for cross-platform home resolution
678
+ * (USERPROFILE on Windows, HOME on Unix) and `path.join()` for separators.
679
+ */
680
+ export function resolveUserPreferencesPath(): string {
681
+ const agentDir = process.env.PI_CODING_AGENT_DIR;
682
+ if (agentDir) {
683
+ return join(agentDir, USER_PREFERENCES_SUBDIR, USER_PREFERENCES_FILENAME);
684
+ }
685
+ return join(homedir(), ".pi", "agent", USER_PREFERENCES_SUBDIR, USER_PREFERENCES_FILENAME);
686
+ }
687
+
688
+ /**
689
+ * Load user preferences from `~/.pi/agent/taskplane/preferences.json`.
690
+ *
691
+ * Behavior:
692
+ * - If file doesn't exist: auto-create with empty defaults `{}`, return defaults
693
+ * - If file is malformed JSON: log warning, return defaults (non-destructive)
694
+ * - Unknown keys are silently ignored (only allowlisted fields extracted)
695
+ * - Returns a fresh UserPreferences object on each call
696
+ *
697
+ * @returns Parsed UserPreferences (only recognized fields)
698
+ */
699
+ export function loadUserPreferences(): UserPreferences {
700
+ const prefsPath = resolveUserPreferencesPath();
701
+
702
+ if (!existsSync(prefsPath)) {
703
+ // Auto-create with empty defaults on first access
704
+ try {
705
+ const dir = join(prefsPath, "..");
706
+ mkdirSync(dir, { recursive: true });
707
+ writeFileSync(prefsPath, JSON.stringify(DEFAULT_USER_PREFERENCES, null, 2) + "\n", "utf-8");
708
+ } catch {
709
+ // Best-effort; if we can't create, just return defaults
710
+ }
711
+ return { ...DEFAULT_USER_PREFERENCES };
712
+ }
713
+
714
+ let raw: string;
715
+ try {
716
+ raw = readFileSync(prefsPath, "utf-8");
717
+ } catch {
718
+ return { ...DEFAULT_USER_PREFERENCES };
719
+ }
720
+
721
+ let parsed: any;
722
+ try {
723
+ parsed = JSON.parse(raw);
724
+ } catch {
725
+ // Malformed JSONreturn defaults without overwriting (non-destructive)
726
+ return { ...DEFAULT_USER_PREFERENCES };
727
+ }
728
+
729
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
730
+ return { ...DEFAULT_USER_PREFERENCES };
731
+ }
732
+
733
+ // Extract only allowlisted fields — unknown keys are ignored
734
+ return extractAllowlistedPreferences(parsed, prefsPath);
735
+ }
736
+
737
+ /**
738
+ * Extract only recognized/allowlisted fields from a raw parsed object.
739
+ * Unknown keys are silently dropped — this is the Layer 2 boundary guardrail.
740
+ */
741
+ function extractAllowlistedPreferences(raw: Record<string, any>, prefsPath: string): UserPreferences {
742
+ migrateUserPreferences(raw, prefsPath);
743
+
744
+ const prefs: UserPreferences = {};
745
+
746
+ if (typeof raw.operatorId === "string") prefs.operatorId = raw.operatorId;
747
+ if (typeof raw.sessionPrefix === "string") {
748
+ prefs.sessionPrefix = raw.sessionPrefix;
749
+ }
750
+ if (raw.spawnMode === "subprocess") {
751
+ prefs.spawnMode = "subprocess";
752
+ }
753
+ if (typeof raw.workerModel === "string") prefs.workerModel = raw.workerModel;
754
+ if (typeof raw.reviewerModel === "string") prefs.reviewerModel = raw.reviewerModel;
755
+ if (typeof raw.mergeModel === "string") prefs.mergeModel = raw.mergeModel;
756
+ if (typeof raw.supervisorModel === "string") prefs.supervisorModel = raw.supervisorModel;
757
+ if (typeof raw.dashboardPort === "number" && Number.isFinite(raw.dashboardPort)) {
758
+ prefs.dashboardPort = raw.dashboardPort;
759
+ }
760
+
761
+ return prefs;
762
+ }
763
+
764
+ /**
765
+ * Apply user preferences (Layer 2) onto a project config (Layer 1).
766
+ *
767
+ * Only allowlisted fields are applied. User preferences win for Layer 2
768
+ * fields; all other config fields (Layer 1) are left untouched.
769
+ *
770
+ * Mutates `config` in place and returns it for chaining.
771
+ *
772
+ * Empty-string preference values are treated as "not set" and do NOT
773
+ * override the project config value. This lets users clear a preference
774
+ * by deleting the field or setting it to "".
775
+ *
776
+ * Mapping table:
777
+ * prefs.operatorId → config.orchestrator.orchestrator.operatorId
778
+ * prefs.sessionPrefix config.orchestrator.orchestrator.sessionPrefix
779
+ * prefs.spawnMode → config.orchestrator.orchestrator.spawnMode
780
+ * prefs.workerModel → config.taskRunner.worker.model
781
+ * prefs.reviewerModel config.taskRunner.reviewer.model
782
+ * prefs.mergeModel → config.orchestrator.merge.model
783
+ * prefs.supervisorModel → config.orchestrator.supervisor.model
784
+ * prefs.dashboardPort (no config target yetstored only)
785
+ */
786
+ export function applyUserPreferences(config: TaskplaneConfig, prefs: UserPreferences): TaskplaneConfig {
787
+ // Helper: only apply non-empty string values
788
+ const applyStr = (val: string | undefined, setter: (v: string) => void) => {
789
+ if (val !== undefined && val !== "") setter(val);
790
+ };
791
+
792
+ applyStr(prefs.operatorId, (v) => { config.orchestrator.orchestrator.operatorId = v; });
793
+ applyStr(prefs.sessionPrefix, (v) => { config.orchestrator.orchestrator.sessionPrefix = v; });
794
+ applyStr(prefs.workerModel, (v) => { config.taskRunner.worker.model = v; });
795
+ applyStr(prefs.reviewerModel, (v) => { config.taskRunner.reviewer.model = v; });
796
+ applyStr(prefs.mergeModel, (v) => { config.orchestrator.merge.model = v; });
797
+ applyStr(prefs.supervisorModel, (v) => { config.orchestrator.supervisor.model = v; });
798
+
799
+ // spawnMode: enum — apply if defined (not a string-empty check)
800
+ if (prefs.spawnMode !== undefined) {
801
+ if (prefs.spawnMode === "tmux") {
802
+ prefs.spawnMode = "subprocess";
803
+ console.error(`[taskplane] Auto-migrated runtime preference: spawnMode "tmux" → "subprocess"`);
804
+ }
805
+ config.orchestrator.orchestrator.spawnMode = prefs.spawnMode;
806
+ }
807
+
808
+ // dashboardPort: no config schema target yet — intentionally not applied
809
+ // It can be read directly from loadUserPreferences() by consumers that need it.
810
+
811
+ return config;
812
+ }
813
+
814
+ // ── Unified Loader ───────────────────────────────────────────────────
815
+
816
+ /**
817
+ * Check whether any config files exist under the given root.
818
+ *
819
+ * Supports both standard layout (`<root>/.pi/<file>`) and flat layout
820
+ * (`<root>/<file>`). Returns true if any recognized config file is found
821
+ * in either location. This allows pointer-resolved roots (e.g.,
822
+ * `<configRepo>/.taskplane/`) where files are scaffolded directly
823
+ * without a `.pi/` subdirectory.
824
+ *
825
+ * Includes optional workspace YAML (`taskplane-workspace.yaml`) so
826
+ * workspace-only roots participate in config-root resolution.
827
+ */
828
+ export function hasConfigFiles(root: string): boolean {
829
+ const files = [
830
+ PROJECT_CONFIG_FILENAME,
831
+ "task-runner.yaml",
832
+ "task-orchestrator.yaml",
833
+ "taskplane-workspace.yaml",
834
+ ];
835
+ for (const f of files) {
836
+ if (existsSync(join(root, ".pi", f)) || existsSync(join(root, f))) return true;
837
+ }
838
+ return false;
839
+ }
840
+
841
+ /**
842
+ * Resolve the config root directory.
843
+ *
844
+ * In workspace mode, workers run in repo worktrees — not the workspace root.
845
+ * TASKPLANE_WORKSPACE_ROOT tells us where config files actually live.
846
+ * The pointer file (`taskplane-pointer.json`) can redirect config loading
847
+ * to a specific repo's config path.
848
+ *
849
+ * Resolution order:
850
+ * 1. If `cwd` has actual config files → use cwd (local override wins)
851
+ * 2. If `pointerConfigRoot` is set and has config files → use it (pointer redirect)
852
+ * 3. If TASKPLANE_WORKSPACE_ROOT is set and has config files → use it (legacy fallback)
853
+ * 4. Fall back to cwd (loaders will return defaults)
854
+ *
855
+ * We check for actual config files — not just the `.pi/` directory —
856
+ * because worktrees may have a sidecar `.pi` without config files.
857
+ *
858
+ * @param cwd - Current working directory (project root or worktree)
859
+ * @param pointerConfigRoot - Resolved config root from pointer file (optional, workspace mode only)
860
+ */
861
+ export function resolveConfigRoot(cwd: string, pointerConfigRoot?: string): string {
862
+ // Prefer cwd if it has actual config files (local override always wins)
863
+ if (hasConfigFiles(cwd)) return cwd;
864
+
865
+ // Pointer-resolved config root — workspace mode with valid pointer
866
+ if (pointerConfigRoot && hasConfigFiles(pointerConfigRoot)) return pointerConfigRoot;
867
+
868
+ // Workspace mode fallback — check for actual config files at workspace root
869
+ const wsRoot = process.env.TASKPLANE_WORKSPACE_ROOT;
870
+ if (wsRoot && hasConfigFiles(wsRoot)) return wsRoot;
871
+
872
+ // Fall back to cwd even without config files — loaders will return defaults
873
+ return cwd;
874
+ }
875
+
876
+ /**
877
+ * Load the unified project configuration.
878
+ *
879
+ * Precedence (layered):
880
+ * Layer 1 Project config:
881
+ * 1. `.pi/taskplane-config.json` — JSON-first (new format)
882
+ * 2. `.pi/task-runner.yaml` + `.pi/task-orchestrator.yaml` — YAML fallback
883
+ * (+ optional `.pi/taskplane-workspace.yaml` workspace section mapping)
884
+ * 3. Defaults — if no config files exist
885
+ *
886
+ * Layer 2 — User preferences (applied on top of Layer 1):
887
+ * Reads `~/.pi/agent/taskplane/preferences.json` and overrides only
888
+ * allowlisted user-scoped fields. See `applyUserPreferences()` for
889
+ * the field mapping.
890
+ *
891
+ * Config root resolution order:
892
+ * 1. cwd has config files → use cwd (local override)
893
+ * 2. pointerConfigRoot has config files use it (pointer redirect, workspace mode)
894
+ * 3. TASKPLANE_WORKSPACE_ROOT has config files → use it (legacy fallback)
895
+ * 4. Fall back to cwd (loaders will return defaults)
896
+ *
897
+ * @param cwd - Current working directory (project root or worktree)
898
+ * @param pointerConfigRoot - Resolved config root from pointer file (optional).
899
+ * Callers in workspace mode should resolve the pointer via `resolvePointer()`
900
+ * and pass `result.configRoot` here. In repo mode, omit or pass undefined.
901
+ * @returns Unified TaskplaneConfig — always a fresh deep-cloned object
902
+ * @throws ConfigLoadError if JSON exists but is malformed or has unsupported version
903
+ */
904
+ export function loadProjectConfig(cwd: string, pointerConfigRoot?: string): TaskplaneConfig {
905
+ const configRoot = resolveConfigRoot(cwd, pointerConfigRoot);
906
+
907
+ // Layer 1: Project config
908
+ let config: TaskplaneConfig;
909
+
910
+ // Try JSON first
911
+ const jsonConfig = loadJsonConfig(configRoot);
912
+ if (jsonConfig !== null) {
913
+ config = jsonConfig;
914
+ } else {
915
+ // Fall back to YAML
916
+ const taskRunner = loadTaskRunnerYaml(configRoot);
917
+ const orchestrator = loadOrchestratorYaml(configRoot);
918
+ const workspace = loadWorkspaceYaml(configRoot);
919
+ config = {
920
+ configVersion: CONFIG_VERSION,
921
+ taskRunner,
922
+ orchestrator,
923
+ ...(workspace ? { workspace } : {}),
924
+ };
925
+ }
926
+
927
+ _projectMigrationDone = false; // Reset guard for each top-level load
928
+ migrateProjectConfig(config, configRoot);
929
+
930
+ // Layer 2: User preferences (allowlisted fields only)
931
+ const prefs = loadUserPreferences();
932
+ applyUserPreferences(config, prefs);
933
+ // No second migrateProjectConfig call needed — idempotency guard + prefs
934
+ // can’t re-introduce tmux fields (migrateUserPreferences already ran).
935
+
936
+ return config;
937
+ }
938
+
939
+ /**
940
+ * Load Layer 1 config only (project config without user preferences).
941
+ *
942
+ * Returns the project config merged with defaults, but WITHOUT applying
943
+ * Layer 2 user preferences. Used by the settings TUI write-back to
944
+ * bootstrap a JSON config file from YAML-only projects without
945
+ * accidentally embedding user preferences into the project config.
946
+ *
947
+ * @param cwd - Current working directory (project root or worktree)
948
+ * @param pointerConfigRoot - Optional pointer-resolved config root (workspace mode)
949
+ * @returns Layer 1 TaskplaneConfig — always a fresh deep-cloned object
950
+ * @throws ConfigLoadError if JSON exists but is malformed or has unsupported version
951
+ */
952
+ export function loadLayer1Config(cwd: string, pointerConfigRoot?: string): TaskplaneConfig {
953
+ const configRoot = resolveConfigRoot(cwd, pointerConfigRoot);
954
+
955
+ // Try JSON first
956
+ const jsonConfig = loadJsonConfig(configRoot);
957
+ if (jsonConfig !== null) {
958
+ migrateProjectConfig(jsonConfig, configRoot);
959
+ return jsonConfig;
960
+ }
961
+
962
+ // Fall back to YAML
963
+ const taskRunner = loadTaskRunnerYaml(configRoot);
964
+ const orchestrator = loadOrchestratorYaml(configRoot);
965
+ const workspace = loadWorkspaceYaml(configRoot);
966
+ const config: TaskplaneConfig = {
967
+ configVersion: CONFIG_VERSION,
968
+ taskRunner,
969
+ orchestrator,
970
+ ...(workspace ? { workspace } : {}),
971
+ };
972
+ migrateProjectConfig(config, configRoot);
973
+ return config;
974
+ }
975
+
976
+
977
+ // ── Backward-Compatible Adapters ─────────────────────────────────────
978
+
979
+ // The following adapter functions convert the unified camelCase config
980
+ // back to the snake_case shapes expected by existing consumers.
981
+
982
+ /**
983
+ * Adapter: produce the legacy `OrchestratorConfig` (snake_case) from unified config.
984
+ *
985
+ * Uses explicit field mapping instead of generic recursive key conversion
986
+ * to preserve record/dictionary keys verbatim (e.g., sizeWeights S/M/L,
987
+ * preWarm.commands keys, etc.).
988
+ */
989
+ export function toOrchestratorConfig(config: TaskplaneConfig): import("./types.ts").OrchestratorConfig {
990
+ const o = config.orchestrator;
991
+ return {
992
+ orchestrator: {
993
+ max_lanes: o.orchestrator.maxLanes,
994
+ worktree_location: o.orchestrator.worktreeLocation,
995
+ worktree_prefix: o.orchestrator.worktreePrefix,
996
+ batch_id_format: o.orchestrator.batchIdFormat,
997
+ spawn_mode: o.orchestrator.spawnMode,
998
+ sessionPrefix: o.orchestrator.sessionPrefix,
999
+ operator_id: o.orchestrator.operatorId,
1000
+ integration: o.orchestrator.integration,
1001
+ },
1002
+ dependencies: {
1003
+ source: o.dependencies.source,
1004
+ cache: o.dependencies.cache,
1005
+ },
1006
+ assignment: {
1007
+ strategy: o.assignment.strategy,
1008
+ // Preserve dictionary keys verbatim (S, M, L, XL, etc.)
1009
+ size_weights: { ...o.assignment.sizeWeights },
1010
+ },
1011
+ pre_warm: {
1012
+ auto_detect: o.preWarm.autoDetect,
1013
+ // Preserve user-defined command keys verbatim
1014
+ commands: { ...o.preWarm.commands },
1015
+ always: [...o.preWarm.always],
1016
+ },
1017
+ merge: {
1018
+ model: o.merge.model,
1019
+ tools: o.merge.tools,
1020
+ verify: [...o.merge.verify],
1021
+ order: o.merge.order,
1022
+ timeout_minutes: o.merge.timeoutMinutes ?? 90,
1023
+ },
1024
+ failure: {
1025
+ on_task_failure: o.failure.onTaskFailure,
1026
+ on_merge_failure: o.failure.onMergeFailure,
1027
+ stall_timeout: o.failure.stallTimeout,
1028
+ max_worker_minutes: o.failure.maxWorkerMinutes,
1029
+ abort_grace_period: o.failure.abortGracePeriod,
1030
+ },
1031
+ monitoring: {
1032
+ poll_interval: o.monitoring.pollInterval,
1033
+ },
1034
+ verification: {
1035
+ enabled: o.verification.enabled,
1036
+ mode: o.verification.mode,
1037
+ flaky_reruns: o.verification.flakyReruns,
1038
+ },
1039
+ };
1040
+ }
1041
+
1042
+ /**
1043
+ * Adapter: produce the legacy `TaskRunnerConfig` (snake_case subset) from unified config.
1044
+ *
1045
+ * The orchestrator's `TaskRunnerConfig` is a subset: { task_areas, reference_docs }.
1046
+ * This adapter maps the unified shape back to that contract.
1047
+ *
1048
+ * Special handling for `repoId`: whitespace-only values are treated as undefined,
1049
+ * and non-empty values are trimmed — matching the original YAML loader behavior.
1050
+ */
1051
+ export function toTaskRunnerConfig(config: TaskplaneConfig): import("./types.ts").TaskRunnerConfig {
1052
+ // task_areas needs snake_case keys inside each area too (repoId → repo_id)
1053
+ const taskAreas: Record<string, import("./types.ts").TaskArea> = {};
1054
+ for (const [name, area] of Object.entries(config.taskRunner.taskAreas)) {
1055
+ const ta: import("./types.ts").TaskArea = {
1056
+ path: area.path,
1057
+ prefix: area.prefix,
1058
+ context: area.context,
1059
+ };
1060
+ // repoId: only set if non-empty after trim (matches original YAML loader)
1061
+ if (area.repoId && typeof area.repoId === "string" && area.repoId.trim()) {
1062
+ ta.repoId = area.repoId.trim();
1063
+ }
1064
+ taskAreas[name] = ta;
1065
+ }
1066
+
1067
+ // Include testing_commands for baseline fingerprinting (TP-032).
1068
+ // Only set the field when there are actual commands configured.
1069
+ const testingCommands = config.taskRunner.testing?.commands;
1070
+ const hasTestingCommands = testingCommands && Object.keys(testingCommands).length > 0;
1071
+
1072
+ return {
1073
+ task_areas: taskAreas,
1074
+ reference_docs: { ...config.taskRunner.referenceDocs },
1075
+ ...(hasTestingCommands ? { testing_commands: { ...testingCommands } } : {}),
1076
+ model_fallback: config.taskRunner.modelFallback ?? "inherit",
1077
+ };
1078
+ }
1079
+
1080
+ /**
1081
+ * Adapter: produce the legacy task-runner `TaskConfig` (snake_case) from unified config.
1082
+ *
1083
+ * The task-runner extension has its own `TaskConfig` interface with snake_case keys.
1084
+ * This adapter maps the unified shape back to that contract.
1085
+ */
1086
+ export function toTaskConfig(config: TaskplaneConfig): {
1087
+ project: { name: string; description: string };
1088
+ paths: { tasks: string; architecture?: string };
1089
+ testing: { commands: Record<string, string> };
1090
+ standards: { docs: string[]; rules: string[] };
1091
+ standards_overrides: Record<string, { docs?: string[]; rules?: string[] }>;
1092
+ task_areas: Record<string, { path: string; [key: string]: any }>;
1093
+ worker: { model: string; tools: string; thinking: string; spawn_mode?: "subprocess" };
1094
+ reviewer: { model: string; tools: string; thinking: string };
1095
+ context: {
1096
+ worker_context_window: number;
1097
+ warn_percent: number;
1098
+ kill_percent: number;
1099
+ max_worker_iterations: number;
1100
+ max_review_cycles: number;
1101
+ no_progress_limit: number;
1102
+ max_worker_minutes?: number;
1103
+ };
1104
+ quality_gate: {
1105
+ enabled: boolean;
1106
+ review_model: string;
1107
+ max_review_cycles: number;
1108
+ max_fix_cycles: number;
1109
+ pass_threshold: "no_critical" | "no_important" | "all_clear";
1110
+ };
1111
+ } {
1112
+ const tr = config.taskRunner;
1113
+
1114
+ // Build standards_overrides with snake_case outer structure
1115
+ const stdOverrides: Record<string, { docs?: string[]; rules?: string[] }> = {};
1116
+ for (const [key, val] of Object.entries(tr.standardsOverrides)) {
1117
+ stdOverrides[key] = { docs: val.docs, rules: val.rules };
1118
+ }
1119
+
1120
+ // Build task_areas
1121
+ const taskAreas: Record<string, { path: string; [key: string]: any }> = {};
1122
+ for (const [key, val] of Object.entries(tr.taskAreas)) {
1123
+ taskAreas[key] = { path: val.path, prefix: val.prefix, context: val.context };
1124
+ if (val.repoId) (taskAreas[key] as any).repo_id = val.repoId;
1125
+ }
1126
+
1127
+ return {
1128
+ project: { ...tr.project },
1129
+ paths: { ...tr.paths },
1130
+ testing: { commands: { ...tr.testing.commands } },
1131
+ standards: { docs: [...tr.standards.docs], rules: [...tr.standards.rules] },
1132
+ standards_overrides: stdOverrides,
1133
+ task_areas: taskAreas,
1134
+ worker: {
1135
+ model: tr.worker.model,
1136
+ tools: tr.worker.tools,
1137
+ thinking: tr.worker.thinking,
1138
+ spawn_mode: tr.worker.spawnMode,
1139
+ },
1140
+ reviewer: { model: tr.reviewer.model, tools: tr.reviewer.tools, thinking: tr.reviewer.thinking },
1141
+ context: {
1142
+ worker_context_window: tr.context.workerContextWindow,
1143
+ warn_percent: tr.context.warnPercent,
1144
+ kill_percent: tr.context.killPercent,
1145
+ max_worker_iterations: tr.context.maxWorkerIterations,
1146
+ max_review_cycles: tr.context.maxReviewCycles,
1147
+ no_progress_limit: tr.context.noProgressLimit,
1148
+ max_worker_minutes: tr.context.maxWorkerMinutes,
1149
+ },
1150
+ quality_gate: {
1151
+ enabled: tr.qualityGate.enabled,
1152
+ review_model: tr.qualityGate.reviewModel,
1153
+ max_review_cycles: tr.qualityGate.maxReviewCycles,
1154
+ max_fix_cycles: tr.qualityGate.maxFixCycles,
1155
+ pass_threshold: tr.qualityGate.passThreshold,
1156
+ },
1157
+ };
1158
+ }