Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions .changeset/phase-r1-rules-skills-mcp-parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
---
"@contentrain/mcp": minor
"@contentrain/rules": minor
"@contentrain/skills": minor
---

fix(rules,skills,mcp): align rules/skills catalogs with MCP tool surface + `cr/*` branches, lock with parity tests

Closes the two P1 drift findings and installs a drift-detection
mechanism so they don't come back:

1. **Missing `contentrain_merge`** — `@contentrain/rules` public
`MCP_TOOLS` listed 15 tools. `@contentrain/mcp` registers 17
(including `merge` and the new `doctor`). `@contentrain/skills`
tool reference also jumped from `submit` straight to `bulk`.
2. **Legacy `contentrain/{operation}/...` branch namespace** —
MCP's `buildBranchName()` returns `cr/...` (Phase 7 migration)
and `checkBranchHealth` filters on `cr/`, but essential rules,
review/normalize prompts, and multiple skills still taught the
old prefix. Agents following the shipped guidance would look
for branches that don't exist.

### `@contentrain/mcp`

- New public export `TOOL_NAMES: readonly string[]` in
`./tools/annotations`, frozen and derived from `TOOL_ANNOTATIONS`.
Single source of truth — parity tests in sibling packages now
import this instead of hardcoding.
- New `./tools/annotations` subpath export in `package.json`.
- Build script now emits the new subpath.

### `@contentrain/rules`

- `MCP_TOOLS` extended to **17 tools** (`contentrain_merge`,
`contentrain_doctor` added in catalog order).
- `essential/contentrain-essentials.md` — tool table gains `doctor`
row; feature-branch pattern rewritten to `cr/{operation}/...`;
health-threshold language mentions `cr/*`.
- `prompts/review-mode.md` — every legacy `contentrain/<op>/...`
reference → `cr/<op>/...` (pattern + type examples).
- `prompts/normalize-mode.md` — branch pattern table rewritten.
- `shared/workflow-rules.md` — branch pattern spec rewritten.
- `tests/mcp-parity.test.ts` (new) — 4 tests:
- `MCP_TOOLS` ↔ `TOOL_NAMES` exact match
- Essential guardrails mention every MCP tool
- `buildBranchName()` output starts with `cr/` (sampled across scopes)
- Rules docs do not contain the legacy `contentrain/<op>/...`
branch prefix (false-positive filter excludes `.contentrain/` paths)
- `package.json` — `@contentrain/mcp: workspace:*` added as devDep
for the parity test.

### `@contentrain/skills`

- `skills/contentrain/references/mcp-tools.md` — new sections for
`contentrain_merge` (after submit) and `contentrain_doctor`
(new Doctor Tools subsection). Submit description updated to
`cr/*` branches.
- `skills/contentrain/references/mcp-pipelines.md` + `workflow.md`
— branch-naming spec + examples rewritten to `cr/*`.
- `skills/contentrain-normalize/SKILL.md` + `references/extraction.md`
+ `references/reuse.md` — 4 stale `contentrain/normalize/*`
references → `cr/normalize/*`.
- `skills/contentrain-translate/SKILL.md` — translate branch pattern
updated.
- `tests/mcp-parity.test.ts` (new) — 2 tests:
- `references/mcp-tools.md` has an `### <tool>` heading for every
MCP tool
- 7 key skill docs do not contain the legacy branch prefix
- `package.json` — `@contentrain/mcp: workspace:*` devDep.

### Monorepo

- `tsconfig.json` paths — `@contentrain/mcp/tools/*` alias added so
TypeScript + vitest resolve the new subpath from source during dev.

### Verification

- `oxlint` across rules + skills + mcp/tools → 0 warnings.
- `tsc --noEmit` across rules, skills, mcp → 0 errors.
- `@contentrain/rules` vitest → 16/16 (was 12 — 4 new parity tests).
- `@contentrain/skills` vitest → 85/85 (was 83 — 2 new parity tests).

### Tool surface

