bingo-light 2.1.2 → 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
@@ -10,7 +10,7 @@
10
10
  <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a>
11
11
  <a href="https://github.com/DanOps-1/bingo-light/releases"><img src="https://img.shields.io/github/v/release/DanOps-1/bingo-light?label=Release&color=orange" alt="Release"></a>
12
12
  <br>
13
- <a href="#for-ai-agents"><img src="https://img.shields.io/badge/MCP_Server-35_tools-blueviolet.svg" alt="MCP: 35 tools"></a>
13
+ <a href="#for-ai-agents"><img src="https://img.shields.io/badge/MCP_Server-49_tools-blueviolet.svg" alt="MCP: 49 tools"></a>
14
14
  <a href="https://www.python.org/"><img src="https://img.shields.io/badge/Python-3.8+-3776ab.svg" alt="Python 3.8+"></a>
15
15
  <img src="https://img.shields.io/badge/Dependencies-Zero-brightgreen.svg" alt="Zero deps">
16
16
  <a href="https://github.com/DanOps-1/bingo-light/stargazers"><img src="https://img.shields.io/github/stars/DanOps-1/bingo-light?style=social" alt="Stars"></a>
@@ -25,7 +25,7 @@ GitHub's "Sync fork" button breaks the moment you have customizations. `git reba
25
25
 
26
26
  Your patches live as a clean, named stack on top of upstream. Syncing is `bingo-light sync`. Conflicts get remembered so you never solve the same one twice. And if something goes sideways, `bingo-light undo` puts everything back in one second.
27
27
 
28
- Every command speaks JSON. The built-in MCP server gives AI agents 35 tools to manage your fork autonomously -- from init through conflict resolution. No human in the loop required.
28
+ Every command speaks JSON. The built-in MCP server gives AI agents 49 tools to manage your fork autonomously -- from init through conflict resolution. No human in the loop required.
29
29
 
30
30
  ---
31
31
 
@@ -69,6 +69,16 @@ 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` 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.
81
+
72
82
  ---
73
83
 
74
84
  ## Key Features
@@ -93,7 +103,7 @@ That's it. Three commands and your fork stays in sync forever.
93
103
 
94
104
  ### For AI Agents
95
105
 
96
- - :electric_plug: **MCP server (35 tools)** -- full fork management from init through conflict resolution.
106
+ - :electric_plug: **MCP server (49 tools)** -- full fork management from init through conflict resolution.
97
107
  - :bar_chart: **`--json` on everything** -- every command returns structured JSON. Parse, don't scrape.
98
108
  - :mute: **`--yes` flag** -- fully non-interactive. No TTY required. No prompts. Ever.
99
109
  - :gear: **Auto-detect non-TTY** -- pipes and subprocesses trigger non-interactive mode automatically.
@@ -200,7 +210,7 @@ make install && bingo-light setup
200
210
 
201
211
  ## For AI Agents
202
212
 
203
- bingo-light was designed from day one for AI agents. Every command speaks JSON. The MCP server exposes 35 tools covering the full lifecycle from `init` to `conflict-resolve`. Non-interactive mode is the default when stdin is not a TTY.
213
+ bingo-light was designed from day one for AI agents. Every command speaks JSON. The MCP server exposes 49 tools covering the full lifecycle from `init` to `conflict-resolve`. Non-interactive mode is the default when stdin is not a TTY.
204
214
 
205
215
  ### MCP setup -- Claude Code
206
216
 
@@ -234,7 +244,7 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
234
244
 
235
245
  **Any MCP client** (VS Code Copilot, Cursor, custom agents): connect via stdio to `python3 mcp-server.py`.
236
246
 
237
- ### 35 MCP Tools
247
+ ### 49 MCP Tools
238
248
 
239
249
  | Tool | Purpose |
240
250
  |------|---------|
@@ -424,7 +434,7 @@ StGit (649 stars) manages patch stacks but has no AI integration, no MCP server,
424
434
  | Handles customizations | **Yes** | **No** | Manual | Manual | Manual |
425
435
  | Conflict memory (rerere) | **Auto** | No | Manual | No | No |
426
436
  | Conflict prediction | **Yes** | No | No | No | No |
427
- | AI / MCP integration | **35 tools** | No | No | No | No |
437
+ | AI / MCP integration | **49 tools** | No | No | No | No |
428
438
  | JSON output | **All commands** | No | No | No | No |
429
439
  | Non-interactive mode | **Native** | No | Partial | Partial | Partial |
430
440
  | Undo sync | **One command** | No | git reflog | Manual | Manual |
@@ -490,7 +500,7 @@ Yes. bingo-light uses standard git operations (fetch, rebase, push). It works wi
490
500
  ```
