Skip to content

Reconcile: divergence becomes a product state with an exit (R0–R5) - #118

Merged
ABB65 merged 7 commits into
mainfrom
feat/reconcile-r0-truth
Aug 15, 2026
Merged

Reconcile: divergence becomes a product state with an exit (R0–R5)#118
ABB65 merged 7 commits into
mainfrom
feat/reconcile-r0-truth

Conversation

@ABB65

@ABB65 ABB65 commented Aug 15, 2026

Copy link
Copy Markdown
Member

Implements the approved AI-REPO-RECONCILE design (analysis in .internal/refactor/tasks/AI-REPO-RECONCILE-YANIT.md, Studio approval with change requests N1/N2/N3). Field origin: the collabers incident — a diverged contentrainmain pair put every Studio approve into a 409/500 loop while the content sat safely on contentrain.

One branch instead of the three PRs sketched in the plan: the phases build on each other (R0's BaseAdvance vocabulary is R2's output contract), so they ship as seven sequential commits that each pass the full gates, reviewable commit-by-commit.

What changed, per phase

  • feat(types) — divergence + reconcile vocabulary: BaseAdvance / RemotePush (N3: values identical to Studio's approve contract; a PR is an attachment, never a third state), ConflictItem with a CLOSED code union and value-hashing conflictId (N1 compare-and-set), optional getMergeBase? / createMergeCommit? / mergeCommit? provider members — optional precisely so external implements code keeps compiling and types stays a minor.
  • fix(mcp,cli) — a diverged base is a partial success, not an error: the guard no longer throws after the content already landed on contentrain; writes and merges report base_advance / remote_push (four silent push-rejection sites now speak); mergeBranch gains the fetch + base-sync pre-step so one clean base commit stops killing every approve; status counts both directions instead of reporting the diverged state as "in sync"; serve approve + UI carry the truth; transaction network calls hardened.
  • feat(mcp) ×3 — ref-bound readers (GitRefReader, bindRef, MemoryProvider fork points); planReconcile, the content-aware three-way planner (entry-, key-, term+locale-level mechanics; one-model-level question for delete-vs-edit; bodies never text-merged; 38 in-memory tests including the collabers golden scenario resolving with zero conflicts); the executors — local worktree merge that writes the ENTIRE plan over git's textual result (N2: the planner is authoritative for content paths) and commits with two parents, GitHub createMergeCommit via the Git Data API, GitLab capability-absent with MR fallback.
  • feat(mcp,cli) — the product doors: contentrain_reconcile (dry_run-first; conflicts are a valid result, never isError) and contentrain reconcile (interactive ours/theirs; --yes never defaults a conflict). Tool count 27 (22 core + 5 media) through the whole parity chain.
  • docs — divergence reaches every surface that documents the workflow (it previously appeared nowhere); §4.4 dual-domain migration recipe in rules + skills; Studio consumption contract in the internal handoff; one changeset, three minors.

Verification

  • pnpm lint — 0 warnings, 0 errors; tsc --noEmit clean in every package (+ serve-ui vue-tsc)
  • pnpm build && pnpm test — full suite green in one run (mcp 38 planner + 5 executor + 2 tool tests new; cli 171 incl. 5 new reconcile command tests; rules/skills parity suites enforce the new tool everywhere)
  • Real-git end-to-end: diverged fixture → dry-run touches nothing → apply lands a two-parent merge commit with editor entries + migrated model + intact source → second run reports in_sync

Open question (for the Studio session)

config.json is not in the approved policy table, so a both-sides change reports as file_conflict. Proposal: a top-level key-based row in the next minor — detailed in 02-studio-handoff.md Appendix C, awaiting ack.

🤖 Generated with Claude Code

ABB65 added 7 commits August 15, 2026 01:26
BaseAdvance ('advanced' | 'blocked_diverged') and RemotePush name what
happens to the base branch and the remote after a write — shared words
with Studio's approve contract, where a PR is an attachment, never a
third state. Commit carries them as optional fields the same way it
already carries workflowAction/sync/warning.

ConflictItem/ConflictCode/ConflictResolution define the surface of the
content-aware three-way reconcile: code is a CLOSED union (consumers key
localized editor questions on it — new value = minor + changelog line),
and id hashes position AND values (conflictId over stableHash, a
dependency-free FNV-1a 64), so a resolution made against a stale dry-run
stops matching the moment any side changes — compare-and-set for free.

RepoProvider gains two OPTIONAL members, getMergeBase and
createMergeCommit, plus the optional capabilities.mergeCommit flag —
optional precisely so external 'implements RepoProvider' code keeps
compiling and this stays a minor. LOCAL_CAPABILITIES declares
mergeCommit: true.

Tests: stableHash determinism/unicode; conflictId stability, the
compare-and-set property, key-order normalization, position sensitivity.
Field report (collabers, 14 Aug): with contentrain and main diverged,
every approve died in a 409 loop while the content sat safely on
contentrain — the product reported failure for work that had landed.
The local flow had the same shape in four places, all fixed here.

transaction.ts: the fast-forward guard no longer throws. By the time it
runs, the feature branch IS merged into contentrain; only the base
advance is impossible. complete() and mergeBranch() now return
base_advance: 'advanced' | 'blocked_diverged' (shared vocabulary with
Studio's approve contract) plus remote_push: 'pushed' | 'rejected' |
'no-remote' — the four silent push-failure sites previously swallowed a
non-fast-forward rejection while the tool reported auto-merged.
mergeBranch also gains the fetch + base-sync pre-step createTransaction
already had: without it, one clean base commit made every subsequent
approve fail permanently. selectiveSync runs only after a real advance —
syncing files when the developer's HEAD did not move would desync their
working tree. Fetch/push now go through networkGit (GIT_TERMINAL_PROMPT=0
+ block timeout), so a hung credential prompt cannot hang an MCP call.

The truth travels the whole chain: LocalApplyResult and
CommitThroughProviderResult carry base_advance/remote_push/warning (the
warning was previously dropped in commitThroughProvider — review-mode
push failures never reached any tool response), a shared gitReport()
builds the git object in all nine write tools instead of nine hand-rolled
literals, and divergenceNextSteps() names the exit path. mergeBranch now
prunes the cr/* branch on a blocked advance too — the phantom
'pending review' rows in serve UI came from that skipped delete.

serve: approve endpoints return the new fields and use
normalizeOperationError, so real failures reach the UI with code and
agent_hint instead of a bare message (git/errors is now an exported
subpath). The UI shows the partial-success warning and renders the
server's error text instead of a generic string.

status (CLI + MCP): contentBranchRelation counts BOTH directions. The
one-directional 'ahead' count printed 'in sync' precisely in the diverged
state that blocks every advance. JSON gains behind/relation; human output
distinguishes diverged / content_ahead / base_ahead.

Tests: diverged-base partial success for complete() and mergeBranch
(content on contentrain, base untouched, branch pruned), the clean
base-advance self-heal that used to kill approves, and the three
remote_push outcomes against a bare remote.
The reconcile planner reads three trees side by side — merge-base,
contentrain, base branch — through plain RepoReaders that are pinned to
one ref each, so the planner never sees a ref at all.

GitRefReader serves a RepoReader over any local ref with no checkout
(git show / ls-tree / cat-file -e); the per-call ref? parameter is
deliberately ignored — a bound reader answering for another ref would be
a lie, bind another instance. bindRef() is the ~15-line adapter for
readers that DO honour the per-call ref (GitHub, GitLab, memory).

MemoryProvider records each branch's fork snapshot — the information a
commit DAG encodes in parent pointers and a tree-as-Map cannot — which
makes getMergeBase answerable in memory: the fork tree is registered as
a synthetic membase/<branch> ref that readers bind to like any other.
createMergeCommit applies changes on ours' tree and records
parents: [ours, theirs]. Both mirror the new optional RepoProvider
members, so the whole reconcile flow can run with zero subprocesses.

Tests: reads/listings/existence against two refs while the working tree
stays on main; bindRef pinning against MemoryProvider.
core/ops/reconcile/: pure planning over three ref-bound RepoReaders
(merge-base / contentrain / main). Everything the policy table can
decide is merged mechanically; what it cannot becomes ConflictItem[],
and the plan is only applied once those carry resolutions.

The three-way rule reduces every policy row to one primitive
(mergeLeaf3): an unchanged side has no vote, identical changes converge,
a lone change wins, two different changes are a question — with
delete-vs-edit split out because it asks 'keep or drop?' instead of
'which value?'. On top of that: collections merge entry-then-field-wise,
dictionaries key-wise (the three-way generalization of 3.0.0's inline
upsert — with no author present, two values are a question, not a pick),
vocabulary at term+locale granularity (why the collabers scenario needs
zero human input), documents merge frontmatter key-wise with the body as
a single never-text-merged leaf and a round-trip guard that falls back
to whole-file resolution when nested frontmatter would not survive a
rewrite, models key-wise with suggested: 'theirs' carried as advice, and
meta arbitrates updated_at/updated_by mechanically while a two-sided
status change stays a human question.

Structure over guessing: each side's files are enumerated with THAT
side's model definition (content_path/locale_strategy may differ across
sides), the output lands at the winning definition's path, and file
shapes are never sniffed — dispatch always comes from the winning kind.
A model deleted on one side while the other edited its definition or
content collapses into ONE model-level question instead of a conflict
per file; a clean deletion cascades mechanically. Structural key
conflicts (kind/i18n/locale_strategy/content_path) block that model's
content phase outright.

N1 lands as ResolutionIndex: conflict ids hash position AND values, so
a decision made against a stale dry-run stops matching, is dropped with
an advisory, and the conflict reappears with a fresh id. Unrecognized
files under the scanned roots are never authored by the planner:
theirs-only changes are carried (so tree-building executors reproduce
what git's merge machinery would), both-sided changes become value-free
file_conflicts resolvable only by choosing a side.

Changes are emitted only when the merged value differs from ours parsed
(reconcile is not a formatter), context.json is regenerated through the
existing buildContextChange whenever the plan is not a no-op, and the
merged state is validated through OverlayReader as advisories that never
block.

Tests: 38 cases in tests/core/ops/reconcile/ — every policy row, the
collabers golden scenario (models theirs-only + content ours-only +
vocabulary split by locale ⇒ zero conflicts), determinism, the total
no-op, and the N1 stale-resolution drop — all on hand-rolled readers
with zero subprocesses.
…ent commit

git/reconcile.ts: reconcileBranches plans with three GitRefReaders (no
checkout, developer tree untouched), then executes a REAL git merge
--no-commit in a temp worktree and writes the ENTIRE plan output over it
before committing. That ordering is the N2 contract: for content-owned
paths the planner is authoritative — git's line-level auto-merge can
produce non-canonical or editorially wrong JSON without ever surfacing
as a conflict — while git still resolves everything the planner does not
own (source files) exactly as a manual merge would. Paths left unmerged
after the overwrite are by construction outside the planner's scope and
abort with RECONCILE_SOURCE_CONFLICT rather than guessing. The commit
joins both parents, so the base branch is an ancestor again and the
ordinary fast-forward advance (update-ref + selectiveSync + push, all
reused from the transaction layer) just works. A merely-behind base
fast-forwards without inventing a merge; unrelated histories refuse with
RECONCILE_NO_MERGE_BASE. Dry-run is the default and touches nothing.

Provider parity for orchestrators (Studio consumes the core op with its
own provider): LocalProvider and GitHubProvider implement getMergeBase +
createMergeCommit — GitHub via compare API + createCommit with
parents: [ours, theirs] on a tree layered over ours (the caller composes
the complete tree delta; theirs-only source changes must ride along or
the merge tree would silently revert them — said out loud in the JSDoc).
Both verify ours is still the branch tip (RECONCILE_STALE_OURS), the
executor-level twin of N1's compare-and-set. GitLab gets getMergeBase
only: its Commits API cannot express two parents, so mergeCommit stays
absent and callers fall back to a merge-request flow.

Tests: real-git divergence modeled on the collabers incident (content
edits on contentrain × model migration + source change on main) —
dry-run leaves every ref alone, apply produces a two-parent merge commit
with editor entries + migrated model + intact source, second run reports
in_sync, an undecidable edit conflicts without writing and applies
cleanly with a choose resolution, and a merely-behind base
fast-forwards.
The exit path the guard's refusal never offered. contentrain_reconcile
is preview-first (dry_run defaults true, mirroring contentrain_apply):
the preview reports the plan summary, the changes, and the conflicts;
dry_run:false executes as a two-parent merge commit and fast-forwards
the base branch. Conflicts come back as a valid result, never isError —
each one is a content decision the agent takes to the editor and answers
via resolutions. Local-only (projectRoot + localWorktree in
availability.ts); Studio consumes planReconcile directly instead.

contentrain reconcile (CLI) is a presenter over the tool through the
in-memory MCP session: dry-run → plan summary → an ours/theirs select
per conflict (with the policy suggestion marked) → execute with the
collected resolutions. --yes executes a CLEAN plan without prompting —
conflicts still stop it, a content decision is never defaulted. --json
mirrors the dry-run verbatim. A 'decide later' choice aborts without
writing.

Every blocked_diverged surface now names the exit: divergenceNextSteps,
both transaction warnings, and contentrain merge's new partial-success
warnings point at contentrain_reconcile / npx contentrain reconcile.
The merge command also stops discarding structured error fields — code,
agent_hint and developer_action now reach the terminal.

Parity chain: rules MCP_TOOLS (27 tools: 22 core + 5 media) + count
test, essentials gains the tool row and the dual-domain divergence
bullets, skills mcp-tools.md gains the full tool section (and the merge
row documents base_advance/remote_push), SKILL.md catalog + package
table updated, plugin payload regenerated.

Tests: tool surface e2e on a real inited project (in_sync, preview,
execute joining both histories); CLI command unit tests (in_sync stop,
--yes clean execute, interactive resolutions, skip aborts, --json).
… workflow

Divergence had no page anywhere — the word did not appear in the docs
site while it was the state that killed every approve. Now it does, as a
product state with an exit:

- concepts.md gains a Divergence section under Git Workflow (why the
  invariant breaks — the dual-domain migration — what blocked_diverged
  means, and how reconcile restores the fast-forward); the tool grouping
  and counts move to 27 (22 core + 5 media)
- reference/providers.md documents the optional getMergeBase /
  createMergeCommit pair and the mergeCommit capability in all four
  places the contract is restated (interface block, meanings table,
  provider matrix, LOCAL_CAPABILITIES copy)
- packages/mcp.md: tool catalog rows, the partial-success paragraph in
  Git-Native Workflow, and the core-exports list (planReconcile/bindRef,
  git/errors, git/reconcile)
- packages/types.md: BaseAdvance/RemotePush/ConflictItem/ConflictCode/
  ConflictResolution rows and the updated provider contract summary
- packages/cli.md + cli/README.md: the reconcile command (dry-run plan,
  interactive decisions, --yes never defaults a conflict) in the command
  table, flag matrix and review-workflow prose; merge documents its
  blocked_diverged partial success
- root README: stale '24 tools' diagram fixed, quick reference gains
  reconcile; getting-started's stale 'All 24' fixed
- rules/shared/workflow-rules.md and the contentrain skill's workflow
  reference gain §4.4 — the divergence rules and the official dual-domain
  migration recipe (isolated branch → PR to base → reconcile right after)
- two stale legacy 'contentrain/*' branch references in skills corrected
  to cr/*; plugin payload regenerated

.internal handoff gains Appendix C — Studio's consumption contract for
planReconcile (ref-bound readers, the full-tree-delta requirement for
createMergeCommit, the two CAS layers, the closed ConflictCode set, and
the one open policy question: config.json awaits Studio's ack).

Changeset: one entry, three minors (types / mcp / cli), telling the
whole story release-side.
@netlify

netlify Bot commented Aug 15, 2026

Copy link
Copy Markdown

Deploy Preview for contentrain-ai ready!

Name Link
🔨 Latest commit ea328a5
🔍 Latest deploy log https://app.netlify.com/projects/contentrain-ai/deploys/6a80106544345b0008581e16
😎 Deploy Preview https://deploy-preview-118--contentrain-ai.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@ABB65
ABB65 merged commit 7d58dca into main Aug 15, 2026
6 checks passed
@ABB65
ABB65 deleted the feat/reconcile-r0-truth branch August 15, 2026 08:56
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 15, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant