diff --git a/.changeset/docs-context-feature-branch.md b/.changeset/docs-context-feature-branch.md new file mode 100644 index 0000000..11ab0fe --- /dev/null +++ b/.changeset/docs-context-feature-branch.md @@ -0,0 +1,8 @@ +--- +"@contentrain/rules": patch +"@contentrain/skills": patch +--- + +Fix stale context.json documentation: the file is never committed on feature branches + +Rules and skills docs still described the pre-1.x behavior ("context.json is committed together with content changes"). Since the dedicated-branch transaction flow landed, context.json is regenerated on the `contentrain` branch after merge and feature branches never carry it — parallel writes therefore cannot conflict on it. Updated workflow-rules, mcp-usage, contentrain-essentials, context-bridge, and the contentrain skill references to state the current contract. diff --git a/.changeset/remote-no-context-on-feature-branch.md b/.changeset/remote-no-context-on-feature-branch.md new file mode 100644 index 0000000..407acab --- /dev/null +++ b/.changeset/remote-no-context-on-feature-branch.md @@ -0,0 +1,9 @@ +--- +"@contentrain/mcp": patch +--- + +Remote write path no longer commits `.contentrain/context.json` to feature branches + +`commitThroughProvider` (used by content/model save/delete over GitHub and GitLab providers) bundled a freshly built `context.json` into every feature-branch commit. Because the file embeds `new Date()` timestamps, two parallel `cr/*` branches forked from the same `contentrain` commit always diverged on it — after the first branch merged and context was regenerated on `contentrain`, the second branch's merge hit a permanent conflict on `context.json` and stayed pending forever (silent content loss in auto-merge setups). + +Remote commits now carry only the plan's own changes, matching the local transaction flow where feature branches intentionally never include `context.json`. The orchestrator that owns the merge (e.g. Studio) is responsible for regenerating `context.json` on the `contentrain` branch post-merge — `buildContextChange` is exported from `@contentrain/mcp/core/context` for that purpose. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 232e822..780865a 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -64,7 +64,7 @@ All write operations are designed around git-backed safety: - auto-merge: feature merges into `contentrain`, baseBranch advanced via update-ref, `.contentrain/` files selectively synced to developer's working tree - review: feature branch pushed to remote for team review - developer's working tree is never mutated during MCP git operations (no stash, no checkout, no merge) -- context.json is committed together with content changes, not as a separate commit +- context.json never lands on feature branches — it is regenerated on the `contentrain` branch after merge (locally by the transaction layer; in remote flows by the orchestrator that owns the merge) - canonical JSON output — sorted keys, 2-space indent, trailing newline - validation + next-step hints surfaced to the caller diff --git a/packages/mcp/src/tools/commit-plan.ts b/packages/mcp/src/tools/commit-plan.ts index 42deede..b932883 100644 --- a/packages/mcp/src/tools/commit-plan.ts +++ b/packages/mcp/src/tools/commit-plan.ts @@ -1,15 +1,15 @@ import { CONTENTRAIN_BRANCH } from '@contentrain/types' import type { FileChange } from '../core/contracts/index.js' -import { buildContextChange } from '../core/context.js' -import { OverlayReader } from '../core/overlay-reader.js' import { LocalProvider } from '../providers/local/index.js' import type { ToolProvider } from '../server.js' /** - * Context payload written into `.contentrain/context.json` as part of the - * same commit. Kept as a loose object so tool-specific payloads can add - * optional fields (`locale`, `entries`) without churning the helper's - * signature. + * Context payload describing the operation, threaded into the local + * transaction so it can regenerate `.contentrain/context.json` on the + * contentrain branch after merge. Kept as a loose object so tool-specific + * payloads can add optional fields (`locale`, `entries`) without churning + * the helper's signature. Remote providers ignore it — see + * `commitThroughProvider` below. */ export interface CommitContextPayload { tool: string @@ -43,11 +43,17 @@ export interface CommitThroughProviderResult { * the project's configured workflow. Selective-sync result is surfaced * to the caller via `sync`. * - * - **Any other RepoProvider** — the context.json write becomes an extra - * `FileChange` bundled into the plan so the whole commit lands - * atomically through the generic `RepoWriter.applyPlan`. Remote flows + * - **Any other RepoProvider** — only the plan's own changes are + * committed. Feature branches NEVER carry `.contentrain/context.json`: + * the file embeds timestamps, so two parallel cr/* branches forked from + * the same contentrain commit would always conflict on it and the + * second merge would fail permanently. This mirrors the local + * transaction flow, which regenerates context.json on the contentrain + * branch after merge (single-threaded, deterministic). Remote flows * always report `pending-review`; Studio (or whatever orchestrator is - * driving the server) owns the merge. + * driving the server) owns the merge AND the post-merge context + * regeneration on the contentrain branch (`buildContextChange` from + * `@contentrain/mcp/core/context` is exported for exactly that). * * The return shape is deliberately uniform so callers don't have to * branch on provider type again. @@ -72,15 +78,7 @@ export async function commitThroughProvider( } } - // Build context.json against an overlay of the pending FileChanges so - // `stats.entries` / `stats.models` reflect the state *after* this - // commit lands, not the pre-change base branch. Without the overlay - // the committed context.json would be stale — a new entry added in - // this commit would not appear in the entry count until the next - // write. - const overlay = new OverlayReader(provider, changes) - const contextChange = await buildContextChange(overlay, contextPayload) - const allChanges = [...changes, contextChange] + const allChanges = changes .toSorted((a, b) => a.path.localeCompare(b.path)) // Feature branches ALWAYS fork from the `contentrain` branch — that's // the single source of truth the local transaction flow enforces, and diff --git a/packages/mcp/tests/server/http.test.ts b/packages/mcp/tests/server/http.test.ts index e459b25..7a50f42 100644 --- a/packages/mcp/tests/server/http.test.ts +++ b/packages/mcp/tests/server/http.test.ts @@ -263,12 +263,15 @@ describe('startHttpMcpServer', () => { expect(git['commit']).toBe('new-commit-sha') // Commit is addressed to the feature branch, not the base. expect((git['branch'] as string).startsWith('cr/content/blog')).toBe(true) - // The tree payload contains our content blob + meta blob + context.json blob. + // The tree payload contains our content blob + meta blob. Feature + // branches never carry context.json — it embeds timestamps, so + // parallel cr/* branches would permanently conflict on it. The + // orchestrator regenerates it on contentrain after merge. const tree = (capturedTree as { tree: Array<{ path: string }> }).tree const paths = tree.map(t => t.path) expect(paths).toContain('.contentrain/content/marketing/blog/en.json') expect(paths).toContain('.contentrain/meta/blog/en.json') - expect(paths).toContain('.contentrain/context.json') + expect(paths).not.toContain('.contentrain/context.json') // Commit parent resolved from contentrain branch (branchExists: false, // so we end up creating a new ref). expect((capturedCommit as { parents: string[] }).parents).toEqual(['base-sha']) @@ -378,13 +381,14 @@ describe('startHttpMcpServer', () => { expect(git['commit']).toBe('gitlab-commit-sha') expect((git['branch'] as string).startsWith('cr/content/blog')).toBe(true) - // One Commits.create call with content + meta + context.json actions. + // One Commits.create call with content + meta actions — no + // context.json on feature branches. expect(capturedCommitsCall).toBeDefined() const actions = capturedCommitsCall!.actions as Array<{ filePath: string, action: string }> const paths = actions.map(a => a.filePath) expect(paths).toContain('.contentrain/content/marketing/blog/en.json') expect(paths).toContain('.contentrain/meta/blog/en.json') - expect(paths).toContain('.contentrain/context.json') + expect(paths).not.toContain('.contentrain/context.json') // startBranch is used because the feature branch does not exist yet. expect(capturedCommitsCall!.options.startBranch).toBe('contentrain') } finally { @@ -448,7 +452,7 @@ describe('startHttpMcpServer', () => { const tree = fixture.capturedTree()!.tree const paths = tree.map(t => t.path) - expect(paths).toContain('.contentrain/context.json') + expect(paths).not.toContain('.contentrain/context.json') // The content entry is removed from the object-map; the file is // rewritten (not deleted) because other entries may still live // there in real flows. The meta file loses the corresponding @@ -505,7 +509,7 @@ describe('startHttpMcpServer', () => { const tree = fixture.capturedTree()!.tree const paths = tree.map(t => t.path) expect(paths).toContain('.contentrain/models/hero.json') - expect(paths).toContain('.contentrain/context.json') + expect(paths).not.toContain('.contentrain/context.json') } finally { await mcpClient.close() } diff --git a/packages/rules/context/context-bridge.md b/packages/rules/context/context-bridge.md index 3322cc6..bd6e9b3 100644 --- a/packages/rules/context/context-bridge.md +++ b/packages/rules/context/context-bridge.md @@ -4,7 +4,7 @@ `context.json` is the project intelligence file. It provides AI agents and Contentrain Studio with structured knowledge about the project: its tech stack, content patterns, conventions, and normalize configuration. -- **Written by:** MCP tools (committed together with content changes in the same branch, not as a separate commit) +- **Written by:** MCP tools (regenerated on the `contentrain` branch after each merge — feature branches never carry it, so parallel writes cannot conflict on it) - **Read by:** AI agents (at the start of every session) and Studio (for UI configuration) - **Location:** `.contentrain/context.json` @@ -206,7 +206,7 @@ When creating `context.json` for the first time, use stack-appropriate defaults. ## Update Behavior -MCP tools update `context.json` as part of each write operation, committed together with the content changes in the same branch (not as a separate commit): +MCP tools update `context.json` after each write operation lands. The file is never committed on `cr/*` feature branches (its timestamps would make parallel branches conflict); it is regenerated deterministically on the `contentrain` branch after merge: - `contentrain_init` creates the initial file - `contentrain_model_save` updates the domains list if a new domain is used - `contentrain_content_save` updates content stats diff --git a/packages/rules/essential/contentrain-essentials.md b/packages/rules/essential/contentrain-essentials.md index be72fed..448a7b6 100644 --- a/packages/rules/essential/contentrain-essentials.md +++ b/packages/rules/essential/contentrain-essentials.md @@ -77,7 +77,7 @@ MCP is **deterministic infrastructure**. The agent (you) is the **intelligence l - Developer's working tree is never mutated during MCP git operations (no stash, no checkout, no merge on the developer's tree) - If the developer manually edits `.contentrain/` files, MCP sync skips dirty files and warns - The `contentrain` branch is protected from deletion -- context.json is committed together with content changes, not as a separate commit +- context.json is never committed on feature branches — it is regenerated on the `contentrain` branch after merge - Never create branches manually, never commit directly to main or the `contentrain` branch - 50+ active `cr/*` branches = warning, 80+ = blocked diff --git a/packages/rules/shared/mcp-usage.md b/packages/rules/shared/mcp-usage.md index f73955a..316ccfe 100644 --- a/packages/rules/shared/mcp-usage.md +++ b/packages/rules/shared/mcp-usage.md @@ -297,7 +297,7 @@ contentrain_apply(mode: "reuse", scope: {model: "model-id"}, dry_run: true) (pre - Branch naming: `cr/{operation}/{model}[/{locale}]/{timestamp}-{suffix}` (locale included when applicable; legacy `contentrain/*` branches are auto-migrated on first init). - You do not create branches manually. MCP handles Git transactions. - Developer's working tree is never mutated during MCP operations (no stash, no checkout, no merge on the developer's tree). -- context.json is committed together with content changes, not as a separate commit. +- context.json is never committed on feature branches — it is regenerated on the `contentrain` branch after merge. - In `auto-merge` mode: feature branch is merged into `contentrain`, then baseBranch is advanced via update-ref (fast-forward), then `.contentrain/` files are selectively synced to the developer's working tree. Dirty files are skipped with a warning. - In `review` mode: feature branch stays local until `contentrain_submit` pushes it to remote. diff --git a/packages/rules/shared/workflow-rules.md b/packages/rules/shared/workflow-rules.md index 0cfd1f1..5d44a36 100644 --- a/packages/rules/shared/workflow-rules.md +++ b/packages/rules/shared/workflow-rules.md @@ -75,7 +75,7 @@ The `contentrain` branch is the **single source of truth** for content state: - Created automatically at `contentrain_init`. - All content writes happen on feature branches forked from `contentrain`. - The `contentrain` branch is **protected from deletion**. -- context.json is committed together with content changes, not as a separate commit. +- context.json is never committed on feature branches — it is regenerated on the `contentrain` branch after merge. ### 3.2 Worktree-Based Transactions diff --git a/packages/skills/skills/contentrain/references/mcp-pipelines.md b/packages/skills/skills/contentrain/references/mcp-pipelines.md index 6c04b2a..3ba5b19 100644 --- a/packages/skills/skills/contentrain/references/mcp-pipelines.md +++ b/packages/skills/skills/contentrain/references/mcp-pipelines.md @@ -150,7 +150,7 @@ contentrain_submit # Push (always review mode) - Branch naming: `cr/{operation}/{model}/{timestamp}` (locale included when applicable) - Do not create branches manually. MCP handles Git transactions - Developer's working tree is never mutated during MCP operations (no stash, no checkout, no merge on the developer's tree) -- context.json is committed together with content changes, not as a separate commit +- context.json is never committed on feature branches — it is regenerated on the `contentrain` branch after merge - `auto-merge` mode: feature branch merged into `contentrain`, baseBranch advanced via update-ref, `.contentrain/` files selectively synced to developer's working tree (dirty files skipped with warning) - `review` mode: feature branch pushed to remote by `contentrain_submit` diff --git a/packages/skills/skills/contentrain/references/workflow.md b/packages/skills/skills/contentrain/references/workflow.md index b5931a7..ddfaec7 100644 --- a/packages/skills/skills/contentrain/references/workflow.md +++ b/packages/skills/skills/contentrain/references/workflow.md @@ -79,7 +79,7 @@ The `contentrain` branch is the **single source of truth** for content state: - Created automatically at `contentrain_init`. - All content writes happen on feature branches forked from `contentrain`. - The `contentrain` branch is **protected from deletion**. -- context.json is committed together with content changes, not as a separate commit. +- context.json is never committed on feature branches — it is regenerated on the `contentrain` branch after merge. ### 3.2 Worktree-Based Transactions