monoco-toolkit 0.2.5__py3-none-any.whl → 0.2.7__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 (39) hide show
  1. monoco/core/agent/adapters.py +24 -1
  2. monoco/core/config.py +77 -17
  3. monoco/core/integrations.py +8 -0
  4. monoco/core/lsp.py +7 -0
  5. monoco/core/output.py +8 -1
  6. monoco/core/resources/zh/SKILL.md +6 -7
  7. monoco/core/setup.py +8 -0
  8. monoco/features/i18n/resources/zh/SKILL.md +5 -5
  9. monoco/features/issue/commands.py +135 -55
  10. monoco/features/issue/core.py +157 -122
  11. monoco/features/issue/domain/__init__.py +0 -0
  12. monoco/features/issue/domain/lifecycle.py +126 -0
  13. monoco/features/issue/domain/models.py +170 -0
  14. monoco/features/issue/domain/parser.py +223 -0
  15. monoco/features/issue/domain/workspace.py +104 -0
  16. monoco/features/issue/engine/__init__.py +22 -0
  17. monoco/features/issue/engine/config.py +172 -0
  18. monoco/features/issue/engine/machine.py +185 -0
  19. monoco/features/issue/engine/models.py +18 -0
  20. monoco/features/issue/linter.py +32 -11
  21. monoco/features/issue/lsp/__init__.py +3 -0
  22. monoco/features/issue/lsp/definition.py +72 -0
  23. monoco/features/issue/models.py +26 -9
  24. monoco/features/issue/resources/zh/SKILL.md +8 -9
  25. monoco/features/issue/validator.py +181 -65
  26. monoco/features/spike/core.py +5 -22
  27. monoco/features/spike/resources/zh/SKILL.md +2 -2
  28. monoco/main.py +2 -26
  29. monoco_toolkit-0.2.7.dist-info/METADATA +129 -0
  30. {monoco_toolkit-0.2.5.dist-info → monoco_toolkit-0.2.7.dist-info}/RECORD +33 -27
  31. monoco/features/agent/commands.py +0 -166
  32. monoco/features/agent/doctor.py +0 -30
  33. monoco/features/pty/core.py +0 -185
  34. monoco/features/pty/router.py +0 -138
  35. monoco/features/pty/server.py +0 -56
  36. monoco_toolkit-0.2.5.dist-info/METADATA +0 -93
  37. {monoco_toolkit-0.2.5.dist-info → monoco_toolkit-0.2.7.dist-info}/WHEEL +0 -0
  38. {monoco_toolkit-0.2.5.dist-info → monoco_toolkit-0.2.7.dist-info}/entry_points.txt +0 -0
  39. {monoco_toolkit-0.2.5.dist-info → monoco_toolkit-0.2.7.dist-info}/licenses/LICENSE +0 -0
@@ -10,35 +10,21 @@ from monoco.core.config import get_config, MonocoConfig
10
10
  from monoco.core.lsp import DiagnosticSeverity
11
11
  from .validator import IssueValidator
12
12
 
13
- PREFIX_MAP = {
14
- IssueType.EPIC: "EPIC",
15
- IssueType.FEATURE: "FEAT",
16
- IssueType.CHORE: "CHORE",
17
- IssueType.FIX: "FIX"
18
- }
13
+ from .engine import get_engine
19
14
 
20
- 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()
21
18
 
22
- def enforce_lifecycle_policy(meta: IssueMetadata) -> None:
23
- """
24
- Apply business rules to ensure IssueMetadata consistency.
25
- Should be called during Create or Update (but NOT during Read/Lint).
26
- """
27
- if meta.status == IssueStatus.BACKLOG:
28
- meta.stage = IssueStage.FREEZED
29
-
30
- elif meta.status == IssueStatus.CLOSED:
31
- # Enforce stage=done for closed issues
32
- if meta.stage != IssueStage.DONE:
33
- meta.stage = IssueStage.DONE
34
- # Auto-fill closed_at if missing
35
- if not meta.closed_at:
36
- meta.closed_at = current_time()
37
-
38
- elif meta.status == IssueStatus.OPEN:
39
- # Ensure valid stage for open status
40
- if meta.stage is None:
41
- meta.stage = IssueStage.DRAFT
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
42
28
 
43
29
  def _get_slug(title: str) -> str:
44
30
  slug = title.lower()
