@cortexkit/opencode-magic-context 0.38.1 → 0.40.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 (172) hide show
  1. package/README.md +57 -4
  2. package/dist/config/index.d.ts.map +1 -1
  3. package/dist/config/profiles.d.ts +22 -0
  4. package/dist/config/profiles.d.ts.map +1 -0
  5. package/dist/config/project-security.d.ts +14 -6
  6. package/dist/config/project-security.d.ts.map +1 -1
  7. package/dist/config/raw-loader.d.ts +42 -0
  8. package/dist/config/raw-loader.d.ts.map +1 -0
  9. package/dist/config/schema/magic-context.d.ts +966 -531
  10. package/dist/config/schema/magic-context.d.ts.map +1 -1
  11. package/dist/features/magic-context/dreamer/classify.d.ts +3 -2
  12. package/dist/features/magic-context/dreamer/classify.d.ts.map +1 -1
  13. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts +9 -2
  14. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
  15. package/dist/features/magic-context/dreamer/map-memories-prompt.d.ts +4 -2
  16. package/dist/features/magic-context/dreamer/map-memories-prompt.d.ts.map +1 -1
  17. package/dist/features/magic-context/dreamer/map-memories.d.ts +21 -5
  18. package/dist/features/magic-context/dreamer/map-memories.d.ts.map +1 -1
  19. package/dist/features/magic-context/dreamer/refresh-primers.d.ts +3 -2
  20. package/dist/features/magic-context/dreamer/refresh-primers.d.ts.map +1 -1
  21. package/dist/features/magic-context/dreamer/task-config.d.ts +2 -1
  22. package/dist/features/magic-context/dreamer/task-config.d.ts.map +1 -1
  23. package/dist/features/magic-context/dreamer/task-executor.d.ts +0 -1
  24. package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -1
  25. package/dist/features/magic-context/dreamer/task-scheduler.d.ts +3 -2
  26. package/dist/features/magic-context/dreamer/task-scheduler.d.ts.map +1 -1
  27. package/dist/features/magic-context/dreamer/verify-prompt.d.ts +5 -2
  28. package/dist/features/magic-context/dreamer/verify-prompt.d.ts.map +1 -1
  29. package/dist/features/magic-context/dreamer/verify.d.ts +6 -3
  30. package/dist/features/magic-context/dreamer/verify.d.ts.map +1 -1
  31. package/dist/features/magic-context/fail-closed-block.d.ts +11 -1
  32. package/dist/features/magic-context/fail-closed-block.d.ts.map +1 -1
  33. package/dist/features/magic-context/memory/embedding-failure.d.ts +11 -0
  34. package/dist/features/magic-context/memory/embedding-failure.d.ts.map +1 -0
  35. package/dist/features/magic-context/memory/embedding-openai.d.ts +5 -0
  36. package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
  37. package/dist/features/magic-context/memory/embedding-provider.d.ts +3 -0
  38. package/dist/features/magic-context/memory/embedding-provider.d.ts.map +1 -1
  39. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  40. package/dist/features/magic-context/mural/compress-cues.d.ts +3 -2
  41. package/dist/features/magic-context/mural/compress-cues.d.ts.map +1 -1
  42. package/dist/features/magic-context/project-embedding-registry.d.ts +2 -0
  43. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  44. package/dist/features/magic-context/smart-notes/compiler.d.ts +3 -2
  45. package/dist/features/magic-context/smart-notes/compiler.d.ts.map +1 -1
  46. package/dist/features/magic-context/smart-notes/condition-compiler.d.ts +2 -0
  47. package/dist/features/magic-context/smart-notes/condition-compiler.d.ts.map +1 -1
  48. package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts.map +1 -1
  49. package/dist/features/magic-context/storage-db.d.ts +2 -2
  50. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  51. package/dist/features/magic-context/storage-meta-persisted.d.ts +7 -2
  52. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  53. package/dist/features/magic-context/storage-meta-shared.d.ts +1 -0
  54. package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
  55. package/dist/features/magic-context/storage-meta.d.ts +1 -1
  56. package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
  57. package/dist/features/magic-context/storage-session-tables.d.ts.map +1 -1
  58. package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
  59. package/dist/features/magic-context/storage.d.ts +1 -1
  60. package/dist/features/magic-context/storage.d.ts.map +1 -1
  61. package/dist/features/magic-context/tool-definition-tokens.d.ts +10 -0
  62. package/dist/features/magic-context/tool-definition-tokens.d.ts.map +1 -1
  63. package/dist/features/magic-context/transform-decision-log.d.ts +11 -0
  64. package/dist/features/magic-context/transform-decision-log.d.ts.map +1 -1
  65. package/dist/features/magic-context/user-memory/review-user-memories.d.ts +3 -2
  66. package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
  67. package/dist/hooks/magic-context/channel2-delivery.d.ts +3 -1
  68. package/dist/hooks/magic-context/channel2-delivery.d.ts.map +1 -1
  69. package/dist/hooks/magic-context/command-handler.d.ts +4 -0
  70. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  71. package/dist/hooks/magic-context/compartment-runner-historian.d.ts +4 -1
  72. package/dist/hooks/magic-context/compartment-runner-historian.d.ts.map +1 -1
  73. package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
  74. package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
  75. package/dist/hooks/magic-context/compartment-runner-types.d.ts +4 -1
  76. package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
  77. package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts +14 -2
  78. package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts.map +1 -1
  79. package/dist/hooks/magic-context/emergency-fail-closed.d.ts +7 -0
  80. package/dist/hooks/magic-context/emergency-fail-closed.d.ts.map +1 -1
  81. package/dist/hooks/magic-context/format-embed-failure.d.ts +3 -0
  82. package/dist/hooks/magic-context/format-embed-failure.d.ts.map +1 -0
  83. package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
  84. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  85. package/dist/hooks/magic-context/inject-compartments.d.ts +13 -1
  86. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  87. package/dist/hooks/magic-context/lkg-persist.d.ts +14 -0
  88. package/dist/hooks/magic-context/lkg-persist.d.ts.map +1 -0
  89. package/dist/hooks/magic-context/lkg-slot.d.ts +13 -0
  90. package/dist/hooks/magic-context/lkg-slot.d.ts.map +1 -1
  91. package/dist/hooks/magic-context/module-state-sync.d.ts +1 -0
  92. package/dist/hooks/magic-context/module-state-sync.d.ts.map +1 -1
  93. package/dist/hooks/magic-context/module-transport.d.ts +12 -0
  94. package/dist/hooks/magic-context/module-transport.d.ts.map +1 -1
  95. package/dist/hooks/magic-context/raw-fallback-context-limit.d.ts.map +1 -1
  96. package/dist/hooks/magic-context/recomp-orchestrator.d.ts +4 -1
  97. package/dist/hooks/magic-context/recomp-orchestrator.d.ts.map +1 -1
  98. package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -1
  99. package/dist/hooks/magic-context/strip-content.d.ts +6 -0
  100. package/dist/hooks/magic-context/strip-content.d.ts.map +1 -1
  101. package/dist/hooks/magic-context/tail-hygiene-walk.d.ts +12 -0
  102. package/dist/hooks/magic-context/tail-hygiene-walk.d.ts.map +1 -1
  103. package/dist/hooks/magic-context/transform-compartment-phase.d.ts +3 -1
  104. package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
  105. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +5 -0
  106. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  107. package/dist/hooks/magic-context/transform.d.ts +9 -1
  108. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  109. package/dist/hooks/magic-context/wrapup-orchestrator.d.ts.map +1 -1
  110. package/dist/{index-2y0ybv5x.js → index-3dkzzh0t.js} +15 -17
  111. package/dist/{index-5znjkjws.js → index-axpym70t.js} +879 -170
  112. package/dist/index-ehpzdy0v.js +126 -0
  113. package/dist/{index-22mw8km4.js → index-rw0qrkvq.js} +4 -2
  114. package/dist/index.d.ts.map +1 -1
  115. package/dist/index.js +18466 -16177
  116. package/dist/model-resolution-r9jbcz86.js +15 -0
  117. package/dist/plugin/dream-timer.d.ts +3 -0
  118. package/dist/plugin/dream-timer.d.ts.map +1 -1
  119. package/dist/plugin/hooks/create-session-hooks.d.ts +2 -0
  120. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  121. package/dist/plugin/messages-transform.d.ts +14 -6
  122. package/dist/plugin/messages-transform.d.ts.map +1 -1
  123. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  124. package/dist/{recomp-orchestrator-p030kxtc.js → recomp-orchestrator-dmyvqekw.js} +2 -3
  125. package/dist/shared/jsonc-edit.d.ts +6 -1
  126. package/dist/shared/jsonc-edit.d.ts.map +1 -1
  127. package/dist/shared/model-resolution.d.ts +53 -0
  128. package/dist/shared/model-resolution.d.ts.map +1 -0
  129. package/dist/shared/model-suggestion-retry.d.ts +4 -1
  130. package/dist/shared/model-suggestion-retry.d.ts.map +1 -1
  131. package/dist/shared/resolve-fallbacks.d.ts +5 -1
  132. package/dist/shared/resolve-fallbacks.d.ts.map +1 -1
  133. package/dist/shared/rpc-types.d.ts +2 -0
  134. package/dist/shared/rpc-types.d.ts.map +1 -1
  135. package/dist/shared/rpc-utils.d.ts +12 -1
  136. package/dist/shared/rpc-utils.d.ts.map +1 -1
  137. package/dist/shared/status-detail-text.d.ts +4 -0
  138. package/dist/shared/status-detail-text.d.ts.map +1 -0
  139. package/dist/shared/subagent-runner.d.ts +2 -1
  140. package/dist/shared/subagent-runner.d.ts.map +1 -1
  141. package/dist/{storage-meta-session-2306t3j7.js → storage-meta-session-yvtbaw7c.js} +1 -1
  142. package/dist/{task-config-at3adc59.js → task-config-a1qmj9bx.js} +2 -2
  143. package/dist/tui/data/context-db.d.ts.map +1 -1
  144. package/dist/tui-compiled/data/context-db.d.ts.map +1 -1
  145. package/package.json +4 -2
  146. package/src/config/index.test.ts +434 -19
  147. package/src/config/index.ts +67 -33
  148. package/src/config/profiles.ts +106 -0
  149. package/src/config/project-security.hostile-matrix.test.ts +480 -0
  150. package/src/config/project-security.test.ts +43 -5
  151. package/src/config/project-security.ts +151 -22
  152. package/src/config/raw-loader.test.ts +424 -0
  153. package/src/config/raw-loader.ts +490 -0
  154. package/src/config/schema/magic-context.test.ts +272 -2
  155. package/src/config/schema/magic-context.ts +342 -49
  156. package/src/shared/jsonc-edit.ts +74 -1
  157. package/src/shared/model-resolution.test.ts +170 -0
  158. package/src/shared/model-resolution.ts +227 -0
  159. package/src/shared/model-suggestion-retry.test.ts +27 -0
  160. package/src/shared/model-suggestion-retry.ts +19 -10
  161. package/src/shared/resolve-fallbacks.test.ts +13 -1
  162. package/src/shared/resolve-fallbacks.ts +19 -4
  163. package/src/shared/rpc-types.ts +2 -0
  164. package/src/shared/rpc-utils.test.ts +32 -0
  165. package/src/shared/rpc-utils.ts +38 -13
  166. package/src/shared/status-detail-text.test.ts +82 -0
  167. package/src/shared/status-detail-text.ts +57 -0
  168. package/src/shared/subagent-runner.ts +3 -1
  169. package/src/tui/data/context-db.ts +1 -0
  170. package/src/tui-compiled/data/context-db.ts +1 -0
  171. package/dist/index-td9depx5.js +0 -46
  172. package/dist/resolve-fallbacks-qf0ekmz9.js +0 -11
