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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
);
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
return
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
if (
|
|
217
|
-
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
if (
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if (
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
if (
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
if (
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
*
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
const
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
const
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
return
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
*
|
|
616
|
-
*
|
|
617
|
-
*
|
|
618
|
-
*
|
|
619
|
-
* -
|
|
620
|
-
*
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
if (
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
*
|
|
692
|
-
*
|
|
693
|
-
*
|
|
694
|
-
*
|
|
695
|
-
*
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
*
|
|
772
|
-
*
|
|
773
|
-
*
|
|
774
|
-
*
|
|
775
|
-
*
|
|
776
|
-
*
|
|
777
|
-
*
|
|
778
|
-
*
|
|
779
|
-
*
|
|
780
|
-
*
|
|
781
|
-
*
|
|
782
|
-
*
|
|
783
|
-
*
|
|
784
|
-
*
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
if (
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
*
|
|
818
|
-
*
|
|
819
|
-
*
|
|
820
|
-
*
|
|
821
|
-
*
|
|
822
|
-
*
|
|
823
|
-
*
|
|
824
|
-
*
|
|
825
|
-
*
|
|
826
|
-
*
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
return
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
*
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
//
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
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 JSON — return 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 yet — stored 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
|
+
}
|