monoco-toolkit 0.1.1__py3-none-any.whl → 0.2.8__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 (76) hide show
  1. monoco/cli/__init__.py +0 -0
  2. monoco/cli/project.py +87 -0
  3. monoco/cli/workspace.py +46 -0
  4. monoco/core/agent/__init__.py +5 -0
  5. monoco/core/agent/action.py +144 -0
  6. monoco/core/agent/adapters.py +129 -0
  7. monoco/core/agent/protocol.py +31 -0
  8. monoco/core/agent/state.py +106 -0
  9. monoco/core/config.py +212 -17
  10. monoco/core/execution.py +62 -0
  11. monoco/core/feature.py +58 -0
  12. monoco/core/git.py +51 -2
  13. monoco/core/injection.py +196 -0
  14. monoco/core/integrations.py +242 -0
  15. monoco/core/lsp.py +68 -0
  16. monoco/core/output.py +21 -3
  17. monoco/core/registry.py +36 -0
  18. monoco/core/resources/en/AGENTS.md +8 -0
  19. monoco/core/resources/en/SKILL.md +66 -0
  20. monoco/core/resources/zh/AGENTS.md +8 -0
  21. monoco/core/resources/zh/SKILL.md +65 -0
  22. monoco/core/setup.py +96 -110
  23. monoco/core/skills.py +444 -0
  24. monoco/core/state.py +53 -0
  25. monoco/core/sync.py +224 -0
  26. monoco/core/telemetry.py +4 -1
  27. monoco/core/workspace.py +85 -20
  28. monoco/daemon/app.py +127 -58
  29. monoco/daemon/models.py +4 -0
  30. monoco/daemon/services.py +56 -155
  31. monoco/features/config/commands.py +125 -44
  32. monoco/features/i18n/adapter.py +29 -0
  33. monoco/features/i18n/commands.py +89 -10
  34. monoco/features/i18n/core.py +113 -27
  35. monoco/features/i18n/resources/en/AGENTS.md +8 -0
  36. monoco/features/i18n/resources/en/SKILL.md +94 -0
  37. monoco/features/i18n/resources/zh/AGENTS.md +8 -0
  38. monoco/features/i18n/resources/zh/SKILL.md +94 -0
  39. monoco/features/issue/adapter.py +34 -0
  40. monoco/features/issue/commands.py +343 -101
  41. monoco/features/issue/core.py +384 -150
  42. monoco/features/issue/domain/__init__.py +0 -0
  43. monoco/features/issue/domain/lifecycle.py +126 -0
  44. monoco/features/issue/domain/models.py +170 -0
  45. monoco/features/issue/domain/parser.py +223 -0
  46. monoco/features/issue/domain/workspace.py +104 -0
  47. monoco/features/issue/engine/__init__.py +22 -0
  48. monoco/features/issue/engine/config.py +172 -0
  49. monoco/features/issue/engine/machine.py +185 -0
  50. monoco/features/issue/engine/models.py +18 -0
  51. monoco/features/issue/linter.py +325 -120
  52. monoco/features/issue/lsp/__init__.py +3 -0
  53. monoco/features/issue/lsp/definition.py +72 -0
  54. monoco/features/issue/migration.py +134 -0
  55. monoco/features/issue/models.py +46 -24
  56. monoco/features/issue/monitor.py +94 -0
  57. monoco/features/issue/resources/en/AGENTS.md +20 -0
  58. monoco/features/issue/resources/en/SKILL.md +111 -0
  59. monoco/features/issue/resources/zh/AGENTS.md +20 -0
  60. monoco/features/issue/resources/zh/SKILL.md +138 -0
  61. monoco/features/issue/validator.py +455 -0
  62. monoco/features/spike/adapter.py +30 -0
  63. monoco/features/spike/commands.py +45 -24
  64. monoco/features/spike/core.py +6 -40
  65. monoco/features/spike/resources/en/AGENTS.md +7 -0
  66. monoco/features/spike/resources/en/SKILL.md +74 -0
  67. monoco/features/spike/resources/zh/AGENTS.md +7 -0
  68. monoco/features/spike/resources/zh/SKILL.md +74 -0
  69. monoco/main.py +91 -2
  70. monoco_toolkit-0.2.8.dist-info/METADATA +136 -0
  71. monoco_toolkit-0.2.8.dist-info/RECORD +83 -0
  72. monoco_toolkit-0.1.1.dist-info/METADATA +0 -93
  73. monoco_toolkit-0.1.1.dist-info/RECORD +0 -33
  74. {monoco_toolkit-0.1.1.dist-info → monoco_toolkit-0.2.8.dist-info}/WHEEL +0 -0
  75. {monoco_toolkit-0.1.1.dist-info → monoco_toolkit-0.2.8.dist-info}/entry_points.txt +0 -0
  76. {monoco_toolkit-0.1.1.dist-info → monoco_toolkit-0.2.8.dist-info}/licenses/LICENSE +0 -0
@@ -6,16 +6,25 @@ from typing import List, Dict, Optional, Tuple, Any, Set, Set
6
6
  from datetime import datetime
7
7
  from .models import IssueMetadata, IssueType, IssueStatus, IssueSolution, IssueStage, IssueDetail, IsolationType, IssueIsolation, IssueID, current_time, generate_uid
8
8
  from monoco.core import git
9
- from monoco.core.config import get_config
9
+ from monoco.core.config import get_config, MonocoConfig
10
+ from monoco.core.lsp import DiagnosticSeverity
11
+ from .validator import IssueValidator
10
12
 
11
- PREFIX_MAP = {
12
- IssueType.EPIC: "EPIC",
13
- IssueType.FEATURE: "FEAT",
14
- IssueType.CHORE: "CHORE",
15
- IssueType.FIX: "FIX"
16
- }
13
+ from .engine import get_engine
17
14
 
