gabriel / muse public
Closed #8 Enhancement
filed by gabriel human · 21 days ago

muse resolve — manual merge resolution command

0 Anchors
Blast radius
Churn 30d
0 Proposals

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 matches path; returns list of symbol addresses cleared
  • resolve_symbol(symbol_address: str) -> bool — removes exactly one entry from conflict_paths; returns whether it was present

Invariants:

  • original_conflict_paths is never mutated — Harmony reads it at commit time to know what was learned
  • conflict_paths becomes empty → merge_in_progress may 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:

  1. Validate a merge is in progress — error clearly if not
  2. Accept <path> or <path>::<symbol> as the argument
  3. Validate the target is actually in conflict_paths — warn (not error) if already resolved
  4. Call Phase 1 mutation
  5. Stage the file (muse code add <path>) so the object store reflects the manual edit
  6. Print per-symbol confirmation lines + remaining conflict count
  7. If 0 conflicts remain, print "Ready to commit."

Flags:

  • --all — resolve all symbols in all currently staged files that are in conflict_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 — in conflict_paths, not yet resolved
  • resolved — in original_conflict_paths but cleared from conflict_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.md after a manual edit clears conflict_paths and stages the file
  • muse resolve hello.md::Hello World resolves a single symbol, leaving others conflicted
  • muse resolve --all resolves all staged conflicted files in one command
  • muse commit succeeds immediately after muse resolve clears all conflicts
  • original_conflict_paths is intact after resolution (Harmony still learns)
  • Harmony records human_verified=True for manually resolved symbols
  • muse conflicts --json distinguishes resolved vs unresolved symbols
  • muse resolve on a non-conflicted path warns and exits 0 (no error)
  • muse resolve outside a merge errors clearly
  • --json flag works on muse 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.

Activity6
gabriel opened this issue 21 days ago
gabriel 18 days ago

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 all conflict_paths entries whose file portion matches path (handles both plain paths and path::symbol entries). 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 resolve CLI command.

gabriel 18 days ago

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.

gabriel 18 days ago

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 resolvemuse code addmuse 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.

gabriel 18 days ago

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 default
  • TestRecordResolutionsManuallyResolved — full confidence matrix: None/empty/in-set/not-in-set/mixed + rationale text
  • TestResolveToHarmonyEndToEnd — CLI muse resolve → MERGE_STATE has correct manually_resolved
gabriel 18 days ago

Phase 5 landed ✅

muse conflicts --json now surfaces resolved conflict data:

  • resolved_count — number of conflicts cleared so far
  • resolved_conflicts — array of {path, file, symbol, kind} objects, one per resolved entry
  • conflict_count reflects 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 far
  • resolved_conflict_count — always equals len(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.

gabriel 18 days ago

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.