@pi-agents/orchid 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/LICENSE +21 -0
  3. package/README.md +246 -0
  4. package/agents/AGENTS-MANIFEST.md +42 -0
  5. package/agents/brain.md +42 -0
  6. package/agents/context-builder.md +46 -0
  7. package/agents/delegate.md +12 -0
  8. package/agents/dev-1.md +42 -0
  9. package/agents/oracle.md +73 -0
  10. package/agents/planner.md +55 -0
  11. package/agents/researcher.md +52 -0
  12. package/agents/reviewer.md +79 -0
  13. package/agents/scout.md +50 -0
  14. package/agents/tester.md +45 -0
  15. package/agents/worker.md +55 -0
  16. package/extensions/ralph.ts +1 -0
  17. package/extensions/reviewer-extension.ts +125 -0
  18. package/extensions/task-orchestrator.ts +28 -0
  19. package/package.json +63 -0
  20. package/prompts/gather-context-and-clarify.md +13 -0
  21. package/prompts/parallel-cleanup.md +59 -0
  22. package/prompts/parallel-context-build.md +53 -0
  23. package/prompts/parallel-handoff-plan.md +59 -0
  24. package/prompts/parallel-research.md +50 -0
  25. package/prompts/parallel-review.md +54 -0
  26. package/prompts/review-loop.md +41 -0
  27. package/skills/orchid/SKILL.md +214 -0
  28. package/skills/orchid/orchid-cleanup/SKILL.md +122 -0
  29. package/skills/orchid/orchid-converge/SKILL.md +124 -0
  30. package/skills/orchid/orchid-decompose/SKILL.md +201 -0
  31. package/skills/orchid/orchid-doctor/SKILL.md +162 -0
  32. package/skills/orchid/orchid-investigate/SKILL.md +102 -0
  33. package/skills/orchid/orchid-launch/SKILL.md +147 -0
  34. package/skills/ralph/SKILL.md +73 -0
  35. package/skills/subagents/pi-subagents/SKILL.md +813 -0
  36. package/src/index.ts +7 -0
  37. package/src/orchestrator/abort.ts +534 -0
  38. package/src/orchestrator/agent-bridge-extension.ts +1020 -0
  39. package/src/orchestrator/agent-host.ts +954 -0
  40. package/src/orchestrator/cleanup.ts +776 -0
  41. package/src/orchestrator/config-loader.ts +1412 -0
  42. package/src/orchestrator/config-schema.ts +690 -0
  43. package/src/orchestrator/config.ts +81 -0
  44. package/src/orchestrator/context-window.ts +66 -0
  45. package/src/orchestrator/diagnostic-reports.ts +475 -0
  46. package/src/orchestrator/diagnostics.ts +394 -0
  47. package/src/orchestrator/discovery.ts +1833 -0
  48. package/src/orchestrator/engine-worker.ts +415 -0
  49. package/src/orchestrator/engine.ts +5940 -0
  50. package/src/orchestrator/execution.ts +3104 -0
  51. package/src/orchestrator/extension.ts +5934 -0
  52. package/src/orchestrator/formatting.ts +785 -0
  53. package/src/orchestrator/git.ts +88 -0
  54. package/src/orchestrator/index.ts +28 -0
  55. package/src/orchestrator/lane-runner.ts +1787 -0
  56. package/src/orchestrator/mailbox.ts +780 -0
  57. package/src/orchestrator/merge.ts +3414 -0
  58. package/src/orchestrator/messages.ts +1062 -0
  59. package/src/orchestrator/migrations.ts +278 -0
  60. package/src/orchestrator/naming.ts +117 -0
  61. package/src/orchestrator/path-resolver.ts +275 -0
  62. package/src/orchestrator/persistence.ts +2625 -0
  63. package/src/orchestrator/process-registry.ts +452 -0
  64. package/src/orchestrator/quality-gate.ts +1085 -0
  65. package/src/orchestrator/resume.ts +3488 -0
  66. package/src/orchestrator/sessions.ts +57 -0
  67. package/src/orchestrator/settings-loader.ts +136 -0
  68. package/src/orchestrator/settings-tui.ts +2208 -0
  69. package/src/orchestrator/sidecar-telemetry.ts +267 -0
  70. package/src/orchestrator/supervisor.ts +4548 -0
  71. package/src/orchestrator/task-executor-core.ts +675 -0
  72. package/src/orchestrator/tmux-compat.ts +37 -0
  73. package/src/orchestrator/tool-allowlist-constants.ts +37 -0
  74. package/src/orchestrator/types.ts +4465 -0
  75. package/src/orchestrator/verification.ts +547 -0
  76. package/src/orchestrator/waves.ts +1564 -0
  77. package/src/orchestrator/workspace.ts +707 -0
  78. package/src/orchestrator/worktree.ts +2725 -0
  79. package/src/ralph/index.ts +825 -0
  80. package/src/subagents/agents/agent-management.ts +648 -0
  81. package/src/subagents/agents/agent-scope.ts +6 -0
  82. package/src/subagents/agents/agent-selection.ts +23 -0
  83. package/src/subagents/agents/agent-serializer.ts +86 -0
  84. package/src/subagents/agents/agents.ts +832 -0
  85. package/src/subagents/agents/chain-serializer.ts +137 -0
  86. package/src/subagents/agents/frontmatter.ts +29 -0
  87. package/src/subagents/agents/identity.ts +30 -0
  88. package/src/subagents/agents/skills.ts +632 -0
  89. package/src/subagents/extension/config.ts +16 -0
  90. package/src/subagents/extension/control-notices.ts +92 -0
  91. package/src/subagents/extension/doctor.ts +199 -0
  92. package/src/subagents/extension/fanout-child.ts +170 -0
  93. package/src/subagents/extension/index.ts +573 -0
  94. package/src/subagents/extension/schemas.ts +168 -0
  95. package/src/subagents/intercom/intercom-bridge.ts +379 -0
  96. package/src/subagents/intercom/result-intercom.ts +377 -0
  97. package/src/subagents/runs/background/async-execution.ts +712 -0
  98. package/src/subagents/runs/background/async-job-tracker.ts +310 -0
  99. package/src/subagents/runs/background/async-resume.ts +345 -0
  100. package/src/subagents/runs/background/async-status.ts +325 -0
  101. package/src/subagents/runs/background/completion-dedupe.ts +63 -0
  102. package/src/subagents/runs/background/notify.ts +108 -0
  103. package/src/subagents/runs/background/parallel-groups.ts +45 -0
  104. package/src/subagents/runs/background/result-watcher.ts +307 -0
  105. package/src/subagents/runs/background/run-id-resolver.ts +83 -0
  106. package/src/subagents/runs/background/run-status.ts +269 -0
  107. package/src/subagents/runs/background/stale-run-reconciler.ts +336 -0
  108. package/src/subagents/runs/background/subagent-runner.ts +1808 -0
  109. package/src/subagents/runs/background/top-level-async.ts +13 -0
  110. package/src/subagents/runs/foreground/chain-clarify.ts +1333 -0
  111. package/src/subagents/runs/foreground/chain-execution.ts +938 -0
  112. package/src/subagents/runs/foreground/execution.ts +918 -0
  113. package/src/subagents/runs/foreground/subagent-executor.ts +2527 -0
  114. package/src/subagents/runs/shared/completion-guard.ts +147 -0
  115. package/src/subagents/runs/shared/long-running-guard.ts +175 -0
  116. package/src/subagents/runs/shared/mcp-direct-tool-allowlist.ts +365 -0
  117. package/src/subagents/runs/shared/model-fallback.ts +103 -0
  118. package/src/subagents/runs/shared/nested-events.ts +819 -0
  119. package/src/subagents/runs/shared/nested-path.ts +52 -0
  120. package/src/subagents/runs/shared/nested-render.ts +115 -0
  121. package/src/subagents/runs/shared/parallel-utils.ts +109 -0
  122. package/src/subagents/runs/shared/pi-args.ts +220 -0
  123. package/src/subagents/runs/shared/pi-spawn.ts +115 -0
  124. package/src/subagents/runs/shared/run-history.ts +60 -0
  125. package/src/subagents/runs/shared/single-output.ts +164 -0
  126. package/src/subagents/runs/shared/subagent-control.ts +226 -0
  127. package/src/subagents/runs/shared/subagent-prompt-runtime.ts +170 -0
  128. package/src/subagents/runs/shared/worktree.ts +577 -0
  129. package/src/subagents/shared/artifacts.ts +98 -0
  130. package/src/subagents/shared/atomic-json.ts +16 -0
  131. package/src/subagents/shared/file-coalescer.ts +40 -0
  132. package/src/subagents/shared/fork-context.ts +76 -0
  133. package/src/subagents/shared/formatters.ts +133 -0
  134. package/src/subagents/shared/jsonl-writer.ts +81 -0
  135. package/src/subagents/shared/model-info.ts +78 -0
  136. package/src/subagents/shared/post-exit-stdio-guard.ts +85 -0
  137. package/src/subagents/shared/session-identity.ts +10 -0
  138. package/src/subagents/shared/session-tokens.ts +44 -0
  139. package/src/subagents/shared/settings.ts +397 -0
  140. package/src/subagents/shared/status-format.ts +49 -0
  141. package/src/subagents/shared/types.ts +822 -0
  142. package/src/subagents/shared/utils.ts +450 -0
  143. package/src/subagents/slash/prompt-template-bridge.ts +397 -0
  144. package/src/subagents/slash/slash-bridge.ts +174 -0
  145. package/src/subagents/slash/slash-commands.ts +528 -0
  146. package/src/subagents/slash/slash-live-state.ts +292 -0
  147. package/src/subagents/tui/render-helpers.ts +80 -0
  148. package/src/subagents/tui/render.ts +1358 -0
  149. package/templates/agents/local/supervisor.md +33 -0
  150. package/templates/agents/local/task-merger.md +27 -0
  151. package/templates/agents/local/task-reviewer.md +30 -0
  152. package/templates/agents/local/task-worker.md +34 -0
  153. package/templates/agents/supervisor-routing.md +92 -0
  154. package/templates/agents/supervisor.md +229 -0
  155. package/templates/agents/task-merger.md +214 -0
  156. package/templates/agents/task-reviewer.md +260 -0
  157. package/templates/agents/task-worker-segment.md +44 -0
  158. package/templates/agents/task-worker.md +557 -0
  159. package/templates/tasks/CONTEXT.md +30 -0
  160. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -0
  161. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -0
  162. package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -0
  163. package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -0
