From 61eeeb237cb62ac5552108c2f6906fc61cf0433d Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sun, 17 May 2026 12:26:00 +0000 Subject: [PATCH 1/7] fix(writers-room): align settings stage key with renamed places template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit be903564 renamed writers-room-settings.md → writers-room-places.md but left the stage-config key and two code references pointing at the old name, causing 'Template for writers-room-settings not found' at runtime. Rename the stage-config key + the two server lookups, and add migration 017 to rename the key in existing installs. The broader BIBLE_KIND.SETTING → PLACE rename stays deferred (PLAN.md). --- data.sample/prompts/stage-config.json | 2 +- .../017-rename-writers-room-settings-stage.js | 72 +++++++++++++++++++ server/lib/bibleExtractor.js | 2 +- server/services/writersRoom/evaluator.js | 2 +- 4 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 scripts/migrations/017-rename-writers-room-settings-stage.js diff --git a/data.sample/prompts/stage-config.json b/data.sample/prompts/stage-config.json index 23de2e690..5b2b031c6 100644 --- a/data.sample/prompts/stage-config.json +++ b/data.sample/prompts/stage-config.json @@ -28,7 +28,7 @@ "returnsJson": true, "variables": [] }, - "writers-room-settings": { + "writers-room-places": { "name": "Writers Room — Setting / World Bible", "description": "Extract a setting/world bible from a Writers Room draft: per-location descriptions keyed by screenplay slugline (palette, era, weather, recurring details). Storyboard scenes match their slugline back to a setting and inject its description into the image prompt for visual continuity.", "model": "default", diff --git a/scripts/migrations/017-rename-writers-room-settings-stage.js b/scripts/migrations/017-rename-writers-room-settings-stage.js new file mode 100644 index 000000000..3e68b7118 --- /dev/null +++ b/scripts/migrations/017-rename-writers-room-settings-stage.js @@ -0,0 +1,72 @@ +/** + * Rename the `writers-room-settings` stage key to `writers-room-places` in + * the installed `data/prompts/stage-config.json`. + * + * Background: commit be903564 renamed the prompt file + * `writers-room-settings.md` → `writers-room-places.md` (Universe rename PR) + * but deferred the corresponding stage-key rename. Existing installs that + * upgrade through this commit still have the old `writers-room-settings` + * key in their config — at runtime the prompt service looks up + * `data/prompts/stages/writers-room-settings.md`, finds nothing (the file + * is now `…-places.md`), and throws `Template for writers-room-settings + * not found`. + * + * Idempotent: skips when only `writers-room-places` is present, or when + * neither key exists (fresh installs get the post-rename sample copy). + */ + +import { readFile, writeFile } from 'fs/promises'; +import { join } from 'path'; + +const STAGE_CONFIG_REL_PATH = 'data/prompts/stage-config.json'; + +export default { + async up({ rootDir }) { + const path = join(rootDir, STAGE_CONFIG_REL_PATH); + const raw = await readFile(path, 'utf-8').catch((err) => { + if (err.code === 'ENOENT') return null; + throw err; + }); + if (raw == null) { + console.log(`📄 ${STAGE_CONFIG_REL_PATH} not present — skipping (fresh install will copy from data.sample)`); + return; + } + + let config; + try { + config = JSON.parse(raw); + } catch (err) { + console.log(`⚠️ ${STAGE_CONFIG_REL_PATH}: invalid JSON, skipping migration (${err.message})`); + return; + } + + const stages = config?.stages; + if (!stages || typeof stages !== 'object') { + console.log(`⚠️ ${STAGE_CONFIG_REL_PATH}: no stages map — skipping`); + return; + } + + if (!stages['writers-room-settings']) { + console.log(`✅ ${STAGE_CONFIG_REL_PATH}: already on writers-room-places, no changes`); + return; + } + + // Preserve order: walk keys and emit a fresh stages object with the + // renamed key in the same slot. If the user happens to also have a + // hand-added `writers-room-places` entry, prefer the existing one and + // discard the legacy key. + const renamed = {}; + for (const [key, value] of Object.entries(stages)) { + if (key === 'writers-room-settings') { + if (stages['writers-room-places']) continue; + renamed['writers-room-places'] = value; + } else { + renamed[key] = value; + } + } + config.stages = renamed; + + await writeFile(path, `${JSON.stringify(config, null, 2)}\n`); + console.log(`📝 ${STAGE_CONFIG_REL_PATH}: renamed writers-room-settings → writers-room-places`); + }, +}; diff --git a/server/lib/bibleExtractor.js b/server/lib/bibleExtractor.js index de5b602e7..78ede0521 100644 --- a/server/lib/bibleExtractor.js +++ b/server/lib/bibleExtractor.js @@ -6,7 +6,7 @@ import { sanitizeBibleList, BIBLE_KIND, BIBLE_FIELD, pickPromptFields } from './ const KIND_STAGE = Object.freeze({ [BIBLE_KIND.CHARACTER]: 'writers-room-characters', - [BIBLE_KIND.SETTING]: 'writers-room-settings', + [BIBLE_KIND.SETTING]: 'writers-room-places', [BIBLE_KIND.OBJECT]: 'writers-room-objects', }); diff --git a/server/services/writersRoom/evaluator.js b/server/services/writersRoom/evaluator.js index c750a448f..b0b6c4fa1 100644 --- a/server/services/writersRoom/evaluator.js +++ b/server/services/writersRoom/evaluator.js @@ -27,7 +27,7 @@ const KIND_META = { format: { stage: 'writers-room-format', returnsJson: false }, script: { stage: 'writers-room-script', returnsJson: true }, characters: { stage: 'writers-room-characters', returnsJson: true }, - settings: { stage: 'writers-room-settings', returnsJson: true }, + settings: { stage: 'writers-room-places', returnsJson: true }, objects: { stage: 'writers-room-objects', returnsJson: true }, }; From 2585aa351339373013df9ca3ef878efa800e277e Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sun, 17 May 2026 12:26:38 +0000 Subject: [PATCH 2/7] chore(migrations): renumber writers-room rename to 018 (017 taken) --- ...ettings-stage.js => 018-rename-writers-room-settings-stage.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename scripts/migrations/{017-rename-writers-room-settings-stage.js => 018-rename-writers-room-settings-stage.js} (100%) diff --git a/scripts/migrations/017-rename-writers-room-settings-stage.js b/scripts/migrations/018-rename-writers-room-settings-stage.js similarity index 100% rename from scripts/migrations/017-rename-writers-room-settings-stage.js rename to scripts/migrations/018-rename-writers-room-settings-stage.js From c9fe90fbd3d243dfbff9c0748f2990a6c90913d2 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sun, 17 May 2026 12:32:52 +0000 Subject: [PATCH 3/7] address review: preserve user customizations in migration 018 setup-data.js runs before migrations and auto-seeds writers-room-places with sample defaults when an existing install still has the legacy writers-room-settings key. The original migration kept that auto-seeded entry and discarded the legacy one, silently losing any user-customized model/provider/variables on writers-room-settings. Detect the byte-for-byte sample-default case and prefer the legacy entry's value in that path. When the installed writers-room-places differs from the sample default, treat that as a deliberate user edit and respect it (discard the legacy entry, same as before). Co-Authored-By: Claude Opus 4.7 --- .../018-rename-writers-room-settings-stage.js | 84 ++++++++++++++++--- 1 file changed, 73 insertions(+), 11 deletions(-) diff --git a/scripts/migrations/018-rename-writers-room-settings-stage.js b/scripts/migrations/018-rename-writers-room-settings-stage.js index 3e68b7118..e6d7b4299 100644 --- a/scripts/migrations/018-rename-writers-room-settings-stage.js +++ b/scripts/migrations/018-rename-writers-room-settings-stage.js @@ -11,14 +11,46 @@ * is now `…-places.md`), and throws `Template for writers-room-settings * not found`. * - * Idempotent: skips when only `writers-room-places` is present, or when - * neither key exists (fresh installs get the post-rename sample copy). + * Update flow ordering caveat (Copilot review on PR #265): `setup-data.js` + * runs *before* migrations, and its `JSON_MERGE_TARGETS` merges any new + * sample stage entries that the install is missing — so by the time this + * migration runs, an existing install will typically have BOTH keys: + * • `writers-room-settings` → the user's (possibly customized) entry + * • `writers-room-places` → freshly auto-seeded from data.sample defaults + * Naively keeping the auto-seeded `…-places` entry would silently discard + * any model/provider/variable customizations the user had on `…-settings`. + * + * Resolution: when both keys are present, compare the installed + * `writers-room-places` against the sample default. If it matches exactly, + * it's the auto-seeded one and we replace it with the user's legacy entry + * (preserving customizations). If it differs, the user hand-edited it on + * purpose and we keep it as-is. + * + * Idempotent: skips when only `writers-room-places` is present (and no + * legacy key), or when neither key exists (fresh installs get the + * post-rename sample copy). */ import { readFile, writeFile } from 'fs/promises'; import { join } from 'path'; const STAGE_CONFIG_REL_PATH = 'data/prompts/stage-config.json'; +const SAMPLE_CONFIG_REL_PATH = 'data.sample/prompts/stage-config.json'; +const LEGACY_KEY = 'writers-room-settings'; +const NEW_KEY = 'writers-room-places'; + +const readJsonOrNull = async (path) => { + const raw = await readFile(path, 'utf-8').catch((err) => { + if (err.code === 'ENOENT') return null; + throw err; + }); + if (raw == null) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } +}; export default { async up({ rootDir }) { @@ -46,20 +78,44 @@ export default { return; } - if (!stages['writers-room-settings']) { - console.log(`✅ ${STAGE_CONFIG_REL_PATH}: already on writers-room-places, no changes`); + if (!stages[LEGACY_KEY]) { + console.log(`✅ ${STAGE_CONFIG_REL_PATH}: already on ${NEW_KEY}, no changes`); return; } + // When both keys are present, decide which value to keep. The most + // common case on `npm run setup && npm run migrations` flow is that + // setup-data just auto-seeded `writers-room-places` with sample + // defaults — in that case we must prefer the user's legacy entry so + // their customizations survive. + let prefersLegacyValue = true; + if (stages[NEW_KEY]) { + const sample = await readJsonOrNull(join(rootDir, SAMPLE_CONFIG_REL_PATH)); + const sampleEntry = sample?.stages?.[NEW_KEY]; + if (sampleEntry && JSON.stringify(stages[NEW_KEY]) === JSON.stringify(sampleEntry)) { + // Installed `…-places` is byte-for-byte the sample default → it + // was just auto-seeded by setup-data.js. Replace with the user's + // legacy entry. + prefersLegacyValue = true; + } else { + // User has hand-customized `…-places` (or sample lookup failed). + // Respect that and discard the legacy entry. + prefersLegacyValue = false; + } + } + // Preserve order: walk keys and emit a fresh stages object with the - // renamed key in the same slot. If the user happens to also have a - // hand-added `writers-room-places` entry, prefer the existing one and - // discard the legacy key. + // renamed key in the same slot the legacy key occupied. When both + // keys exist and we're keeping the user's `…-places` entry, drop the + // legacy slot entirely (the existing `…-places` slot stays in place). const renamed = {}; for (const [key, value] of Object.entries(stages)) { - if (key === 'writers-room-settings') { - if (stages['writers-room-places']) continue; - renamed['writers-room-places'] = value; + if (key === LEGACY_KEY) { + if (stages[NEW_KEY] && !prefersLegacyValue) continue; + renamed[NEW_KEY] = value; + } else if (key === NEW_KEY) { + if (prefersLegacyValue && stages[LEGACY_KEY]) continue; + renamed[NEW_KEY] = value; } else { renamed[key] = value; } @@ -67,6 +123,12 @@ export default { config.stages = renamed; await writeFile(path, `${JSON.stringify(config, null, 2)}\n`); - console.log(`📝 ${STAGE_CONFIG_REL_PATH}: renamed writers-room-settings → writers-room-places`); + if (stages[NEW_KEY] && prefersLegacyValue) { + console.log(`📝 ${STAGE_CONFIG_REL_PATH}: replaced auto-seeded ${NEW_KEY} with legacy ${LEGACY_KEY} entry (preserving user customizations)`); + } else if (stages[NEW_KEY] && !prefersLegacyValue) { + console.log(`📝 ${STAGE_CONFIG_REL_PATH}: discarded legacy ${LEGACY_KEY} (user-customized ${NEW_KEY} already present)`); + } else { + console.log(`📝 ${STAGE_CONFIG_REL_PATH}: renamed ${LEGACY_KEY} → ${NEW_KEY}`); + } }, }; From ea661f03fd1aa35019042912166e9e6f5bfae4f8 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sun, 17 May 2026 12:38:33 +0000 Subject: [PATCH 4/7] address review: also migrate writers-room-settings.md prompt file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review pointed out that the previous fix only preserved customizations in the stage-config entry. setup-data.js's ensureSampleContent will also auto-seed data/prompts/stages/writers-room-places.md from data.sample, leaving any user-customized writers-room-settings.md orphaned and unused after the stage-key rename. Mirror the same conflict-resolution policy for the .md template: - legacy file exists, …-places.md matches sample default (auto-seeded), legacy differs from sample → preserve legacy content as …-places.md and warn the user that migration 007's intExt / timeOfDay updates need manual merge. - …-places.md differs from sample → user-customized, keep it as-is. - …-places.md missing → promote legacy file directly. - always remove the now-orphan writers-room-settings.md. Run before the stage-config rename so an orphan prompt file is cleaned up even when the stage-key is already on …-places. Co-Authored-By: Claude Opus 4.7 --- .../018-rename-writers-room-settings-stage.js | 126 +++++++++++++++--- 1 file changed, 110 insertions(+), 16 deletions(-) diff --git a/scripts/migrations/018-rename-writers-room-settings-stage.js b/scripts/migrations/018-rename-writers-room-settings-stage.js index e6d7b4299..725d9cc23 100644 --- a/scripts/migrations/018-rename-writers-room-settings-stage.js +++ b/scripts/migrations/018-rename-writers-room-settings-stage.js @@ -1,6 +1,7 @@ /** * Rename the `writers-room-settings` stage key to `writers-room-places` in - * the installed `data/prompts/stage-config.json`. + * the installed `data/prompts/stage-config.json`, and migrate the matching + * `.md` prompt template. * * Background: commit be903564 renamed the prompt file * `writers-room-settings.md` → `writers-room-places.md` (Universe rename PR) @@ -12,26 +13,39 @@ * not found`. * * Update flow ordering caveat (Copilot review on PR #265): `setup-data.js` - * runs *before* migrations, and its `JSON_MERGE_TARGETS` merges any new - * sample stage entries that the install is missing — so by the time this - * migration runs, an existing install will typically have BOTH keys: - * • `writers-room-settings` → the user's (possibly customized) entry - * • `writers-room-places` → freshly auto-seeded from data.sample defaults - * Naively keeping the auto-seeded `…-places` entry would silently discard - * any model/provider/variable customizations the user had on `…-settings`. + * runs *before* migrations, with two side effects this migration must + * counteract: * - * Resolution: when both keys are present, compare the installed - * `writers-room-places` against the sample default. If it matches exactly, - * it's the auto-seeded one and we replace it with the user's legacy entry - * (preserving customizations). If it differs, the user hand-edited it on - * purpose and we keep it as-is. + * 1. `JSON_MERGE_TARGETS` merges any new sample stage entries — so by the + * time this migration runs, an existing install will typically have + * BOTH stage-config keys: `writers-room-settings` (user's, possibly + * customized) and `writers-room-places` (fresh sample defaults). + * Naively keeping the auto-seeded `…-places` entry would silently + * discard any model/provider/variable customizations the user had on + * `…-settings`. + * + * 2. `ensureSampleContent` copies missing prompt files — so + * `data/prompts/stages/writers-room-places.md` will be auto-seeded + * from data.sample (full post-rename + post-migration-007 content) + * while the user's old `writers-room-settings.md` is left orphaned. + * Switching the stage key to `…-places` makes the runtime use the + * freshly seeded sample template, ignoring any customizations the + * user had in `…-settings.md`. + * + * Resolution for both: when the corresponding `…-places` artifact (entry + * or file) byte-for-byte matches the sample default, treat it as an + * auto-seed and replace it with the legacy artifact's content (preserving + * user customizations). If `…-places` differs from the sample, treat it + * as a deliberate user edit and keep it. The legacy `…-settings.md` file + * is removed once the migration has either preserved its content or + * detected that it was untouched. * * Idempotent: skips when only `writers-room-places` is present (and no - * legacy key), or when neither key exists (fresh installs get the - * post-rename sample copy). + * legacy key/file), or when neither key/file exists (fresh installs get + * the post-rename sample copy). */ -import { readFile, writeFile } from 'fs/promises'; +import { readFile, writeFile, unlink, stat } from 'fs/promises'; import { join } from 'path'; const STAGE_CONFIG_REL_PATH = 'data/prompts/stage-config.json'; @@ -39,6 +53,11 @@ const SAMPLE_CONFIG_REL_PATH = 'data.sample/prompts/stage-config.json'; const LEGACY_KEY = 'writers-room-settings'; const NEW_KEY = 'writers-room-places'; +const PROMPTS_STAGES_DIR_REL = 'data/prompts/stages'; +const SAMPLE_STAGES_DIR_REL = 'data.sample/prompts/stages'; +const LEGACY_PROMPT_FILE = 'writers-room-settings.md'; +const NEW_PROMPT_FILE = 'writers-room-places.md'; + const readJsonOrNull = async (path) => { const raw = await readFile(path, 'utf-8').catch((err) => { if (err.code === 'ENOENT') return null; @@ -52,8 +71,83 @@ const readJsonOrNull = async (path) => { } }; +const readTextOrNull = async (path) => readFile(path, 'utf-8').catch((err) => { + if (err.code === 'ENOENT') return null; + throw err; +}); + +const fileExists = async (path) => stat(path).then(() => true, (err) => { + if (err.code === 'ENOENT') return false; + throw err; +}); + +// Migrate the `.md` prompt template. Mirrors the stage-config conflict +// policy: when both legacy and new files exist and `…-places.md` matches +// the sample default, replace it with the legacy content (preserving the +// user's customizations). Otherwise keep the user-customized `…-places.md`. +// Always remove the now-orphan `…-settings.md` when its content has been +// preserved or it matches the pre-rename baseline (== sample default). +const migratePromptFile = async (rootDir) => { + const dataDir = join(rootDir, PROMPTS_STAGES_DIR_REL); + const sampleDir = join(rootDir, SAMPLE_STAGES_DIR_REL); + const legacyPath = join(dataDir, LEGACY_PROMPT_FILE); + const newPath = join(dataDir, NEW_PROMPT_FILE); + const samplePath = join(sampleDir, NEW_PROMPT_FILE); + + const legacyExists = await fileExists(legacyPath); + if (!legacyExists) { + return; // already migrated or fresh install + } + + const legacyContent = await readTextOrNull(legacyPath); + const newContent = await readTextOrNull(newPath); + const sampleContent = await readTextOrNull(samplePath); + + if (legacyContent == null) { + // race / disappeared between stat and read — let next run resolve + return; + } + + if (newContent != null && sampleContent != null && newContent === sampleContent) { + // `…-places.md` was just auto-seeded from data.sample. If the legacy + // file differs from the sample, the user had customized it; preserve + // those customizations into `…-places.md`. (Migration 007's intExt / + // timeOfDay fields will be missing — warn so the user can re-merge.) + if (legacyContent !== sampleContent) { + await writeFile(newPath, legacyContent); + console.warn( + `⚠️ ${PROMPTS_STAGES_DIR_REL}/${NEW_PROMPT_FILE}: replaced auto-seeded sample with your customized ${LEGACY_PROMPT_FILE}.\n` + + ` Note: if you had not picked up migration 007 (intExt / timeOfDay fields), diff against\n` + + ` ${SAMPLE_STAGES_DIR_REL}/${NEW_PROMPT_FILE}\n` + + ` and merge the new field bullets + JSON keys manually.`, + ); + } else { + console.log(`📝 ${PROMPTS_STAGES_DIR_REL}/${NEW_PROMPT_FILE}: legacy file matched sample default, kept auto-seeded copy`); + } + } else if (newContent != null) { + // `…-places.md` exists but differs from sample → user customized it + // (or sample missing). Respect that and just drop the legacy orphan. + console.log(`📝 ${PROMPTS_STAGES_DIR_REL}/${NEW_PROMPT_FILE}: user-customized, kept as-is (legacy ${LEGACY_PROMPT_FILE} discarded)`); + } else { + // `…-places.md` missing entirely (setup-data didn't run) — promote + // the legacy file in place rather than dropping the user's content. + await writeFile(newPath, legacyContent); + console.log(`📝 ${PROMPTS_STAGES_DIR_REL}/${NEW_PROMPT_FILE}: promoted from legacy ${LEGACY_PROMPT_FILE}`); + } + + await unlink(legacyPath).catch((err) => { + if (err.code !== 'ENOENT') throw err; + }); + console.log(`🧹 removed orphan ${PROMPTS_STAGES_DIR_REL}/${LEGACY_PROMPT_FILE}`); +}; + export default { async up({ rootDir }) { + // Migrate the `.md` prompt template first — even if the stage-config + // is already on `writers-room-places`, an orphan legacy prompt file + // may still need cleanup. + await migratePromptFile(rootDir); + const path = join(rootDir, STAGE_CONFIG_REL_PATH); const raw = await readFile(path, 'utf-8').catch((err) => { if (err.code === 'ENOENT') return null; From 77a0c9e41668cf5743adcf5ae9ea58072519860c Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sun, 17 May 2026 12:43:36 +0000 Subject: [PATCH 5/7] address review: gate prompt-file preservation on pre-rename baseline hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 018's prompt-file branch previously treated any difference between the legacy `…-settings.md` and the current sample as a "user customization", then promoted the legacy content over the freshly seeded `…-places.md`. For installs that never ran migration 007, the unmodified legacy file is *expected* to differ from the current sample (which has 007's intExt/timeOfDay fields), so this would silently overwrite the modern template with an older default and require manual re-merge of a migration the user did not customize. Mirror migration 007's approach: embed the pre-rename shipped baseline hash (`7f1f80eb…` — equal to migration 007's OLD_SHIPPED_MD5 for the renamed file, since be903564 only renamed, no content change). When the legacy file hashes to that baseline, treat it as unmodified-default and keep the freshly seeded modern sample. Only diverging hashes count as customizations and trigger the legacy-content preservation branch. Co-Authored-By: Claude Opus 4.7 --- .../018-rename-writers-room-settings-stage.js | 63 +++++++++++++++---- 1 file changed, 52 insertions(+), 11 deletions(-) diff --git a/scripts/migrations/018-rename-writers-room-settings-stage.js b/scripts/migrations/018-rename-writers-room-settings-stage.js index 725d9cc23..1dc60d748 100644 --- a/scripts/migrations/018-rename-writers-room-settings-stage.js +++ b/scripts/migrations/018-rename-writers-room-settings-stage.js @@ -34,11 +34,24 @@ * * Resolution for both: when the corresponding `…-places` artifact (entry * or file) byte-for-byte matches the sample default, treat it as an - * auto-seed and replace it with the legacy artifact's content (preserving - * user customizations). If `…-places` differs from the sample, treat it - * as a deliberate user edit and keep it. The legacy `…-settings.md` file - * is removed once the migration has either preserved its content or - * detected that it was untouched. + * auto-seed. Then decide whether the legacy artifact is genuinely + * customized: + * + * - stage-config entry: compared structurally against the sample default. + * Equal → unmodified, drop in favor of the seed; different → customized, + * promote into `…-places`. + * - `.md` prompt file: compared against an *embedded pre-rename baseline + * hash* (`LEGACY_PROMPT_SHIPPED_MD5`), not the current sample. The + * current sample includes migration 007's intExt/timeOfDay fields, so + * an install that never ran 007 will *correctly* show the legacy file + * as unmodified-baseline (matching the hash) — keeping the freshly + * seeded modern sample is the right move. Only files whose hash + * diverges from the baseline are treated as user customizations. + * + * If `…-places` differs from the sample, treat it as a deliberate user + * edit and keep it. The legacy `…-settings.md` file is removed once the + * migration has either preserved its content or detected that it was + * untouched. * * Idempotent: skips when only `writers-room-places` is present (and no * legacy key/file), or when neither key/file exists (fresh installs get @@ -47,6 +60,7 @@ import { readFile, writeFile, unlink, stat } from 'fs/promises'; import { join } from 'path'; +import { createHash } from 'crypto'; const STAGE_CONFIG_REL_PATH = 'data/prompts/stage-config.json'; const SAMPLE_CONFIG_REL_PATH = 'data.sample/prompts/stage-config.json'; @@ -58,6 +72,23 @@ const SAMPLE_STAGES_DIR_REL = 'data.sample/prompts/stages'; const LEGACY_PROMPT_FILE = 'writers-room-settings.md'; const NEW_PROMPT_FILE = 'writers-room-places.md'; +// MD5 of the pre-rename `writers-room-settings.md` shipped baseline (the +// content that existed in data.sample right before commit be903564 renamed +// the file to `writers-room-places.md`). An installed legacy file at this +// hash is an *unmodified* default — the user did not customize it. This +// also happens to equal migration 007's `OLD_SHIPPED_MD5` for the renamed +// file, since be903564 only renamed (no content change). +// +// Used so we can distinguish "user customized legacy prompt → preserve" +// from "user never ran migration 007 + never customized → keep the freshly +// seeded modern sample with intExt/timeOfDay fields". +const LEGACY_PROMPT_SHIPPED_MD5 = '7f1f80eb63d67a21161994cde115045e'; + +const md5 = (str) => { + const normalized = str.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + return createHash('md5').update(normalized).digest('hex'); +}; + const readJsonOrNull = async (path) => { const raw = await readFile(path, 'utf-8').catch((err) => { if (err.code === 'ENOENT') return null; @@ -109,11 +140,21 @@ const migratePromptFile = async (rootDir) => { } if (newContent != null && sampleContent != null && newContent === sampleContent) { - // `…-places.md` was just auto-seeded from data.sample. If the legacy - // file differs from the sample, the user had customized it; preserve - // those customizations into `…-places.md`. (Migration 007's intExt / - // timeOfDay fields will be missing — warn so the user can re-merge.) - if (legacyContent !== sampleContent) { + // `…-places.md` was just auto-seeded from data.sample. Decide whether + // the legacy file is an unmodified default (keep the modern auto-seed) + // or carries real user customizations (preserve those over the seed). + // + // Compare against the pre-rename shipped baseline hash, NOT the current + // sample — for installs that never ran migration 007, the unmodified + // legacy file is *expected* to differ from the current sample (which + // has migration 007's intExt / timeOfDay fields). Treating any + // difference as "customized" would overwrite the freshly seeded modern + // template with an older default and silently undo migration 007. + const legacyHash = md5(legacyContent); + const legacyIsUnmodifiedDefault = + legacyHash === LEGACY_PROMPT_SHIPPED_MD5 || legacyContent === sampleContent; + + if (!legacyIsUnmodifiedDefault) { await writeFile(newPath, legacyContent); console.warn( `⚠️ ${PROMPTS_STAGES_DIR_REL}/${NEW_PROMPT_FILE}: replaced auto-seeded sample with your customized ${LEGACY_PROMPT_FILE}.\n` + @@ -122,7 +163,7 @@ const migratePromptFile = async (rootDir) => { ` and merge the new field bullets + JSON keys manually.`, ); } else { - console.log(`📝 ${PROMPTS_STAGES_DIR_REL}/${NEW_PROMPT_FILE}: legacy file matched sample default, kept auto-seeded copy`); + console.log(`📝 ${PROMPTS_STAGES_DIR_REL}/${NEW_PROMPT_FILE}: legacy file matched shipped baseline, kept auto-seeded copy`); } } else if (newContent != null) { // `…-places.md` exists but differs from sample → user customized it From e5cf9fa0bf6b10a69d8e82322a920f527484fba9 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sun, 17 May 2026 12:45:09 +0000 Subject: [PATCH 6/7] address review: apply baseline check to missing-new-file promotion branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The setup-data-skipped branch (where `…-places.md` doesn't exist) was unconditionally promoting the legacy file. For an unmodified pre-rename baseline (a user who never ran migration 007), that installs the older default instead of the current sample — regressing 007's intExt / timeOfDay schema. Apply the same `LEGACY_PROMPT_SHIPPED_MD5` baseline check here too: if the legacy file is the unmodified shipped baseline AND a current sample is available, install the sample. Only promote legacy content when it actually diverges from the baseline (real user customizations) or when no sample is available to fall back to. Co-Authored-By: Claude Opus 4.7 --- .../018-rename-writers-room-settings-stage.js | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/scripts/migrations/018-rename-writers-room-settings-stage.js b/scripts/migrations/018-rename-writers-room-settings-stage.js index 1dc60d748..e88dfc932 100644 --- a/scripts/migrations/018-rename-writers-room-settings-stage.js +++ b/scripts/migrations/018-rename-writers-room-settings-stage.js @@ -170,10 +170,21 @@ const migratePromptFile = async (rootDir) => { // (or sample missing). Respect that and just drop the legacy orphan. console.log(`📝 ${PROMPTS_STAGES_DIR_REL}/${NEW_PROMPT_FILE}: user-customized, kept as-is (legacy ${LEGACY_PROMPT_FILE} discarded)`); } else { - // `…-places.md` missing entirely (setup-data didn't run) — promote - // the legacy file in place rather than dropping the user's content. - await writeFile(newPath, legacyContent); - console.log(`📝 ${PROMPTS_STAGES_DIR_REL}/${NEW_PROMPT_FILE}: promoted from legacy ${LEGACY_PROMPT_FILE}`); + // `…-places.md` missing entirely (setup-data didn't run). Apply the + // same baseline check as the auto-seeded branch: if the legacy file + // is the unmodified pre-rename baseline AND we have a current sample, + // install the modern sample (so users picking up this migration cold + // also pick up migration 007's intExt / timeOfDay fields). Otherwise + // the legacy file carries user customizations (or there's no sample + // available) — promote it in place. + const legacyHash = md5(legacyContent); + if (legacyHash === LEGACY_PROMPT_SHIPPED_MD5 && sampleContent != null) { + await writeFile(newPath, sampleContent); + console.log(`📝 ${PROMPTS_STAGES_DIR_REL}/${NEW_PROMPT_FILE}: legacy matched shipped baseline, installed current sample`); + } else { + await writeFile(newPath, legacyContent); + console.log(`📝 ${PROMPTS_STAGES_DIR_REL}/${NEW_PROMPT_FILE}: promoted from legacy ${LEGACY_PROMPT_FILE}`); + } } await unlink(legacyPath).catch((err) => { From 0a382c16484ab5ed4a5ee8c6ceca4e0f2685cfce Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sun, 17 May 2026 12:52:18 +0000 Subject: [PATCH 7/7] address review: warn about migration 007 in legacy promotion path --- .../018-rename-writers-room-settings-stage.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/migrations/018-rename-writers-room-settings-stage.js b/scripts/migrations/018-rename-writers-room-settings-stage.js index e88dfc932..95cb4f133 100644 --- a/scripts/migrations/018-rename-writers-room-settings-stage.js +++ b/scripts/migrations/018-rename-writers-room-settings-stage.js @@ -183,7 +183,15 @@ const migratePromptFile = async (rootDir) => { console.log(`📝 ${PROMPTS_STAGES_DIR_REL}/${NEW_PROMPT_FILE}: legacy matched shipped baseline, installed current sample`); } else { await writeFile(newPath, legacyContent); - console.log(`📝 ${PROMPTS_STAGES_DIR_REL}/${NEW_PROMPT_FILE}: promoted from legacy ${LEGACY_PROMPT_FILE}`); + // Mirror the auto-seeded customized path's warning: the promoted + // legacy content lacks migration 007's intExt / timeOfDay fields, + // so the user needs to know to re-merge them manually. + console.warn( + `⚠️ ${PROMPTS_STAGES_DIR_REL}/${NEW_PROMPT_FILE}: promoted from legacy ${LEGACY_PROMPT_FILE}.\n` + + ` Note: if you had not picked up migration 007 (intExt / timeOfDay fields), diff against\n` + + ` ${SAMPLE_STAGES_DIR_REL}/${NEW_PROMPT_FILE}\n` + + ` and merge the new field bullets + JSON keys manually.`, + ); } }