491
501
  bingo-light CLI tool (Python 3, zero deps)
492
502
  bingo_core/ Core library package (all business logic)
493
- mcp-server.py MCP server (zero-dep Python 3, 35 tools, JSON-RPC 2.0)
503
+ mcp-server.py MCP server (zero-dep Python 3, 49 tools, JSON-RPC 2.0)
494
504
  contrib/agent.py Advisor agent (monitors drift, auto-syncs when safe)
495
505
  contrib/tui.py Terminal dashboard (curses TUI, real-time monitoring)
496
506
  install.sh Installer (--yes for CI, --help for options)
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  <a href="https://github.com/DanOps-1/bingo-light/actions"><img src="https://github.com/DanOps-1/bingo-light/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
10
10
  <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a>
11
11
  <a href="https://github.com/DanOps-1/bingo-light/releases"><img src="https://img.shields.io/github/v/release/DanOps-1/bingo-light?label=Release&color=orange" alt="Release"></a>
12
- <a href="#mcp-服务器"><img src="https://img.shields.io/badge/MCP_Server-35_tools-blueviolet.svg" alt="MCP: 35 tools"></a>
12
+ <a href="#mcp-服务器"><img src="https://img.shields.io/badge/MCP_Server-49_tools-blueviolet.svg" alt="MCP: 49 tools"></a>
13
13
  <a href="https://www.python.org/"><img src="https://img.shields.io/badge/Python-3.8+-3776ab.svg" alt="Python 3.8+"></a>
14
14
  <img src="https://img.shields.io/badge/Dependencies-Zero-brightgreen.svg" alt="Zero deps">
15
15
  <a href="https://github.com/DanOps-1/bingo-light/stargazers"><img src="https://img.shields.io/github/stars/DanOps-1/bingo-light?style=social" alt="Stars"></a>
@@ -168,10 +168,36 @@ cd bingo-light && make install && bingo-light setup
168
168
  "theirs": "... 你的补丁版本 ...",
169
169
  "hint": "上游重构了调度器核心;补丁需要适配新结构。"
170
170
  }
171
- ]
171
+ ],
172
+ "patch_intent": {
173
+ "name": "custom-scheduler",
174
+ "subject": "...",
175
+ "message": "完整 commit 消息",
176
+ "original_sha": "a1b2c3d...",
177
+ "original_diff": "diff --git ...",
178
+ "meta": {"reason": "...", "tags": [], "status": "permanent"},
179
+ "stack_position": {"index": 3, "total": 7}
180
+ },
181
+ "verify": {
182
+ "test_command": "make test",
183
+ "file_hints": [
184
+ {"file": "kernel/sched/core.c", "command": "bash -n ...", "kind": "syntax"}
185
+ ]
186
+ }
172
187
  }
173
188
  ```
174
189
 
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。
200
+
175
201
  </details>
176
202
 
177
203
  <details>
@@ -197,7 +223,7 @@ Claude Code:
197
223
  ```console
198
224
  $ bingo-light setup
199
225
 
200
- ◆ bingo-light setup v2.1.1
226
+ ◆ bingo-light setup v2.x.x
201
227
 
202
228
  ◆ MCP Server
203
229
  │ Connect bingo-light tools to your AI coding assistants
@@ -434,7 +460,7 @@ bingo-light help 打印帮助
434
460
 
435
461
  | 集成方式 | 适用场景 | 示例 |
436
462
  |---------|---------|------|
