@contentrain/mcp 3.8.2 → 3.9.1

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 (40) hide show
  1. package/dist/{apply-manager-B-VMXKiW.mjs → apply-manager-BenQNb_b.mjs} +4 -4
  2. package/dist/{apply-manager-B-VMXKiW.mjs.map → apply-manager-BenQNb_b.mjs.map} +1 -1
  3. package/dist/{context-fJCKH_qv.mjs → context-Cp8b8Nxm.mjs} +2 -2
  4. package/dist/{context-fJCKH_qv.mjs.map → context-Cp8b8Nxm.mjs.map} +1 -1
  5. package/dist/core/apply-manager.mjs +4 -4
  6. package/dist/core/content-manager.mjs +1 -1
  7. package/dist/core/context.mjs +2 -2
  8. package/dist/core/doctor.mjs +2 -2
  9. package/dist/core/model-manager.d.mts.map +1 -1
  10. package/dist/core/model-manager.mjs +1 -1
  11. package/dist/core/ops/index.mjs +5 -5
  12. package/dist/core/validator/index.mjs +2 -2
  13. package/dist/{doctor-Du4MVG5w.mjs → doctor-Qabcb-Z1.mjs} +2 -2
  14. package/dist/{doctor-Du4MVG5w.mjs.map → doctor-Qabcb-Z1.mjs.map} +1 -1
  15. package/dist/git/reconcile.mjs +6 -6
  16. package/dist/git/transaction.mjs +3 -3
  17. package/dist/index.mjs +11 -11
  18. package/dist/{local-BXTVAAr-.mjs → local-DaITA8Ye.mjs} +5 -5
  19. package/dist/{local-BXTVAAr-.mjs.map → local-DaITA8Ye.mjs.map} +1 -1
  20. package/dist/{model-manager-DPXulg7z.mjs → model-manager-BYF2z7qC.mjs} +18 -8
  21. package/dist/model-manager-BYF2z7qC.mjs.map +1 -0
  22. package/dist/{ops-DxhCCs_Z.mjs → ops-CQ_IIedh.mjs} +3 -3
  23. package/dist/{ops-DxhCCs_Z.mjs.map → ops-CQ_IIedh.mjs.map} +1 -1
  24. package/dist/providers/local/index.mjs +7 -7
  25. package/dist/{reconcile-CPDGFQ8H.mjs → reconcile-8pEO4mBC.mjs} +4 -4
  26. package/dist/{reconcile-CPDGFQ8H.mjs.map → reconcile-8pEO4mBC.mjs.map} +1 -1
  27. package/dist/{reconcile-BWbckvbv.mjs → reconcile-Do8OJDAo.mjs} +3 -3
  28. package/dist/{reconcile-BWbckvbv.mjs.map → reconcile-Do8OJDAo.mjs.map} +1 -1
  29. package/dist/server/http/index.mjs +11 -11
  30. package/dist/{server-UojGbAaw.mjs → server-rEFicTHs.mjs} +12 -12
  31. package/dist/server-rEFicTHs.mjs.map +1 -0
  32. package/dist/server.mjs +11 -11
  33. package/dist/{transaction-DfrvGIfk.mjs → transaction-CcjGiVYg.mjs} +2 -2
  34. package/dist/{transaction-DfrvGIfk.mjs.map → transaction-CcjGiVYg.mjs.map} +1 -1
  35. package/dist/{validator-C08p1OpM.mjs → validator-eUdQqQGc.mjs} +3 -3
  36. package/dist/validator-eUdQqQGc.mjs.map +1 -0
  37. package/package.json +2 -2
  38. package/dist/model-manager-DPXulg7z.mjs.map +0 -1
  39. package/dist/server-UojGbAaw.mjs.map +0 -1
  40. package/dist/validator-C08p1OpM.mjs.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"doctor-Du4MVG5w.mjs","names":[],"sources":["../src/core/doctor.ts"],"sourcesContent":["import { join } from 'node:path'\nimport { createGit } from '../git/identity.js'\nimport { readdir, stat } from 'node:fs/promises'\nimport type { Dirent } from 'node:fs'\nimport type { ContentrainConfig, ModelDefinition } from '@contentrain/types'\nimport { readConfig } from './config.js'\nimport { listModels, readModel, validateModelDefinition } from './model-manager.js'\nimport { resolveContentDir, resolveJsonFilePath, resolveLocaleStrategy } from './content-manager.js'\nimport { autoDetectSourceDirs, discoverFiles } from './scan-config.js'\nimport { checkBranchHealth, listRemoteCrBranches } from '../git/branch-lifecycle.js'\nimport { contentrainDir, pathExists, readDir, readJson, readText } from '../util/fs.js'\n\n/**\n * Doctor — project health report.\n *\n * The public entry point is `runDoctor(projectRoot, { usage? })`. It is\n * inherently local-filesystem work (Node version, git install, file\n * mtimes, orphan directory detection), so the MCP tool surface gates\n * it behind the `localWorktree` capability — same pattern as\n * `contentrain_setup` and normalize.\n *\n * The report is structured JSON so three consumers can share it:\n *\n * - The `contentrain doctor` CLI command pretty-prints the checks.\n * - The Serve UI `/api/doctor` route returns the report to the\n * Dashboard's Doctor panel.\n * - Automation (CI, Studio) gets a deterministic JSON shape it can\n * assert against.\n *\n * Usage analysis (`--usage`) is a heavier, opt-in branch — it scans\n * every source file in the repo for content-key references. Kept\n * behind the flag so the default doctor run stays fast.\n */\n\nexport type CheckSeverity = 'error' | 'warning' | 'info'\n\nexport interface DoctorCheck {\n name: string\n pass: boolean\n detail: string\n /**\n * `error` — default for failing checks. Blocks a clean bill of health.\n * `warning` — failing-but-not-blocking (e.g. pending branches above\n * threshold, stale SDK client).\n * `info` — passed check; pure informational.\n */\n severity?: CheckSeverity\n}\n\nexport interface UnusedKeyEntry {\n model: string\n kind: string\n key: string\n locale: string\n}\n\nexport interface DuplicateValueEntry {\n model: string\n locale: string\n value: string\n keys: string[]\n}\n\nexport interface MissingLocaleEntry {\n model: string\n key: string\n missingIn: string\n}\n\nexport interface DoctorUsageAnalysis {\n unusedKeys: UnusedKeyEntry[]\n duplicateValues: DuplicateValueEntry[]\n missingLocaleKeys: MissingLocaleEntry[]\n}\n\nexport interface DoctorReport {\n checks: DoctorCheck[]\n summary: {\n total: number\n passed: number\n failed: number\n warnings: number\n }\n /** Present only when `options.usage === true`. */\n usage?: DoctorUsageAnalysis\n}\n\nexport interface RunDoctorOptions {\n /** Run heavier `--usage` analysis (unused keys, duplicates, locale gaps). */\n usage?: boolean\n}\n\nexport async function runDoctor(\n projectRoot: string,\n options: RunDoctorOptions = {},\n): Promise<DoctorReport> {\n const checks: DoctorCheck[] = []\n\n // ─── 1. Git installed ───\n try {\n const git = createGit(projectRoot)\n const version = await git.version()\n checks.push({\n name: 'Git',\n pass: true,\n detail: `v${version.major}.${version.minor}.${version.patch}`,\n })\n } catch {\n checks.push({ name: 'Git', pass: false, detail: 'Not installed or not in PATH', severity: 'error' })\n }\n\n // ─── 2. Git repo initialized ───\n const hasGit = await pathExists(join(projectRoot, '.git'))\n checks.push({\n name: 'Git repository',\n pass: hasGit,\n detail: hasGit ? projectRoot : 'No .git directory found',\n severity: hasGit ? undefined : 'error',\n })\n\n // ─── 3. Node version ───\n const nodeVersion = process.versions.node\n const [major] = nodeVersion.split('.').map(Number)\n const nodePass = (major ?? 0) >= 22\n checks.push({\n name: 'Node.js',\n pass: nodePass,\n detail: `v${nodeVersion}${nodePass ? '' : ' (requires ≥22)'}`,\n severity: nodePass ? undefined : 'error',\n })\n\n // ─── 4. .contentrain/ structure ───\n const crDir = contentrainDir(projectRoot)\n const hasCrDir = await pathExists(crDir)\n const hasConfig = await pathExists(join(crDir, 'config.json'))\n const hasModels = await pathExists(join(crDir, 'models'))\n const hasContent = await pathExists(join(crDir, 'content'))\n const structurePass = hasCrDir && hasConfig && hasModels && hasContent\n\n checks.push({\n name: '.contentrain/ structure',\n pass: structurePass,\n detail: !hasCrDir\n ? 'Not initialized — run `contentrain init`'\n : [\n hasConfig ? null : 'missing config.json',\n hasModels ? null : 'missing models/',\n hasContent ? null : 'missing content/',\n ].filter(Boolean).join(', ') || 'OK',\n severity: structurePass ? undefined : 'error',\n })\n\n // ─── 5. Config parseable ───\n let config: ContentrainConfig | null = null\n if (hasConfig) {\n config = await readConfig(projectRoot)\n checks.push({\n name: 'Config',\n pass: config !== null,\n detail: config\n ? `stack: ${config.stack}, locales: ${config.locales.supported.join(', ')}`\n : 'Failed to parse config.json',\n severity: config ? undefined : 'error',\n })\n }\n\n // ─── 6. Models parse AND satisfy the schema contract ───\n //\n // This check used to report \"all valid\" on the strength of JSON.parse alone,\n // which was a claim it had not earned. Model reads are unvalidated casts, so a\n // definition can be structurally wrong — a missing title_field, a field\n // constraint that cannot hold — and still load without a word. Doctor reports;\n // `contentrain validate --fix` is what repairs.\n if (hasCrDir) {\n try {\n // Enumerate the files, not `listModels`. `listModels` drops anything that\n // fails to parse, so routing this check through it made the \"failed to\n // parse\" branch unreachable — a corrupt model file was invisible to the\n // check whose job is to find it.\n const modelsDir = join(crDir, 'models')\n const files = (await readDir(modelsDir)).filter(f => f.endsWith('.json'))\n const parsed = await Promise.all(\n files.map(f => readJson<ModelDefinition>(join(modelsDir, f))),\n )\n const unparseable = files.filter((_, i) => parsed[i] === null)\n const invalid = parsed\n .filter((m): m is ModelDefinition => m !== null)\n .filter(m => validateModelDefinition(m).errors.length > 0)\n .map(m => m.id)\n const models = parsed.filter((m): m is ModelDefinition => m !== null)\n\n const problems = [\n ...(unparseable.length > 0 ? [`${unparseable.length} failed to parse (${unparseable.join(', ')})`] : []),\n ...(invalid.length > 0 ? [`${invalid.length} invalid (${invalid.join(', ')}) — run: contentrain validate --fix`] : []),\n ]\n checks.push({\n name: 'Models',\n pass: problems.length === 0,\n detail: `${models.length} model(s)${problems.length === 0 ? ', all valid' : `, ${problems.join('; ')}`}`,\n severity: problems.length === 0 ? undefined : 'error',\n })\n } catch {\n checks.push({ name: 'Models', pass: false, detail: 'Failed to read models', severity: 'error' })\n }\n }\n\n // ─── 7. Orphan content ───\n if (hasCrDir) {\n const orphans = await findOrphanContent(projectRoot)\n checks.push({\n name: 'Orphan content',\n pass: orphans.length === 0,\n detail: orphans.length === 0 ? 'None' : `Found: ${orphans.join(', ')}`,\n severity: orphans.length === 0 ? undefined : 'warning',\n })\n }\n\n // ─── 8. Stale contentrain branches ───\n if (hasGit) {\n try {\n const health = await checkBranchHealth(projectRoot)\n checks.push({\n name: 'Pending branches',\n pass: !health.blocked && !health.warning,\n detail: health.message\n ?? (health.unmerged === 0 ? 'None' : `${health.unmerged} active cr/* branch(es)`),\n severity: health.blocked ? 'error' : health.warning ? 'warning' : undefined,\n })\n } catch {\n checks.push({ name: 'Pending branches', pass: true, detail: 'Could not check' })\n }\n\n // ─── 8b. Remote cr/* branches ───\n // Authoritative ls-remote count (local branch pressure cannot see the\n // remote pile). Best-effort: skipped entirely without a remote, and an\n // unreachable remote is informational — doctor never fails offline.\n const remoteList = await listRemoteCrBranches(projectRoot, { timeoutMs: 5000 })\n if (remoteList) {\n if (remoteList.error) {\n checks.push({\n name: 'Remote branches',\n pass: true,\n detail: `Could not check ${remoteList.remote} (offline?)`,\n severity: 'info',\n })\n } else {\n const count = remoteList.branches.length\n const warnLimit = config?.branchWarnLimit ?? 50\n const warn = count >= warnLimit\n checks.push({\n name: 'Remote branches',\n pass: !warn,\n detail: count === 0\n ? `None on ${remoteList.remote}`\n : `${count} cr/* branch(es) on ${remoteList.remote}${warn ? ' — run `contentrain prune` to remove merged leftovers' : ''}`,\n severity: warn ? 'warning' : undefined,\n })\n }\n }\n }\n\n // ─── 9. SDK client freshness ───\n const clientDir = join(crDir, 'client')\n const modelsDir = join(crDir, 'models')\n if (await pathExists(clientDir) && await pathExists(modelsDir)) {\n try {\n const [clientMtime, modelsMtime] = await Promise.all([\n newestFileMtime(clientDir),\n newestFileMtime(modelsDir),\n ])\n if (clientMtime === null || modelsMtime === null) {\n checks.push({ name: 'SDK client', pass: true, detail: 'Could not check' })\n } else {\n const fresh = clientMtime >= modelsMtime\n checks.push({\n name: 'SDK client',\n pass: fresh,\n detail: fresh ? 'Up to date' : 'Stale — run `contentrain generate`',\n severity: fresh ? undefined : 'warning',\n })\n }\n } catch {\n checks.push({ name: 'SDK client', pass: true, detail: 'Could not check' })\n }\n }\n\n // ─── 10–12. Usage analysis (optional) ───\n let usage: DoctorUsageAnalysis | undefined\n if (options.usage && hasCrDir && config) {\n const [unusedKeys, duplicateValues, missingLocaleKeys] = await Promise.all([\n analyzeUnusedKeys(projectRoot, config),\n analyzeDuplicateValues(projectRoot, config),\n analyzeMissingLocaleKeys(projectRoot, config),\n ])\n usage = { unusedKeys, duplicateValues, missingLocaleKeys }\n\n checks.push({\n name: 'Unused dictionary keys',\n pass: unusedKeys.length === 0,\n detail: unusedKeys.length === 0\n ? 'All dictionary keys referenced in source'\n : `${unusedKeys.length} dictionary key(s) not referenced in source code`,\n severity: unusedKeys.length === 0 ? undefined : 'warning',\n })\n\n checks.push({\n name: 'Duplicate dictionary values',\n pass: duplicateValues.length === 0,\n detail: duplicateValues.length === 0\n ? 'No duplicate values'\n : `${duplicateValues.length} value(s) mapped to multiple keys`,\n severity: duplicateValues.length === 0 ? undefined : 'warning',\n })\n\n checks.push({\n name: 'Locale key coverage',\n pass: missingLocaleKeys.length === 0,\n detail: missingLocaleKeys.length === 0\n ? 'All locales have matching keys'\n : `${missingLocaleKeys.length} key(s) missing in some locales`,\n severity: missingLocaleKeys.length === 0 ? undefined : 'warning',\n })\n }\n\n const passed = checks.filter(c => c.pass).length\n const failed = checks.length - passed\n const warnings = checks.filter(c => !c.pass && c.severity === 'warning').length\n\n const report: DoctorReport = {\n checks,\n summary: { total: checks.length, passed, failed, warnings },\n }\n if (usage) report.usage = usage\n return report\n}\n\n/**\n * Newest mtime among the files under `dir`, recursively — null if it holds none.\n *\n * Stat the files, never the directory. A directory's mtime only moves when an\n * entry is added, removed, or renamed inside it: `generate` rewrites the client\n * files in place, so `.contentrain/client` never moves after the first run,\n * while a selective sync recreates model files via `git checkout`, which does\n * move `.contentrain/models`. Comparing the two directories therefore reported\n * \"stale\" permanently after any model save.\n */\nasync function newestFileMtime(dir: string): Promise<number | null> {\n const entries: Dirent[] = await readdir(dir, { withFileTypes: true }).catch(() => [])\n const mtimes = await Promise.all(entries.map(async (entry) => {\n const full = join(dir, entry.name)\n if (entry.isDirectory()) return newestFileMtime(full)\n return stat(full).then(s => s.mtimeMs, () => null)\n }))\n const known = mtimes.filter((m): m is number => m !== null)\n return known.length > 0 ? Math.max(...known) : null\n}\n\nasync function findOrphanContent(projectRoot: string): Promise<string[]> {\n const crDir = contentrainDir(projectRoot)\n const models = await listModels(projectRoot)\n const orphans: string[] = []\n\n const knownContentDirs = new Set<string>()\n for (const m of models) {\n const full = await readModel(projectRoot, m.id)\n const modelForPath = full\n ? {\n ...full,\n content_path: full.content_path ?? (m as { content_path?: string }).content_path,\n }\n : {\n id: m.id,\n name: m.id,\n kind: m.kind,\n domain: m.domain,\n i18n: m.i18n,\n fields: {},\n content_path: (m as { content_path?: string }).content_path,\n }\n knownContentDirs.add(resolveContentDir(projectRoot, modelForPath))\n }\n\n const contentDir = join(crDir, 'content')\n if (await pathExists(contentDir)) {\n const domains = await readDir(contentDir)\n for (const domain of domains) {\n const domainDir = join(contentDir, domain)\n const entries = await readDir(domainDir)\n for (const entry of entries) {\n if (entry === '.gitkeep') continue\n const entryDir = join(domainDir, entry)\n if (!knownContentDirs.has(entryDir)) {\n orphans.push(`${domain}/${entry}`)\n }\n }\n }\n }\n\n for (const dir of knownContentDirs) {\n if (dir.startsWith(contentDir)) continue\n if (!await pathExists(dir)) {\n orphans.push(`(missing custom path) ${dir}`)\n continue\n }\n await readDir(dir)\n }\n\n return orphans\n}\n\nasync function analyzeUnusedKeys(\n projectRoot: string,\n config: ContentrainConfig,\n): Promise<UnusedKeyEntry[]> {\n const sourceDirs = await autoDetectSourceDirs(projectRoot)\n const files = await discoverFiles(projectRoot, { paths: sourceDirs })\n if (files.length === 0) return []\n\n const chunks = await Promise.all(\n files.map(async (relPath) => {\n const content = await readText(join(projectRoot, relPath))\n return content ?? ''\n }),\n )\n const allSource = chunks.join('\\n')\n\n // Dictionaries only.\n //\n // A dictionary key IS the reference — source code writes `t('nav.home')`, so\n // a key absent from source is genuinely dead. Collection entry IDs and\n // document slugs are not: entries are fetched by query, and a hex id like\n // `0a1b2c3d4e5f` will never appear in a source file. Checking them produced\n // a report that was mostly noise — of 134 warnings on a real project, ~120\n // were collection and document rows, and the real findings were lost among\n // them.\n const models = (await listModels(projectRoot)).filter(m => m.kind === 'dictionary')\n const defaultLocale = config.locales.default\n const unused: UnusedKeyEntry[] = []\n\n for (const m of models) {\n const fullModel = await readModel(projectRoot, m.id)\n if (!fullModel) continue\n\n const keys = await extractContentKeys(projectRoot, fullModel, defaultLocale)\n for (const key of keys) {\n if (!allSource.includes(key)) {\n unused.push({ model: m.id, kind: m.kind, key, locale: defaultLocale })\n }\n }\n }\n\n return unused\n}\n\n/**\n * Keys of a model that source code could plausibly reference by name.\n *\n * Only dictionaries have any: their keys are semantic addresses that appear\n * verbatim in source. Every other kind is queried, so its ids and slugs are\n * data, not identifiers, and looking for them in source finds nothing and\n * means nothing.\n */\nasync function extractContentKeys(\n projectRoot: string,\n model: ModelDefinition,\n locale: string,\n): Promise<string[]> {\n const cDir = resolveContentDir(projectRoot, model)\n if (!await pathExists(cDir)) return []\n\n switch (model.kind) {\n case 'dictionary': {\n const filePath = resolveJsonFilePath(cDir, model, locale)\n const data = await readJson<Record<string, string>>(filePath)\n return data ? Object.keys(data) : []\n }\n case 'collection': {\n const filePath = resolveJsonFilePath(cDir, model, locale)\n const data = await readJson<Record<string, Record<string, unknown>>>(filePath)\n return data ? Object.keys(data) : []\n }\n case 'document': {\n const strategy = resolveLocaleStrategy(model)\n const slugs: string[] = []\n if (!model.i18n) {\n const files = await readDir(cDir)\n for (const f of files) if (f.endsWith('.md')) slugs.push(f.replace('.md', ''))\n } else if (strategy === 'file') {\n const dirs = await readDir(cDir)\n for (const d of dirs) if (!d.startsWith('.')) slugs.push(d)\n } else if (strategy === 'suffix') {\n const files = await readDir(cDir)\n const suffix = `.${locale}.md`\n for (const f of files) if (f.endsWith(suffix)) slugs.push(f.slice(0, -suffix.length))\n } else if (strategy === 'directory') {\n const localeDir = join(cDir, locale)\n if (await pathExists(localeDir)) {\n const files = await readDir(localeDir)\n for (const f of files) if (f.endsWith('.md')) slugs.push(f.replace('.md', ''))\n }\n } else {\n const files = await readDir(cDir)\n for (const f of files) if (f.endsWith('.md')) slugs.push(f.replace('.md', ''))\n }\n return slugs\n }\n case 'singleton':\n return []\n default:\n return []\n }\n}\n\nasync function analyzeDuplicateValues(\n projectRoot: string,\n config: ContentrainConfig,\n): Promise<DuplicateValueEntry[]> {\n const models = await listModels(projectRoot)\n const result: DuplicateValueEntry[] = []\n\n for (const m of models) {\n if (m.kind !== 'dictionary') continue\n const fullModel = await readModel(projectRoot, m.id)\n if (!fullModel) continue\n\n const cDir = resolveContentDir(projectRoot, fullModel)\n for (const locale of config.locales.supported) {\n const filePath = resolveJsonFilePath(cDir, fullModel, locale)\n const data = await readJson<Record<string, string>>(filePath)\n if (!data) continue\n\n const valueToKeys = new Map<string, string[]>()\n for (const [key, value] of Object.entries(data)) {\n const arr = valueToKeys.get(value)\n if (arr) arr.push(key)\n else valueToKeys.set(value, [key])\n }\n\n for (const [value, keys] of valueToKeys) {\n if (keys.length > 1) {\n result.push({ model: m.id, locale, value, keys })\n }\n }\n }\n }\n\n return result\n}\n\nasync function analyzeMissingLocaleKeys(\n projectRoot: string,\n config: ContentrainConfig,\n): Promise<MissingLocaleEntry[]> {\n if (config.locales.supported.length < 2) return []\n\n const models = await listModels(projectRoot)\n const result: MissingLocaleEntry[] = []\n const defaultLocale = config.locales.default\n const otherLocales = config.locales.supported.filter(l => l !== defaultLocale)\n\n for (const m of models) {\n if (m.kind !== 'dictionary' && m.kind !== 'collection') continue\n if (!m.i18n) continue\n\n const fullModel = await readModel(projectRoot, m.id)\n if (!fullModel) continue\n\n const cDir = resolveContentDir(projectRoot, fullModel)\n const defaultPath = resolveJsonFilePath(cDir, fullModel, defaultLocale)\n const defaultData = await readJson<Record<string, unknown>>(defaultPath)\n if (!defaultData) continue\n const defaultKeys = new Set(Object.keys(defaultData))\n\n for (const locale of otherLocales) {\n const localePath = resolveJsonFilePath(cDir, fullModel, locale)\n const localeData = await readJson<Record<string, unknown>>(localePath)\n const localeKeys = localeData ? new Set(Object.keys(localeData)) : new Set<string>()\n\n for (const key of defaultKeys) {\n if (!localeKeys.has(key)) {\n result.push({ model: m.id, key, missingIn: locale })\n }\n }\n }\n }\n\n return result\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"doctor-Qabcb-Z1.mjs","names":[],"sources":["../src/core/doctor.ts"],"sourcesContent":["import { join } from 'node:path'\nimport { createGit } from '../git/identity.js'\nimport { readdir, stat } from 'node:fs/promises'\nimport type { Dirent } from 'node:fs'\nimport type { ContentrainConfig, ModelDefinition } from '@contentrain/types'\nimport { readConfig } from './config.js'\nimport { listModels, readModel, validateModelDefinition } from './model-manager.js'\nimport { resolveContentDir, resolveJsonFilePath, resolveLocaleStrategy } from './content-manager.js'\nimport { autoDetectSourceDirs, discoverFiles } from './scan-config.js'\nimport { checkBranchHealth, listRemoteCrBranches } from '../git/branch-lifecycle.js'\nimport { contentrainDir, pathExists, readDir, readJson, readText } from '../util/fs.js'\n\n/**\n * Doctor — project health report.\n *\n * The public entry point is `runDoctor(projectRoot, { usage? })`. It is\n * inherently local-filesystem work (Node version, git install, file\n * mtimes, orphan directory detection), so the MCP tool surface gates\n * it behind the `localWorktree` capability — same pattern as\n * `contentrain_setup` and normalize.\n *\n * The report is structured JSON so three consumers can share it:\n *\n * - The `contentrain doctor` CLI command pretty-prints the checks.\n * - The Serve UI `/api/doctor` route returns the report to the\n * Dashboard's Doctor panel.\n * - Automation (CI, Studio) gets a deterministic JSON shape it can\n * assert against.\n *\n * Usage analysis (`--usage`) is a heavier, opt-in branch — it scans\n * every source file in the repo for content-key references. Kept\n * behind the flag so the default doctor run stays fast.\n */\n\nexport type CheckSeverity = 'error' | 'warning' | 'info'\n\nexport interface DoctorCheck {\n name: string\n pass: boolean\n detail: string\n /**\n * `error` — default for failing checks. Blocks a clean bill of health.\n * `warning` — failing-but-not-blocking (e.g. pending branches above\n * threshold, stale SDK client).\n * `info` — passed check; pure informational.\n */\n severity?: CheckSeverity\n}\n\nexport interface UnusedKeyEntry {\n model: string\n kind: string\n key: string\n locale: string\n}\n\nexport interface DuplicateValueEntry {\n model: string\n locale: string\n value: string\n keys: string[]\n}\n\nexport interface MissingLocaleEntry {\n model: string\n key: string\n missingIn: string\n}\n\nexport interface DoctorUsageAnalysis {\n unusedKeys: UnusedKeyEntry[]\n duplicateValues: DuplicateValueEntry[]\n missingLocaleKeys: MissingLocaleEntry[]\n}\n\nexport interface DoctorReport {\n checks: DoctorCheck[]\n summary: {\n total: number\n passed: number\n failed: number\n warnings: number\n }\n /** Present only when `options.usage === true`. */\n usage?: DoctorUsageAnalysis\n}\n\nexport interface RunDoctorOptions {\n /** Run heavier `--usage` analysis (unused keys, duplicates, locale gaps). */\n usage?: boolean\n}\n\nexport async function runDoctor(\n projectRoot: string,\n options: RunDoctorOptions = {},\n): Promise<DoctorReport> {\n const checks: DoctorCheck[] = []\n\n // ─── 1. Git installed ───\n try {\n const git = createGit(projectRoot)\n const version = await git.version()\n checks.push({\n name: 'Git',\n pass: true,\n detail: `v${version.major}.${version.minor}.${version.patch}`,\n })\n } catch {\n checks.push({ name: 'Git', pass: false, detail: 'Not installed or not in PATH', severity: 'error' })\n }\n\n // ─── 2. Git repo initialized ───\n const hasGit = await pathExists(join(projectRoot, '.git'))\n checks.push({\n name: 'Git repository',\n pass: hasGit,\n detail: hasGit ? projectRoot : 'No .git directory found',\n severity: hasGit ? undefined : 'error',\n })\n\n // ─── 3. Node version ───\n const nodeVersion = process.versions.node\n const [major] = nodeVersion.split('.').map(Number)\n const nodePass = (major ?? 0) >= 22\n checks.push({\n name: 'Node.js',\n pass: nodePass,\n detail: `v${nodeVersion}${nodePass ? '' : ' (requires ≥22)'}`,\n severity: nodePass ? undefined : 'error',\n })\n\n // ─── 4. .contentrain/ structure ───\n const crDir = contentrainDir(projectRoot)\n const hasCrDir = await pathExists(crDir)\n const hasConfig = await pathExists(join(crDir, 'config.json'))\n const hasModels = await pathExists(join(crDir, 'models'))\n const hasContent = await pathExists(join(crDir, 'content'))\n const structurePass = hasCrDir && hasConfig && hasModels && hasContent\n\n checks.push({\n name: '.contentrain/ structure',\n pass: structurePass,\n detail: !hasCrDir\n ? 'Not initialized — run `contentrain init`'\n : [\n hasConfig ? null : 'missing config.json',\n hasModels ? null : 'missing models/',\n hasContent ? null : 'missing content/',\n ].filter(Boolean).join(', ') || 'OK',\n severity: structurePass ? undefined : 'error',\n })\n\n // ─── 5. Config parseable ───\n let config: ContentrainConfig | null = null\n if (hasConfig) {\n config = await readConfig(projectRoot)\n checks.push({\n name: 'Config',\n pass: config !== null,\n detail: config\n ? `stack: ${config.stack}, locales: ${config.locales.supported.join(', ')}`\n : 'Failed to parse config.json',\n severity: config ? undefined : 'error',\n })\n }\n\n // ─── 6. Models parse AND satisfy the schema contract ───\n //\n // This check used to report \"all valid\" on the strength of JSON.parse alone,\n // which was a claim it had not earned. Model reads are unvalidated casts, so a\n // definition can be structurally wrong — a missing title_field, a field\n // constraint that cannot hold — and still load without a word. Doctor reports;\n // `contentrain validate --fix` is what repairs.\n if (hasCrDir) {\n try {\n // Enumerate the files, not `listModels`. `listModels` drops anything that\n // fails to parse, so routing this check through it made the \"failed to\n // parse\" branch unreachable — a corrupt model file was invisible to the\n // check whose job is to find it.\n const modelsDir = join(crDir, 'models')\n const files = (await readDir(modelsDir)).filter(f => f.endsWith('.json'))\n const parsed = await Promise.all(\n files.map(f => readJson<ModelDefinition>(join(modelsDir, f))),\n )\n const unparseable = files.filter((_, i) => parsed[i] === null)\n const invalid = parsed\n .filter((m): m is ModelDefinition => m !== null)\n .filter(m => validateModelDefinition(m).errors.length > 0)\n .map(m => m.id)\n const models = parsed.filter((m): m is ModelDefinition => m !== null)\n\n const problems = [\n ...(unparseable.length > 0 ? [`${unparseable.length} failed to parse (${unparseable.join(', ')})`] : []),\n ...(invalid.length > 0 ? [`${invalid.length} invalid (${invalid.join(', ')}) — run: contentrain validate --fix`] : []),\n ]\n checks.push({\n name: 'Models',\n pass: problems.length === 0,\n detail: `${models.length} model(s)${problems.length === 0 ? ', all valid' : `, ${problems.join('; ')}`}`,\n severity: problems.length === 0 ? undefined : 'error',\n })\n } catch {\n checks.push({ name: 'Models', pass: false, detail: 'Failed to read models', severity: 'error' })\n }\n }\n\n // ─── 7. Orphan content ───\n if (hasCrDir) {\n const orphans = await findOrphanContent(projectRoot)\n checks.push({\n name: 'Orphan content',\n pass: orphans.length === 0,\n detail: orphans.length === 0 ? 'None' : `Found: ${orphans.join(', ')}`,\n severity: orphans.length === 0 ? undefined : 'warning',\n })\n }\n\n // ─── 8. Stale contentrain branches ───\n if (hasGit) {\n try {\n const health = await checkBranchHealth(projectRoot)\n checks.push({\n name: 'Pending branches',\n pass: !health.blocked && !health.warning,\n detail: health.message\n ?? (health.unmerged === 0 ? 'None' : `${health.unmerged} active cr/* branch(es)`),\n severity: health.blocked ? 'error' : health.warning ? 'warning' : undefined,\n })\n } catch {\n checks.push({ name: 'Pending branches', pass: true, detail: 'Could not check' })\n }\n\n // ─── 8b. Remote cr/* branches ───\n // Authoritative ls-remote count (local branch pressure cannot see the\n // remote pile). Best-effort: skipped entirely without a remote, and an\n // unreachable remote is informational — doctor never fails offline.\n const remoteList = await listRemoteCrBranches(projectRoot, { timeoutMs: 5000 })\n if (remoteList) {\n if (remoteList.error) {\n checks.push({\n name: 'Remote branches',\n pass: true,\n detail: `Could not check ${remoteList.remote} (offline?)`,\n severity: 'info',\n })\n } else {\n const count = remoteList.branches.length\n const warnLimit = config?.branchWarnLimit ?? 50\n const warn = count >= warnLimit\n checks.push({\n name: 'Remote branches',\n pass: !warn,\n detail: count === 0\n ? `None on ${remoteList.remote}`\n : `${count} cr/* branch(es) on ${remoteList.remote}${warn ? ' — run `contentrain prune` to remove merged leftovers' : ''}`,\n severity: warn ? 'warning' : undefined,\n })\n }\n }\n }\n\n // ─── 9. SDK client freshness ───\n const clientDir = join(crDir, 'client')\n const modelsDir = join(crDir, 'models')\n if (await pathExists(clientDir) && await pathExists(modelsDir)) {\n try {\n const [clientMtime, modelsMtime] = await Promise.all([\n newestFileMtime(clientDir),\n newestFileMtime(modelsDir),\n ])\n if (clientMtime === null || modelsMtime === null) {\n checks.push({ name: 'SDK client', pass: true, detail: 'Could not check' })\n } else {\n const fresh = clientMtime >= modelsMtime\n checks.push({\n name: 'SDK client',\n pass: fresh,\n detail: fresh ? 'Up to date' : 'Stale — run `contentrain generate`',\n severity: fresh ? undefined : 'warning',\n })\n }\n } catch {\n checks.push({ name: 'SDK client', pass: true, detail: 'Could not check' })\n }\n }\n\n // ─── 10–12. Usage analysis (optional) ───\n let usage: DoctorUsageAnalysis | undefined\n if (options.usage && hasCrDir && config) {\n const [unusedKeys, duplicateValues, missingLocaleKeys] = await Promise.all([\n analyzeUnusedKeys(projectRoot, config),\n analyzeDuplicateValues(projectRoot, config),\n analyzeMissingLocaleKeys(projectRoot, config),\n ])\n usage = { unusedKeys, duplicateValues, missingLocaleKeys }\n\n checks.push({\n name: 'Unused dictionary keys',\n pass: unusedKeys.length === 0,\n detail: unusedKeys.length === 0\n ? 'All dictionary keys referenced in source'\n : `${unusedKeys.length} dictionary key(s) not referenced in source code`,\n severity: unusedKeys.length === 0 ? undefined : 'warning',\n })\n\n checks.push({\n name: 'Duplicate dictionary values',\n pass: duplicateValues.length === 0,\n detail: duplicateValues.length === 0\n ? 'No duplicate values'\n : `${duplicateValues.length} value(s) mapped to multiple keys`,\n severity: duplicateValues.length === 0 ? undefined : 'warning',\n })\n\n checks.push({\n name: 'Locale key coverage',\n pass: missingLocaleKeys.length === 0,\n detail: missingLocaleKeys.length === 0\n ? 'All locales have matching keys'\n : `${missingLocaleKeys.length} key(s) missing in some locales`,\n severity: missingLocaleKeys.length === 0 ? undefined : 'warning',\n })\n }\n\n const passed = checks.filter(c => c.pass).length\n const failed = checks.length - passed\n const warnings = checks.filter(c => !c.pass && c.severity === 'warning').length\n\n const report: DoctorReport = {\n checks,\n summary: { total: checks.length, passed, failed, warnings },\n }\n if (usage) report.usage = usage\n return report\n}\n\n/**\n * Newest mtime among the files under `dir`, recursively — null if it holds none.\n *\n * Stat the files, never the directory. A directory's mtime only moves when an\n * entry is added, removed, or renamed inside it: `generate` rewrites the client\n * files in place, so `.contentrain/client` never moves after the first run,\n * while a selective sync recreates model files via `git checkout`, which does\n * move `.contentrain/models`. Comparing the two directories therefore reported\n * \"stale\" permanently after any model save.\n */\nasync function newestFileMtime(dir: string): Promise<number | null> {\n const entries: Dirent[] = await readdir(dir, { withFileTypes: true }).catch(() => [])\n const mtimes = await Promise.all(entries.map(async (entry) => {\n const full = join(dir, entry.name)\n if (entry.isDirectory()) return newestFileMtime(full)\n return stat(full).then(s => s.mtimeMs, () => null)\n }))\n const known = mtimes.filter((m): m is number => m !== null)\n return known.length > 0 ? Math.max(...known) : null\n}\n\nasync function findOrphanContent(projectRoot: string): Promise<string[]> {\n const crDir = contentrainDir(projectRoot)\n const models = await listModels(projectRoot)\n const orphans: string[] = []\n\n const knownContentDirs = new Set<string>()\n for (const m of models) {\n const full = await readModel(projectRoot, m.id)\n const modelForPath = full\n ? {\n ...full,\n content_path: full.content_path ?? (m as { content_path?: string }).content_path,\n }\n : {\n id: m.id,\n name: m.id,\n kind: m.kind,\n domain: m.domain,\n i18n: m.i18n,\n fields: {},\n content_path: (m as { content_path?: string }).content_path,\n }\n knownContentDirs.add(resolveContentDir(projectRoot, modelForPath))\n }\n\n const contentDir = join(crDir, 'content')\n if (await pathExists(contentDir)) {\n const domains = await readDir(contentDir)\n for (const domain of domains) {\n const domainDir = join(contentDir, domain)\n const entries = await readDir(domainDir)\n for (const entry of entries) {\n if (entry === '.gitkeep') continue\n const entryDir = join(domainDir, entry)\n if (!knownContentDirs.has(entryDir)) {\n orphans.push(`${domain}/${entry}`)\n }\n }\n }\n }\n\n for (const dir of knownContentDirs) {\n if (dir.startsWith(contentDir)) continue\n if (!await pathExists(dir)) {\n orphans.push(`(missing custom path) ${dir}`)\n continue\n }\n await readDir(dir)\n }\n\n return orphans\n}\n\nasync function analyzeUnusedKeys(\n projectRoot: string,\n config: ContentrainConfig,\n): Promise<UnusedKeyEntry[]> {\n const sourceDirs = await autoDetectSourceDirs(projectRoot)\n const files = await discoverFiles(projectRoot, { paths: sourceDirs })\n if (files.length === 0) return []\n\n const chunks = await Promise.all(\n files.map(async (relPath) => {\n const content = await readText(join(projectRoot, relPath))\n return content ?? ''\n }),\n )\n const allSource = chunks.join('\\n')\n\n // Dictionaries only.\n //\n // A dictionary key IS the reference — source code writes `t('nav.home')`, so\n // a key absent from source is genuinely dead. Collection entry IDs and\n // document slugs are not: entries are fetched by query, and a hex id like\n // `0a1b2c3d4e5f` will never appear in a source file. Checking them produced\n // a report that was mostly noise — of 134 warnings on a real project, ~120\n // were collection and document rows, and the real findings were lost among\n // them.\n const models = (await listModels(projectRoot)).filter(m => m.kind === 'dictionary')\n const defaultLocale = config.locales.default\n const unused: UnusedKeyEntry[] = []\n\n for (const m of models) {\n const fullModel = await readModel(projectRoot, m.id)\n if (!fullModel) continue\n\n const keys = await extractContentKeys(projectRoot, fullModel, defaultLocale)\n for (const key of keys) {\n if (!allSource.includes(key)) {\n unused.push({ model: m.id, kind: m.kind, key, locale: defaultLocale })\n }\n }\n }\n\n return unused\n}\n\n/**\n * Keys of a model that source code could plausibly reference by name.\n *\n * Only dictionaries have any: their keys are semantic addresses that appear\n * verbatim in source. Every other kind is queried, so its ids and slugs are\n * data, not identifiers, and looking for them in source finds nothing and\n * means nothing.\n */\nasync function extractContentKeys(\n projectRoot: string,\n model: ModelDefinition,\n locale: string,\n): Promise<string[]> {\n const cDir = resolveContentDir(projectRoot, model)\n if (!await pathExists(cDir)) return []\n\n switch (model.kind) {\n case 'dictionary': {\n const filePath = resolveJsonFilePath(cDir, model, locale)\n const data = await readJson<Record<string, string>>(filePath)\n return data ? Object.keys(data) : []\n }\n case 'collection': {\n const filePath = resolveJsonFilePath(cDir, model, locale)\n const data = await readJson<Record<string, Record<string, unknown>>>(filePath)\n return data ? Object.keys(data) : []\n }\n case 'document': {\n const strategy = resolveLocaleStrategy(model)\n const slugs: string[] = []\n if (!model.i18n) {\n const files = await readDir(cDir)\n for (const f of files) if (f.endsWith('.md')) slugs.push(f.replace('.md', ''))\n } else if (strategy === 'file') {\n const dirs = await readDir(cDir)\n for (const d of dirs) if (!d.startsWith('.')) slugs.push(d)\n } else if (strategy === 'suffix') {\n const files = await readDir(cDir)\n const suffix = `.${locale}.md`\n for (const f of files) if (f.endsWith(suffix)) slugs.push(f.slice(0, -suffix.length))\n } else if (strategy === 'directory') {\n const localeDir = join(cDir, locale)\n if (await pathExists(localeDir)) {\n const files = await readDir(localeDir)\n for (const f of files) if (f.endsWith('.md')) slugs.push(f.replace('.md', ''))\n }\n } else {\n const files = await readDir(cDir)\n for (const f of files) if (f.endsWith('.md')) slugs.push(f.replace('.md', ''))\n }\n return slugs\n }\n case 'singleton':\n return []\n default:\n return []\n }\n}\n\nasync function analyzeDuplicateValues(\n projectRoot: string,\n config: ContentrainConfig,\n): Promise<DuplicateValueEntry[]> {\n const models = await listModels(projectRoot)\n const result: DuplicateValueEntry[] = []\n\n for (const m of models) {\n if (m.kind !== 'dictionary') continue\n const fullModel = await readModel(projectRoot, m.id)\n if (!fullModel) continue\n\n const cDir = resolveContentDir(projectRoot, fullModel)\n for (const locale of config.locales.supported) {\n const filePath = resolveJsonFilePath(cDir, fullModel, locale)\n const data = await readJson<Record<string, string>>(filePath)\n if (!data) continue\n\n const valueToKeys = new Map<string, string[]>()\n for (const [key, value] of Object.entries(data)) {\n const arr = valueToKeys.get(value)\n if (arr) arr.push(key)\n else valueToKeys.set(value, [key])\n }\n\n for (const [value, keys] of valueToKeys) {\n if (keys.length > 1) {\n result.push({ model: m.id, locale, value, keys })\n }\n }\n }\n }\n\n return result\n}\n\nasync function analyzeMissingLocaleKeys(\n projectRoot: string,\n config: ContentrainConfig,\n): Promise<MissingLocaleEntry[]> {\n if (config.locales.supported.length < 2) return []\n\n const models = await listModels(projectRoot)\n const result: MissingLocaleEntry[] = []\n const defaultLocale = config.locales.default\n const otherLocales = config.locales.supported.filter(l => l !== defaultLocale)\n\n for (const m of models) {\n if (m.kind !== 'dictionary' && m.kind !== 'collection') continue\n if (!m.i18n) continue\n\n const fullModel = await readModel(projectRoot, m.id)\n if (!fullModel) continue\n\n const cDir = resolveContentDir(projectRoot, fullModel)\n const defaultPath = resolveJsonFilePath(cDir, fullModel, defaultLocale)\n const defaultData = await readJson<Record<string, unknown>>(defaultPath)\n if (!defaultData) continue\n const defaultKeys = new Set(Object.keys(defaultData))\n\n for (const locale of otherLocales) {\n const localePath = resolveJsonFilePath(cDir, fullModel, locale)\n const localeData = await readJson<Record<string, unknown>>(localePath)\n const localeKeys = localeData ? new Set(Object.keys(localeData)) : new Set<string>()\n\n for (const key of defaultKeys) {\n if (!localeKeys.has(key)) {\n result.push({ model: m.id, key, missingIn: locale })\n }\n }\n }\n }\n\n return result\n}\n"],"mappings":"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"}
@@ -1,14 +1,14 @@
1
- import "../reconcile-CPDGFQ8H.mjs";
2
- import "../context-fJCKH_qv.mjs";
3
- import "../model-manager-DPXulg7z.mjs";
1
+ import "../reconcile-8pEO4mBC.mjs";
2
+ import "../context-Cp8b8Nxm.mjs";
3
+ import "../model-manager-BYF2z7qC.mjs";
4
4
  import "../fs-BFTuXbbw.mjs";
5
5
  import "../meta-manager-CSpKtjnP.mjs";
6
6
  import "../config-BLwZtwlI.mjs";
7
- import "../validator-C08p1OpM.mjs";
7
+ import "../validator-eUdQqQGc.mjs";
8
8
  import "../identity-CxAB9Qb8.mjs";
9
9
  import "../base-branch-Cw9DWvCC.mjs";
10
10
  import "../branch-lifecycle-CcsSyfSw.mjs";
11
11
  import "../id-jrHWdtqn.mjs";
12
- import "../transaction-DfrvGIfk.mjs";
13
- import { t as reconcileBranches } from "../reconcile-BWbckvbv.mjs";
12
+ import "../transaction-CcjGiVYg.mjs";
13
+ import { t as reconcileBranches } from "../reconcile-Do8OJDAo.mjs";
14
14
  export { reconcileBranches };
@@ -1,5 +1,5 @@
1
- import "../context-fJCKH_qv.mjs";
2
- import "../model-manager-DPXulg7z.mjs";
1
+ import "../context-Cp8b8Nxm.mjs";
2
+ import "../model-manager-BYF2z7qC.mjs";
3
3
  import "../fs-BFTuXbbw.mjs";
4
4
  import "../meta-manager-CSpKtjnP.mjs";
5
5
  import "../config-BLwZtwlI.mjs";
@@ -7,5 +7,5 @@ import "../identity-CxAB9Qb8.mjs";
7
7
  import "../base-branch-Cw9DWvCC.mjs";
8
8
  import "../branch-lifecycle-CcsSyfSw.mjs";
9
9
  import "../id-jrHWdtqn.mjs";
10
- import { a as isAncestor, c as selectiveSync, i as ensureContentBranch, n as buildBranchName, o as mergeBranch, r as createTransaction, s as pushContentBranches, t as NETWORK_TIMEOUT_MS } from "../transaction-DfrvGIfk.mjs";
10
+ import { a as isAncestor, c as selectiveSync, i as ensureContentBranch, n as buildBranchName, o as mergeBranch, r as createTransaction, s as pushContentBranches, t as NETWORK_TIMEOUT_MS } from "../transaction-CcjGiVYg.mjs";
11
11
  export { NETWORK_TIMEOUT_MS, buildBranchName, createTransaction, ensureContentBranch, isAncestor, mergeBranch, pushContentBranches, selectiveSync };
package/dist/index.mjs CHANGED
@@ -1,30 +1,30 @@
1
1
  #!/usr/bin/env node
2
2
  import "./contracts-DfL0BfrD.mjs";
3
- import "./reconcile-CPDGFQ8H.mjs";
4
- import "./context-fJCKH_qv.mjs";
5
- import "./model-manager-DPXulg7z.mjs";
6
- import "./ops-DxhCCs_Z.mjs";
3
+ import "./reconcile-8pEO4mBC.mjs";
4
+ import "./context-Cp8b8Nxm.mjs";
5
+ import "./model-manager-BYF2z7qC.mjs";
6
+ import "./ops-CQ_IIedh.mjs";
7
7
  import "./fs-BFTuXbbw.mjs";
8
8
  import "./meta-manager-CSpKtjnP.mjs";
9
9
  import "./config-BLwZtwlI.mjs";
10
- import "./validator-C08p1OpM.mjs";
10
+ import "./validator-eUdQqQGc.mjs";
11
11
  import "./identity-CxAB9Qb8.mjs";
12
12
  import "./base-branch-Cw9DWvCC.mjs";
13
13
  import "./branch-lifecycle-CcsSyfSw.mjs";
14
14
  import "./id-jrHWdtqn.mjs";
15
- import "./transaction-DfrvGIfk.mjs";
16
- import "./local-BXTVAAr-.mjs";
15
+ import "./transaction-CcjGiVYg.mjs";
16
+ import "./local-DaITA8Ye.mjs";
17
17
  import "./detect-B1QHYA0u.mjs";
18
18
  import "./annotations-BYkjoj2d.mjs";
19
- import { n as createServer } from "./server-UojGbAaw.mjs";
19
+ import { n as createServer } from "./server-rEFicTHs.mjs";
20
20
  import "./errors-De8Noahk.mjs";
21
- import "./reconcile-BWbckvbv.mjs";
21
+ import "./reconcile-Do8OJDAo.mjs";
22
22
  import "./scan-config-S5fkzNhL.mjs";
23
23
  import "./graph-builder-BPhi-1KH.mjs";
24
24
  import "./scanner-0sc_pARB.mjs";
25
25
  import "./tsx-parser-DgFdpsea.mjs";
26
- import "./apply-manager-B-VMXKiW.mjs";
27
- import "./doctor-Du4MVG5w.mjs";
26
+ import "./apply-manager-BenQNb_b.mjs";
27
+ import "./doctor-Qabcb-Z1.mjs";
28
28
  import { resolve } from "node:path";
29
29
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
30
30
  //#region src/index.ts
@@ -1,12 +1,12 @@
1
1
  import { t as LOCAL_CAPABILITIES } from "./contracts-DfL0BfrD.mjs";
2
- import { n as applyChangesToWorktree } from "./reconcile-CPDGFQ8H.mjs";
3
- import { o as canonicalStringify$1, s as sortKeys$1 } from "./context-fJCKH_qv.mjs";
4
- import { k as LocalReader } from "./model-manager-DPXulg7z.mjs";
2
+ import { n as applyChangesToWorktree } from "./reconcile-8pEO4mBC.mjs";
3
+ import { o as canonicalStringify$1, s as sortKeys$1 } from "./context-Cp8b8Nxm.mjs";
4
+ import { k as LocalReader } from "./model-manager-BYF2z7qC.mjs";
5
5
  import { t as readConfig } from "./config-BLwZtwlI.mjs";
6
6
  import { i as gitBinary, r as createGit } from "./identity-CxAB9Qb8.mjs";
7
7
  import { t as resolveBaseBranch } from "./base-branch-Cw9DWvCC.mjs";
8
8
  import { n as checkBranchHealth, o as deleteRemoteBranch, r as classifyMergedBranches } from "./branch-lifecycle-CcsSyfSw.mjs";
9
- import { o as mergeBranch$1, r as createTransaction } from "./transaction-DfrvGIfk.mjs";
9
+ import { o as mergeBranch$1, r as createTransaction } from "./transaction-CcjGiVYg.mjs";
10
10
  import { join, resolve } from "node:path";
11
11
  import { readFile, stat } from "node:fs/promises";
12
12
  import { tmpdir } from "node:os";
@@ -688,4 +688,4 @@ function staleTreeGuidance(baseBranch, baseCheckedOut, readFromRef) {
688
688
  //#endregion
689
689
  export { withReadScope as n, isMerged as r, LocalProvider as t };
690
690
 
691
- //# sourceMappingURL=local-BXTVAAr-.mjs.map
691
+ //# sourceMappingURL=local-DaITA8Ye.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"local-BXTVAAr-.mjs","names":["mergeBranchOp","canonicalStringify","sortKeys","listBranchesOp","createBranchOp","deleteBranchOp","getBranchDiffOp","mergeBranchOp","isMergedOp","getDefaultBranchOp"],"sources":["../src/providers/local/branch-ops.ts","../src/git/ref-snapshot.ts","../src/providers/local/content-view.ts","../src/providers/local/rebase-changes.ts","../src/providers/local/provider.ts"],"sourcesContent":["import { CONTENTRAIN_BRANCH } from '@contentrain/types'\nimport { createGit } from '../../git/identity.js'\nimport type { Branch, FileDiff, MergeResult } from '../../core/contracts/index.js'\nimport { readConfig } from '../../core/config.js'\nimport { resolveBaseBranch } from '../../git/base-branch.js'\nimport { classifyMergedBranches, deleteRemoteBranch } from '../../git/branch-lifecycle.js'\nimport { mergeBranch as mergeBranchOp } from '../../git/transaction.js'\n\n/**\n * Branch/merge/diff helpers backed by the local simple-git worktree flow.\n *\n * Pure functions composed into `LocalProvider` — mirroring the shape of\n * `providers/github/branch-ops.ts` so the two providers share the same\n * surface at the `RepoProvider` boundary.\n */\n\nexport async function getDefaultBranch(projectRoot: string): Promise<string> {\n return resolveBaseBranch(createGit(projectRoot), await readConfig(projectRoot))\n}\n\nexport async function listBranches(\n projectRoot: string,\n prefix?: string,\n): Promise<Branch[]> {\n const git = createGit(projectRoot)\n const summary = await git.branchLocal()\n const names = prefix\n ? summary.all.filter(n => n.startsWith(prefix))\n : summary.all\n const branches: Branch[] = []\n for (const name of names) {\n const info = summary.branches[name]\n branches.push({ name, sha: info?.commit ?? '' })\n }\n return branches\n}\n\nexport async function createBranch(\n projectRoot: string,\n name: string,\n fromRef: string,\n): Promise<void> {\n const git = createGit(projectRoot)\n await git.raw(['branch', name, fromRef])\n}\n\nexport async function deleteBranch(\n projectRoot: string,\n name: string,\n): Promise<void> {\n const git = createGit(projectRoot)\n await git.deleteLocalBranch(name, true)\n // Parity with the remote-API providers, whose deleteBranch removes the\n // remote ref: best-effort, config-gated inside the helper, never throws.\n await deleteRemoteBranch(projectRoot, name)\n}\n\nexport async function getBranchDiff(\n projectRoot: string,\n branch: string,\n base: string,\n): Promise<FileDiff[]> {\n const git = createGit(projectRoot)\n const raw = await git.raw(['diff', '--name-status', `${base}...${branch}`])\n const diffs: FileDiff[] = []\n for (const line of raw.split('\\n')) {\n if (!line.trim()) continue\n const [code, ...pathParts] = line.split('\\t')\n const path = pathParts[pathParts.length - 1]\n if (!code || !path) continue\n const status: FileDiff['status'] = code.startsWith('A')\n ? 'added'\n : code.startsWith('D')\n ? 'removed'\n : 'modified'\n diffs.push({ path, status, before: null, after: null })\n }\n return diffs\n}\n\nexport async function mergeBranch(\n projectRoot: string,\n branch: string,\n into: string,\n): Promise<MergeResult> {\n if (into !== CONTENTRAIN_BRANCH) {\n throw Object.assign(new Error(\n `LocalProvider.mergeBranch only supports merging into \"${CONTENTRAIN_BRANCH}\" (got \"${into}\"). `\n + `The local flow merges feature branches into the content-tracking branch and fast-forwards the base branch via update-ref.`,\n ), {\n code: 'UNSUPPORTED_MERGE_TARGET',\n agent_hint: `Pass \"${CONTENTRAIN_BRANCH}\" as the merge target, or use a non-local provider that supports arbitrary targets.`,\n developer_action: `Merge \"${branch}\" into \"${CONTENTRAIN_BRANCH}\" instead.`,\n })\n }\n const result = await mergeBranchOp(projectRoot, branch)\n return {\n merged: true,\n sha: result.commit,\n pullRequestUrl: null,\n sync: result.sync,\n ...(result.remote ? { remote: result.remote } : {}),\n }\n}\n\nexport async function isMerged(\n projectRoot: string,\n branch: string,\n into: string,\n): Promise<boolean> {\n try {\n const merged = await classifyMergedBranches(projectRoot, [branch], into)\n return merged.has(branch)\n } catch {\n return false\n }\n}\n","import { spawn } from 'node:child_process'\nimport type { RepoReader } from '../core/contracts/index.js'\nimport { gitBinary } from './identity.js'\n\n/**\n * RefSnapshotReader — a `RepoReader` over one subtree of one commit, read\n * with two git spawns in total however many files the caller touches.\n *\n * `GitRefReader` spawns `git show` per file, which is right for a planner\n * reading a handful of paths and wrong for a tool that walks every model and\n * every locale file (`content_list`, `status`, validation): a few hundred\n * spawns per call (#229). Here one `ls-tree -r` lists the subtree — which\n * answers `listDirectory` and `fileExists` from memory — and the first\n * `readFile` pulls every content blob under it through a single\n * `cat-file --batch`. Only content is batched ({@link batched}): media under\n * `assets_path`, and any large or non-text file, is read on demand, one\n * `cat-file blob` each, so a list or a save never loads the project's images.\n *\n * Bound to a commit sha, not a branch name, so a snapshot never changes\n * under its reader; {@link loadRefSnapshot} caches one per (repo, sha).\n *\n * Paths are repo-relative with `/` separators; the caller maps its own paths\n * (see LocalProvider). Only paths under `root` are in the snapshot — anything\n * else reads as missing.\n */\nexport class RefSnapshotReader implements RepoReader {\n private blobs: Promise<Map<string, string>> | null = null\n private readonly single = new Map<string, Promise<string>>()\n\n constructor(\n private readonly repoDir: string,\n public readonly commit: string,\n public readonly root: string,\n /** repo-relative file path → blob oid and size in bytes */\n private readonly files: ReadonlyMap<string, SnapshotFile>,\n /** repo-relative directory path → child names */\n private readonly dirs: ReadonlyMap<string, readonly string[]>,\n ) {}\n\n async readFile(path: string, _ref?: string): Promise<string> {\n const p = trim(path)\n const file = this.files.get(p)\n if (!file) throw Object.assign(new Error(`ENOENT: ${path} is not in ${this.commit.slice(0, 8)}:${this.root}`), { code: 'ENOENT' })\n if (!batched(p, file.size)) {\n let text = this.single.get(file.oid)\n if (!text) {\n text = runGit(this.repoDir, ['cat-file', 'blob', file.oid]).then(buf => buf.toString('utf-8'))\n text.catch(() => this.single.delete(file.oid))\n this.single.set(file.oid, text)\n }\n return text\n }\n this.blobs ??= readBlobs(this.repoDir, [...new Set([...this.files].filter(([f, { size }]) => batched(f, size)).map(([, { oid }]) => oid))])\n const text = (await this.blobs).get(file.oid)\n if (text === undefined) throw new Error(`blob ${file.oid} for ${path} could not be read`)\n return text\n }\n\n async listDirectory(path: string, _ref?: string): Promise<string[]> {\n return [...(this.dirs.get(trim(path)) ?? [])]\n }\n\n async fileExists(path: string, _ref?: string): Promise<boolean> {\n const p = trim(path)\n return this.files.has(p) || this.dirs.has(p)\n }\n}\n\ninterface SnapshotFile { oid: string, size: number }\n\n/** Largest blob the batch loads; a content file is far smaller. */\nconst BATCH_MAX_BYTES = 1024 * 1024\n\n/**\n * Whether the first `readFile` loads this file with the rest: a content file —\n * JSON (config, models, content, meta) or Markdown (documents) — no larger\n * than {@link BATCH_MAX_BYTES}. Media never matches by extension, wherever\n * `assets_path` points.\n */\nfunction batched(path: string, size: number): boolean {\n return size <= BATCH_MAX_BYTES && /\\.(?:json|md|mdx)$/i.test(path)\n}\n\nconst snapshots = new Map<string, Promise<RefSnapshotReader>>()\nconst MAX_SNAPSHOTS = 8\n\n/**\n * The snapshot of `root` at `commit`, listed once per (repo, commit) for the\n * life of the process: a commit's tree never changes, so a later tool call on\n * an unmoved branch costs no spawn at all.\n */\nexport function loadRefSnapshot(repoDir: string, commit: string, root: string): Promise<RefSnapshotReader> {\n const key = `${repoDir}\\0${commit}\\0${root}`\n let hit = snapshots.get(key)\n if (!hit) {\n hit = listTree(repoDir, commit, root)\n hit.catch(() => snapshots.delete(key))\n snapshots.set(key, hit)\n // Oldest-first eviction; a long-lived server sees a new tip per write.\n if (snapshots.size > MAX_SNAPSHOTS) snapshots.delete(snapshots.keys().next().value!)\n }\n return hit\n}\n\nasync function listTree(repoDir: string, commit: string, root: string): Promise<RefSnapshotReader> {\n const raw = await runGit(repoDir, ['ls-tree', '-r', '-l', '-z', '--full-tree', commit, '--', root])\n const files = new Map<string, SnapshotFile>()\n const children = new Map<string, Set<string>>()\n for (const record of raw.toString('utf-8').split('\\0')) {\n if (!record) continue\n // \"<mode> <type> <oid> <size, space-padded>\\t<path>\"\n const tab = record.indexOf('\\t')\n const [, type, oid, size] = record.slice(0, tab).split(/ +/)\n if (type !== 'blob' || !oid) continue\n const path = record.slice(tab + 1)\n files.set(path, { oid, size: Number(size) })\n // Register every ancestor directory down from the repo root.\n const parts = path.split('/')\n for (let i = 0; i < parts.length; i++) {\n const dir = parts.slice(0, i).join('/')\n let set = children.get(dir)\n if (!set) children.set(dir, set = new Set())\n set.add(parts[i]!)\n }\n }\n const dirs = new Map([...children].map(([dir, names]) => [dir, [...names].toSorted()] as const))\n return new RefSnapshotReader(repoDir, commit, root, files, dirs)\n}\n\n/** The given blobs in one `cat-file --batch` process. */\nasync function readBlobs(repoDir: string, oids: string[]): Promise<Map<string, string>> {\n const out = new Map<string, string>()\n if (oids.length === 0) return out\n const buf = await runGit(repoDir, ['cat-file', '--batch'], `${oids.join('\\n')}\\n`)\n // Each record: \"<oid> <type> <size>\\n<size bytes>\\n\"\n let at = 0\n while (at < buf.length) {\n const eol = buf.indexOf(0x0A, at)\n if (eol < 0) break\n const [oid, , size] = buf.subarray(at, eol).toString('utf-8').split(' ')\n const bytes = Number(size)\n if (!oid || !Number.isFinite(bytes)) break\n out.set(oid, buf.subarray(eol + 1, eol + 1 + bytes).toString('utf-8'))\n at = eol + 1 + bytes + 1\n }\n return out\n}\n\nfunction runGit(cwd: string, args: string[], stdin?: string): Promise<Buffer> {\n return new Promise((resolve, reject) => {\n const child = spawn(gitBinary(), args, { cwd, stdio: ['pipe', 'pipe', 'pipe'] })\n const chunks: Buffer[] = []\n let stderr = ''\n child.stdout.on('data', (c: Buffer) => chunks.push(c))\n child.stderr.on('data', (c: Buffer) => { stderr += c.toString() })\n child.on('error', reject)\n // git may exit before reading all of stdin (a bad ref, a missing object): the write then fails\n // with EPIPE. The exit code below reports that failure; unhandled, it would crash the process.\n child.stdin.on('error', () => {})\n child.on('close', (code) => {\n if (code === 0) resolve(Buffer.concat(chunks))\n else reject(new Error(`git ${args[0]} exited ${code}: ${stderr.trim()}`))\n })\n child.stdin.end(stdin ?? '')\n })\n}\n\nfunction trim(path: string): string {\n let p = path.replaceAll('\\\\', '/')\n while (p.startsWith('./')) p = p.slice(2)\n while (p.endsWith('/')) p = p.slice(0, -1)\n return p === '.' ? '' : p\n}\n","import { AsyncLocalStorage } from 'node:async_hooks'\nimport { readFile, stat } from 'node:fs/promises'\nimport { join, resolve } from 'node:path'\nimport { CONTENTRAIN_BRANCH, type ContentReadSource } from '@contentrain/types'\nimport { readConfig } from '../../core/config.js'\nimport { resolveBaseBranch } from '../../git/base-branch.js'\nimport { createGit } from '../../git/identity.js'\nimport { loadRefSnapshot, type RefSnapshotReader } from '../../git/ref-snapshot.js'\n\n/**\n * Where a LocalProvider reads `.contentrain/` from (#229).\n *\n * Content writes land on `contentrain` and the base branch; a checked-out\n * feature branch is deliberately left alone (#227). Its working tree is then\n * behind every write, so reading `.contentrain/` from it plans the next write\n * from a stale value and shows read tools stale content. On such a branch the\n * content is read from the `contentrain` ref instead. Everything else — source\n * files, scan, normalize — still comes from the working tree.\n *\n * Working tree, unchanged from before, when:\n * - the base branch is checked out (writes advance it, so it is current once\n * pulled — #226's carry-over and `CONTENT_WORKING_TREE_STALE` still apply);\n * - HEAD is detached (a CI checkout validates the commit it checked out);\n * - HEAD is `contentrain` itself or a `cr/*` review branch (their tree IS\n * the content under review);\n * - there is no `contentrain` branch yet, or git cannot answer.\n */\nexport type ContentView =\n | { source: 'working_tree' }\n | { source: 'ref', ref: string, commit: string, branch: string, reader: RefSnapshotReader, prefix: string }\n\n/** The `.contentrain` paths that are never committed: always the working tree. */\nconst UNTRACKED = ['.contentrain/.cache', '.contentrain/client']\n\nconst scope = new AsyncLocalStorage<Map<string, Promise<ContentView>>>()\n\n/**\n * Run `fn` with one content view per project for its whole duration: every\n * read inside answers from the same snapshot, resolved once. The MCP server\n * wraps each tool call in this, so a call costs a fixed handful of spawns\n * however many files it reads.\n */\nexport function withReadScope<T>(fn: () => T): T {\n return scope.run(new Map(), fn)\n}\n\nexport function contentView(projectRoot: string): Promise<ContentView> {\n const memo = scope.getStore()\n if (!memo) return resolveView(projectRoot)\n let view = memo.get(projectRoot)\n if (!view) {\n view = resolveView(projectRoot)\n memo.set(projectRoot, view)\n }\n return view\n}\n\nexport function describeSource(view: ContentView): ContentReadSource {\n return view.source === 'working_tree'\n ? { source: 'working_tree' }\n : { source: 'ref', ref: view.ref, commit: view.commit, checked_out: view.branch }\n}\n\n/**\n * The repo-relative path a snapshot answers for, or null when `path` must come\n * from the working tree (outside `.contentrain/`, or never committed).\n */\nexport function refPath(view: ContentView & { source: 'ref' }, projectRoot: string, path: string): string | null {\n const abs = resolve(projectRoot, path)\n const root = resolve(projectRoot)\n if (abs !== root && !abs.startsWith(`${root}/`) && !abs.startsWith(`${root}\\\\`)) return null\n const rel = abs.slice(root.length + 1).replaceAll('\\\\', '/')\n if (rel !== '.contentrain' && !rel.startsWith('.contentrain/')) return null\n if (UNTRACKED.some(p => rel === p || rel.startsWith(`${p}/`))) return null\n return view.prefix + rel\n}\n\nasync function resolveView(projectRoot: string): Promise<ContentView> {\n try {\n const head = await headBranch(projectRoot)\n if (!head || head === CONTENTRAIN_BRANCH || head.startsWith('cr/')) return { source: 'working_tree' }\n const git = createGit(projectRoot)\n const base = await resolveBaseBranch(git, await readConfig(projectRoot), { currentBranch: head })\n if (head === base) return { source: 'working_tree' }\n const [prefix, commit] = (await git.raw(['rev-parse', '--show-prefix', `refs/heads/${CONTENTRAIN_BRANCH}^{commit}`]))\n .split('\\n').map(line => line.trim())\n if (!commit) return { source: 'working_tree' }\n const root = `${prefix ?? ''}.contentrain`\n const reader = await loadRefSnapshot(projectRoot, commit, root)\n return { source: 'ref', ref: CONTENTRAIN_BRANCH, commit, branch: head, reader, prefix: prefix ?? '' }\n } catch {\n return { source: 'working_tree' }\n }\n}\n\n/**\n * The checked-out branch, or null when HEAD is detached. Read from the HEAD\n * file when `.git` sits at the project root (no spawn — this runs once per\n * tool call on every local project); asked of git otherwise (a linked\n * worktree, or a project in a monorepo subdirectory).\n */\nasync function headBranch(projectRoot: string): Promise<string | null> {\n const dotGit = join(projectRoot, '.git')\n try {\n let gitDir = dotGit\n if ((await stat(dotGit)).isFile()) {\n const pointer = (await readFile(dotGit, 'utf-8')).match(/^gitdir:\\s*(.+)$/m)?.[1]?.trim()\n if (!pointer) throw new Error('unreadable .git file')\n gitDir = resolve(projectRoot, pointer)\n }\n const head = (await readFile(join(gitDir, 'HEAD'), 'utf-8')).trim()\n return head.startsWith('ref: refs/heads/') ? head.slice('ref: refs/heads/'.length) : null\n } catch {\n const out = await createGit(projectRoot).raw(['symbolic-ref', '-q', '--short', 'HEAD']).catch(() => '')\n return out.trim() || null\n }\n}\n","import { MODEL_FIELD_ORDER } from '@contentrain/types'\nimport type { FileChange } from '../../core/contracts/index.js'\nimport { canonicalStringify, sortKeys } from '../../core/serialization/index.js'\n\n/**\n * Carry a plan's full-file changes over to the tree they are committed on.\n *\n * The local provider plans against the developer's working tree, but commits\n * in a worktree on the `contentrain` branch after fetching and merging the\n * remote. Those two trees differ whenever another writer (Studio, a teammate,\n * CI) reached `contentrain` since the developer last pulled. A plan is a set\n * of whole files, so writing it as-is replaced every file it touched with a\n * copy built from the stale tree — silently deleting whatever the other\n * writer had added (#226).\n *\n * Each change is a three-way merge instead:\n * basis — the file as the planner read it (working tree, `null` if absent)\n * planned — the change's content (`null` for a delete)\n * current — the file in the worktree the commit is built on\n *\n * `current === basis` is the common case and writes the plan verbatim. When\n * they differ, JSON object files (collection / dictionary / singleton content,\n * meta, model definitions) are merged key by key: what the plan changed\n * relative to its basis is applied on top of `current`, recursively through\n * nested objects. Anything that cannot be merged that way — both sides\n * changed the same value, a markdown document, a delete of a file that\n * gained entries — is a conflict. Conflicts are never resolved by guessing:\n * the caller refuses the write.\n *\n * One exception, in meta files only: the write stamp (`updated_at`,\n * `updated_by`, `source`). Every write re-stamps the entries it touches\n * (`mergeEntryMeta`, `applyStatusChange`), so two writes to the same entry\n * always disagree there. The stamp describes the latest write, which is this\n * one — so when this write re-stamped an entry, its whole stamp wins, as a\n * unit (never this write's `updated_at` with the other writer's\n * `updated_by`). `status` and scheduling still merge or conflict normally.\n */\nexport type RebaseResult =\n | { ok: true, change: FileChange }\n | { ok: false, path: string, reason: string }\n\nexport function rebaseChange(change: FileChange, basis: string | null, current: string | null): RebaseResult {\n if (current === basis || current === change.content) return { ok: true, change }\n\n const reject = (reason: string): RebaseResult => ({ ok: false, path: change.path, reason })\n\n if (basis !== null && current === null) {\n return reject('the file was deleted on the contentrain branch since it was read')\n }\n if (!change.path.endsWith('.json')) {\n return reject('the file changed on the contentrain branch since it was read, and only JSON files can be merged')\n }\n\n const base = basis === null ? {} : parseObject(basis)\n const theirs = current === null ? {} : parseObject(current)\n const ours = change.content === null ? {} : parseObject(change.content)\n if (!base || !theirs || !ours) {\n return reject('the file changed on the contentrain branch since it was read, and is not a JSON object')\n }\n\n const stampWins = change.path.startsWith('.contentrain/meta/')\n const merged = merge3(base, ours, theirs, [], stampWins)\n if (!merged.ok) {\n return reject(`${describeKey(merged.at)} was changed both by this write and on the contentrain branch`)\n }\n const value = merged.value === ABSENT ? {} : merged.value as Record<string, unknown>\n\n if (change.content === null) {\n // A delete merges to \"the keys only the other writer has\". Writing those\n // back would keep a file this operation meant to remove (a deleted model's\n // content, say) — refuse instead and let the caller decide after a pull.\n return Object.keys(value).length === 0\n ? { ok: true, change }\n : reject('the file gained entries on the contentrain branch since it was read, so deleting it would drop them')\n }\n\n const fieldOrder = change.path.startsWith('.contentrain/models/') ? MODEL_FIELD_ORDER : undefined\n return { ok: true, change: { path: change.path, content: canonicalStringify(value, fieldOrder) } }\n}\n\nconst ABSENT = Symbol('absent')\ntype Value = unknown | typeof ABSENT\n\ntype MergeResult = { ok: true, value: Value } | { ok: false, at: string[] }\n\n/** Meta keys that record who wrote last and when — see the module comment. */\nconst WRITE_STAMP = new Set(['updated_at', 'updated_by', 'source'])\n\nfunction merge3(base: Value, ours: Value, theirs: Value, at: string[], stampWins: boolean): MergeResult {\n if (same(ours, base)) return { ok: true, value: theirs }\n if (same(theirs, base)) return { ok: true, value: ours }\n if (same(ours, theirs)) return { ok: true, value: ours }\n if (!isPlainObject(base) || !isPlainObject(ours) || !isPlainObject(theirs)) {\n return { ok: false, at }\n }\n\n const restamped = stampWins\n && [...WRITE_STAMP].some(key => !same(field(ours, key), field(base, key)))\n const out: Record<string, unknown> = {}\n const keys = new Set([...Object.keys(base), ...Object.keys(ours), ...Object.keys(theirs)])\n for (const key of keys) {\n if (restamped && WRITE_STAMP.has(key)) {\n const value = field(ours, key)\n if (value !== ABSENT) out[key] = value\n continue\n }\n const result = merge3(field(base, key), field(ours, key), field(theirs, key), [...at, key], stampWins)\n if (!result.ok) return result\n if (result.value !== ABSENT) out[key] = result.value\n }\n return { ok: true, value: out }\n}\n\nfunction field(obj: Record<string, unknown>, key: string): Value {\n return Object.hasOwn(obj, key) && obj[key] !== null && obj[key] !== undefined ? obj[key] : ABSENT\n}\n\n/** Canonical equality — the same normalisation the serializer applies (sorted keys, nulls dropped). */\nfunction same(a: Value, b: Value): boolean {\n if (a === ABSENT || b === ABSENT) return a === b\n return JSON.stringify(sortKeys(a)) === JSON.stringify(sortKeys(b))\n}\n\nfunction isPlainObject(value: Value): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\nfunction parseObject(raw: string): Record<string, unknown> | undefined {\n try {\n const parsed: unknown = JSON.parse(raw)\n return isPlainObject(parsed) ? parsed : undefined\n } catch {\n return undefined\n }\n}\n\nfunction describeKey(at: string[]): string {\n if (at.length === 0) return 'the file'\n if (at.length === 1) return `\"${at[0]}\"`\n return `\"${at[0]}\" (field ${at.slice(1).map(k => `\"${k}\"`).join(' → ')})`\n}\n","import { readFile } from 'node:fs/promises'\nimport { join } from 'node:path'\nimport { tmpdir } from 'node:os'\nimport { randomUUID } from 'node:crypto'\nimport { CONTENTRAIN_BRANCH } from '@contentrain/types'\nimport type {\n Branch,\n Commit,\n CommitAuthor,\n ContentReadSource,\n FileChange,\n FileDiff,\n MergeResult,\n ProviderCapabilities,\n RepoProvider,\n WriteReadiness,\n} from '../../core/contracts/index.js'\nimport { LOCAL_CAPABILITIES } from '../../core/contracts/index.js'\nimport { applyChangesToWorktree } from '../../core/ops/index.js'\nimport { checkBranchHealth } from '../../git/branch-lifecycle.js'\nimport { createGit } from '../../git/identity.js'\nimport { createTransaction } from '../../git/transaction.js'\nimport {\n createBranch as createBranchOp,\n deleteBranch as deleteBranchOp,\n getBranchDiff as getBranchDiffOp,\n getDefaultBranch as getDefaultBranchOp,\n isMerged as isMergedOp,\n listBranches as listBranchesOp,\n mergeBranch as mergeBranchOp,\n} from './branch-ops.js'\nimport { contentView, describeSource, refPath, type ContentView } from './content-view.js'\nimport { LocalReader } from './reader.js'\nimport { rebaseChange } from './rebase-changes.js'\nimport type { LocalApplyPlanInput, LocalApplyResult } from './types.js'\n\nconst DEFAULT_AUTHOR_NAME = 'Contentrain'\nconst DEFAULT_AUTHOR_EMAIL = 'ai@contentrain.io'\n\n/**\n * LocalProvider — the local-filesystem, worktree-backed content provider.\n *\n * Implements the full `RepoProvider` surface:\n * - Reader methods delegate to `LocalReader`.\n * - `applyPlan` wraps `createTransaction` and returns `LocalApplyResult`\n * (a superset of `Commit` carrying workflow action + selective sync).\n * - Branch ops mirror `GitHubProvider` — thin wrappers over the local\n * simple-git helpers in `./branch-ops.ts`.\n *\n * `mergeBranch` only supports merging into the singleton\n * `CONTENTRAIN_BRANCH`; the local flow advances the base branch via\n * `update-ref` in `transaction.mergeBranch`, so arbitrary merge targets\n * would bypass that invariant.\n */\nexport class LocalProvider implements RepoProvider {\n readonly capabilities: ProviderCapabilities = LOCAL_CAPABILITIES\n private readonly reader: LocalReader\n\n constructor(public readonly projectRoot: string) {\n this.reader = new LocalReader(projectRoot)\n }\n\n // Reads: `.contentrain/**` from the content view (the `contentrain` ref\n // while a feature branch is checked out — #229), everything else from the\n // working tree. See content-view.ts.\n\n async readFile(path: string, ref?: string): Promise<string> {\n const at = await this.routed(path)\n return at ? at.view.reader.readFile(at.path) : this.reader.readFile(path, ref)\n }\n\n async listDirectory(path: string, ref?: string): Promise<string[]> {\n const at = await this.routed(path)\n return at ? at.view.reader.listDirectory(at.path) : this.reader.listDirectory(path, ref)\n }\n\n async fileExists(path: string, ref?: string): Promise<boolean> {\n const at = await this.routed(path)\n return at ? at.view.reader.fileExists(at.path) : this.reader.fileExists(path, ref)\n }\n\n async contentSource(): Promise<ContentReadSource> {\n return describeSource(await contentView(this.projectRoot))\n }\n\n private async routed(path: string): Promise<{ view: ContentView & { source: 'ref' }, path: string } | null> {\n const view = await contentView(this.projectRoot)\n if (view.source !== 'ref') return null\n const inRef = refPath(view, this.projectRoot, path)\n return inRef === null ? null : { view, path: inRef }\n }\n\n /**\n * Branch pressure gate. Unmerged `cr/*` branches accumulate in a local repo\n * and eventually have to stop new writes; hosted providers have no such\n * limit, so they omit this method entirely.\n */\n async checkWriteReadiness(): Promise<WriteReadiness> {\n const health = await checkBranchHealth(this.projectRoot)\n return health.blocked\n ? { blocked: true, message: health.message ?? 'Too many active contentrain branches.' }\n : { blocked: false }\n }\n\n async applyPlan(input: LocalApplyPlanInput): Promise<LocalApplyResult> {\n // What the planner read: the content view — the working tree, or the\n // `contentrain` snapshot on a feature branch (#229). Captured before the\n // transaction fetches, so it is the tree the plan was computed from — not\n // whatever the remote says now.\n const view = await contentView(this.projectRoot)\n const basis = await Promise.all(input.changes.map(c => this.readBasis(view, c.path)))\n const tx = await createTransaction(this.projectRoot, input.branch, {\n workflowOverride: input.workflowOverride,\n })\n try {\n await tx.write(async (wt) => {\n // The worktree is the contentrain tip after fetch + merge — the tree\n // this commit is built on. Carry the plan over to it instead of\n // overwriting it with files built from a possibly stale working tree\n // (#226). See rebaseChange.\n const current = await Promise.all(input.changes.map(c => readOrNull(join(wt, c.path))))\n const results = input.changes.map((change, i) => rebaseChange(change, basis[i]!, current[i]!))\n const conflicts = results.filter(r => !r.ok)\n if (conflicts.length > 0) {\n throw Object.assign(new Error(\n `Nothing was written: the content branch moved since your working tree was last updated, and this change cannot be applied on top of it. `\n + conflicts.map(c => `${c.path}: ${c.reason}.`).join(' '),\n ), staleTreeGuidance(tx.baseBranch, tx.baseCheckedOut, view.source === 'ref'))\n }\n await applyChangesToWorktree(wt, results.map(r => (r as Extract<typeof r, { ok: true }>).change))\n })\n await tx.commit(input.message, input.context)\n const gitResult = await tx.complete()\n return {\n sha: gitResult.commit,\n message: input.message,\n author: input.author ?? {\n name: process.env['CONTENTRAIN_AUTHOR_NAME'] ?? DEFAULT_AUTHOR_NAME,\n email: process.env['CONTENTRAIN_AUTHOR_EMAIL'] ?? DEFAULT_AUTHOR_EMAIL,\n },\n timestamp: new Date().toISOString(),\n workflowAction: gitResult.action,\n sync: gitResult.sync,\n warning: gitResult.warning,\n base_advance: gitResult.base_advance,\n remote_push: gitResult.remote_push,\n }\n } finally {\n await tx.cleanup()\n }\n }\n\n private async readBasis(view: ContentView, path: string): Promise<string | null> {\n if (view.source === 'ref') {\n const inRef = refPath(view, this.projectRoot, path)\n if (inRef !== null) return view.reader.readFile(inRef).catch(() => null)\n }\n return readOrNull(join(this.projectRoot, path))\n }\n\n listBranches(prefix?: string): Promise<Branch[]> {\n return listBranchesOp(this.projectRoot, prefix)\n }\n\n async createBranch(name: string, fromRef?: string): Promise<void> {\n const resolved = fromRef ?? CONTENTRAIN_BRANCH\n await createBranchOp(this.projectRoot, name, resolved)\n }\n\n deleteBranch(name: string): Promise<void> {\n return deleteBranchOp(this.projectRoot, name)\n }\n\n getBranchDiff(branch: string, base?: string): Promise<FileDiff[]> {\n const resolved = base ?? CONTENTRAIN_BRANCH\n return getBranchDiffOp(this.projectRoot, branch, resolved)\n }\n\n mergeBranch(branch: string, into: string): Promise<MergeResult> {\n return mergeBranchOp(this.projectRoot, branch, into)\n }\n\n isMerged(branch: string, into?: string): Promise<boolean> {\n const resolved = into ?? CONTENTRAIN_BRANCH\n return isMergedOp(this.projectRoot, branch, resolved)\n }\n\n getDefaultBranch(): Promise<string> {\n return getDefaultBranchOp(this.projectRoot)\n }\n\n async getMergeBase(refA: string, refB: string): Promise<string | null> {\n const git = createGit(this.projectRoot)\n try {\n return (await git.raw(['merge-base', refA, refB])).trim() || null\n } catch {\n return null\n }\n }\n\n /**\n * Two-parent merge commit in a temp worktree. The real `git merge`\n * resolves everything outside the plan; the plan's changes then\n * overwrite every content-owned path (planner output is authoritative,\n * never git's textual auto-merge). Leftover unmerged paths abort — they\n * are outside the content planner's scope by construction.\n */\n async createMergeCommit(input: {\n branch: string\n ours: string\n theirs: string\n changes: FileChange[]\n message: string\n author: CommitAuthor\n }): Promise<Commit> {\n const git = createGit(this.projectRoot)\n const tip = (await git.raw(['rev-parse', input.branch])).trim()\n if (tip !== input.ours) {\n throw Object.assign(new Error(\n `createMergeCommit: \"${input.branch}\" is at ${tip.slice(0, 8)}, not the expected ${input.ours.slice(0, 8)} — the branch moved since planning.`,\n ), {\n code: 'RECONCILE_STALE_OURS',\n agent_hint: 'Re-run the reconcile plan against the current tip.',\n })\n }\n\n const worktreePath = join(tmpdir(), `cr-merge-commit-${randomUUID()}`)\n await git.raw(['worktree', 'add', worktreePath, input.branch])\n const wtGit = createGit(worktreePath, {\n config: [\n `user.name=${input.author.name}`,\n `user.email=${input.author.email}`,\n ],\n })\n try {\n try {\n await wtGit.merge([input.theirs, '--no-commit', '--no-ff'])\n } catch {\n // Conflicts are expected — the plan overwrites content paths next.\n }\n await applyChangesToWorktree(worktreePath, input.changes)\n await wtGit.raw(['add', '-A'])\n const unmerged = (await wtGit.raw(['ls-files', '-u'])).trim()\n if (unmerged) {\n const paths = [...new Set(unmerged.split('\\n').map(l => l.split('\\t')[1]).filter(Boolean))]\n try { await wtGit.merge(['--abort']) } catch { /* not in merge state */ }\n throw Object.assign(new Error(\n `createMergeCommit: ${paths.length} non-content file(s) conflict: ${paths.join(', ')}.`,\n ), { code: 'RECONCILE_SOURCE_CONFLICT' })\n }\n await wtGit.commit(input.message, { '--no-verify': null })\n const sha = (await wtGit.raw(['rev-parse', 'HEAD'])).trim()\n return {\n sha,\n message: input.message,\n author: input.author,\n timestamp: new Date().toISOString(),\n }\n } finally {\n try {\n await git.raw(['worktree', 'remove', worktreePath, '--force'])\n } catch {\n // worktree may already be cleaned up\n }\n }\n }\n}\n\nasync function readOrNull(path: string): Promise<string | null> {\n try {\n return await readFile(path, 'utf-8')\n } catch {\n return null\n }\n}\n\n/**\n * How to bring the planner's tree up to date. On the base branch that is a\n * pull. On a feature branch the content never reaches the working tree (the\n * advance leaves a non-base checkout alone), so a pull fetches nothing\n * useful — merging the base is what brings it in.\n */\nfunction staleTreeGuidance(baseBranch: string, baseCheckedOut: boolean, readFromRef: boolean): {\n code: string\n agent_hint: string\n developer_action?: string\n} {\n // Planned from the contentrain snapshot (#229): nothing on the developer's\n // side is stale — another writer moved contentrain between the read and\n // this write. Re-reading is the whole fix.\n if (readFromRef) {\n return {\n code: 'CONTENT_WORKING_TREE_STALE',\n agent_hint: `Another writer (Studio, a teammate, CI) changed the same value on the contentrain branch after this write read it. Re-read the content and retry — do not retry unchanged. No git action is needed.`,\n }\n }\n const update = baseCheckedOut\n ? 'Ask the developer to update their working tree (git pull)'\n : `The developer is on a branch other than \"${baseBranch}\", which content writes do not update. Ask them to merge \"${baseBranch}\" into it (or rebase onto it)`\n return {\n code: 'CONTENT_WORKING_TREE_STALE',\n agent_hint: `The content on the contentrain branch differs from the working tree this write was planned from — another writer (Studio, a teammate, CI) or an earlier write changed the same value. ${update}, then re-read the content and retry — do not retry unchanged.`,\n developer_action: baseCheckedOut ? 'git pull' : `git merge ${baseBranch}`,\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"local-DaITA8Ye.mjs","names":["mergeBranchOp","canonicalStringify","sortKeys","listBranchesOp","createBranchOp","deleteBranchOp","getBranchDiffOp","mergeBranchOp","isMergedOp","getDefaultBranchOp"],"sources":["../src/providers/local/branch-ops.ts","../src/git/ref-snapshot.ts","../src/providers/local/content-view.ts","../src/providers/local/rebase-changes.ts","../src/providers/local/provider.ts"],"sourcesContent":["import { CONTENTRAIN_BRANCH } from '@contentrain/types'\nimport { createGit } from '../../git/identity.js'\nimport type { Branch, FileDiff, MergeResult } from '../../core/contracts/index.js'\nimport { readConfig } from '../../core/config.js'\nimport { resolveBaseBranch } from '../../git/base-branch.js'\nimport { classifyMergedBranches, deleteRemoteBranch } from '../../git/branch-lifecycle.js'\nimport { mergeBranch as mergeBranchOp } from '../../git/transaction.js'\n\n/**\n * Branch/merge/diff helpers backed by the local simple-git worktree flow.\n *\n * Pure functions composed into `LocalProvider` — mirroring the shape of\n * `providers/github/branch-ops.ts` so the two providers share the same\n * surface at the `RepoProvider` boundary.\n */\n\nexport async function getDefaultBranch(projectRoot: string): Promise<string> {\n return resolveBaseBranch(createGit(projectRoot), await readConfig(projectRoot))\n}\n\nexport async function listBranches(\n projectRoot: string,\n prefix?: string,\n): Promise<Branch[]> {\n const git = createGit(projectRoot)\n const summary = await git.branchLocal()\n const names = prefix\n ? summary.all.filter(n => n.startsWith(prefix))\n : summary.all\n const branches: Branch[] = []\n for (const name of names) {\n const info = summary.branches[name]\n branches.push({ name, sha: info?.commit ?? '' })\n }\n return branches\n}\n\nexport async function createBranch(\n projectRoot: string,\n name: string,\n fromRef: string,\n): Promise<void> {\n const git = createGit(projectRoot)\n await git.raw(['branch', name, fromRef])\n}\n\nexport async function deleteBranch(\n projectRoot: string,\n name: string,\n): Promise<void> {\n const git = createGit(projectRoot)\n await git.deleteLocalBranch(name, true)\n // Parity with the remote-API providers, whose deleteBranch removes the\n // remote ref: best-effort, config-gated inside the helper, never throws.\n await deleteRemoteBranch(projectRoot, name)\n}\n\nexport async function getBranchDiff(\n projectRoot: string,\n branch: string,\n base: string,\n): Promise<FileDiff[]> {\n const git = createGit(projectRoot)\n const raw = await git.raw(['diff', '--name-status', `${base}...${branch}`])\n const diffs: FileDiff[] = []\n for (const line of raw.split('\\n')) {\n if (!line.trim()) continue\n const [code, ...pathParts] = line.split('\\t')\n const path = pathParts[pathParts.length - 1]\n if (!code || !path) continue\n const status: FileDiff['status'] = code.startsWith('A')\n ? 'added'\n : code.startsWith('D')\n ? 'removed'\n : 'modified'\n diffs.push({ path, status, before: null, after: null })\n }\n return diffs\n}\n\nexport async function mergeBranch(\n projectRoot: string,\n branch: string,\n into: string,\n): Promise<MergeResult> {\n if (into !== CONTENTRAIN_BRANCH) {\n throw Object.assign(new Error(\n `LocalProvider.mergeBranch only supports merging into \"${CONTENTRAIN_BRANCH}\" (got \"${into}\"). `\n + `The local flow merges feature branches into the content-tracking branch and fast-forwards the base branch via update-ref.`,\n ), {\n code: 'UNSUPPORTED_MERGE_TARGET',\n agent_hint: `Pass \"${CONTENTRAIN_BRANCH}\" as the merge target, or use a non-local provider that supports arbitrary targets.`,\n developer_action: `Merge \"${branch}\" into \"${CONTENTRAIN_BRANCH}\" instead.`,\n })\n }\n const result = await mergeBranchOp(projectRoot, branch)\n return {\n merged: true,\n sha: result.commit,\n pullRequestUrl: null,\n sync: result.sync,\n ...(result.remote ? { remote: result.remote } : {}),\n }\n}\n\nexport async function isMerged(\n projectRoot: string,\n branch: string,\n into: string,\n): Promise<boolean> {\n try {\n const merged = await classifyMergedBranches(projectRoot, [branch], into)\n return merged.has(branch)\n } catch {\n return false\n }\n}\n","import { spawn } from 'node:child_process'\nimport type { RepoReader } from '../core/contracts/index.js'\nimport { gitBinary } from './identity.js'\n\n/**\n * RefSnapshotReader — a `RepoReader` over one subtree of one commit, read\n * with two git spawns in total however many files the caller touches.\n *\n * `GitRefReader` spawns `git show` per file, which is right for a planner\n * reading a handful of paths and wrong for a tool that walks every model and\n * every locale file (`content_list`, `status`, validation): a few hundred\n * spawns per call (#229). Here one `ls-tree -r` lists the subtree — which\n * answers `listDirectory` and `fileExists` from memory — and the first\n * `readFile` pulls every content blob under it through a single\n * `cat-file --batch`. Only content is batched ({@link batched}): media under\n * `assets_path`, and any large or non-text file, is read on demand, one\n * `cat-file blob` each, so a list or a save never loads the project's images.\n *\n * Bound to a commit sha, not a branch name, so a snapshot never changes\n * under its reader; {@link loadRefSnapshot} caches one per (repo, sha).\n *\n * Paths are repo-relative with `/` separators; the caller maps its own paths\n * (see LocalProvider). Only paths under `root` are in the snapshot — anything\n * else reads as missing.\n */\nexport class RefSnapshotReader implements RepoReader {\n private blobs: Promise<Map<string, string>> | null = null\n private readonly single = new Map<string, Promise<string>>()\n\n constructor(\n private readonly repoDir: string,\n public readonly commit: string,\n public readonly root: string,\n /** repo-relative file path → blob oid and size in bytes */\n private readonly files: ReadonlyMap<string, SnapshotFile>,\n /** repo-relative directory path → child names */\n private readonly dirs: ReadonlyMap<string, readonly string[]>,\n ) {}\n\n async readFile(path: string, _ref?: string): Promise<string> {\n const p = trim(path)\n const file = this.files.get(p)\n if (!file) throw Object.assign(new Error(`ENOENT: ${path} is not in ${this.commit.slice(0, 8)}:${this.root}`), { code: 'ENOENT' })\n if (!batched(p, file.size)) {\n let text = this.single.get(file.oid)\n if (!text) {\n text = runGit(this.repoDir, ['cat-file', 'blob', file.oid]).then(buf => buf.toString('utf-8'))\n text.catch(() => this.single.delete(file.oid))\n this.single.set(file.oid, text)\n }\n return text\n }\n this.blobs ??= readBlobs(this.repoDir, [...new Set([...this.files].filter(([f, { size }]) => batched(f, size)).map(([, { oid }]) => oid))])\n const text = (await this.blobs).get(file.oid)\n if (text === undefined) throw new Error(`blob ${file.oid} for ${path} could not be read`)\n return text\n }\n\n async listDirectory(path: string, _ref?: string): Promise<string[]> {\n return [...(this.dirs.get(trim(path)) ?? [])]\n }\n\n async fileExists(path: string, _ref?: string): Promise<boolean> {\n const p = trim(path)\n return this.files.has(p) || this.dirs.has(p)\n }\n}\n\ninterface SnapshotFile { oid: string, size: number }\n\n/** Largest blob the batch loads; a content file is far smaller. */\nconst BATCH_MAX_BYTES = 1024 * 1024\n\n/**\n * Whether the first `readFile` loads this file with the rest: a content file —\n * JSON (config, models, content, meta) or Markdown (documents) — no larger\n * than {@link BATCH_MAX_BYTES}. Media never matches by extension, wherever\n * `assets_path` points.\n */\nfunction batched(path: string, size: number): boolean {\n return size <= BATCH_MAX_BYTES && /\\.(?:json|md|mdx)$/i.test(path)\n}\n\nconst snapshots = new Map<string, Promise<RefSnapshotReader>>()\nconst MAX_SNAPSHOTS = 8\n\n/**\n * The snapshot of `root` at `commit`, listed once per (repo, commit) for the\n * life of the process: a commit's tree never changes, so a later tool call on\n * an unmoved branch costs no spawn at all.\n */\nexport function loadRefSnapshot(repoDir: string, commit: string, root: string): Promise<RefSnapshotReader> {\n const key = `${repoDir}\\0${commit}\\0${root}`\n let hit = snapshots.get(key)\n if (!hit) {\n hit = listTree(repoDir, commit, root)\n hit.catch(() => snapshots.delete(key))\n snapshots.set(key, hit)\n // Oldest-first eviction; a long-lived server sees a new tip per write.\n if (snapshots.size > MAX_SNAPSHOTS) snapshots.delete(snapshots.keys().next().value!)\n }\n return hit\n}\n\nasync function listTree(repoDir: string, commit: string, root: string): Promise<RefSnapshotReader> {\n const raw = await runGit(repoDir, ['ls-tree', '-r', '-l', '-z', '--full-tree', commit, '--', root])\n const files = new Map<string, SnapshotFile>()\n const children = new Map<string, Set<string>>()\n for (const record of raw.toString('utf-8').split('\\0')) {\n if (!record) continue\n // \"<mode> <type> <oid> <size, space-padded>\\t<path>\"\n const tab = record.indexOf('\\t')\n const [, type, oid, size] = record.slice(0, tab).split(/ +/)\n if (type !== 'blob' || !oid) continue\n const path = record.slice(tab + 1)\n files.set(path, { oid, size: Number(size) })\n // Register every ancestor directory down from the repo root.\n const parts = path.split('/')\n for (let i = 0; i < parts.length; i++) {\n const dir = parts.slice(0, i).join('/')\n let set = children.get(dir)\n if (!set) children.set(dir, set = new Set())\n set.add(parts[i]!)\n }\n }\n const dirs = new Map([...children].map(([dir, names]) => [dir, [...names].toSorted()] as const))\n return new RefSnapshotReader(repoDir, commit, root, files, dirs)\n}\n\n/** The given blobs in one `cat-file --batch` process. */\nasync function readBlobs(repoDir: string, oids: string[]): Promise<Map<string, string>> {\n const out = new Map<string, string>()\n if (oids.length === 0) return out\n const buf = await runGit(repoDir, ['cat-file', '--batch'], `${oids.join('\\n')}\\n`)\n // Each record: \"<oid> <type> <size>\\n<size bytes>\\n\"\n let at = 0\n while (at < buf.length) {\n const eol = buf.indexOf(0x0A, at)\n if (eol < 0) break\n const [oid, , size] = buf.subarray(at, eol).toString('utf-8').split(' ')\n const bytes = Number(size)\n if (!oid || !Number.isFinite(bytes)) break\n out.set(oid, buf.subarray(eol + 1, eol + 1 + bytes).toString('utf-8'))\n at = eol + 1 + bytes + 1\n }\n return out\n}\n\nfunction runGit(cwd: string, args: string[], stdin?: string): Promise<Buffer> {\n return new Promise((resolve, reject) => {\n const child = spawn(gitBinary(), args, { cwd, stdio: ['pipe', 'pipe', 'pipe'] })\n const chunks: Buffer[] = []\n let stderr = ''\n child.stdout.on('data', (c: Buffer) => chunks.push(c))\n child.stderr.on('data', (c: Buffer) => { stderr += c.toString() })\n child.on('error', reject)\n // git may exit before reading all of stdin (a bad ref, a missing object): the write then fails\n // with EPIPE. The exit code below reports that failure; unhandled, it would crash the process.\n child.stdin.on('error', () => {})\n child.on('close', (code) => {\n if (code === 0) resolve(Buffer.concat(chunks))\n else reject(new Error(`git ${args[0]} exited ${code}: ${stderr.trim()}`))\n })\n child.stdin.end(stdin ?? '')\n })\n}\n\nfunction trim(path: string): string {\n let p = path.replaceAll('\\\\', '/')\n while (p.startsWith('./')) p = p.slice(2)\n while (p.endsWith('/')) p = p.slice(0, -1)\n return p === '.' ? '' : p\n}\n","import { AsyncLocalStorage } from 'node:async_hooks'\nimport { readFile, stat } from 'node:fs/promises'\nimport { join, resolve } from 'node:path'\nimport { CONTENTRAIN_BRANCH, type ContentReadSource } from '@contentrain/types'\nimport { readConfig } from '../../core/config.js'\nimport { resolveBaseBranch } from '../../git/base-branch.js'\nimport { createGit } from '../../git/identity.js'\nimport { loadRefSnapshot, type RefSnapshotReader } from '../../git/ref-snapshot.js'\n\n/**\n * Where a LocalProvider reads `.contentrain/` from (#229).\n *\n * Content writes land on `contentrain` and the base branch; a checked-out\n * feature branch is deliberately left alone (#227). Its working tree is then\n * behind every write, so reading `.contentrain/` from it plans the next write\n * from a stale value and shows read tools stale content. On such a branch the\n * content is read from the `contentrain` ref instead. Everything else — source\n * files, scan, normalize — still comes from the working tree.\n *\n * Working tree, unchanged from before, when:\n * - the base branch is checked out (writes advance it, so it is current once\n * pulled — #226's carry-over and `CONTENT_WORKING_TREE_STALE` still apply);\n * - HEAD is detached (a CI checkout validates the commit it checked out);\n * - HEAD is `contentrain` itself or a `cr/*` review branch (their tree IS\n * the content under review);\n * - there is no `contentrain` branch yet, or git cannot answer.\n */\nexport type ContentView =\n | { source: 'working_tree' }\n | { source: 'ref', ref: string, commit: string, branch: string, reader: RefSnapshotReader, prefix: string }\n\n/** The `.contentrain` paths that are never committed: always the working tree. */\nconst UNTRACKED = ['.contentrain/.cache', '.contentrain/client']\n\nconst scope = new AsyncLocalStorage<Map<string, Promise<ContentView>>>()\n\n/**\n * Run `fn` with one content view per project for its whole duration: every\n * read inside answers from the same snapshot, resolved once. The MCP server\n * wraps each tool call in this, so a call costs a fixed handful of spawns\n * however many files it reads.\n */\nexport function withReadScope<T>(fn: () => T): T {\n return scope.run(new Map(), fn)\n}\n\nexport function contentView(projectRoot: string): Promise<ContentView> {\n const memo = scope.getStore()\n if (!memo) return resolveView(projectRoot)\n let view = memo.get(projectRoot)\n if (!view) {\n view = resolveView(projectRoot)\n memo.set(projectRoot, view)\n }\n return view\n}\n\nexport function describeSource(view: ContentView): ContentReadSource {\n return view.source === 'working_tree'\n ? { source: 'working_tree' }\n : { source: 'ref', ref: view.ref, commit: view.commit, checked_out: view.branch }\n}\n\n/**\n * The repo-relative path a snapshot answers for, or null when `path` must come\n * from the working tree (outside `.contentrain/`, or never committed).\n */\nexport function refPath(view: ContentView & { source: 'ref' }, projectRoot: string, path: string): string | null {\n const abs = resolve(projectRoot, path)\n const root = resolve(projectRoot)\n if (abs !== root && !abs.startsWith(`${root}/`) && !abs.startsWith(`${root}\\\\`)) return null\n const rel = abs.slice(root.length + 1).replaceAll('\\\\', '/')\n if (rel !== '.contentrain' && !rel.startsWith('.contentrain/')) return null\n if (UNTRACKED.some(p => rel === p || rel.startsWith(`${p}/`))) return null\n return view.prefix + rel\n}\n\nasync function resolveView(projectRoot: string): Promise<ContentView> {\n try {\n const head = await headBranch(projectRoot)\n if (!head || head === CONTENTRAIN_BRANCH || head.startsWith('cr/')) return { source: 'working_tree' }\n const git = createGit(projectRoot)\n const base = await resolveBaseBranch(git, await readConfig(projectRoot), { currentBranch: head })\n if (head === base) return { source: 'working_tree' }\n const [prefix, commit] = (await git.raw(['rev-parse', '--show-prefix', `refs/heads/${CONTENTRAIN_BRANCH}^{commit}`]))\n .split('\\n').map(line => line.trim())\n if (!commit) return { source: 'working_tree' }\n const root = `${prefix ?? ''}.contentrain`\n const reader = await loadRefSnapshot(projectRoot, commit, root)\n return { source: 'ref', ref: CONTENTRAIN_BRANCH, commit, branch: head, reader, prefix: prefix ?? '' }\n } catch {\n return { source: 'working_tree' }\n }\n}\n\n/**\n * The checked-out branch, or null when HEAD is detached. Read from the HEAD\n * file when `.git` sits at the project root (no spawn — this runs once per\n * tool call on every local project); asked of git otherwise (a linked\n * worktree, or a project in a monorepo subdirectory).\n */\nasync function headBranch(projectRoot: string): Promise<string | null> {\n const dotGit = join(projectRoot, '.git')\n try {\n let gitDir = dotGit\n if ((await stat(dotGit)).isFile()) {\n const pointer = (await readFile(dotGit, 'utf-8')).match(/^gitdir:\\s*(.+)$/m)?.[1]?.trim()\n if (!pointer) throw new Error('unreadable .git file')\n gitDir = resolve(projectRoot, pointer)\n }\n const head = (await readFile(join(gitDir, 'HEAD'), 'utf-8')).trim()\n return head.startsWith('ref: refs/heads/') ? head.slice('ref: refs/heads/'.length) : null\n } catch {\n const out = await createGit(projectRoot).raw(['symbolic-ref', '-q', '--short', 'HEAD']).catch(() => '')\n return out.trim() || null\n }\n}\n","import { MODEL_FIELD_ORDER } from '@contentrain/types'\nimport type { FileChange } from '../../core/contracts/index.js'\nimport { canonicalStringify, sortKeys } from '../../core/serialization/index.js'\n\n/**\n * Carry a plan's full-file changes over to the tree they are committed on.\n *\n * The local provider plans against the developer's working tree, but commits\n * in a worktree on the `contentrain` branch after fetching and merging the\n * remote. Those two trees differ whenever another writer (Studio, a teammate,\n * CI) reached `contentrain` since the developer last pulled. A plan is a set\n * of whole files, so writing it as-is replaced every file it touched with a\n * copy built from the stale tree — silently deleting whatever the other\n * writer had added (#226).\n *\n * Each change is a three-way merge instead:\n * basis — the file as the planner read it (working tree, `null` if absent)\n * planned — the change's content (`null` for a delete)\n * current — the file in the worktree the commit is built on\n *\n * `current === basis` is the common case and writes the plan verbatim. When\n * they differ, JSON object files (collection / dictionary / singleton content,\n * meta, model definitions) are merged key by key: what the plan changed\n * relative to its basis is applied on top of `current`, recursively through\n * nested objects. Anything that cannot be merged that way — both sides\n * changed the same value, a markdown document, a delete of a file that\n * gained entries — is a conflict. Conflicts are never resolved by guessing:\n * the caller refuses the write.\n *\n * One exception, in meta files only: the write stamp (`updated_at`,\n * `updated_by`, `source`). Every write re-stamps the entries it touches\n * (`mergeEntryMeta`, `applyStatusChange`), so two writes to the same entry\n * always disagree there. The stamp describes the latest write, which is this\n * one — so when this write re-stamped an entry, its whole stamp wins, as a\n * unit (never this write's `updated_at` with the other writer's\n * `updated_by`). `status` and scheduling still merge or conflict normally.\n */\nexport type RebaseResult =\n | { ok: true, change: FileChange }\n | { ok: false, path: string, reason: string }\n\nexport function rebaseChange(change: FileChange, basis: string | null, current: string | null): RebaseResult {\n if (current === basis || current === change.content) return { ok: true, change }\n\n const reject = (reason: string): RebaseResult => ({ ok: false, path: change.path, reason })\n\n if (basis !== null && current === null) {\n return reject('the file was deleted on the contentrain branch since it was read')\n }\n if (!change.path.endsWith('.json')) {\n return reject('the file changed on the contentrain branch since it was read, and only JSON files can be merged')\n }\n\n const base = basis === null ? {} : parseObject(basis)\n const theirs = current === null ? {} : parseObject(current)\n const ours = change.content === null ? {} : parseObject(change.content)\n if (!base || !theirs || !ours) {\n return reject('the file changed on the contentrain branch since it was read, and is not a JSON object')\n }\n\n const stampWins = change.path.startsWith('.contentrain/meta/')\n const merged = merge3(base, ours, theirs, [], stampWins)\n if (!merged.ok) {\n return reject(`${describeKey(merged.at)} was changed both by this write and on the contentrain branch`)\n }\n const value = merged.value === ABSENT ? {} : merged.value as Record<string, unknown>\n\n if (change.content === null) {\n // A delete merges to \"the keys only the other writer has\". Writing those\n // back would keep a file this operation meant to remove (a deleted model's\n // content, say) — refuse instead and let the caller decide after a pull.\n return Object.keys(value).length === 0\n ? { ok: true, change }\n : reject('the file gained entries on the contentrain branch since it was read, so deleting it would drop them')\n }\n\n const fieldOrder = change.path.startsWith('.contentrain/models/') ? MODEL_FIELD_ORDER : undefined\n return { ok: true, change: { path: change.path, content: canonicalStringify(value, fieldOrder) } }\n}\n\nconst ABSENT = Symbol('absent')\ntype Value = unknown | typeof ABSENT\n\ntype MergeResult = { ok: true, value: Value } | { ok: false, at: string[] }\n\n/** Meta keys that record who wrote last and when — see the module comment. */\nconst WRITE_STAMP = new Set(['updated_at', 'updated_by', 'source'])\n\nfunction merge3(base: Value, ours: Value, theirs: Value, at: string[], stampWins: boolean): MergeResult {\n if (same(ours, base)) return { ok: true, value: theirs }\n if (same(theirs, base)) return { ok: true, value: ours }\n if (same(ours, theirs)) return { ok: true, value: ours }\n if (!isPlainObject(base) || !isPlainObject(ours) || !isPlainObject(theirs)) {\n return { ok: false, at }\n }\n\n const restamped = stampWins\n && [...WRITE_STAMP].some(key => !same(field(ours, key), field(base, key)))\n const out: Record<string, unknown> = {}\n const keys = new Set([...Object.keys(base), ...Object.keys(ours), ...Object.keys(theirs)])\n for (const key of keys) {\n if (restamped && WRITE_STAMP.has(key)) {\n const value = field(ours, key)\n if (value !== ABSENT) out[key] = value\n continue\n }\n const result = merge3(field(base, key), field(ours, key), field(theirs, key), [...at, key], stampWins)\n if (!result.ok) return result\n if (result.value !== ABSENT) out[key] = result.value\n }\n return { ok: true, value: out }\n}\n\nfunction field(obj: Record<string, unknown>, key: string): Value {\n return Object.hasOwn(obj, key) && obj[key] !== null && obj[key] !== undefined ? obj[key] : ABSENT\n}\n\n/** Canonical equality — the same normalisation the serializer applies (sorted keys, nulls dropped). */\nfunction same(a: Value, b: Value): boolean {\n if (a === ABSENT || b === ABSENT) return a === b\n return JSON.stringify(sortKeys(a)) === JSON.stringify(sortKeys(b))\n}\n\nfunction isPlainObject(value: Value): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\nfunction parseObject(raw: string): Record<string, unknown> | undefined {\n try {\n const parsed: unknown = JSON.parse(raw)\n return isPlainObject(parsed) ? parsed : undefined\n } catch {\n return undefined\n }\n}\n\nfunction describeKey(at: string[]): string {\n if (at.length === 0) return 'the file'\n if (at.length === 1) return `\"${at[0]}\"`\n return `\"${at[0]}\" (field ${at.slice(1).map(k => `\"${k}\"`).join(' → ')})`\n}\n","import { readFile } from 'node:fs/promises'\nimport { join } from 'node:path'\nimport { tmpdir } from 'node:os'\nimport { randomUUID } from 'node:crypto'\nimport { CONTENTRAIN_BRANCH } from '@contentrain/types'\nimport type {\n Branch,\n Commit,\n CommitAuthor,\n ContentReadSource,\n FileChange,\n FileDiff,\n MergeResult,\n ProviderCapabilities,\n RepoProvider,\n WriteReadiness,\n} from '../../core/contracts/index.js'\nimport { LOCAL_CAPABILITIES } from '../../core/contracts/index.js'\nimport { applyChangesToWorktree } from '../../core/ops/index.js'\nimport { checkBranchHealth } from '../../git/branch-lifecycle.js'\nimport { createGit } from '../../git/identity.js'\nimport { createTransaction } from '../../git/transaction.js'\nimport {\n createBranch as createBranchOp,\n deleteBranch as deleteBranchOp,\n getBranchDiff as getBranchDiffOp,\n getDefaultBranch as getDefaultBranchOp,\n isMerged as isMergedOp,\n listBranches as listBranchesOp,\n mergeBranch as mergeBranchOp,\n} from './branch-ops.js'\nimport { contentView, describeSource, refPath, type ContentView } from './content-view.js'\nimport { LocalReader } from './reader.js'\nimport { rebaseChange } from './rebase-changes.js'\nimport type { LocalApplyPlanInput, LocalApplyResult } from './types.js'\n\nconst DEFAULT_AUTHOR_NAME = 'Contentrain'\nconst DEFAULT_AUTHOR_EMAIL = 'ai@contentrain.io'\n\n/**\n * LocalProvider — the local-filesystem, worktree-backed content provider.\n *\n * Implements the full `RepoProvider` surface:\n * - Reader methods delegate to `LocalReader`.\n * - `applyPlan` wraps `createTransaction` and returns `LocalApplyResult`\n * (a superset of `Commit` carrying workflow action + selective sync).\n * - Branch ops mirror `GitHubProvider` — thin wrappers over the local\n * simple-git helpers in `./branch-ops.ts`.\n *\n * `mergeBranch` only supports merging into the singleton\n * `CONTENTRAIN_BRANCH`; the local flow advances the base branch via\n * `update-ref` in `transaction.mergeBranch`, so arbitrary merge targets\n * would bypass that invariant.\n */\nexport class LocalProvider implements RepoProvider {\n readonly capabilities: ProviderCapabilities = LOCAL_CAPABILITIES\n private readonly reader: LocalReader\n\n constructor(public readonly projectRoot: string) {\n this.reader = new LocalReader(projectRoot)\n }\n\n // Reads: `.contentrain/**` from the content view (the `contentrain` ref\n // while a feature branch is checked out — #229), everything else from the\n // working tree. See content-view.ts.\n\n async readFile(path: string, ref?: string): Promise<string> {\n const at = await this.routed(path)\n return at ? at.view.reader.readFile(at.path) : this.reader.readFile(path, ref)\n }\n\n async listDirectory(path: string, ref?: string): Promise<string[]> {\n const at = await this.routed(path)\n return at ? at.view.reader.listDirectory(at.path) : this.reader.listDirectory(path, ref)\n }\n\n async fileExists(path: string, ref?: string): Promise<boolean> {\n const at = await this.routed(path)\n return at ? at.view.reader.fileExists(at.path) : this.reader.fileExists(path, ref)\n }\n\n async contentSource(): Promise<ContentReadSource> {\n return describeSource(await contentView(this.projectRoot))\n }\n\n private async routed(path: string): Promise<{ view: ContentView & { source: 'ref' }, path: string } | null> {\n const view = await contentView(this.projectRoot)\n if (view.source !== 'ref') return null\n const inRef = refPath(view, this.projectRoot, path)\n return inRef === null ? null : { view, path: inRef }\n }\n\n /**\n * Branch pressure gate. Unmerged `cr/*` branches accumulate in a local repo\n * and eventually have to stop new writes; hosted providers have no such\n * limit, so they omit this method entirely.\n */\n async checkWriteReadiness(): Promise<WriteReadiness> {\n const health = await checkBranchHealth(this.projectRoot)\n return health.blocked\n ? { blocked: true, message: health.message ?? 'Too many active contentrain branches.' }\n : { blocked: false }\n }\n\n async applyPlan(input: LocalApplyPlanInput): Promise<LocalApplyResult> {\n // What the planner read: the content view — the working tree, or the\n // `contentrain` snapshot on a feature branch (#229). Captured before the\n // transaction fetches, so it is the tree the plan was computed from — not\n // whatever the remote says now.\n const view = await contentView(this.projectRoot)\n const basis = await Promise.all(input.changes.map(c => this.readBasis(view, c.path)))\n const tx = await createTransaction(this.projectRoot, input.branch, {\n workflowOverride: input.workflowOverride,\n })\n try {\n await tx.write(async (wt) => {\n // The worktree is the contentrain tip after fetch + merge — the tree\n // this commit is built on. Carry the plan over to it instead of\n // overwriting it with files built from a possibly stale working tree\n // (#226). See rebaseChange.\n const current = await Promise.all(input.changes.map(c => readOrNull(join(wt, c.path))))\n const results = input.changes.map((change, i) => rebaseChange(change, basis[i]!, current[i]!))\n const conflicts = results.filter(r => !r.ok)\n if (conflicts.length > 0) {\n throw Object.assign(new Error(\n `Nothing was written: the content branch moved since your working tree was last updated, and this change cannot be applied on top of it. `\n + conflicts.map(c => `${c.path}: ${c.reason}.`).join(' '),\n ), staleTreeGuidance(tx.baseBranch, tx.baseCheckedOut, view.source === 'ref'))\n }\n await applyChangesToWorktree(wt, results.map(r => (r as Extract<typeof r, { ok: true }>).change))\n })\n await tx.commit(input.message, input.context)\n const gitResult = await tx.complete()\n return {\n sha: gitResult.commit,\n message: input.message,\n author: input.author ?? {\n name: process.env['CONTENTRAIN_AUTHOR_NAME'] ?? DEFAULT_AUTHOR_NAME,\n email: process.env['CONTENTRAIN_AUTHOR_EMAIL'] ?? DEFAULT_AUTHOR_EMAIL,\n },\n timestamp: new Date().toISOString(),\n workflowAction: gitResult.action,\n sync: gitResult.sync,\n warning: gitResult.warning,\n base_advance: gitResult.base_advance,\n remote_push: gitResult.remote_push,\n }\n } finally {\n await tx.cleanup()\n }\n }\n\n private async readBasis(view: ContentView, path: string): Promise<string | null> {\n if (view.source === 'ref') {\n const inRef = refPath(view, this.projectRoot, path)\n if (inRef !== null) return view.reader.readFile(inRef).catch(() => null)\n }\n return readOrNull(join(this.projectRoot, path))\n }\n\n listBranches(prefix?: string): Promise<Branch[]> {\n return listBranchesOp(this.projectRoot, prefix)\n }\n\n async createBranch(name: string, fromRef?: string): Promise<void> {\n const resolved = fromRef ?? CONTENTRAIN_BRANCH\n await createBranchOp(this.projectRoot, name, resolved)\n }\n\n deleteBranch(name: string): Promise<void> {\n return deleteBranchOp(this.projectRoot, name)\n }\n\n getBranchDiff(branch: string, base?: string): Promise<FileDiff[]> {\n const resolved = base ?? CONTENTRAIN_BRANCH\n return getBranchDiffOp(this.projectRoot, branch, resolved)\n }\n\n mergeBranch(branch: string, into: string): Promise<MergeResult> {\n return mergeBranchOp(this.projectRoot, branch, into)\n }\n\n isMerged(branch: string, into?: string): Promise<boolean> {\n const resolved = into ?? CONTENTRAIN_BRANCH\n return isMergedOp(this.projectRoot, branch, resolved)\n }\n\n getDefaultBranch(): Promise<string> {\n return getDefaultBranchOp(this.projectRoot)\n }\n\n async getMergeBase(refA: string, refB: string): Promise<string | null> {\n const git = createGit(this.projectRoot)\n try {\n return (await git.raw(['merge-base', refA, refB])).trim() || null\n } catch {\n return null\n }\n }\n\n /**\n * Two-parent merge commit in a temp worktree. The real `git merge`\n * resolves everything outside the plan; the plan's changes then\n * overwrite every content-owned path (planner output is authoritative,\n * never git's textual auto-merge). Leftover unmerged paths abort — they\n * are outside the content planner's scope by construction.\n */\n async createMergeCommit(input: {\n branch: string\n ours: string\n theirs: string\n changes: FileChange[]\n message: string\n author: CommitAuthor\n }): Promise<Commit> {\n const git = createGit(this.projectRoot)\n const tip = (await git.raw(['rev-parse', input.branch])).trim()\n if (tip !== input.ours) {\n throw Object.assign(new Error(\n `createMergeCommit: \"${input.branch}\" is at ${tip.slice(0, 8)}, not the expected ${input.ours.slice(0, 8)} — the branch moved since planning.`,\n ), {\n code: 'RECONCILE_STALE_OURS',\n agent_hint: 'Re-run the reconcile plan against the current tip.',\n })\n }\n\n const worktreePath = join(tmpdir(), `cr-merge-commit-${randomUUID()}`)\n await git.raw(['worktree', 'add', worktreePath, input.branch])\n const wtGit = createGit(worktreePath, {\n config: [\n `user.name=${input.author.name}`,\n `user.email=${input.author.email}`,\n ],\n })\n try {\n try {\n await wtGit.merge([input.theirs, '--no-commit', '--no-ff'])\n } catch {\n // Conflicts are expected — the plan overwrites content paths next.\n }\n await applyChangesToWorktree(worktreePath, input.changes)\n await wtGit.raw(['add', '-A'])\n const unmerged = (await wtGit.raw(['ls-files', '-u'])).trim()\n if (unmerged) {\n const paths = [...new Set(unmerged.split('\\n').map(l => l.split('\\t')[1]).filter(Boolean))]\n try { await wtGit.merge(['--abort']) } catch { /* not in merge state */ }\n throw Object.assign(new Error(\n `createMergeCommit: ${paths.length} non-content file(s) conflict: ${paths.join(', ')}.`,\n ), { code: 'RECONCILE_SOURCE_CONFLICT' })\n }\n await wtGit.commit(input.message, { '--no-verify': null })\n const sha = (await wtGit.raw(['rev-parse', 'HEAD'])).trim()\n return {\n sha,\n message: input.message,\n author: input.author,\n timestamp: new Date().toISOString(),\n }\n } finally {\n try {\n await git.raw(['worktree', 'remove', worktreePath, '--force'])\n } catch {\n // worktree may already be cleaned up\n }\n }\n }\n}\n\nasync function readOrNull(path: string): Promise<string | null> {\n try {\n return await readFile(path, 'utf-8')\n } catch {\n return null\n }\n}\n\n/**\n * How to bring the planner's tree up to date. On the base branch that is a\n * pull. On a feature branch the content never reaches the working tree (the\n * advance leaves a non-base checkout alone), so a pull fetches nothing\n * useful — merging the base is what brings it in.\n */\nfunction staleTreeGuidance(baseBranch: string, baseCheckedOut: boolean, readFromRef: boolean): {\n code: string\n agent_hint: string\n developer_action?: string\n} {\n // Planned from the contentrain snapshot (#229): nothing on the developer's\n // side is stale — another writer moved contentrain between the read and\n // this write. Re-reading is the whole fix.\n if (readFromRef) {\n return {\n code: 'CONTENT_WORKING_TREE_STALE',\n agent_hint: `Another writer (Studio, a teammate, CI) changed the same value on the contentrain branch after this write read it. Re-read the content and retry — do not retry unchanged. No git action is needed.`,\n }\n }\n const update = baseCheckedOut\n ? 'Ask the developer to update their working tree (git pull)'\n : `The developer is on a branch other than \"${baseBranch}\", which content writes do not update. Ask them to merge \"${baseBranch}\" into it (or rebase onto it)`\n return {\n code: 'CONTENT_WORKING_TREE_STALE',\n agent_hint: `The content on the contentrain branch differs from the working tree this write was planned from — another writer (Studio, a teammate, CI) or an earlier write changed the same value. ${update}, then re-read the content and retry — do not retry unchanged.`,\n developer_action: baseCheckedOut ? 'git pull' : `git merge ${baseBranch}`,\n }\n}\n"],"mappings":"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"}
@@ -2,7 +2,7 @@ import { a as readJson, c as writeText, i as readDir, n as ensureDir, o as readT
2
2
  import { a as writeMeta, i as readMeta, n as deleteMeta, r as mergeEntryMeta } from "./meta-manager-CSpKtjnP.mjs";
3
3
  import { join, resolve } from "node:path";
4
4
  import { access, readFile, readdir, rm } from "node:fs/promises";
5
- import { DICTIONARY_TITLE_FIELD, LOCALE_PATTERN, MODEL_FIELD_ORDER, MODEL_FIELD_ORDER as MODEL_FIELD_ORDER$1, TITLE_FIELD_TYPES, generateEntryId, isTitleFieldType, parseMarkdownFrontmatter, parseMarkdownFrontmatter as parseFrontmatter, serializeMarkdownFrontmatter, serializeMarkdownFrontmatter as serializeFrontmatter, validateEntryId as validateEntryId$1, validateLocale as validateLocale$1, validateSlug as validateSlug$1 } from "@contentrain/types";
5
+ import { DICTIONARY_TITLE_FIELD, LOCALE_PATTERN, MODEL_FIELD_ORDER, MODEL_FIELD_ORDER as MODEL_FIELD_ORDER$1, TITLE_FIELD_TYPES, generateEntryId, isTitleFieldType, parseMarkdownFrontmatter, parseMarkdownFrontmatter as parseFrontmatter, serializeMarkdownFrontmatter, serializeMarkdownFrontmatter as serializeFrontmatter, titleFieldTarget, validateEntryId as validateEntryId$1, validateLocale as validateLocale$1, validateSlug as validateSlug$1 } from "@contentrain/types";
6
6
  import { z } from "zod";
7
7
  //#region src/core/ops/paths.ts
8
8
  /**
@@ -1320,7 +1320,12 @@ function byRequired(a, b) {
1320
1320
  */
1321
1321
  function titleFieldOptions(kind, fields) {
1322
1322
  if (kind === "dictionary") return [DICTIONARY_TITLE_FIELD];
1323
- return titleCandidates(fields).map((c) => c.field);
1323
+ const top = titleCandidates(fields).map((c) => c.field);
1324
+ const nested = Object.entries(fields ?? {}).flatMap(([name, raw]) => {
1325
+ const def = raw;
1326
+ return def?.type === "object" ? titleCandidates(def.fields).map((c) => `${name}.${c.field}`) : [];
1327
+ });
1328
+ return [...top, ...nested];
1324
1329
  }
1325
1330
  /**
1326
1331
  * How strongly a field name reads as a title. Exact matches outrank snake_case
@@ -1403,12 +1408,17 @@ function titleFieldIssues(input) {
1403
1408
  else if (input.kind === "dictionary") {
1404
1409
  if (titleField !== DICTIONARY_TITLE_FIELD) errors.push(`Dictionary models must use title_field: "${DICTIONARY_TITLE_FIELD}". Dictionaries have no fields — the key is the title. Got "${titleField}".`);
1405
1410
  } else if (!input.fields || Object.keys(input.fields).length === 0) errors.push(`Invalid "title_field": "${titleField}" cannot resolve — the model declares no fields.`);
1406
- else if (!(titleField in input.fields)) errors.push(`Invalid "title_field": field "${titleField}" is not defined in fields.` + (titleField === DICTIONARY_TITLE_FIELD ? ` "${DICTIONARY_TITLE_FIELD}" is reserved for dictionary models.` : ""));
1407
1411
  else {
1408
- const def = input.fields[titleField];
1409
- const type = def?.type;
1410
- if (typeof type === "string" && !isTitleFieldType(type)) errors.push(`Invalid "title_field": field "${titleField}" has type "${type}" — a title must be one of: ${TITLE_FIELD_TYPES.join(", ")}.`);
1411
- else if (def?.required !== true) warnings.push(`"title_field" points at optional field "${titleField}" — entries may render with an empty title. Consider required: true.`);
1412
+ const target = titleFieldTarget(input.fields, titleField);
1413
+ if (target.kind === "too-deep") errors.push(`Invalid "title_field": "${titleField}" reaches more than one level deep. A title is a field, or a field of an object field ("hero.heading").`);
1414
+ else if (target.kind === "not-object") errors.push(`Invalid "title_field": "${titleField}" goes through "${target.at}", which has type "${target.type}" — a dotted title reaches into an object field only.`);
1415
+ else if (target.kind === "missing") errors.push(`Invalid "title_field": field "${titleField}" is not defined in fields.` + (titleField === DICTIONARY_TITLE_FIELD ? ` "${DICTIONARY_TITLE_FIELD}" is reserved for dictionary models.` : ""));
1416
+ else {
1417
+ const { def, parent } = target;
1418
+ const type = def.type;
1419
+ if (typeof type === "string" && !isTitleFieldType(type)) errors.push(`Invalid "title_field": field "${titleField}" has type "${type}" — a title must be one of: ${TITLE_FIELD_TYPES.join(", ")}.`);
1420
+ else if (def.required !== true || parent && parent.required !== true) warnings.push(`"title_field" points at optional field "${titleField}" — entries may render with an empty title. Consider required: true${parent ? " on the object and its field" : ""}.`);
1421
+ }
1412
1422
  }
1413
1423
  return {
1414
1424
  errors,
@@ -1439,4 +1449,4 @@ function validateModelDefinition(input, opts = {}) {
1439
1449
  //#endregion
1440
1450
  export { contentDirPath as A, resolveMdFilePath as C, validateSlug$1 as D, validateLocale$1 as E, documentFilePath as M, metaFilePath as N, writeContent as O, resolveLocaleStrategy as S, validateEntryId$1 as T, listContent as _, countEntries as a, resolveContentDir as b, inferTitleField as c, readModel as d, titleFieldIssues as f, deleteContent as g, writeModel as h, collectFieldPaths as i, contentFilePath as j, LocalReader as k, legacyFieldNames as l, validateModelDefinition as m, MODEL_FIELD_ORDER$1 as n, deleteModel as o, titleFieldOptions as p, checkReferences as r, fieldDefZodSchema as s, FIELD_TYPE_ENUM as t, listModels as u, parseFrontmatter as v, serializeFrontmatter as w, resolveJsonFilePath as x, readContent as y };
1441
1451
 
1442
- //# sourceMappingURL=model-manager-DPXulg7z.mjs.map
1452
+ //# sourceMappingURL=model-manager-BYF2z7qC.mjs.map