monoco-toolkit 0.1.0__py3-none-any.whl → 0.2.5__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 (69) 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 +106 -0
  7. monoco/core/agent/protocol.py +31 -0
  8. monoco/core/agent/state.py +106 -0
  9. monoco/core/config.py +152 -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 +234 -0
  15. monoco/core/lsp.py +61 -0
  16. monoco/core/output.py +13 -2
  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 +66 -0
  22. monoco/core/setup.py +88 -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/agent/commands.py +166 -0
  32. monoco/features/agent/doctor.py +30 -0
  33. monoco/features/config/commands.py +125 -44
  34. monoco/features/i18n/adapter.py +29 -0
  35. monoco/features/i18n/commands.py +89 -10
  36. monoco/features/i18n/core.py +113 -27
  37. monoco/features/i18n/resources/en/AGENTS.md +8 -0
  38. monoco/features/i18n/resources/en/SKILL.md +94 -0
  39. monoco/features/i18n/resources/zh/AGENTS.md +8 -0
  40. monoco/features/i18n/resources/zh/SKILL.md +94 -0
  41. monoco/features/issue/adapter.py +34 -0
  42. monoco/features/issue/commands.py +183 -65
  43. monoco/features/issue/core.py +172 -77
  44. monoco/features/issue/linter.py +215 -116
  45. monoco/features/issue/migration.py +134 -0
  46. monoco/features/issue/models.py +23 -19
  47. monoco/features/issue/monitor.py +94 -0
  48. monoco/features/issue/resources/en/AGENTS.md +15 -0
  49. monoco/features/issue/resources/en/SKILL.md +87 -0
  50. monoco/features/issue/resources/zh/AGENTS.md +15 -0
  51. monoco/features/issue/resources/zh/SKILL.md +114 -0
  52. monoco/features/issue/validator.py +269 -0
  53. monoco/features/pty/core.py +185 -0
  54. monoco/features/pty/router.py +138 -0
  55. monoco/features/pty/server.py +56 -0
  56. monoco/features/spike/adapter.py +30 -0
  57. monoco/features/spike/commands.py +45 -24
  58. monoco/features/spike/core.py +4 -21
  59. monoco/features/spike/resources/en/AGENTS.md +7 -0
  60. monoco/features/spike/resources/en/SKILL.md +74 -0
  61. monoco/features/spike/resources/zh/AGENTS.md +7 -0
  62. monoco/features/spike/resources/zh/SKILL.md +74 -0
  63. monoco/main.py +115 -2
  64. {monoco_toolkit-0.1.0.dist-info → monoco_toolkit-0.2.5.dist-info}/METADATA +10 -3
  65. monoco_toolkit-0.2.5.dist-info/RECORD +77 -0
  66. monoco_toolkit-0.1.0.dist-info/RECORD +0 -33
  67. {monoco_toolkit-0.1.0.dist-info → monoco_toolkit-0.2.5.dist-info}/WHEEL +0 -0
  68. {monoco_toolkit-0.1.0.dist-info → monoco_toolkit-0.2.5.dist-info}/entry_points.txt +0 -0
  69. {monoco_toolkit-0.1.0.dist-info → monoco_toolkit-0.2.5.dist-info}/licenses/LICENSE +0 -0