437
- | **MCP** (35 tools) | Claude Code / Cursor / Windsurf 等 | `bingo-light setup` 自动配 |
463
+ | **MCP** (49 tools) | Claude Code / Cursor / Windsurf 等 | `bingo-light setup` 自动配 |
438
464
  | **CLI `--json`** | 任何能跑 shell 的 AI | `bingo-light sync --json --yes` |
439
465
  | **Skill** | Claude Code / Continue / Gemini 等 | `/bingo` 教 AI 用法 |
440
466
 
package/bingo-light CHANGED
@@ -234,6 +234,7 @@ def _format_conflict_analyze(result: dict) -> str:
234
234
  conflicts = result.get("conflicts", [])
235
235
  if not conflicts:
236
236
  return f"{_c(GREEN, 'OK')} No conflicts detected."
237
+
237
238
  lines = [f"{_c(YELLOW, '!')} {len(conflicts)} conflicting file(s):"]
238
239
  for c in conflicts:
239
240
  f = c.get("file", "?")
@@ -241,6 +242,85 @@ def _format_conflict_analyze(result: dict) -> str:
241
242
  lines.append(f" {_c(RED, f)}")
242
243
  if hint:
243
244
  lines.append(f" hint: {hint}")
245
+
246
+ intent = result.get("patch_intent")
247
+ if intent and intent.get("name"):
248
+ lines.append("")
249
+ lines.append(
250
+ f"{_c(BOLD, 'Patch intent:')} "
251
+ f"{intent['name']} \u2014 {intent.get('subject', '')}"
252
+ )
253
+ meta = intent.get("meta") or {}
254
+ if meta.get("reason"):
255
+ lines.append(f" reason: {meta['reason']}")
256
+ if meta.get("tags"):
257
+ lines.append(f" tags: {', '.join(meta['tags'])}")
258
+ sp = intent.get("stack_position")
259
+ if sp:
260
+ lines.append(f" position: {sp['index']}/{sp['total']}")
261
+
262
+ verify = result.get("verify")
263
+ if verify:
264
+ lines.append("")
265
+ lines.append(f"{_c(BOLD, 'Verify suggestions:')}")
266
+ tc = verify.get("test_command")
267
+ if tc:
268
+ lines.append(f" test.command: {tc}")
269
+ for h in verify.get("file_hints", []):
270
+ lines.append(f" [{h['kind']}] {h['command']}")
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
+
244
324
  return "\n".join(lines)
245
325
 
246
326
 
@@ -261,10 +341,18 @@ def _format_conflict_resolve(result: dict) -> str:
261
341
  )
262
342
 
263
343
  if result.get("sync_complete"):
264
- return (
265
- f"{_c(GREEN, 'OK')} Resolved {resolved} "
266
- f"\u2014 sync complete!"
267
- )
344
+ out = f"{_c(GREEN, 'OK')} Resolved {resolved} \u2014 sync complete!"
345
+ vr = result.get("verify_result")
346
+ if vr:
347
+ if vr.get("skipped"):
348
+ out += f"\n verify: skipped ({vr.get('reason', '')})"
349
+ elif vr.get("test") == "pass":
350
+ out += f"\n {_c(GREEN, 'verify: PASS')} ({vr.get('command', '')})"
351
+ elif vr.get("test") == "fail":
352
+ out += f"\n {_c(RED, 'verify: FAIL')} ({vr.get('command', '')})"
353
+ else:
354
+ out += f"\n verify: {vr.get('test', '?')}"
355
+ return out
268
356
 
269
357
  if result.get("conflict"):
270
358
  patch = result.get("current_patch", "")
@@ -284,10 +372,18 @@ def _format_conflict_resolve(result: dict) -> str:
284
372
  return "\n".join(lines)
285
373
 
286
374
  if result.get("rebase_continued"):
287
- return (
288
- f"{_c(GREEN, 'OK')} Resolved {resolved} "
289
- f"\u2014 rebase continued."
290
- )
375
+ out = f"{_c(GREEN, 'OK')} Resolved {resolved} \u2014 rebase continued."
376
+ vr = result.get("verify_result")
377
+ if vr:
378
+ if vr.get("skipped"):
379
+ out += f"\n verify: skipped ({vr.get('reason', '')})"
380
+ elif vr.get("test") == "pass":
381
+ out += f"\n {_c(GREEN, 'verify: PASS')} ({vr.get('command', '')})"
382
+ elif vr.get("test") == "fail":
383
+ out += f"\n {_c(RED, 'verify: FAIL')} ({vr.get('command', '')})"
384
+ else:
385
+ out += f"\n verify: {vr.get('test', '?')}"
386
+ return out
291
387
 