@@ -0,0 +1,490 @@
1
+ import {
2
+ closeSync,
3
+ existsSync,
4
+ linkSync,
5
+ openSync,
6
+ readFileSync,
7
+ renameSync,
8
+ statSync,
9
+ unlinkSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { basename, dirname, join } from "node:path";
13
+
14
+ import { removeJsoncValue, setJsoncValue } from "../shared/jsonc-edit";
15
+ import { parseJsonc } from "../shared/jsonc-parser";
16
+
17
+ export type ConfigTier = "user" | "project";
18
+
19
+ export interface FlatConfigDiagnostic {
20
+ path: string;
21
+ message: string;
22
+ }
23
+
24
+ export interface FlatConfigMigration {
25
+ bytes: Buffer;
26
+ hasFlatKeys: boolean;
27
+ diagnostics: FlatConfigDiagnostic[];
28
+ }
29
+
30
+ export interface RawConfigLoadOptions {
31
+ configPath: string;
32
+ tier: ConfigTier;
33
+ /** Test-only seam for making a competing loader replace the target before this loader re-checks it. */
34
+ afterTemporaryWrite?: () => void;
35
+ }
36
+
37
+ export interface RawConfigLoadResult {
38
+ configPath: string;
39
+ bytes: Buffer;
40
+ text: string;
41
+ warnings: string[];
42
+ migrated: boolean;
43
+ }
44
+
45
+ const MODEL_FIELDS = ["model", "fallback_models"] as const;
46
+ const QUALIFIER_FIELDS = ["variant", "thinking_level"] as const;
47
+ const TASK_MODEL_FIELDS = [...MODEL_FIELDS, ...QUALIFIER_FIELDS, "timeout_minutes"] as const;
48
+ const PRE_PER_HARNESS_BACKUP_SUFFIX = ".pre-per-harness.bak";
49
+ let temporaryFileSequence = 0;
50
+
51
+ function isRecord(value: unknown): value is Record<string, unknown> {
52
+ return typeof value === "object" && value !== null && !Array.isArray(value);
53
+ }
54
+
55
+ function asDocument(text: string): Record<string, unknown> | null {
56
+ try {
57
+ // JSON.parse rejects a UTF-8 BOM, while JSONC files commonly retain one.
58
+ // Remove it only for inspection; every byte-writing path adds it back.
59
+ const document = parseJsonc<unknown>(text.startsWith("\uFEFF") ? text.slice(1) : text);
60
+ return isRecord(document) ? document : null;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ function getAtPath(document: Record<string, unknown>, path: readonly string[]): unknown {
67
+ let current: unknown = document;
68
+ for (const part of path) {
69
+ if (!isRecord(current) || !Object.hasOwn(current, part)) return undefined;
70
+ current = current[part];
71
+ }
72
+ return current;
73
+ }
74
+
75
+ function stableJson(value: unknown): string {
76
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
77
+ if (!isRecord(value)) return JSON.stringify(value);
78
+ return `{${Object.keys(value)
79
+ .sort()
80
+ .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
81
+ .join(",")}}`;
82
+ }
83
+
84
+ function valuesMatch(left: unknown, right: unknown): boolean {
85
+ return stableJson(left) === stableJson(right);
86
+ }
87
+
88
+ function valueForDiagnostic(value: unknown): string {
89
+ return stableJson(value);
90
+ }
91
+
92
+ function migrateEntryForHarness(value: unknown, harness: "opencode" | "pi"): unknown {
93
+ if (Array.isArray(value)) return value.map((entry) => migrateEntryForHarness(entry, harness));
94
+ if (!isRecord(value) || !Object.hasOwn(value, "model")) return value;
95
+
96
+ const entry: Record<string, unknown> = { model: value.model };
97
+ if (harness === "opencode" && Object.hasOwn(value, "variant")) {
98
+ entry.variant = value.variant;
99
+ }
100
+ if (harness === "pi" && Object.hasOwn(value, "thinking_level")) {
101
+ entry.thinking_level = value.thinking_level;
102
+ }
103
+ return entry;
104
+ }
105
+
106
+ function migrateFallbackForHarness(value: unknown, harness: "opencode" | "pi"): unknown {
107
+ if (typeof value === "string") return [value];
108
+ return migrateEntryForHarness(value, harness);
109
+ }
110
+
111
+ function canCreateAtPath(document: Record<string, unknown>, path: readonly string[]): boolean {
112
+ let current: unknown = document;
113
+ for (const part of path) {
114
+ if (current === undefined) return true;
115
+ if (!isRecord(current)) return false;
116
+ current = current[part];
117
+ }
118
+ return current === undefined || isRecord(current);
119
+ }
120
+
121
+ function flatFieldPath(parts: readonly string[]): string {
122
+ return parts.join(".");
123
+ }
124
+
125
+ function updateDocumentForFlatFields(text: string): {
126
+ text: string;
127
+ hasFlatKeys: boolean;
128
+ diagnostics: FlatConfigDiagnostic[];
129
+ flatPaths: string[];
130
+ } {
131
+ const hasBom = text.startsWith("\uFEFF");
132
+ const editableText = hasBom ? text.slice(1) : text;
133
+ const document = asDocument(text);
134
+ if (!document) {
135
+ return { text, hasFlatKeys: false, diagnostics: [], flatPaths: [] };
136
+ }
137
+
138
+ let nextText = editableText;
139
+ let hasFlatKeys = false;
140
+ const diagnostics: FlatConfigDiagnostic[] = [];
141
+ const flatPaths: string[] = [];
142
+ const sourcePathsToRemove: string[][] = [];
143
+
144
+ const addDestination = (
145
+ sourcePath: string[],
146
+ destinationPath: string[],
147
+ destinationValue: unknown,
148
+ ): void => {
149
+ const sourceLabel = flatFieldPath(sourcePath);
150
+ const destinationLabel = flatFieldPath(destinationPath);
151
+ if (!canCreateAtPath(document, destinationPath.slice(0, -1))) {
152
+ diagnostics.push({
153
+ path: sourceLabel,
154
+ message: `Flat config field "${sourceLabel}" (${valueForDiagnostic(destinationValue)}) conflicts with non-object destination "${destinationLabel}" (${valueForDiagnostic(getAtPath(document, destinationPath.slice(0, -1)))}); kept the destination and ignored the flat field.`,
155
+ });
156
+ return;
157
+ }
158
+
159
+ const existing = getAtPath(document, destinationPath);
160
+ if (existing !== undefined) {
161
+ if (!valuesMatch(existing, destinationValue)) {
162
+ diagnostics.push({
163
+ path: sourceLabel,
164
+ message: `Flat config field "${sourceLabel}" (${valueForDiagnostic(destinationValue)}) conflicts with "${destinationLabel}" (${valueForDiagnostic(existing)}); kept "${destinationLabel}" and ignored the flat field.`,
165
+ });
166
+ }
167
+ return;
168
+ }
169
+
170
+ nextText = setJsoncValue(nextText, destinationPath, destinationValue);
171
+ };
172
+
173
+ const migrateAgentFields = (agentName: "historian" | "dreamer"): void => {
174
+ const agent = document[agentName];
175
+ if (!isRecord(agent)) return;
176
+
177
+ for (const field of MODEL_FIELDS) {
178
+ if (!Object.hasOwn(agent, field)) continue;
179
+ const sourcePath = [agentName, field];
180
+ hasFlatKeys = true;
181
+ flatPaths.push(flatFieldPath(sourcePath));
182
+ sourcePathsToRemove.push(sourcePath);
183
+ const migrateValue =
184
+ field === "fallback_models" ? migrateFallbackForHarness : migrateEntryForHarness;
185
+ addDestination(
186
+ sourcePath,
187
+ [agentName, "opencode", field],
188
+ migrateValue(agent[field], "opencode"),
189
+ );
190
+ addDestination(sourcePath, [agentName, "pi", field], migrateValue(agent[field], "pi"));
191
+ }
192
+
193
+ if (Object.hasOwn(agent, "variant")) {
194
+ const sourcePath = [agentName, "variant"];
195
+ hasFlatKeys = true;
196
+ flatPaths.push(flatFieldPath(sourcePath));
197
+ sourcePathsToRemove.push(sourcePath);
198
+ addDestination(sourcePath, [agentName, "opencode", "variant"], agent.variant);
199
+ }
200
+
201
+ if (Object.hasOwn(agent, "thinking_level")) {
202
+ const sourcePath = [agentName, "thinking_level"];
203
+ hasFlatKeys = true;
204
+ flatPaths.push(flatFieldPath(sourcePath));
205
+ sourcePathsToRemove.push(sourcePath);
206
+ addDestination(sourcePath, [agentName, "pi", "thinking_level"], agent.thinking_level);
207
+ }
208
+ };
209
+
210
+ migrateAgentFields("historian");
211
+ migrateAgentFields("dreamer");
212
+
213
+ const dreamer = document.dreamer;
214
+ const tasks = isRecord(dreamer) ? dreamer.tasks : undefined;
215
+ if (isRecord(tasks)) {
216
+ for (const taskName of Object.keys(tasks).sort()) {
217
+ const task = tasks[taskName];
218
+ if (!isRecord(task)) continue;
219
+
220
+ for (const field of TASK_MODEL_FIELDS) {
221
+ if (!Object.hasOwn(task, field)) continue;
222
+ const sourcePath = ["dreamer", "tasks", taskName, field];
223
+ hasFlatKeys = true;
224
+ flatPaths.push(flatFieldPath(sourcePath));
225
+ sourcePathsToRemove.push(sourcePath);
226
+
227
+ if (field === "model" || field === "fallback_models") {
228
+ addDestination(
229
+ sourcePath,
230
+ ["dreamer", "opencode", "tasks", taskName, field],
231
+ field === "fallback_models"
232
+ ? migrateFallbackForHarness(task[field], "opencode")
233
+ : migrateEntryForHarness(task[field], "opencode"),
234
+ );
235
+ addDestination(
236
+ sourcePath,
237
+ ["dreamer", "pi", "tasks", taskName, field],
238
+ field === "fallback_models"
239
+ ? migrateFallbackForHarness(task[field], "pi")
240
+ : migrateEntryForHarness(task[field], "pi"),
241
+ );
242
+ } else if (field === "variant") {
243
+ addDestination(
244
+ sourcePath,
245
+ ["dreamer", "opencode", "tasks", taskName, field],
246
+ task[field],
247
+ );
248
+ } else if (field === "thinking_level") {
249
+ addDestination(
250
+ sourcePath,
251
+ ["dreamer", "pi", "tasks", taskName, field],
252
+ task[field],
253
+ );
254
+ } else {
255
+ addDestination(
256
+ sourcePath,
257
+ ["dreamer", "opencode", "tasks", taskName, field],
258
+ task[field],
259
+ );
260
+ addDestination(
261
+ sourcePath,
262
+ ["dreamer", "pi", "tasks", taskName, field],
263
+ task[field],
264
+ );
265
+ }
266
+ }
267
+ }
268
+ }
269
+
270
+ for (const sourcePath of sourcePathsToRemove) {
271
+ nextText = removeJsoncValue(nextText, sourcePath);
272
+ }
273
+
274
+ return {
275
+ text: hasBom ? `\uFEFF${nextText}` : nextText,
276
+ hasFlatKeys,
277
+ diagnostics,
278
+ flatPaths,
279
+ };
280
+ }
281
+
282
+ /** True when historian, dreamer, or dreamer-task fields still use the flat shape. */
283
+ export function hasFlatKeys(input: Buffer | string): boolean {
284
+ const text = typeof input === "string" ? input : input.toString("utf-8");
285
+ return updateDocumentForFlatFields(text).hasFlatKeys;
286
+ }
287
+
288
+ /**
289
+ * Deterministically maps flat model fields into per-harness blocks while leaving
290
+ * unrelated JSONC regions untouched. The return type matches the caller's input
291
+ * representation so callers that operate on file bytes retain BOM and line-ending
292
+ * details exactly outside the surgical edits.
293
+ */
294
+ export function migrateFlat(input: string): string;
295
+ export function migrateFlat(input: Buffer): Buffer;
296
+ export function migrateFlat(input: Buffer | string): Buffer | string {
297
+ const text = typeof input === "string" ? input : input.toString("utf-8");
298
+ const migrated = updateDocumentForFlatFields(text).text;
299
+ return typeof input === "string" ? migrated : Buffer.from(migrated, "utf-8");
300
+ }
301
+
302
+ export function migrateFlatDetailed(input: Buffer | string): FlatConfigMigration {
303
+ const bytes = typeof input === "string" ? Buffer.from(input, "utf-8") : input;
304
+ const result = updateDocumentForFlatFields(bytes.toString("utf-8"));
305
+ return {
306
+ bytes: Buffer.from(result.text, "utf-8"),
307
+ hasFlatKeys: result.hasFlatKeys,
308
+ diagnostics: result.diagnostics,
309
+ };
310
+ }
311
+
312
+ function writeExclusiveBackup(backupPath: string, bytes: Buffer, mode: number): void {
313
+ const temporaryPath = writeTemporaryCandidate(backupPath, bytes, mode);
314
+ try {
315
+ try {
316
+ // A hard link publishes the fully written candidate without replacing a concurrent backup.
317
+ linkSync(temporaryPath, backupPath);
318
+ return;
319
+ } catch (error) {
320
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
321
+ }
322
+
323
+ const existingBytes = readFileSync(backupPath);
324
+ if (existingBytes.equals(bytes)) return;
325
+
326
+ // A prefix may be a partial file left when the former direct-write approach crashed.
327
+ // Preserve any other content because it may be a complete backup from an earlier run.
328
+ if (bytes.subarray(0, existingBytes.length).equals(existingBytes)) {
329
+ renameSync(temporaryPath, backupPath);
330
+ return;
331
+ }
332
+ } finally {
333
+ try {
334
+ unlinkSync(temporaryPath);
335
+ } catch {
336
+ // The candidate may already be linked or renamed into place; cleanup failure must
337
+ // not obscure the result of creating or validating the permanent backup.
338
+ }
339
+ }
340
+ }
341
+
342
+ function writeTemporaryCandidate(configPath: string, bytes: Buffer, mode: number): string {
343
+ const directory = dirname(configPath);
344
+ const stem = basename(configPath);
345
+ for (let attempt = 0; attempt < 32; attempt++) {
346
+ temporaryFileSequence += 1;
347
+ const path = join(
348
+ directory,
349
+ `.${stem}.per-harness-${process.pid}-${temporaryFileSequence}.tmp`,
350
+ );
351
+ let descriptor: number | undefined;
352
+ try {
353
+ descriptor = openSync(path, "wx", mode);
354
+ writeFileSync(descriptor, bytes);
355
+ closeSync(descriptor);
356
+ return path;
357
+ } catch (error) {
358
+ if (descriptor !== undefined) {
359
+ try {
360
+ closeSync(descriptor);
361
+ } catch {
362
+ // The write error below remains the actionable error.
363
+ }
364
+ }
365
+ try {
366
+ unlinkSync(path);
367
+ } catch {
368
+ // A failed cleanup must not hide the original write error.
369
+ }
370
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
371
+ }
372
+ }
373
+ throw new Error(`Could not allocate a temporary config file beside ${configPath}`);
374
+ }
375
+
376
+ function migrationWarning(diagnostic: FlatConfigDiagnostic): string {
377
+ return diagnostic.message;
378
+ }
379
+
380
+ /**
381
+ * Reads the raw tier before substitution and schema validation. User files are
382
+ * rewritten through an exact-byte backup and same-directory atomic replacement;
383
+ * project files are adapted only in memory. This is the common seam used by both
384
+ * harness loaders and their doctor paths.
385
+ */
386
+ export function loadRawConfigFile(options: RawConfigLoadOptions): RawConfigLoadResult | null {
387
+ if (!existsSync(options.configPath)) return null;
388
+
389
+ let observedBytes: Buffer;
390
+ try {
391
+ observedBytes = readFileSync(options.configPath);
392
+ } catch (error) {
393
+ throw new Error(
394
+ `failed to read config: ${error instanceof Error ? error.message : String(error)}`,
395
+ );
396
+ }
397
+
398
+ const initialMigration = migrateFlatDetailed(observedBytes);
399
+ if (!initialMigration.hasFlatKeys) {
400
+ return {
401
+ configPath: options.configPath,
402
+ bytes: observedBytes,
403
+ text: observedBytes.toString("utf-8"),
404
+ warnings: [],
405
+ migrated: false,
406
+ };
407
+ }
408
+
409
+ if (options.tier === "project") {
410
+ return {
411
+ configPath: options.configPath,
412
+ bytes: initialMigration.bytes,
413
+ text: initialMigration.bytes.toString("utf-8"),
414
+ warnings: [
415
+ "Adapted flat model config in memory; use historian.opencode/historian.pi and dreamer.opencode/dreamer.pi instead. Project config files are never rewritten.",
416
+ ...initialMigration.diagnostics.map(migrationWarning),
417
+ ],
418
+ migrated: false,
419
+ };
420
+ }
421
+
422
+ const backupPath = `${options.configPath}${PRE_PER_HARNESS_BACKUP_SUFFIX}`;
423
+ for (;;) {
424
+ const migration = migrateFlatDetailed(observedBytes);
425
+ if (!migration.hasFlatKeys) {
426
+ return {
427
+ configPath: options.configPath,
428
+ bytes: observedBytes,
429
+ text: observedBytes.toString("utf-8"),
430
+ warnings: [],
431
+ migrated: false,
432
+ };
433
+ }
434
+
435
+ let temporaryPath: string | undefined;
436
+ try {
437
+ const mode = statSync(options.configPath).mode & 0o777;
438
+ writeExclusiveBackup(backupPath, observedBytes, mode);
439
+ temporaryPath = writeTemporaryCandidate(options.configPath, migration.bytes, mode);
440
+ options.afterTemporaryWrite?.();
441
+
442
+ const currentBytes = readFileSync(options.configPath);
443
+ if (!hasFlatKeys(currentBytes)) {
444
+ unlinkSync(temporaryPath);
445
+ return {
446
+ configPath: options.configPath,
447
+ bytes: currentBytes,
448
+ text: currentBytes.toString("utf-8"),
449
+ warnings: [],
450
+ migrated: false,
451
+ };
452
+ }
453
+ if (!currentBytes.equals(observedBytes)) {
454
+ unlinkSync(temporaryPath);
455
+ observedBytes = currentBytes;
456
+ continue;
457
+ }
458
+
459
+ renameSync(temporaryPath, options.configPath);
460
+ return {
461
+ configPath: options.configPath,
462
+ bytes: migration.bytes,
463
+ text: migration.bytes.toString("utf-8"),
464
+ warnings: [
465
+ "Migrated flat historian/dreamer model config to per-harness blocks.",
466
+ ...migration.diagnostics.map(migrationWarning),
467
+ ],
468
+ migrated: true,
469
+ };
470
+ } catch (error) {
471
+ if (temporaryPath) {
472
+ try {
473
+ unlinkSync(temporaryPath);
474
+ } catch {
475
+ // The migration error is more useful than a failed temp cleanup.
476
+ }
477
+ }
478
+ return {
479
+ configPath: options.configPath,
480
+ bytes: observedBytes,
481
+ text: observedBytes.toString("utf-8"),
482
+ warnings: [
483
+ `Could not migrate flat model config: ${error instanceof Error ? error.message : String(error)}. Flat fields were not applied.`,
484
+ ...migration.diagnostics.map(migrationWarning),
485
+ ],
486
+ migrated: false,
487
+ };
488
+ }
489
+ }
490
+ }