diff --git a/packages/cli/src/commands/doctor-opencode.test.ts b/packages/cli/src/commands/doctor-opencode.test.ts index 155d0995..28f04731 100644 --- a/packages/cli/src/commands/doctor-opencode.test.ts +++ b/packages/cli/src/commands/doctor-opencode.test.ts @@ -15,6 +15,7 @@ import { } from "../lib/opencode-plugin-cache"; import { runV22BackfillCommands } from "../lib/v22-backfill-commands"; import { + checkUserMemoriesDreamerCompatibility, collectNpmReleaseAgeWarnings, getUserNpmrcPath, isPinnedOpenCodePluginSpecifier, @@ -79,6 +80,79 @@ describe("doctor OpenCode legacy agent enabled migration", () => { }); }); +describe("checkUserMemoriesDreamerCompatibility", () => { + const WARNING = + 'dreamer.tasks["review-user-memories"] is scheduled but dreamer.disable=true, so new promotions will not run. Remove dreamer.disable or set dreamer.tasks["review-user-memories"].schedule="" to disable the task.'; + + it("warns when review-user-memories is scheduled and dreamer.disable=true", () => { + const result = checkUserMemoriesDreamerCompatibility({ + dreamer: { + disable: true, + tasks: { "review-user-memories": { schedule: "0 2 * * *" } }, + }, + }); + expect(result).toBe(WARNING); + }); + + it("returns null when dreamer is not disabled", () => { + const result = checkUserMemoriesDreamerCompatibility({ + dreamer: { + disable: false, + tasks: { "review-user-memories": { schedule: "0 2 * * *" } }, + }, + }); + expect(result).toBeNull(); + }); + + it("returns null when review-user-memories schedule is empty", () => { + const result = checkUserMemoriesDreamerCompatibility({ + dreamer: { + disable: true, + tasks: { "review-user-memories": { schedule: "" } }, + }, + }); + expect(result).toBeNull(); + }); + + it("returns null when review-user-memories schedule is whitespace-only", () => { + const result = checkUserMemoriesDreamerCompatibility({ + dreamer: { + disable: true, + tasks: { "review-user-memories": { schedule: " " } }, + }, + }); + expect(result).toBeNull(); + }); + + it("returns null when review-user-memories task is absent", () => { + const result = checkUserMemoriesDreamerCompatibility({ + dreamer: { disable: true, tasks: { verify: { schedule: "0 2 * * *" } } }, + }); + expect(result).toBeNull(); + }); + + it("returns null when dreamer block is absent", () => { + expect(checkUserMemoriesDreamerCompatibility({})).toBeNull(); + }); + + it("returns null when tasks block is absent (legacy v1 shape without user_memories)", () => { + const result = checkUserMemoriesDreamerCompatibility({ + dreamer: { disable: true }, + }); + expect(result).toBeNull(); + }); + + it("does not read legacy dreamer.user_memories (v1 key, migrated away in v2)", () => { + const result = checkUserMemoriesDreamerCompatibility({ + dreamer: { + disable: true, + user_memories: { enabled: true }, + }, + }); + expect(result).toBeNull(); + }); +}); + const tempDirs: string[] = []; const dbs: Database[] = []; let originalXdgCacheHome: string | undefined; diff --git a/packages/cli/src/commands/doctor-opencode.ts b/packages/cli/src/commands/doctor-opencode.ts index afa74e95..ef814d00 100644 --- a/packages/cli/src/commands/doctor-opencode.ts +++ b/packages/cli/src/commands/doctor-opencode.ts @@ -107,6 +107,25 @@ export function migrateLegacyAgentEnabledConfigForDoctor( return { changed, fixes }; } +/** + * Check whether the `review-user-memories` dreamer task is scheduled while the + * dreamer itself is disabled, a no-op combination where candidate promotions + * will never run. In v2, user-memory collection is gated by the task schedule + * (non-empty = enabled), replacing the v1 `dreamer.user_memories` block. + * Returns the warning message when the combination is wrong, or null otherwise. + */ +export function checkUserMemoriesDreamerCompatibility( + mcConfig: Record, +): string | null { + const dreamerObj = mcConfig?.dreamer as Record | undefined; + if (dreamerObj?.disable !== true) return null; + const tasksObj = dreamerObj.tasks as Record | undefined; + const reviewTask = tasksObj?.["review-user-memories"] as Record | undefined; + const schedule = reviewTask?.schedule; + if (typeof schedule !== "string" || schedule.trim() === "") return null; + return 'dreamer.tasks["review-user-memories"] is scheduled but dreamer.disable=true, so new promotions will not run. Remove dreamer.disable or set dreamer.tasks["review-user-memories"].schedule="" to disable the task.'; +} + /** * Fetch the latest version of an npm package from the registry. Returns null * on any error so the doctor can report "check unavailable" rather than fail. @@ -1042,23 +1061,17 @@ export async function runDoctor( } // 7. Check user memories + dreamer compatibility. - // user_memories graduated from experimental to dreamer.user_memories in - // v0.14, and the default is now enabled. Candidate extraction still - // requires dreamer to actually promote candidates into stable memories, + // In v2, user-memory collection is gated by the `review-user-memories` task + // schedule (non-empty = enabled), replacing the v1 `dreamer.user_memories` + // block. The task needs the dreamer to actually run to promote candidates, // so warn loudly when the combination is wrong. if (existsSync(paths.magicContextConfig)) { try { const mcRaw = readFileSync(paths.magicContextConfig, "utf-8"); const mcConfig = parse(mcRaw) as Record; - const dreamerObj = mcConfig?.dreamer as Record | undefined; - const dreamerDisabled = dreamerObj?.disable === true; - const userMemObj = dreamerObj?.user_memories as Record | undefined; - // user_memories defaults to enabled, so treat `undefined` as true. - const userMemEnabled = userMemObj?.enabled !== false; - if (userMemEnabled && dreamerDisabled) { - log.warn( - "dreamer.user_memories is enabled but dreamer.disable=true, so new promotions will not run. Remove dreamer.disable or set dreamer.user_memories.enabled=false.", - ); + const warning = checkUserMemoriesDreamerCompatibility(mcConfig); + if (warning) { + log.warn(warning); issues++; } } catch {