292
388
  return f"{_c(GREEN, 'OK')} Resolved {resolved}."
293
389
 
@@ -500,6 +596,118 @@ def _format_patch_meta(result: dict) -> str:
500
596
  return f" {k}: {v}" if k else f"{_c(GREEN, 'OK')} Done."
501
597
 
502
598
 
599
+ def _format_patch_lock(result: dict) -> str:
600
+ """Format patch lock/unlock result."""
601
+ if result.get("ok") is False:
602
+ return f"{_c(RED, 'x')} {result.get('error', 'Failed.')}"
603
+ patch = result.get("patch", "")
604
+ owner = result.get("owner", "")
605
+ if result.get("was_locked") is False:
606
+ return f"{_c(GREEN, 'OK')} {patch} was not locked."
607
+ if "locked_at" in result:
608
+ reason = result.get("reason", "")
609
+ msg = f"{_c(GREEN, 'OK')} Locked {_c(BOLD, patch)} by {owner}"
610
+ if reason:
611
+ msg += f" ({reason})"
612
+ return msg
613
+ prev = result.get("previous_owner", "")
614
+ if prev and prev != owner:
615
+ return f"{_c(GREEN, 'OK')} Unlocked {_c(BOLD, patch)} (was locked by {prev})"
616
+ return f"{_c(GREEN, 'OK')} Unlocked {_c(BOLD, patch)}"
617
+
618
+
619
+ def _format_patch_check(result: dict) -> str:
620
+ """Format patch check (obsolescence detection) result."""
621
+ if result.get("ok") is False:
622
+ return f"{_c(RED, 'x')} {result.get('error', 'Failed.')}"
623
+ patches = result.get("patches", [])
624
+ if not patches:
625
+ return "No patches to check."
626
+ lines = []
627
+ for p in patches:
628
+ status = p.get("status", "unknown")
629
+ name = p.get("name", "?")
630
+ reason = p.get("reason", "")
631
+ if status == "obsolete":
632
+ lines.append(f" {_c(YELLOW, 'OBSOLETE')} {_c(BOLD, name)} — {reason}")
633
+ elif status == "active":
634
+ lines.append(f" {_c(GREEN, 'ACTIVE')} {_c(BOLD, name)} — {reason}")
635
+ else:
636
+ lines.append(f" {_c(DIM, 'UNKNOWN')} {name} — {reason}")
637
+ header = f"Checked {len(patches)} patch(es):"
638
+ return header + "\n" + "\n".join(lines)
639
+
640
+
641
+ def _format_patch_upstream(result: dict) -> str:
642
+ """Format patch upstream (PR-ready export) result."""
643
+ if result.get("ok") is False:
644
+ return f"{_c(RED, 'x')} {result.get('error', 'Failed.')}"
645
+ name = result.get("patch", "")
646
+ desc = result.get("description", "")
647
+ files = result.get("files", [])
648
+ stats = result.get("stats", "")
649
+ diff = result.get("diff", "")
650
+ lines = [
651
+ f"{_c(BOLD, 'PR-ready export:')} {name}",
652
+ f" Description: {desc}",
653
+ f" Files: {', '.join(files)}",
654
+ ]
655
+ if stats:
656
+ lines.append(f" Stats: {stats}")
657
+ lines.append("")
658
+ lines.append(diff)
659
+ return "\n".join(lines)
660
+
661
+
662
+ def _format_patch_expire(result: dict) -> str:
663
+ """Format patch expire result."""
664
+ if result.get("ok") is False:
665
+ return f"{_c(RED, 'x')} {result.get('error', 'Failed.')}"
666
+ expired = result.get("expired", [])
667
+ expiring = result.get("expiring_soon", [])
668
+ if not expired and not expiring:
669
+ return f"{_c(GREEN, 'OK')} No expired or expiring patches."
670
+ lines = []
671
+ if expired:
672
+ lines.append(f"{_c(RED, 'Expired')} ({len(expired)}):")
673
+ for e in expired:
674
+ lines.append(f" {_c(RED, 'x')} {_c(BOLD, e['name'])} — expired {e['expires']} ({abs(e['days_left'])}d ago)")
675
+ if expiring:
676
+ lines.append(f"{_c(YELLOW, 'Expiring soon')} ({len(expiring)}):")
677
+ for e in expiring:
678
+ lines.append(f" {_c(YELLOW, '!')} {_c(BOLD, e['name'])} — expires {e['expires']} ({e['days_left']}d left)")
679
+ return "\n".join(lines)
680
+
681
+
682
+ def _format_patch_stats(result: dict) -> str:
683
+ """Format patch stats result."""
684
+ if result.get("ok") is False:
685
+ return f"{_c(RED, 'x')} {result.get('error', 'Failed.')}"
686
+ patches = result.get("patches", [])
687
+ if not patches:
688
+ return "No patches."
689
+ lines = [f"{'#':>3} {'Name':<20} {'Age':>6} {'Size':>10} {'Syncs':>6} {'Status':<12} {'Owner':<15}"]
690
+ lines.append("─" * 80)
691
+ for i, p in enumerate(patches, 1):
692
+ age = f"{p['age_days']}d" if p.get("age_days", -1) >= 0 else "?"
693
+ size = f"+{p.get('insertions', 0)}/-{p.get('deletions', 0)}"
694
+ syncs = str(p.get("syncs_survived", 0))
695
+ status = p.get("status", "")
696
+ owner = p.get("owner", "") or p.get("locked_by", "") or ""
697
+ lines.append(f"{i:>3} {p['name']:<20} {age:>6} {size:>10} {syncs:>6} {status:<12} {owner:<15}")
698
+ return "\n".join(lines)
699
+
700
+
701
+ def _format_report(result: dict) -> str:
702
+ """Format fork health report."""
703
+ if result.get("ok") is False:
704
+ return f"{_c(RED, 'x')} {result.get('error', 'Failed.')}"
705
+ report = result.get("report", "")
706
+ if report:
707
+ return report
708
+ return f"{_c(GREEN, 'OK')} Report generated."
709
+
710
+
503
711
  def _format_undo(result: dict) -> str:
504
712
  """Format undo result."""
505
713
  if result.get("ok") is False:
@@ -740,7 +948,11 @@ def build_parser() -> argparse.ArgumentParser:
740
948
  sub.add_parser("diff", aliases=["d"], add_help=False)
741
949
 
742
950
  # doctor
743
- sub.add_parser("doctor", add_help=False)
951
+ p_doctor = sub.add_parser("doctor", add_help=False)
952
+ p_doctor.add_argument("--report", action="store_true")
953
+
954
+ # report
955
+ sub.add_parser("report", add_help=False)
744
956
 
745
957
  # log
746
958
  sub.add_parser("log", add_help=False)
@@ -755,6 +967,8 @@ def build_parser() -> argparse.ArgumentParser:
755
967
  cr = sub.add_parser("conflict-resolve", add_help=False)
756
968
  cr.add_argument("resolve_file", nargs="?", default="")
757
969
  cr.add_argument("--content-stdin", action="store_true")
970
+ cr.add_argument("--verify", action="store_true",
971
+ help="Run test.command after the final rebase continues")
758
972
 
759
973
  # session
760
974
  p_session = sub.add_parser("session", add_help=False)
@@ -811,6 +1025,23 @@ def build_parser() -> argparse.ArgumentParser:
811
1025
  pp_meta.add_argument("meta_key", nargs="?", default="")
812
1026
  pp_meta.add_argument("meta_value", nargs="?", default="")
813
1027
 