No MCP tool behaviour changes. The new `TOOL_NAMES` export is
additive; everything else is documentation + tests.
8 changes: 6 additions & 2 deletions packages/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@
"types": "./dist/git/branch-lifecycle.d.mts",
"import": "./dist/git/branch-lifecycle.mjs"
},
"./tools/annotations": {
"types": "./dist/tools/annotations.d.mts",
"import": "./dist/tools/annotations.mjs"
},
"./templates": {
"types": "./dist/templates/index.d.mts",
"import": "./dist/templates/index.mjs"
Expand All @@ -139,8 +143,8 @@
"dist"
],
"scripts": {
"build": "tsdown src/index.ts src/server.ts src/core/config.ts src/core/context.ts src/core/model-manager.ts src/core/content-manager.ts src/core/meta-manager.ts src/core/validator/index.ts src/core/scanner.ts src/core/scan-config.ts src/core/doctor.ts src/core/graph-builder.ts src/core/apply-manager.ts src/util/detect.ts src/util/fs.ts src/util/id.ts src/git/transaction.ts src/git/branch-lifecycle.ts src/templates/index.ts src/core/contracts/index.ts src/core/ops/index.ts src/core/overlay-reader.ts src/providers/local/index.ts src/providers/github/index.ts src/providers/gitlab/index.ts src/server/http/index.ts --format esm --dts --external typescript",
"dev": "tsdown src/index.ts src/server.ts src/core/config.ts src/core/context.ts src/core/model-manager.ts src/core/content-manager.ts src/core/meta-manager.ts src/core/validator/index.ts src/core/scanner.ts src/core/scan-config.ts src/core/doctor.ts src/core/graph-builder.ts src/core/apply-manager.ts src/util/detect.ts src/util/fs.ts src/util/id.ts src/git/transaction.ts src/git/branch-lifecycle.ts src/templates/index.ts src/core/contracts/index.ts src/core/ops/index.ts src/core/overlay-reader.ts src/providers/local/index.ts src/providers/github/index.ts src/providers/gitlab/index.ts src/server/http/index.ts --format esm --dts --external typescript --watch",
"build": "tsdown src/index.ts src/server.ts src/core/config.ts src/core/context.ts src/core/model-manager.ts src/core/content-manager.ts src/core/meta-manager.ts src/core/validator/index.ts src/core/scanner.ts src/core/scan-config.ts src/core/doctor.ts src/core/graph-builder.ts src/core/apply-manager.ts src/util/detect.ts src/util/fs.ts src/util/id.ts src/git/transaction.ts src/git/branch-lifecycle.ts src/tools/annotations.ts src/templates/index.ts src/core/contracts/index.ts src/core/ops/index.ts src/core/overlay-reader.ts src/providers/local/index.ts src/providers/github/index.ts src/providers/gitlab/index.ts src/server/http/index.ts --format esm --dts --external typescript",
"dev": "tsdown src/index.ts src/server.ts src/core/config.ts src/core/context.ts src/core/model-manager.ts src/core/content-manager.ts src/core/meta-manager.ts src/core/validator/index.ts src/core/scanner.ts src/core/scan-config.ts src/core/doctor.ts src/core/graph-builder.ts src/core/apply-manager.ts src/util/detect.ts src/util/fs.ts src/util/id.ts src/git/transaction.ts src/git/branch-lifecycle.ts src/tools/annotations.ts src/templates/index.ts src/core/contracts/index.ts src/core/ops/index.ts src/core/overlay-reader.ts src/providers/local/index.ts src/providers/github/index.ts src/providers/gitlab/index.ts src/server/http/index.ts --format esm --dts --external typescript --watch",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
Expand Down
14 changes: 14 additions & 0 deletions packages/mcp/src/tools/annotations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'
/**
* Centralized tool annotations registry.
* MCP clients use these hints to distinguish read-only vs. write vs. destructive tools.
*
* Also serves as the **single source of truth for the tool name list**. Consumers
* that need to enumerate every registered tool (e.g. parity tests in
* `@contentrain/rules` / `@contentrain/skills`) should import `TOOL_NAMES` below
* rather than hardcoding the list.
*/
export const TOOL_ANNOTATIONS: Record<string, ToolAnnotations> = {
// ─── Context (read-only) ───
Expand Down Expand Up @@ -121,3 +126,12 @@ export const TOOL_ANNOTATIONS: Record<string, ToolAnnotations> = {
idempotentHint: false,
},
}

/**
* Canonical list of every registered MCP tool name, derived from the
* single source of truth above. Re-exported here with a stable name so
* parity tests in sibling packages (`@contentrain/rules`,
* `@contentrain/skills`) can assert against it without depending on
* `TOOL_ANNOTATIONS` internals.
*/
export const TOOL_NAMES: readonly string[] = Object.freeze(Object.keys(TOOL_ANNOTATIONS))
5 changes: 3 additions & 2 deletions packages/rules/essential/contentrain-essentials.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ MCP is **deterministic infrastructure**. The agent (you) is the **intelligence l
| `contentrain_submit` | Push branches to remote |
| `contentrain_merge` | Merge a review-mode branch into contentrain locally |
| `contentrain_bulk` | Batch operations (copy_locale/update_status/delete_entries) |
| `contentrain_doctor` | Project health report (env + structure + orphan content + branch pressure + SDK freshness) |

## Mandatory Protocols

Expand All @@ -68,15 +69,15 @@ MCP is **deterministic infrastructure**. The agent (you) is the **intelligence l

- A dedicated `contentrain` branch is the single source of truth for content state, created at init
- Every write operation creates a temporary worktree on the `contentrain` branch automatically
- Feature branches (`contentrain/{operation}/{model}/{locale}/{timestamp}`) are created from `contentrain` for each operation
- Feature branches (`cr/{operation}/{model}/{locale}/{timestamp}`) are created from `contentrain` for each operation
- **auto-merge** mode: feature branch merges into `contentrain`, then `contentrain` advances baseBranch via update-ref (fast-forward), then `.contentrain/` files are selectively synced to the developer's working tree
- **review** mode: 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 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
- Never create branches manually, never commit directly to main or the `contentrain` branch
- 50+ active branches = warning, 80+ = blocked
- 50+ active `cr/*` branches = warning, 80+ = blocked

## CLI Serve — Review & Approval

Expand Down
1 change: 1 addition & 0 deletions packages/rules/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@contentrain/mcp": "workspace:*",
"@types/node": "^22.0.0",
"tsdown": "^0.21.0",
"typescript": "^5.7.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/rules/prompts/normalize-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ This mode converts a codebase with hardcoded strings into a Contentrain-managed
|--------|---------------------|----------------|
| Purpose | Pull content from source code into `.contentrain/` | Patch source files to reference extracted content |
| Source files modified | No | Yes |
| Branch pattern | `contentrain/normalize/extract/{domain}/{timestamp}` | `contentrain/normalize/reuse/{model}/{locale}/{timestamp}` |
| Branch pattern | `cr/normalize/extract/{domain}/{timestamp}` | `cr/normalize/reuse/{model}/{locale}/{timestamp}` |
| Prerequisite | Initialized `.contentrain/` | Completed extraction (content exists) |
| Workflow mode | Always `review` | Always `review` |
| Standalone value | Yes — content is manageable in Studio immediately | Depends on Phase 1 |
Expand Down
18 changes: 9 additions & 9 deletions packages/rules/prompts/review-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

> **Prerequisites:** Read `prompts/common.md` first. All shared rules apply.

This mode is for reviewing content changes on pending `contentrain/*` branches before they are merged. You act as a content quality reviewer, applying all Contentrain rules systematically.
This mode is for reviewing content changes on pending `cr/*` branches before they are merged. You act as a content quality reviewer, applying all Contentrain rules systematically.

---

## Pipeline

```
Step 1: List open contentrain/* branches
Step 1: List open cr/* branches
Step 2: Show diffs for selected branch
Step 3: Apply review checklist
Step 4: Recommend action
Expand All @@ -19,19 +19,19 @@ Step 4: Recommend action

## Step 1: List Pending Branches

Call `contentrain_status` to see pending changes and open branches. Identify branches with the `contentrain/` prefix that are awaiting review.
Call `contentrain_status` to see pending changes and open branches. Identify branches with the `cr/` prefix that are awaiting review.

Branch naming convention:
```
contentrain/{operation}/{model}/{locale}/{timestamp}
cr/{operation}/{model}/{locale}/{timestamp}
```

Common branch types:
- `contentrain/content/...` — content updates
- `contentrain/model/...` — model changes
- `contentrain/normalize/extract/...` — normalize extraction
- `contentrain/normalize/reuse/...` — normalize reuse (source patching)
- `contentrain/new/scaffold-...` — scaffold operations
- `cr/content/...` — content updates
- `cr/model/...` — model changes
- `cr/normalize/extract/...` — normalize extraction
- `cr/normalize/reuse/...` — normalize reuse (source patching)
- `cr/new/scaffold-...` — scaffold operations

---

Expand Down
2 changes: 1 addition & 1 deletion packages/rules/shared/workflow-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Contentrain supports two workflow modes, configured in `.contentrain/config.json
All Contentrain branches follow a strict naming pattern:

```
contentrain/{operation}/{model}/{locale}/{timestamp}
cr/{operation}/{model}/{locale}/{timestamp}
```

### Examples
Expand Down
4 changes: 3 additions & 1 deletion packages/rules/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export type FieldType = (typeof FIELD_TYPES)[number]
export const MODEL_KINDS = ['singleton', 'collection', 'document', 'dictionary'] as const
export type ModelKind = (typeof MODEL_KINDS)[number]

// ─── MCP Tools (15 tools) ───
// ─── MCP Tools (17 tools) ───

export const MCP_TOOLS = [
'contentrain_status', 'contentrain_describe', 'contentrain_describe_format',
Expand All @@ -32,7 +32,9 @@ export const MCP_TOOLS = [
'contentrain_content_save', 'contentrain_content_delete', 'contentrain_content_list',
'contentrain_scan', 'contentrain_apply',
'contentrain_validate', 'contentrain_submit',
'contentrain_merge',
'contentrain_bulk',
'contentrain_doctor',
] as const

export type McpTool = (typeof MCP_TOOLS)[number]
Expand Down
75 changes: 75 additions & 0 deletions packages/rules/tests/mcp-parity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { TOOL_NAMES } from '@contentrain/mcp/tools/annotations'
import { buildBranchName } from '@contentrain/mcp/git/transaction'
import { MCP_TOOLS, ESSENTIAL_RULES_FILE } from '../src/index.js'

/**
* Cross-package parity tests.
*
* `@contentrain/rules` publishes a public catalog of tools and branch
* conventions that agents rely on. `@contentrain/mcp` is the runtime
* authority. Without this file, the two can (and historically did) drift:
* the rules catalog sat at 15 tools while MCP advertised 16; the
* essential rules kept teaching the legacy `contentrain/*` branch
* namespace after MCP switched to `cr/*`.
*
* The tests below fail loudly whenever either side moves without the
* other. Fix by aligning — not by muting the test.
*/

const PKG_ROOT = join(import.meta.dirname, '..')

describe('MCP parity — tool registry', () => {
it('MCP_TOOLS matches the MCP annotations registry exactly', () => {
const rulesSet = new Set(MCP_TOOLS)
const mcpSet = new Set(TOOL_NAMES)

const missingFromRules = [...mcpSet].filter(t => !rulesSet.has(t))
const missingFromMcp = [...rulesSet].filter(t => !mcpSet.has(t))

expect(missingFromRules, 'tools registered in @contentrain/mcp but missing from @contentrain/rules MCP_TOOLS').toEqual([])
expect(missingFromMcp, 'tools in @contentrain/rules MCP_TOOLS but not registered in @contentrain/mcp').toEqual([])
expect(MCP_TOOLS.length).toBe(TOOL_NAMES.length)
})

it('essential guardrails document every MCP tool', () => {
const content = readFileSync(join(PKG_ROOT, ESSENTIAL_RULES_FILE), 'utf-8')
for (const tool of TOOL_NAMES) {
expect(content, `essential rules do not mention ${tool}`).toContain(tool)
}
})
})

describe('MCP parity — branch naming', () => {
it('buildBranchName() emits the `cr/` prefix that rules + skills document', () => {
const samples = [
buildBranchName('content', 'blog-post', 'en'),
buildBranchName('model', 'team-member'),
buildBranchName('normalize/extract', 'marketing'),
buildBranchName('new', 'scaffold-landing', 'en'),
]
for (const branch of samples) {
expect(branch, `branch name should start with "cr/": ${branch}`).toMatch(/^cr\//u)
}
})

it('rules docs do not reference the legacy `contentrain/{operation}/` branch prefix', () => {
// The `.contentrain/` directory path is correct — only the branch
// prefix is stale. Filter accordingly so the test doesn't
// false-positive on real filesystem paths.
const filesToScan = [
'essential/contentrain-essentials.md',
'prompts/review-mode.md',
'prompts/normalize-mode.md',
'shared/workflow-rules.md',
]
const legacyPattern = /(^|[^.])contentrain\/(content|model|normalize|new|fix|review)\b/gmu
for (const rel of filesToScan) {
const content = readFileSync(join(PKG_ROOT, rel), 'utf-8')
const matches = [...content.matchAll(legacyPattern)]
expect(matches.length, `legacy "contentrain/<op>/" branch prefix in ${rel}: ${matches.map(m => m[0]).join(', ')}`).toBe(0)
}
})
})
2 changes: 1 addition & 1 deletion packages/rules/tests/validate-rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ describe('essential rules', () => {
describe('constants', () => {
it('FIELD_TYPES has 27 entries', () => { expect(FIELD_TYPES).toHaveLength(27) })
it('MODEL_KINDS has 4 entries', () => { expect(MODEL_KINDS).toHaveLength(4) })
it('MCP_TOOLS has 15 entries', () => { expect(MCP_TOOLS).toHaveLength(15) })
it('MCP_TOOLS has 17 entries', () => { expect(MCP_TOOLS).toHaveLength(17) })
it('all MCP tools match pattern', () => {
for (const t of MCP_TOOLS) expect(t).toMatch(/^contentrain_/)
})
Expand Down
1 change: 1 addition & 0 deletions packages/skills/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@contentrain/mcp": "workspace:*",
"@types/node": "^22.0.0",
"tsdown": "^0.21.0",
"typescript": "^5.7.0",
Expand Down
6 changes: 3 additions & 3 deletions packages/skills/skills/contentrain-normalize/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ branch is pushed.
| Purpose | Pull content from source to `.contentrain/` | Patch source files with content references |
| Scope | Full project scan | Per model or per domain |
| Source files modified | No | Yes |
| Branch pattern | `contentrain/normalize/extract/{domain}/{timestamp}` | `contentrain/normalize/reuse/{model}/{locale}/{timestamp}` |
| Branch pattern | `cr/normalize/extract/{domain}/{timestamp}` | `cr/normalize/reuse/{model}/{locale}/{timestamp}` |
| Prerequisite | Initialized `.contentrain/` | Completed extraction (content exists in `.contentrain/`) |
| Workflow mode | Always `review` | Always `review` |
| Standalone value | Yes -- content is manageable in Studio immediately | Depends on Phase 1 |
Expand Down Expand Up @@ -133,7 +133,7 @@ Call `contentrain_apply(mode: "extract", dry_run: true)` to generate a preview.

### 6. Execute Extraction

After user approval, call `contentrain_apply(mode: "extract", dry_run: false)`. This creates model definitions and content files in `.contentrain/` on a `contentrain/normalize/extract/{timestamp}` branch. Source files are NOT modified.
After user approval, call `contentrain_apply(mode: "extract", dry_run: false)`. This creates model definitions and content files in `.contentrain/` on a `cr/normalize/extract/{timestamp}` branch. Source files are NOT modified.

### 7. Validate and Submit

Expand Down Expand Up @@ -207,7 +207,7 @@ Call `contentrain_apply(mode: "reuse", scope: { model: "<model-id>" }, patches:

### 4. Execute Reuse

After user confirmation, call `contentrain_apply(mode: "reuse", scope: { model: "<model-id>" }, patches: [...], dry_run: false)`. This patches source files and creates a `contentrain/normalize/reuse/{model}/{timestamp}` branch.
After user confirmation, call `contentrain_apply(mode: "reuse", scope: { model: "<model-id>" }, patches: [...], dry_run: false)`. This patches source files and creates a `cr/normalize/reuse/{model}/{timestamp}` branch.

### 5. Validate and Submit

Expand Down
Loading
Loading