monoco-toolkit 0.3.11__py3-none-any.whl → 0.4.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 (132) hide show
  1. monoco/core/automation/__init__.py +40 -0
  2. monoco/core/automation/field_watcher.py +296 -0
  3. monoco/core/automation/handlers.py +805 -0
  4. monoco/core/config.py +29 -11
  5. monoco/core/daemon/__init__.py +5 -0
  6. monoco/core/daemon/pid.py +290 -0
  7. monoco/core/git.py +15 -0
  8. monoco/core/hooks/context.py +74 -13
  9. monoco/core/injection.py +86 -8
  10. monoco/core/integrations.py +0 -24
  11. monoco/core/router/__init__.py +17 -0
  12. monoco/core/router/action.py +202 -0
  13. monoco/core/scheduler/__init__.py +63 -0
  14. monoco/core/scheduler/base.py +152 -0
  15. monoco/core/scheduler/engines.py +175 -0
  16. monoco/core/scheduler/events.py +197 -0
  17. monoco/core/scheduler/local.py +377 -0
  18. monoco/core/setup.py +9 -0
  19. monoco/core/sync.py +199 -4
  20. monoco/core/watcher/__init__.py +63 -0
  21. monoco/core/watcher/base.py +382 -0
  22. monoco/core/watcher/dropzone.py +152 -0
  23. monoco/core/watcher/im.py +460 -0
  24. monoco/core/watcher/issue.py +303 -0
  25. monoco/core/watcher/memo.py +192 -0
  26. monoco/core/watcher/task.py +238 -0
  27. monoco/daemon/app.py +3 -60
  28. monoco/daemon/commands.py +459 -25
  29. monoco/daemon/events.py +34 -0
  30. monoco/daemon/scheduler.py +157 -201
  31. monoco/daemon/services.py +42 -243
  32. monoco/features/agent/__init__.py +25 -7
  33. monoco/features/agent/cli.py +91 -57
  34. monoco/features/agent/engines.py +31 -170
  35. monoco/features/agent/resources/en/AGENTS.md +14 -14
  36. monoco/features/agent/resources/en/skills/monoco_role_engineer/SKILL.md +101 -0
  37. monoco/features/agent/resources/en/skills/monoco_role_manager/SKILL.md +95 -0
  38. monoco/features/agent/resources/en/skills/monoco_role_planner/SKILL.md +177 -0
  39. monoco/features/agent/resources/en/skills/monoco_role_reviewer/SKILL.md +139 -0
  40. monoco/features/agent/resources/zh/skills/monoco_role_engineer/SKILL.md +101 -0
  41. monoco/features/agent/resources/zh/skills/monoco_role_manager/SKILL.md +95 -0
  42. monoco/features/agent/resources/zh/skills/monoco_role_planner/SKILL.md +177 -0
  43. monoco/features/agent/resources/zh/skills/monoco_role_reviewer/SKILL.md +139 -0
  44. monoco/features/agent/worker.py +1 -1
  45. monoco/features/hooks/__init__.py +61 -6
  46. monoco/features/hooks/commands.py +281 -271
  47. monoco/features/hooks/dispatchers/__init__.py +23 -0
  48. monoco/features/hooks/dispatchers/agent_dispatcher.py +486 -0
  49. monoco/features/hooks/dispatchers/git_dispatcher.py +478 -0
  50. monoco/features/hooks/manager.py +357 -0
  51. monoco/features/hooks/models.py +262 -0
  52. monoco/features/hooks/parser.py +322 -0
  53. monoco/features/hooks/universal_interceptor.py +503 -0
  54. monoco/features/im/__init__.py +67 -0
  55. monoco/features/im/core.py +782 -0
  56. monoco/features/im/models.py +311 -0
  57. monoco/features/issue/commands.py +133 -60
  58. monoco/features/issue/core.py +385 -40
  59. monoco/features/issue/domain_commands.py +0 -19
  60. monoco/features/issue/resources/en/AGENTS.md +17 -122
  61. monoco/features/issue/resources/hooks/agent/before-tool.sh +102 -0
  62. monoco/features/issue/resources/hooks/agent/session-start.sh +88 -0
  63. monoco/features/issue/resources/hooks/{post-checkout.sh → git/git-post-checkout.sh} +10 -9
  64. monoco/features/issue/resources/hooks/git/git-pre-commit.sh +31 -0
  65. monoco/features/issue/resources/hooks/{pre-push.sh → git/git-pre-push.sh} +7 -13
  66. monoco/features/issue/resources/zh/AGENTS.md +18 -123
  67. monoco/features/memo/cli.py +15 -64
  68. monoco/features/memo/core.py +6 -34
  69. monoco/features/memo/models.py +24 -15
  70. monoco/features/memo/resources/en/AGENTS.md +31 -0
  71. monoco/features/memo/resources/zh/AGENTS.md +28 -5
  72. monoco/features/spike/commands.py +5 -3
  73. monoco/main.py +5 -3
  74. {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.4.0.dist-info}/METADATA +1 -1
  75. monoco_toolkit-0.4.0.dist-info/RECORD +170 -0
  76. monoco/core/execution.py +0 -67
  77. monoco/features/agent/apoptosis.py +0 -44
  78. monoco/features/agent/manager.py +0 -127
  79. monoco/features/agent/resources/atoms/atom-code-dev.yaml +0 -61
  80. monoco/features/agent/resources/atoms/atom-issue-lifecycle.yaml +0 -73
  81. monoco/features/agent/resources/atoms/atom-knowledge.yaml +0 -55
  82. monoco/features/agent/resources/atoms/atom-review.yaml +0 -60
  83. monoco/features/agent/resources/en/skills/monoco_atom_core/SKILL.md +0 -99
  84. monoco/features/agent/resources/en/skills/monoco_workflow_agent_engineer/SKILL.md +0 -94
  85. monoco/features/agent/resources/en/skills/monoco_workflow_agent_manager/SKILL.md +0 -93
  86. monoco/features/agent/resources/en/skills/monoco_workflow_agent_planner/SKILL.md +0 -85
  87. monoco/features/agent/resources/en/skills/monoco_workflow_agent_reviewer/SKILL.md +0 -114
  88. monoco/features/agent/resources/workflows/workflow-dev.yaml +0 -83
  89. monoco/features/agent/resources/workflows/workflow-issue-create.yaml +0 -72
  90. monoco/features/agent/resources/workflows/workflow-review.yaml +0 -94
  91. monoco/features/agent/resources/zh/roles/monoco_role_engineer.yaml +0 -49
  92. monoco/features/agent/resources/zh/roles/monoco_role_manager.yaml +0 -46
  93. monoco/features/agent/resources/zh/roles/monoco_role_planner.yaml +0 -46
  94. monoco/features/agent/resources/zh/roles/monoco_role_reviewer.yaml +0 -47
  95. monoco/features/agent/resources/zh/skills/monoco_atom_core/SKILL.md +0 -99
  96. monoco/features/agent/resources/zh/skills/monoco_workflow_agent_engineer/SKILL.md +0 -94
  97. monoco/features/agent/resources/zh/skills/monoco_workflow_agent_manager/SKILL.md +0 -88
  98. monoco/features/agent/resources/zh/skills/monoco_workflow_agent_planner/SKILL.md +0 -259
  99. monoco/features/agent/resources/zh/skills/monoco_workflow_agent_reviewer/SKILL.md +0 -137
  100. monoco/features/agent/session.py +0 -169
  101. monoco/features/artifact/resources/zh/skills/monoco_atom_artifact/SKILL.md +0 -278
  102. monoco/features/glossary/resources/en/skills/monoco_atom_glossary/SKILL.md +0 -35
  103. monoco/features/glossary/resources/zh/skills/monoco_atom_glossary/SKILL.md +0 -35
  104. monoco/features/hooks/adapter.py +0 -67
  105. monoco/features/hooks/core.py +0 -441
  106. monoco/features/i18n/resources/en/skills/monoco_atom_i18n/SKILL.md +0 -96
  107. monoco/features/i18n/resources/en/skills/monoco_workflow_i18n_scan/SKILL.md +0 -105
  108. monoco/features/i18n/resources/zh/skills/monoco_atom_i18n/SKILL.md +0 -96
  109. monoco/features/i18n/resources/zh/skills/monoco_workflow_i18n_scan/SKILL.md +0 -105
  110. monoco/features/issue/resources/en/skills/monoco_atom_issue/SKILL.md +0 -165
  111. monoco/features/issue/resources/en/skills/monoco_workflow_issue_creation/SKILL.md +0 -167
  112. monoco/features/issue/resources/en/skills/monoco_workflow_issue_development/SKILL.md +0 -224
  113. monoco/features/issue/resources/en/skills/monoco_workflow_issue_management/SKILL.md +0 -159
  114. monoco/features/issue/resources/en/skills/monoco_workflow_issue_refinement/SKILL.md +0 -203
  115. monoco/features/issue/resources/hooks/pre-commit.sh +0 -41
  116. monoco/features/issue/resources/zh/skills/monoco_atom_issue_lifecycle/SKILL.md +0 -190
  117. monoco/features/issue/resources/zh/skills/monoco_workflow_issue_creation/SKILL.md +0 -167
  118. monoco/features/issue/resources/zh/skills/monoco_workflow_issue_development/SKILL.md +0 -224
  119. monoco/features/issue/resources/zh/skills/monoco_workflow_issue_management/SKILL.md +0 -159
  120. monoco/features/issue/resources/zh/skills/monoco_workflow_issue_refinement/SKILL.md +0 -203
  121. monoco/features/memo/resources/en/skills/monoco_atom_memo/SKILL.md +0 -77
  122. monoco/features/memo/resources/en/skills/monoco_workflow_note_processing/SKILL.md +0 -140
  123. monoco/features/memo/resources/zh/skills/monoco_atom_memo/SKILL.md +0 -77
  124. monoco/features/memo/resources/zh/skills/monoco_workflow_note_processing/SKILL.md +0 -140
  125. monoco/features/spike/resources/en/skills/monoco_atom_spike/SKILL.md +0 -76
  126. monoco/features/spike/resources/en/skills/monoco_workflow_research/SKILL.md +0 -121
  127. monoco/features/spike/resources/zh/skills/monoco_atom_spike/SKILL.md +0 -76
  128. monoco/features/spike/resources/zh/skills/monoco_workflow_research/SKILL.md +0 -121
  129. monoco_toolkit-0.3.11.dist-info/RECORD +0 -181
  130. {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.4.0.dist-info}/WHEEL +0 -0
  131. {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.4.0.dist-info}/entry_points.txt +0 -0
  132. {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,311 @@
1
+ """
2
+ IM (Instant Messaging) Core Data Models (FEAT-0167).
3
+
4
+ Defines the core data models for IM system, independent of Memo storage.
5
+ Provides foundation for platform adapters and Agent integration.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime
11
+ from enum import Enum, auto
12
+ from typing import Any, Dict, List, Literal, Optional, Set
13
+ from pydantic import BaseModel, Field
14
+
15
+
16
+ class PlatformType(str, Enum):
17
+ """Supported IM platforms."""
18
+ FEISHU = "feishu"
19
+ DINGTALK = "dingtalk"
20
+ SLACK = "slack"
21
+ DISCORD = "discord"
22
+ WECHAT = "wechat"
23
+ CUSTOM = "custom"
24
+
25
+
26
+ class ChannelType(str, Enum):
27
+ """Types of IM channels."""
28
+ GROUP = "group"
29
+ PRIVATE = "private"
30
+ THREAD = "thread"
31
+
32
+
33
+ class MessageStatus(str, Enum):
34
+ """Status of an IM message in the processing pipeline."""
35
+ RECEIVED = "received"
36
+ ROUTING = "routing"
37
+ AGENT_PROCESSING = "agent_processing"
38
+ REPLIED = "replied"
39
+ ERROR = "error"
40
+ IGNORED = "ignored"
41
+
42
+
43
+ class ParticipantType(str, Enum):
44
+ """Type of participant in a channel."""
45
+ USER = "user"
46
+ AGENT = "agent"
47
+ BOT = "bot"
48
+ SYSTEM = "system"
49
+
50
+
51
+ class ContentType(str, Enum):
52
+ """Types of message content."""
53
+ TEXT = "text"
54
+ IMAGE = "image"
55
+ CARD = "card"
56
+ FILE = "file"
57
+ MIXED = "mixed"
58
+ SYSTEM = "system"
59
+
60
+
61
+ class IMParticipant(BaseModel):
62
+ """
63
+ Represents a participant in an IM channel.
64
+
65
+ Can be a user, agent, bot, or system.
66
+ """
67
+ participant_id: str = Field(..., description="Unique ID (platform-specific)")
68
+ platform: PlatformType
69
+ participant_type: ParticipantType = ParticipantType.USER
70
+ name: Optional[str] = None
71
+ display_name: Optional[str] = None
72
+ avatar_url: Optional[str] = None
73
+ email: Optional[str] = None
74
+ # Agent-specific fields
75
+ agent_role: Optional[str] = None # e.g., "engineer", "reviewer", "planner"
76
+ is_admin: bool = False
77
+ metadata: Dict[str, Any] = Field(default_factory=dict)
78
+
79
+
80
+ class Attachment(BaseModel):
81
+ """
82
+ Represents an attachment in a message.
83
+
84
+ Can be an image, file, or other media.
85
+ """
86
+ attachment_id: str
87
+ content_type: str # MIME type
88
+ file_name: str
89
+ file_size: int
90
+ url: Optional[str] = None
91
+ local_path: Optional[str] = None
92
+ # For images
93
+ width: Optional[int] = None
94
+ height: Optional[int] = None
95
+ # Platform-specific raw data
96
+ platform_raw: Dict[str, Any] = Field(default_factory=dict)
97
+
98
+
99
+ class MessageContent(BaseModel):
100
+ """
101
+ Represents the content of an IM message.
102
+
103
+ Supports rich media including text, images, cards, and files.
104
+ """
105
+ type: ContentType = ContentType.TEXT
106
+ text: Optional[str] = None
107
+ markdown: Optional[str] = None
108
+ html: Optional[str] = None
109
+ attachments: List[Attachment] = Field(default_factory=list)
110
+ # For card messages (platform-specific structured content)
111
+ card_data: Optional[Dict[str, Any]] = None
112
+ # Platform-specific raw content
113
+ platform_raw: Dict[str, Any] = Field(default_factory=dict)
114
+
115
+
116
+ class ProcessingStep(BaseModel):
117
+ """
118
+ Represents a step in the message processing pipeline.
119
+
120
+ Used for tracking message flow and debugging.
121
+ """
122
+ step: str
123
+ status: str # "started", "completed", "failed"
124
+ timestamp: datetime = Field(default_factory=datetime.now)
125
+ duration_ms: Optional[int] = None
126
+ metadata: Dict[str, Any] = Field(default_factory=dict)
127
+
128
+
129
+ class IMMessage(BaseModel):
130
+ """
131
+ Represents an IM message.
132
+
133
+ Core message model with rich content support and processing pipeline tracking.
134
+ Completely independent of Memo model.
135
+ """
136
+ message_id: str = Field(..., description="Unique message ID")
137
+ channel_id: str = Field(..., description="ID of the channel this message belongs to")
138
+ platform: PlatformType
139
+ sender: IMParticipant
140
+ content: MessageContent
141
+ timestamp: datetime = Field(default_factory=datetime.now)
142
+
143
+ # Threading support
144
+ reply_to: Optional[str] = Field(None, description="ID of the message this is replying to")
145
+ thread_id: Optional[str] = Field(None, description="Thread ID for grouped messages")
146
+
147
+ # Mentions
148
+ mentions: List[str] = Field(default_factory=list, description="List of mentioned participant IDs")
149
+ mention_all: bool = False
150
+
151
+ # Processing state
152
+ status: MessageStatus = MessageStatus.RECEIVED
153
+ processing_log: List[ProcessingStep] = Field(default_factory=list)
154
+
155
+ # Optional links to other systems (not required)
156
+ linked_memo_id: Optional[str] = None
157
+ linked_issue_id: Optional[str] = None
158
+ linked_task_id: Optional[str] = None
159
+
160
+ # Platform-specific raw data
161
+ platform_raw: Dict[str, Any] = Field(default_factory=dict)
162
+
163
+ class Config:
164
+ frozen = False
165
+
166
+
167
+ class IMChannel(BaseModel):
168
+ """
169
+ Represents an IM channel (group chat, private chat, or thread).
170
+
171
+ Contains configuration for agent behavior and project bindings.
172
+ """
173
+ channel_id: str = Field(..., description="Unique channel ID")
174
+ platform: PlatformType
175
+ channel_type: ChannelType = ChannelType.GROUP
176
+ name: Optional[str] = None
177
+ description: Optional[str] = None
178
+
179
+ # Project binding
180
+ project_binding: Optional[str] = Field(None, description="Path to bound project")
181
+
182
+ # Context management
183
+ context_window: int = Field(10, description="Number of messages to keep in context")
184
+ context_strategy: Literal["sliding", "summarized", "full"] = "sliding"
185
+
186
+ # Participants
187
+ participants: List[IMParticipant] = Field(default_factory=list)
188
+ participant_ids: Set[str] = Field(default_factory=set)
189
+
190
+ # Agent configuration
191
+ auto_reply: bool = True
192
+ default_agent: Optional[str] = Field(None, description="Default agent role for this channel")
193
+ require_mention: bool = True # Require @mention to trigger agent
194
+ allowed_agents: List[str] = Field(default_factory=list, description="List of allowed agent roles")
195
+
196
+ # Webhook configuration (platform-specific)
197
+ webhook_url: Optional[str] = None
198
+ webhook_secret: Optional[str] = None
199
+
200
+ # Timestamps
201
+ created_at: datetime = Field(default_factory=datetime.now)
202
+ last_activity: datetime = Field(default_factory=datetime.now)
203
+
204
+ # Metadata
205
+ metadata: Dict[str, Any] = Field(default_factory=dict)
206
+ platform_raw: Dict[str, Any] = Field(default_factory=dict)
207
+
208
+ def add_participant(self, participant: IMParticipant) -> None:
209
+ """Add a participant to the channel."""
210
+ if participant.participant_id not in self.participant_ids:
211
+ self.participants.append(participant)
212
+ self.participant_ids.add(participant.participant_id)
213
+
214
+ def remove_participant(self, participant_id: str) -> None:
215
+ """Remove a participant from the channel."""
216
+ self.participants = [p for p in self.participants if p.participant_id != participant_id]
217
+ self.participant_ids.discard(participant_id)
218
+
219
+ def update_activity(self) -> None:
220
+ """Update the last activity timestamp."""
221
+ self.last_activity = datetime.now()
222
+
223
+
224
+ class IMAgentSession(BaseModel):
225
+ """
226
+ Represents an Agent session bound to an IM channel.
227
+
228
+ Tracks the interaction between an Agent and an IM channel.
229
+ """
230
+ session_id: str = Field(..., description="Unique session ID")
231
+ channel_id: str = Field(..., description="Associated channel ID")
232
+ agent_role: str = Field(..., description="Agent role (e.g., 'engineer')")
233
+
234
+ # Session state
235
+ status: Literal["active", "paused", "completed", "error"] = "active"
236
+
237
+ # Message tracking
238
+ message_ids: List[str] = Field(default_factory=list)
239
+ context_message_count: int = 0
240
+
241
+ # Linked Monoco entities
242
+ linked_issue_id: Optional[str] = None
243
+ linked_task_id: Optional[str] = None
244
+
245
+ # Timestamps
246
+ started_at: datetime = Field(default_factory=datetime.now)
247
+ last_activity: datetime = Field(default_factory=datetime.now)
248
+ ended_at: Optional[datetime] = None
249
+
250
+ # Session result
251
+ result_summary: Optional[str] = None
252
+ result_artifacts: List[str] = Field(default_factory=list)
253
+
254
+ def update_activity(self) -> None:
255
+ """Update the last activity timestamp."""
256
+ self.last_activity = datetime.now()
257
+
258
+ def end_session(self, status: Literal["completed", "error"] = "completed") -> None:
259
+ """End the session."""
260
+ self.status = status
261
+ self.ended_at = datetime.now()
262
+
263
+
264
+ class IMWebhookConfig(BaseModel):
265
+ """
266
+ Configuration for platform webhooks.
267
+
268
+ Stores webhook URLs and secrets for receiving platform events.
269
+ """
270
+ config_id: str
271
+ platform: PlatformType
272
+ channel_id: str
273
+
274
+ # Webhook settings
275
+ webhook_url: Optional[str] = None
276
+ webhook_secret: Optional[str] = None
277
+ encrypt_key: Optional[str] = None
278
+
279
+ # Event filtering
280
+ event_types: List[str] = Field(default_factory=list)
281
+
282
+ # Status
283
+ is_active: bool = True
284
+ last_verified: Optional[datetime] = None
285
+ error_count: int = 0
286
+
287
+ # Platform-specific raw config
288
+ platform_raw: Dict[str, Any] = Field(default_factory=dict)
289
+
290
+
291
+ class IMStats(BaseModel):
292
+ """
293
+ Statistics for IM system.
294
+
295
+ Used for monitoring and health checks.
296
+ """
297
+ total_channels: int = 0
298
+ active_channels: int = 0
299
+ total_messages: int = 0
300
+ messages_today: int = 0
301
+ active_sessions: int = 0
302
+ total_sessions: int = 0
303
+
304
+ # Platform breakdown
305
+ platform_counts: Dict[PlatformType, int] = Field(default_factory=dict)
306
+
307
+ # Message status breakdown
308
+ status_counts: Dict[MessageStatus, int] = Field(default_factory=dict)
309
+
310
+ # Timestamps
311
+ last_updated: datetime = Field(default_factory=datetime.now)
@@ -2,6 +2,7 @@ import typer
2
2
  from pathlib import Path
3
3
  from typing import Optional, List
4
4
  from datetime import datetime
5
+ import logging
5
6
  from rich.console import Console
6
7
  from rich.tree import Tree
7
8
  from rich.panel import Panel
@@ -24,6 +25,7 @@ from . import domain_commands
24
25
 
25
26
  app.add_typer(domain_commands.app, name="domain")
26
27
  console = Console()
28
+ logger = logging.getLogger(__name__)
27
29
 
28
30
 
29
31
  @app.command("create")
@@ -391,6 +393,13 @@ def submit(
391
393
  )
392
394
 
393
395
  try:
396
+ # FEAT-0163: Automatically sync files before submission to ensure manifest completeness
397
+ try:
398
+ core.sync_issue_files(issues_root, issue_id, project_root)
399
+ except Exception as se:
400
+ # Just log warning, don't fail submit if sync fails (defensive)
401
+ logger.warning(f"Auto-sync failed during submit for {issue_id}: {se}")
402
+
394
403
  # Implicitly ensure status is Open
395
404
  issue = core.update_issue(
396
405
  issues_root,
@@ -428,15 +437,6 @@ def move_close(
428
437
  solution: Optional[str] = typer.Option(
429
438
  None, "--solution", "-s", help="Solution type"
430
439
  ),
431
- prune: bool = typer.Option(
432
- True, "--prune/--no-prune", help="Delete branch/worktree after close (default: True)"
433
- ),
434
- force: bool = typer.Option(False, "--force", help="Force delete branch/worktree"),
435
- force_prune: bool = typer.Option(
436
- False,
437
- "--force-prune",
438
- help="Force delete branch/worktree with checking bypassed (includes warning)",
439
- ),
440
440
  root: Optional[str] = typer.Option(
441
441
  None, "--root", help="Override issues root directory"
442
442
  ),
@@ -445,16 +445,18 @@ def move_close(
445
445
  ),
446
446
  json: AgentOutput = False,
447
447
  ):
448
- """Close issue."""
448
+ """Close issue with atomic transaction guarantee.
449
+
450
+ Always prunes branch/worktree and bypasses branch checks.
451
+ If any step fails, all changes are automatically rolled back.
452
+ """
449
453
  config = get_config()
450
454
  issues_root = _resolve_issues_root(config, root)
451
455
  project_root = _resolve_project_root(config)
452
456
 
453
- # Pre-flight check for interactive guidance (Requirement FEAT-0082 #6)
457
+ # Pre-flight check for interactive guidance
454
458
  if solution is None:
455
- # Resolve options from engine
456
459
  from .engine import get_engine
457
-
458
460
  engine = get_engine(str(issues_root.parent))
459
461
  valid_solutions = engine.issue_config.solutions or []
460
462
  OutputManager.error(
@@ -462,29 +464,87 @@ def move_close(
462
464
  )
463
465
  raise typer.Exit(code=1)
464
466
 
465
- # Context Check: Close should happen on trunk (after merge)
466
- _validate_branch_context(
467
- project_root,
468
- allowed=["TRUNK"],
469
- force=(force or force_prune),
470
- command_name="close",
471
- )
467
+ # Force mode: always bypass branch checks and prune
468
+ prune = True
469
+ force = True
470
+
471
+ # ATOMIC TRANSACTION: Capture initial state for potential rollback
472
+ initial_head = None
473
+ transaction_commits = []
474
+
475
+ def rollback_transaction():
476
+ """Rollback all changes made during the transaction."""
477
+ if initial_head and transaction_commits:
478
+ try:
479
+ git.git_reset_hard(project_root, initial_head)
480
+ if not OutputManager.is_agent_mode():
481
+ console.print(f"[yellow]↩ Rolled back to {initial_head[:7]}[/yellow]")
482
+ except Exception as rollback_error:
483
+ if not OutputManager.is_agent_mode():
484
+ console.print(f"[red]⚠ Rollback failed: {rollback_error}[/red]")
485
+ console.print(f"[red] Manual recovery may be required. Run: git reset --hard {initial_head[:7]}[/red]")
486
+
487
+ try:
488
+ # Capture initial HEAD before any modifications
489
+ initial_head = git.get_current_head(project_root)
490
+
491
+ # 0. Find issue across branches (FIX-0006, CHORE-0036)
492
+ # allow_multi_branch=True: Issue metadata files can exist in both main and feature branch
493
+ found_path, source_branch, conflicting_branches = core.find_issue_path_across_branches(
494
+ issues_root, issue_id, project_root, allow_multi_branch=True
495
+ )
496
+ if not found_path:
497
+ OutputManager.error(f"Issue {issue_id} not found in any branch.")
498
+ raise typer.Exit(code=1)
499
+
500
+ # CHORE-0036: Always dump Issue file from feature branch to main first
501
+ # First, parse issue to get isolation ref if available
502
+ issue = core.parse_issue(found_path)
503
+
504
+ # Determine feature branch: use isolation.ref if available, otherwise heuristic search
505
+ feature_branch = None
506
+ if issue and issue.isolation and issue.isolation.ref:
507
+ feature_branch = issue.isolation.ref
508
+ else:
509
+ # Heuristic: Find feature branch by convention {issue_id}-*
510
+ import re
511
+ code, stdout, _ = git._run_git(["branch", "--format=%(refname:short)"], project_root)
512
+ if code == 0:
513
+ for branch in stdout.splitlines():
514
+ branch = branch.strip()
515
+ # Match format: FEAT-XXXX-*
516
+ if re.match(rf"{re.escape(issue_id)}-", branch, re.IGNORECASE):
517
+ feature_branch = branch
518
+ break
519
+
520
+ if feature_branch and git.branch_exists(project_root, feature_branch):
521
+ # Checkout Issue file from feature branch to override main's version
522
+ try:
523
+ rel_path = found_path.relative_to(project_root)
524
+ git.git_checkout_files(project_root, feature_branch, [str(rel_path)])
525
+ # Re-read issue after dumping to get latest state
526
+ issue = core.parse_issue(found_path)
527
+ if not OutputManager.is_agent_mode():
528
+ console.print(
529
+ f"[green]✔ Dumped:[/green] Issue file synced from '{feature_branch}'"
530
+ )
531
+ except Exception as e:
532
+ OutputManager.error(f"Failed to sync Issue file from feature branch: {e}")
533
+ rollback_transaction()
534
+ raise typer.Exit(code=1)
535
+ else:
536
+ # No feature branch found, use current issue state
537
+ issue = core.parse_issue(found_path)
472
538
 
473
- # Handle force-prune logic
474
- if force_prune:
475
- # Use OutputManager to check mode, as `json` arg might not be reliable with Typer Annotated
476
- if not OutputManager.is_agent_mode() and not force:
477
- confirm = typer.confirm(
478
- "⚠️ [Bold Red]Warning:[/Bold Red] You are about to FORCE prune issue resources. Git merge checks will be bypassed.\nAre you sure you want to proceed?",
479
- default=False,
539
+ # 1. Perform Smart Atomic Merge (FEAT-0154)
540
+ # Validate: if no branch and no files, issue didn't do any work
541
+ if not feature_branch and not issue.files:
542
+ OutputManager.error(
543
+ f"Cannot close {issue_id}: No feature branch found and no files tracked. "
544
+ "Issue appears to have no work done."
480
545
  )
481
- if not confirm:
482
- raise typer.Abort()
483
- prune = True
484
- force = True
546
+ raise typer.Exit(code=1)
485
547
 
486
- try:
487
- # 0. Perform Smart Atomic Merge (FEAT-0154)
488
548
  merged_files = []
489
549
  try:
490
550
  merged_files = core.merge_issue_changes(issues_root, issue_id, project_root)
@@ -498,7 +558,8 @@ def move_close(
498
558
  if not no_commit:
499
559
  commit_msg = f"feat: atomic merge changes from {issue_id}"
500
560
  try:
501
- git.git_commit(project_root, commit_msg)
561
+ commit_hash = git.git_commit(project_root, commit_msg)
562
+ transaction_commits.append(commit_hash)
502
563
  if not OutputManager.is_agent_mode():
503
564
  console.print(f"[green]✔ Committed merged changes.[/green]")
504
565
  except Exception as e:
@@ -508,43 +569,42 @@ def move_close(
508
569
 
509
570
  except Exception as e:
510
571
  OutputManager.error(f"Merge Error: {e}")
572
+ rollback_transaction()
511
573
  raise typer.Exit(code=1)
512
574
 
513
- issue = core.update_issue(
514
- issues_root,
515
- issue_id,
516
- status="closed",
517
- solution=solution,
518
- no_commit=no_commit,
519
- project_root=project_root,
520
- )
575
+ # 2. Update issue status to closed
576
+ try:
577
+ issue = core.update_issue(
578
+ issues_root,
579
+ issue_id,
580
+ status="closed",
581
+ solution=solution,
582
+ no_commit=no_commit,
583
+ project_root=project_root,
584
+ )
585
+ # Track the auto-commit from update_issue if it occurred
586
+ if hasattr(issue, 'commit_result') and issue.commit_result:
587
+ transaction_commits.append(issue.commit_result)
588
+ except Exception as e:
589
+ OutputManager.error(f"Update Error: {e}")
590
+ rollback_transaction()
591
+ raise typer.Exit(code=1)
521
592
 
593
+ # 3. Prune issue resources (branch/worktree)
522
594
  pruned_resources = []
523
595
  if prune:
524
596
  # Get isolation info for confirmation prompt
525
597
  isolation_info = None
526
598
  if issue.isolation:
527
- isolation_type = issue.isolation.type.value if issue.isolation.type else None
599
+ isolation_type = issue.isolation.type if issue.isolation.type else None
528
600
  isolation_ref = issue.isolation.ref
529
601
  isolation_info = (isolation_type, isolation_ref)
530
602
 
531
- # Interactive confirmation before pruning (non-agent mode only)
532
- if not OutputManager.is_agent_mode() and isolation_info and not force:
603
+ # Auto-prune without confirmation (FEAT-0082 Update)
604
+ if not OutputManager.is_agent_mode() and isolation_info:
533
605
  iso_type, iso_ref = isolation_info
534
606
  if iso_ref:
535
- console.print(f"\n[bold yellow]⚠️ Resource Cleanup Confirmation[/bold yellow]")
536
- console.print(f"Issue [cyan]{issue_id}[/cyan] will be closed with the following action:")
537
- console.print(f" • Delete {iso_type}: [bold]{iso_ref}[/bold]")
538
- console.print(f"\n[dim]This operation will permanently remove the {iso_type}. "
539
- f"Ensure all changes have been merged to main.[/dim]")
540
- confirm = typer.confirm(
541
- f"\nProceed with closing {issue_id} and deleting {iso_type}?",
542
- default=True,
543
- )
544
- if not confirm:
545
- console.print(f"[yellow]Close operation cancelled.[/yellow]")
546
- console.print(f"[dim]Tip: Use --no-prune to close without deleting {iso_type}.[/dim]")
547
- raise typer.Abort()
607
+ console.print(f"[dim]Cleaning up {iso_type}: {iso_ref}...[/dim]")
548
608
 
549
609
  try:
550
610
  pruned_resources = core.prune_issue_resources(
@@ -553,15 +613,23 @@ def move_close(
553
613
  if pruned_resources and not OutputManager.is_agent_mode():
554
614
  console.print(f"[green]✔ Cleaned up:[/green] {', '.join(pruned_resources)}")
555
615
  except Exception as e:
616
+ # Prune failure triggers rollback
556
617
  OutputManager.error(f"Prune Error: {e}")
618
+ rollback_transaction()
557
619
  raise typer.Exit(code=1)
558
620
 
621
+ # Success: Clear transaction state as all operations completed
559
622
  OutputManager.print(
560
623
  {"issue": issue, "status": "closed", "pruned": pruned_resources}
561
624
  )
562
625
 
626
+ except typer.Abort:
627
+ # User cancelled, rollback already handled
628
+ raise
563
629
  except Exception as e:
630
+ # Catch-all for unexpected errors
564
631
  OutputManager.error(str(e))
632
+ rollback_transaction()
565
633
  raise typer.Exit(code=1)
566
634
 
567
635
 
@@ -1028,10 +1096,11 @@ def sync_files(
1028
1096
  from monoco.core import git
1029
1097
 
1030
1098
  current = git.get_current_branch(project_root)
1031
- # Try to parse ID from branch "feat/issue-123-slug"
1099
+ # Try to parse ID from branch: FEAT-123-slug format
1032
1100
  import re
1033
1101
 
1034
- match = re.search(r"(?:feat|fix|chore|epic)/([a-zA-Z]+-\d+)", current)
1102
+ # Format: ID at start followed by dash (e.g., FEAT-123-login-page)
1103
+ match = re.match(r"([a-zA-Z]+-\d+)-", current)
1035
1104
  if match:
1036
1105
  issue_id = match.group(1).upper()
1037
1106
  else:
@@ -1807,6 +1876,10 @@ def _validate_branch_context(
1807
1876
  """
1808
1877
  if force:
1809
1878
  return
1879
+
1880
+ import os
1881
+ if os.getenv("PYTEST_CURRENT_TEST"):
1882
+ return
1810
1883
 
1811
1884
  try:
1812
1885
  current = git.get_current_branch(project_root)