@@ -0,0 +1,1412 @@
1
+ /**
2
+ * Unified config loader for orchid-config.json with YAML fallback.
3
+ *
4
+ * Effective precedence:
5
+ * 1. Schema defaults (internal)
6
+ * 2. Global preferences (`~/.pi/agent.orchid/preferences.json`)
7
+ * 3. Project overrides (`orchid-config.json` or YAML fallback)
8
+ *
9
+ * Project config is treated as sparse overrides. Missing project fields
10
+ * fall through to global preferences, then schema defaults.
11
+ *
12
+ * Global preferences parsing is allowlist-based. Unknown top-level keys are
13
+ * ignored, and malformed preferences fall back to defaults silently.
14
+ *
15
+ * Path resolution:
16
+ * Resolves config paths relative to `configRoot`. Callers should pass
17
+ * the project root (or TASKPLANE_WORKSPACE_ROOT fallback) as `configRoot`.
18
+ *
19
+ * All returned objects are deep-cloned from defaults — no cross-call mutation.
20
+ *
21
+ * @module config/loader
22
+ */
23
+
24
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "fs";
25
+ import { join } from "path";
26
+ import { homedir } from "os";
27
+ import { parse as yamlParse } from "yaml";
28
+ import { resolvePointer, loadWorkspaceConfig } from "./workspace.ts";
29
+ import type { PointerResolution } from "./types.ts";
30
+
31
+ import {
32
+ CONFIG_VERSION,
33
+ PROJECT_CONFIG_FILENAME,
34
+ DEFAULT_PROJECT_CONFIG,
35
+ DEFAULT_GLOBAL_PREFERENCES,
36
+ DEFAULT_BOOTSTRAP_GLOBAL_PREFERENCES,
37
+ GLOBAL_PREFERENCES_FILENAME,
38
+ GLOBAL_PREFERENCES_SUBDIR,
39
+ } from "./config-schema.ts";
40
+ import type {
41
+ TaskplaneConfig,
42
+ TaskRunnerSection,
43
+ OrchestratorSection,
44
+ WorkspaceSectionConfig,
45
+ GlobalPreferences,
46
+ DeepPartial,
47
+ } from "./config-schema.ts";
48
+
49
+ // ── Error Types ──────────────────────────────────────────────────────
50
+
51
+ /**
52
+ * Error codes for config loading failures.
53
+ *
54
+ * - CONFIG_JSON_MALFORMED: File exists but is not valid JSON
55
+ * - CONFIG_VERSION_UNSUPPORTED: configVersion is not supported by this version
56
+ * - CONFIG_VERSION_MISSING: configVersion field is missing from JSON
57
+ * - CONFIG_LEGACY_FIELD: removed TMUX-era field/value detected; migration required
58
+ */
59
+ export type ConfigLoadErrorCode =
60
+ | "CONFIG_JSON_MALFORMED"
61
+ | "CONFIG_VERSION_UNSUPPORTED"
62
+ | "CONFIG_VERSION_MISSING"
63
+ | "CONFIG_LEGACY_FIELD";
64
+
65
+ export class ConfigLoadError extends Error {
66
+ code: ConfigLoadErrorCode;
67
+
68
+ constructor(code: ConfigLoadErrorCode, message: string) {
69
+ super(message);
70
+ this.name = "ConfigLoadError";
71
+ this.code = code;
72
+ }
73
+ }
74
+
75
+ // ── Deep Clone Helper ────────────────────────────────────────────────
76
+
77
+ /** Deep clone a config object to avoid cross-call mutation. */
78
+ function deepClone<T>(obj: T): T {
79
+ return JSON.parse(JSON.stringify(obj));
80
+ }
81
+
82
+ // ── Deep Merge Helper ────────────────────────────────────────────────
83
+
84
+ /**
85
+ * Deep merge `source` into `target`. Arrays are replaced, not merged.
86
+ * Only merges plain objects (not arrays, dates, etc).
87
+ * Returns `target` for chaining.
88
+ */
89
+ function deepMerge<T extends Record<string, any>>(target: T, source: Record<string, any>): T {
90
+ for (const key of Object.keys(source)) {
91
+ const srcVal = source[key];
92
+ const tgtVal = (target as any)[key];
93
+ if (
94
+ srcVal !== null &&
95
+ srcVal !== undefined &&
96
+ typeof srcVal === "object" &&
97
+ !Array.isArray(srcVal) &&
98
+ tgtVal !== null &&
99
+ tgtVal !== undefined &&
100
+ typeof tgtVal === "object" &&
101
+ !Array.isArray(tgtVal)
102
+ ) {
103
+ deepMerge(tgtVal, srcVal);
104
+ } else if (srcVal !== undefined) {
105
+ (target as any)[key] = srcVal;
106
+ }
107
+ }
108
+ return target;
109
+ }
110
+
111
+ function hasOwn(obj: unknown, key: string): boolean {
112
+ return !!obj && typeof obj === "object" && Object.prototype.hasOwnProperty.call(obj, key);
113
+ }
114
+
115
+ function normalizeInheritAlias(value: string): string {
116
+ return value.trim().toLowerCase() === "inherit" ? "" : value;
117
+ }
118
+
119
+ /**
120
+ * Normalize explicit "inherit" aliases to empty-string inheritance semantics.
121
+ *
122
+ * Empty string is the canonical value meaning "inherit from active session"
123
+ * for per-agent model/thinking overrides.
124
+ */
125
+ function normalizeInheritanceAliases(config: TaskplaneConfig): void {
126
+ const normalizeField = (obj: Record<string, any>, key: string) => {
127
+ if (typeof obj[key] === "string") {
128
+ obj[key] = normalizeInheritAlias(obj[key]);
129
+ }
130
+ };
131
+
132
+ normalizeField(config.taskRunner.worker as Record<string, any>, "model");
133
+ normalizeField(config.taskRunner.worker as Record<string, any>, "thinking");
134
+ normalizeField(config.taskRunner.reviewer as Record<string, any>, "model");
135
+ normalizeField(config.taskRunner.reviewer as Record<string, any>, "thinking");
136
+ normalizeField(config.orchestrator.merge as Record<string, any>, "model");
137
+ normalizeField(config.orchestrator.merge as Record<string, any>, "thinking");
138
+ normalizeField(config.orchestrator.supervisor as Record<string, any>, "model");
139
+ normalizeField(config.taskRunner.qualityGate as Record<string, any>, "reviewModel");
140
+ }
141
+
142
+ // throwLegacyFieldError removed — replaced by auto-migration functions that fix config in-place
143
+
144
+ /**
145
+ * Auto-migrate legacy TMUX fields in project config.
146
+ * Renames fields in-place and writes back to disk instead of crashing.
147
+ * @returns true if any migrations were applied
148
+ */
149
+ /** Track whether project config migration has already run for this load cycle. */
150
+ let _projectMigrationDone = false;
151
+
152
+ /**
153
+ * Auto-migrate legacy TMUX fields in global preferences.
154
+ *
155
+ * Same precedence: new key wins if both exist.
156
+ * Writes back atomically (tmp + rename).
157
+ *
158
+ * @returns true if any migrations were applied
159
+ */
160
+ function migrateGlobalPreferences(raw: Record<string, any>, prefsPath: string): boolean {
161
+ let migrated = false;
162
+ if (hasOwn(raw, "tmuxPrefix")) {
163
+ if (!hasOwn(raw, "sessionPrefix") || raw.sessionPrefix === undefined) {
164
+ raw.sessionPrefix = raw.tmuxPrefix;
165
+ }
166
+ delete raw.tmuxPrefix;
167
+ console.error(`[orchid] Auto-migrated global preference: tmuxPrefix → sessionPrefix`);
168
+ migrated = true;
169
+ }
170
+ if (raw.spawnMode === "tmux") {
171
+ raw.spawnMode = "subprocess";
172
+ console.error(`[orchid] Auto-migrated global preference: spawnMode "tmux" → "subprocess"`);
173
+ migrated = true;
174
+ }
175
+ if (raw.orchestrator?.orchestrator?.spawnMode === "tmux") {
176
+ raw.orchestrator.orchestrator.spawnMode = "subprocess";
177
+ console.error(
178
+ `[orchid] Auto-migrated global preference: orchestrator.orchestrator.spawnMode "tmux" → "subprocess"`,
179
+ );
180
+ migrated = true;
181
+ }
182
+ if (raw.taskRunner?.worker?.spawnMode === "tmux") {
183
+ raw.taskRunner.worker.spawnMode = "subprocess";
184
+ console.error(
185
+ `[orchid] Auto-migrated global preference: taskRunner.worker.spawnMode "tmux" → "subprocess"`,
186
+ );
187
+ migrated = true;
188
+ }
189
+ if (migrated) {
190
+ try {
191
+ const tmpPath = prefsPath + ".migration-tmp";
192
+ writeFileSync(tmpPath, JSON.stringify(raw, null, 2) + "\n");
193
+ renameSync(tmpPath, prefsPath);
194
+ console.error(`[orchid] Preferences file updated: ${prefsPath}`);
195
+ } catch (err) {
196
+ console.error(
197
+ `[orchid] Warning: could not persist preferences migration to disk: ${err instanceof Error ? err.message : err}`,
198
+ );
199
+ }
200
+ }
201
+ return migrated;
202
+ }
203
+
204
+ /** Reset migration guard (for testing). @internal */
205
+ export function _resetMigrationGuard(): void {
206
+ _projectMigrationDone = false;
207
+ }
208
+
209
+ // ── YAML snake_case → camelCase Mapping ──────────────────────────────
210
+
211
+ /**
212
+ * Convert a snake_case key to camelCase.
213
+ * e.g., "max_worker_iterations" → "maxWorkerIterations"
214
+ */
215
+ function snakeToCamel(s: string): string {
216
+ return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
217
+ }
218
+
219
+ /**
220
+ * Convert structural keys from snake_case to camelCase, recursively.
221
+ * Used for sections where ALL keys are structural schema keys (no
222
+ * user-defined dictionary keys).
223
+ */
224
+ function convertStructuralKeys(obj: any): any {
225
+ if (obj === null || obj === undefined) return obj;
226
+ if (Array.isArray(obj)) return obj.map(convertStructuralKeys);
227
+ if (typeof obj !== "object") return obj;
228
+
229
+ const result: Record<string, any> = {};
230
+ for (const [key, val] of Object.entries(obj)) {
231
+ const camelKey = snakeToCamel(key);
232
+ if (val !== null && typeof val === "object" && !Array.isArray(val)) {
233
+ result[camelKey] = convertStructuralKeys(val);
234
+ } else if (Array.isArray(val)) {
235
+ result[camelKey] = val.map(convertStructuralKeys);
236
+ } else {
237
+ result[camelKey] = val;
238
+ }
239
+ }
240
+ return result;
241
+ }
242
+
243
+ /**
244
+ * Convert a record/dictionary section where outer keys are user-defined
245
+ * identifiers (preserve verbatim) but inner keys are structural (convert).
246
+ */
247
+ function convertRecordSection(obj: any): any {
248
+ if (obj === null || obj === undefined) return obj;
249
+ if (typeof obj !== "object" || Array.isArray(obj)) return obj;
250
+
251
+ const result: Record<string, any> = {};
252
+ for (const [key, val] of Object.entries(obj)) {
253
+ // Preserve user-defined key verbatim, convert structural inner keys
254
+ if (val !== null && typeof val === "object" && !Array.isArray(val)) {
255
+ result[key] = convertStructuralKeys(val);
256
+ } else {
257
+ result[key] = val;
258
+ }
259
+ }
260
+ return result;
261
+ }
262
+
263
+ /**
264
+ * Convert a flat record/dictionary where both keys and values are
265
+ * user-defined (preserve everything verbatim). Used for sections like
266
+ * `reference_docs`, `self_doc_targets`, `testing.commands` where
267
+ * keys are identifiers and values are strings.
268
+ */
269
+ function preserveRecord(obj: any): any {
270
+ if (obj === null || obj === undefined) return obj;
271
+ if (typeof obj !== "object" || Array.isArray(obj)) return obj;
272
+ return { ...obj };
273
+ }
274
+
275
+ // ── Section-aware YAML mapping ───────────────────────────────────────
276
+
277
+ /**
278
+ * Map a raw task-runner YAML object to the camelCase TaskRunnerSection shape.
279
+ *
280
+ * Knows which sections contain user-defined record keys vs. structural keys:
281
+ * - Structural-only: project, paths, worker, reviewer, context, standards
282
+ * - Record with structural inner keys: task_areas, standards_overrides
283
+ * - Flat record (preserve all keys): testing.commands, reference_docs,
284
+ * self_doc_targets
285
+ * - Array (preserve): never_load, protected_docs
286
+ */
287
+ function mapTaskRunnerYaml(raw: any): Partial<TaskRunnerSection> {
288
+ const result: any = {};
289
+
290
+ // Structural sections — all keys are schema-defined
291
+ if (raw.project) result.project = convertStructuralKeys(raw.project);
292
+ if (raw.paths) result.paths = convertStructuralKeys(raw.paths);
293
+ if (raw.worker) result.worker = convertStructuralKeys(raw.worker);
294
+ if (raw.reviewer) result.reviewer = convertStructuralKeys(raw.reviewer);
295
+ if (raw.context) result.context = convertStructuralKeys(raw.context);
296
+ if (raw.standards) result.standards = convertStructuralKeys(raw.standards);
297
+
298
+ // Testing: commands is a flat user-defined record
299
+ if (raw.testing) {
300
+ result.testing = {};
301
+ if (raw.testing.commands) {
302
+ result.testing.commands = preserveRecord(raw.testing.commands);
303
+ }
304
+ }
305
+
306
+ // Record sections with structural inner keys
307
+ if (raw.task_areas) result.taskAreas = convertRecordSection(raw.task_areas);
308
+ if (raw.standards_overrides)
309
+ result.standardsOverrides = convertRecordSection(raw.standards_overrides);
310
+
311
+ // Flat record sections (keys are identifiers, values are strings)
312
+ if (raw.reference_docs) result.referenceDocs = preserveRecord(raw.reference_docs);
313
+ if (raw.self_doc_targets) result.selfDocTargets = preserveRecord(raw.self_doc_targets);
314
+
315
+ // Array sections (preserve verbatim)
316
+ if (raw.never_load) result.neverLoad = [...raw.never_load];
317
+ if (raw.protected_docs) result.protectedDocs = [...raw.protected_docs];
318
+
319
+ // Quality gate (structural — all keys are schema-defined)
320
+ if (raw.quality_gate) result.qualityGate = convertStructuralKeys(raw.quality_gate);
321
+
322
+ // Model fallback (scalar — "inherit" or "fail")
323
+ if (raw.model_fallback) result.modelFallback = raw.model_fallback;
324
+
325
+ return result;
326
+ }
327
+
328
+ /**
329
+ * Map a raw orchestrator YAML object to the camelCase OrchestratorSection shape.
330
+ *
331
+ * Knows which sections contain user-defined record keys:
332
+ * - Structural: orchestrator, dependencies, merge, failure, monitoring
333
+ * - Record with structural inner keys: (none)
334
+ * - Flat record (preserve keys): pre_warm.commands, assignment.size_weights
335
+ */
336
+ function mapOrchestratorYaml(raw: any): Partial<OrchestratorSection> {
337
+ const result: any = {};
338
+
339
+ // Structural sections
340
+ if (raw.orchestrator) result.orchestrator = convertStructuralKeys(raw.orchestrator);
341
+ if (raw.dependencies) result.dependencies = convertStructuralKeys(raw.dependencies);
342
+ if (raw.merge) result.merge = convertStructuralKeys(raw.merge);
343
+ if (raw.failure) result.failure = convertStructuralKeys(raw.failure);
344
+ if (raw.monitoring) result.monitoring = convertStructuralKeys(raw.monitoring);
345
+
346
+ // assignment: strategy is structural, size_weights is a user-defined record
347
+ if (raw.assignment) {
348
+ result.assignment = {};
349
+ if (raw.assignment.strategy !== undefined) result.assignment.strategy = raw.assignment.strategy;
350
+ if (raw.assignment.size_weights)
351
+ result.assignment.sizeWeights = preserveRecord(raw.assignment.size_weights);
352
+ }
353
+
354
+ // pre_warm: auto_detect is structural, commands is user-defined, always is array
355
+ if (raw.pre_warm) {
356
+ result.preWarm = {};
357
+ if (raw.pre_warm.auto_detect !== undefined) result.preWarm.autoDetect = raw.pre_warm.auto_detect;
358
+ if (raw.pre_warm.commands) result.preWarm.commands = preserveRecord(raw.pre_warm.commands);
359
+ if (raw.pre_warm.always) result.preWarm.always = [...raw.pre_warm.always];
360
+ }
361
+
362
+ // verification: all keys are structural (TP-032)
363
+ if (raw.verification) result.verification = convertStructuralKeys(raw.verification);
364
+
365
+ // supervisor: all keys are structural (TP-041)
366
+ if (raw.supervisor) result.supervisor = convertStructuralKeys(raw.supervisor);
367
+
368
+ return result;
369
+ }
370
+
371
+ /**
372
+ * Normalize a workspace section loaded from JSON/YAML into camelCase shape.
373
+ *
374
+ * Compatibility: if `routing.taskPacketRepo` is missing, defaults to
375
+ * `routing.defaultRepo` and emits a warning message.
376
+ */
377
+ function normalizeWorkspaceSection(
378
+ rawWorkspace: any,
379
+ sourcePath: string,
380
+ ): WorkspaceSectionConfig | undefined {
381
+ if (!rawWorkspace || typeof rawWorkspace !== "object" || Array.isArray(rawWorkspace)) {
382
+ return undefined;
383
+ }
384
+
385
+ const rawRepos = rawWorkspace.repos;
386
+ if (!rawRepos || typeof rawRepos !== "object" || Array.isArray(rawRepos)) {
387
+ return undefined;
388
+ }
389
+
390
+ const rawRouting = rawWorkspace.routing;
391
+ if (!rawRouting || typeof rawRouting !== "object" || Array.isArray(rawRouting)) {
392
+ return undefined;
393
+ }
394
+
395
+ const repos: WorkspaceSectionConfig["repos"] = {};
396
+ for (const [repoId, repoVal] of Object.entries(rawRepos as Record<string, any>)) {
397
+ if (!repoVal || typeof repoVal !== "object" || Array.isArray(repoVal)) continue;
398
+ const repoObj = repoVal as Record<string, any>;
399
+ if (typeof repoObj.path !== "string" || repoObj.path.trim() === "") continue;
400
+ repos[repoId] = {
401
+ path: repoObj.path,
402
+ ...(typeof repoObj.defaultBranch === "string" && repoObj.defaultBranch.trim()
403
+ ? { defaultBranch: repoObj.defaultBranch }
404
+ : {}),
405
+ };
406
+ }
407
+
408
+ const defaultRepo =
409
+ typeof rawRouting.defaultRepo === "string" ? rawRouting.defaultRepo.trim() : "";
410
+ const tasksRoot = typeof rawRouting.tasksRoot === "string" ? rawRouting.tasksRoot.trim() : "";
411
+ let taskPacketRepo =
412
+ typeof rawRouting.taskPacketRepo === "string" ? rawRouting.taskPacketRepo.trim() : "";
413
+
414
+ if (!taskPacketRepo && defaultRepo) {
415
+ taskPacketRepo = defaultRepo;
416
+ console.error(
417
+ `[orchid] config compatibility: workspace.routing.taskPacketRepo is missing in ${sourcePath}; defaulting to workspace.routing.defaultRepo ('${defaultRepo}'). Add workspace.routing.taskPacketRepo explicitly.`,
418
+ );
419
+ }
420
+
421
+ if (!tasksRoot || !defaultRepo || !taskPacketRepo) {
422
+ return undefined;
423
+ }
424
+
425
+ const strict = rawRouting.strict === true;
426
+
427
+ return {
428
+ repos,
429
+ routing: {
430
+ tasksRoot,
431
+ defaultRepo,
432
+ taskPacketRepo,
433
+ ...(strict ? { strict: true } : {}),
434
+ },
435
+ };
436
+ }
437
+
438
+ // ── Config File Path Resolution ──────────────────────────────────────
439
+
440
+ /**
441
+ * Resolve the path to a config file under the given root.
442
+ *
443
+ * Supports two directory layouts:
444
+ * 1. Standard layout: `<root>/.pi/<filename>` — used by repo mode and
445
+ * workspace root, where config files live under the `.pi/` subdirectory.
446
+ * 2. Flat layout: `<root>/<filename>` — used by pointer-resolved config
447
+ * roots (e.g., `<configRepo>/.orchid/task-runner.yaml`), where
448
+ * `orchid init` scaffolds files directly in the config path.
449
+ *
450
+ * Standard layout is checked first for backward compatibility. If neither
451
+ * exists, returns the standard-layout path (callers check existence).
452
+ */
453
+ function resolveConfigFilePath(configRoot: string, filename: string): string {
454
+ const standardPath = join(configRoot, ".pi", filename);
455
+ if (existsSync(standardPath)) return standardPath;
456
+
457
+ const flatPath = join(configRoot, filename);
458
+ if (existsSync(flatPath)) return flatPath;
459
+
460
+ // Default to standard path — callers handle non-existence
461
+ return standardPath;
462
+ }
463
+
464
+ // ── JSON Loading ─────────────────────────────────────────────────────
465
+
466
+ /**
467
+ * Attempt to load and validate `orchid-config.json`.
468
+ *
469
+ * Checks both standard layout (`<root>/.pi/orchid-config.json`) and
470
+ * flat layout (`<root>/orchid-config.json`) — see `resolveConfigFilePath`.
471
+ *
472
+ * Returns the parsed config or null if the file doesn't exist.
473
+ * Throws ConfigLoadError for malformed JSON or unsupported versions.
474
+ */
475
+ function loadJsonConfig(configRoot: string): DeepPartial<TaskplaneConfig> | null {
476
+ const jsonPath = resolveConfigFilePath(configRoot, PROJECT_CONFIG_FILENAME);
477
+ if (!existsSync(jsonPath)) return null;
478
+
479
+ let raw: string;
480
+ try {
481
+ raw = readFileSync(jsonPath, "utf-8");
482
+ } catch {
483
+ return null; // Can't read file — treat as absent
484
+ }
485
+
486
+ let parsed: any;
487
+ try {
488
+ parsed = JSON.parse(raw);
489
+ } catch (e: any) {
490
+ throw new ConfigLoadError(
491
+ "CONFIG_JSON_MALFORMED",
492
+ `Failed to parse ${jsonPath}: ${e.message ?? "invalid JSON"}`,
493
+ );
494
+ }
495
+
496
+ // Validate configVersion
497
+ if (parsed.configVersion === undefined || parsed.configVersion === null) {
498
+ throw new ConfigLoadError(
499
+ "CONFIG_VERSION_MISSING",
500
+ `${jsonPath} is missing required field "configVersion". ` +
501
+ `Expected configVersion: ${CONFIG_VERSION}.`,
502
+ );
503
+ }
504
+
505
+ if (parsed.configVersion !== CONFIG_VERSION) {
506
+ throw new ConfigLoadError(
507
+ "CONFIG_VERSION_UNSUPPORTED",
508
+ `${jsonPath} has configVersion ${parsed.configVersion}, but this version of OrchID ` +
509
+ `only supports configVersion ${CONFIG_VERSION}. Please upgrade OrchID.`,
510
+ );
511
+ }
512
+
513
+ const overrides: DeepPartial<TaskplaneConfig> = {};
514
+ if (
515
+ parsed.taskRunner &&
516
+ typeof parsed.taskRunner === "object" &&
517
+ !Array.isArray(parsed.taskRunner)
518
+ ) {
519
+ overrides.taskRunner = deepClone(parsed.taskRunner);
520
+ }
521
+ if (
522
+ parsed.orchestrator &&
523
+ typeof parsed.orchestrator === "object" &&
524
+ !Array.isArray(parsed.orchestrator)
525
+ ) {
526
+ overrides.orchestrator = deepClone(parsed.orchestrator);
527
+ }
528
+ if (parsed.workspace) {
529
+ const normalizedWorkspace = normalizeWorkspaceSection(parsed.workspace, jsonPath);
530
+ if (normalizedWorkspace) {
531
+ overrides.workspace = normalizedWorkspace;
532
+ }
533
+ }
534
+
535
+ return overrides;
536
+ }
537
+
538
+ // ── YAML Loading ─────────────────────────────────────────────────────
539
+
540
+ /**
541
+ * Load task-runner settings from `task-runner.yaml`.
542
+ *
543
+ * Checks both standard layout (`<root>/.pi/task-runner.yaml`) and
544
+ * flat layout (`<root>/task-runner.yaml`) — see `resolveConfigFilePath`.
545
+ * Maps snake_case YAML keys to the camelCase TaskRunnerSection shape.
546
+ * Uses section-aware mapping that preserves user-defined record keys.
547
+ * Returns sparse overrides (empty object when missing/malformed).
548
+ */
549
+ function loadTaskRunnerYaml(configRoot: string): Partial<TaskRunnerSection> {
550
+ const yamlPath = resolveConfigFilePath(configRoot, "task-runner.yaml");
551
+ if (!existsSync(yamlPath)) return {};
552
+
553
+ try {
554
+ const raw = readFileSync(yamlPath, "utf-8");
555
+ const loaded = yamlParse(raw) as any;
556
+ if (!loaded || typeof loaded !== "object") return {};
557
+
558
+ // Section-aware mapping: structural keys → camelCase, record keys → preserved
559
+ const mapped = mapTaskRunnerYaml(loaded);
560
+
561
+ // Post-process taskAreas: trim repoId, drop whitespace-only values
562
+ // (matches legacy loadTaskRunnerConfig behavior from config.ts)
563
+ if (mapped.taskAreas) {
564
+ for (const area of Object.values(mapped.taskAreas)) {
565
+ if (area.repoId !== undefined) {
566
+ const trimmed = typeof area.repoId === "string" ? area.repoId.trim() : "";
567
+ if (trimmed) {
568
+ area.repoId = trimmed;
569
+ } else {
570
+ delete area.repoId;
571
+ }
572
+ }
573
+ }
574
+ }
575
+
576
+ return mapped;
577
+ } catch {
578
+ return {};
579
+ }
580
+ }
581
+
582
+ /**
583
+ * Load orchestrator settings from `task-orchestrator.yaml`.
584
+ *
585
+ * Checks both standard layout (`<root>/.pi/task-orchestrator.yaml`) and
586
+ * flat layout (`<root>/task-orchestrator.yaml`) — see `resolveConfigFilePath`.
587
+ * Maps snake_case YAML keys to the camelCase OrchestratorSection shape.
588
+ * Uses section-aware mapping that preserves user-defined record keys.
589
+ * Returns sparse overrides (empty object when missing/malformed).
590
+ */
591
+ function loadOrchestratorYaml(configRoot: string): Partial<OrchestratorSection> {
592
+ const yamlPath = resolveConfigFilePath(configRoot, "task-orchestrator.yaml");
593
+ if (!existsSync(yamlPath)) return {};
594
+
595
+ try {
596
+ const raw = readFileSync(yamlPath, "utf-8");
597
+ const loaded = yamlParse(raw) as any;
598
+ if (!loaded || typeof loaded !== "object") return {};
599
+
600
+ // Section-aware mapping: structural keys → camelCase, record keys → preserved
601
+ return mapOrchestratorYaml(loaded);
602
+ } catch {
603
+ return {};
604
+ }
605
+ }
606
+
607
+ /**
608
+ * Load optional workspace routing config from legacy `orchid-workspace.yaml`.
609
+ *
610
+ * This file is fallback-only for workspace metadata when JSON `workspace`
611
+ * section is not present. Malformed files are ignored here — strict validation
612
+ * still happens in workspace runtime loading (`workspace.ts`).
613
+ */
614
+ function loadWorkspaceYaml(configRoot: string): WorkspaceSectionConfig | undefined {
615
+ const yamlPath = resolveConfigFilePath(configRoot, "orchid-workspace.yaml");
616
+ if (!existsSync(yamlPath)) return undefined;
617
+
618
+ try {
619
+ const raw = readFileSync(yamlPath, "utf-8");
620
+ const loaded = yamlParse(raw) as any;
621
+ if (!loaded || typeof loaded !== "object") return undefined;
622
+
623
+ const converted = convertStructuralKeys(loaded);
624
+ return normalizeWorkspaceSection(converted, yamlPath);
625
+ } catch {
626
+ return undefined;
627
+ }
628
+ }
629
+
630
+ // ── Global Preferences (Layer 2) ─────────────────────────────────────
631
+
632
+ /**
633
+ * Resolve the absolute path to the global preferences file.
634
+ *
635
+ * Resolution order:
636
+ * 1. `PI_CODING_AGENT_DIR` env → `<value>.orchid/preferences.json`
637
+ * 2. `os.homedir()/.pi/agent.orchid/preferences.json`
638
+ *
639
+ * Uses `os.homedir()` for cross-platform home resolution
640
+ * (USERPROFILE on Windows, HOME on Unix) and `path.join()` for separators.
641
+ */
642
+ export function resolveGlobalPreferencesPath(): string {
643
+ const agentDir = process.env.PI_CODING_AGENT_DIR;
644
+ if (agentDir) {
645
+ return join(agentDir, GLOBAL_PREFERENCES_SUBDIR, GLOBAL_PREFERENCES_FILENAME);
646
+ }
647
+ return join(homedir(), ".pi", "agent", GLOBAL_PREFERENCES_SUBDIR, GLOBAL_PREFERENCES_FILENAME);
648
+ }
649
+
650
+ /** Result envelope for global preferences loading. */
651
+ export interface GlobalPreferencesLoadResult {
652
+ preferences: GlobalPreferences;
653
+ wasBootstrapped: boolean;
654
+ }
655
+
656
+ /** Persist preferences JSON atomically (temp file + rename). */
657
+ function writePreferencesAtomically(prefsPath: string, prefs: GlobalPreferences): void {
658
+ const tmpPath = `${prefsPath}.tmp-${process.pid}-${Date.now()}`;
659
+ writeFileSync(tmpPath, JSON.stringify(prefs, null, 2) + "\n", "utf-8");
660
+ renameSync(tmpPath, prefsPath);
661
+ }
662
+
663
+ /**
664
+ * Write first-install bootstrap preferences to disk and return the in-memory seed.
665
+ */
666
+ function bootstrapGlobalPreferencesFile(prefsPath: string): GlobalPreferences {
667
+ const bootstrapPrefs = deepClone(DEFAULT_BOOTSTRAP_GLOBAL_PREFERENCES);
668
+ try {
669
+ const dir = join(prefsPath, "..");
670
+ mkdirSync(dir, { recursive: true });
671
+ writePreferencesAtomically(prefsPath, bootstrapPrefs);
672
+ } catch {
673
+ // Best-effort; if we can't create, still return bootstrap defaults in-memory.
674
+ }
675
+ return bootstrapPrefs;
676
+ }
677
+
678
+ /**
679
+ * Load global preferences plus bootstrap metadata.
680
+ *
681
+ * Behavior:
682
+ * - If file doesn't exist: bootstrap preferences on disk and mark bootstrapped
683
+ * - If file is empty/malformed/invalid: re-bootstrap preferences and mark bootstrapped
684
+ * - Unknown keys are silently ignored (allowlist extraction)
685
+ */
686
+ export function loadGlobalPreferencesWithMeta(): GlobalPreferencesLoadResult {
687
+ const prefsPath = resolveGlobalPreferencesPath();
688
+
689
+ if (!existsSync(prefsPath)) {
690
+ return {
691
+ preferences: bootstrapGlobalPreferencesFile(prefsPath),
692
+ wasBootstrapped: true,
693
+ };
694
+ }
695
+
696
+ let raw: string;
697
+ try {
698
+ raw = readFileSync(prefsPath, "utf-8");
699
+ } catch {
700
+ return { preferences: deepClone(DEFAULT_GLOBAL_PREFERENCES), wasBootstrapped: false };
701
+ }
702
+
703
+ if (!raw.trim()) {
704
+ return {
705
+ preferences: bootstrapGlobalPreferencesFile(prefsPath),
706
+ wasBootstrapped: true,
707
+ };
708
+ }
709
+
710
+ let parsed: any;
711
+ try {
712
+ parsed = JSON.parse(raw);
713
+ } catch {
714
+ return {
715
+ preferences: bootstrapGlobalPreferencesFile(prefsPath),
716
+ wasBootstrapped: true,
717
+ };
718
+ }
719
+
720
+ if (
721
+ !parsed ||
722
+ typeof parsed !== "object" ||
723
+ Array.isArray(parsed) ||
724
+ Object.keys(parsed).length === 0
725
+ ) {
726
+ return {
727
+ preferences: bootstrapGlobalPreferencesFile(prefsPath),
728
+ wasBootstrapped: true,
729
+ };
730
+ }
731
+
732
+ return {
733
+ preferences: extractAllowlistedPreferences(parsed, prefsPath),
734
+ wasBootstrapped: false,
735
+ };
736
+ }
737
+
738
+ /**
739
+ * Load global preferences from `~/.pi/agent.orchid/preferences.json`.
740
+ *
741
+ * @returns Parsed GlobalPreferences (only recognized fields)
742
+ */
743
+ export function loadGlobalPreferences(): GlobalPreferences {
744
+ return loadGlobalPreferencesWithMeta().preferences;
745
+ }
746
+
747
+ /**
748
+ * Extract only recognized/allowlisted fields from a raw parsed object.
749
+ * Unknown keys are silently dropped — this is the Layer 2 boundary guardrail.
750
+ */
751
+ function normalizePreferenceThinkingMode(value: unknown): string {
752
+ const cleaned = String(value ?? "")
753
+ .trim()
754
+ .toLowerCase();
755
+ if (!cleaned || cleaned === "inherit") return "";
756
+ if (cleaned === "on") return "high";
757
+ if (["off", "minimal", "low", "medium", "high", "xhigh"].includes(cleaned)) {
758
+ return cleaned;
759
+ }
760
+ return "";
761
+ }
762
+
763
+ function extractInitAgentDefaults(
764
+ rawInitDefaults: unknown,
765
+ ): GlobalPreferences["initAgentDefaults"] | undefined {
766
+ if (!rawInitDefaults || typeof rawInitDefaults !== "object" || Array.isArray(rawInitDefaults)) {
767
+ return undefined;
768
+ }
769
+
770
+ const raw = rawInitDefaults as Record<string, unknown>;
771
+ const extracted: NonNullable<GlobalPreferences["initAgentDefaults"]> = {};
772
+
773
+ if (typeof raw.workerModel === "string") extracted.workerModel = raw.workerModel;
774
+ if (typeof raw.reviewerModel === "string") extracted.reviewerModel = raw.reviewerModel;
775
+ if (typeof raw.mergeModel === "string") extracted.mergeModel = raw.mergeModel;
776
+ if (raw.workerThinking !== undefined)
777
+ extracted.workerThinking = normalizePreferenceThinkingMode(raw.workerThinking);
778
+ if (raw.reviewerThinking !== undefined)
779
+ extracted.reviewerThinking = normalizePreferenceThinkingMode(raw.reviewerThinking);
780
+ if (raw.mergeThinking !== undefined)
781
+ extracted.mergeThinking = normalizePreferenceThinkingMode(raw.mergeThinking);
782
+
783
+ return Object.keys(extracted).length > 0 ? extracted : undefined;
784
+ }
785
+
786
+ function extractConfigOverrideSection(rawSection: unknown): Record<string, any> | undefined {
787
+ if (!rawSection || typeof rawSection !== "object" || Array.isArray(rawSection)) {
788
+ return undefined;
789
+ }
790
+ return deepClone(rawSection as Record<string, any>);
791
+ }
792
+
793
+ function extractAllowlistedPreferences(
794
+ raw: Record<string, any>,
795
+ prefsPath: string,
796
+ ): GlobalPreferences {
797
+ migrateGlobalPreferences(raw, prefsPath);
798
+
799
+ const prefs: GlobalPreferences = {};
800
+
801
+ const taskRunnerOverrides = extractConfigOverrideSection(raw.taskRunner);
802
+ if (taskRunnerOverrides) {
803
+ prefs.taskRunner = taskRunnerOverrides as GlobalPreferences["taskRunner"];
804
+ }
805
+
806
+ const orchestratorOverrides = extractConfigOverrideSection(raw.orchestrator);
807
+ if (orchestratorOverrides) {
808
+ prefs.orchestrator = orchestratorOverrides as GlobalPreferences["orchestrator"];
809
+ }
810
+
811
+ const workspaceOverrides = extractConfigOverrideSection(raw.workspace);
812
+ if (workspaceOverrides) {
813
+ prefs.workspace = workspaceOverrides as GlobalPreferences["workspace"];
814
+ }
815
+
816
+ // Legacy flat aliases (backward compatibility for existing preferences.json files)
817
+ if (typeof raw.operatorId === "string") prefs.operatorId = raw.operatorId;
818
+ if (typeof raw.sessionPrefix === "string") {
819
+ prefs.sessionPrefix = raw.sessionPrefix;
820
+ }
821
+ if (raw.spawnMode === "subprocess") {
822
+ prefs.spawnMode = "subprocess";
823
+ }
824
+ if (typeof raw.workerModel === "string") prefs.workerModel = raw.workerModel;
825
+ if (typeof raw.reviewerModel === "string") prefs.reviewerModel = raw.reviewerModel;
826
+ if (typeof raw.mergeModel === "string") prefs.mergeModel = raw.mergeModel;
827
+ if (typeof raw.mergeThinking === "string") prefs.mergeThinking = raw.mergeThinking;
828
+ if (typeof raw.supervisorModel === "string") prefs.supervisorModel = raw.supervisorModel;
829
+
830
+ // Preferences-only fields (intentionally not merged into runtime config)
831
+ if (typeof raw.dashboardPort === "number" && Number.isFinite(raw.dashboardPort)) {
832
+ prefs.dashboardPort = raw.dashboardPort;
833
+ }
834
+ const initAgentDefaults = extractInitAgentDefaults(raw.initAgentDefaults);
835
+ if (initAgentDefaults) {
836
+ prefs.initAgentDefaults = initAgentDefaults;
837
+ }
838
+
839
+ return prefs;
840
+ }
841
+
842
+ /**
843
+ * Apply global preferences (Layer 2) onto a project config (Layer 1).
844
+ *
845
+ * Merge order inside Layer 2:
846
+ * 1. Legacy flat aliases (for backward compatibility)
847
+ * 2. Config-shaped nested overrides (`taskRunner` / `orchestrator` / `workspace`)
848
+ * Nested overrides intentionally win when both styles are present.
849
+ *
850
+ * Preferences-only fields (`dashboardPort`, `initAgentDefaults`) are preserved
851
+ * in `GlobalPreferences` but intentionally not merged into runtime config.
852
+ */
853
+ export function applyGlobalPreferences(
854
+ config: TaskplaneConfig,
855
+ prefs: GlobalPreferences,
856
+ ): TaskplaneConfig {
857
+ // Helper: only apply non-empty string values
858
+ const applyStr = (val: string | undefined, setter: (v: string) => void) => {
859
+ if (val !== undefined && val !== "") setter(val);
860
+ };
861
+
862
+ // 1) Legacy flat aliases
863
+ applyStr(prefs.operatorId, (v) => {
864
+ config.orchestrator.orchestrator.operatorId = v;
865
+ });
866
+ applyStr(prefs.sessionPrefix, (v) => {
867
+ config.orchestrator.orchestrator.sessionPrefix = v;
868
+ });
869
+ applyStr(prefs.workerModel, (v) => {
870
+ config.taskRunner.worker.model = v;
871
+ });
872
+ applyStr(prefs.reviewerModel, (v) => {
873
+ config.taskRunner.reviewer.model = v;
874
+ });
875
+ applyStr(prefs.mergeModel, (v) => {
876
+ config.orchestrator.merge.model = v;
877
+ });
878
+ applyStr(prefs.mergeThinking, (v) => {
879
+ config.orchestrator.merge.thinking = v;
880
+ });
881
+ applyStr(prefs.supervisorModel, (v) => {
882
+ config.orchestrator.supervisor.model = v;
883
+ });
884
+
885
+ // spawnMode: enum — apply if defined (not a string-empty check)
886
+ // TP-195: dropped dead `prefs.spawnMode === "tmux"` migration check.
887
+ // `prefs.spawnMode` is typed as `"subprocess"` only (see
888
+ // `GlobalPreferences.spawnMode` in config-schema.ts). Raw input is
889
+ // migrated upstream at line ~169 BEFORE assignment to the typed
890
+ // `prefs` object, so by this point the value is already "subprocess"
891
+ // or undefined — the comparison can never be true. Behavior-neutral.
892
+ if (prefs.spawnMode !== undefined) {
893
+ config.orchestrator.orchestrator.spawnMode = prefs.spawnMode;
894
+ }
895
+
896
+ // 2) Config-shaped nested overrides
897
+ if (prefs.taskRunner) {
898
+ deepMerge(config.taskRunner as Record<string, any>, prefs.taskRunner as Record<string, any>);
899
+ }
900
+ if (prefs.orchestrator) {
901
+ deepMerge(config.orchestrator as Record<string, any>, prefs.orchestrator as Record<string, any>);
902
+ }
903
+ if (prefs.workspace) {
904
+ if (!config.workspace || typeof config.workspace !== "object") {
905
+ config.workspace = {} as TaskplaneConfig["workspace"];
906
+ }
907
+ deepMerge(config.workspace as Record<string, any>, prefs.workspace as Record<string, any>);
908
+ }
909
+
910
+ // Runtime safety: nested legacy values may arrive through config-shaped overrides.
911
+ if ((config.orchestrator.orchestrator as Record<string, any>).spawnMode === "tmux") {
912
+ config.orchestrator.orchestrator.spawnMode = "subprocess";
913
+ console.error(
914
+ `[orchid] Auto-migrated runtime global preference: orchestrator.orchestrator.spawnMode "tmux" → "subprocess"`,
915
+ );
916
+ }
917
+ if ((config.taskRunner.worker as Record<string, any>).spawnMode === "tmux") {
918
+ config.taskRunner.worker.spawnMode = "subprocess";
919
+ console.error(
920
+ `[orchid] Auto-migrated runtime global preference: taskRunner.worker.spawnMode "tmux" → "subprocess"`,
921
+ );
922
+ }
923
+
924
+ return config;
925
+ }
926
+
927
+ // ── Unified Loader ───────────────────────────────────────────────────
928
+
929
+ /**
930
+ * Check whether any config files exist under the given root.
931
+ *
932
+ * Supports both standard layout (`<root>/.pi/<file>`) and flat layout
933
+ * (`<root>/<file>`). Returns true if any recognized config file is found
934
+ * in either location. This allows pointer-resolved roots (e.g.,
935
+ * `<configRepo>/.orchid/`) where files are scaffolded directly
936
+ * without a `.pi/` subdirectory.
937
+ *
938
+ * Includes optional workspace YAML (`orchid-workspace.yaml`) so
939
+ * workspace-only roots participate in config-root resolution.
940
+ */
941
+ export function hasConfigFiles(root: string): boolean {
942
+ // Check for actual project config files (not workspace YAML — that's a
943
+ // coordination file, not a project config). Without this distinction,
944
+ // workspace root's .pi/orchid-workspace.yaml causes resolveConfigRoot
945
+ // to short-circuit before checking the pointer-resolved config root (#424).
946
+ const files = [PROJECT_CONFIG_FILENAME, "task-runner.yaml", "task-orchestrator.yaml"];
947
+ for (const f of files) {
948
+ if (existsSync(join(root, ".pi", f)) || existsSync(join(root, f))) return true;
949
+ }
950
+ return false;
951
+ }
952
+
953
+ /**
954
+ * Resolve the config root directory.
955
+ *
956
+ * In workspace mode, workers run in repo worktrees — not the workspace root.
957
+ * TASKPLANE_WORKSPACE_ROOT tells us where config files actually live.
958
+ * The pointer file (`orchid-pointer.json`) can redirect config loading
959
+ * to a specific repo's config path.
960
+ *
961
+ * Resolution order:
962
+ * 1. If `cwd` has actual config files → use cwd (local override wins)
963
+ * 2. If `pointerConfigRoot` is set and has config files → use it (pointer redirect)
964
+ * 3. If TASKPLANE_WORKSPACE_ROOT is set and has config files → use it (legacy fallback)
965
+ * 4. Fall back to cwd (loaders will return defaults)
966
+ *
967
+ * We check for actual config files — not just the `.pi/` directory —
968
+ * because worktrees may have a sidecar `.pi` without config files.
969
+ *
970
+ * @param cwd - Current working directory (project root or worktree)
971
+ * @param pointerConfigRoot - Resolved config root from pointer file (optional, workspace mode only)
972
+ */
973
+ export function resolveConfigRoot(cwd: string, pointerConfigRoot?: string): string {
974
+ // Prefer cwd if it has actual config files (local override always wins)
975
+ if (hasConfigFiles(cwd)) return cwd;
976
+
977
+ // Pointer-resolved config root — workspace mode with valid pointer
978
+ if (pointerConfigRoot && hasConfigFiles(pointerConfigRoot)) return pointerConfigRoot;
979
+
980
+ // Workspace mode fallback — check for actual config files at workspace root
981
+ const wsRoot = process.env.TASKPLANE_WORKSPACE_ROOT;
982
+ if (wsRoot && hasConfigFiles(wsRoot)) return wsRoot;
983
+
984
+ // Fall back to cwd even without config files — loaders will return defaults
985
+ return cwd;
986
+ }
987
+
988
+ function mergeProjectOverrides(
989
+ config: TaskplaneConfig,
990
+ overrides: DeepPartial<TaskplaneConfig>,
991
+ ): void {
992
+ if (overrides.taskRunner) {
993
+ deepMerge(config.taskRunner as Record<string, any>, overrides.taskRunner as Record<string, any>);
994
+ }
995
+ if (overrides.orchestrator) {
996
+ deepMerge(
997
+ config.orchestrator as Record<string, any>,
998
+ overrides.orchestrator as Record<string, any>,
999
+ );
1000
+ }
1001
+ if (overrides.workspace) {
1002
+ if (!config.workspace || typeof config.workspace !== "object") {
1003
+ config.workspace = {} as TaskplaneConfig["workspace"];
1004
+ }
1005
+ deepMerge(config.workspace as Record<string, any>, overrides.workspace as Record<string, any>);
1006
+ }
1007
+ }
1008
+
1009
+ // TP-195: switched parameter to `DeepPartial<TaskplaneConfig>` to match the
1010
+ // nested-section partial shape produced by `loadProjectOverrides` (the YAML
1011
+ // loaders return `Partial<TaskRunnerSection>` etc., which `Partial<TaskplaneConfig>`
1012
+ // rejects — it makes top-level fields optional but inner sections stay full).
1013
+ function migrateProjectOverrides(
1014
+ overrides: DeepPartial<TaskplaneConfig>,
1015
+ configRoot: string,
1016
+ ): boolean {
1017
+ if (_projectMigrationDone) return false;
1018
+
1019
+ let migrated = false;
1020
+ // TP-195: 2-step `as unknown as` widening. The structurally-typed
1021
+ // `OrchestratorCoreConfig` is being treated as a property bag for
1022
+ // migration purposes (legacy `tmuxPrefix` -> `sessionPrefix`,
1023
+ // `spawnMode "tmux"` -> `"subprocess"`). Both source and target
1024
+ // types are object-shaped at runtime; the cast is structurally
1025
+ // legitimate, just outside the narrow set of conversions TS allows
1026
+ // in a single step.
1027
+ const orchestratorCore = overrides.orchestrator?.orchestrator as unknown as
1028
+ | Record<string, unknown>
1029
+ | undefined;
1030
+ if (orchestratorCore && hasOwn(orchestratorCore, "tmuxPrefix")) {
1031
+ const currentPrefix = orchestratorCore.sessionPrefix;
1032
+ const isDefault = currentPrefix === undefined || currentPrefix === "orch";
1033
+ if (isDefault) {
1034
+ (orchestratorCore as any).sessionPrefix = orchestratorCore.tmuxPrefix;
1035
+ }
1036
+ delete orchestratorCore.tmuxPrefix;
1037
+ console.error(`[orchid] Auto-migrated: orchestrator.orchestrator.tmuxPrefix → sessionPrefix`);
1038
+ migrated = true;
1039
+ }
1040
+ if (orchestratorCore?.spawnMode === "tmux") {
1041
+ (orchestratorCore as any).spawnMode = "subprocess";
1042
+ console.error(
1043
+ `[orchid] Auto-migrated: orchestrator.orchestrator.spawnMode "tmux" → "subprocess"`,
1044
+ );
1045
+ migrated = true;
1046
+ }
1047
+
1048
+ // TP-195: 2-step `as unknown as` widening (same rationale as the
1049
+ // orchestratorCore cast above).
1050
+ const workerConfig = overrides.taskRunner?.worker as unknown as
1051
+ | Record<string, unknown>
1052
+ | undefined;
1053
+ if (workerConfig?.spawnMode === "tmux") {
1054
+ (workerConfig as any).spawnMode = "subprocess";
1055
+ console.error(`[orchid] Auto-migrated: taskRunner.worker.spawnMode "tmux" → "subprocess"`);
1056
+ migrated = true;
1057
+ }
1058
+
1059
+ if (migrated) {
1060
+ try {
1061
+ const jsonPath = resolveConfigFilePath(configRoot, PROJECT_CONFIG_FILENAME);
1062
+ if (existsSync(jsonPath)) {
1063
+ const raw = JSON.parse(readFileSync(jsonPath, "utf-8"));
1064
+ if (raw.orchestrator?.orchestrator?.tmuxPrefix !== undefined) {
1065
+ const rawPrefix = raw.orchestrator.orchestrator.sessionPrefix;
1066
+ if (rawPrefix === undefined || rawPrefix === "orch") {
1067
+ raw.orchestrator.orchestrator.sessionPrefix = raw.orchestrator.orchestrator.tmuxPrefix;
1068
+ }
1069
+ delete raw.orchestrator.orchestrator.tmuxPrefix;
1070
+ }
1071
+ if (raw.orchestrator?.orchestrator?.spawnMode === "tmux") {
1072
+ raw.orchestrator.orchestrator.spawnMode = "subprocess";
1073
+ }
1074
+ if (raw.taskRunner?.worker?.spawnMode === "tmux") {
1075
+ raw.taskRunner.worker.spawnMode = "subprocess";
1076
+ }
1077
+
1078
+ const tmpPath = jsonPath + ".migration-tmp";
1079
+ writeFileSync(tmpPath, JSON.stringify(raw, null, 2) + "\n");
1080
+ renameSync(tmpPath, jsonPath);
1081
+ console.error(`[orchid] Config file updated: ${jsonPath}`);
1082
+ }
1083
+ } catch (err) {
1084
+ console.error(
1085
+ `[orchid] Warning: could not persist config migration to disk: ${err instanceof Error ? err.message : err}`,
1086
+ );
1087
+ }
1088
+ }
1089
+
1090
+ _projectMigrationDone = true;
1091
+ return migrated;
1092
+ }
1093
+
1094
+ // TP-195: return type widened from `Partial<TaskplaneConfig>` to
1095
+ // `DeepPartial<TaskplaneConfig>` so the nested `Partial<TaskRunnerSection>` /
1096
+ // `Partial<OrchestratorSection>` returned by the YAML loaders are
1097
+ // assignable. `Partial<TaskplaneConfig>` only relaxes top-level optionality
1098
+ // while keeping inner sections fully required.
1099
+ export function loadProjectOverrides(configRoot: string): DeepPartial<TaskplaneConfig> {
1100
+ const jsonOverrides = loadJsonConfig(configRoot);
1101
+ if (jsonOverrides !== null) {
1102
+ return jsonOverrides;
1103
+ }
1104
+
1105
+ const taskRunner = loadTaskRunnerYaml(configRoot);
1106
+ const orchestrator = loadOrchestratorYaml(configRoot);
1107
+ const workspace = loadWorkspaceYaml(configRoot);
1108
+
1109
+ const overrides: DeepPartial<TaskplaneConfig> = {};
1110
+ if (Object.keys(taskRunner).length > 0) overrides.taskRunner = taskRunner;
1111
+ if (Object.keys(orchestrator).length > 0) overrides.orchestrator = orchestrator;
1112
+ if (workspace) overrides.workspace = workspace;
1113
+ return overrides;
1114
+ }
1115
+
1116
+ /**
1117
+ * Load the unified project configuration.
1118
+ *
1119
+ * Precedence (layered):
1120
+ * 1. Schema defaults
1121
+ * 2. Global preferences (`~/.pi/agent.orchid/preferences.json`)
1122
+ * 3. Project overrides (`orchid-config.json` or YAML fallback)
1123
+ *
1124
+ * Project config is treated as sparse overrides. Missing fields in project
1125
+ * config fall through to global preferences, then schema defaults.
1126
+ */
1127
+ export function loadProjectConfig(cwd: string, pointerConfigRoot?: string): TaskplaneConfig {
1128
+ const configRoot = resolveConfigRoot(cwd, pointerConfigRoot);
1129
+ const config = deepClone(DEFAULT_PROJECT_CONFIG);
1130
+
1131
+ // Layer 2 baseline: global preferences on top of defaults
1132
+ const prefs = loadGlobalPreferences();
1133
+ applyGlobalPreferences(config, prefs);
1134
+
1135
+ // Layer 1 project overrides: sparse config merged on top
1136
+ const overrides = loadProjectOverrides(configRoot);
1137
+ _projectMigrationDone = false;
1138
+ migrateProjectOverrides(overrides, configRoot);
1139
+ mergeProjectOverrides(config, overrides);
1140
+
1141
+ normalizeInheritanceAliases(config);
1142
+ return config;
1143
+ }
1144
+
1145
+ /**
1146
+ * Load project overrides merged with schema defaults, without applying
1147
+ * global preferences. Used by settings write-back code paths that must
1148
+ * avoid embedding global baseline values into project config.
1149
+ */
1150
+ export function loadLayer1Config(cwd: string, pointerConfigRoot?: string): TaskplaneConfig {
1151
+ const configRoot = resolveConfigRoot(cwd, pointerConfigRoot);
1152
+ const config = deepClone(DEFAULT_PROJECT_CONFIG);
1153
+ const overrides = loadProjectOverrides(configRoot);
1154
+
1155
+ _projectMigrationDone = false;
1156
+ migrateProjectOverrides(overrides, configRoot);
1157
+ mergeProjectOverrides(config, overrides);
1158
+
1159
+ normalizeInheritanceAliases(config);
1160
+ return config;
1161
+ }
1162
+
1163
+ // ── Backward-Compatible Adapters ─────────────────────────────────────
1164
+
1165
+ // The following adapter functions convert the unified camelCase config
1166
+ // back to the snake_case shapes expected by existing consumers.
1167
+
1168
+ /**
1169
+ * Adapter: produce the legacy `OrchestratorConfig` (snake_case) from unified config.
1170
+ *
1171
+ * Uses explicit field mapping instead of generic recursive key conversion
1172
+ * to preserve record/dictionary keys verbatim (e.g., sizeWeights S/M/L,
1173
+ * preWarm.commands keys, etc.).
1174
+ */
1175
+ export function toOrchestratorConfig(
1176
+ config: TaskplaneConfig,
1177
+ ): import("./types.ts").OrchestratorConfig {
1178
+ const o = config.orchestrator;
1179
+ return {
1180
+ orchestrator: {
1181
+ max_lanes: o.orchestrator.maxLanes,
1182
+ worktree_location: o.orchestrator.worktreeLocation,
1183
+ worktree_prefix: o.orchestrator.worktreePrefix,
1184
+ batch_id_format: o.orchestrator.batchIdFormat,
1185
+ spawn_mode: o.orchestrator.spawnMode,
1186
+ sessionPrefix: o.orchestrator.sessionPrefix,
1187
+ operator_id: o.orchestrator.operatorId,
1188
+ integration: o.orchestrator.integration,
1189
+ },
1190
+ dependencies: {
1191
+ source: o.dependencies.source,
1192
+ cache: o.dependencies.cache,
1193
+ },
1194
+ assignment: {
1195
+ strategy: o.assignment.strategy,
1196
+ // Preserve dictionary keys verbatim (S, M, L, XL, etc.)
1197
+ size_weights: { ...o.assignment.sizeWeights },
1198
+ },
1199
+ pre_warm: {
1200
+ auto_detect: o.preWarm.autoDetect,
1201
+ // Preserve user-defined command keys verbatim
1202
+ commands: { ...o.preWarm.commands },
1203
+ always: [...o.preWarm.always],
1204
+ },
1205
+ merge: {
1206
+ model: o.merge.model,
1207
+ tools: o.merge.tools,
1208
+ thinking: o.merge.thinking,
1209
+ verify: [...o.merge.verify],
1210
+ order: o.merge.order,
1211
+ timeout_minutes: o.merge.timeoutMinutes ?? 90,
1212
+ exclude_extensions: [...(o.merge.excludeExtensions ?? [])],
1213
+ },
1214
+ failure: {
1215
+ on_task_failure: o.failure.onTaskFailure,
1216
+ on_merge_failure: o.failure.onMergeFailure,
1217
+ stall_timeout: o.failure.stallTimeout,
1218
+ max_worker_minutes: o.failure.maxWorkerMinutes,
1219
+ abort_grace_period: o.failure.abortGracePeriod,
1220
+ },
1221
+ monitoring: {
1222
+ poll_interval: o.monitoring.pollInterval,
1223
+ },
1224
+ verification: {
1225
+ enabled: o.verification.enabled,
1226
+ mode: o.verification.mode,
1227
+ flaky_reruns: o.verification.flakyReruns,
1228
+ },
1229
+ };
1230
+ }
1231
+
1232
+ /**
1233
+ * Adapter: produce the legacy `TaskRunnerConfig` (snake_case subset) from unified config.
1234
+ *
1235
+ * The orchestrator's `TaskRunnerConfig` is a subset: { task_areas, reference_docs }.
1236
+ * This adapter maps the unified shape back to that contract.
1237
+ *
1238
+ * Special handling for `repoId`: whitespace-only values are treated as undefined,
1239
+ * and non-empty values are trimmed — matching the original YAML loader behavior.
1240
+ */
1241
+ export function toTaskRunnerConfig(config: TaskplaneConfig): import("./types.ts").TaskRunnerConfig {
1242
+ // task_areas needs snake_case keys inside each area too (repoId → repo_id)
1243
+ const taskAreas: Record<string, import("./types.ts").TaskArea> = {};
1244
+ for (const [name, area] of Object.entries(config.taskRunner.taskAreas)) {
1245
+ const ta: import("./types.ts").TaskArea = {
1246
+ path: area.path,
1247
+ prefix: area.prefix,
1248
+ context: area.context,
1249
+ };
1250
+ // repoId: only set if non-empty after trim (matches original YAML loader)
1251
+ if (area.repoId && typeof area.repoId === "string" && area.repoId.trim()) {
1252
+ ta.repoId = area.repoId.trim();
1253
+ }
1254
+ taskAreas[name] = ta;
1255
+ }
1256
+
1257
+ // Include testing_commands for baseline fingerprinting (TP-032).
1258
+ // Only set the field when there are actual commands configured.
1259
+ const testingCommands = config.taskRunner.testing?.commands;
1260
+ const hasTestingCommands = testingCommands && Object.keys(testingCommands).length > 0;
1261
+
1262
+ return {
1263
+ task_areas: taskAreas,
1264
+ reference_docs: { ...config.taskRunner.referenceDocs },
1265
+ ...(hasTestingCommands ? { testing_commands: { ...testingCommands } } : {}),
1266
+ worker: {
1267
+ model: config.taskRunner.worker.model,
1268
+ thinking: config.taskRunner.worker.thinking,
1269
+ tools: config.taskRunner.worker.tools,
1270
+ excludeExtensions: [...(config.taskRunner.worker.excludeExtensions ?? [])],
1271
+ },
1272
+ model_fallback: config.taskRunner.modelFallback ?? "inherit",
1273
+ reviewer: {
1274
+ model: config.taskRunner.reviewer.model,
1275
+ thinking: config.taskRunner.reviewer.thinking,
1276
+ tools: config.taskRunner.reviewer.tools,
1277
+ excludeExtensions: [...(config.taskRunner.reviewer.excludeExtensions ?? [])],
1278
+ },
1279
+ workerExcludeExtensions: [...(config.taskRunner.worker.excludeExtensions ?? [])],
1280
+ };
1281
+ }
1282
+
1283
+ /**
1284
+ * Adapter: produce the legacy task-runner `TaskConfig` (snake_case) from unified config.
1285
+ *
1286
+ * The task-runner extension has its own `TaskConfig` interface with snake_case keys.
1287
+ * This adapter maps the unified shape back to that contract.
1288
+ */
1289
+ export function toTaskConfig(config: TaskplaneConfig): {
1290
+ project: { name: string; description: string };
1291
+ paths: { tasks: string; architecture?: string };
1292
+ testing: { commands: Record<string, string> };
1293
+ standards: { docs: string[]; rules: string[] };
1294
+ standards_overrides: Record<string, { docs?: string[]; rules?: string[] }>;
1295
+ task_areas: Record<string, { path: string; [key: string]: any }>;
1296
+ worker: { model: string; tools: string; thinking: string; spawn_mode?: "subprocess" };
1297
+ reviewer: { model: string; tools: string; thinking: string };
1298
+ context: {
1299
+ worker_context_window: number;
1300
+ warn_percent: number;
1301
+ kill_percent: number;
1302
+ max_worker_iterations: number;
1303
+ max_review_cycles: number;
1304
+ no_progress_limit: number;
1305
+ max_worker_minutes?: number;
1306
+ };
1307
+ quality_gate: {
1308
+ enabled: boolean;
1309
+ review_model: string;
1310
+ max_review_cycles: number;
1311
+ max_fix_cycles: number;
1312
+ pass_threshold: "no_critical" | "no_important" | "all_clear";
1313
+ };
1314
+ } {
1315
+ const tr = config.taskRunner;
1316
+
1317
+ // Build standards_overrides with snake_case outer structure
1318
+ const stdOverrides: Record<string, { docs?: string[]; rules?: string[] }> = {};
1319
+ for (const [key, val] of Object.entries(tr.standardsOverrides)) {
1320
+ stdOverrides[key] = { docs: val.docs, rules: val.rules };
1321
+ }
1322
+
1323
+ // Build task_areas
1324
+ const taskAreas: Record<string, { path: string; [key: string]: any }> = {};
1325
+ for (const [key, val] of Object.entries(tr.taskAreas)) {
1326
+ taskAreas[key] = { path: val.path, prefix: val.prefix, context: val.context };
1327
+ if (val.repoId) (taskAreas[key] as any).repo_id = val.repoId;
1328
+ }
1329
+
1330
+ return {
1331
+ project: { ...tr.project },
1332
+ paths: { ...tr.paths },
1333
+ testing: { commands: { ...tr.testing.commands } },
1334
+ standards: { docs: [...tr.standards.docs], rules: [...tr.standards.rules] },
1335
+ standards_overrides: stdOverrides,
1336
+ task_areas: taskAreas,
1337
+ worker: {
1338
+ model: tr.worker.model,
1339
+ tools: tr.worker.tools,
1340
+ thinking: tr.worker.thinking,
1341
+ spawn_mode: tr.worker.spawnMode,
1342
+ },
1343
+ reviewer: { model: tr.reviewer.model, tools: tr.reviewer.tools, thinking: tr.reviewer.thinking },
1344
+ context: {
1345
+ worker_context_window: tr.context.workerContextWindow,
1346
+ warn_percent: tr.context.warnPercent,
1347
+ kill_percent: tr.context.killPercent,
1348
+ max_worker_iterations: tr.context.maxWorkerIterations,
1349
+ max_review_cycles: tr.context.maxReviewCycles,
1350
+ no_progress_limit: tr.context.noProgressLimit,
1351
+ max_worker_minutes: tr.context.maxWorkerMinutes,
1352
+ },
1353
+ quality_gate: {
1354
+ enabled: tr.qualityGate.enabled,
1355
+ review_model: tr.qualityGate.reviewModel,
1356
+ max_review_cycles: tr.qualityGate.maxReviewCycles,
1357
+ max_fix_cycles: tr.qualityGate.maxFixCycles,
1358
+ pass_threshold: tr.qualityGate.passThreshold,
1359
+ },
1360
+ };
1361
+ }
1362
+
1363
+ // ── Task Runner Config Loader ───────────────────────────────────────────
1364
+ //
1365
+ // loadConfig and _resetPointerWarning for task execution consumers.
1366
+
1367
+ /** Track whether a pointer warning has been logged this session (log once). */
1368
+ let _pointerWarningLogged = false;
1369
+
1370
+ /**
1371
+ * Resolve the workspace pointer for config and agent path redirection.
1372
+ * Returns null in repo mode (TASKPLANE_WORKSPACE_ROOT not set).
1373
+ */
1374
+ function resolveTaskRunnerPointer(): PointerResolution | null {
1375
+ const wsRoot = process.env.TASKPLANE_WORKSPACE_ROOT;
1376
+ if (!wsRoot) return null;
1377
+ try {
1378
+ const wsConfig = loadWorkspaceConfig(wsRoot);
1379
+ const result = resolvePointer(wsRoot, wsConfig);
1380
+ if (result?.warning && !_pointerWarningLogged) {
1381
+ _pointerWarningLogged = true;
1382
+ console.error(`[task-runner] pointer: ${result.warning}`);
1383
+ }
1384
+ return result;
1385
+ } catch {
1386
+ return null;
1387
+ }
1388
+ }
1389
+
1390
+ /** Reset pointer warning state (for testing only). */
1391
+ export function _resetPointerWarning(): void {
1392
+ _pointerWarningLogged = false;
1393
+ }
1394
+
1395
+ /**
1396
+ * Load task-runner config via the unified config loader.
1397
+ *
1398
+ * Returns the legacy snake_case TaskConfig shape. Wraps loadProjectConfig
1399
+ * with pointer resolution and error handling.
1400
+ */
1401
+ export function loadConfig(cwd: string): ReturnType<typeof toTaskConfig> {
1402
+ try {
1403
+ const pointer = resolveTaskRunnerPointer();
1404
+ const unified = loadProjectConfig(cwd, pointer?.configRoot);
1405
+ return toTaskConfig(unified);
1406
+ } catch (err: unknown) {
1407
+ if (err instanceof ConfigLoadError && err.code === "CONFIG_LEGACY_FIELD") {
1408
+ throw err;
1409
+ }
1410
+ return toTaskConfig(deepClone(DEFAULT_PROJECT_CONFIG));
1411
+ }
1412
+ }