monoco-toolkit 0.3.11__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. monoco/core/automation/__init__.py +40 -0
  2. monoco/core/automation/field_watcher.py +296 -0
  3. monoco/core/automation/handlers.py +805 -0
  4. monoco/core/config.py +29 -11
  5. monoco/core/daemon/__init__.py +5 -0
  6. monoco/core/daemon/pid.py +290 -0
  7. monoco/core/git.py +15 -0
  8. monoco/core/hooks/context.py +74 -13
  9. monoco/core/injection.py +86 -8
  10. monoco/core/integrations.py +0 -24
  11. monoco/core/router/__init__.py +17 -0
  12. monoco/core/router/action.py +202 -0
  13. monoco/core/scheduler/__init__.py +63 -0
  14. monoco/core/scheduler/base.py +152 -0
  15. monoco/core/scheduler/engines.py +175 -0
  16. monoco/core/scheduler/events.py +197 -0
  17. monoco/core/scheduler/local.py +377 -0
  18. monoco/core/setup.py +9 -0
  19. monoco/core/sync.py +199 -4
  20. monoco/core/watcher/__init__.py +63 -0
  21. monoco/core/watcher/base.py +382 -0
  22. monoco/core/watcher/dropzone.py +152 -0
  23. monoco/core/watcher/im.py +460 -0
  24. monoco/core/watcher/issue.py +303 -0
  25. monoco/core/watcher/memo.py +192 -0
  26. monoco/core/watcher/task.py +238 -0
  27. monoco/daemon/app.py +3 -60
  28. monoco/daemon/commands.py +459 -25
  29. monoco/daemon/events.py +34 -0
  30. monoco/daemon/scheduler.py +157 -201
  31. monoco/daemon/services.py +42 -243
  32. monoco/features/agent/__init__.py +25 -7
  33. monoco/features/agent/cli.py +91 -57
  34. monoco/features/agent/engines.py +31 -170
  35. monoco/features/agent/resources/en/AGENTS.md +14 -14
  36. monoco/features/agent/resources/en/skills/monoco_role_engineer/SKILL.md +101 -0
  37. monoco/features/agent/resources/en/skills/monoco_role_manager/SKILL.md +95 -0
  38. monoco/features/agent/resources/en/skills/monoco_role_planner/SKILL.md +177 -0
  39. monoco/features/agent/resources/en/skills/monoco_role_reviewer/SKILL.md +139 -0
  40. monoco/features/agent/resources/zh/skills/monoco_role_engineer/SKILL.md +101 -0
  41. monoco/features/agent/resources/zh/skills/monoco_role_manager/SKILL.md +95 -0
  42. monoco/features/agent/resources/zh/skills/monoco_role_planner/SKILL.md +177 -0
  43. monoco/features/agent/resources/zh/skills/monoco_role_reviewer/SKILL.md +139 -0
  44. monoco/features/agent/worker.py +1 -1
  45. monoco/features/hooks/__init__.py +61 -6
  46. monoco/features/hooks/commands.py +281 -271
  47. monoco/features/hooks/dispatchers/__init__.py +23 -0
  48. monoco/features/hooks/dispatchers/agent_dispatcher.py +486 -0
  49. monoco/features/hooks/dispatchers/git_dispatcher.py +478 -0
  50. monoco/features/hooks/manager.py +357 -0
  51. monoco/features/hooks/models.py +262 -0
  52. monoco/features/hooks/parser.py +322 -0
  53. monoco/features/hooks/universal_interceptor.py +503 -0
  54. monoco/features/im/__init__.py +67 -0
  55. monoco/features/im/core.py +782 -0
  56. monoco/features/im/models.py +311 -0
  57. monoco/features/issue/commands.py +133 -60
  58. monoco/features/issue/core.py +385 -40
  59. monoco/features/issue/domain_commands.py +0 -19
  60. monoco/features/issue/resources/en/AGENTS.md +17 -122
  61. monoco/features/issue/resources/hooks/agent/before-tool.sh +102 -0
  62. monoco/features/issue/resources/hooks/agent/session-start.sh +88 -0
  63. monoco/features/issue/resources/hooks/{post-checkout.sh → git/git-post-checkout.sh} +10 -9
  64. monoco/features/issue/resources/hooks/git/git-pre-commit.sh +31 -0
  65. monoco/features/issue/resources/hooks/{pre-push.sh → git/git-pre-push.sh} +7 -13
  66. monoco/features/issue/resources/zh/AGENTS.md +18 -123
  67. monoco/features/memo/cli.py +15 -64
  68. monoco/features/memo/core.py +6 -34
  69. monoco/features/memo/models.py +24 -15
  70. monoco/features/memo/resources/en/AGENTS.md +31 -0
  71. monoco/features/memo/resources/zh/AGENTS.md +28 -5
  72. monoco/features/spike/commands.py +5 -3
  73. monoco/main.py +5 -3
  74. {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.4.0.dist-info}/METADATA +1 -1
  75. monoco_toolkit-0.4.0.dist-info/RECORD +170 -0
  76. monoco/core/execution.py +0 -67
  77. monoco/features/agent/apoptosis.py +0 -44
  78. monoco/features/agent/manager.py +0 -127
  79. monoco/features/agent/resources/atoms/atom-code-dev.yaml +0 -61
  80. monoco/features/agent/resources/atoms/atom-issue-lifecycle.yaml +0 -73
  81. monoco/features/agent/resources/atoms/atom-knowledge.yaml +0 -55
  82. monoco/features/agent/resources/atoms/atom-review.yaml +0 -60
  83. monoco/features/agent/resources/en/skills/monoco_atom_core/SKILL.md +0 -99
  84. monoco/features/agent/resources/en/skills/monoco_workflow_agent_engineer/SKILL.md +0 -94
  85. monoco/features/agent/resources/en/skills/monoco_workflow_agent_manager/SKILL.md +0 -93
  86. monoco/features/agent/resources/en/skills/monoco_workflow_agent_planner/SKILL.md +0 -85
  87. monoco/features/agent/resources/en/skills/monoco_workflow_agent_reviewer/SKILL.md +0 -114
  88. monoco/features/agent/resources/workflows/workflow-dev.yaml +0 -83
  89. monoco/features/agent/resources/workflows/workflow-issue-create.yaml +0 -72
  90. monoco/features/agent/resources/workflows/workflow-review.yaml +0 -94
  91. monoco/features/agent/resources/zh/roles/monoco_role_engineer.yaml +0 -49
  92. monoco/features/agent/resources/zh/roles/monoco_role_manager.yaml +0 -46
  93. monoco/features/agent/resources/zh/roles/monoco_role_planner.yaml +0 -46
  94. monoco/features/agent/resources/zh/roles/monoco_role_reviewer.yaml +0 -47
  95. monoco/features/agent/resources/zh/skills/monoco_atom_core/SKILL.md +0 -99
  96. monoco/features/agent/resources/zh/skills/monoco_workflow_agent_engineer/SKILL.md +0 -94
  97. monoco/features/agent/resources/zh/skills/monoco_workflow_agent_manager/SKILL.md +0 -88
  98. monoco/features/agent/resources/zh/skills/monoco_workflow_agent_planner/SKILL.md +0 -259
  99. monoco/features/agent/resources/zh/skills/monoco_workflow_agent_reviewer/SKILL.md +0 -137
  100. monoco/features/agent/session.py +0 -169
  101. monoco/features/artifact/resources/zh/skills/monoco_atom_artifact/SKILL.md +0 -278
  102. monoco/features/glossary/resources/en/skills/monoco_atom_glossary/SKILL.md +0 -35
  103. monoco/features/glossary/resources/zh/skills/monoco_atom_glossary/SKILL.md +0 -35
  104. monoco/features/hooks/adapter.py +0 -67
  105. monoco/features/hooks/core.py +0 -441
  106. monoco/features/i18n/resources/en/skills/monoco_atom_i18n/SKILL.md +0 -96
  107. monoco/features/i18n/resources/en/skills/monoco_workflow_i18n_scan/SKILL.md +0 -105
  108. monoco/features/i18n/resources/zh/skills/monoco_atom_i18n/SKILL.md +0 -96
  109. monoco/features/i18n/resources/zh/skills/monoco_workflow_i18n_scan/SKILL.md +0 -105
  110. monoco/features/issue/resources/en/skills/monoco_atom_issue/SKILL.md +0 -165
  111. monoco/features/issue/resources/en/skills/monoco_workflow_issue_creation/SKILL.md +0 -167
  112. monoco/features/issue/resources/en/skills/monoco_workflow_issue_development/SKILL.md +0 -224
  113. monoco/features/issue/resources/en/skills/monoco_workflow_issue_management/SKILL.md +0 -159
  114. monoco/features/issue/resources/en/skills/monoco_workflow_issue_refinement/SKILL.md +0 -203
  115. monoco/features/issue/resources/hooks/pre-commit.sh +0 -41
  116. monoco/features/issue/resources/zh/skills/monoco_atom_issue_lifecycle/SKILL.md +0 -190
  117. monoco/features/issue/resources/zh/skills/monoco_workflow_issue_creation/SKILL.md +0 -167
  118. monoco/features/issue/resources/zh/skills/monoco_workflow_issue_development/SKILL.md +0 -224
  119. monoco/features/issue/resources/zh/skills/monoco_workflow_issue_management/SKILL.md +0 -159
  120. monoco/features/issue/resources/zh/skills/monoco_workflow_issue_refinement/SKILL.md +0 -203
  121. monoco/features/memo/resources/en/skills/monoco_atom_memo/SKILL.md +0 -77
  122. monoco/features/memo/resources/en/skills/monoco_workflow_note_processing/SKILL.md +0 -140
  123. monoco/features/memo/resources/zh/skills/monoco_atom_memo/SKILL.md +0 -77
  124. monoco/features/memo/resources/zh/skills/monoco_workflow_note_processing/SKILL.md +0 -140
  125. monoco/features/spike/resources/en/skills/monoco_atom_spike/SKILL.md +0 -76
  126. monoco/features/spike/resources/en/skills/monoco_workflow_research/SKILL.md +0 -121
  127. monoco/features/spike/resources/zh/skills/monoco_atom_spike/SKILL.md +0 -76
  128. monoco/features/spike/resources/zh/skills/monoco_workflow_research/SKILL.md +0 -121
  129. monoco_toolkit-0.3.11.dist-info/RECORD +0 -181
  130. {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.4.0.dist-info}/WHEEL +0 -0
  131. {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.4.0.dist-info}/entry_points.txt +0 -0
  132. {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -46,6 +46,56 @@ def get_issue_dir(issue_type: str, issues_root: Path) -> Path:
46
46
  return issues_root / folder
47
47
 
48
48
 
49
+ def _extract_issue_id_from_branch(branch: str) -> Optional[str]:
50
+ """
51
+ Extract issue ID from a branch name.
52
+
53
+ Supported patterns:
54
+ - <ID>-slug (e.g., FEAT-0123-login-page)
55
+ - <ID> (e.g., FEAT-0123)
56
+
57
+ Args:
58
+ branch: Branch name to parse
59
+
60
+ Returns:
61
+ The extracted issue ID (uppercase) or None if no match
62
+ """
63
+ # Pattern: <prefix>-<number> at start of branch name
64
+ # Examples:
65
+ # FEAT-0123-login-page -> FEAT-0123
66
+ # FEAT-0123 -> FEAT-0123
67
+ # FIX-0001-critical -> FIX-0001
68
+ pattern = r'^([a-z]+-\d+)'
69
+ match = re.match(pattern, branch, re.IGNORECASE)
70
+ if match:
71
+ return match.group(1).upper()
72
+ return None
73
+
74
+
75
+ def _parse_isolation_ref(ref: str) -> str:
76
+ """
77
+ Parse isolation ref and strip any 'branch:' or 'worktree:' prefix.
78
+
79
+ The isolation.ref field may contain prefixes like 'branch:feat/xxx' or 'worktree:xxx'
80
+ for display/logging purposes, but git commands need the raw branch name.
81
+
82
+ Args:
83
+ ref: The isolation ref string, may contain prefix
84
+
85
+ Returns:
86
+ The clean branch name without prefix
87
+ """
88
+ if not ref:
89
+ return ref
90
+
91
+ # Strip known prefixes
92
+ for prefix in ("branch:", "worktree:"):
93
+ if ref.startswith(prefix):
94
+ return ref[len(prefix):]
95
+
96
+ return ref
97
+
98
+
49
99
  def _get_slug(title: str) -> str:
50
100
  slug = title.lower()
51
101
  # Replace non-word characters (including punctuation, spaces) with hyphens
@@ -59,6 +109,55 @@ def _get_slug(title: str) -> str:
59
109
  return slug
60
110
 
61
111
 
112
+ def _unquote_git_path(path: str) -> str:
113
+ """
114
+ Decode Git C-quoting format paths to native strings.
115
+
116
+ Git uses C-quoting for paths containing non-ASCII characters or special characters:
117
+ - Wraps the path in double quotes: "path"
118
+ - Escapes bytes as octal: \351\207\215 represents UTF-8 bytes
119
+
120
+ Args:
121
+ path: Path string, potentially in Git C-quoting format
122
+
123
+ Returns:
124
+ Decoded native path string
125
+
126
+ Examples:
127
+ >>> _unquote_git_path('"path-\\351\\207\\215.md"')
128
+ 'path-重.md'
129
+ >>> _unquote_git_path('"path\\ with\\ space.md"')
130
+ 'path with space.md'
131
+ >>> _unquote_git_path('normal/path.md')
132
+ 'normal/path.md'
133
+ """
134
+ path = path.strip()
135
+
136
+ # Check if the path is wrapped in double quotes (C-quoting format)
137
+ if not (path.startswith('"') and path.endswith('"')):
138
+ return path
139
+
140
+ # Remove outer quotes
141
+ path = path[1:-1]
142
+
143
+ # Collect all bytes (octal escapes -> bytes, ASCII chars -> their byte values)
144
+ bytes_list = []
145
+ i = 0
146
+ while i < len(path):
147
+ if path[i] == '\\' and i + 3 < len(path) and path[i+1:i+4].isdigit():
148
+ # This is an octal escape sequence (e.g., \351)
149
+ byte_val = int(path[i+1:i+4], 8)
150
+ bytes_list.append(byte_val)
151
+ i += 4
152
+ else:
153
+ # Regular ASCII character
154
+ bytes_list.append(ord(path[i]))
155
+ i += 1
156
+
157
+ # Decode the entire byte sequence as UTF-8
158
+ return bytes(bytes_list).decode('utf-8')
159
+
160
+
62
161
  def parse_issue(file_path: Path, raise_error: bool = False) -> Optional[IssueMetadata]:
63
162
  if not file_path.suffix == ".md":
64
163
  return None
@@ -466,6 +565,192 @@ def find_issue_path(issues_root: Path, issue_id: str, include_archived: bool = T
466
565
  return None
467
566
 
468
567
 
568
+ def find_issue_path_across_branches(
569
+ issues_root: Path,
570
+ issue_id: str,
571
+ project_root: Optional[Path] = None,
572
+ include_archived: bool = True,
573
+ allow_multi_branch: bool = False
574
+ ) -> Tuple[Optional[Path], Optional[str], Optional[List[str]]]:
575
+ """
576
+ Find issue path across all local git branches.
577
+
578
+ Implements the "Golden Path" logic:
579
+ - If issue found in exactly one branch -> return it silently
580
+ - If issue found in multiple branches -> handle based on allow_multi_branch flag
581
+ - If issue not found in any branch -> return None
582
+
583
+ Args:
584
+ issues_root: Root directory of issues
585
+ issue_id: Issue ID to find
586
+ project_root: Project root (defaults to issues_root.parent)
587
+ include_archived: Whether to search in archived directory
588
+ allow_multi_branch: If True, return all conflicting branches instead of raising error
589
+
590
+ Returns:
591
+ Tuple of (file_path, branch_name, conflicting_branches) or (None, None, None) if not found
592
+ conflicting_branches is None if no conflict, or a list of branch names if conflict detected
593
+
594
+ Raises:
595
+ RuntimeError: If issue found in multiple branches and allow_multi_branch is False (conflict)
596
+ """
597
+ # First, try to find in current working tree
598
+ local_path = find_issue_path(issues_root, issue_id, include_archived)
599
+
600
+ # Determine project root
601
+ if project_root is None:
602
+ project_root = issues_root.parent
603
+
604
+ # If not a git repo, just return local result
605
+ if not git.is_git_repo(project_root):
606
+ return (local_path, None, None) if local_path else (None, None, None)
607
+
608
+ # Get current branch
609
+ current_branch = git.get_current_branch(project_root)
610
+
611
+ # Issue files existing in multiple branches is NORMAL - not a conflict
612
+ # Just return the local path without any conflict checking
613
+ if local_path:
614
+ return local_path, current_branch, None
615
+
616
+ # Not found locally, search in all branches
617
+ return _search_issue_in_branches(issues_root, issue_id, project_root, include_archived)
618
+
619
+
620
+ def _find_branches_with_file(project_root: Path, rel_path: str, exclude_branch: str) -> List[str]:
621
+ """
622
+ Find all branches (except excluded) that contain the given file path.
623
+
624
+ Args:
625
+ project_root: Project root path
626
+ rel_path: Relative path from project root
627
+ exclude_branch: Branch to exclude from search
628
+
629
+ Returns:
630
+ List of branch names containing the file
631
+ """
632
+ branches_with_file = []
633
+
634
+ # Get all local branches
635
+ code, stdout, _ = git._run_git(["branch", "--format=%(refname:short)"], project_root)
636
+ if code != 0:
637
+ return []
638
+
639
+ all_branches = [b.strip() for b in stdout.splitlines() if b.strip()]
640
+
641
+ for branch in all_branches:
642
+ if branch == exclude_branch:
643
+ continue
644
+
645
+ # Check if file exists in this branch
646
+ code, _, _ = git._run_git(["show", f"{branch}:{rel_path}"], project_root)
647
+ if code == 0:
648
+ branches_with_file.append(branch)
649
+
650
+ return branches_with_file
651
+
652
+
653
+ def _search_issue_in_branches(
654
+ issues_root: Path,
655
+ issue_id: str,
656
+ project_root: Path,
657
+ include_archived: bool = True
658
+ ) -> Tuple[Optional[Path], Optional[str], Optional[List[str]]]:
659
+ """
660
+ Search for an issue file across all branches.
661
+
662
+ Returns:
663
+ Tuple of (file_path, branch_name, conflicting_branches) or (None, None, None)
664
+ conflicting_branches is None if no conflict, or a list of branch names if conflict detected
665
+
666
+ Raises:
667
+ RuntimeError: If issue found in multiple branches and allow_multi_branch is False
668
+ """
669
+ parsed = IssueID(issue_id)
670
+
671
+ if not parsed.is_local:
672
+ # For workspace issues, just use standard find
673
+ path = find_issue_path(issues_root, issue_id, include_archived)
674
+ return (path, None, None) if path else (None, None, None)
675
+
676
+ # Get issue type from prefix
677
+ try:
678
+ prefix = parsed.local_id.split("-")[0].upper()
679
+ except IndexError:
680
+ return None, None, None
681
+
682
+ reverse_prefix_map = get_reverse_prefix_map(issues_root)
683
+ issue_type = reverse_prefix_map.get(prefix)
684
+ if not issue_type:
685
+ return None, None, None
686
+
687
+ # Build possible paths to search
688
+ base_dir = get_issue_dir(issue_type, issues_root)
689
+ rel_base = base_dir.relative_to(project_root)
690
+
691
+ status_dirs = ["open", "backlog", "closed"]
692
+ if include_archived:
693
+ status_dirs.append("archived")
694
+
695
+ # Search pattern: Issues/{Type}/{status}/{issue_id}-*.md
696
+ found_in_branches: List[Tuple[str, str]] = [] # (branch, file_path)
697
+
698
+ # Get all local branches
699
+ code, stdout, _ = git._run_git(["branch", "--format=%(refname:short)"], project_root)
700
+ if code != 0:
701
+ return None, None, None
702
+
703
+ all_branches = [b.strip() for b in stdout.splitlines() if b.strip()]
704
+
705
+ for branch in all_branches:
706
+ # List files in each status directory for this branch
707
+ for status_dir in status_dirs:
708
+ dir_path = f"{rel_base}/{status_dir}"
709
+
710
+ # List all files in this directory on the branch
711
+ code, stdout, _ = git._run_git(
712
+ ["ls-tree", "-r", "--name-only", branch, dir_path],
713
+ project_root
714
+ )
715
+
716
+ if code != 0 or not stdout.strip():
717
+ continue
718
+
719
+ # Find matching issue file (Handling quoted paths from git ls-tree)
720
+ pattern = f"{parsed.local_id}-"
721
+ for line in stdout.splitlines():
722
+ line = line.strip()
723
+ # Git quotes non-ASCII paths: "Issues/Chores/...md"
724
+ if line.startswith('"') and line.endswith('"'):
725
+ line = line[1:-1]
726
+
727
+ if pattern in line and line.endswith(".md"):
728
+ found_in_branches.append((branch, line))
729
+
730
+ if not found_in_branches:
731
+ return None, None, None
732
+
733
+ if len(found_in_branches) > 1:
734
+ # Found in multiple branches - return all branches for caller to handle
735
+ branches = [b for b, _ in found_in_branches]
736
+ branch, file_path = found_in_branches[0]
737
+ full_path = project_root / file_path
738
+ return full_path, branch, branches
739
+
740
+ # Golden path: exactly one match
741
+ branch, file_path = found_in_branches[0]
742
+
743
+ # Checkout the file to working tree
744
+ try:
745
+ git.git_checkout_files(project_root, branch, [file_path])
746
+ full_path = project_root / file_path
747
+ return full_path, branch, None
748
+ except Exception:
749
+ # If checkout fails, return the path anyway - caller can handle
750
+ full_path = project_root / file_path
751
+ return full_path, branch, None
752
+
753
+
469
754
  def update_issue(
470
755
  issues_root: Path,
471
756
  issue_id: str,
@@ -823,7 +1108,7 @@ def start_issue_isolation(
823
1108
  )
824
1109
 
825
1110
  slug = _get_slug(issue.title)
826
- branch_name = f"feat/{issue_id.lower()}-{slug}"
1111
+ branch_name = f"{issue_id}-{slug}"
827
1112
 
828
1113
  isolation_meta = None
829
1114
 
@@ -899,7 +1184,7 @@ def prune_issue_resources(
899
1184
  return []
900
1185
 
901
1186
  if issue.isolation.type == IsolationType.BRANCH:
902
- branch = issue.isolation.ref
1187
+ branch = _parse_isolation_ref(issue.isolation.ref)
903
1188
  current = git.get_current_branch(project_root)
904
1189
  if current == branch:
905
1190
  raise RuntimeError(
@@ -925,7 +1210,7 @@ def prune_issue_resources(
925
1210
  # Also delete the branch associated?
926
1211
  # Worktree create makes a branch. When removing worktree, branch remains.
927
1212
  # Usually we want to remove the branch too if it was created for this issue.
928
- branch = issue.isolation.ref
1213
+ branch = _parse_isolation_ref(issue.isolation.ref)
929
1214
  if branch and git.branch_exists(project_root, branch):
930
1215
  # We can't delete branch if it is checked out in the worktree we just removed?
931
1216
  # git worktree remove unlocks the branch.
@@ -961,9 +1246,41 @@ def delete_issue_file(issues_root: Path, issue_id: str):
961
1246
  path.unlink()
962
1247
 
963
1248
 
1249
+ def _has_uncommitted_changes(project_root: Path) -> Tuple[bool, List[str]]:
1250
+ """
1251
+ Check if there are uncommitted changes in the working directory.
1252
+
1253
+ Returns:
1254
+ Tuple of (has_changes, list_of_changed_files)
1255
+ """
1256
+ # Check for staged changes
1257
+ cmd_staged = ["diff", "--cached", "--name-only"]
1258
+ code, stdout, _ = git._run_git(cmd_staged, project_root)
1259
+ staged_files = [f.strip() for f in stdout.splitlines() if f.strip()] if code == 0 else []
1260
+
1261
+ # Check for unstaged changes
1262
+ cmd_unstaged = ["diff", "--name-only"]
1263
+ code, stdout, _ = git._run_git(cmd_unstaged, project_root)
1264
+ unstaged_files = [f.strip() for f in stdout.splitlines() if f.strip()] if code == 0 else []
1265
+
1266
+ # Check for untracked files
1267
+ cmd_untracked = ["ls-files", "--others", "--exclude-standard"]
1268
+ code, stdout, _ = git._run_git(cmd_untracked, project_root)
1269
+ untracked_files = [f.strip() for f in stdout.splitlines() if f.strip()] if code == 0 else []
1270
+
1271
+ all_changes = list(set(staged_files + unstaged_files + untracked_files))
1272
+ all_changes.sort()
1273
+
1274
+ return len(all_changes) > 0, all_changes
1275
+
1276
+
964
1277
  def sync_issue_files(issues_root: Path, issue_id: str, project_root: Path) -> List[str]:
965
1278
  """
966
1279
  Sync 'files' field in issue metadata with actual changed files in git.
1280
+
1281
+ Pre-condition: All changes must be committed. If there are uncommitted changes,
1282
+ this function will raise an error with instructions on how to proceed.
1283
+
967
1284
  Strategies:
968
1285
  1. Isolation Ref: If issue has isolation (branch/worktree), use that ref.
969
1286
  2. Convention: If no isolation, look for branch `*/<id>-*`.
@@ -979,23 +1296,37 @@ def sync_issue_files(issues_root: Path, issue_id: str, project_root: Path) -> Li
979
1296
  if not issue:
980
1297
  raise ValueError(f"Could not parse issue {issue_id}")
981
1298
 
1299
+ # CHORE-0036: Check for uncommitted changes
1300
+ has_uncommitted, uncommitted_files = _has_uncommitted_changes(project_root)
1301
+ if has_uncommitted:
1302
+ files_list = "\n - ".join([""] + uncommitted_files)
1303
+ raise RuntimeError(
1304
+ f"Uncommitted changes detected in working directory:{files_list}\n\n"
1305
+ f"You must explicitly handle these files before syncing:\n"
1306
+ f" 1. git add <files> && git commit -m '...' (to commit)\n"
1307
+ f" 2. git checkout -- <files> (to discard)\n"
1308
+ f" 3. echo '<pattern>' >> .gitignore (to ignore)\n"
1309
+ f" 4. git rm <files> (to remove)\n\n"
1310
+ f"After handling, run: monoco issue sync-files {issue_id}"
1311
+ )
1312
+
982
1313
  # Determine Target Branch
983
1314
  target_ref = None
984
1315
 
985
1316
  if issue.isolation and issue.isolation.ref:
986
- target_ref = issue.isolation.ref
1317
+ target_ref = _parse_isolation_ref(issue.isolation.ref)
987
1318
  if target_ref == "current":
988
1319
  target_ref = git.get_current_branch(project_root)
989
1320
  else:
990
- # Heuristic Search
991
- # 1. Is current branch related?
1321
+ # Heuristic Search - STRICT ID MATCHING ONLY
1322
+ # 1. Is current branch's extracted ID exactly matching the issue ID?
992
1323
  current = git.get_current_branch(project_root)
993
- if issue_id.lower() in current.lower():
1324
+ branch_issue_id = _extract_issue_id_from_branch(current)
1325
+ if branch_issue_id and branch_issue_id.upper() == issue_id.upper():
994
1326
  target_ref = current
995
1327
  else:
996
- # 2. Search for branch
997
- # Limitation: core.git doesn't list all branches yet.
998
- # We skip this for now to avoid complexity, relying on isolation or current context.
1328
+ # 2. Search for branch - not implemented yet
1329
+ # To avoid ambiguity, we only match current branch if it exactly matches
999
1330
  pass
1000
1331
 
1001
1332
  if not target_ref:
@@ -1025,6 +1356,14 @@ def sync_issue_files(issues_root: Path, issue_id: str, project_root: Path) -> Li
1025
1356
 
1026
1357
  changed_files = [f.strip() for f in stdout.splitlines() if f.strip()]
1027
1358
 
1359
+ # Decode Git C-quoting format paths to native paths
1360
+ changed_files = [_unquote_git_path(f) for f in changed_files]
1361
+
1362
+ # FEAT-0172: Exclude issue file itself from files list
1363
+ # Issue file is handled separately by update_issue (directory move + status update)
1364
+ relative_issue_path = str(path.relative_to(project_root))
1365
+ changed_files = [f for f in changed_files if f != relative_issue_path]
1366
+
1028
1367
  # Sort for consistency
1029
1368
  changed_files.sort()
1030
1369
 
@@ -1061,30 +1400,23 @@ def merge_issue_changes(
1061
1400
  # If not there, we try heuristic.
1062
1401
  source_ref = None
1063
1402
  if issue.isolation and issue.isolation.ref:
1064
- source_ref = issue.isolation.ref
1403
+ source_ref = _parse_isolation_ref(issue.isolation.ref)
1065
1404
  else:
1066
- # Heuristic: Search for branch by convention
1067
- # We can't use 'current' here safely if we are on main,
1068
- # but let's assume we might be calling this from elsewhere?
1069
- # Actually, for 'close', we are likely on main.
1070
- # So we search for a branch named 'feat/{id}-*' or similar?
1071
- pass
1405
+ # Heuristic: Search for branch by convention {id}-*
1406
+ import re
1407
+ code, stdout, _ = git._run_git(["branch", "--format=%(refname:short)"], project_root)
1408
+ if code == 0:
1409
+ for branch in stdout.splitlines():
1410
+ branch = branch.strip()
1411
+ if re.match(rf"{re.escape(issue_id)}-", branch, re.IGNORECASE):
1412
+ source_ref = branch
1413
+ break
1072
1414
 
1073
- # If local metadata doesn't have isolation ref, we might be stuck.
1074
- # But let's assume valid workflow.
1075
- if not source_ref:
1076
- # Try to find a branch starting with feat/{id} or {id}
1077
- # This is a bit weak, needs better implementation in 'git' or 'issue' module
1078
- # For now, if we can't find it, we error.
1079
- pass
1080
-
1081
- if not source_ref or not git.branch_exists(project_root, source_ref):
1082
- # Fallback: maybe we are currently ON the feature branch?
1083
- # If so, source_ref should be current. But we expect to call this from MAIN.
1084
- pass
1085
-
1086
1415
  if not source_ref:
1087
- raise RuntimeError(f"Could not determine source branch for Issue {issue_id}. Ensure isolation ref is set.")
1416
+ # No branch found - files should already be in current branch
1417
+ # This is valid for direct workflow (no feature branch created)
1418
+ # Nothing to merge, files are already in place
1419
+ return issue.files
1088
1420
 
1089
1421
  if not git.branch_exists(project_root, source_ref):
1090
1422
  raise RuntimeError(f"Source branch {source_ref} does not exist.")
@@ -1115,10 +1447,20 @@ def merge_issue_changes(
1115
1447
  if not issue.files:
1116
1448
  return []
1117
1449
 
1118
- # 1. Conflict Check
1119
- # A conflict occurs if a file in 'files' has changed on HEAD (main)
1120
- # since the common ancestor of HEAD and source_ref.
1450
+ # Compatibility: Decode any legacy escaped paths in files field
1451
+ # This handles files stored before the _unquote_git_path fix was applied
1452
+ decoded_files = [_unquote_git_path(f) for f in issue.files]
1453
+
1454
+ if not decoded_files:
1455
+ return []
1456
+
1457
+ # Identify the issue file path
1458
+ relative_issue_path = str(path.relative_to(project_root))
1459
+
1460
+ # Separate files: issue file vs code files
1461
+ code_files = [f for f in decoded_files if f != relative_issue_path]
1121
1462
 
1463
+ # 1. Conflict Check (only for code files, issue file skips conflict check)
1122
1464
  current_head = git.get_current_branch(project_root)
1123
1465
  try:
1124
1466
  base = git.get_merge_base(project_root, current_head, source_ref)
@@ -1126,7 +1468,7 @@ def merge_issue_changes(
1126
1468
  raise RuntimeError(f"Failed to determine merge base: {e}")
1127
1469
 
1128
1470
  conflicts = []
1129
- for f in issue.files:
1471
+ for f in code_files:
1130
1472
  # Has main changed this file?
1131
1473
  if git.has_diff(project_root, base, current_head, [f]):
1132
1474
  # Has feature also changed this file?
@@ -1141,12 +1483,15 @@ def merge_issue_changes(
1141
1483
  )
1142
1484
 
1143
1485
  # 2. Perform Atomic Merge (Selective Checkout)
1144
- try:
1145
- git.git_checkout_files(project_root, source_ref, issue.files)
1146
- except Exception as e:
1147
- raise RuntimeError(f"Selective checkout failed: {e}")
1486
+ # FEAT-0172: Only merge code files, issue file is handled separately
1487
+ files_to_merge = code_files
1488
+ if files_to_merge:
1489
+ try:
1490
+ git.git_checkout_files(project_root, source_ref, files_to_merge)
1491
+ except Exception as e:
1492
+ raise RuntimeError(f"Selective checkout failed: {e}")
1148
1493
 
1149
- return issue.files
1494
+ return files_to_merge
1150
1495
 
1151
1496
 
1152
1497
  # Resources
@@ -26,22 +26,3 @@ def list_domains():
26
26
  )
27
27
 
28
28
  console.print(table)
29
-
30
-
31
- @app.command("check")
32
- def check_domain(domain: str = typer.Argument(..., help="Domain name to check")):
33
- """Check if a domain is valid and resolve it."""
34
- service = DomainService()
35
-
36
- if service.is_canonical(domain):
37
- console.print(f"[green]✔ '{domain}' is a canonical domain.[/green]")
38
- elif service.is_alias(domain):
39
- canonical = service.get_canonical(domain)
40
- console.print(f"[yellow]➜ '{domain}' is an alias for '{canonical}'.[/yellow]")
41
- else:
42
- if service.config.strict:
43
- console.print(f"[red]✘ '{domain}' is NOT a valid domain.[/red]")
44
- else:
45
- console.print(
46
- f"[yellow]⚠ '{domain}' is undefined (Strict Mode: OFF).[/yellow]"
47
- )