@@ -6,7 +6,9 @@ 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
13
  PREFIX_MAP = {
12
14
  IssueType.EPIC: "EPIC",
@@ -17,6 +19,27 @@ PREFIX_MAP = {
17
19
 
18
20
  REVERSE_PREFIX_MAP = {v: k for k, v in PREFIX_MAP.items()}
19
21
 
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
42
+
20
43
  def _get_slug(title: str) -> str:
21
44
  slug = title.lower()
22
45
  # Replace non-word characters (including punctuation, spaces) with hyphens
@@ -51,7 +74,11 @@ def parse_issue(file_path: Path) -> Optional[IssueMetadata]:
51
74
  data = yaml.safe_load(match.group(1))
52
75
  if not isinstance(data, dict):
53
76
  return None
54
- return IssueMetadata(**data)
77
+
78
+ data['path'] = str(file_path.absolute())
79
+ meta = IssueMetadata(**data)
80
+ meta.actions = get_available_actions(meta)
81
+ return meta
55
82
  except Exception:
56
83
  return None
57
84
 
@@ -72,6 +99,8 @@ def parse_issue_detail(file_path: Path) -> Optional[IssueDetail]:
72
99
  data = yaml.safe_load(yaml_str)
73
100
  if not isinstance(data, dict):
74
101
  return None
102
+
103
+ data['path'] = str(file_path.absolute())
75
104
  return IssueDetail(**data, body=body, raw_content=content)
76
105
  except Exception:
77
106
  return None
@@ -139,8 +168,10 @@ def create_issue_file(
139
168
  tags=tags,
140
169
  opened_at=current_time() if status == IssueStatus.OPEN else None
141
170
  )
142
-
143
171
 
172
+ # Enforce lifecycle policies (defaults, auto-corrections)
173
+ enforce_lifecycle_policy(metadata)
174
+
144
175
  yaml_header = yaml.dump(metadata.model_dump(exclude_none=True, mode='json'), sort_keys=False, allow_unicode=True)
145
176
  slug = _get_slug(title)
146
177
  filename = f"{issue_id}-{slug}.md"
@@ -161,60 +192,56 @@ def create_issue_file(
161
192
  file_path = target_dir / filename
162
193
  file_path.write_text(file_content)
163
194
 
195
+ # Inject path into returned metadata
196
+ metadata.path = str(file_path.absolute())
197
+
164
198
  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.")
199
+ def get_available_actions(meta: IssueMetadata) -> List[Any]:
200
+ from .models import IssueAction
201
+ 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))
217
+
218
+ # Generic cancel
219
+ actions.append(IssueAction(label="Cancel", target_status=IssueStatus.CLOSED, target_stage=IssueStage.DONE, target_solution=IssueSolution.CANCELLED))
181
220
 
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.")
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))
227
+
228
+ return actions
206
229
 
207
230
  def find_issue_path(issues_root: Path, issue_id: str) -> Optional[Path]:
208
231
  parsed = IssueID(issue_id)
209
232
 
210
233
  if not parsed.is_local:
234
+ if not parsed.namespace:
235
+ return None
236
+
211
237
  # Resolve Workspace
212
- # Assumption: issues_root is direct child of project_root.
213
- # This is a weak assumption but fits current architecture.
238
+ # Traverse up from issues_root to find a config that defines the namespace
214
239
  project_root = issues_root.parent
215
- conf = get_config(str(project_root))
216
240
 
241
+ # Try current root first
242
+ conf = MonocoConfig.load(str(project_root))
217
243
  member_rel_path = conf.project.members.get(parsed.namespace)
244
+
218
245
  if not member_rel_path:
219
246
  return None
220
247
 
@@ -244,7 +271,19 @@ def find_issue_path(issues_root: Path, issue_id: str) -> Optional[Path]:
244
271
  return f
245
272
  return None
246
273
 
247
- def update_issue(issues_root: Path, issue_id: str, status: Optional[IssueStatus] = None, stage: Optional[IssueStage] = None, solution: Optional[IssueSolution] = None) -> IssueMetadata:
274
+ def update_issue(
275
+ issues_root: Path,
276
+ issue_id: str,
277
+ status: Optional[IssueStatus] = None,
278
+ stage: Optional[IssueStage] = None,
279
+ solution: Optional[IssueSolution] = None,
280
+ title: Optional[str] = None,
281
+ parent: Optional[str] = None,
282
+ sprint: Optional[str] = None,
283
+ dependencies: Optional[List[str]] = None,
284
+ related: Optional[List[str]] = None,
285
+ tags: Optional[List[str]] = None
286
+ ) -> IssueMetadata:
248
287
  path = find_issue_path(issues_root, issue_id)
249
288
  if not path:
250
289
  raise FileNotFoundError(f"Issue {issue_id} not found.")
@@ -253,7 +292,7 @@ def update_issue(issues_root: Path, issue_id: str, status: Optional[IssueStatus]
253
292
  content = path.read_text()
254
293
 
255
294
  # Split Frontmatter and Body
256
- match = re.search(r"^---(.*?)---\n(.*)$\n", content, re.DOTALL | re.MULTILINE)
295
+ match = re.search(r"^---(.*?)---\n(.*)\n", content, re.DOTALL | re.MULTILINE)
257
296
  if not match:
258
297
  # Fallback
259
298
  match_simple = re.search(r"^---(.*?)---", content, re.DOTALL | re.MULTILINE)
@@ -289,9 +328,7 @@ def update_issue(issues_root: Path, issue_id: str, status: Optional[IssueStatus]
289
328
  raise ValueError(f"Lifecycle Policy: Cannot submit Backlog issue directly. Run `monoco issue pull {issue_id}` first.")
290
329
 
291
330
  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
-
331
+ # Validator will check solution presence
295
332
  current_data_stage = data.get('stage')
296
333
 
297
334
  # Policy: IMPLEMENTED requires REVIEW stage
@@ -304,14 +341,29 @@ def update_issue(issues_root: Path, issue_id: str, status: Optional[IssueStatus]
304
341
  raise ValueError("Cannot close issue in progress (Doing). Please review (`monoco issue submit`) or stop (`monoco issue open`) first.")
305
342
 
306
343
  # Policy: Dependencies must be closed
307
- dependencies = data.get('dependencies', [])
308
- if dependencies:
309
- for dep_id in dependencies:
344
+ dependencies_to_check = dependencies if dependencies is not None else data.get('dependencies', [])
345
+ if dependencies_to_check:
346
+ for dep_id in dependencies_to_check:
310
347
  dep_path = find_issue_path(issues_root, dep_id)
311
348
  if dep_path:
312
349
  dep_meta = parse_issue(dep_path)
313
350
  if dep_meta and dep_meta.status != IssueStatus.CLOSED:
314
351
  raise ValueError(f"Dependency Block: Cannot close {issue_id} because dependency {dep_id} is [Status: {dep_meta.status.value}].")
352
+
353
+ # Validate new parent/dependencies/related exist
354
+ if parent is not None and parent != "":
355
+ if not find_issue_path(issues_root, parent):
356
+ raise ValueError(f"Parent issue {parent} not found.")
357
+
358
+ if dependencies is not None:
359
+ for dep_id in dependencies:
360
+ if not find_issue_path(issues_root, dep_id):
361
+ raise ValueError(f"Dependency issue {dep_id} not found.")
362
+
363
+ if related is not None:
364
+ for rel_id in related:
365
+ if not find_issue_path(issues_root, rel_id):
366
+ raise ValueError(f"Related issue {rel_id} not found.")
315
367
 
316
368
  # Update Data
317
369
  if status:
@@ -322,6 +374,27 @@ def update_issue(issues_root: Path, issue_id: str, status: Optional[IssueStatus]
322
374
  if solution:
323
375
  data['solution'] = solution.value
324
376
 
377
+ if title:
378
+ data['title'] = title
379
+
380
+ if parent is not None:
381
+ if parent == "":
382
+ data.pop('parent', None) # Remove parent field
383
+ else:
384
+ data['parent'] = parent
385
+
386
+ if sprint is not None:
387
+ data['sprint'] = sprint
388
+
389
+ if dependencies is not None:
390
+ data['dependencies'] = dependencies
391
+
392
+ if related is not None:
393
+ data['related'] = related
394
+
395
+ if tags is not None:
396
+ data['tags'] = tags
397
+
325
398
  # Lifecycle Hooks
326
399
  # 1. Opened At: If transitioning to OPEN
327
400
  if target_status == IssueStatus.OPEN and current_status != IssueStatus.OPEN:
@@ -336,9 +409,24 @@ def update_issue(issues_root: Path, issue_id: str, status: Optional[IssueStatus]
336
409
  # Touch updated_at
337
410
  data['updated_at'] = current_time()
338
411
 
339
- # Re-hydrate through Model to trigger Logic (Stage, ClosedAt defaults)
412
+ # Re-hydrate through Model
340
413
  try:
341
414
  updated_meta = IssueMetadata(**data)
415
+
416
+ # Enforce lifecycle policies (defaults, auto-corrections)
417
+ # This ensures that when we update, we also fix invalid states (like Closed but not Done)
418
+ enforce_lifecycle_policy(updated_meta)
419
+
420
+ # Delegate to IssueValidator for static state validation
421
+ # We need to construct the full content to validate body-dependent rules (like checkboxes)
422
+ # Note: 'body' here is the OLD body. We assume update_issue doesn't change body.
423
+ # If body is invalid (unchecked boxes) and we move to DONE, this MUST fail.
424
+ validator = IssueValidator(issues_root)
425
+ diagnostics = validator.validate(updated_meta, body)
426
+ errors = [d for d in diagnostics if d.severity == DiagnosticSeverity.Error]
427
+ if errors:
428
+ raise ValueError(f"Validation Failed: {errors[0].message}")
429
+
342
430
  except Exception as e:
343
431
  raise ValueError(f"Failed to validate updated metadata: {e}")
344
432
 
@@ -376,11 +464,15 @@ def update_issue(issues_root: Path, issue_id: str, status: Optional[IssueStatus]
376
464
  if path != target_path:
377
465
  target_path.parent.mkdir(parents=True, exist_ok=True)
378
466
  path.rename(target_path)
467
+ path = target_path # Update local path variable for returned meta
379
468
 
380
469
  # Hook: Recursive Aggregation (FEAT-0003)
381
470
  if updated_meta.parent:
382
471
  recalculate_parent(issues_root, updated_meta.parent)
383
-
472
+
473
+ # Update returned metadata with final absolute path
474
+ updated_meta.path = str(path.absolute())
475
+ updated_meta.actions = get_available_actions(updated_meta)
384
476
  return updated_meta
385
477
 
386
478
  def start_issue_isolation(issues_root: Path, issue_id: str, mode: IsolationType, project_root: Path) -> IssueMetadata:
@@ -392,6 +484,8 @@ def start_issue_isolation(issues_root: Path, issue_id: str, mode: IsolationType,
392
484
  raise FileNotFoundError(f"Issue {issue_id} not found.")
393
485
 
394
486
  issue = parse_issue(path)
487
+ if not issue:
488
+ raise ValueError(f"Could not parse metadata for issue {issue_id}")
395
489
 
396
490
  # Idempotency / Conflict Check
397
491
  if issue.isolation:
@@ -467,6 +561,9 @@ def prune_issue_resources(issues_root: Path, issue_id: str, force: bool, project
467
561
  raise FileNotFoundError(f"Issue {issue_id} not found.")
468
562
 
469
563
  issue = parse_issue(path)
564
+ if not issue:
565
+ raise ValueError(f"Could not parse metadata for issue {issue_id}")
566
+
470
567
  deleted_items = []
471
568
 
472
569
  if not issue.isolation:
@@ -574,15 +671,6 @@ description: Monoco Issue System 的官方技能定义。将 Issue 视为通用
574
671
  5. **Modification**: `monoco issue start/submit/delete <id>`
575
672
  """
576
673
 
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
674
 
587
675
  def init(issues_root: Path):
588
676
  """Initialize the Issues directory structure."""
@@ -603,9 +691,7 @@ def get_resources() -> Dict[str, Any]:
603
691
  "skills": {
604
692
  "issues-management": SKILL_CONTENT
605
693
  },
606
- "prompts": {
607
- "issues-management": PROMPT_CONTENT
608
- }
694
+ "prompts": {} # Handled by adapter via resource files
609
695
  }
610
696
 
611
697
 
@@ -653,7 +739,7 @@ def get_board_data(issues_root: Path) -> Dict[str, List[IssueMetadata]]:
653
739
  Get open issues grouped by their stage for Kanban view.
654
740
  """
655
741
  board = {
656
- IssueStage.TODO.value: [],
742
+ IssueStage.DRAFT.value: [],
657
743
  IssueStage.DOING.value: [],
658
744
  IssueStage.REVIEW.value: [],
659
745
  IssueStage.DONE.value: []
@@ -769,7 +855,10 @@ def generate_delivery_report(issues_root: Path, issue_id: str, project_root: Pat
769
855
  commits = git.search_commits_by_message(project_root, f"Ref: {issue_id}")
770
856
 
771
857
  if not commits:
772
- return parse_issue(path)
858
+ meta = parse_issue(path)
859
+ if not meta:
860
+ raise ValueError(f"Could not parse metadata for issue {issue_id}")
861
+ return meta
773
862
 
774
863
  # 2. Aggregate Data
775
864
  all_files = set()
@@ -825,7 +914,10 @@ def generate_delivery_report(issues_root: Path, issue_id: str, project_root: Pat
825
914
  # We can add it to 'extra' or extend the model later.
826
915
  # For now, just persisting the text is enough for FEAT-0002.
827
916
 
828
- return parse_issue(path)
917
+ meta = parse_issue(path)
918
+ if not meta:
919
+ raise ValueError(f"Could not parse metadata for issue {issue_id}")
920
+ return meta
829
921
 
830
922
  def get_children(issues_root: Path, parent_id: str) -> List[IssueMetadata]:
831
923
  """Find all direct children of an issue."""
@@ -1033,11 +1125,11 @@ def recalculate_parent(issues_root: Path, parent_id: str):
1033
1125
  # FEAT-0003 Req: "If first child starts doing, auto-start Parent?"
1034
1126
  # If parent is OPEN/TODO and child is DOING/REVIEW/DONE, set parent to DOING?
1035
1127
  current_status = data.get("status", "open").lower()
1036
- current_stage = data.get("stage", "todo").lower()
1128
+ current_stage = data.get("stage", "draft").lower()
1037
1129
 
1038
- if current_status == "open" and current_stage == "todo":
1130
+ if current_status == "open" and current_stage == "draft":
1039
1131
  # Check if any child is active
1040
- active_children = [c for c in children if c.status == IssueStatus.OPEN and c.stage != IssueStage.TODO]
1132
+ active_children = [c for c in children if c.status == IssueStatus.OPEN and c.stage != IssueStage.DRAFT]
1041
1133
  closed_children = [c for c in children if c.status == IssueStatus.CLOSED]
1042
1134
 
1043
1135
  if active_children or closed_children:
@@ -1143,7 +1235,7 @@ def move_issue(
1143
1235
  # 5. Update content if ID changed
1144
1236
  if new_id != old_id:
1145
1237
  # Update frontmatter
1146
- content = issue.raw_content
1238
+ content = issue.raw_content or ""
1147
1239
  match = re.search(r"^---(.*?)---", content, re.DOTALL | re.MULTILINE)
1148
1240
  if match:
1149
1241
  yaml_str = match.group(1)
@@ -1159,9 +1251,9 @@ def move_issue(
1159
1251
 
1160
1252
  new_content = f"---\n{new_yaml}---{body}"
1161
1253
  else:
1162
- new_content = issue.raw_content
1254
+ new_content = issue.raw_content or ""
1163
1255
  else:
1164
- new_content = issue.raw_content
1256
+ new_content = issue.raw_content or ""
1165
1257
 
1166
1258
  # 6. Write to target
1167
1259
  target_path.write_text(new_content)
@@ -1171,4 +1263,7 @@ def move_issue(
1171
1263
 
1172
1264
  # 8. Return updated metadata
1173
1265
  final_meta = parse_issue(target_path)
1266
+ if not final_meta:
1267
+ raise ValueError(f"Failed to parse moved issue at {target_path}")
1268
+
1174
1269
  return final_meta, target_path