mosscode 0.1.0__py3-none-any.whl

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.
Files changed (91) hide show
  1. mosscode/__init__.py +10 -0
  2. mosscode/__main__.py +3 -0
  3. mosscode/agent.py +486 -0
  4. mosscode/agents/__init__.py +249 -0
  5. mosscode/agents/builtin/explore.md +16 -0
  6. mosscode/agents/builtin/general.md +12 -0
  7. mosscode/agents/builtin/plan.md +16 -0
  8. mosscode/agents/runtime.py +685 -0
  9. mosscode/agents/tasks.py +193 -0
  10. mosscode/appearance.py +184 -0
  11. mosscode/builtin_commands.py +1363 -0
  12. mosscode/cli.py +547 -0
  13. mosscode/commands.py +254 -0
  14. mosscode/compaction.py +259 -0
  15. mosscode/config.py +337 -0
  16. mosscode/context.py +102 -0
  17. mosscode/conversation.py +1046 -0
  18. mosscode/coordinator.py +54 -0
  19. mosscode/doctor.py +303 -0
  20. mosscode/hooks/__init__.py +22 -0
  21. mosscode/hooks/actions.py +151 -0
  22. mosscode/hooks/conditions.py +170 -0
  23. mosscode/hooks/config.py +404 -0
  24. mosscode/hooks/engine.py +401 -0
  25. mosscode/hooks/events.py +48 -0
  26. mosscode/launcher.py +177 -0
  27. mosscode/mcp_client.py +460 -0
  28. mosscode/mcp_config.py +216 -0
  29. mosscode/member_ui.py +289 -0
  30. mosscode/memory.py +291 -0
  31. mosscode/memory_extract.py +245 -0
  32. mosscode/messages.py +216 -0
  33. mosscode/paths.py +31 -0
  34. mosscode/permissions.py +733 -0
  35. mosscode/prompts.py +483 -0
  36. mosscode/providers.py +587 -0
  37. mosscode/scrollback.py +106 -0
  38. mosscode/security.py +282 -0
  39. mosscode/serialization.py +121 -0
  40. mosscode/session.py +563 -0
  41. mosscode/setup.py +248 -0
  42. mosscode/skills/__init__.py +455 -0
  43. mosscode/skills/builtin/commit/SKILL.md +15 -0
  44. mosscode/skills/builtin/review/SKILL.md +15 -0
  45. mosscode/skills/builtin/test/SKILL.md +13 -0
  46. mosscode/skills/builtin/test/scripts/detect.py +96 -0
  47. mosscode/skills/builtin/test/tool.json +9 -0
  48. mosscode/teams/__init__.py +216 -0
  49. mosscode/teams/backends.py +259 -0
  50. mosscode/teams/board.py +230 -0
  51. mosscode/teams/lock.py +173 -0
  52. mosscode/teams/mailbox.py +172 -0
  53. mosscode/teams/member.py +427 -0
  54. mosscode/teams/merge.py +207 -0
  55. mosscode/teams/pane.py +20 -0
  56. mosscode/teams/pane_app.py +167 -0
  57. mosscode/teams/process.py +371 -0
  58. mosscode/teams/runtime.py +1355 -0
  59. mosscode/teams/view.py +182 -0
  60. mosscode/tool_budget.py +86 -0
  61. mosscode/tools/__init__.py +38 -0
  62. mosscode/tools/agent.py +127 -0
  63. mosscode/tools/base.py +384 -0
  64. mosscode/tools/diffing.py +16 -0
  65. mosscode/tools/execution.py +99 -0
  66. mosscode/tools/files.py +287 -0
  67. mosscode/tools/memory.py +65 -0
  68. mosscode/tools/plan.py +30 -0
  69. mosscode/tools/process.py +216 -0
  70. mosscode/tools/search.py +229 -0
  71. mosscode/tools/search_worker.py +243 -0
  72. mosscode/tools/shell.py +121 -0
  73. mosscode/tools/skill.py +118 -0
  74. mosscode/tools/team.py +447 -0
  75. mosscode/tools/windows_job.py +75 -0
  76. mosscode/tui.py +2325 -0
  77. mosscode/usage.py +68 -0
  78. mosscode/windows_inline.py +148 -0
  79. mosscode/worktree/__init__.py +29 -0
  80. mosscode/worktree/changes.py +59 -0
  81. mosscode/worktree/git.py +125 -0
  82. mosscode/worktree/manager.py +462 -0
  83. mosscode/worktree/meta.py +82 -0
  84. mosscode/worktree/names.py +52 -0
  85. mosscode/worktree/setup.py +119 -0
  86. mosscode-0.1.0.dist-info/METADATA +92 -0
  87. mosscode-0.1.0.dist-info/RECORD +91 -0
  88. mosscode-0.1.0.dist-info/WHEEL +5 -0
  89. mosscode-0.1.0.dist-info/entry_points.txt +3 -0
  90. mosscode-0.1.0.dist-info/licenses/LICENSE +21 -0
  91. mosscode-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,427 @@
