bingo-light 2.1.3 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -69,11 +69,15 @@ That's it. Three commands and your fork stays in sync forever.
69
69
 
70
70
  > The AI calls `conflict-analyze --json`, reads the structured ours/theirs data, writes the merged file, and the rebase continues. No human needed.
71
71
 
72
- During a rebase, `conflict-analyze` also returns:
73
- - **`patch_intent`** — patch name, subject, full commit message, original SHA, original diff, metadata (reason/tags/upstream_pr/status/owner), and position in the patch stack.
74
- - **`verify`** — configured `test.command` plus per-file syntax/parse commands by extension (`.py/.json/.yml/.yaml/.toml/.sh`).
75
-
76
- `conflict-resolve --verify` (CLI) or `verify: true` (MCP) runs `test.command` after the final `git rebase --continue`; the result is attached as `verify_result`.
72
+ During a rebase, `conflict-analyze` returns a full situational briefing:
73
+ - **`patch_intent`** — patch name, subject, full commit message, original SHA, original diff, metadata, stack position.
74
+ - **`verify`** — configured `test.command` + per-file syntax/parse hints by extension (`.py/.json/.yml/.yaml/.toml/.sh`).
75
+ - **`upstream_context`** — upstream commits touching conflicts, with author, subject, and extracted PR numbers.
76
+ - **`patch_dependencies`** later patches in your stack that modify overlapping files (cascade risk).
77
+ - **`decision_memory`** — prior resolutions for this patch from `.bingo/decisions/`, ranked by relevance.
78
+ - Each `conflicts[]` entry carries **`semantic_class`**: `whitespace` / `import_reorder` / `signature_change` / `logic`.
79
+
80
+ `conflict-resolve --verify` (CLI) or `verify: true` (MCP) runs `test.command` after the final `git rebase --continue`; the result is attached as `verify_result`. `conflict-resolve` also auto-records the decision (file, semantic class, strategy) to decision memory.
77
81
 
78
82
  ---
79
83
 
package/README.md CHANGED
@@ -187,7 +187,16 @@ cd bingo-light && make install && bingo-light setup
187
187
  }