@@ -52,15 +38,6 @@ def _get_slug(title: str) -> str:
52
38
 
53
39
  return slug
54
40
 
55
- def get_issue_dir(issue_type: IssueType, issues_root: Path) -> Path:
56
- mapping = {
57
- IssueType.EPIC: "Epics",
58
- IssueType.FEATURE: "Features",
59
- IssueType.CHORE: "Chores",
60
- IssueType.FIX: "Fixes",
61
- }
62
- return issues_root / mapping[issue_type]
63
-
64
41
  def parse_issue(file_path: Path) -> Optional[IssueMetadata]:
65
42
  if not file_path.suffix == ".md":
66
43
  return None
@@ -105,8 +82,9 @@ def parse_issue_detail(file_path: Path) -> Optional[IssueDetail]:
105
82
  except Exception:
106
83
  return None
107
84
 
108
- def find_next_id(issue_type: IssueType, issues_root: Path) -> str:
109
- 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")
110
88
  pattern = re.compile(rf"{prefix}-(\d+)")
111
89
  max_id = 0
112
90
 
@@ -147,7 +125,7 @@ def create_issue_file(
147
125
 
148
126
  issue_id = find_next_id(issue_type, issues_root)
149
127
  base_type_dir = get_issue_dir(issue_type, issues_root)
150
- target_dir = base_type_dir / status.value
128
+ target_dir = base_type_dir / status
151
129
 
152
130
  if subdir:
153
131
  target_dir = target_dir / subdir
@@ -170,24 +148,50 @@ def create_issue_file(
170
148
  )
171
149
 
172
150
  # Enforce lifecycle policies (defaults, auto-corrections)
173
- enforce_lifecycle_policy(metadata)
151
+ from .engine import get_engine
152
+ get_engine().enforce_policy(metadata)
153
+
154
+ # Serialize metadata
155
+ # Explicitly exclude actions and path from file persistence
156
+ yaml_header = yaml.dump(metadata.model_dump(exclude_none=True, mode='json', exclude={'actions', 'path'}), sort_keys=False, allow_unicode=True)
157
+
158
+ # Inject Self-Documenting Hints (Interactive Frontmatter)
159
+ if "parent:" not in yaml_header:
160
+ yaml_header += "# parent: <EPIC-ID> # Optional: Parent Issue ID\n"
161
+ if "solution:" not in yaml_header:
162
+ yaml_header += "# solution: null # Required for Closed state (implemented, cancelled, etc.)\n"
174
163
 
175
- yaml_header = yaml.dump(metadata.model_dump(exclude_none=True, mode='json'), sort_keys=False, allow_unicode=True)
176
164
  slug = _get_slug(title)
177
165
  filename = f"{issue_id}-{slug}.md"
178
166
 
167
+ # Enhanced Template with Instructional Comments
179
168
  file_content = f"""---
180
169
  {yaml_header}---
181
170
 
182
171
  ## {issue_id}: {title}
183
172
 
184
173
  ## Objective
174
+ <!-- Describe the "Why" and "What" clearly. Focus on value. -->
185
175
 
186
176
  ## Acceptance Criteria
177
+ <!-- Define binary conditions for success. -->
178
+ - [ ] Criteria 1
187
179
 
188
180
  ## Technical Tasks
181
+ <!-- Breakdown into atomic steps. Use nested lists for sub-tasks. -->
189
182
 
190
- - [ ]
183
+ <!-- Status Syntax: -->
184
+ <!-- [ ] To Do -->
185
+ <!-- [/] Doing -->
186
+ <!-- [x] Done -->
187
+ <!-- [~] Cancelled -->
188
+ <!-- - [ ] Parent Task -->
189
+ <!-- - [ ] Sub Task -->
190
+
191
+ - [ ] Task 1
192
+
193
+ ## Review Comments
194
+ <!-- Required for Review/Done stage. Record review feedback here. -->
191
195
  """
192
196
  file_path = target_dir / filename
193
197
  file_path.write_text(file_content)
@@ -198,32 +202,23 @@ def create_issue_file(
198
202
  return metadata, file_path
199
203
  def get_available_actions(meta: IssueMetadata) -> List[Any]:
200
204
  from .models import IssueAction
205
+ from .engine import get_engine
206
+
207
+ engine = get_engine()
208
+ transitions = engine.get_available_transitions(meta)
209
+
201
210
  actions = []
202
-
203
- if meta.status == IssueStatus.OPEN:
204
- # Stage-based movements
205
- if meta.stage == IssueStage.DRAFT:
206
- actions.append(IssueAction(label="Start", target_status=IssueStatus.OPEN, target_stage=IssueStage.DOING))
207
- actions.append(IssueAction(label="Freeze", target_status=IssueStatus.BACKLOG))
208
- elif meta.stage == IssueStage.DOING:
209
- actions.append(IssueAction(label="Stop", target_status=IssueStatus.OPEN, target_stage=IssueStage.DRAFT))
210
- actions.append(IssueAction(label="Submit", target_status=IssueStatus.OPEN, target_stage=IssueStage.REVIEW))
211
- elif meta.stage == IssueStage.REVIEW:
212
- actions.append(IssueAction(label="Approve", target_status=IssueStatus.CLOSED, target_stage=IssueStage.DONE, target_solution=IssueSolution.IMPLEMENTED))
213
- actions.append(IssueAction(label="Reject", target_status=IssueStatus.OPEN, target_stage=IssueStage.DOING))
214
- elif meta.stage == IssueStage.DONE:
215
- actions.append(IssueAction(label="Reopen", target_status=IssueStatus.OPEN, target_stage=IssueStage.DRAFT))
216
- actions.append(IssueAction(label="Close", target_status=IssueStatus.CLOSED, target_stage=IssueStage.DONE, target_solution=IssueSolution.IMPLEMENTED))
211
+ for t in transitions:
212
+ command = t.command_template.format(id=meta.id) if t.command_template else ""
217
213
 
218
- # Generic cancel
219
- actions.append(IssueAction(label="Cancel", target_status=IssueStatus.CLOSED, target_stage=IssueStage.DONE, target_solution=IssueSolution.CANCELLED))
220
-
221
- elif meta.status == IssueStatus.BACKLOG:
222
- actions.append(IssueAction(label="Pull", target_status=IssueStatus.OPEN, target_stage=IssueStage.DRAFT))
223
- actions.append(IssueAction(label="Cancel", target_status=IssueStatus.CLOSED, target_stage=IssueStage.DONE, target_solution=IssueSolution.CANCELLED))
224
-
225
- elif meta.status == IssueStatus.CLOSED:
226
- actions.append(IssueAction(label="Reopen", target_status=IssueStatus.OPEN, target_stage=IssueStage.DRAFT))
214
+ actions.append(IssueAction(
215
+ label=t.label,
216
+ icon=t.icon,
217
+ target_status=t.to_status if t.to_status != meta.status or t.to_stage != meta.stage else None,
218
+ target_stage=t.to_stage if t.to_stage != meta.stage else None,
219
+ target_solution=t.required_solution,
220
+ command=command
221
+ ))
227
222
 
228
223
  return actions
229
224
 
@@ -261,7 +256,8 @@ def find_issue_path(issues_root: Path, issue_id: str) -> Optional[Path]:
261
256
  except IndexError:
262
257
  return None
263
258
 
264
- issue_type = REVERSE_PREFIX_MAP.get(prefix)
259
+ reverse_prefix_map = get_reverse_prefix_map(issues_root)
260
+ issue_type = reverse_prefix_map.get(prefix)
265
261
  if not issue_type:
266
262
  return None
267
263
 
@@ -316,30 +312,43 @@ def update_issue(
316
312
  current_status = IssueStatus(current_status_str.lower())
317
313
  except ValueError:
318
314
  current_status = IssueStatus.OPEN
315
+
316
+ current_stage_str = data.get("stage")
317
+ current_stage = IssueStage(current_stage_str.lower()) if current_stage_str else None
319
318
 
320
319
  # Logic: Status Update
321
320
  target_status = status if status else current_status
322
321
 
323
- # Validation: For closing
324
- effective_solution = solution.value if solution else data.get("solution")
322
+ # If status is changing, we don't default target_stage to current_stage
323
+ # because the new status might have different allowed stages.
324
+ # enforce_policy will handle setting the correct default stage for the new status.
325
+ if status and status != current_status:
326
+ target_stage = stage
327
+ else:
328
+ target_stage = stage if stage else current_stage
329
+
330
+ # Engine Validation
331
+ from .engine import get_engine
332
+ engine = get_engine()
325
333
 
326
- # Policy: Prevent Backlog -> Review
327
- if stage == IssueStage.REVIEW and current_status == IssueStatus.BACKLOG:
328
- raise ValueError(f"Lifecycle Policy: Cannot submit Backlog issue directly. Run `monoco issue pull {issue_id}` first.")
334
+ # Map solution string to enum if present
335
+ effective_solution = solution
336
+ if not effective_solution and data.get("solution"):
337
+ try:
338
+ effective_solution = IssueSolution(data.get("solution").lower())
339
+ except ValueError:
340
+ pass
329
341
 
330
- if target_status == IssueStatus.CLOSED:
331
- # Validator will check solution presence
332
- current_data_stage = data.get('stage')
333
-
334
- # Policy: IMPLEMENTED requires REVIEW stage
335
- if effective_solution == IssueSolution.IMPLEMENTED.value:
336
- if current_data_stage != IssueStage.REVIEW.value:
337
- 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}`.")
338
-
339
- # Policy: No closing from DOING (General Safety)
340
- if current_data_stage == IssueStage.DOING.value:
341
- raise ValueError("Cannot close issue in progress (Doing). Please review (`monoco issue submit`) or stop (`monoco issue open`) first.")
342
-
342
+ # Use engine to validate the transition
343
+ engine.validate_transition(
344
+ from_status=current_status,
345
+ from_stage=current_stage,
346
+ to_status=target_status,
347
+ to_stage=target_stage,
348
+ solution=effective_solution
349
+ )
350
+
351
+ if target_status == "closed":
343
352
  # Policy: Dependencies must be closed
344
353
  dependencies_to_check = dependencies if dependencies is not None else data.get('dependencies', [])
345
354
  if dependencies_to_check:
@@ -347,8 +356,8 @@ def update_issue(
347
356
  dep_path = find_issue_path(issues_root, dep_id)
348
357
  if dep_path:
349
358
  dep_meta = parse_issue(dep_path)
350
- if dep_meta and dep_meta.status != IssueStatus.CLOSED:
351
- raise ValueError(f"Dependency Block: Cannot close {issue_id} because dependency {dep_id} is [Status: {dep_meta.status.value}].")
359
+ if dep_meta and dep_meta.status != "closed":
360
+ raise ValueError(f"Dependency Block: Cannot close {issue_id} because dependency {dep_id} is [Status: {dep_meta.status}].")
352
361
 
353
362
  # Validate new parent/dependencies/related exist
354
363
  if parent is not None and parent != "":
@@ -367,12 +376,12 @@ def update_issue(
367
376
 
368
377
  # Update Data
369
378
  if status:
370
- data['status'] = status.value
379
+ data['status'] = status
371
380
 
372
381
  if stage:
373
- data['stage'] = stage.value
382
+ data['stage'] = stage
374
383
  if solution:
375
- data['solution'] = solution.value
384
+ data['solution'] = solution
376
385
 
377
386
  if title:
378
387
  data['title'] = title
@@ -415,7 +424,8 @@ def update_issue(
415
424
 
416
425
  # Enforce lifecycle policies (defaults, auto-corrections)
417
426
  # This ensures that when we update, we also fix invalid states (like Closed but not Done)
418
- enforce_lifecycle_policy(updated_meta)
427
+ from .engine import get_engine
428
+ get_engine().enforce_policy(updated_meta)
419
429
 
420
430
  # Delegate to IssueValidator for static state validation
421
431
  # We need to construct the full content to validate body-dependent rules (like checkboxes)
@@ -431,7 +441,8 @@ def update_issue(
431
441
  raise ValueError(f"Failed to validate updated metadata: {e}")
432
442
 
433
443
  # Serialize back
434
- new_yaml = yaml.dump(updated_meta.model_dump(exclude_none=True, mode='json'), sort_keys=False, allow_unicode=True)
444
+ # Explicitly exclude actions and path from file persistence
445
+ new_yaml = yaml.dump(updated_meta.model_dump(exclude_none=True, mode='json', exclude={'actions', 'path'}), sort_keys=False, allow_unicode=True)
435
446
 
436
447
  # Reconstruct File
437
448
  match_header = re.search(r"^---(.*?)---", content, re.DOTALL | re.MULTILINE)
@@ -451,7 +462,8 @@ def update_issue(
451
462
  if status and status != current_status:
452
463
  # Move file
453
464
  prefix = issue_id.split("-")[0].upper()
454
- base_type_dir = get_issue_dir(REVERSE_PREFIX_MAP[prefix], issues_root)
465
+ reverse_prefix_map = get_reverse_prefix_map(issues_root)
466
+ base_type_dir = get_issue_dir(reverse_prefix_map[prefix], issues_root)
455
467
 
456
468
  try:
457
469
  rel_path = path.relative_to(base_type_dir)
@@ -459,7 +471,7 @@ def update_issue(
459
471
  except ValueError:
460
472
  structure_path = Path(path.name)
461
473
 
462
- target_path = base_type_dir / target_status.value / structure_path
474
+ target_path = base_type_dir / target_status / structure_path
463
475
 
464
476
  if path != target_path:
465
477
  target_path.parent.mkdir(parents=True, exist_ok=True)
@@ -475,7 +487,7 @@ def update_issue(
475
487
  updated_meta.actions = get_available_actions(updated_meta)
476
488
  return updated_meta
477
489
 
478
- def start_issue_isolation(issues_root: Path, issue_id: str, mode: IsolationType, project_root: Path) -> IssueMetadata:
490
+ def start_issue_isolation(issues_root: Path, issue_id: str, mode: str, project_root: Path) -> IssueMetadata:
479
491
  """
480
492
  Start physical isolation for an issue (Branch or Worktree).
481
493
  """
@@ -503,7 +515,7 @@ def start_issue_isolation(issues_root: Path, issue_id: str, mode: IsolationType,
503
515
 
504
516
  isolation_meta = None
505
517
 
506
- if mode == IsolationType.BRANCH:
518
+ if mode == "branch":
507
519
  if not git.branch_exists(project_root, branch_name):
508
520
  git.create_branch(project_root, branch_name, checkout=True)
509
521
  else:
@@ -513,9 +525,9 @@ def start_issue_isolation(issues_root: Path, issue_id: str, mode: IsolationType,
513
525
  if current != branch_name:
514
526
  git.checkout_branch(project_root, branch_name)
515
527
 
516
- isolation_meta = IssueIsolation(type=IsolationType.BRANCH, ref=branch_name)
528
+ isolation_meta = IssueIsolation(type="branch", ref=branch_name)
517
529
 
518
- elif mode == IsolationType.WORKTREE:
530
+ elif mode == "worktree":
519
531
  wt_path = project_root / ".monoco" / "worktrees" / f"{issue_id.lower()}-{slug}"
520
532
 
521
533
  # Check if worktree exists physically
@@ -526,7 +538,7 @@ def start_issue_isolation(issues_root: Path, issue_id: str, mode: IsolationType,
526
538
  wt_path.parent.mkdir(parents=True, exist_ok=True)
527
539
  git.worktree_add(project_root, branch_name, wt_path)
528
540
 
529
- isolation_meta = IssueIsolation(type=IsolationType.WORKTREE, ref=branch_name, path=str(wt_path))
541
+ isolation_meta = IssueIsolation(type="worktree", ref=branch_name, path=str(wt_path))
530
542
 
531
543
  # Persist Metadata
532
544
  # We load raw, update isolation field, save.
@@ -538,7 +550,7 @@ def start_issue_isolation(issues_root: Path, issue_id: str, mode: IsolationType,
538
550
 
539
551
  data['isolation'] = isolation_meta.model_dump(mode='json')
540
552
  # Also ensure stage is DOING (logic link)
541
- data['stage'] = IssueStage.DOING.value
553
+ data['stage'] = "doing"
542
554
  data['updated_at'] = current_time()
543
555
 
544
556
  new_yaml = yaml.dump(data, sort_keys=False, allow_unicode=True)
@@ -663,7 +675,7 @@ description: Monoco Issue System 的官方技能定义。将 Issue 视为通用
663
675
  - **Status Level (Lowercase)**: `open`, `backlog`, `closed`
664
676
 
665
677
  ### 路径流转
666
- 使用 `monoco issue`:
678
+ 使用 `monoco issue`:
667
679
  1. **Create**: `monoco issue create <type> --title "..."`
668
680
  2. **Transition**: `monoco issue open/close/backlog <id>`
669
681
  3. **View**: `monoco issue scope`
@@ -700,7 +712,10 @@ def list_issues(issues_root: Path, recursive_workspace: bool = False) -> List[Is
700
712
  List all issues in the project.
701
713
  """
702
714
  issues = []
703
- for issue_type in IssueType:
715
+ engine = get_engine(str(issues_root.parent))
716
+ all_types = engine.get_all_types()
717
+
718
+ for issue_type in all_types:
704
719
  base_dir = get_issue_dir(issue_type, issues_root)
705
720
  for status_dir in ["open", "backlog", "closed"]:
706
721
  d = base_dir / status_dir
@@ -726,6 +741,22 @@ def list_issues(issues_root: Path, recursive_workspace: bool = False) -> List[Is
726
741
  member_issues = list_issues(member_issues_dir, False)
727
742
  for m in member_issues:
728
743
  # Namespace the ID to avoid collisions and indicate origin
744
+ # CRITICAL: Also namespace references to keep parent-child structure intact
745
+ if m.parent and "::" not in m.parent:
746
+ m.parent = f"{name}::{m.parent}"
747
+
748
+ if m.dependencies:
749
+ m.dependencies = [
750
+ f"{name}::{d}" if d and "::" not in d else d
751
+ for d in m.dependencies
752
+ ]
753
+
754
+ if m.related:
755
+ m.related = [
756
+ f"{name}::{r}" if r and "::" not in r else r
757
+ for r in m.related
758
+ ]
759
+
729
760
  m.id = f"{name}::{m.id}"
730
761
  issues.append(m)
731
762
  except Exception:
@@ -739,21 +770,21 @@ def get_board_data(issues_root: Path) -> Dict[str, List[IssueMetadata]]:
739
770
  Get open issues grouped by their stage for Kanban view.
740
771
  """
741
772
  board = {
742
- IssueStage.DRAFT.value: [],
743
- IssueStage.DOING.value: [],
744
- IssueStage.REVIEW.value: [],
745
- IssueStage.DONE.value: []
773
+ "draft": [],
774
+ "doing": [],
775
+ "review": [],
776
+ "done": []
746
777
  }
747
778
 
748
779
  issues = list_issues(issues_root)
749
780
  for issue in issues:
750
- if issue.status == IssueStatus.OPEN and issue.stage:
751
- stage_val = issue.stage.value
781
+ if issue.status == "open" and issue.stage:
782
+ stage_val = issue.stage
752
783
  if stage_val in board:
753
784
  board[stage_val].append(issue)
754
- elif issue.status == IssueStatus.CLOSED:
785
+ elif issue.status == "closed":
755
786
  # Optionally show recently closed items in DONE column
756
- board[IssueStage.DONE.value].append(issue)
787
+ board["done"].append(issue)
757
788
 
758
789
  return board
759
790
 
@@ -763,14 +794,14 @@ def validate_issue_integrity(meta: IssueMetadata, all_issue_ids: Set[str] = set(
763
794
  UI-agnostic.
764
795
  """
765
796
  errors = []
766
- if meta.status == IssueStatus.CLOSED and not meta.solution:
797
+ if meta.status == "closed" and not meta.solution:
767
798
  errors.append(f"Solution Missing: {meta.id} is closed but has no solution field.")
768
799
 
769
800
  if meta.parent:
770
801
  if all_issue_ids and meta.parent not in all_issue_ids:
771
802
  errors.append(f"Broken Link: {meta.id} refers to non-existent parent {meta.parent}.")
772
803
 
773
- if meta.status == IssueStatus.BACKLOG and meta.stage != IssueStage.FREEZED:
804
+ if meta.status == "backlog" and meta.stage != "freezed":
774
805
  errors.append(f"Lifecycle Error: {meta.id} is backlog but stage is not freezed (found: {meta.stage}).")
775
806
 
776
807
  return errors
@@ -815,7 +846,8 @@ def update_issue_content(issues_root: Path, issue_id: str, new_content: str) ->
815
846
  # Reuse logic from update_issue (simplified)
816
847
 
817
848
  prefix = issue_id.split("-")[0].upper()
818
- base_type_dir = get_issue_dir(REVERSE_PREFIX_MAP[prefix], issues_root)
849
+ reverse_prefix_map = get_reverse_prefix_map(issues_root)
850
+ base_type_dir = get_issue_dir(reverse_prefix_map[prefix], issues_root)
819
851
 
820
852
  # Calculate structure path (preserve subdir)
821
853
  try:
@@ -828,7 +860,7 @@ def update_issue_content(issues_root: Path, issue_id: str, new_content: str) ->
828
860
  # Fallback if path is weird
829
861
  structure_path = Path(path.name)
830
862
 
831
- target_path = base_type_dir / meta.status.value / structure_path
863
+ target_path = base_type_dir / meta.status / structure_path
832
864
 
833
865
  if path != target_path:
834
866
  target_path.parent.mkdir(parents=True, exist_ok=True)
@@ -980,9 +1012,9 @@ def check_issue_match(issue: IssueMetadata, explicit_positives: List[str], terms
980
1012
  searchable_parts = [
981
1013
  issue.id,
982
1014
  issue.title,
983
- issue.status.value,
984
- issue.type.value,
985
- str(issue.stage.value) if issue.stage else "",
1015
+ issue.status,
1016
+ issue.type,
1017
+ str(issue.stage) if issue.stage else "",
986
1018
  *(issue.tags or []),
987
1019
  *(issue.dependencies or []),
988
1020
  *(issue.related or []),
@@ -1041,7 +1073,10 @@ def search_issues(issues_root: Path, query: str) -> List[IssueMetadata]:
1041
1073
  # To support deep search (Body), we need to read files.
1042
1074
  # Let's iterate files directly.
1043
1075
 
1044
- for issue_type in IssueType:
1076
+ engine = get_engine(str(issues_root.parent))
1077
+ all_types = engine.get_all_types()
1078
+
1079
+ for issue_type in all_types:
1045
1080
  base_dir = get_issue_dir(issue_type, issues_root)
1046
1081
  for status_dir in ["open", "backlog", "closed"]:
1047
1082
  d = base_dir / status_dir
@@ -1089,7 +1124,7 @@ def recalculate_parent(issues_root: Path, parent_id: str):
1089
1124
  return
1090
1125
 
1091
1126
  total = len(children)
1092
- closed = len([c for c in children if c.status == IssueStatus.CLOSED])
1127
+ closed = len([c for c in children if c.status == "closed"])
1093
1128
  # Progress string: "3/5"
1094
1129
  progress_str = f"{closed}/{total}"
1095
1130
 
@@ -1129,8 +1164,8 @@ def recalculate_parent(issues_root: Path, parent_id: str):
1129
1164
 
1130
1165
  if current_status == "open" and current_stage == "draft":
1131
1166
  # Check if any child is active
1132
- active_children = [c for c in children if c.status == IssueStatus.OPEN and c.stage != IssueStage.DRAFT]
1133
- closed_children = [c for c in children if c.status == IssueStatus.CLOSED]
1167
+ active_children = [c for c in children if c.status == "open" and c.stage != "draft"]
1168
+ closed_children = [c for c in children if c.status == "closed"]
1134
1169
 
1135
1170
  if active_children or closed_children:
1136
1171
  data["stage"] = "doing"
@@ -1212,7 +1247,7 @@ def move_issue(
1212
1247
 
1213
1248
  # 4. Construct target path
1214
1249
  target_type_dir = get_issue_dir(issue.type, target_issues_root)
1215
- target_status_dir = target_type_dir / issue.status.value
1250
+ target_status_dir = target_type_dir / issue.status
1216
1251
 
1217
1252
  # Preserve subdirectory structure if any
1218
1253
  try:
File without changes
@@ -0,0 +1,126 @@
1
+ from typing import List, Optional, Callable
2
+ from pydantic import BaseModel
3
+ from ..models import IssueStatus, IssueStage, IssueSolution, current_time
4
+ from .models import Issue
5
+
6
+ class Transition(BaseModel):
7
+ name: str
8
+ from_status: Optional[IssueStatus] = None # None means any
9
+ from_stage: Optional[IssueStage] = None # None means any
10
+ to_status: IssueStatus
11
+ to_stage: Optional[IssueStage] = None
12
+ required_solution: Optional[IssueSolution] = None
13
+ description: str = ""
14
+
15
+ def is_allowed(self, issue: Issue) -> bool:
16
+ if self.from_status and issue.status != self.from_status:
17
+ return False
18
+ if self.from_stage and issue.frontmatter.stage != self.from_stage:
19
+ return False
20
+ return True
21
+
22
+ class TransitionService:
23
+ def __init__(self):
24
+ self.transitions: List[Transition] = [
25
+ # Open -> Backlog
26
+ Transition(
27
+ name="freeze",
28
+ from_status=IssueStatus.OPEN,
29
+ to_status=IssueStatus.BACKLOG,
30
+ to_stage=IssueStage.FREEZED,
31
+ description="Move open issue to backlog"
32
+ ),
33
+ # Backlog -> Open
34
+ Transition(
35
+ name="activate",
36
+ from_status=IssueStatus.BACKLOG,
37
+ to_status=IssueStatus.OPEN,
38
+ to_stage=IssueStage.DRAFT, # Reset to draft?
39
+ description="Restore issue from backlog"
40
+ ),
41
+ # Open (Draft) -> Open (Doing)
42
+ Transition(
43
+ name="start",
44
+ from_status=IssueStatus.OPEN,
45
+ from_stage=IssueStage.DRAFT,
46
+ to_status=IssueStatus.OPEN,
47
+ to_stage=IssueStage.DOING,
48
+ description="Start working on the issue"
49
+ ),
50
+ # Open (Doing) -> Open (Review)
51
+ Transition(
52
+ name="submit",
53
+ from_status=IssueStatus.OPEN,
54
+ from_stage=IssueStage.DOING,
55
+ to_status=IssueStatus.OPEN,
56
+ to_stage=IssueStage.REVIEW,
57
+ description="Submit for review"
58
+ ),
59
+ # Open (Review) -> Open (Doing) - reject
60
+ Transition(
61
+ name="reject",
62
+ from_status=IssueStatus.OPEN,
63
+ from_stage=IssueStage.REVIEW,
64
+ to_status=IssueStatus.OPEN,
65
+ to_stage=IssueStage.DOING,
66
+ description="Reject review and return to doing"
67
+ ),
68
+ # Open (Review) -> Closed (Implemented)
69
+ Transition(
70
+ name="accept",
71
+ from_status=IssueStatus.OPEN,
72
+ from_stage=IssueStage.REVIEW,
73
+ to_status=IssueStatus.CLOSED,
74
+ to_stage=IssueStage.DONE,
75
+ required_solution=IssueSolution.IMPLEMENTED,
76
+ description="Accept and close issue"
77
+ ),
78
+ # Direct Close (Cancel, Wontfix, Duplicate)
79
+ Transition(
80
+ name="cancel",
81
+ to_status=IssueStatus.CLOSED,
82
+ to_stage=IssueStage.DONE,
83
+ required_solution=IssueSolution.CANCELLED,
84
+ description="Cancel the issue"
85
+ ),
86
+ Transition(
87
+ name="wontfix",
88
+ to_status=IssueStatus.CLOSED,
89
+ to_stage=IssueStage.DONE,
90
+ required_solution=IssueSolution.WONTFIX,
91
+ description="Mark as wontfix"
92
+ ),
93
+ ]
94
+
95
+ def get_available_transitions(self, issue: Issue) -> List[Transition]:
96
+ return [t for t in self.transitions if t.is_allowed(issue)]
97
+
98
+ def apply_transition(self, issue: Issue, transition_name: str) -> Issue:
99
+ # Find transition
100
+ candidates = [t for t in self.transitions if t.name == transition_name]
101
+ valid_transition = None
102
+ for t in candidates:
103
+ if t.is_allowed(issue):
104
+ valid_transition = t
105
+ break
106
+
107
+ if not valid_transition:
108
+ raise ValueError(f"Transition '{transition_name}' is not allowed for current state.")
109
+
110
+ # Apply changes
111
+ issue.frontmatter.status = valid_transition.to_status
112
+ if valid_transition.to_stage:
113
+ issue.frontmatter.stage = valid_transition.to_stage
114
+ if valid_transition.required_solution:
115
+ issue.frontmatter.solution = valid_transition.required_solution
116
+
117
+ issue.frontmatter.updated_at = current_time()
118
+
119
+ # Logic for closed_at, opened_at etc.
120
+ if valid_transition.to_status == IssueStatus.CLOSED and issue.frontmatter.closed_at is None:
121
+ issue.frontmatter.closed_at = current_time()
122
+
123
+ if valid_transition.to_status == IssueStatus.OPEN and issue.frontmatter.opened_at is None:
124
+ issue.frontmatter.opened_at = current_time()
125
+
126
+ return issue