1028
+ pp_lock = patch_sub.add_parser("lock", add_help=False)
1029
+ pp_lock.add_argument("target")
1030
+ pp_lock.add_argument("--reason", default="")
1031
+
1032
+ pp_unlock = patch_sub.add_parser("unlock", add_help=False)
1033
+ pp_unlock.add_argument("target")
1034
+ pp_unlock.add_argument("--force", action="store_true")
1035
+
1036
+ pp_check = patch_sub.add_parser("check", add_help=False)
1037
+ pp_check.add_argument("target", nargs="?", default="")
1038
+
1039
+ pp_upstream = patch_sub.add_parser("upstream", add_help=False)
1040
+ pp_upstream.add_argument("target")
1041
+
1042
+ patch_sub.add_parser("expire", add_help=False)
1043
+ patch_sub.add_parser("stats", add_help=False)
1044
+
814
1045
  # workspace
815
1046
  p_ws = sub.add_parser("workspace", aliases=["ws"], add_help=False)
816
1047
  ws_sub = p_ws.add_subparsers(dest="ws_command")
@@ -853,6 +1084,26 @@ def build_parser() -> argparse.ArgumentParser:
853
1084
  dp_drop.add_argument("dep_package")
854
1085
  dp_drop.add_argument("dep_patch_name", nargs="?", default="")
855
1086
 
1087
+ # dep override subcommands
1088
+ dp_override = dep_sub.add_parser("override", add_help=False)
1089
+ override_sub = dp_override.add_subparsers(dest="override_command")
1090
+ override_sub.add_parser("list", add_help=False)
1091
+ override_sub.add_parser("check", add_help=False)
1092
+ ov_add = override_sub.add_parser("add", add_help=False)
1093
+ ov_add.add_argument("override_package")
1094
+ ov_add.add_argument("override_version")
1095
+ ov_add.add_argument("--reason", dest="override_reason", default="")
1096
+ ov_drop = override_sub.add_parser("drop", add_help=False)
1097
+ ov_drop.add_argument("override_package")
1098
+
1099
+ # dep fork subcommands
1100
+ dp_fork = dep_sub.add_parser("fork", add_help=False)
1101
+ fork_sub = dp_fork.add_subparsers(dest="fork_command")
1102
+ fork_sub.add_parser("list", add_help=False)
1103
+ fork_sub.add_parser("check", add_help=False)
1104
+ fk_sync = fork_sub.add_parser("sync", add_help=False)
1105
+ fk_sync.add_argument("fork_package")
1106
+
856
1107
  # help
857
1108
  sub.add_parser("help", add_help=False)
858
1109
 
@@ -884,6 +1135,31 @@ def dispatch(args: argparse.Namespace, json_mode: bool) -> Optional[dict]:
884
1135
  return dm.sync()
885
1136
  if dcmd == "drop":
886
1137
  return dm.drop(args.dep_package, getattr(args, "dep_patch_name", ""))
1138
+ if dcmd == "override":
1139
+ ocmd = getattr(args, "override_command", "")
1140
+ if ocmd == "list":
1141
+ return dm.override_list()
1142
+ if ocmd == "check":
1143
+ return dm.override_check()
1144
+ if ocmd == "add":
1145
+ return dm.override_add(
1146
+ args.override_package, args.override_version,
1147
+ getattr(args, "override_reason", ""),
1148
+ )
1149
+ if ocmd == "drop":
1150
+ return dm.override_drop(args.override_package)
1151
+ return {"ok": False, "error": f"Unknown override subcommand: {ocmd}"}
1152
+ if dcmd == "fork":
1153
+ from bingo_core.dep_fork import ForkTracker
1154
+ ft = ForkTracker()
1155
+ fcmd = getattr(args, "fork_command", "")
1156
+ if fcmd == "list":
1157
+ return ft.fork_list()
1158
+ if fcmd == "check":
1159
+ return ft.fork_check()
1160
+ if fcmd == "sync":
1161
+ return ft.fork_sync(args.fork_package)
1162
+ return {"ok": False, "error": f"Unknown fork subcommand: {fcmd}"}
887
1163
  return {"ok": False, "error": f"Unknown dep subcommand: {dcmd}"}
888
1164
 
889
1165
  # setup doesn't need a Repo (works outside git repos)