1
+ """A member's long-lived conversation, and what any participant may do in a team.
2
+
3
+ The same runner serves an in-process member and a member in its own terminal pane.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import secrets
10
+ import time
11
+ from collections.abc import Callable
12
+ from contextlib import aclosing
13
+ from dataclasses import asdict, fields
14
+ from typing import TYPE_CHECKING
15
+
16
+ from ..messages import Completed, ProviderError, RunStopped, ToolProgress
17
+ from ..permissions import ALL_CHOICES, ApprovalRequest, Decision, Operation, PolicyView
18
+ from .board import STATUS_LABELS, TeamTask, blocks, is_ready
19
+ from .lock import TeamError
20
+ from .mailbox import BROADCAST, LEAD, RUNTIME_ONLY, Message
21
+
22
+ if TYPE_CHECKING:
23
+ from ..agent import AgentLoop
24
+ from . import TeamStore
25
+
26
+ POLL = 0.5 # Seconds between mailbox reads when nothing woke the reader.
27
+ SUMMARY = 300 # Characters of the last reply in an idle notification.
28
+ TYPE_LABELS = {
29
+ "text": "消息",
30
+ "plan_approval_request": "计划审批请求",
31
+ "plan_approval_response": "计划审批答复",
32
+ "shutdown_request": "停止请求",
33
+ "shutdown_response": "停止答复",
34
+ "idle_notification": "空闲通知",
35
+ "permission_response": "审批答复",
36
+ }
37
+
38
+
39
+ def addendum(team: str, name: str, worktree: dict | None) -> str:
40
+ """Fixed text, only the names differ."""
41
+ lines = [
42
+ f"<team-member>\n你是团队 {team} 的成员 {name},Lead 的名称是 {LEAD}。",
43
+ "- 你的文字回复不会被任何人看到:汇报、提问、交付结果都必须用 send_message(通常发给 lead)。",
44
+ "- 工作流程:team_task_list 查看任务 → 领取就绪的任务(team_task_update 把 owner 设为自己、status 设为 in_progress)"
45
+ "→ 完成 → git commit 提交改动 → 把任务设为 completed → send_message 向 lead 汇报结果。",
46
+ "- 每次收到的消息都在 <incoming-messages> 中;处理完就结束这一轮,等待下一条消息,不要空等或轮询。",
47
+ "- 收到 shutdown_request 时,结束手头的工作,用 send_message(type: shutdown_response,request_id 为该请求的 id,"
48
+ "approve: true)回复;确有未完成的工作时可以 approve: false 并说明理由。",
49
+ ]
50
+ if worktree:
51
+ main = worktree.get("main") or "<主分支>"
52
+ lines.append(
53
+ f"- 你在自己的 git 工作树中工作:{worktree['path']},分支 {worktree['branch']},主分支是 {main}。"
54
+ "完成的工作必须提交到这个分支,Lead 才能合并;Lead 要求你解决合并冲突时,在工作树中执行 "
55
+ f"git merge {main},解决冲突后提交。"
56
+ )
57
+ else:
58
+ lines.append("- 你在主项目目录中工作,与其他人共用文件;只改分配给你的文件。")
59
+ return "\n".join(lines) + "\n</team-member>"
60
+
61
+
62
+ def member_mode(agent_mode: Callable[[str], str], session_mode: str, *, require: bool, approved: bool) -> str:
63
+ """Plan until the lead approves; then the sub-agent rules against the current session mode."""
64
+ if require and not approved:
65
+ return "plan"
66
+ return agent_mode(session_mode)
67
+
68
+
69
+ class MemberPolicy(PolicyView):
70
+ """A member's exit_plan_mode goes to the lead, not to the user; the tool sends it."""
71
+
72
+ async def _approve_plan(self, call_id, params, gate=None, notify=None) -> Decision:
73
+ if self.mode != "plan":
74
+ return Decision("deny", "not_in_plan", "当前不在计划模式,无需提交计划。")
75
+ if gate is not None:
76
+ rejected = await gate(None)
77
+ if rejected is not None:
78
+ return rejected
79
+ return Decision("allow")
80
+
81
+
82
+ def _task_line(task: TeamTask, tasks: dict[str, TeamTask]) -> str:
83
+ owner = task.owner or "未指派"
84
+ waiting = [dep for dep in task.blocked_by if dep in tasks and tasks[dep].status != "completed"]
85
+ tail = f";等待 {'、'.join(waiting)}" if waiting else (";就绪" if task.status == "pending" else "")
86
+ return f"{task.id} [{STATUS_LABELS.get(task.status, task.status)}] {owner} · {task.subject}{tail}"
87
+
88
+
89
+ def task_listing(tasks: list[TeamTask]) -> str:
90
+ if not tasks:
91
+ return "还没有任务。"
92
+ index = {task.id: task for task in tasks}
93
+ return "\n".join(_task_line(task, index) for task in tasks)
94
+
95
+
96
+ def task_detail(task: TeamTask, tasks: list[TeamTask]) -> str:
97
+ index = {item.id: item for item in tasks}
98
+ lines = [
99
+ f"任务 {task.id}:{task.subject}",
100
+ f"状态:{STATUS_LABELS.get(task.status, task.status)}",
101
+ f"负责人:{task.owner or '未指派'}",
102
+ f"依赖:{'、'.join(task.blocked_by) or '无'}({'就绪' if is_ready(task, index) else '未就绪'})",
103
+ f"被依赖:{'、'.join(blocks(task.id, tasks)) or '无'}",
104
+ f"更新:{task.updated}",
105
+ ]
106
+ if task.description:
107
+ lines += ["", task.description]
108
+ return "\n".join(lines)
109
+
110
+
111
+ class TeamAccess:
112
+ """What one participant (the lead or a member) does in a team: messages and shared tasks.
113
+
114
+ Methods are blocking file work; callers run them in a thread and then call wake() on the event loop.
115
+ """
116
+
117
+ def __init__(self, store: TeamStore, actor: str, wake: Callable[[str], None] | None = None):
118
+ self.store = store
119
+ self.actor = actor
120
+ self._wake = wake
121
+
122
+ @property
123
+ def team(self) -> str:
124
+ return self.store.name
125
+
126
+ def wake(self, names) -> None:
127
+ if self._wake is not None:
128
+ for name in names:
129
+ self._wake(name)
130
+
131
+ def participants(self) -> tuple[str, ...]:
132
+ return self.store.load().names()
133
+
134
+ def recipients(self, to: str, kind: str) -> list[str]:
135
+ """Who a message goes to; TeamError when nobody or the wrong one."""
136
+ names = self.participants()
137
+ if to == BROADCAST:
138
+ if kind != "text":
139
+ raise TeamError("invalid_message", "只有 text 消息可以广播。")
140
+ found = [name for name in names if name != self.actor]
141
+ if not found:
142
+ raise TeamError("unknown_member", "团队中没有其他成员可以接收广播。")
143
+ return found
144
+ if to == self.actor:
145
+ raise TeamError("invalid_message", "不能给自己发消息。")
146
+ if to != LEAD and to not in names:
147
+ raise TeamError("unknown_member", f"团队 {self.team} 中没有成员 {to};成员:{'、'.join(names) or '无'}。")
148
+ return [to]
149
+
150
+ def send(
151
+ self, to: str, kind: str, body: str = "", summary: str = "", *, runtime: bool = False, **extra
152
+ ) -> list[str]:
153
+ """The model never sends idle notifications or plan requests itself (runtime=False)."""
154
+ if kind in RUNTIME_ONLY and not runtime:
155
+ raise TeamError("invalid_message", f"{kind} 由系统自动发送,不能手动发送。")
156
+ targets = self.recipients(to, kind)
157
+ for target in targets:
158
+ self.store.mailbox.send(target, Message(self.actor, kind, body, summary, extra=dict(extra)))
159
+ return targets
160
+
161
+ async def asend(self, to: str, kind: str, body: str = "", summary: str = "", **extra) -> list[str]:
162
+ targets = await asyncio.to_thread(self.send, to, kind, body, summary, **extra)
163
+ self.wake(targets)
164
+ return targets
165
+
166
+ # Shared tasks -----------------------------------------------------------------------
167
+
168
+ def task_create(self, subject: str, description: str = "", blocked_by=(), owner: str = "") -> TeamTask:
169
+ return self.store.board.create(
170
+ subject, description, blocked_by, owner=owner, actor=self.actor, participants=self.participants()
171
+ )
172
+
173
+ def task_update(self, task_id: str, **changes):
174
+ return self.store.board.update(task_id, actor=self.actor, participants=self.participants(), **changes)
175
+
176
+
177
+ def render_incoming(messages: list[Message]) -> str:
178
+ """One user message for every unread message of a turn."""
179
+ parts = []
180
+ for message in messages:
181
+ head = f"[来自 {message.sender} · {TYPE_LABELS.get(message.type, message.type)} · id={message.id}]"
182
+ extra = message.extra
183
+ if message.type == "text":
184
+ parts.append(f"{head} 摘要:{message.summary}\n{message.body}")
185
+ elif message.type == "plan_approval_response":
186
+ verdict = "已批准" if extra.get("approve") else "被拒绝"
187
+ parts.append(
188
+ f"{head} 你的计划(request_id={extra.get('request_id')}){verdict}。"
189
+ + (f"\n反馈:{extra.get('feedback')}" if extra.get("feedback") else "")
190
+ )
191
+ elif message.type == "shutdown_request":
192
+ parts.append(f"{head} Lead 请求你停止。理由:{extra.get('reason') or '无'}")
193
+ else:
194
+ parts.append(f"{head}\n{message.body}")
195
+ return "<incoming-messages>\n" + "\n\n".join(parts) + "\n</incoming-messages>"
196
+
197
+
198
+ def approval_record(request: ApprovalRequest) -> dict:
199
+ """A pane member's waiting request, as its record keeps it for the lead."""
200
+ return {
201
+ "id": request.id,
202
+ "call_id": request.call_id,
203
+ "operation": asdict(request.operation),
204
+ "reason": request.reason,
205
+ "choices": list(request.choices),
206
+ }
207
+
208
+
209
+ def approval_from_record(item, source: str) -> ApprovalRequest | None:
210
+ """The lead's side: None when the record cannot be shown or answered."""
211
+ try:
212
+ known = {field.name for field in fields(Operation)}
213
+ operation = Operation(**{key: value for key, value in item["operation"].items() if key in known})
214
+ choices = tuple(item["choices"])
215
+ if not item["id"] or not choices or not all(choice in ALL_CHOICES for choice in choices):
216
+ return None
217
+ return ApprovalRequest(
218
+ str(item["id"]), str(item.get("call_id", "")), operation, str(item.get("reason", "")), choices, source
219
+ )
220
+ except (KeyError, TypeError, AttributeError):
221
+ return None
222
+
223
+
224
+ class MemberRunner:
225
+ """Read the mailbox, run a turn, tell the lead, wait; until stopped."""
226
+
227
+ def __init__(
228
+ self,
229
+ access: TeamAccess,
230
+ name: str,
231
+ *,
232
+ agent_mode: Callable[[str], str],
233
+ session_mode: Callable[[], str],
234
+ require_plan_approval: bool = False,
235
+ on_approval: Callable[[ApprovalRequest], None] | None = None,
236
+ on_status: Callable[[str, dict], None] | None = None,
237
+ poll: float = POLL,
238
+ on_event: Callable[[object], None] | None = None,
239
+ ):
240
+ self.access, self.name = access, name
241
+ self.loop: AgentLoop | None = None # Set once the tools that need this runner exist.
242
+ self.owned = False # The loop's provider is closed when the member stops.
243
+ self.agent_mode, self.session_mode = agent_mode, session_mode
244
+ self.require_plan_approval = require_plan_approval
245
+ self.approved = False
246
+ self.awaiting: str | None = None # request_id of the plan waiting for the lead.
247
+ self.shutdown: bool | None = None # Set by its shutdown_response: True stops after this turn.
248
+ self.on_approval, self.on_status = on_approval, on_status
249
+ self.on_event = on_event # A pane member's own window shows the turn.
250
+ self.poll = poll
251
+ self.status = "idle"
252
+ self.since = time.monotonic() # When it entered its status.
253
+ self.last_tool = ""
254
+ self.note = "" # Why the mode is what it is after an approval.
255
+ self.wakeup = asyncio.Event()
256
+ self.job: asyncio.Task | None = None
257
+ self._run: asyncio.Task | None = None # The model run of the current turn (Esc stops it).
258
+ self._interrupted = False
259
+
260
+ @property
261
+ def policy(self):
262
+ return self.loop.registry.permissions
263
+
264
+ def wake(self) -> None:
265
+ self.wakeup.set()
266
+
267
+ def interrupt(self) -> bool:
268
+ """End the current turn early; the member still reports and goes idle."""
269
+ if self._run is None or self._run.done():
270
+ return False
271
+ self._interrupted = True
272
+ self._run.cancel()
273
+ return True
274
+
275
+ def start(self) -> asyncio.Task:
276
+ self.job = asyncio.get_running_loop().create_task(self.serve())
277
+ return self.job
278
+
279
+ async def stop(self) -> None:
280
+ if self.job is not None and not self.job.done():
281
+ self.job.cancel()
282
+ await asyncio.gather(self.job, return_exceptions=True)
283
+ await self.close()
284
+
285
+ async def close(self) -> None:
286
+ if self.owned and self.loop is not None:
287
+ self.owned = False
288
+ await self.loop.provider.close()
289
+
290
+ def _status(self, status: str, **fields) -> None:
291
+ if status != self.status:
292
+ self.since = time.monotonic()
293
+ self.status = status
294
+ if self.on_status is not None:
295
+ self.on_status(status, fields)
296
+
297
+ async def serve(self) -> None:
298
+ try:
299
+ while self.shutdown is not True:
300
+ try:
301
+ await asyncio.wait_for(self.wakeup.wait(), self.poll)
302
+ except TimeoutError:
303
+ pass
304
+ self.wakeup.clear()
305
+ messages = await asyncio.to_thread(self.access.store.mailbox.read_unread, self.name)
306
+ # An answer to an approval already settled in the member's own window; not for the model.
307
+ messages = [message for message in messages if message.type != "permission_response"]
308
+ if messages:
309
+ await self.turn(messages)
310
+ finally:
311
+ self._status("stopped")
312
+
313
+ def submit_plan(self, plan: str) -> str:
314
+ """From the member's exit_plan_mode; blocking (run in a thread by the tool)."""
315
+ request_id = f"r-{secrets.token_hex(4)}"
316
+ self.access.send(LEAD, "plan_approval_request", plan, runtime=True, request_id=request_id, plan=plan)
317
+ self.awaiting = request_id
318
+ return request_id
319
+
320
+ def _absorb(self, messages: list[Message]) -> None:
321
+ """Answers to this member's plan change its state before the turn starts."""
322
+ for message in messages:
323
+ if message.type != "plan_approval_response" or message.extra.get("request_id") != self.awaiting:
324
+ continue
325
+ self.awaiting = None
326
+ if message.extra.get("approve"):
327
+ self.approved = True
328
+
329
+ def _completed(self) -> set[str]:
330
+ try:
331
+ tasks = self.access.store.board.list()
332
+ except TeamError:
333
+ return set()
334
+ return {task.id for task in tasks if task.owner == self.name and task.status == "completed"}
335
+
336
+ async def _consume(self, prompt: str):
337
+ final, stopped = "", None
338
+ async with aclosing(self.loop.run(prompt)) as events:
339
+ async for event in events:
340
+ if self.on_event is not None:
341
+ self.on_event(event)
342
+ if isinstance(event, ApprovalRequest):
343
+ if self.on_approval is not None:
344
+ self.on_approval(event)
345
+ elif isinstance(event, ToolProgress) and event.phase == "running":
346
+ from ..agents.runtime import describe_call
347
+
348
+ self.last_tool = describe_call(event.call, self.loop.registry.permissions.root)
349
+ self._status("running", last_tool=self.last_tool)
350
+ elif isinstance(event, Completed):
351
+ final = event.message.text
352
+ elif isinstance(event, RunStopped):
353
+ stopped = event
354
+ return final, stopped
355
+
356
+ async def turn(self, messages: list[Message]) -> None:
357
+ self._absorb(messages)
358
+ request = next((m for m in messages if m.type == "shutdown_request"), None)
359
+ if request is not None:
360
+ self.shutdown = None # Its shutdown_response this turn decides.
361
+ mode = member_mode(
362
+ self.agent_mode, self.session_mode(), require=self.require_plan_approval, approved=self.approved
363
+ )
364
+ prompt = render_incoming(messages)
365
+ if self.on_event is not None:
366
+ self.on_event(messages)
367
+ if self.approved and mode == "plan":
368
+ prompt += "\n(会话当前处于计划模式,你仍只能做只读调查;等用户退出计划模式后再动手。)"
369
+ self.policy.set_mode(mode)
370
+ before = await asyncio.to_thread(self._completed)
371
+ self._status("running")
372
+ final, stopped, failure, interrupted = "", None, "", False
373
+ self._interrupted = False
374
+ self._run = asyncio.ensure_future(self._consume(prompt))
375
+ try:
376
+ final, stopped = await self._run
377
+ except ProviderError as exc:
378
+ failure = str(exc)
379
+ except asyncio.CancelledError:
380
+ if not self._interrupted or asyncio.current_task().cancelling():
381
+ raise
382
+ failure, interrupted = "用户在成员窗格中中断了这一轮", True
383
+ finally:
384
+ self._run = None
385
+ if not failure and (stopped is None or stopped.reason not in {"completed", "max_tokens"}):
386
+ failure = stopped.notice if stopped is not None else "没有完整回复"
387
+ done = sorted(
388
+ await asyncio.to_thread(self._completed) - before, key=lambda key: int(key) if key.isdigit() else 0
389
+ )
390
+ self.last_tool = ""
391
+ if request is not None and self.shutdown is None:
392
+ self.shutdown = True # It never answered: the request stands.
393
+ try:
394
+ await asyncio.to_thread(
395
+ self.access.send, LEAD, "shutdown_response", request_id=request.id, approve=True, reason=""
396
+ )
397
+ self.access.wake([LEAD])
398
+ except TeamError:
399
+ pass
400
+ # The plan request itself tells the lead; a member that stops says so in its shutdown_response.
401
+ if (self.awaiting is None or failure) and self.shutdown is not True:
402
+ reason = "interrupted" if interrupted else "failed" if failure else "completed"
403
+ summary = (failure or final.strip())[:SUMMARY]
404
+ try:
405
+ await asyncio.to_thread(
406
+ self.access.send,
407
+ LEAD,
408
+ "idle_notification",
409
+ "",
410
+ summary,
411
+ runtime=True,
412
+ reason=reason,
413
+ completed_tasks=done,
414
+ )
415
+ self.access.wake([LEAD])
416
+ except TeamError:
417
+ pass # The lead's mailbox is busy or gone; the status below still shows the member idle.
418
+ usage = self.loop.total_usage
419
+ self._status(
420
+ "idle",
421
+ usage={
422
+ "input_tokens": usage.input_tokens,
423
+ "output_tokens": usage.output_tokens,
424
+ "cached_input_tokens": usage.cached_input_tokens,
425
+ },
426
+ approved=self.approved,
427
+ )
@@ -0,0 +1,207 @@
1
+ """Merging a member's branch into the main directory's current branch.
2
+
3
+ Blocking git work; the runtime runs it in a thread and asks the user in between.
4
+ Nothing here ever resets, checks out or cleans files.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ from ..worktree.git import WorktreeError, run_git
14
+ from ..worktree.meta import load_meta
15
+ from .lock import TeamError
16
+
17
+ if TYPE_CHECKING:
18
+ from ..worktree import WorktreeManager
19
+ from . import TeamStore
20
+
21
+ COMMIT_LIMIT = 20
22
+ STAT_LIMIT = 4000
23
+ FILE_LIMIT = 30
24
+ IN_PROGRESS = ("MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "rebase-merge", "rebase-apply")
25
+
26
+
27
+ @dataclass
28
+ class MergePlan:
29
+ """What the user confirms, and what the merge needs."""
30
+
31
+ member: str
32
+ branch: str
33
+ target: str
34
+ top: Path
35
+ commits: list[str]
36
+ count: int
37
+ stat: str
38
+ untracked: list[str]
39
+
40
+
41
+ def _git(args, top: Path):
42
+ try:
43
+ return run_git(args, top)
44
+ except WorktreeError as exc:
45
+ raise TeamError("main_not_ready", str(exc)) from None
46
+
47
+
48
+ def _output(done) -> str:
49
+ return (done.stderr or done.stdout or "").strip()[:2000]
50
+
51
+
52
+ def git_dir(top: Path) -> Path:
53
+ done = _git(["rev-parse", "--git-dir"], top)
54
+ path = Path(done.stdout.strip())
55
+ return path if path.is_absolute() else top / path
56
+
57
+
58
+ def current_branch(top: Path) -> str:
59
+ """ "" when HEAD is detached or top is no repository."""
60
+ try:
61
+ done = run_git(["symbolic-ref", "-q", "--short", "HEAD"], top)
62
+ except WorktreeError:
63
+ return ""
64
+ return done.stdout.strip() if done.returncode == 0 else ""
65
+
66
+
67
+ def unmerged(top: Path, branch: str) -> int:
68
+ """Commits of branch that HEAD does not have; 0 when the branch is gone."""
69
+ done = _git(["rev-list", "--count", f"HEAD..refs/heads/{branch}"], top)
70
+ try:
71
+ return int(done.stdout.strip() or "0") if done.returncode == 0 else 0
72
+ except ValueError:
73
+ return 0
74
+
75
+
76
+ def _main_state(top: Path) -> tuple[str, list[str]]:
77
+ """The target branch and the untracked files; TeamError otherwise."""
78
+ target = current_branch(top)
79
+ if not target:
80
+ raise TeamError("main_not_ready", "主目录的 HEAD 不在分支上(分离 HEAD);先切换到要合并进去的分支。")
81
+ directory = git_dir(top)
82
+ busy = [name for name in IN_PROGRESS if (directory / name).exists()]
83
+ if busy:
84
+ raise TeamError("main_not_ready", f"主目录有进行中的 git 操作({'、'.join(busy)});先完成或放弃它,再合并。")
85
+ done = _git(["status", "--porcelain", "--untracked-files=all"], top)
86
+ if done.returncode != 0:
87
+ raise TeamError("main_not_ready", f"无法读取主目录的 git 状态:{_output(done)}")
88
+ tracked, untracked = [], []
89
+ for line in done.stdout.splitlines():
90
+ name = line[3:].split(" -> ")[-1].strip().strip('"')
91
+ (untracked if line.startswith("??") else tracked).append(name)
92
+ if tracked:
93
+ shown = "、".join(tracked[:FILE_LIMIT]) + ("…" if len(tracked) > FILE_LIMIT else "")
94
+ raise TeamError(
95
+ "main_dirty",
96
+ f"主目录有未提交的改动({len(tracked)} 个文件:{shown})。停下来问用户如何处理"
97
+ "(提交、暂存或放弃由用户决定),不要自己清理。",
98
+ )
99
+ return target, untracked
100
+
101
+
102
+ def check(manager: WorktreeManager, store: TeamStore, name: str, busy: bool) -> MergePlan:
103
+ """In order; the first failing check is the error."""
104
+ top = manager.top
105
+ target, untracked = _main_state(top)
106
+ team = store.load()
107
+ member = team.member(name)
108
+ if member is None:
109
+ raise TeamError(
110
+ "unknown_member", f"团队 {team.name} 中没有成员 {name};成员:{'、'.join(team.names()) or '无'}。"
111
+ )
112
+ meta = load_meta(manager.meta_path(member.worktree["flat"])) if member.worktree else None
113
+ if meta is None or not Path(meta.path).is_dir():
114
+ raise TeamError("no_worktree", f"成员 {name} 没有工作树,没有可合并的分支。")
115
+ if busy:
116
+ raise TeamError("member_busy", f"成员 {name} 正在运行一轮;等它空闲后再合并。")
117
+ changes = manager.changes(meta)
118
+ if changes.uncommitted:
119
+ files = "、".join(changes.files) or changes.error
120
+ raise TeamError("member_uncommitted", f"成员 {name} 的工作树有未提交的改动({files});让它提交后再合并。")
121
+ count = unmerged(top, meta.branch)
122
+ if count == 0:
123
+ raise TeamError("nothing_to_merge", f"成员 {name} 的分支 {meta.branch} 没有 {target} 中没有的提交。")
124
+ _check_order(top, store, team, name)
125
+ log = _git(["log", "--oneline", "--no-decorate", f"-{COMMIT_LIMIT}", f"HEAD..refs/heads/{meta.branch}"], top)
126
+ stat = _git(["diff", "--stat", f"HEAD...refs/heads/{meta.branch}"], top)
127
+ return MergePlan(
128
+ name,
129
+ meta.branch,
130
+ target,
131
+ top,
132
+ log.stdout.strip().splitlines(),
133
+ count,
134
+ stat.stdout.rstrip()[:STAT_LIMIT],
135
+ untracked,
136
+ )
137
+
138
+
139
+ def _check_order(top: Path, store: TeamStore, team, name: str) -> None:
140
+ """What this member finished waits on another member's work still outside HEAD."""
141
+ tasks = {task.id: task for task in store.board.list()}
142
+ for task in tasks.values():
143
+ if task.owner != name or task.status != "completed":
144
+ continue
145
+ for dep in task.blocked_by:
146
+ other = tasks.get(dep)
147
+ if other is None or not other.owner or other.owner == name:
148
+ continue
149
+ record = team.member(other.owner)
150
+ if record is None or not record.worktree:
151
+ continue
152
+ if unmerged(top, record.worktree["branch"]):
153
+ raise TeamError(
154
+ "merge_order",
155
+ f"先合并 {other.owner}:任务 {task.id} 依赖它的任务 {dep},而它的分支还有未合并的提交。",
156
+ )
157
+
158
+
159
+ def preview(plan: MergePlan) -> str:
160
+ """The confirmation card."""
161
+ more = f"(共 {plan.count} 个,只列前 {COMMIT_LIMIT} 个)" if plan.count > len(plan.commits) else ""
162
+ lines = [f"成员:{plan.member}", f"分支:{plan.branch} → {plan.target}", f"提交{more}:"]
163
+ lines += [f" {line}" for line in plan.commits]
164
+ if plan.stat:
165
+ lines += ["改动:", plan.stat]
166
+ if plan.untracked:
167
+ shown = "、".join(plan.untracked[:FILE_LIMIT]) + ("…" if len(plan.untracked) > FILE_LIMIT else "")
168
+ lines.append(f"主目录中未跟踪的文件(不阻止合并):{shown}")
169
+ return "\n".join(lines)
170
+
171
+
172
+ def _abort(top: Path) -> None:
173
+ done = _git(["merge", "--abort"], top)
174
+ if done.returncode != 0:
175
+ raise TeamError(
176
+ "merge_abort_failed",
177
+ "合并失败,执行 git merge --abort 也失败了。立即告诉用户,不要再执行任何 git "
178
+ f"写操作。git 输出:{_output(done)}",
179
+ )
180
+
181
+
182
+ def merge(plan: MergePlan) -> tuple[str, dict]:
183
+ top = plan.top
184
+ done = _git(["merge", "--no-ff", "--no-edit", plan.branch], top)
185
+ if done.returncode == 0:
186
+ commit = _git(["rev-parse", "--short", "HEAD"], top).stdout.strip()
187
+ summary = _git(["diff", "--shortstat", "HEAD^1", "HEAD"], top).stdout.strip()
188
+ text = (
189
+ f"已把成员 {plan.member} 的分支 {plan.branch} 合并到 {plan.target}:合并提交 {commit}"
190
+ + (f",{summary}" if summary else "")
191
+ + "。成员的工作树和分支保留,它可以继续工作;"
192
+ "之后再做改动前,让它先合入 " + plan.target + "。"
193
+ )
194
+ return text, {"member": plan.member, "branch": plan.branch, "commit": commit, "merged": True}
195
+ conflicts = _git(["diff", "--name-only", "--diff-filter=U"], top).stdout.split()
196
+ if conflicts:
197
+ _abort(top)
198
+ shown = "、".join(conflicts[:FILE_LIMIT])
199
+ raise TeamError(
200
+ "merge_conflict",
201
+ f"合并 {plan.branch} 有冲突({shown}),已放弃这次合并,主目录恢复原状。"
202
+ f"下一步:让成员 {plan.member} 在它的工作树中执行 git merge {plan.target},"
203
+ "解决冲突并提交后,再调用 team_merge。",
204
+ )
205
+ if (git_dir(top) / "MERGE_HEAD").exists():
206
+ _abort(top)
207
+ raise TeamError("merge_failed", f"git merge 失败,主目录没有改动:{_output(done)}")
mosscode/teams/pane.py ADDED
@@ -0,0 +1,20 @@
1
+ """What a Windows Terminal pane runs — the member as a child, then exit 0.
2
+
3
+ Windows Terminal keeps a pane open when its process exits non-zero, and taskkill
4
+ always leaves exit code 1. Exiting 0 here closes the pane however the member ended.
5
+ """
6
+
7
+ import subprocess
8
+ import sys
9
+
10
+
11
+ def main() -> None:
12
+ try:
13
+ subprocess.run([sys.executable, "-m", "mosscode", *sys.argv[1:]])
14
+ except BaseException:
15
+ pass
16
+ sys.exit(0)
17
+
18
+
19
+ if __name__ == "__main__":
20
+ main()