@cortexkit/opencode-magic-context 0.38.0 → 0.39.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.
- package/README.md +37 -4
- package/dist/{announcement-6c1g4es4.js → announcement-5eg4t64s.js} +6 -6
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/project-security.d.ts +11 -6
- package/dist/config/project-security.d.ts.map +1 -1
- package/dist/config/raw-loader.d.ts +42 -0
- package/dist/config/raw-loader.d.ts.map +1 -0
- package/dist/config/schema/magic-context.d.ts +586 -551
- package/dist/config/schema/magic-context.d.ts.map +1 -1
- package/dist/{conflict-warning-hook-an4fxhwf.js → conflict-warning-hook-f406ggbw.js} +5 -5
- package/dist/{derive-budgets-da4yg3tz.js → derive-budgets-xk8cy5e6.js} +5 -5
- package/dist/{emscripten-module-ap8t3st0.js → emscripten-module-49k43zfy.js} +1 -1
- package/dist/{emscripten-module-682pfzaa.js → emscripten-module-ex4v0x4z.js} +1 -1
- package/dist/{emscripten-module-r5h7aghp.js → emscripten-module-jfs3d1dq.js} +1 -1
- package/dist/{emscripten-module-bkktz927.js → emscripten-module-ss4g4spn.js} +1 -1
- package/dist/features/magic-context/dreamer/classify.d.ts +3 -2
- package/dist/features/magic-context/dreamer/classify.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts +9 -2
- package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/map-memories-prompt.d.ts +4 -2
- package/dist/features/magic-context/dreamer/map-memories-prompt.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/map-memories.d.ts +21 -5
- package/dist/features/magic-context/dreamer/map-memories.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/refresh-primers.d.ts +3 -2
- package/dist/features/magic-context/dreamer/refresh-primers.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/retrospective-orphan-sweep.d.ts +17 -6
- package/dist/features/magic-context/dreamer/retrospective-orphan-sweep.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/task-config.d.ts +2 -1
- package/dist/features/magic-context/dreamer/task-config.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/task-executor.d.ts +0 -1
- package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/task-scheduler.d.ts +3 -2
- package/dist/features/magic-context/dreamer/task-scheduler.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/verify-prompt.d.ts +5 -2
- package/dist/features/magic-context/dreamer/verify-prompt.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/verify.d.ts +6 -3
- package/dist/features/magic-context/dreamer/verify.d.ts.map +1 -1
- package/dist/features/magic-context/memory/storage-memory.d.ts.map +1 -1
- package/dist/features/magic-context/memory/types.d.ts +16 -0
- package/dist/features/magic-context/memory/types.d.ts.map +1 -1
- package/dist/features/magic-context/message-index.d.ts +3 -2
- package/dist/features/magic-context/message-index.d.ts.map +1 -1
- package/dist/features/magic-context/migrations.d.ts.map +1 -1
- package/dist/features/magic-context/mural/compress-cues.d.ts +3 -2
- package/dist/features/magic-context/mural/compress-cues.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/compiler.d.ts +3 -2
- package/dist/features/magic-context/smart-notes/compiler.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/condition-compiler.d.ts +2 -0
- package/dist/features/magic-context/smart-notes/condition-compiler.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts.map +1 -1
- package/dist/features/magic-context/storage-db.d.ts +1 -1
- package/dist/features/magic-context/storage-db.d.ts.map +1 -1
- package/dist/features/magic-context/storage-embedding-measurements.d.ts +1 -1
- package/dist/features/magic-context/storage-embedding-measurements.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-persisted.d.ts +7 -2
- package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-shared.d.ts +1 -0
- package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta.d.ts +1 -1
- package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
- package/dist/features/magic-context/storage-session-tables.d.ts +18 -0
- package/dist/features/magic-context/storage-session-tables.d.ts.map +1 -0
- package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
- package/dist/features/magic-context/storage.d.ts +1 -1
- package/dist/features/magic-context/storage.d.ts.map +1 -1
- package/dist/features/magic-context/tool-definition-tokens.d.ts +10 -0
- package/dist/features/magic-context/tool-definition-tokens.d.ts.map +1 -1
- package/dist/features/magic-context/transform-decision-log.d.ts +33 -0
- package/dist/features/magic-context/transform-decision-log.d.ts.map +1 -1
- package/dist/features/magic-context/user-memory/review-user-memories.d.ts +3 -2
- package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
- package/dist/{ffi-d8pnn3mn.js → ffi-bbm9vkfa.js} +1 -1
- package/dist/{ffi-04jzypzk.js → ffi-kb5r0x1j.js} +1 -1
- package/dist/{ffi-t8b862vx.js → ffi-qnrt0y78.js} +1 -1
- package/dist/{ffi-9wvt2wwz.js → ffi-yx2hpf4p.js} +1 -1
- package/dist/hooks/magic-context/channel2-delivery.d.ts +3 -1
- package/dist/hooks/magic-context/channel2-delivery.d.ts.map +1 -1
- package/dist/hooks/magic-context/command-handler.d.ts +4 -0
- package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-historian.d.ts +4 -1
- package/dist/hooks/magic-context/compartment-runner-historian.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-types.d.ts +4 -1
- package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
- package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts +14 -2
- package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts.map +1 -1
- package/dist/hooks/magic-context/emergency-fail-closed.d.ts +7 -0
- package/dist/hooks/magic-context/emergency-fail-closed.d.ts.map +1 -1
- package/dist/hooks/magic-context/empty-task-output.d.ts +16 -0
- package/dist/hooks/magic-context/empty-task-output.d.ts.map +1 -0
- package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
- package/dist/hooks/magic-context/hook.d.ts.map +1 -1
- package/dist/hooks/magic-context/inject-compartments.d.ts +18 -1
- package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
- package/dist/hooks/magic-context/lkg-persist.d.ts +14 -0
- package/dist/hooks/magic-context/lkg-persist.d.ts.map +1 -0
- package/dist/hooks/magic-context/lkg-slot.d.ts +13 -0
- package/dist/hooks/magic-context/lkg-slot.d.ts.map +1 -1
- package/dist/hooks/magic-context/module-state-sync.d.ts +1 -0
- package/dist/hooks/magic-context/module-state-sync.d.ts.map +1 -1
- package/dist/hooks/magic-context/module-transport.d.ts +12 -0
- package/dist/hooks/magic-context/module-transport.d.ts.map +1 -1
- package/dist/hooks/magic-context/raw-fallback-context-limit.d.ts.map +1 -1
- package/dist/hooks/magic-context/recomp-orchestrator.d.ts +4 -1
- package/dist/hooks/magic-context/recomp-orchestrator.d.ts.map +1 -1
- package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -1
- package/dist/hooks/magic-context/tail-hygiene-walk.d.ts +12 -0
- package/dist/hooks/magic-context/tail-hygiene-walk.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-compartment-phase.d.ts +3 -1
- package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +8 -0
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts +9 -1
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/hooks/magic-context/wrapup-orchestrator.d.ts.map +1 -1
- package/dist/{index-cae89eas.js → index-1h9t9xb5.js} +2 -2
- package/dist/{index-wckvcay0.js → index-1yh8g550.js} +8 -6
- package/dist/{index-cgyfn1s2.js → index-2wchnyk6.js} +4 -2
- package/dist/{index-2y0ybv5x.js → index-3dkzzh0t.js} +15 -17
- package/dist/{index-4cz5nfxf.js → index-btj2qhmc.js} +5 -5
- package/dist/index-ehpzdy0v.js +126 -0
- package/dist/{index-mmvhrqqb.js → index-k2ts0wk1.js} +6435 -4339
- package/dist/{index-9zhhvr4w.js → index-mg1yx1ph.js} +1 -1
- package/dist/{index-ayd60xnw.js → index-rgdqj8nj.js} +3 -3
- package/dist/{index-ygcng9t8.js → index-rpgg35ms.js} +50 -50
- package/dist/index-rw0qrkvq.js +560 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +27158 -24736
- package/dist/model-resolution-r9jbcz86.js +15 -0
- package/dist/{module-ES6BEMUI-atfbtnbp.js → module-ES6BEMUI-1yjdz59c.js} +4 -4
- package/dist/{module-asyncify-2EFITU5U-7xckhw8w.js → module-asyncify-2EFITU5U-fdkx4fhy.js} +1 -1
- package/dist/plugin/dream-timer.d.ts +8 -0
- package/dist/plugin/dream-timer.d.ts.map +1 -1
- package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
- package/dist/plugin/messages-transform.d.ts +14 -6
- package/dist/plugin/messages-transform.d.ts.map +1 -1
- package/dist/plugin/rpc-handlers.d.ts.map +1 -1
- package/dist/{prompt-context-jpe2dstn.js → prompt-context-29qkb28f.js} +1 -1
- package/dist/{recomp-orchestrator-jsde4ygw.js → recomp-orchestrator-2pncxee7.js} +15 -16
- package/dist/{rpc-notifications-4d22ybn5.js → rpc-notifications-1ffm755w.js} +6 -6
- package/dist/{safe-notification-target-b693f9x3.js → safe-notification-target-tepzc2xa.js} +3 -3
- package/dist/{send-session-notification-dxske5zm.js → send-session-notification-6v5b72b4.js} +7 -7
- package/dist/shared/harness-provider-map.d.ts +6 -0
- package/dist/shared/harness-provider-map.d.ts.map +1 -1
- package/dist/shared/jsonc-edit.d.ts +6 -1
- package/dist/shared/jsonc-edit.d.ts.map +1 -1
- package/dist/shared/model-resolution.d.ts +53 -0
- package/dist/shared/model-resolution.d.ts.map +1 -0
- package/dist/shared/model-suggestion-retry.d.ts +4 -1
- package/dist/shared/model-suggestion-retry.d.ts.map +1 -1
- package/dist/shared/resolve-fallbacks.d.ts +5 -1
- package/dist/shared/resolve-fallbacks.d.ts.map +1 -1
- package/dist/shared/status-detail-text.d.ts +4 -0
- package/dist/shared/status-detail-text.d.ts.map +1 -0
- package/dist/shared/subagent-runner.d.ts +2 -1
- package/dist/shared/subagent-runner.d.ts.map +1 -1
- package/dist/{storage-meta-session-mk4gsnke.js → storage-meta-session-yvtbaw7c.js} +8 -8
- package/dist/{task-config-gbp7r23g.js → task-config-a1qmj9bx.js} +8 -8
- package/package.json +4 -2
- package/src/config/index.test.ts +73 -17
- package/src/config/index.ts +38 -23
- package/src/config/project-security.hostile-matrix.test.ts +471 -0
- package/src/config/project-security.test.ts +26 -5
- package/src/config/project-security.ts +141 -22
- package/src/config/raw-loader.test.ts +424 -0
- package/src/config/raw-loader.ts +490 -0
- package/src/config/schema/magic-context.test.ts +145 -2
- package/src/config/schema/magic-context.ts +256 -52
- package/src/shared/harness-provider-map.test.ts +36 -0
- package/src/shared/harness-provider-map.ts +8 -0
- package/src/shared/jsonc-edit.ts +74 -1
- package/src/shared/model-resolution.test.ts +170 -0
- package/src/shared/model-resolution.ts +227 -0
- package/src/shared/model-suggestion-retry.test.ts +27 -0
- package/src/shared/model-suggestion-retry.ts +19 -10
- package/src/shared/resolve-fallbacks.test.ts +13 -1
- package/src/shared/resolve-fallbacks.ts +19 -4
- package/src/shared/status-detail-text.test.ts +80 -0
- package/src/shared/status-detail-text.ts +56 -0
- package/src/shared/subagent-runner.ts +3 -1
- package/src/tui/index.tsx +1 -1
- package/src/tui/tui-compiled-runtime-imports.test.ts +3 -0
- package/src/tui-compiled/index.tsx +1 -1
- package/dist/index-crke4z5j.js +0 -3022
- package/dist/index-td9depx5.js +0 -46
- package/dist/resolve-fallbacks-5n5tvge1.js +0 -11
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_EXECUTE_THRESHOLD_PERCENTAGE,
|
|
3
|
+
PER_HARNESS_MIGRATION_INVENTORY,
|
|
4
|
+
} from "./schema/magic-context";
|
|
2
5
|
|
|
3
6
|
/**
|
|
4
7
|
* Security hardening for PROJECT-level (repo-supplied, untrusted) config.
|
|
@@ -15,7 +18,11 @@ import { DEFAULT_EXECUTE_THRESHOLD_PERCENTAGE } from "./schema/magic-context";
|
|
|
15
18
|
|
|
16
19
|
/** Hidden agents that run with elevated/autonomous capability. */
|
|
17
20
|
const HIDDEN_AGENT_KEYS = ["historian", "dreamer", "sidekick"] as const;
|
|
18
|
-
const
|
|
21
|
+
const HARNESS_KEYS = ["opencode", "pi"] as const;
|
|
22
|
+
/** Every historian model-resolution field, including per-harness qualifiers.
|
|
23
|
+
* Variant and thinking_level merge onto the user's historian model at resolve
|
|
24
|
+
* time, so leaving them would let a cloned repo force extra spend. */
|
|
25
|
+
const HISTORIAN_USER_ONLY_FIELDS = PER_HARNESS_MIGRATION_INVENTORY.historian.migrated_execution;
|
|
19
26
|
const PROMPT_SURFACE_USER_ONLY_FIELDS = ["guidance_override_path", "tool_descriptions"] as const;
|
|
20
27
|
|
|
21
28
|
/**
|
|
@@ -64,6 +71,66 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
|
64
71
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
65
72
|
}
|
|
66
73
|
|
|
74
|
+
function stripListedFields(
|
|
75
|
+
target: Record<string, unknown>,
|
|
76
|
+
fields: readonly string[],
|
|
77
|
+
path: string,
|
|
78
|
+
removed: string[],
|
|
79
|
+
): void {
|
|
80
|
+
for (const field of fields) {
|
|
81
|
+
if (field in target) {
|
|
82
|
+
delete target[field];
|
|
83
|
+
removed.push(path.length > 0 ? `${path}.${field}` : field);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Strip prompt, permission, tools, and system_prompt from one agent, harness,
|
|
89
|
+
* task, or model-entry object, including each fallback_models entry. A cloned
|
|
90
|
+
* repo must not reprogram hidden agents through any of those nestings. */
|
|
91
|
+
function stripEscalationAtExecutableSite(
|
|
92
|
+
block: Record<string, unknown>,
|
|
93
|
+
path: string,
|
|
94
|
+
removed: string[],
|
|
95
|
+
): void {
|
|
96
|
+
stripListedFields(block, AGENT_ESCALATION_FIELDS, path, removed);
|
|
97
|
+
if (isPlainObject(block.model)) {
|
|
98
|
+
stripListedFields(block.model, AGENT_ESCALATION_FIELDS, `${path}.model`, removed);
|
|
99
|
+
}
|
|
100
|
+
if (Array.isArray(block.fallback_models)) {
|
|
101
|
+
for (let index = 0; index < block.fallback_models.length; index++) {
|
|
102
|
+
const entry = block.fallback_models[index];
|
|
103
|
+
if (isPlainObject(entry)) {
|
|
104
|
+
stripListedFields(
|
|
105
|
+
entry,
|
|
106
|
+
AGENT_ESCALATION_FIELDS,
|
|
107
|
+
`${path}.fallback_models.${index}`,
|
|
108
|
+
removed,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Remove `mural.model` smuggled under hidden-agent trees. Top-level and
|
|
116
|
+
* experimental mural blocks are handled separately so their warnings stay
|
|
117
|
+
* specific; this walk covers harness and task nesting the schema does not
|
|
118
|
+
* admit but a hostile file can still write. */
|
|
119
|
+
function stripNestedMuralModels(
|
|
120
|
+
node: Record<string, unknown>,
|
|
121
|
+
path: string,
|
|
122
|
+
removed: string[],
|
|
123
|
+
): void {
|
|
124
|
+
for (const [key, value] of Object.entries(node)) {
|
|
125
|
+
if (key === "mural" && isPlainObject(value) && "model" in value) {
|
|
126
|
+
delete value.model;
|
|
127
|
+
removed.push(`${path}.${key}.model`);
|
|
128
|
+
} else if (isPlainObject(value)) {
|
|
129
|
+
stripNestedMuralModels(value, `${path}.${key}`, removed);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
67
134
|
function isValidPercentageThreshold(value: unknown): value is number {
|
|
68
135
|
return typeof value === "number" && Number.isFinite(value) && value >= 20 && value <= 80;
|
|
69
136
|
}
|
|
@@ -213,17 +280,22 @@ function makeProjectThresholdWarning(field: string, reason: string): string {
|
|
|
213
280
|
* - `transform_mode` is intentionally allowed at project tier so a repository
|
|
214
281
|
* can opt its own runtime into the experimental Rust pipeline. The resolver
|
|
215
282
|
* requires trusted user-level `subc` configuration before Rust can activate.
|
|
216
|
-
* -
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
283
|
+
* - historian model-resolution fields (model, fallback_models, variant,
|
|
284
|
+
* thinking_level), including both per-harness blocks — historian model
|
|
285
|
+
* spend is user-level only. Qualifiers merge onto the user's historian
|
|
286
|
+
* model at resolve time, so a cloned repo cannot force extra thinking or
|
|
287
|
+
* variant cost.
|
|
288
|
+
* - `mural.model` at the top-level block, the legacy experimental spelling,
|
|
289
|
+
* and any nested `mural.model` under hidden agents — a cloned repo cannot
|
|
290
|
+
* choose where project memory is sent.
|
|
220
291
|
* - `pi.subagent_extensions` — a cloned repo must not choose which extensions
|
|
221
292
|
* the user's Pi child processes load.
|
|
222
293
|
* - `prompt_surface.guidance_override_path` / `tool_descriptions` — a repository
|
|
223
294
|
* may select a reviewed preset, but must not inject arbitrary guidance or tool
|
|
224
295
|
* description text into the user's provider-visible prompt.
|
|
225
|
-
* - hidden-agent `prompt`/`permission`/`tools`
|
|
226
|
-
*
|
|
296
|
+
* - hidden-agent `prompt`/`permission`/`tools`/`system_prompt` at the agent
|
|
297
|
+
* root, each harness block, each task block, and inside model/fallback
|
|
298
|
+
* entry objects — a repo must not reprogram or re-permission hidden agents.
|
|
227
299
|
*/
|
|
228
300
|
export function stripUnsafeProjectConfigFields(projectRaw: Record<string, unknown>): string[] {
|
|
229
301
|
const warnings: string[] = [];
|
|
@@ -356,6 +428,49 @@ export function stripUnsafeProjectConfigFields(projectRaw: Record<string, unknow
|
|
|
356
428
|
}
|
|
357
429
|
}
|
|
358
430
|
|
|
431
|
+
for (const agentKey of HIDDEN_AGENT_KEYS) {
|
|
432
|
+
const block = projectRaw[agentKey];
|
|
433
|
+
if (!isPlainObject(block)) continue;
|
|
434
|
+
const removed: string[] = [];
|
|
435
|
+
stripEscalationAtExecutableSite(block, agentKey, removed);
|
|
436
|
+
for (const harness of HARNESS_KEYS) {
|
|
437
|
+
const harnessBlock = block[harness];
|
|
438
|
+
if (!isPlainObject(harnessBlock)) continue;
|
|
439
|
+
stripEscalationAtExecutableSite(harnessBlock, `${agentKey}.${harness}`, removed);
|
|
440
|
+
const tasks = harnessBlock.tasks;
|
|
441
|
+
if (isPlainObject(tasks)) {
|
|
442
|
+
for (const [taskName, taskBlock] of Object.entries(tasks)) {
|
|
443
|
+
if (isPlainObject(taskBlock)) {
|
|
444
|
+
stripEscalationAtExecutableSite(
|
|
445
|
+
taskBlock,
|
|
446
|
+
`${agentKey}.${harness}.tasks.${taskName}`,
|
|
447
|
+
removed,
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
const schedulingTasks = block.tasks;
|
|
454
|
+
if (isPlainObject(schedulingTasks)) {
|
|
455
|
+
for (const [taskName, taskBlock] of Object.entries(schedulingTasks)) {
|
|
456
|
+
if (isPlainObject(taskBlock)) {
|
|
457
|
+
stripListedFields(
|
|
458
|
+
taskBlock,
|
|
459
|
+
AGENT_ESCALATION_FIELDS,
|
|
460
|
+
`${agentKey}.tasks.${taskName}`,
|
|
461
|
+
removed,
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (removed.length > 0) {
|
|
467
|
+
warnings.push(
|
|
468
|
+
`Ignoring ${removed.join(", ")} from project config ` +
|
|
469
|
+
"(security: a repository cannot reprogram or re-permission hidden agents).",
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
359
474
|
const historian = projectRaw.historian;
|
|
360
475
|
if (isPlainObject(historian)) {
|
|
361
476
|
const removed: string[] = [];
|
|
@@ -365,9 +480,19 @@ export function stripUnsafeProjectConfigFields(projectRaw: Record<string, unknow
|
|
|
365
480
|
removed.push(field);
|
|
366
481
|
}
|
|
367
482
|
}
|
|
483
|
+
for (const harness of HARNESS_KEYS) {
|
|
484
|
+
const harnessBlock = historian[harness];
|
|
485
|
+
if (!isPlainObject(harnessBlock)) continue;
|
|
486
|
+
for (const field of HISTORIAN_USER_ONLY_FIELDS) {
|
|
487
|
+
if (field in harnessBlock) {
|
|
488
|
+
delete harnessBlock[field];
|
|
489
|
+
removed.push(`${harness}.${field}`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
368
493
|
if (removed.length > 0) {
|
|
369
494
|
warnings.push(
|
|
370
|
-
`Ignoring historian.${
|
|
495
|
+
`Ignoring ${removed.map((path) => `historian.${path}`).join(", ")} from project config ` +
|
|
371
496
|
"(security: historian model selection is user-level only; a repository cannot force extra compaction cost).",
|
|
372
497
|
);
|
|
373
498
|
}
|
|
@@ -394,22 +519,16 @@ export function stripUnsafeProjectConfigFields(projectRaw: Record<string, unknow
|
|
|
394
519
|
);
|
|
395
520
|
}
|
|
396
521
|
|
|
522
|
+
const nestedMuralRemoved: string[] = [];
|
|
397
523
|
for (const agentKey of HIDDEN_AGENT_KEYS) {
|
|
398
524
|
const block = projectRaw[agentKey];
|
|
399
525
|
if (!isPlainObject(block)) continue;
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
}
|
|
407
|
-
if (removed.length > 0) {
|
|
408
|
-
warnings.push(
|
|
409
|
-
`Ignoring ${agentKey}.${removed.join("/")} from project config ` +
|
|
410
|
-
"(security: a repository cannot reprogram or re-permission hidden agents).",
|
|
411
|
-
);
|
|
412
|
-
}
|
|
526
|
+
stripNestedMuralModels(block, agentKey, nestedMuralRemoved);
|
|
527
|
+
}
|
|
528
|
+
if (nestedMuralRemoved.length > 0) {
|
|
529
|
+
warnings.push(
|
|
530
|
+
`Ignoring ${nestedMuralRemoved.join(", ")} from project config (security: the mural cue-compressor model is a user-level setting; a repository cannot choose where project memory is sent).`,
|
|
531
|
+
);
|
|
413
532
|
}
|
|
414
533
|
|
|
415
534
|
return warnings;
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
statSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
import { parseJsonc } from "../shared/jsonc-parser";
|
|
15
|
+
import { hasFlatKeys, loadRawConfigFile, migrateFlatDetailed } from "./raw-loader";
|
|
16
|
+
import { PER_HARNESS_MIGRATION_INVENTORY } from "./schema/magic-context";
|
|
17
|
+
|
|
18
|
+
const temporaryDirectories: string[] = [];
|
|
19
|
+
|
|
20
|
+
function temporaryDirectory(): string {
|
|
21
|
+
const directory = mkdtempSync(join(tmpdir(), "mc-per-harness-"));
|
|
22
|
+
temporaryDirectories.push(directory);
|
|
23
|
+
return directory;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parse(text: string): Record<string, unknown> {
|
|
27
|
+
const withoutBom = text.startsWith("\uFEFF") ? text.slice(1) : text;
|
|
28
|
+
return parseJsonc<Record<string, unknown>>(withoutBom);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
for (const directory of temporaryDirectories.splice(0)) {
|
|
33
|
+
rmSync(directory, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("per-harness raw config migration", () => {
|
|
38
|
+
it("maps flat agent and task fields without moving scheduling fields", () => {
|
|
39
|
+
const input = `\uFEFF{\r
|
|
40
|
+
// preserve this comment\r
|
|
41
|
+
"historian": {\r
|
|
42
|
+
"model": { "model": "provider/historian", "variant": "fast", "thinking_level": "high" }, // historian model note\r
|
|
43
|
+
"fallback_models": [{ "model": "provider/fallback", "variant": "careful", "thinking_level": "low" }],\r
|
|
44
|
+
"variant": "block-variant",\r
|
|
45
|
+
"thinking_level": "medium"\r
|
|
46
|
+
},\r
|
|
47
|
+
"dreamer": {\r
|
|
48
|
+
"model": "provider/dreamer",\r
|
|
49
|
+
"fallback_models": "provider/dreamer-fallback",\r
|
|
50
|
+
"tasks": {\r
|
|
51
|
+
"review": {\r
|
|
52
|
+
"schedule": "0 3 * * *",\r
|
|
53
|
+
"model": { "model": "provider/task", "variant": "task-variant", "thinking_level": "max" },\r
|
|
54
|
+
"thinking_level": "high",\r
|
|
55
|
+
"timeout_minutes": 45\r
|
|
56
|
+
},\r
|
|
57
|
+
"timeout-only": { "timeout_minutes": 15 }\r
|
|
58
|
+
}\r
|
|
59
|
+
},\r
|
|
60
|
+
"unrelated": { "keep": true }\r
|
|
61
|
+
}\r
|
|
62
|
+
`;
|
|
63
|
+
|
|
64
|
+
const migrated = migrateFlatDetailed(Buffer.from(input, "utf-8"));
|
|
65
|
+
const repeated = migrateFlatDetailed(Buffer.from(input, "utf-8"));
|
|
66
|
+
const idempotent = migrateFlatDetailed(migrated.bytes);
|
|
67
|
+
const output = migrated.bytes.toString("utf-8");
|
|
68
|
+
const config = parse(output);
|
|
69
|
+
const historian = config.historian as Record<string, unknown>;
|
|
70
|
+
const dreamer = config.dreamer as Record<string, unknown>;
|
|
71
|
+
const opencodeHistorian = historian.opencode as Record<string, unknown>;
|
|
72
|
+
const piHistorian = historian.pi as Record<string, unknown>;
|
|
73
|
+
const opencodeDreamer = dreamer.opencode as Record<string, unknown>;
|
|
74
|
+
const piDreamer = dreamer.pi as Record<string, unknown>;
|
|
75
|
+
const tasks = dreamer.tasks as Record<string, Record<string, unknown>>;
|
|
76
|
+
|
|
77
|
+
expect(migrated.hasFlatKeys).toBe(true);
|
|
78
|
+
expect(repeated.bytes).toEqual(migrated.bytes);
|
|
79
|
+
expect(repeated.diagnostics).toEqual(migrated.diagnostics);
|
|
80
|
+
expect(idempotent.bytes).toEqual(migrated.bytes);
|
|
81
|
+
expect(idempotent.hasFlatKeys).toBe(false);
|
|
82
|
+
expect(idempotent.diagnostics).toEqual([]);
|
|
83
|
+
expect(hasFlatKeys(migrated.bytes)).toBe(false);
|
|
84
|
+
expect(output.startsWith("\uFEFF")).toBe(true);
|
|
85
|
+
expect(output).toContain("// preserve this comment");
|
|
86
|
+
expect(output).toContain("// historian model note");
|
|
87
|
+
expect(output).toContain("\r\n");
|
|
88
|
+
expect(config.unrelated).toEqual({ keep: true });
|
|
89
|
+
expect(historian.model).toBeUndefined();
|
|
90
|
+
expect(opencodeHistorian).toEqual({
|
|
91
|
+
model: { model: "provider/historian", variant: "fast" },
|
|
92
|
+
fallback_models: [{ model: "provider/fallback", variant: "careful" }],
|
|
93
|
+
variant: "block-variant",
|
|
94
|
+
});
|
|
95
|
+
expect(piHistorian).toEqual({
|
|
96
|
+
model: { model: "provider/historian", thinking_level: "high" },
|
|
97
|
+
fallback_models: [{ model: "provider/fallback", thinking_level: "low" }],
|
|
98
|
+
thinking_level: "medium",
|
|
99
|
+
});
|
|
100
|
+
expect(opencodeDreamer.fallback_models).toEqual(["provider/dreamer-fallback"]);
|
|
101
|
+
expect(piDreamer.fallback_models).toEqual(["provider/dreamer-fallback"]);
|
|
102
|
+
expect(tasks.review).toEqual({ schedule: "0 3 * * *" });
|
|
103
|
+
expect(tasks["timeout-only"]).toEqual({});
|
|
104
|
+
expect((opencodeDreamer.tasks as Record<string, Record<string, unknown>>).review).toEqual({
|
|
105
|
+
model: { model: "provider/task", variant: "task-variant" },
|
|
106
|
+
timeout_minutes: 45,
|
|
107
|
+
});
|
|
108
|
+
expect((piDreamer.tasks as Record<string, Record<string, unknown>>).review).toEqual({
|
|
109
|
+
model: { model: "provider/task", thinking_level: "max" },
|
|
110
|
+
thinking_level: "high",
|
|
111
|
+
timeout_minutes: 45,
|
|
112
|
+
});
|
|
113
|
+
expect(
|
|
114
|
+
(opencodeDreamer.tasks as Record<string, Record<string, unknown>>)["timeout-only"],
|
|
115
|
+
).toEqual({ timeout_minutes: 15 });
|
|
116
|
+
expect(
|
|
117
|
+
(piDreamer.tasks as Record<string, Record<string, unknown>>)["timeout-only"],
|
|
118
|
+
).toEqual({ timeout_minutes: 15 });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("turns a singleton fallback into one entry without changing its spelling", () => {
|
|
122
|
+
const migrated = migrateFlatDetailed(
|
|
123
|
+
Buffer.from(
|
|
124
|
+
JSON.stringify({
|
|
125
|
+
historian: { fallback_models: "Vendor/Model:Exact-Spelling" },
|
|
126
|
+
}),
|
|
127
|
+
),
|
|
128
|
+
);
|
|
129
|
+
const historian = parse(migrated.bytes.toString("utf-8")).historian as Record<
|
|
130
|
+
string,
|
|
131
|
+
Record<string, unknown>
|
|
132
|
+
>;
|
|
133
|
+
|
|
134
|
+
expect(historian.opencode.fallback_models).toEqual(["Vendor/Model:Exact-Spelling"]);
|
|
135
|
+
expect(historian.pi.fallback_models).toEqual(["Vendor/Model:Exact-Spelling"]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("preserves every schema-enumerated retained field at its original level", () => {
|
|
139
|
+
const historianRetained = {
|
|
140
|
+
temperature: 0.1,
|
|
141
|
+
top_p: 0.9,
|
|
142
|
+
prompt: "historian prompt",
|
|
143
|
+
tools: { read: true },
|
|
144
|
+
disable: false,
|
|
145
|
+
description: "historian description",
|
|
146
|
+
mode: "subagent",
|
|
147
|
+
color: "blue",
|
|
148
|
+
maxSteps: 11,
|
|
149
|
+
permission: { edit: "deny" },
|
|
150
|
+
maxTokens: 4096,
|
|
151
|
+
two_pass: true,
|
|
152
|
+
disallowed_tools: ["bash"],
|
|
153
|
+
};
|
|
154
|
+
const dreamerRetained = {
|
|
155
|
+
temperature: 0.2,
|
|
156
|
+
top_p: 0.8,
|
|
157
|
+
prompt: "dreamer prompt",
|
|
158
|
+
tools: { write: true },
|
|
159
|
+
disable: false,
|
|
160
|
+
description: "dreamer description",
|
|
161
|
+
mode: "subagent",
|
|
162
|
+
color: "purple",
|
|
163
|
+
maxSteps: 12,
|
|
164
|
+
permission: { bash: "deny" },
|
|
165
|
+
maxTokens: 8192,
|
|
166
|
+
inject_docs: false,
|
|
167
|
+
};
|
|
168
|
+
const taskRetained = {
|
|
169
|
+
schedule: "0 3 * * *",
|
|
170
|
+
promotion_threshold: 4,
|
|
171
|
+
};
|
|
172
|
+
expect(Object.keys(historianRetained)).toEqual(
|
|
173
|
+
PER_HARNESS_MIGRATION_INVENTORY.historian.retained,
|
|
174
|
+
);
|
|
175
|
+
expect(Object.keys(dreamerRetained)).toEqual(
|
|
176
|
+
PER_HARNESS_MIGRATION_INVENTORY.dreamer.retained,
|
|
177
|
+
);
|
|
178
|
+
expect(Object.keys(taskRetained)).toEqual(PER_HARNESS_MIGRATION_INVENTORY.task.retained);
|
|
179
|
+
|
|
180
|
+
const migrated = migrateFlatDetailed(
|
|
181
|
+
Buffer.from(
|
|
182
|
+
JSON.stringify({
|
|
183
|
+
historian: { ...historianRetained, model: "provider/historian" },
|
|
184
|
+
dreamer: {
|
|
185
|
+
...dreamerRetained,
|
|
186
|
+
model: "provider/dreamer",
|
|
187
|
+
tasks: {
|
|
188
|
+
review: {
|
|
189
|
+
...taskRetained,
|
|
190
|
+
model: "provider/task",
|
|
191
|
+
timeout_minutes: 25,
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
}),
|
|
196
|
+
),
|
|
197
|
+
);
|
|
198
|
+
const config = parse(migrated.bytes.toString("utf-8"));
|
|
199
|
+
const historian = config.historian as Record<string, unknown>;
|
|
200
|
+
const dreamer = config.dreamer as Record<string, unknown>;
|
|
201
|
+
const task = (dreamer.tasks as Record<string, Record<string, unknown>>).review;
|
|
202
|
+
|
|
203
|
+
for (const [field, value] of Object.entries(historianRetained)) {
|
|
204
|
+
expect(historian[field]).toEqual(value);
|
|
205
|
+
}
|
|
206
|
+
for (const [field, value] of Object.entries(dreamerRetained)) {
|
|
207
|
+
expect(dreamer[field]).toEqual(value);
|
|
208
|
+
}
|
|
209
|
+
expect(task).toEqual(taskRetained);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("fills absent destinations and silently removes equal flat sources", () => {
|
|
213
|
+
const migrated = migrateFlatDetailed(
|
|
214
|
+
Buffer.from(`{
|
|
215
|
+
"historian": {
|
|
216
|
+
"model": "provider/same",
|
|
217
|
+
"opencode": { "model": "provider/same" },
|
|
218
|
+
"pi": {}
|
|
219
|
+
}
|
|
220
|
+
}`),
|
|
221
|
+
);
|
|
222
|
+
const historian = parse(migrated.bytes.toString("utf-8")).historian as Record<
|
|
223
|
+
string,
|
|
224
|
+
unknown
|
|
225
|
+
>;
|
|
226
|
+
|
|
227
|
+
expect(historian.model).toBeUndefined();
|
|
228
|
+
expect(historian.opencode).toEqual({ model: "provider/same" });
|
|
229
|
+
expect(historian.pi).toEqual({ model: "provider/same" });
|
|
230
|
+
expect(migrated.diagnostics).toEqual([]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("keeps new-shape destinations on conflicts and names both paths in warnings", () => {
|
|
234
|
+
const input = `{
|
|
235
|
+
"historian": {
|
|
236
|
+
"model": "provider/flat",
|
|
237
|
+
"fallback_models": "provider/fallback",
|
|
238
|
+
"opencode": { "model": "provider/opencode", "fallback_models": ["provider/fallback"] },
|
|
239
|
+
"pi": { "model": "provider/pi", "fallback_models": ["provider/fallback"] }
|
|
240
|
+
}
|
|
241
|
+
}`;
|
|
242
|
+
|
|
243
|
+
const migrated = migrateFlatDetailed(Buffer.from(input));
|
|
244
|
+
const config = parse(migrated.bytes.toString("utf-8"));
|
|
245
|
+
const historian = config.historian as Record<string, unknown>;
|
|
246
|
+
|
|
247
|
+
expect(historian.model).toBeUndefined();
|
|
248
|
+
expect(historian.fallback_models).toBeUndefined();
|
|
249
|
+
expect(historian.opencode).toEqual({
|
|
250
|
+
model: "provider/opencode",
|
|
251
|
+
fallback_models: ["provider/fallback"],
|
|
252
|
+
});
|
|
253
|
+
expect(historian.pi).toEqual({
|
|
254
|
+
model: "provider/pi",
|
|
255
|
+
fallback_models: ["provider/fallback"],
|
|
256
|
+
});
|
|
257
|
+
expect(migrated.diagnostics.map((diagnostic) => diagnostic.path)).toEqual([
|
|
258
|
+
"historian.model",
|
|
259
|
+
"historian.model",
|
|
260
|
+
]);
|
|
261
|
+
expect(migrated.diagnostics[0]?.message).toContain("historian.opencode.model");
|
|
262
|
+
expect(migrated.diagnostics[1]?.message).toContain("historian.pi.model");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("processes flat keys re-added after migration while existing blocks keep winning", () => {
|
|
266
|
+
const first = migrateFlatDetailed(
|
|
267
|
+
Buffer.from(JSON.stringify({ historian: { model: "provider/original" } })),
|
|
268
|
+
);
|
|
269
|
+
const reintroduced = parse(first.bytes.toString("utf-8"));
|
|
270
|
+
(reintroduced.historian as Record<string, unknown>).model = "provider/re-added";
|
|
271
|
+
|
|
272
|
+
const second = migrateFlatDetailed(Buffer.from(JSON.stringify(reintroduced)));
|
|
273
|
+
const historian = parse(second.bytes.toString("utf-8")).historian as Record<
|
|
274
|
+
string,
|
|
275
|
+
unknown
|
|
276
|
+
>;
|
|
277
|
+
|
|
278
|
+
expect(historian.model).toBeUndefined();
|
|
279
|
+
expect(historian.opencode).toEqual({ model: "provider/original" });
|
|
280
|
+
expect(historian.pi).toEqual({ model: "provider/original" });
|
|
281
|
+
expect(second.diagnostics.map((diagnostic) => diagnostic.message)).toEqual([
|
|
282
|
+
expect.stringContaining("historian.opencode.model"),
|
|
283
|
+
expect.stringContaining("historian.pi.model"),
|
|
284
|
+
]);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("rewrites user config atomically with one exact-byte restricted backup", () => {
|
|
288
|
+
const directory = temporaryDirectory();
|
|
289
|
+
const configPath = join(directory, "magic-context.jsonc");
|
|
290
|
+
const original = Buffer.from(
|
|
291
|
+
'{\n "dreamer": { "tasks": { "review": { "timeout_minutes": 30 } } }\n}\n',
|
|
292
|
+
);
|
|
293
|
+
writeFileSync(configPath, original);
|
|
294
|
+
chmodSync(configPath, 0o600);
|
|
295
|
+
const expected = migrateFlatDetailed(original).bytes;
|
|
296
|
+
let temporaryNamesDuringWrite: string[] = [];
|
|
297
|
+
|
|
298
|
+
const first = loadRawConfigFile({
|
|
299
|
+
configPath,
|
|
300
|
+
tier: "user",
|
|
301
|
+
afterTemporaryWrite: () => {
|
|
302
|
+
expect(readFileSync(configPath)).toEqual(original);
|
|
303
|
+
temporaryNamesDuringWrite = readdirSync(directory).filter((name) =>
|
|
304
|
+
name.endsWith(".tmp"),
|
|
305
|
+
);
|
|
306
|
+
expect(temporaryNamesDuringWrite).toHaveLength(1);
|
|
307
|
+
expect(
|
|
308
|
+
readFileSync(join(directory, temporaryNamesDuringWrite[0] as string)),
|
|
309
|
+
).toEqual(expected);
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
const second = loadRawConfigFile({ configPath, tier: "user" });
|
|
313
|
+
const backupPath = `${configPath}.pre-per-harness.bak`;
|
|
314
|
+
|
|
315
|
+
expect(first?.migrated).toBe(true);
|
|
316
|
+
expect(first?.warnings).toContain(
|
|
317
|
+
"Migrated flat historian/dreamer model config to per-harness blocks.",
|
|
318
|
+
);
|
|
319
|
+
expect(readFileSync(backupPath)).toEqual(original);
|
|
320
|
+
expect(readFileSync(configPath)).toEqual(first?.bytes);
|
|
321
|
+
expect(temporaryNamesDuringWrite).toHaveLength(1);
|
|
322
|
+
expect(readdirSync(directory).filter((name) => name.endsWith(".tmp"))).toEqual([]);
|
|
323
|
+
expect(statSync(configPath).mode & 0o777).toBe(0o600);
|
|
324
|
+
expect(statSync(backupPath).mode & 0o777).toBe(0o600);
|
|
325
|
+
expect(second?.migrated).toBe(false);
|
|
326
|
+
expect(second?.warnings).toEqual([]);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("repairs a truncated migration backup left by the direct-write protocol", () => {
|
|
330
|
+
const directory = temporaryDirectory();
|
|
331
|
+
const configPath = join(directory, "magic-context.jsonc");
|
|
332
|
+
const backupPath = `${configPath}.pre-per-harness.bak`;
|
|
333
|
+
const original = Buffer.from('{ "historian": { "model": "provider/model" } }\n');
|
|
334
|
+
writeFileSync(configPath, original);
|
|
335
|
+
writeFileSync(backupPath, original.subarray(0, 17));
|
|
336
|
+
|
|
337
|
+
const loaded = loadRawConfigFile({ configPath, tier: "user" });
|
|
338
|
+
|
|
339
|
+
expect(loaded?.migrated).toBe(true);
|
|
340
|
+
expect(readFileSync(backupPath)).toEqual(original);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("leaves a completed migration backup with different bytes untouched", () => {
|
|
344
|
+
const directory = temporaryDirectory();
|
|
345
|
+
const configPath = join(directory, "magic-context.jsonc");
|
|
346
|
+
const backupPath = `${configPath}.pre-per-harness.bak`;
|
|
347
|
+
const sentinel = Buffer.from("not config: backup is opaque and permanent\n");
|
|
348
|
+
writeFileSync(configPath, '{ "historian": { "model": "provider/model" } }');
|
|
349
|
+
writeFileSync(backupPath, sentinel);
|
|
350
|
+
|
|
351
|
+
const first = loadRawConfigFile({ configPath, tier: "user" });
|
|
352
|
+
const second = loadRawConfigFile({ configPath, tier: "user" });
|
|
353
|
+
|
|
354
|
+
expect(first?.migrated).toBe(true);
|
|
355
|
+
expect(second?.migrated).toBe(false);
|
|
356
|
+
expect(readFileSync(backupPath)).toEqual(sentinel);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("accepts a matching migration backup without rewriting it", () => {
|
|
360
|
+
const directory = temporaryDirectory();
|
|
361
|
+
const configPath = join(directory, "magic-context.jsonc");
|
|
362
|
+
const backupPath = `${configPath}.pre-per-harness.bak`;
|
|
363
|
+
const original = Buffer.from('{ "historian": { "model": "provider/model" } }\n');
|
|
364
|
+
writeFileSync(configPath, original);
|
|
365
|
+
writeFileSync(backupPath, original);
|
|
366
|
+
const backupInode = statSync(backupPath).ino;
|
|
367
|
+
|
|
368
|
+
const loaded = loadRawConfigFile({ configPath, tier: "user" });
|
|
369
|
+
|
|
370
|
+
expect(loaded?.migrated).toBe(true);
|
|
371
|
+
expect(readFileSync(backupPath)).toEqual(original);
|
|
372
|
+
expect(statSync(backupPath).ino).toBe(backupInode);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("reloads the winning candidate when a concurrent loader replaces the target", () => {
|
|
376
|
+
const directory = temporaryDirectory();
|
|
377
|
+
const configPath = join(directory, "magic-context.jsonc");
|
|
378
|
+
const original = Buffer.from('{\n "historian": { "model": "provider/model" }\n}\n');
|
|
379
|
+
writeFileSync(configPath, original);
|
|
380
|
+
|
|
381
|
+
let competing: ReturnType<typeof loadRawConfigFile> | undefined;
|
|
382
|
+
const first = loadRawConfigFile({
|
|
383
|
+
configPath,
|
|
384
|
+
tier: "user",
|
|
385
|
+
afterTemporaryWrite: () => {
|
|
386
|
+
competing ??= loadRawConfigFile({ configPath, tier: "user" });
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
expect([first?.migrated, competing?.migrated].filter(Boolean)).toHaveLength(1);
|
|
391
|
+
expect(competing?.migrated).toBe(true);
|
|
392
|
+
expect(first?.migrated).toBe(false);
|
|
393
|
+
expect(first?.warnings).toEqual([]);
|
|
394
|
+
expect(competing?.warnings).toEqual([
|
|
395
|
+
"Migrated flat historian/dreamer model config to per-harness blocks.",
|
|
396
|
+
]);
|
|
397
|
+
expect(first?.bytes).toEqual(competing?.bytes);
|
|
398
|
+
expect(parse(first?.text ?? "{}")).toEqual(parse(competing?.text ?? "{}"));
|
|
399
|
+
expect(readFileSync(configPath)).toEqual(competing?.bytes);
|
|
400
|
+
expect(readFileSync(`${configPath}.pre-per-harness.bak`)).toEqual(original);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("adapts project config in memory without changing bytes or mtime", () => {
|
|
404
|
+
const directory = temporaryDirectory();
|
|
405
|
+
const configPath = join(directory, "magic-context.jsonc");
|
|
406
|
+
const original = Buffer.from(
|
|
407
|
+
'{\n "dreamer": { "tasks": { "review": { "timeout_minutes": 30 } } }\n}\n',
|
|
408
|
+
);
|
|
409
|
+
writeFileSync(configPath, original);
|
|
410
|
+
const before = statSync(configPath);
|
|
411
|
+
|
|
412
|
+
const loaded = loadRawConfigFile({ configPath, tier: "project" });
|
|
413
|
+
const after = statSync(configPath);
|
|
414
|
+
const config = parse(loaded?.text ?? "{}");
|
|
415
|
+
|
|
416
|
+
expect(loaded?.migrated).toBe(false);
|
|
417
|
+
expect(loaded?.warnings[0]).toContain("Adapted flat model config in memory");
|
|
418
|
+
expect(readFileSync(configPath)).toEqual(original);
|
|
419
|
+
expect(after.mtimeMs).toBe(before.mtimeMs);
|
|
420
|
+
expect(
|
|
421
|
+
((config.dreamer as Record<string, unknown>).opencode as Record<string, unknown>).tasks,
|
|
422
|
+
).toEqual({ review: { timeout_minutes: 30 } });
|
|
423
|
+
});
|
|
424
|
+
});
|