@@ -919,7 +1195,10 @@ def dispatch(args: argparse.Namespace, json_mode: bool) -> Optional[dict]:
919
1195
  return repo.diff()
920
1196
 
921
1197
  if cmd == "doctor":
922
- return repo.doctor()
1198
+ return repo.doctor(report=getattr(args, "report", False))
1199
+
1200
+ if cmd == "report":
1201
+ return repo.report()
923
1202
 
924
1203
  if cmd == "log":
925
1204
  return repo.history()
@@ -935,7 +1214,9 @@ def dispatch(args: argparse.Namespace, json_mode: bool) -> Optional[dict]:
935
1214
  if args.content_stdin:
936
1215
  import sys as _sys
937
1216
  content = _sys.stdin.read()
938
- return repo.conflict_resolve(args.resolve_file, content)
1217
+ return repo.conflict_resolve(
1218
+ args.resolve_file, content, verify=args.verify
1219
+ )
939
1220
 
940
1221
  if cmd == "session":
941
1222
  update = (args.session_action == "update")
@@ -1018,6 +1299,24 @@ def _dispatch_patch(args: argparse.Namespace, repo: Repo) -> dict:
1018
1299
  value=meta_value,
1019
1300
  )
1020
1301
 
1302
+ if pcmd == "lock":
1303
+ return repo.patch_lock(args.target, reason=getattr(args, "reason", ""))
1304
+
1305
+ if pcmd == "unlock":
1306
+ return repo.patch_unlock(args.target, force=getattr(args, "force", False))
1307
+
1308
+ if pcmd == "check":
1309
+ return repo.patch_check(getattr(args, "target", ""))
1310
+
1311
+ if pcmd == "upstream":
1312
+ return repo.patch_upstream(args.target)
1313
+
1314
+ if pcmd == "expire":
1315
+ return repo.patch_expire()
1316
+
1317
+ if pcmd == "stats":
1318
+ return repo.patch_stats()
1319
+
1021
1320
  raise BingoError(f"Unknown patch subcommand: {pcmd}")
1022
1321
 
1023
1322
 
@@ -1075,6 +1374,7 @@ _FORMATTERS: Dict[str, Any] = {
1075
1374
  "smart-sync": _format_smart_sync,
1076
1375
  "setup": _format_setup,
1077
1376
  "dep": _format_dep,
1377
+ "report": _format_report,
1078
1378
  }
1079
1379
 
1080
1380
 
@@ -1099,6 +1399,16 @@ def _get_formatter(args: argparse.Namespace):
1099
1399
  return _format_patch_import
1100
1400
  if pcmd == "meta":
1101
1401
  return _format_patch_meta
1402
+ if pcmd in ("lock", "unlock"):
1403
+ return _format_patch_lock
1404
+ if pcmd == "check":
1405
+ return _format_patch_check
1406
+ if pcmd == "upstream":
1407
+ return _format_patch_upstream
1408
+ if pcmd == "expire":
1409
+ return _format_patch_expire
1410
+ if pcmd == "stats":
1411
+ return _format_patch_stats
1102
1412
  return _format_generic
1103
1413
 
1104
1414
  # workspace subcommands
@@ -14,7 +14,7 @@ import re
14
14
 
15
15
  # --- Constants ---
16
16
 
17
- VERSION = "2.1.2"
17
+ VERSION = "2.2.0"
18
18
  PATCH_PREFIX = "[bl]"
19
19
  CONFIG_FILE = ".bingolight"
20
20
  BINGO_DIR = ".bingo"
@@ -39,9 +39,12 @@ 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
47
+ from bingo_core.team import TeamState # noqa: E402
45
48
  from bingo_core.repo import Repo # noqa: E402
46
49
 
47
50
  __all__ = [
@@ -69,9 +72,14 @@ __all__ = [
69
72
  # Data classes
70
73
  "PatchInfo",
71
74
  "ConflictInfo",
75
+ # Functions
76
+ "classify_conflict",
77
+ "detect_resolution_strategy",
72
78
  # Classes
79
+ "DecisionMemory",
73
80
  "Git",
74
81
  "Config",
75
82
  "State",
83
+ "TeamState",
76
84
  "Repo",
77
85
  ]