188
188
  ```
189
189
 
190
- `conflict-analyze` 在 rebase 中额外附带 `patch_intent`(补丁意图:原始 commit、diff、metadata、栈位置)与 `verify`(配置的 `test.command` + 按扩展名的逐文件校验命令)。`conflict-resolve --verify` 在最终 `git rebase --continue` 完成后自动跑 `test.command`,结果挂在 `verify_result` 字段。
190
+ `conflict-analyze` 在 rebase 中返回完整态势简报:
191
+
192
+ - **`patch_intent`**:补丁意图(原始 commit、diff、metadata、栈位置)
193
+ - **`verify`**:配置的 `test.command` + 按扩展名的逐文件校验命令
194
+ - **`upstream_context`**:触发冲突的上游 commits(作者、主题、自动抽取的 PR 号)
195
+ - **`patch_dependencies`**:栈中后续补丁是否触及相同文件(cascade 风险)
196
+ - **`decision_memory`**:该补丁的历史解决方案(`.bingo/decisions/`)
197
+ - 每条 `conflicts[]` 都带 **`semantic_class`**:`whitespace` / `import_reorder` / `signature_change` / `logic`
198
+
199
+ `conflict-resolve --verify` 在最终 `git rebase --continue` 完成后自动跑 `test.command`,结果挂在 `verify_result` 字段;`conflict-resolve` 还会自动把本次决策(file, semantic class, strategy)写入 decision memory。
191
200
 
192
201
  </details>
193
202
 
package/bingo-light CHANGED
@@ -269,6 +269,58 @@ def _format_conflict_analyze(result: dict) -> str:
269
269
  for h in verify.get("file_hints", []):
270
270
  lines.append(f" [{h['kind']}] {h['command']}")
271
271
 
272
+ deps = result.get("patch_dependencies")
273
+ if deps and deps.get("dependents"):
274
+ lines.append("")
275
+ dlist = deps["dependents"]
276
+ lines.append(
277
+ f"{_c(BOLD, 'Cascade risk:')} "
278
+ f"{len(dlist)} later patch(es) touch overlapping files"
279
+ )
280
+ for d in dlist[:5]:
281
+ files = ", ".join(d.get("overlapping_files", []))
282
+ lines.append(
283
+ f" #{d['position']} {d['name']}: {d.get('subject', '')} "
284
+ f"\u2014 {_c(YELLOW, files)}"
285
+ )
286
+ if len(dlist) > 5:
287
+ lines.append(f" {_c(DIM, f'... {len(dlist) - 5} more')}")
288
+
289
+ dm = result.get("decision_memory")
290
+ if dm and dm.get("entries"):
291
+ lines.append("")
292
+ lines.append(f"{_c(BOLD, 'Decision memory:')} prior resolutions for this patch")
293
+ for e in dm["entries"]:
294
+ lines.append(f" {_c(CYAN, e['file'])} ({e['semantic_class']}):")
295
+ for prev in e.get("previous_decisions", [])[:3]:
296
+ strat = prev.get("resolution_strategy", "?")
297
+ subj = prev.get("upstream_subject") or "(no subject)"
298
+ ts = prev.get("timestamp", "")
299
+ rel = prev.get("relevance", "")
300
+ lines.append(
301
+ f" [{strat}] {subj} {_c(DIM, f'({rel}, {ts})')}"
302
+ )
303
+
304
+ uc = result.get("upstream_context")
305
+ if uc:
306
+ lines.append("")
307
+ total = uc.get("total_commits", 0)
308
+ touching = uc.get("commits_touching_conflicts", [])
309
+ lines.append(
310
+ f"{_c(BOLD, 'Upstream context:')} "
311
+ f"{uc.get('range', '?')} "
312
+ f"({total} commits, {len(touching)} touching conflicts)"
313
+ )
314
+ for commit in touching[:5]:
315
+ short = commit.get("short_sha", "?")
316
+ subj = commit.get("subject", "")
317
+ author = commit.get("author", "")
318
+ pr = commit.get("pr")
319
+ pr_tag = f" #{pr}" if pr else ""
320
+ lines.append(f" {_c(DIM, short)} {subj}{pr_tag} \u2014 {author}")
321
+ if len(touching) > 5:
322
+ lines.append(f" {_c(DIM, f'... {len(touching) - 5} more')}")
323
+
272
324
  return "\n".join(lines)
273
325
 
274
326
 
@@ -14,7 +14,7 @@ import re
14
14
 
15
15
  # --- Constants ---
16
16
 
17
- VERSION = "2.1.3"
17
+ VERSION = "2.2.0"
18
18
  PATCH_PREFIX = "[bl]"
19
19
  CONFIG_FILE = ".bingolight"
20
20
  BINGO_DIR = ".bingo"
@@ -39,6 +39,8 @@ from bingo_core.exceptions import ( # noqa: E402
39
39
  DirtyTreeError,
40
40
  )
41
41
  from bingo_core.models import PatchInfo, ConflictInfo # noqa: E402
42
+ from bingo_core.semantic import classify_conflict # noqa: E402
43
+ from bingo_core.decisions import DecisionMemory, detect_resolution_strategy # noqa: E402
42
44
  from bingo_core.git import Git # noqa: E402
43
45
  from bingo_core.config import Config # noqa: E402
44
46
  from bingo_core.state import State # noqa: E402
@@ -70,7 +72,11 @@ __all__ = [
70
72
  # Data classes
71
73
  "PatchInfo",
72
74
  "ConflictInfo",
75
+ # Functions
76
+ "classify_conflict",
77
+ "detect_resolution_strategy",
73
78
  # Classes
79
+ "DecisionMemory",
74
80
  "Git",
75
81
  "Config",
76
82
  "State",
@@ -0,0 +1,167 @@
1
+ """
2
+ bingo_core.decisions — per-patch conflict-resolution memory.
3
+
4
+ Complements git rerere (which keys by literal conflict text) with a
5
+ pattern-level memory: records how patch X was resolved against upstream
6
+ commit Y, keyed by (patch_name, file, semantic_class). When the same
7
+ patch conflicts again in a similar pattern, previous decisions are
8
+ surfaced to the AI during conflict-analyze so it can consider the
9
+ prior choice.
10
+
11
+ Storage: .bingo/decisions/<patch-name>.json, one file per patch.
12
+ This avoids hot contention and keeps each patch's history isolated.
13
+
14
+ Python 3.8+ stdlib only. No external dependencies.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import re
22
+ from datetime import datetime, timezone
23
+ from typing import List, Optional
24
+
25
+ # Patch name constraint mirrors PATCH_NAME_RE to keep filenames safe.
26
+ _SAFE_PATCH_NAME = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$")
27
+ MAX_DECISIONS_PER_PATCH = 50
28
+
29
+
30
+ class DecisionMemory:
31
+ """Per-patch decision log stored under .bingo/decisions/."""
32
+
33
+ def __init__(self, repo_path: str):
34
+ self.repo_path = repo_path
35
+ self.dir = os.path.join(repo_path, ".bingo", "decisions")
36
+
37
+ def _path_for(self, patch_name: str) -> Optional[str]:
38
+ if not patch_name or not _SAFE_PATCH_NAME.match(patch_name):
39
+ return None
40
+ return os.path.join(self.dir, f"{patch_name}.json")
41
+
42
+ def _load_all(self, patch_name: str) -> List[dict]:
43
+ path = self._path_for(patch_name)
44
+ if not path or not os.path.isfile(path):
45
+ return []
46
+ try:
47
+ with open(path) as f:
48
+ data = json.load(f)
49
+ except (IOError, OSError, json.JSONDecodeError):
50
+ return []
51
+ decisions = data.get("decisions", [])
52
+ return decisions if isinstance(decisions, list) else []
53
+
54
+ def _save_all(self, patch_name: str, decisions: List[dict]) -> None:
55
+ path = self._path_for(patch_name)
56
+ if not path:
57
+ return
58
+ os.makedirs(self.dir, exist_ok=True)
59
+ tmp = path + ".tmp"
60
+ try:
61
+ with open(tmp, "w") as f:
62
+ json.dump(
63
+ {"patch": patch_name, "decisions": decisions},
64
+ f, indent=2,
65
+ )
66
+ os.replace(tmp, path)
67
+ except (IOError, OSError):
68
+ try:
69
+ os.unlink(tmp)
70
+ except OSError:
71
+ pass
72
+
73
+ def record(
74
+ self,
75
+ patch_name: str,
76
+ file: str,
77
+ semantic_class: str,
78
+ resolution_strategy: str,
79
+ upstream_sha: Optional[str] = None,
80
+ upstream_subject: Optional[str] = None,
81
+ notes: str = "",
82
+ ) -> None:
83
+ """Append one decision for a patch.
84
+
85
+ Silently no-ops if patch_name is empty or invalid. The newest
86
+ MAX_DECISIONS_PER_PATCH entries are retained; older entries are
87
+ dropped FIFO to keep files bounded.
88
+ """
89
+ if not patch_name or not _SAFE_PATCH_NAME.match(patch_name):
90
+ return
91
+ entry = {
92
+ "timestamp": datetime.now(timezone.utc).strftime(
93
+ "%Y-%m-%dT%H:%M:%SZ"
94
+ ),
95
+ "file": file,
96
+ "semantic_class": semantic_class,
97
+ "resolution_strategy": resolution_strategy,
98
+ "upstream_sha": upstream_sha,
99
+ "upstream_subject": upstream_subject,
100
+ "notes": notes,
101
+ }
102
+ decisions = self._load_all(patch_name)
103
+ decisions.append(entry)
104
+ if len(decisions) > MAX_DECISIONS_PER_PATCH:
105
+ decisions = decisions[-MAX_DECISIONS_PER_PATCH:]
106
+ self._save_all(patch_name, decisions)
107
+
108
+ def lookup(
109
+ self,
110
+ patch_name: str,
111
+ file: Optional[str] = None,
112
+ semantic_class: Optional[str] = None,
113
+ limit: int = 5,
114
+ ) -> List[dict]:
115
+ """Return up to `limit` previous decisions, most-recent first,
116
+ ranked by relevance to the given file/semantic_class.
117
+
118
+ Ranking: +2 if file matches, +1 if semantic_class matches.
119
+ Ties broken by recency.
120
+ """
121
+ decisions = self._load_all(patch_name)
122
+ if not decisions:
123
+ return []
124
+
125
+ def score(d: dict) -> tuple:
126
+ relevance = 0
127
+ if file and d.get("file") == file:
128
+ relevance += 2
129
+ if semantic_class and d.get("semantic_class") == semantic_class:
130
+ relevance += 1
131
+ return (relevance, d.get("timestamp", ""))
132
+
133
+ ranked = sorted(decisions, key=score, reverse=True)
134
+ result = []
135
+ for d in ranked[:limit]:
136
+ # Add a human-readable relevance tag
137
+ tag = []
138
+ if file and d.get("file") == file:
139
+ tag.append("same_file")
140
+ if semantic_class and d.get("semantic_class") == semantic_class:
141
+ tag.append("same_class")
142
+ entry = dict(d)
143
+ entry["relevance"] = "+".join(tag) if tag else "recent"
144
+ result.append(entry)
145
+ return result
146
+
147
+
148
+ def detect_resolution_strategy(
149
+ resolved_content: str, ours: str, theirs: str
150
+ ) -> str:
151
+ """Classify how a resolution was produced by comparing bytes.
152
+
153
+ Returns "keep_ours" / "keep_theirs" / "manual". Exact-match required
154
+ because partial merges still count as manual. Empty `resolved_content`
155
+ returns "manual" (caller didn't tell us what was written).
156
+ """
157
+ if not resolved_content:
158
+ return "manual"
159
+ # Strip trailing whitespace on both sides to be forgiving of newline diffs.
160
+ r = resolved_content.rstrip()
161
+ o = (ours or "").rstrip()
162
+ t = (theirs or "").rstrip()
163
+ if r == o:
164
+ return "keep_ours"
165
+ if r == t:
166
+ return "keep_theirs"
167
+ return "manual"
@@ -32,6 +32,7 @@ class ConflictInfo:
32
32
  theirs: str = ""
33
33
  conflict_count: int = 0
34
34
  merge_hint: str = ""
35
+ semantic_class: str = "logic"
35
36
 
36
37
  def to_dict(self) -> dict:
37
38
  return asdict(self)
@@ -31,6 +31,8 @@ from bingo_core.exceptions import (
31
31
  DirtyTreeError,
32
32
  )
33
33
  from bingo_core.models import ConflictInfo
34
+ from bingo_core.semantic import classify_conflict
35
+ from bingo_core.decisions import DecisionMemory, detect_resolution_strategy
34
36
  from bingo_core.git import Git
35
37
  from bingo_core.config import Config
36
38
  from bingo_core.state import State
@@ -46,6 +48,7 @@ class Repo:
46
48
  self.config = Config(self.path)
47
49
  self.state = State(self.path)
48
50
  self.team = TeamState(self.path, git=self.git)
51
+ self.decisions = DecisionMemory(self.path)
49
52
 
50
53
  # -- Internal helpers --
51
54
 
@@ -359,6 +362,167 @@ class Repo:
359
362
  ".sh": ("bash -n {path}", "syntax"),
360
363
  }
361
364
 
365
+ _PR_NUMBER_RE = re.compile(r"(?:#|pull request #)(\d+)")
366
+
367
+ def _build_patch_dependencies(self, current_name: str) -> Optional[dict]:
368
+ """Find later patches in the stack that touch the same files as current.
369
+
370
+ Useful for detecting cascade risk: if we're resolving a conflict in
371
+ patch A, and patches B/C/D build on A's changes to the same files,
372
+ the AI should consider whether its merge choice will cascade.
373
+
374
+ Returns {current_patch, dependents: [{name, subject, position,
375
+ overlapping_files}]} or None if no stack info available.
376
+ """
377
+ if not current_name:
378
+ return None
379
+ try:
380
+ c = self._load()
381
+ base = self._patches_base(c)
382
+ if not base:
383
+ return None
384
+ try:
385
+ log_output = self.git.run(
386
+ "rev-list", "--reverse",
387
+ f"{base}..{c['patches_branch']}"
388
+ )
389
+ except GitError:
390
+ return None
391
+ shas = log_output.splitlines()
392
+ if not shas:
393
+ return None
394
+
395
+ # Collect (index, name, subject, sha, files) for each patch.
396
+ patches_info = []
397
+ for idx, sha in enumerate(shas, start=1):
398
+ try:
399
+ subj = self.git.run("log", "-1", "--format=%s", sha).strip()
400
+ except GitError:
401
+ continue
402
+ m = self._PATCH_SUBJECT_RE.match(subj)
403
+ if not m:
404
+ continue
405
+ try:
406
+ files_out = self.git.run(
407
+ "show", "--format=", "--name-only", sha
408
+ )
409
+ except GitError:
410
+ files_out = ""
411
+ files = [ln for ln in files_out.splitlines() if ln]
412
+ patches_info.append({
413
+ "index": idx,
414
+ "name": m.group(1),
415
+ "subject": m.group(2).strip(),
416
+ "files": set(files),
417
+ })
418
+
419
+ # Find current patch and its files.
420
+ cur = next(
421
+ (p for p in patches_info if p["name"] == current_name), None
422
+ )
423
+ if cur is None:
424
+ return None
425
+
426
+ # Later patches that overlap.
427
+ dependents = []
428
+ for p in patches_info:
429
+ if p["index"] <= cur["index"]:
430
+ continue
431
+ overlap = cur["files"] & p["files"]
432
+ if overlap:
433
+ dependents.append({
434
+ "name": p["name"],
435
+ "subject": p["subject"],
436
+ "position": p["index"],
437
+ "overlapping_files": sorted(overlap),
438
+ })
439
+ return {
440
+ "current_patch": current_name,
441
+ "dependents": dependents,
442
+ }
443
+ except Exception:
444
+ return None
445
+
446
+ def _build_upstream_context(self, conflicted_files: List[str]) -> Optional[dict]:
447
+ """Find upstream commits that modified the conflicting files.
448
+
449
+ Uses .bingo/.undo-tracking (pre-sync upstream position) as the
450
+ baseline and the current tracking branch as the target. For each
451
+ conflicted file, lists upstream commits between those two points.
452
+
453
+ Returns a dict {range, total_commits, commits_touching_conflicts}
454
+ or None if the comparison range cannot be established.
455
+ """
456
+ try:
457
+ _head, old_tracking = self.state.load_undo()
458
+ except Exception:
459
+ return None
460
+ if not old_tracking:
461
+ return None
462
+
463
+ try:
464
+ c = self._load()
465
+ except Exception:
466
+ return None
467
+ # During a conflict, _sync_locked rolls back the tracking branch,
468
+ # so we use the remote-tracking ref (upstream/<branch>) which
469
+ # reflects the fetched target position.
470
+ new_tracking = (
471
+ self.git.rev_parse(f"upstream/{c['upstream_branch']}")
472
+ or self.git.rev_parse(c["tracking_branch"])
473
+ )
474
+ if not new_tracking or new_tracking == old_tracking:
475
+ return None
476
+
477
+ commit_map: dict = {}
478
+ FMT = "%H%x1f%h%x1f%an%x1f%at%x1f%s"
479
+ for f in conflicted_files:
480
+ try:
481
+ out = self.git.run(
482
+ "log",
483
+ f"--format={FMT}",
484
+ f"{old_tracking}..{new_tracking}",
485
+ "--",
486
+ f,
487
+ )
488
+ except GitError:
489
+ continue
490
+ for line in out.splitlines():
491
+ parts = line.split("\x1f")
492
+ if len(parts) != 5:
493
+ continue
494
+ sha, short, author, ts, subject = parts
495
+ entry = commit_map.setdefault(sha, {
496
+ "sha": sha,
497
+ "short_sha": short,
498
+ "author": author,
499
+ "timestamp": int(ts) if ts.isdigit() else 0,
500
+ "subject": subject,
501
+ "files": [],
502
+ "pr": None,
503
+ })
504
+ if f not in entry["files"]:
505
+ entry["files"].append(f)
506
+ if entry["pr"] is None:
507
+ m = self._PR_NUMBER_RE.search(subject)
508
+ if m:
509
+ entry["pr"] = m.group(1)
510
+
511
+ total = 0
512
+ try:
513
+ total = self.git.rev_list_count(f"{old_tracking}..{new_tracking}")
514
+ except Exception:
515
+ pass
516
+
517
+ commits = sorted(
518
+ commit_map.values(), key=lambda x: x["timestamp"], reverse=True
519
+ )
520
+ return {
521
+ "range": f"{old_tracking[:7]}..{new_tracking[:7]}",
522
+ "total_commits": total,
523
+ "commits_touching_conflicts": commits,
524
+ }
525
+
362
526
  def _verify_hints_for(self, files: List[str]) -> List[dict]:
363
527
  """Generate per-file verification commands by extension.
364
528
 
@@ -525,6 +689,7 @@ class Repo:
525
689
  theirs=theirs,
526
690
  conflict_count=conflict_count,
527
691
  merge_hint=merge_hint,
692
+ semantic_class=classify_conflict(ours, theirs, filepath),
528
693
  )
529
694
 
530
695
  def _record_sync(self, c: dict, behind: int, saved_tracking: str) -> None:
@@ -1138,8 +1303,12 @@ class Repo:
1138
1303
  "test_command": self.config.get("test.command") or None,
1139
1304
  "file_hints": self._verify_hints_for(conflicted),
1140
1305
  }
1306
+ upstream_context = self._build_upstream_context(conflicted)
1307
+ patch_dependencies = self._build_patch_dependencies(
1308
+ patch_intent.get("name", "") if patch_intent else ""
1309
+ )
1141
1310
 
1142
- return {
1311
+ result = {
1143
1312
  "ok": True,
1144
1313
  "in_rebase": True,
1145
1314
  "current_patch": current_patch,
@@ -1155,6 +1324,34 @@ class Repo:
1155
1324
  "6. To abort instead: git rebase --abort",
1156
1325
  ],
1157
1326
  }
1327
+ if upstream_context is not None:
1328
+ result["upstream_context"] = upstream_context
1329
+ if patch_dependencies is not None:
1330
+ result["patch_dependencies"] = patch_dependencies
1331
+
1332
+ # Decision memory: look up previous resolutions for this patch.
1333
+ patch_name = patch_intent.get("name", "") if patch_intent else ""
1334
+ if patch_name:
1335
+ memory_entries = []
1336
+ for conflict in conflicts:
1337
+ prior = self.decisions.lookup(
1338
+ patch_name,
1339
+ file=conflict.file,
1340
+ semantic_class=conflict.semantic_class,
1341
+ limit=3,
1342
+ )
1343
+ if prior:
1344
+ memory_entries.append({
1345
+ "file": conflict.file,
1346
+ "semantic_class": conflict.semantic_class,
1347
+ "previous_decisions": prior,
1348
+ })
1349
+ if memory_entries:
1350
+ result["decision_memory"] = {
1351
+ "patch": patch_name,
1352
+ "entries": memory_entries,
1353
+ }
1354
+ return result
1158
1355
 
1159
1356
  def conflict_resolve(
1160
1357
  self, file_path: str, content: str = "", verify: bool = False
@@ -1197,6 +1394,9 @@ class Repo:
1197
1394
  f"Unmerged files: {', '.join(unmerged) if unmerged else '(none)'}"
1198
1395
  )
1199
1396
 
1397
+ # Capture pre-resolve conflict snapshot (for decision memory).
1398
+ pre_conflict = self._extract_conflict(rel_path)
1399
+
1200
1400
  # Write content if provided
1201
1401
  if content:
1202
1402
  full_path = str(resolved)
@@ -1210,6 +1410,41 @@ class Repo:
1210
1410
  if not self.git.run_ok("add", rel_path):
1211
1411
  raise BingoError(f"Failed to stage file: {rel_path}")
1212
1412
 
1413
+ # Record decision memory (best-effort; silent on failure).
1414
+ try:
1415
+ intent = self._build_patch_intent()
1416
+ patch_name = intent.get("name", "") if intent else ""
1417
+ if patch_name:
1418
+ resolved_content = content
1419
+ if not resolved_content:
1420
+ try:
1421
+ with open(str(resolved)) as f:
1422
+ resolved_content = f.read()
1423
+ except (IOError, OSError):
1424
+ resolved_content = ""
1425
+ strategy = detect_resolution_strategy(
1426
+ resolved_content, pre_conflict.ours, pre_conflict.theirs
1427
+ )
1428
+ # Pick the first upstream commit touching this file as the
1429
+ # "triggering" upstream change (best-effort context).
1430
+ uc = self._build_upstream_context([rel_path])
1431
+ upstream_sha = None
1432
+ upstream_subject = None
1433
+ if uc and uc.get("commits_touching_conflicts"):
1434
+ top = uc["commits_touching_conflicts"][0]
1435
+ upstream_sha = top.get("sha")
1436
+ upstream_subject = top.get("subject")
1437
+ self.decisions.record(
1438
+ patch_name,
1439
+ file=rel_path,
1440
+ semantic_class=pre_conflict.semantic_class,
1441
+ resolution_strategy=strategy,
1442
+ upstream_sha=upstream_sha,
1443
+ upstream_subject=upstream_subject,
1444
+ )
1445
+ except Exception:
1446
+ pass # memory is best-effort; never block rebase on it
1447
+
1213
1448
  # Check remaining unmerged files
1214
1449
  remaining = self.git.ls_files_unmerged()
1215
1450
  if remaining:
@@ -0,0 +1,85 @@
1
+ """
2
+ bingo_core.semantic — semantic classification of conflict regions.
3
+
4
+ Given ours/theirs text for a single conflict region, return one of:
5
+ "whitespace" — regions differ only in whitespace
6
+ "import_reorder" — both regions are only import statements,
7
+ same set of imports, just reordered
8
+ "signature_change" — function/method signature changed (params
9
+ added, removed, or renamed) but name same
10
+ "logic" — default; real logic change requiring human
11
+ or AI reasoning
12
+
13
+ The classifier is intentionally conservative: when unsure, return
14
+ "logic" so callers treat it as a real conflict.
15
+
16
+ Python 3.8+ stdlib only.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+
23
+ # Matches Python "import X" or "from X import Y" lines.
24
+ _IMPORT_RE_PY = re.compile(r"^\s*(?:import|from)\s+\S")
25
+ # Matches JS/TS imports and CommonJS require.
26
+ _IMPORT_RE_JS = re.compile(r"^\s*(?:import\s|const\s+\w+\s*=\s*require\()")
27
+
28
+ # Function signature captures: (name, params) for Python def and JS function.
29
+ _FN_SIG_PY = re.compile(r"^\s*(?:async\s+)?def\s+(\w+)\s*\(([^)]*)\)")
30
+ _FN_SIG_JS = re.compile(r"^\s*(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)")
31
+
32
+
33
+ def classify_conflict(ours: str, theirs: str, file: str = "") -> str:
34
+ """Classify a conflict region as whitespace / import_reorder /
35
+ signature_change / logic.
36
+
37
+ `file` is accepted for future extension (language-specific rules)
38
+ but not currently used.
39
+ """
40
+ if _is_whitespace_only(ours, theirs):
41
+ return "whitespace"
42
+ if _is_import_reorder(ours, theirs):
43
+ return "import_reorder"
44
+ if _is_signature_change(ours, theirs):
45
+ return "signature_change"
46
+ return "logic"
47
+
48
+
49
+ def _is_whitespace_only(a: str, b: str) -> bool:
50
+ """True if the only difference is whitespace (tabs, spaces, newlines).
51
+
52
+ All whitespace is removed for comparison — matching git's
53
+ `diff --ignore-all-space` semantics.
54
+ """
55
+ na = "".join(a.split())
56
+ nb = "".join(b.split())
57
+ return na == nb and na != ""
58
+
59
+
60
+ def _is_import_reorder(a: str, b: str) -> bool:
61
+ """True if both sides contain only import statements, with the same
62
+ set of imports (just reordered)."""
63
+ a_lines = [ln for ln in a.splitlines() if ln.strip()]
64
+ b_lines = [ln for ln in b.splitlines() if ln.strip()]
65
+ if not a_lines or not b_lines:
66
+ return False
67
+ for line in a_lines + b_lines:
68
+ if not (_IMPORT_RE_PY.match(line) or _IMPORT_RE_JS.match(line)):
69
+ return False
70
+ # Compare as sorted sets: order-insensitive match.
71
+ return sorted(a_lines) == sorted(b_lines)
72
+
73
+
74
+ def _is_signature_change(a: str, b: str) -> bool:
75
+ """True if both sides contain a function definition for the same
76
+ name but with different parameter lists."""
77
+ for pattern in (_FN_SIG_PY, _FN_SIG_JS):
78
+ a_match = pattern.search(a)
79
+ b_match = pattern.search(b)
80
+ if a_match and b_match:
81
+ same_name = a_match.group(1) == b_match.group(1)
82
+ different_params = a_match.group(2).strip() != b_match.group(2).strip()
83
+ if same_name and different_params:
84
+ return True
85
+ return False
package/mcp-server.py CHANGED
@@ -288,10 +288,13 @@ TOOLS = [
288
288
  "name": "bingo_conflict_analyze",
289
289
  "description": (
290
290
  "Analyze current rebase conflicts. Returns structured info about each conflicted file "
291
- "(ours/theirs, conflict count, hints) plus patch_intent (name, subject, full commit "
292
- "message, original_sha, original_diff, meta, stack_position) and verify "
293
- "(test_command + per-file syntax/parse commands). "
294
- "Call this when bingo_sync reports a conflict to understand what needs fixing."
291
+ "(ours/theirs, conflict count, hints, semantic_class: whitespace/import_reorder/"
292
+ "signature_change/logic) plus: patch_intent (name, subject, full commit message, "
293
+ "original_sha, original_diff, meta, stack_position), verify (test_command + per-file "
294
+ "syntax/parse commands), upstream_context (upstream commits touching conflicts with "
295
+ "author/PR), patch_dependencies (later patches touching same files — cascade risk), "
296
+ "and decision_memory (prior resolutions for this patch from .bingo/decisions/). "
297
+ "Call this when bingo_sync reports a conflict."
295
298
  ),
296
299
  "inputSchema": {
297
300
  "type": "object",
@@ -1094,7 +1097,7 @@ def main():
1094
1097
  "capabilities": {"tools": {}},
1095
1098
  "serverInfo": {
1096
1099
  "name": "bingo-light",
1097
- "version": "2.1.3",
1100
+ "version": "2.2.0",
1098
1101
  },
1099
1102
  }))
1100
1103
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bingo-light",
3
- "version": "2.1.3",
3
+ "version": "2.2.0",
4
4
  "description": "AI-native fork maintenance — manage customizations as a clean patch stack on top of upstream",
5
5
  "keywords": ["git", "fork", "patch", "mcp", "ai", "maintenance", "upstream", "rebase"],
6
6
  "homepage": "https://github.com/DanOps-1/bingo-light",