monoco-toolkit 0.2.4__py3-none-any.whl → 0.2.6__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 (54) hide show
  1. monoco/cli/project.py +15 -7
  2. monoco/cli/workspace.py +11 -3
  3. monoco/core/agent/adapters.py +24 -1
  4. monoco/core/config.py +81 -3
  5. monoco/core/integrations.py +8 -0
  6. monoco/core/lsp.py +7 -0
  7. monoco/core/output.py +8 -1
  8. monoco/core/resources/en/SKILL.md +1 -1
  9. monoco/core/setup.py +8 -1
  10. monoco/daemon/app.py +18 -12
  11. monoco/features/agent/commands.py +94 -17
  12. monoco/features/agent/core.py +48 -0
  13. monoco/features/agent/resources/en/critique.prompty +16 -0
  14. monoco/features/agent/resources/en/develop.prompty +16 -0
  15. monoco/features/agent/resources/en/investigate.prompty +16 -0
  16. monoco/features/agent/resources/en/refine.prompty +14 -0
  17. monoco/features/agent/resources/en/verify.prompty +16 -0
  18. monoco/features/agent/resources/zh/critique.prompty +18 -0
  19. monoco/features/agent/resources/zh/develop.prompty +18 -0
  20. monoco/features/agent/resources/zh/investigate.prompty +18 -0
  21. monoco/features/agent/resources/zh/refine.prompty +16 -0
  22. monoco/features/agent/resources/zh/verify.prompty +18 -0
  23. monoco/features/config/commands.py +35 -14
  24. monoco/features/i18n/commands.py +89 -10
  25. monoco/features/i18n/core.py +112 -16
  26. monoco/features/issue/commands.py +254 -85
  27. monoco/features/issue/core.py +142 -119
  28. monoco/features/issue/domain/__init__.py +0 -0
  29. monoco/features/issue/domain/lifecycle.py +126 -0
  30. monoco/features/issue/domain/models.py +170 -0
  31. monoco/features/issue/domain/parser.py +223 -0
  32. monoco/features/issue/domain/workspace.py +104 -0
  33. monoco/features/issue/engine/__init__.py +22 -0
  34. monoco/features/issue/engine/config.py +189 -0
  35. monoco/features/issue/engine/machine.py +185 -0
  36. monoco/features/issue/engine/models.py +18 -0
  37. monoco/features/issue/linter.py +32 -11
  38. monoco/features/issue/lsp/__init__.py +3 -0
  39. monoco/features/issue/lsp/definition.py +72 -0
  40. monoco/features/issue/models.py +8 -8
  41. monoco/features/issue/validator.py +204 -65
  42. monoco/features/spike/commands.py +45 -24
  43. monoco/features/spike/core.py +5 -22
  44. monoco/main.py +11 -17
  45. {monoco_toolkit-0.2.4.dist-info → monoco_toolkit-0.2.6.dist-info}/METADATA +1 -1
  46. monoco_toolkit-0.2.6.dist-info/RECORD +96 -0
  47. monoco/features/issue/executions/refine.md +0 -26
  48. monoco/features/pty/core.py +0 -185
  49. monoco/features/pty/router.py +0 -138
  50. monoco/features/pty/server.py +0 -56
  51. monoco_toolkit-0.2.4.dist-info/RECORD +0 -78
  52. {monoco_toolkit-0.2.4.dist-info → monoco_toolkit-0.2.6.dist-info}/WHEEL +0 -0
  53. {monoco_toolkit-0.2.4.dist-info → monoco_toolkit-0.2.6.dist-info}/entry_points.txt +0 -0
  54. {monoco_toolkit-0.2.4.dist-info → monoco_toolkit-0.2.6.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,49 @@ 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)
174
153
 
154
+ # Serialize metadata
175
155
  yaml_header = yaml.dump(metadata.model_dump(exclude_none=True, mode='json'), sort_keys=False, allow_unicode=True)
156
+
157
+ # Inject Self-Documenting Hints (Interactive Frontmatter)
158
+ if "parent:" not in yaml_header:
159
+ yaml_header += "# parent: <EPIC-ID> # Optional: Parent Issue ID\n"
160
+ if "solution:" not in yaml_header:
161
+ yaml_header += "# solution: null # Required for Closed state (implemented, cancelled, etc.)\n"
162
+
176
163
  slug = _get_slug(title)