18
- REVERSE_PREFIX_MAP = {v: k for k, v in PREFIX_MAP.items()}
15
+ def get_prefix_map(issues_root: Path) -> Dict[str, str]:
16
+ engine = get_engine(str(issues_root.parent))
17
+ return engine.get_prefix_map()
18
+
19
+ def get_reverse_prefix_map(issues_root: Path) -> Dict[str, str]:
20
+ prefix_map = get_prefix_map(issues_root)
21
+ return {v: k for k, v in prefix_map.items()}
22
+
23
+ def get_issue_dir(issue_type: str, issues_root: Path) -> Path:
24
+ engine = get_engine(str(issues_root.parent))
25
+ folder_map = engine.get_folder_map()
26
+ folder = folder_map.get(issue_type, issue_type.capitalize() + "s")
27
+ return issues_root / folder
19
28
 
20
29
  def _get_slug(title: str) -> str:
21
30
  slug = title.lower()
@@ -29,15 +38,6 @@ def _get_slug(title: str) -> str:
29
38
 
30
39
  return slug
31
40
 
32
- def get_issue_dir(issue_type: IssueType, issues_root: Path) -> Path:
33
- mapping = {
34
- IssueType.EPIC: "Epics",
35
- IssueType.FEATURE: "Features",
36
- IssueType.CHORE: "Chores",
37
- IssueType.FIX: "Fixes",
38
- }
39
- return issues_root / mapping[issue_type]
40
-
41
41
  def parse_issue(file_path: Path) -> Optional[IssueMetadata]:
42
42
  if not file_path.suffix == ".md":
43
43
  return None
@@ -51,7 +51,11 @@ def parse_issue(file_path: Path) -> Optional[IssueMetadata]:
51
51
  data = yaml.safe_load(match.group(1))
52
52
  if not isinstance(data, dict):
53
53
  return None
54
- return IssueMetadata(**data)
54
+
55
+ data['path'] = str(file_path.absolute())
56
+ meta = IssueMetadata(**data)
57
+ meta.actions = get_available_actions(meta)
58
+ return meta
55
59
  except Exception:
56
60
  return None
57
61
 
@@ -72,12 +76,15 @@ def parse_issue_detail(file_path: Path) -> Optional[IssueDetail]:
72
76
  data = yaml.safe_load(yaml_str)
73
77
  if not isinstance(data, dict):
74
78
  return None
79
+
80
+ data['path'] = str(file_path.absolute())
75
81
  return IssueDetail(**data, body=body, raw_content=content)
76
82
  except Exception:
77
83
  return None
78
84
 
79
- def find_next_id(issue_type: IssueType, issues_root: Path) -> str:
80
- prefix = PREFIX_MAP[issue_type]
85
+ def find_next_id(issue_type: str, issues_root: Path) -> str:
86
+ prefix_map = get_prefix_map(issues_root)
87
+ prefix = prefix_map.get(issue_type, "ISSUE")
81
88
  pattern = re.compile(rf"{prefix}-(\d+)")
82
89
  max_id = 0
83
90
 
