muse resolve — manual merge resolution command
Problem
muse code add <path> stages a manually merged file but does not clear conflict_paths in MERGE_STATE. This leaves muse commit permanently blocked even when the merge is correct. The only escape hatch today is muse checkout --ours or --theirs, both of which force a side-pick and destroy the manual merge.
This was discovered during a three-way merge demo at ~/dev/foundations. Two orthogonal changes to hello.md::Hello World — one adding style="background: red;", one adding inner text — were manually combined correctly, but the commit guard kept firing because MERGE_STATE still listed the symbol as conflicted. The workaround required throwing away the manual work, running checkout --ours, then re-applying the merged content by hand.
The missing primitive is muse resolve — a command that marks a conflict resolved in MERGE_STATE without touching the working tree.
Proposed UX
# Edit file manually to combine both sides, then:
muse resolve hello.md
# ✅ hello.md::Hello World — marked resolved
# 0 conflicts remaining. Ready to commit.
Symbol-level variant for granular resolution in multi-symbol files:
muse resolve hello.md::Hello World
This mirrors git add <path> after a manual conflict resolution — staging is the signal. Muse's symbol-level conflict tracking is more granular than git's, so muse resolve should be equally ergonomic at both path and symbol granularity.
Full happy path after the fix:
# edit the file manually
muse resolve hello.md # clears conflict, stages file
muse commit -m "merge: ..." # succeeds
Implementation Plan
Phase 1 — MERGE_STATE mutation primitives
Scope: muse/core/merge_state.py (or equivalent)
Add two methods:
resolve_path(path: str) -> list[str]— removes all conflict entries whose file portion matchespath; returns list of symbol addresses clearedresolve_symbol(symbol_address: str) -> bool— removes exactly one entry fromconflict_paths; returns whether it was present
Invariants:
original_conflict_pathsis never mutated — Harmony reads it at commit time to know what was learnedconflict_pathsbecomes empty →merge_in_progressmay remain true until commit clears it- Both methods are idempotent — calling them on an already-resolved path is a no-op, not an error
Phase 2 — muse resolve command
Scope: muse/cli/commands/resolve.py + registration in CLI root
Behaviour:
- Validate a merge is in progress — error clearly if not
- Accept
<path>or<path>::<symbol>as the argument - Validate the target is actually in
conflict_paths— warn (not error) if already resolved - Call Phase 1 mutation
- Stage the file (
muse code add <path>) so the object store reflects the manual edit - Print per-symbol confirmation lines + remaining conflict count
- If 0 conflicts remain, print "Ready to commit."
Flags:
--all— resolve all symbols in all currently staged files that are inconflict_paths--json— machine-readable output:{resolved: [...], remaining: N}
Error cases:
- No merge in progress →
❌ Not in a merge. Nothing to resolve. - Path not tracked →
❌ hello.md is not a tracked file. - Path not conflicted → warn
⚠ hello.md has no unresolved conflicts.(exit 0)
Phase 3 — commit guard alignment
Scope: muse/cli/commands/commit.py (the conflict_paths guard)
Verify the existing guard reads conflict_paths (the mutable list, not original_conflict_paths). If it does, Phase 1 + 2 are sufficient and this phase is a no-op. If it reads the wrong field, fix it here.
Add a test: after muse resolve, muse commit must succeed.
Phase 4 — Harmony integration
Scope: muse/harmony/ — record_resolutions call site
muse resolve is the human-in-the-loop signal. When the subsequent commit fires, Harmony should record the resolution with confidence=1.0 and human_verified=True for each symbol that was resolved via muse resolve (as opposed to auto-applied).
Mechanism: store a manually_resolved set in MERGE_STATE (populated by Phase 2). The Harmony commit hook reads this set and upgrades confidence accordingly.
This makes muse resolve a first-class Harmony learning event — not just a UX fix, but a signal that raises the quality of future auto-resolutions.
Phase 5 — muse conflicts and muse status display
Scope: muse/cli/commands/conflicts.py, muse/cli/commands/status.py
Update muse conflicts --json output to distinguish:
unresolved— inconflict_paths, not yet resolvedresolved— inoriginal_conflict_pathsbut cleared fromconflict_paths
Update muse status --json to surface the same distinction so tooling and agents can see partial progress through a complex merge.
Acceptance Criteria
muse resolve hello.mdafter a manual edit clearsconflict_pathsand stages the filemuse resolve hello.md::Hello Worldresolves a single symbol, leaving others conflictedmuse resolve --allresolves all staged conflicted files in one commandmuse commitsucceeds immediately aftermuse resolveclears all conflictsoriginal_conflict_pathsis intact after resolution (Harmony still learns)- Harmony records
human_verified=Truefor manually resolved symbols muse conflicts --jsondistinguishes resolved vs unresolved symbolsmuse resolveon a non-conflicted path warns and exits 0 (no error)muse resolveoutside a merge errors clearly--jsonflag works onmuse resolve
Background
Discovered during three-way merge demo at ~/dev/foundations. The Harmony system already records resolutions correctly at commit time — muse resolve is the missing link that makes the human-in-the-loop path as ergonomic as the auto-resolved path. Without it, manual merges require a destructive workaround that throws away work.
Phase 2 merged to dev/main at sha256:d22ae2a43e39.
Added muse/cli/commands/resolve.py and registered it in muse/cli/app.py.
muse resolve — CLI interface
muse resolve <path> — clear all conflicts in a file
muse resolve <path>::<symbol> — clear one symbol-level conflict
muse resolve --all — drain the entire conflict list
muse resolve --json — machine-readable output
JSON shape
{
"resolved": ["src/billing.py::Invoice.charge"],
"remaining": 0,
"ready_to_commit": true,
"duration_ms": 0.4,
"exit_code": 0
}
Typical agent workflow
muse merge feature-x
# ... edit conflicted files manually ...
muse resolve src/billing.py
muse code add src/billing.py
muse commit -m 'merge: resolve billing conflicts' --agent-id claude-code --model-id claude-sonnet-4-6 --sign
Behaviour mirrors muse checkout --ours/--theirs: MERGE_STATE is updated, staging is left to the agent. --all iterates file by file using the Phase 1 primitives so original_conflict_paths is always preserved.
Phase 3 merged to dev/main at sha256:5e60c0960248.
Guard verification
Inspected commit.py:305:
if merge_state is not None and merge_state.conflict_paths:
Reads conflict_paths (mutable list) — correct. Phase 3 is a no-op fix.
Tests added — tests/test_resolve_phase3.py (20 tests)
| Class | What it covers |
|---|---|
TestCommitGuardReadsConflictPaths |
Guard blocks on non-empty conflict_paths, passes when empty regardless of original_conflict_paths |
TestResolvePathThenCommit |
resolve_path() primitive → muse commit succeeds; partial resolve still blocks; MERGE_STATE cleared; original_conflict_paths preserved |
TestResolveSymbolThenCommit |
resolve_symbol() primitive → same flow |
TestResolveCliThenCommitCli |
Full CLI: muse resolve → muse code add → muse commit exits 0; --all; --json; no-merge error; already-resolved warns and exits 0 |
The acceptance criterion is met: after muse resolve clears all conflicts, muse commit succeeds immediately.
Phase 4 merged to dev/main at sha256:f61171578c88.
What changed
MERGE_STATE gains a manually_resolved field — a growing list of conflict addresses explicitly confirmed via muse resolve. The field is absent in legacy MERGE_STATE files (pre-Phase-4 merges) and is populated incrementally:
muse resolve hello.md::A # → manually_resolved: ["hello.md::A"]
muse resolve hello.md::B # → manually_resolved: ["hello.md::A", "hello.md::B"]
record_resolutions (harmony.py) gains a manually_resolved: set[str] | None parameter:
| Condition | confidence | human_verified |
|---|---|---|
manually_resolved is None (legacy / all side-pick) |
1.0 | True |
path in manually_resolved (edited manually) |
1.0 | True |
path not in manually_resolved (checkout --ours/--theirs) |
0.8 | False |
commit.py passes set(merge_state.manually_resolved) to harmony when non-empty, None when empty (preserving legacy behaviour for pure side-pick flows).
Tests (19) — tests/test_resolve_phase4.py
TestManuallyResolvedTracking— MERGE_STATE accumulation, noop, original_conflict_paths invariant, round-trip, legacy state defaultTestRecordResolutionsManuallyResolved— full confidence matrix: None/empty/in-set/not-in-set/mixed + rationale textTestResolveToHarmonyEndToEnd— CLImuse resolve→ MERGE_STATE has correct manually_resolved
Phase 5 landed ✅
muse conflicts --json now surfaces resolved conflict data:
resolved_count— number of conflicts cleared so farresolved_conflicts— array of{path, file, symbol, kind}objects, one per resolved entryconflict_countreflects only unresolved entries (original minus resolved)- Both keys always present — zero values when nothing is resolved or no merge in progress
muse status --json gains:
resolved_conflict_paths— list of paths resolved so farresolved_conflict_count— always equalslen(resolved_conflict_paths)- Both keys always present (empty list / 0 when no merge in progress)
Text output — when partially resolved, both commands now show a (N resolved) progress hint and list each resolved path with ✅.
16 tests added in tests/test_resolve_phase5.py, all green. Typing ratchet holds at 0.
Issue #8 — Complete ✅
Full acceptance criteria review before closing:
| Criterion | Status | Notes |
|---|---|---|
muse resolve hello.md clears conflict_paths and stages the file |
✅ | Auto-staging added in fix commit; _stage_files() helper wires into all three resolution paths |
muse resolve hello.md::Hello World resolves one symbol, leaves others |
✅ | Phase 1 + Phase 2 |
muse resolve --all resolves all staged conflicted files |
✅ | Phase 2; auto-stages every file that had conflicts cleared |
muse commit succeeds immediately after muse resolve clears all |
✅ | Phase 3; guard reads conflict_paths (mutable) |
original_conflict_paths intact after resolution (Harmony still learns) |
✅ | Phase 1 invariant; never mutated by resolve |
Harmony records human_verified=True for manually resolved symbols |
✅ | Phase 4; manually_resolved field in MERGE_STATE; record_resolutions uses it |
muse conflicts --json distinguishes resolved vs unresolved |
✅ | Phase 5; resolved_count + resolved_conflicts array |
muse resolve on non-conflicted path warns, exits 0 |
✅ | Phase 2; ℹ️ message, no error |
muse resolve outside a merge errors clearly |
✅ | Phase 2; ❌ message + exit 1 |
--json flag works on muse resolve |
✅ | Phase 2; {resolved, remaining, ready_to_commit} |
78 tests across 4 test files, all green. Typing ratchet at 0.
Closing.
Phase 1 merged to dev/main at sha256:93031bac55d9.
Added two primitives to
muse/core/merge_engine.py:resolve_path(root, path) -> list[str]— clears allconflict_pathsentries whose file portion matchespath(handles both plain paths andpath::symbolentries). Returns cleared addresses. Idempotent.resolve_symbol(root, symbol_address) -> bool— removes exactly one entry by exact match. Returns True/False. Idempotent.Both: never touch
original_conflict_paths, preserve all other MERGE_STATE fields, raise ValueError when no merge is in progress. 20 tests, typing ratchet clean.Starting Phase 2 —
muse resolveCLI command.