177
164
  filename = f"{issue_id}-{slug}.md"
178
165
 
166
+ # Enhanced Template with Instructional Comments
179
167
  file_content = f"""---
180
168
  {yaml_header}---
181
169
 
182
170
  ## {issue_id}: {title}
183
171
 
184
172
  ## Objective
173
+ <!-- Describe the "Why" and "What" clearly. Focus on value. -->
185
174
 
186
175
  ## Acceptance Criteria
176
+ <!-- Define binary conditions for success. -->
177
+ - [ ] Criteria 1
187
178
 
188
179
  ## Technical Tasks
180
+ <!-- Breakdown into atomic steps. Use nested lists for sub-tasks. -->
189
181
 
190
- - [ ]
182
+ <!-- Status Syntax: -->
183
+ <!-- [ ] To Do -->
184
+ <!-- [/] Doing -->
185
+ <!-- [x] Done -->
186
+ <!-- [~] Cancelled -->
187
+ <!-- - [ ] Parent Task -->
188
+ <!-- - [ ] Sub Task -->
189
+
190
+ - [ ] Task 1
191
+
192
+ ## Review Comments
193
+ <!-- Required for Review/Done stage. Record review feedback here. -->
191
194
  """
192
195
  file_path = target_dir / filename
193
196
  file_path.write_text(file_content)
@@ -198,32 +201,29 @@ def create_issue_file(
198
201
  return metadata, file_path
199
202
  def get_available_actions(meta: IssueMetadata) -> List[Any]:
200
203
  from .models import IssueAction
204
+ from .engine import get_engine
205
+
206
+ engine = get_engine()
207
+ transitions = engine.get_available_transitions(meta)
208
+
201
209
  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))
210
+ for t in transitions:
211
+ command = t.command_template.format(id=meta.id) if t.command_template else ""
217
212
 
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))
213
+ # Determine task if it's an agent action
214
+ task = None
215
+ if t.name == "develop":
216
+ task = "develop"
217
+
218
+ actions.append(IssueAction(
219
+ label=t.label,
220
+ icon=t.icon,
221
+ target_status=t.to_status if t.to_status != meta.status or t.to_stage != meta.stage else None,
222
+ target_stage=t.to_stage if t.to_stage != meta.stage else None,
223
+ target_solution=t.required_solution,
224
+ command=command,
225
+ task=task
226
+ ))
227
227
 
228
228
  return actions
229
229
 
@@ -261,7 +261,8 @@ def find_issue_path(issues_root: Path, issue_id: str) -> Optional[Path]:
261
261
  except IndexError:
262
262
  return None
263
263
 
264
- issue_type = REVERSE_PREFIX_MAP.get(prefix)
264
+ reverse_prefix_map = get_reverse_prefix_map(issues_root)
265
+ issue_type = reverse_prefix_map.get(prefix)
265
266
  if not issue_type:
266
267
  return None
267
268
 
@@ -316,30 +317,43 @@ def update_issue(
316
317
  current_status = IssueStatus(current_status_str.lower())
317
318
  except ValueError:
318
319
  current_status = IssueStatus.OPEN
320
+
321
+ current_stage_str = data.get("stage")
322
+ current_stage = IssueStage(current_stage_str.lower()) if current_stage_str else None
319
323
 
320
324
  # Logic: Status Update
321
325
  target_status = status if status else current_status
322
326
 
323
- # Validation: For closing
324
- effective_solution = solution.value if solution else data.get("solution")
327
+ # If status is changing, we don't default target_stage to current_stage
328
+ # because the new status might have different allowed stages.
329
+ # enforce_policy will handle setting the correct default stage for the new status.
330
+ if status and status != current_status:
331
+ target_stage = stage
332
+ else:
333
+ target_stage = stage if stage else current_stage
325
334
 
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.")
335
+ # Engine Validation
336
+ from .engine import get_engine
337
+ engine = get_engine()
338
+
339
+ # Map solution string to enum if present
340
+ effective_solution = solution
341
+ if not effective_solution and data.get("solution"):
342
+ try:
343
+ effective_solution = IssueSolution(data.get("solution").lower())
344
+ except ValueError:
345
+ pass
329
346
 
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
-
347
+ # Use engine to validate the transition
348
+ engine.validate_transition(
349
+ from_status=current_status,
350
+ from_stage=current_stage,
351
+ to_status=target_status,
352
+ to_stage=target_stage,
353
+ solution=effective_solution
354
+ )
355
+
356
+ if target_status == "closed":
343
357
  # Policy: Dependencies must be closed
344
358
  dependencies_to_check = dependencies if dependencies is not None else data.get('dependencies', [])
345
359
  if dependencies_to_check:
@@ -347,8 +361,8 @@ def update_issue(
347
361
  dep_path = find_issue_path(issues_root, dep_id)
348
362
  if dep_path:
349
363
  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}].")