@@ -118,13 +125,35 @@ def create_issue_file(
118
125
 
119
126
  issue_id = find_next_id(issue_type, issues_root)
120
127
  base_type_dir = get_issue_dir(issue_type, issues_root)
121
- target_dir = base_type_dir / status.value
128
+ target_dir = base_type_dir / status
122
129
 
123
130
  if subdir:
124
131
  target_dir = target_dir / subdir
125
132
 
126
133
  target_dir.mkdir(parents=True, exist_ok=True)
127
134
 
135
+ # Auto-Populate Tags with required IDs (Requirement: Maintain tags field with parent/deps/related/self IDs)
136
+ # Ensure they are prefixed with '#' for tagging convention if not present (usually tags are just strings, but user asked for #ID)
137
+ auto_tags = set(tags) if tags else set()
138
+
139
+ # 1. Add Parent
140
+ if parent:
141
+ auto_tags.add(f"#{parent}")
142
+
143
+ # 2. Add Dependencies
144
+ for dep in dependencies:
145
+ auto_tags.add(f"#{dep}")
146
+
147
+ # 3. Add Related
148
+ for rel in related:
149
+ auto_tags.add(f"#{rel}")
150
+
151
+ # 4. Add Self (as per instruction "auto add this issue... number")
152
+ # Note: issue_id is generated just above
153
+ auto_tags.add(f"#{issue_id}")
154
+
155
+ final_tags = sorted(list(auto_tags))
156
+
128
157
  metadata = IssueMetadata(
129
158
  id=issue_id,
130
159
  uid=generate_uid(), # Generate global unique identifier
@@ -136,85 +165,107 @@ def create_issue_file(
136
165
  dependencies=dependencies,
137
166
  related=related,
138
167
  sprint=sprint,
139
- tags=tags,
168
+ tags=final_tags,
140
169
  opened_at=current_time() if status == IssueStatus.OPEN else None
141
170
  )
171
+
172
+ # Enforce lifecycle policies (defaults, auto-corrections)
173
+ from .engine import get_engine
174
+ get_engine().enforce_policy(metadata)
142
175
 
176
+ # Serialize metadata
177
+ # Explicitly exclude actions and path from file persistence
178
+ yaml_header = yaml.dump(metadata.model_dump(exclude_none=True, mode='json', exclude={'actions', 'path'}), sort_keys=False, allow_unicode=True)
143
179
 
144
- yaml_header = yaml.dump(metadata.model_dump(exclude_none=True, mode='json'), sort_keys=False, allow_unicode=True)
180
+ # Inject Self-Documenting Hints (Interactive Frontmatter)
181
+ if "parent:" not in yaml_header:
182
+ yaml_header += "# parent: <EPIC-ID> # Optional: Parent Issue ID\n"
183
+ if "solution:" not in yaml_header:
184
+ yaml_header += "# solution: null # Required for Closed state (implemented, cancelled, etc.)\n"
185
+
186
+ if "dependencies:" not in yaml_header:
187
+ yaml_header += "# dependencies: [] # List of dependency IDs\n"
188
+ if "related:" not in yaml_header:
189
+ yaml_header += "# related: [] # List of related issue IDs\n"
190
+ if "files:" not in yaml_header:
191
+ yaml_header += "# files: [] # List of modified files\n"
192
+
145
193
  slug = _get_slug(title)
146
194
  filename = f"{issue_id}-{slug}.md"
147
195
 
196
+ # Enhanced Template with Instructional Comments
148
197
  file_content = f"""---
149
198
  {yaml_header}---
150
199
 
151
200
  ## {issue_id}: {title}
152
201
 
153
202
  ## Objective
203
+ <!-- Describe the "Why" and "What" clearly. Focus on value. -->
154
204
 
155
205
  ## Acceptance Criteria
206
+ <!-- Define binary conditions for success. -->
207
+ - [ ] Criteria 1
156
208
 
157
209
  ## Technical Tasks
210
+ <!-- Breakdown into atomic steps. Use nested lists for sub-tasks. -->
158
211
 
159
- - [ ]
212
+ <!-- Status Syntax: -->
213
+ <!-- [ ] To Do -->
214
+ <!-- [/] Doing -->
215
+ <!-- [x] Done -->
216
+ <!-- [~] Cancelled -->
217
+ <!-- - [ ] Parent Task -->
218
+ <!-- - [ ] Sub Task -->
219
+
220
+ - [ ] Task 1
221
+
222
+ ## Review Comments
223
+ <!-- Required for Review/Done stage. Record review feedback here. -->
160
224
  """
161
225
  file_path = target_dir / filename
162
226
  file_path.write_text(file_content)
163
227
 
228
+ # Inject path into returned metadata
229
+ metadata.path = str(file_path.absolute())
230
+
164
231
  return metadata, file_path
165
- def validate_transition(
166
- current_status: IssueStatus,
167
- current_stage: Optional[IssueStage],
168
- target_status: IssueStatus,
169
- target_stage: Optional[IssueStage],
170
- target_solution: Optional[str],
171
- issue_dependencies: List[str],
172
- issues_root: Path,
173
- issue_id: str
174
- ):
175
- """
176
- Centralized validation logic for state transitions.
177
- """
178
- # Policy: Prevent Backlog -> Review
179
- if target_stage == IssueStage.REVIEW and current_status == IssueStatus.BACKLOG:
180
- raise ValueError(f"Lifecycle Policy: Cannot submit Backlog issue directly. Run `monoco issue pull {issue_id}` first.")
232
+ def get_available_actions(meta: IssueMetadata) -> List[Any]:
233
+ from .models import IssueAction
234
+ from .engine import get_engine
235
+
236
+ engine = get_engine()
237
+ transitions = engine.get_available_transitions(meta)
238
+
239
+ actions = []
240
+ for t in transitions:
241
+ command = t.command_template.format(id=meta.id) if t.command_template else ""
242
+
243
+ actions.append(IssueAction(
244
+ label=t.label,
245
+ icon=t.icon,
246
+ target_status=t.to_status if t.to_status != meta.status or t.to_stage != meta.stage else None,
247
+ target_stage=t.to_stage if t.to_stage != meta.stage else None,
248
+ target_solution=t.required_solution,
249
+ command=command
250
+ ))
181
251
 
182
- if target_status == IssueStatus.CLOSED:
183
- if not target_solution:
184
- raise ValueError(f"Closing an issue requires a solution. Please provide --solution or edit the file metadata.")
185
-
186
- # Policy: IMPLEMENTED requires REVIEW stage (unless we are already in REVIEW)
187
- # Check current stage.
188
- if target_solution == IssueSolution.IMPLEMENTED.value:
189
- # If we are transitioning FROM Review, it's fine.
190
- # If we are transitioning TO Closed, current stage must be Review.
191
- if current_stage != IssueStage.REVIEW:
192
- raise ValueError(f"Lifecycle Policy: 'Implemented' issues must be submitted for review first.\nCurrent stage: {current_stage}\nAction: Run `monoco issue submit {issue_id}`.")
193
-
194
- # Policy: No closing from DOING (General Safety)
195
- if current_stage == IssueStage.DOING:
196
- raise ValueError("Cannot close issue in progress (Doing). Please review (`monoco issue submit`) or stop (`monoco issue open`) first.")
197
-
198
- # Policy: Dependencies must be closed
199
- if issue_dependencies:
200
- for dep_id in issue_dependencies:
201
- dep_path = find_issue_path(issues_root, dep_id)
202
- if dep_path:
203
- dep_meta = parse_issue(dep_path)
204
- if dep_meta and dep_meta.status != IssueStatus.CLOSED:
205
- raise ValueError(f"Dependency Block: Cannot close {issue_id} because dependency {dep_id} is not closed.")
252
+ return actions
206
253
 
207
254
  def find_issue_path(issues_root: Path, issue_id: str) -> Optional[Path]:
208
255
  parsed = IssueID(issue_id)
209
256
 
210
257
  if not parsed.is_local:
258
+ if not parsed.namespace:
259
+ return None
260
+
211
261
  # Resolve Workspace
212
- # Assumption: issues_root is direct child of project_root.
213
- # This is a weak assumption but fits current architecture.
262
+ # Traverse up from issues_root to find a config that defines the namespace
214
263
  project_root = issues_root.parent
215
- conf = get_config(str(project_root))
216
264
 
265
+ # Try current root first
266
+ conf = MonocoConfig.load(str(project_root))
217
267
  member_rel_path = conf.project.members.get(parsed.namespace)
268
+
218
269
  if not member_rel_path:
219
270
  return None
220
271
 
@@ -234,7 +285,8 @@ def find_issue_path(issues_root: Path, issue_id: str) -> Optional[Path]:
234
285
  except IndexError:
235
286
  return None
236
287
 
237
- issue_type = REVERSE_PREFIX_MAP.get(prefix)
288
+ reverse_prefix_map = get_reverse_prefix_map(issues_root)
289
+ issue_type = reverse_prefix_map.get(prefix)
238
290
  if not issue_type:
239
291
  return None
240
292
 
@@ -244,7 +296,20 @@ def find_issue_path(issues_root: Path, issue_id: str) -> Optional[Path]:
244
296
  return f
245
297
  return None
246
298
 
247
- def update_issue(issues_root: Path, issue_id: str, status: Optional[IssueStatus] = None, stage: Optional[IssueStage] = None, solution: Optional[IssueSolution] = None) -> IssueMetadata:
299
+ def update_issue(
300
+ issues_root: Path,
301
+ issue_id: str,
302
+ status: Optional[IssueStatus] = None,
303
+ stage: Optional[IssueStage] = None,
304
+ solution: Optional[IssueSolution] = None,
305
+ title: Optional[str] = None,
306
+ parent: Optional[str] = None,
307
+ sprint: Optional[str] = None,
308
+ dependencies: Optional[List[str]] = None,
309
+ related: Optional[List[str]] = None,
310
+ tags: Optional[List[str]] = None,
311
+ files: Optional[List[str]] = None
312
+ ) -> IssueMetadata:
248
313
  path = find_issue_path(issues_root, issue_id)
249
314
  if not path:
250
315
  raise FileNotFoundError(f"Issue {issue_id} not found.")
@@ -253,7 +318,7 @@ def update_issue(issues_root: Path, issue_id: str, status: Optional[IssueStatus]
253
318
  content = path.read_text()
254
319
 
255
320
  # Split Frontmatter and Body
256
- match = re.search(r"^---(.*?)---\n(.*)$\n", content, re.DOTALL | re.MULTILINE)
321
+ match = re.search(r"^---(.*?)---\n(.*)\n", content, re.DOTALL | re.MULTILINE)
257
322
  if not match:
258
323
  # Fallback
259
324
  match_simple = re.search(r"^---(.*?)---", content, re.DOTALL | re.MULTILINE)
@@ -277,50 +342,100 @@ def update_issue(issues_root: Path, issue_id: str, status: Optional[IssueStatus]
277
342
  current_status = IssueStatus(current_status_str.lower())
278
343
  except ValueError:
279
344
  current_status = IssueStatus.OPEN
345
+
346
+ current_stage_str = data.get("stage")
347
+ current_stage = IssueStage(current_stage_str.lower()) if current_stage_str else None
280
348
 
281
349
  # Logic: Status Update
282
350
  target_status = status if status else current_status
283
351
 
284
- # Validation: For closing
285
- effective_solution = solution.value if solution else data.get("solution")
352
+ # If status is changing, we don't default target_stage to current_stage
353
+ # because the new status might have different allowed stages.
354
+ # enforce_policy will handle setting the correct default stage for the new status.
355
+ if status and status != current_status:
356
+ target_stage = stage
357
+ else:
358
+ target_stage = stage if stage else current_stage
286
359
 
287
- # Policy: Prevent Backlog -> Review
288
- if stage == IssueStage.REVIEW and current_status == IssueStatus.BACKLOG:
289
- raise ValueError(f"Lifecycle Policy: Cannot submit Backlog issue directly. Run `monoco issue pull {issue_id}` first.")
360
+ # Engine Validation
361
+ from .engine import get_engine
362
+ engine = get_engine()
363
+
364
+ # Map solution string to enum if present
365
+ effective_solution = solution
366
+ if not effective_solution and data.get("solution"):
367
+ try:
368
+ effective_solution = IssueSolution(data.get("solution").lower())
369
+ except ValueError:
370
+ pass
290
371
 
291
- if target_status == IssueStatus.CLOSED:
292
- if not effective_solution:
293
- raise ValueError(f"Closing an issue requires a solution. Please provide --solution or edit the file metadata.")
294
-
295
- current_data_stage = data.get('stage')
296
-
297
- # Policy: IMPLEMENTED requires REVIEW stage
298
- if effective_solution == IssueSolution.IMPLEMENTED.value:
299
- if current_data_stage != IssueStage.REVIEW.value:
300
- raise ValueError(f"Lifecycle Policy: 'Implemented' issues must be submitted for review first.\nCurrent stage: {current_data_stage}\nAction: Run `monoco issue submit {issue_id}`.")
301
-
302
- # Policy: No closing from DOING (General Safety)
303
- if current_data_stage == IssueStage.DOING.value:
304
- raise ValueError("Cannot close issue in progress (Doing). Please review (`monoco issue submit`) or stop (`monoco issue open`) first.")
305
-
372
+ # Use engine to validate the transition
373
+ engine.validate_transition(
374
+ from_status=current_status,
375
+ from_stage=current_stage,
376
+ to_status=target_status,
377
+ to_stage=target_stage,
378
+ solution=effective_solution
379
+ )
380
+
381
+ if target_status == "closed":
306
382
  # Policy: Dependencies must be closed
307
- dependencies = data.get('dependencies', [])
308
- if dependencies:
309
- for dep_id in dependencies:
383
+ dependencies_to_check = dependencies if dependencies is not None else data.get('dependencies', [])
384
+ if dependencies_to_check:
385
+ for dep_id in dependencies_to_check:
310
386
  dep_path = find_issue_path(issues_root, dep_id)
311
387
  if dep_path:
312
388
  dep_meta = parse_issue(dep_path)
313
- if dep_meta and dep_meta.status != IssueStatus.CLOSED:
314
- raise ValueError(f"Dependency Block: Cannot close {issue_id} because dependency {dep_id} is [Status: {dep_meta.status.value}].")
389
+ if dep_meta and dep_meta.status != "closed":
390
+ raise ValueError(f"Dependency Block: Cannot close {issue_id} because dependency {dep_id} is [Status: {dep_meta.status}].")
391
+
392
+ # Validate new parent/dependencies/related exist
393
+ if parent is not None and parent != "":
394
+ if not find_issue_path(issues_root, parent):
395
+ raise ValueError(f"Parent issue {parent} not found.")
396
+
397
+ if dependencies is not None:
398
+ for dep_id in dependencies:
399
+ if not find_issue_path(issues_root, dep_id):
400
+ raise ValueError(f"Dependency issue {dep_id} not found.")
401
+
402
+ if related is not None:
403
+ for rel_id in related:
404
+ if not find_issue_path(issues_root, rel_id):
405
+ raise ValueError(f"Related issue {rel_id} not found.")
315
406
 
316
407
  # Update Data
317
408
  if status:
318
- data['status'] = status.value
409
+ data['status'] = status
319
410
 
320
411
  if stage:
321
- data['stage'] = stage.value
412
+ data['stage'] = stage
322
413
  if solution:
323
- data['solution'] = solution.value
414
+ data['solution'] = solution
415
+
416
+ if title:
417
+ data['title'] = title
418
+
419
+ if parent is not None:
420
+ if parent == "":
421
+ data.pop('parent', None) # Remove parent field
422
+ else:
423
+ data['parent'] = parent
424
+
425
+ if sprint is not None:
426
+ data['sprint'] = sprint
427
+
428
+ if dependencies is not None:
429
+ data['dependencies'] = dependencies
430
+
431
+ if related is not None:
432
+ data['related'] = related
433
+
434
+ if tags is not None:
435
+ data['tags'] = tags
436
+
437
+ if files is not None:
438
+ data['files'] = files
324
439
 
325
440
  # Lifecycle Hooks
326
441
  # 1. Opened At: If transitioning to OPEN
@@ -336,14 +451,31 @@ def update_issue(issues_root: Path, issue_id: str, status: Optional[IssueStatus]
336
451
  # Touch updated_at
337
452
  data['updated_at'] = current_time()
338
453
 
339
- # Re-hydrate through Model to trigger Logic (Stage, ClosedAt defaults)
454
+ # Re-hydrate through Model
340
455
  try:
341
456
  updated_meta = IssueMetadata(**data)
457
+
458
+ # Enforce lifecycle policies (defaults, auto-corrections)
459
+ # This ensures that when we update, we also fix invalid states (like Closed but not Done)
460
+ from .engine import get_engine
461
+ get_engine().enforce_policy(updated_meta)
462
+
463
+ # Delegate to IssueValidator for static state validation
464
+ # We need to construct the full content to validate body-dependent rules (like checkboxes)
465
+ # Note: 'body' here is the OLD body. We assume update_issue doesn't change body.
466
+ # If body is invalid (unchecked boxes) and we move to DONE, this MUST fail.
467
+ validator = IssueValidator(issues_root)
468
+ diagnostics = validator.validate(updated_meta, body)
469
+ errors = [d for d in diagnostics if d.severity == DiagnosticSeverity.Error]
470
+ if errors:
471
+ raise ValueError(f"Validation Failed: {errors[0].message}")
472
+
342
473
  except Exception as e:
343
474
  raise ValueError(f"Failed to validate updated metadata: {e}")
344
475
 
345
476
  # Serialize back
346
- new_yaml = yaml.dump(updated_meta.model_dump(exclude_none=True, mode='json'), sort_keys=False, allow_unicode=True)
477
+ # Explicitly exclude actions and path from file persistence
478
+ new_yaml = yaml.dump(updated_meta.model_dump(exclude_none=True, mode='json', exclude={'actions', 'path'}), sort_keys=False, allow_unicode=True)
347
479
 
348
480
  # Reconstruct File
349
481
  match_header = re.search(r"^---(.*?)---", content, re.DOTALL | re.MULTILINE)
@@ -363,7 +495,8 @@ def update_issue(issues_root: Path, issue_id: str, status: Optional[IssueStatus]
363
495
  if status and status != current_status:
364
496
  # Move file
365
497
  prefix = issue_id.split("-")[0].upper()
366
- base_type_dir = get_issue_dir(REVERSE_PREFIX_MAP[prefix], issues_root)
498
+ reverse_prefix_map = get_reverse_prefix_map(issues_root)
499
+ base_type_dir = get_issue_dir(reverse_prefix_map[prefix], issues_root)
367
500
 
368
501
  try:
369
502
  rel_path = path.relative_to(base_type_dir)
@@ -371,19 +504,23 @@ def update_issue(issues_root: Path, issue_id: str, status: Optional[IssueStatus]
371
504
  except ValueError:
372
505
  structure_path = Path(path.name)
373
506
 
374
- target_path = base_type_dir / target_status.value / structure_path
507
+ target_path = base_type_dir / target_status / structure_path
375
508
 
376
509
  if path != target_path:
377
510
  target_path.parent.mkdir(parents=True, exist_ok=True)
378
511
  path.rename(target_path)
512
+ path = target_path # Update local path variable for returned meta
379
513
 
380
514
  # Hook: Recursive Aggregation (FEAT-0003)
381
515
  if updated_meta.parent:
382
516
  recalculate_parent(issues_root, updated_meta.parent)
383
-
517
+
518
+ # Update returned metadata with final absolute path
519
+ updated_meta.path = str(path.absolute())
520
+ updated_meta.actions = get_available_actions(updated_meta)
384
521
  return updated_meta
385
522
 
386
- def start_issue_isolation(issues_root: Path, issue_id: str, mode: IsolationType, project_root: Path) -> IssueMetadata:
523
+ def start_issue_isolation(issues_root: Path, issue_id: str, mode: str, project_root: Path) -> IssueMetadata:
387
524
  """
388
525
  Start physical isolation for an issue (Branch or Worktree).
389
526
  """
@@ -392,6 +529,8 @@ def start_issue_isolation(issues_root: Path, issue_id: str, mode: IsolationType,
392
529
  raise FileNotFoundError(f"Issue {issue_id} not found.")
393
530
 
394
531
  issue = parse_issue(path)
532
+ if not issue:
533
+ raise ValueError(f"Could not parse metadata for issue {issue_id}")
395
534
 
396
535
  # Idempotency / Conflict Check
397
536
  if issue.isolation:
@@ -409,7 +548,7 @@ def start_issue_isolation(issues_root: Path, issue_id: str, mode: IsolationType,
409
548
 
410
549
  isolation_meta = None
411
550
 
412
- if mode == IsolationType.BRANCH:
551
+ if mode == "branch":
413
552
  if not git.branch_exists(project_root, branch_name):
414
553
  git.create_branch(project_root, branch_name, checkout=True)
415
554
  else:
@@ -419,9 +558,9 @@ def start_issue_isolation(issues_root: Path, issue_id: str, mode: IsolationType,
419
558
  if current != branch_name:
420
559
  git.checkout_branch(project_root, branch_name)
421
560
 
422
- isolation_meta = IssueIsolation(type=IsolationType.BRANCH, ref=branch_name)
561
+ isolation_meta = IssueIsolation(type="branch", ref=branch_name)
423
562
 
424
- elif mode == IsolationType.WORKTREE:
563
+ elif mode == "worktree":
425
564
  wt_path = project_root / ".monoco" / "worktrees" / f"{issue_id.lower()}-{slug}"
426
565
 
427
566
  # Check if worktree exists physically
@@ -432,7 +571,7 @@ def start_issue_isolation(issues_root: Path, issue_id: str, mode: IsolationType,
432
571
  wt_path.parent.mkdir(parents=True, exist_ok=True)
433
572
  git.worktree_add(project_root, branch_name, wt_path)
434
573
 
435
- isolation_meta = IssueIsolation(type=IsolationType.WORKTREE, ref=branch_name, path=str(wt_path))
574
+ isolation_meta = IssueIsolation(type="worktree", ref=branch_name, path=str(wt_path))
436
575
 
437
576
  # Persist Metadata
438
577
  # We load raw, update isolation field, save.
@@ -444,7 +583,7 @@ def start_issue_isolation(issues_root: Path, issue_id: str, mode: IsolationType,
444
583
 
445
584
  data['isolation'] = isolation_meta.model_dump(mode='json')
446
585
  # Also ensure stage is DOING (logic link)
447
- data['stage'] = IssueStage.DOING.value
586
+ data['stage'] = "doing"
448
587
  data['updated_at'] = current_time()
449
588
 
450
589
  new_yaml = yaml.dump(data, sort_keys=False, allow_unicode=True)
@@ -467,6 +606,9 @@ def prune_issue_resources(issues_root: Path, issue_id: str, force: bool, project
467
606
  raise FileNotFoundError(f"Issue {issue_id} not found.")
468
607
 
469
608
  issue = parse_issue(path)
609
+ if not issue:
610
+ raise ValueError(f"Could not parse metadata for issue {issue_id}")
611
+
470
612
  deleted_items = []
471
613
 
472
614
  if not issue.isolation:
@@ -532,6 +674,77 @@ def delete_issue_file(issues_root: Path, issue_id: str):
532
674
  raise FileNotFoundError(f"Issue {issue_id} not found.")
533
675
 
534
676
  path.unlink()
677
+
678
+ def sync_issue_files(issues_root: Path, issue_id: str, project_root: Path) -> List[str]:
679
+ """
680
+ Sync 'files' field in issue metadata with actual changed files in git.
681
+ Strategies:
682
+ 1. Isolation Ref: If issue has isolation (branch/worktree), use that ref.
683
+ 2. Convention: If no isolation, look for branch `*/<id>-*`.
684
+ 3. Current Branch: If current branch matches pattern.
685
+
686
+ Compares against default branch (usually 'main' or 'master').
687
+ """
688
+ path = find_issue_path(issues_root, issue_id)
689
+ if not path:
690
+ raise FileNotFoundError(f"Issue {issue_id} not found.")
691
+
692
+ issue = parse_issue(path)
693
+ if not issue:
694
+ raise ValueError(f"Could not parse issue {issue_id}")
695
+
696
+ # Determine Target Branch
697
+ target_ref = None
698
+
699
+ if issue.isolation and issue.isolation.ref:
700
+ target_ref = issue.isolation.ref
701
+ else:
702
+ # Heuristic Search
703
+ # 1. Is current branch related?
704
+ current = git.get_current_branch(project_root)
705
+ if issue_id.lower() in current.lower():
706
+ target_ref = current
707
+ else:
708
+ # 2. Search for branch
709
+ # Limitation: core.git doesn't list all branches yet.
710
+ # We skip this for now to avoid complexity, relying on isolation or current context.
711
+ pass
712
+
713
+ if not target_ref:
714
+ raise RuntimeError(f"Could not determine git branch for Issue {issue_id}. Please ensure issue is started or you are on the feature branch.")
715
+
716
+ # Determine Base Branch (assume main, or config?)
717
+ # For now hardcode main, eventually read from config
718
+ base_ref = "main"
719
+
720
+ # Check if base exists, if not try master
721
+ if not git.branch_exists(project_root, base_ref):
722
+ if git.branch_exists(project_root, "master"):
723
+ base_ref = "master"
724
+ else:
725
+ # Fallback: remote/main?
726
+ pass
727
+
728
+ # Git Diff
729
+ # git diff --name-only base...target
730
+ cmd = ["diff", "--name-only", f"{base_ref}...{target_ref}"]
731
+ code, stdout, stderr = git._run_git(cmd, project_root)
732
+
733
+ if code != 0:
734
+ raise RuntimeError(f"Git diff failed: {stderr}")
735
+
736
+ changed_files = [f.strip() for f in stdout.splitlines() if f.strip()]
737
+
738
+ # Sort for consistency
739
+ changed_files.sort()
740
+
741
+ # Update Issue
742
+ # Only update if changed
743
+ if changed_files != issue.files:
744
+ update_issue(issues_root, issue_id, files=changed_files)
745
+ return changed_files
746
+
747
+ return []
535
748
 
536
749
  # Resources
537
750
  SKILL_CONTENT = """
@@ -566,7 +779,7 @@ description: Monoco Issue System 的官方技能定义。将 Issue 视为通用
566
779
  - **Status Level (Lowercase)**: `open`, `backlog`, `closed`
567
780
 
568
781
  ### 路径流转
569
- 使用 `monoco issue`:
782
+ 使用 `monoco issue`:
570
783
  1. **Create**: `monoco issue create <type> --title "..."`
571
784
  2. **Transition**: `monoco issue open/close/backlog <id>`
572
785
  3. **View**: `monoco issue scope`
@@ -574,15 +787,6 @@ description: Monoco Issue System 的官方技能定义。将 Issue 视为通用
574
787
  5. **Modification**: `monoco issue start/submit/delete <id>`
575
788
  """
576
789
 
577
- PROMPT_CONTENT = """
578
- ### Issue Management
579
- System for managing tasks using `monoco issue`.
580
- - **Create**: `monoco issue create <type> -t "Title"` (types: epic, feature, chore, fix)
581
- - **Status**: `monoco issue open|close|backlog <id>`
582
- - **Check**: `monoco issue lint` (Must run after manual edits)
583
- - **Lifecycle**: `monoco issue start|submit|delete <id>`
584
- - **Structure**: `Issues/{CapitalizedPluralType}/{lowercase_status}/` (e.g. `Issues/Features/open/`). Do not deviate.
585
- """
586
790
 
587
791
  def init(issues_root: Path):
588
792
  """Initialize the Issues directory structure."""
@@ -603,9 +807,7 @@ def get_resources() -> Dict[str, Any]:
603
807
  "skills": {
604
808
  "issues-management": SKILL_CONTENT
605
809
  },
606
- "prompts": {
607
- "issues-management": PROMPT_CONTENT
608
- }
810
+ "prompts": {} # Handled by adapter via resource files
609
811
  }
610
812
 
611
813
 
@@ -614,7 +816,10 @@ def list_issues(issues_root: Path, recursive_workspace: bool = False) -> List[Is
614
816
  List all issues in the project.
615
817
  """
616
818
  issues = []
617
- for issue_type in IssueType:
819
+ engine = get_engine(str(issues_root.parent))
820
+ all_types = engine.get_all_types()
821
+
822
+ for issue_type in all_types:
618
823
  base_dir = get_issue_dir(issue_type, issues_root)
619
824
  for status_dir in ["open", "backlog", "closed"]:
620
825
  d = base_dir / status_dir
@@ -640,6 +845,22 @@ def list_issues(issues_root: Path, recursive_workspace: bool = False) -> List[Is
640
845
  member_issues = list_issues(member_issues_dir, False)
641
846
  for m in member_issues:
642
847
  # Namespace the ID to avoid collisions and indicate origin
848
+ # CRITICAL: Also namespace references to keep parent-child structure intact
849
+ if m.parent and "::" not in m.parent:
850
+ m.parent = f"{name}::{m.parent}"
851
+
852
+ if m.dependencies:
853
+ m.dependencies = [
854
+ f"{name}::{d}" if d and "::" not in d else d
855
+ for d in m.dependencies
856
+ ]
857
+
858
+ if m.related:
859
+ m.related = [
860
+ f"{name}::{r}" if r and "::" not in r else r
861
+ for r in m.related
862
+ ]
863
+
643
864
  m.id = f"{name}::{m.id}"
644
865
  issues.append(m)
645
866
  except Exception:
@@ -653,21 +874,21 @@ def get_board_data(issues_root: Path) -> Dict[str, List[IssueMetadata]]:
653
874
  Get open issues grouped by their stage for Kanban view.
654
875
  """
655
876
  board = {
656
- IssueStage.TODO.value: [],
657
- IssueStage.DOING.value: [],
658
- IssueStage.REVIEW.value: [],
659
- IssueStage.DONE.value: []
877
+ "draft": [],
878
+ "doing": [],
879
+ "review": [],
880
+ "done": []
660
881
  }
661
882
 
662
883
  issues = list_issues(issues_root)
663
884
  for issue in issues:
664
- if issue.status == IssueStatus.OPEN and issue.stage:
665
- stage_val = issue.stage.value
885
+ if issue.status == "open" and issue.stage:
886
+ stage_val = issue.stage
666
887
  if stage_val in board:
667
888
  board[stage_val].append(issue)
668
- elif issue.status == IssueStatus.CLOSED:
889
+ elif issue.status == "closed":
669
890
  # Optionally show recently closed items in DONE column
670
- board[IssueStage.DONE.value].append(issue)
891
+ board["done"].append(issue)
671
892
 
672
893
  return board
673
894
 
@@ -677,14 +898,14 @@ def validate_issue_integrity(meta: IssueMetadata, all_issue_ids: Set[str] = set(
677
898
  UI-agnostic.
678
899
  """
679
900
  errors = []
680
- if meta.status == IssueStatus.CLOSED and not meta.solution:
901
+ if meta.status == "closed" and not meta.solution:
681
902
  errors.append(f"Solution Missing: {meta.id} is closed but has no solution field.")
682
903
 
683
904
  if meta.parent:
684
905
  if all_issue_ids and meta.parent not in all_issue_ids:
685
906
  errors.append(f"Broken Link: {meta.id} refers to non-existent parent {meta.parent}.")
686
907
 
687
- if meta.status == IssueStatus.BACKLOG and meta.stage != IssueStage.FREEZED:
908
+ if meta.status == "backlog" and meta.stage != "freezed":
688
909
  errors.append(f"Lifecycle Error: {meta.id} is backlog but stage is not freezed (found: {meta.stage}).")
689
910
 
690
911
  return errors
@@ -729,7 +950,8 @@ def update_issue_content(issues_root: Path, issue_id: str, new_content: str) ->
729
950
  # Reuse logic from update_issue (simplified)
730
951
 
731
952
  prefix = issue_id.split("-")[0].upper()
732
- base_type_dir = get_issue_dir(REVERSE_PREFIX_MAP[prefix], issues_root)
953
+ reverse_prefix_map = get_reverse_prefix_map(issues_root)
954
+ base_type_dir = get_issue_dir(reverse_prefix_map[prefix], issues_root)
733
955
 
734
956
  # Calculate structure path (preserve subdir)
735
957
  try:
@@ -742,7 +964,7 @@ def update_issue_content(issues_root: Path, issue_id: str, new_content: str) ->
742
964
  # Fallback if path is weird
743
965
  structure_path = Path(path.name)
744
966
 
745
- target_path = base_type_dir / meta.status.value / structure_path
967
+ target_path = base_type_dir / meta.status / structure_path
746
968
 
747
969
  if path != target_path:
748
970
  target_path.parent.mkdir(parents=True, exist_ok=True)
@@ -769,7 +991,10 @@ def generate_delivery_report(issues_root: Path, issue_id: str, project_root: Pat
769
991
  commits = git.search_commits_by_message(project_root, f"Ref: {issue_id}")
770
992
 
771
993
  if not commits:
772
- return parse_issue(path)
994
+ meta = parse_issue(path)
995
+ if not meta:
996
+ raise ValueError(f"Could not parse metadata for issue {issue_id}")
997
+ return meta
773
998
 
774
999
  # 2. Aggregate Data
775
1000
  all_files = set()
@@ -825,7 +1050,10 @@ def generate_delivery_report(issues_root: Path, issue_id: str, project_root: Pat
825
1050
  # We can add it to 'extra' or extend the model later.
826
1051
  # For now, just persisting the text is enough for FEAT-0002.
827
1052
 
828
- return parse_issue(path)
1053
+ meta = parse_issue(path)
1054
+ if not meta:
1055
+ raise ValueError(f"Could not parse metadata for issue {issue_id}")
1056
+ return meta
829
1057
 
830
1058
  def get_children(issues_root: Path, parent_id: str) -> List[IssueMetadata]:
831
1059
  """Find all direct children of an issue."""
@@ -888,9 +1116,9 @@ def check_issue_match(issue: IssueMetadata, explicit_positives: List[str], terms
888
1116
  searchable_parts = [
889
1117
  issue.id,
890
1118
  issue.title,
891
- issue.status.value,
892
- issue.type.value,
893
- str(issue.stage.value) if issue.stage else "",
1119
+ issue.status,
1120
+ issue.type,
1121
+ str(issue.stage) if issue.stage else "",
894
1122
  *(issue.tags or []),
895
1123
  *(issue.dependencies or []),
896
1124
  *(issue.related or []),
@@ -949,7 +1177,10 @@ def search_issues(issues_root: Path, query: str) -> List[IssueMetadata]:
949
1177
  # To support deep search (Body), we need to read files.
950
1178
  # Let's iterate files directly.
951
1179
 
952
- for issue_type in IssueType:
1180
+ engine = get_engine(str(issues_root.parent))
1181
+ all_types = engine.get_all_types()
1182
+
1183
+ for issue_type in all_types:
953
1184
  base_dir = get_issue_dir(issue_type, issues_root)
954
1185
  for status_dir in ["open", "backlog", "closed"]:
955
1186
  d = base_dir / status_dir
@@ -997,7 +1228,7 @@ def recalculate_parent(issues_root: Path, parent_id: str):
997
1228
  return
998
1229
 
999
1230
  total = len(children)
1000
- closed = len([c for c in children if c.status == IssueStatus.CLOSED])
1231
+ closed = len([c for c in children if c.status == "closed"])
1001
1232
  # Progress string: "3/5"
1002
1233
  progress_str = f"{closed}/{total}"
1003
1234
 
@@ -1033,12 +1264,12 @@ def recalculate_parent(issues_root: Path, parent_id: str):
1033
1264
  # FEAT-0003 Req: "If first child starts doing, auto-start Parent?"
1034
1265
  # If parent is OPEN/TODO and child is DOING/REVIEW/DONE, set parent to DOING?
1035
1266
  current_status = data.get("status", "open").lower()
1036
- current_stage = data.get("stage", "todo").lower()
1267
+ current_stage = data.get("stage", "draft").lower()
1037
1268
 
1038
- if current_status == "open" and current_stage == "todo":
1269
+ if current_status == "open" and current_stage == "draft":
1039
1270
  # Check if any child is active
1040
- active_children = [c for c in children if c.status == IssueStatus.OPEN and c.stage != IssueStage.TODO]
1041
- closed_children = [c for c in children if c.status == IssueStatus.CLOSED]
1271
+ active_children = [c for c in children if c.status == "open" and c.stage != "draft"]
1272
+ closed_children = [c for c in children if c.status == "closed"]
1042
1273
 
1043
1274
  if active_children or closed_children:
1044
1275
  data["stage"] = "doing"
@@ -1120,7 +1351,7 @@ def move_issue(
1120
1351
 
1121
1352
  # 4. Construct target path
1122
1353
  target_type_dir = get_issue_dir(issue.type, target_issues_root)
1123
- target_status_dir = target_type_dir / issue.status.value
1354
+ target_status_dir = target_type_dir / issue.status
1124
1355
 
1125
1356
  # Preserve subdirectory structure if any
1126
1357
  try:
@@ -1143,7 +1374,7 @@ def move_issue(
1143
1374
  # 5. Update content if ID changed
1144
1375
  if new_id != old_id:
1145
1376
  # Update frontmatter
1146
- content = issue.raw_content
1377
+ content = issue.raw_content or ""
1147
1378
  match = re.search(r"^---(.*?)---", content, re.DOTALL | re.MULTILINE)
1148
1379
  if match:
1149
1380
  yaml_str = match.group(1)
@@ -1159,9 +1390,9 @@ def move_issue(
1159
1390
 
1160
1391
  new_content = f"---\n{new_yaml}---{body}"
1161
1392
  else:
1162
- new_content = issue.raw_content
1393
+ new_content = issue.raw_content or ""
1163
1394
  else:
1164
- new_content = issue.raw_content
1395
+ new_content = issue.raw_content or ""
1165
1396
 
1166
1397
  # 6. Write to target
1167
1398
  target_path.write_text(new_content)
@@ -1171,4 +1402,7 @@ def move_issue(
1171
1402
 
1172
1403
  # 8. Return updated metadata
1173
1404
  final_meta = parse_issue(target_path)
1405
+ if not final_meta:
1406
+ raise ValueError(f"Failed to parse moved issue at {target_path}")
1407
+
1174
1408
  return final_meta, target_path