364
+ if dep_meta and dep_meta.status != "closed":
365
+ raise ValueError(f"Dependency Block: Cannot close {issue_id} because dependency {dep_id} is [Status: {dep_meta.status}].")
352
366
 
353
367
  # Validate new parent/dependencies/related exist
354
368
  if parent is not None and parent != "":
@@ -367,12 +381,12 @@ def update_issue(
367
381
 
368
382
  # Update Data
369
383
  if status:
370
- data['status'] = status.value
384
+ data['status'] = status
371
385
 
372
386
  if stage:
373
- data['stage'] = stage.value
387
+ data['stage'] = stage
374
388
  if solution:
375
- data['solution'] = solution.value
389
+ data['solution'] = solution
376
390
 
377
391
  if title:
378
392
  data['title'] = title
@@ -415,7 +429,8 @@ def update_issue(
415
429
 
416
430
  # Enforce lifecycle policies (defaults, auto-corrections)
417
431
  # This ensures that when we update, we also fix invalid states (like Closed but not Done)
418
- enforce_lifecycle_policy(updated_meta)
432
+ from .engine import get_engine
433
+ get_engine().enforce_policy(updated_meta)
419
434
 
420
435
  # Delegate to IssueValidator for static state validation
421
436
  # We need to construct the full content to validate body-dependent rules (like checkboxes)
@@ -451,7 +466,8 @@ def update_issue(
451
466
  if status and status != current_status:
452
467
  # Move file
453
468
  prefix = issue_id.split("-")[0].upper()
454
- base_type_dir = get_issue_dir(REVERSE_PREFIX_MAP[prefix], issues_root)
469
+ reverse_prefix_map = get_reverse_prefix_map(issues_root)
470
+ base_type_dir = get_issue_dir(reverse_prefix_map[prefix], issues_root)
455
471
 
456
472
  try:
457
473
  rel_path = path.relative_to(base_type_dir)
@@ -459,7 +475,7 @@ def update_issue(
459
475
  except ValueError:
460
476
  structure_path = Path(path.name)
461
477
 
462
- target_path = base_type_dir / target_status.value / structure_path
478
+ target_path = base_type_dir / target_status / structure_path
463
479
 
464
480
  if path != target_path:
465
481
  target_path.parent.mkdir(parents=True, exist_ok=True)
@@ -475,7 +491,7 @@ def update_issue(
475
491
  updated_meta.actions = get_available_actions(updated_meta)
476
492
  return updated_meta
477
493
 
478
- def start_issue_isolation(issues_root: Path, issue_id: str, mode: IsolationType, project_root: Path) -> IssueMetadata:
494
+ def start_issue_isolation(issues_root: Path, issue_id: str, mode: str, project_root: Path) -> IssueMetadata:
479
495
  """
480
496
  Start physical isolation for an issue (Branch or Worktree).
481
497
  """
@@ -503,7 +519,7 @@ def start_issue_isolation(issues_root: Path, issue_id: str, mode: IsolationType,
503
519
 
504
520
  isolation_meta = None
505
521
 
506
- if mode == IsolationType.BRANCH:
522
+ if mode == "branch":
507
523
  if not git.branch_exists(project_root, branch_name):
508
524
  git.create_branch(project_root, branch_name, checkout=True)
509
525
  else:
@@ -513,9 +529,9 @@ def start_issue_isolation(issues_root: Path, issue_id: str, mode: IsolationType,
513
529
  if current != branch_name:
514
530
  git.checkout_branch(project_root, branch_name)
515
531
 
516
- isolation_meta = IssueIsolation(type=IsolationType.BRANCH, ref=branch_name)
532
+ isolation_meta = IssueIsolation(type="branch", ref=branch_name)
517
533
 
518
- elif mode == IsolationType.WORKTREE:
534
+ elif mode == "worktree":
519
535
  wt_path = project_root / ".monoco" / "worktrees" / f"{issue_id.lower()}-{slug}"
520
536
 
521
537
  # Check if worktree exists physically
@@ -526,7 +542,7 @@ def start_issue_isolation(issues_root: Path, issue_id: str, mode: IsolationType,
526
542
  wt_path.parent.mkdir(parents=True, exist_ok=True)
527
543
  git.worktree_add(project_root, branch_name, wt_path)
528
544
 
529
- isolation_meta = IssueIsolation(type=IsolationType.WORKTREE, ref=branch_name, path=str(wt_path))
545
+ isolation_meta = IssueIsolation(type="worktree", ref=branch_name, path=str(wt_path))
530
546
 
531
547
  # Persist Metadata
532
548
  # We load raw, update isolation field, save.
@@ -538,7 +554,7 @@ def start_issue_isolation(issues_root: Path, issue_id: str, mode: IsolationType,
538
554
 
539
555
  data['isolation'] = isolation_meta.model_dump(mode='json')
540
556
  # Also ensure stage is DOING (logic link)
541
- data['stage'] = IssueStage.DOING.value
557
+ data['stage'] = "doing"
542
558
  data['updated_at'] = current_time()
543
559
 
544
560
  new_yaml = yaml.dump(data, sort_keys=False, allow_unicode=True)
@@ -700,7 +716,10 @@ def list_issues(issues_root: Path, recursive_workspace: bool = False) -> List[Is
700
716
  List all issues in the project.
701
717
  """
702
718
  issues = []
703
- for issue_type in IssueType:
719
+ engine = get_engine(str(issues_root.parent))
720
+ all_types = engine.get_all_types()
721
+
722
+ for issue_type in all_types:
704
723
  base_dir = get_issue_dir(issue_type, issues_root)
705
724
  for status_dir in ["open", "backlog", "closed"]:
706
725
  d = base_dir / status_dir
@@ -739,21 +758,21 @@ def get_board_data(issues_root: Path) -> Dict[str, List[IssueMetadata]]:
739
758
  Get open issues grouped by their stage for Kanban view.
740
759
  """
741
760
  board = {
742
- IssueStage.DRAFT.value: [],
743
- IssueStage.DOING.value: [],
744
- IssueStage.REVIEW.value: [],
745
- IssueStage.DONE.value: []
761
+ "draft": [],
762
+ "doing": [],
763
+ "review": [],
764
+ "done": []
746
765
  }
747
766
 
748
767
  issues = list_issues(issues_root)
749
768
  for issue in issues:
750
- if issue.status == IssueStatus.OPEN and issue.stage:
751
- stage_val = issue.stage.value
769
+ if issue.status == "open" and issue.stage:
770
+ stage_val = issue.stage
752
771
  if stage_val in board:
753
772
  board[stage_val].append(issue)
754
- elif issue.status == IssueStatus.CLOSED:
773
+ elif issue.status == "closed":
755
774
  # Optionally show recently closed items in DONE column
756
- board[IssueStage.DONE.value].append(issue)
775
+ board["done"].append(issue)
757
776
 
758
777
  return board
759
778
 
@@ -763,14 +782,14 @@ def validate_issue_integrity(meta: IssueMetadata, all_issue_ids: Set[str] = set(
763
782
  UI-agnostic.
764
783
  """
765
784
  errors = []
766
- if meta.status == IssueStatus.CLOSED and not meta.solution:
785
+ if meta.status == "closed" and not meta.solution:
767
786
  errors.append(f"Solution Missing: {meta.id} is closed but has no solution field.")
768
787
 
769
788
  if meta.parent:
770
789
  if all_issue_ids and meta.parent not in all_issue_ids:
771
790
  errors.append(f"Broken Link: {meta.id} refers to non-existent parent {meta.parent}.")
772
791
 
773
- if meta.status == IssueStatus.BACKLOG and meta.stage != IssueStage.FREEZED:
792
+ if meta.status == "backlog" and meta.stage != "freezed":
774
793
  errors.append(f"Lifecycle Error: {meta.id} is backlog but stage is not freezed (found: {meta.stage}).")
775
794
 
776
795
  return errors
@@ -815,7 +834,8 @@ def update_issue_content(issues_root: Path, issue_id: str, new_content: str) ->
815
834
  # Reuse logic from update_issue (simplified)
816
835
 
817
836
  prefix = issue_id.split("-")[0].upper()
818
- base_type_dir = get_issue_dir(REVERSE_PREFIX_MAP[prefix], issues_root)
837
+ reverse_prefix_map = get_reverse_prefix_map(issues_root)
838
+ base_type_dir = get_issue_dir(reverse_prefix_map[prefix], issues_root)
819
839
 
820
840
  # Calculate structure path (preserve subdir)
821
841
  try:
@@ -828,7 +848,7 @@ def update_issue_content(issues_root: Path, issue_id: str, new_content: str) ->
828
848
  # Fallback if path is weird
829
849
  structure_path = Path(path.name)
830
850
 
831
- target_path = base_type_dir / meta.status.value / structure_path
851
+ target_path = base_type_dir / meta.status / structure_path
832
852
 
833
853
  if path != target_path:
834
854
  target_path.parent.mkdir(parents=True, exist_ok=True)
@@ -980,9 +1000,9 @@ def check_issue_match(issue: IssueMetadata, explicit_positives: List[str], terms
980
1000
  searchable_parts = [
981
1001
  issue.id,
982
1002
  issue.title,
983
- issue.status.value,
984
- issue.type.value,
985
- str(issue.stage.value) if issue.stage else "",
1003
+ issue.status,
1004
+ issue.type,
1005
+ str(issue.stage) if issue.stage else "",
986
1006
  *(issue.tags or []),
987
1007
  *(issue.dependencies or []),
988
1008
  *(issue.related or []),
@@ -1041,7 +1061,10 @@ def search_issues(issues_root: Path, query: str) -> List[IssueMetadata]:
1041
1061
  # To support deep search (Body), we need to read files.
1042
1062
  # Let's iterate files directly.
1043
1063
 
1044
- for issue_type in IssueType:
1064
+ engine = get_engine(str(issues_root.parent))
1065
+ all_types = engine.get_all_types()
1066
+
1067
+ for issue_type in all_types:
1045
1068
  base_dir = get_issue_dir(issue_type, issues_root)
1046
1069
  for status_dir in ["open", "backlog", "closed"]:
1047
1070
  d = base_dir / status_dir
@@ -1089,7 +1112,7 @@ def recalculate_parent(issues_root: Path, parent_id: str):
1089
1112
  return
1090
1113
 
1091
1114
  total = len(children)
1092
- closed = len([c for c in children if c.status == IssueStatus.CLOSED])
1115
+ closed = len([c for c in children if c.status == "closed"])
1093
1116
  # Progress string: "3/5"
1094
1117
  progress_str = f"{closed}/{total}"
1095
1118
 
@@ -1129,8 +1152,8 @@ def recalculate_parent(issues_root: Path, parent_id: str):
1129
1152
 
1130
1153
  if current_status == "open" and current_stage == "draft":
1131
1154
  # 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]
1155
+ active_children = [c for c in children if c.status == "open" and c.stage != "draft"]
1156
+ closed_children = [c for c in children if c.status == "closed"]
1134
1157
 
1135
1158
  if active_children or closed_children:
1136
1159
  data["stage"] = "doing"
@@ -1212,7 +1235,7 @@ def move_issue(
1212
1235
 
1213
1236
  # 4. Construct target path
1214
1237
  target_type_dir = get_issue_dir(issue.type, target_issues_root)
1215
- target_status_dir = target_type_dir / issue.status.value
1238
+ target_status_dir = target_type_dir / issue.status
1216
1239
 
1217
1240
  # Preserve subdirectory structure if any
1218
1241
  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