monoco-toolkit 0.3.5__py3-none-any.whl → 0.3.9__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 (59) hide show
  1. monoco/cli/workspace.py +1 -1
  2. monoco/core/config.py +51 -0
  3. monoco/core/hooks/__init__.py +19 -0
  4. monoco/core/hooks/base.py +104 -0
  5. monoco/core/hooks/builtin/__init__.py +11 -0
  6. monoco/core/hooks/builtin/git_cleanup.py +266 -0
  7. monoco/core/hooks/builtin/logging_hook.py +78 -0
  8. monoco/core/hooks/context.py +131 -0
  9. monoco/core/hooks/registry.py +222 -0
  10. monoco/core/integrations.py +6 -0
  11. monoco/core/registry.py +2 -0
  12. monoco/core/setup.py +1 -1
  13. monoco/core/skills.py +226 -42
  14. monoco/features/{scheduler → agent}/__init__.py +4 -2
  15. monoco/features/{scheduler → agent}/cli.py +134 -80
  16. monoco/features/{scheduler → agent}/config.py +17 -3
  17. monoco/features/agent/defaults.py +55 -0
  18. monoco/features/agent/flow_skills.py +281 -0
  19. monoco/features/{scheduler → agent}/manager.py +39 -2
  20. monoco/features/{scheduler → agent}/models.py +6 -3
  21. monoco/features/{scheduler → agent}/reliability.py +1 -1
  22. monoco/features/agent/resources/skills/flow_engineer/SKILL.md +94 -0
  23. monoco/features/agent/resources/skills/flow_manager/SKILL.md +88 -0
  24. monoco/features/agent/resources/skills/flow_reviewer/SKILL.md +114 -0
  25. monoco/features/{scheduler → agent}/session.py +39 -5
  26. monoco/features/{scheduler → agent}/worker.py +2 -2
  27. monoco/features/i18n/resources/skills/i18n_scan_workflow/SKILL.md +105 -0
  28. monoco/features/issue/commands.py +427 -21
  29. monoco/features/issue/core.py +104 -0
  30. monoco/features/issue/criticality.py +553 -0
  31. monoco/features/issue/domain/models.py +28 -2
  32. monoco/features/issue/engine/machine.py +65 -37
  33. monoco/features/issue/git_service.py +185 -0
  34. monoco/features/issue/linter.py +291 -62
  35. monoco/features/issue/models.py +91 -14
  36. monoco/features/issue/resources/en/SKILL.md +48 -0
  37. monoco/features/issue/resources/skills/issue_lifecycle_workflow/SKILL.md +159 -0
  38. monoco/features/issue/resources/zh/SKILL.md +50 -0
  39. monoco/features/issue/test_priority_integration.py +1 -0
  40. monoco/features/issue/validator.py +185 -65
  41. monoco/features/memo/__init__.py +4 -0
  42. monoco/features/memo/adapter.py +32 -0
  43. monoco/features/memo/cli.py +112 -0
  44. monoco/features/memo/core.py +146 -0
  45. monoco/features/memo/resources/skills/note_processing_workflow/SKILL.md +140 -0
  46. monoco/features/memo/resources/zh/AGENTS.md +8 -0
  47. monoco/features/memo/resources/zh/SKILL.md +75 -0
  48. monoco/features/spike/resources/skills/research_workflow/SKILL.md +121 -0
  49. monoco/main.py +6 -3
  50. {monoco_toolkit-0.3.5.dist-info → monoco_toolkit-0.3.9.dist-info}/METADATA +1 -1
  51. {monoco_toolkit-0.3.5.dist-info → monoco_toolkit-0.3.9.dist-info}/RECORD +56 -35
  52. monoco/features/scheduler/defaults.py +0 -54
  53. monoco/features/skills/__init__.py +0 -0
  54. monoco/features/skills/core.py +0 -102
  55. /monoco/core/{hooks.py → githooks.py} +0 -0
  56. /monoco/features/{scheduler → agent}/engines.py +0 -0
  57. {monoco_toolkit-0.3.5.dist-info → monoco_toolkit-0.3.9.dist-info}/WHEEL +0 -0
  58. {monoco_toolkit-0.3.5.dist-info → monoco_toolkit-0.3.9.dist-info}/entry_points.txt +0 -0
  59. {monoco_toolkit-0.3.5.dist-info → monoco_toolkit-0.3.9.dist-info}/licenses/LICENSE +0 -0
@@ -24,46 +24,201 @@ def check_integrity(issues_root: Path, recursive: bool = False) -> List[Diagnost
24
24
  validator = IssueValidator(issues_root)
25
25
 
26
26
  all_issue_ids = set()
27
+ id_to_path = {}
27
28
  all_issues = []
28
29
 
29
30
  # 1. Collection Phase (Build Index)
30
31
  # Helper to collect issues from a project
31
32
  def collect_project_issues(project_issues_root: Path, project_name: str = "local"):
32
33
  project_issues = []
33
- for subdir in ["Epics", "Features", "Chores", "Fixes"]:
34
+ project_diagnostics = []
35
+ for subdir in ["Epics", "Features", "Chores", "Fixes", "Domains"]:
34
36
  d = project_issues_root / subdir
35
37
  if d.exists():
36
- files = []
37
- for status in ["open", "closed", "backlog"]:
38
- status_dir = d / status
39
- if status_dir.exists():
40
- files.extend(status_dir.rglob("*.md"))
41
-
42
- for f in files:
43
- try:
44
- meta = core.parse_issue(f, raise_error=True)
45
- if meta:
46
- local_id = meta.id
47
- full_id = f"{project_name}::{local_id}"
48
-
49
- all_issue_ids.add(local_id)
50
- all_issue_ids.add(full_id)
51
-
52
- project_issues.append((f, meta, project_name))
53
- except Exception as e:
54
- # Report parsing failure as diagnostic
55
- d = Diagnostic(
56
- range=Range(
57
- start=Position(line=0, character=0),
58
- end=Position(line=0, character=0),
59
- ),
60
- message=f"Schema Error: {str(e)}",
61
- severity=DiagnosticSeverity.Error,
62
- source="System",
63
- )
64
- d.data = {"path": f}
65
- diagnostics.append(d)
66
- return project_issues
38
+ if subdir == "Domains":
39
+ # Special handling for Domains (not Issue tickets)
40
+ for f in d.rglob("*.md"):
41
+ # Domain validation happens here inline or via separate validator
42
+ # For now, we just index them for reference validation
43
+ domain_key = f.stem
44
+ # Ensure H1 matches filename
45
+ try:
46
+ content = f.read_text(encoding="utf-8")
47
+
48
+ # 1. H1 Check
49
+ h1_match = re.search(r"^#\s+(.+)$", content, re.MULTILINE)
50
+ if not h1_match:
51
+ project_diagnostics.append(
52
+ Diagnostic(
53
+ range=Range(
54
+ start=Position(line=0, character=0),
55
+ end=Position(line=0, character=0),
56
+ ),
57
+ message=f"Domain '{f.name}' missing H1 title.",
58
+ severity=DiagnosticSeverity.Error,
59
+ source="DomainValidator",
60
+ )
61
+ )
62
+ else:
63
+ h1_title = h1_match.group(1).strip()
64
+ # Allow exact match or "Domain: Name" pattern? User said "Title should be same as filename"
65
+ # But let's be strict: Filename stem MUST match H1
66
+ # User spec: "标题应该和文件名相同" -> The H1 content (after #) must equal filename stem (spaces sensitive).
67
+ # But wait, user example "Domain: Agent Onboarding" (bad) vs "Agent Onboarding" (good?).
68
+ # Actually user said "Attribute key in yaml" should match.
69
+ # Let's enforce: Filename 'Agent Onboarding.md' -> H1 '# Agent Onboarding'
70
+
71
+ # Check for "Domain: " prefix which is forbidden
72
+ if h1_title.lower().startswith("domain:"):
73
+ project_diagnostics.append(
74
+ Diagnostic(
75
+ range=Range(
76
+ start=Position(line=0, character=0),
77
+ end=Position(line=0, character=0),
78
+ ),
79
+ message=f"Domain H1 must not use 'Domain:' prefix. Found '{h1_title}'.",
80
+ severity=DiagnosticSeverity.Error,
81
+ source="DomainValidator",
82
+ )
83
+ )
84
+ elif h1_title != f.stem:
85
+ project_diagnostics.append(
86
+ Diagnostic(
87
+ range=Range(
88
+ start=Position(line=0, character=0),
89
+ end=Position(line=0, character=0),
90
+ ),
91
+ message=f"Domain H1 '{h1_title}' does not match filename '{f.stem}'.",
92
+ severity=DiagnosticSeverity.Error,
93
+ source="DomainValidator",
94
+ )
95
+ )
96
+
97
+ # 2. Source Language Check
98
+ # We import the check from i18n core if not available in validator yet
99
+ # But linter.py imports core... issue core.
100
+
101
+ # Need source lang. Conf is available below, let's grab it or pass it.
102
+ # We are inside a helper func, need to access outer scope or pass config.
103
+ # We'll do it in validation phase? Or here?
104
+ # 'conf' is defined in outer scope but not passed to this helper.
105
+ # Let's resolve config inside loop or pass it.
106
+ # To keep it simple, we do light check here.
107
+
108
+ # Actually, we should collect Domains into a list to pass to Validator
109
+ # so Validator can check if Issue 'domains' field references valid domains.
110
+ # We'll use a special set for this.
111
+ project_issues.append(
112
+ (f, "DOMAIN", f.stem)
113
+ ) # Marker for later
114
+
115
+ except Exception as e:
116
+ project_diagnostics.append(
117
+ Diagnostic(
118
+ range=Range(
119
+ start=Position(line=0, character=0),
120
+ end=Position(line=0, character=0),
121
+ ),
122
+ message=f"Domain Read Error: {e}",
123
+ severity=DiagnosticSeverity.Error,
124
+ source="DomainValidator",
125
+ )
126
+ )
127
+
128
+ else:
129
+ # Standard Issues (Epics/Features/etc)
130
+ files = []
131
+ for status in ["open", "closed", "backlog"]:
132
+ status_dir = d / status
133
+ if status_dir.exists():
134
+ files.extend(status_dir.rglob("*.md"))
135
+
136
+ for f in files:
137
+ try:
138
+ meta = core.parse_issue(f, raise_error=True)
139
+ if meta:
140
+ local_id = meta.id
141
+ full_id = f"{project_name}::{local_id}"
142
+
143
+ if local_id in id_to_path:
144
+ other_path = id_to_path[local_id]
145
+ # Report on current file
146
+ d_dup = Diagnostic(
147
+ range=Range(
148
+ start=Position(line=0, character=0),
149
+ end=Position(line=0, character=0),
150
+ ),
151
+ message=f"Duplicate ID Violation: ID '{local_id}' is already used by {other_path.name}",
152
+ severity=DiagnosticSeverity.Error,
153
+ source=local_id,
154
+ )
155
+ d_dup.data = {"path": f}
156
+ project_diagnostics.append(d_dup)
157
+ else:
158
+ id_to_path[local_id] = f
159
+
160
+ all_issue_ids.add(local_id)
161
+ all_issue_ids.add(full_id)
162
+
163
+ # Filename Consistency Check
164
+ # Pattern: {ID}-{slug}.md
165
+ expected_slug = meta.title.lower().replace(" ", "-")
166
+ # Remove common symbols from slug for matching
167
+ expected_slug = re.sub(
168
+ r"[^a-z0-9\-]", "", expected_slug
169
+ )
170
+ # Trim double dashes
171
+ expected_slug = re.sub(r"-+", "-", expected_slug).strip(
172
+ "-"
173
+ )
174
+
175
+ filename_stem = f.stem
176
+ # Check if it starts with ID-
177
+ if not filename_stem.startswith(f"{meta.id}-"):
178
+ project_diagnostics.append(
179
+ Diagnostic(
180
+ range=Range(
181
+ start=Position(line=0, character=0),
182
+ end=Position(line=0, character=0),
183
+ ),
184
+ message=f"Filename Error: Filename '{f.name}' must start with ID '{meta.id}-'",
185
+ severity=DiagnosticSeverity.Error,
186
+ source=meta.id,
187
+ data={"path": f},
188
+ )
189
+ )
190
+ else:
191
+ # Check slug matching (loose match, ensuring it's present)
192
+ actual_slug = filename_stem[len(meta.id) + 1 :]
193
+ if not actual_slug:
194
+ project_diagnostics.append(
195
+ Diagnostic(
196
+ range=Range(
197
+ start=Position(line=0, character=0),
198
+ end=Position(line=0, character=0),
199
+ ),
200
+ message=f"Filename Error: Filename '{f.name}' missing title slug. Expected: '{meta.id}-{expected_slug}.md'",
201
+ severity=DiagnosticSeverity.Error,
202
+ source=meta.id,
203
+ data={"path": f},
204
+ )
205
+ )
206
+
207
+ project_issues.append((f, meta, project_name))
208
+ except Exception as e:
209
+ # Report parsing failure as diagnostic
210
+ d = Diagnostic(
211
+ range=Range(
212
+ start=Position(line=0, character=0),
213
+ end=Position(line=0, character=0),
214
+ ),
215
+ message=f"Schema Error: {str(e)}",
216
+ severity=DiagnosticSeverity.Error,
217
+ source="System",
218
+ )
219
+ d.data = {"path": f}
220
+ project_diagnostics.append(d)
221
+ return project_issues, project_diagnostics
67
222
 
68
223
  conf = get_config(str(issues_root.parent))
69
224
 
@@ -92,7 +247,11 @@ def check_integrity(issues_root: Path, recursive: bool = False) -> List[Diagnost
92
247
  workspace_root_name = root_conf.project.name.lower()
93
248
 
94
249
  # Collect from local issues_root
95
- all_issues.extend(collect_project_issues(issues_root, local_project_name))
250
+ proj_issues, proj_diagnostics = collect_project_issues(
251
+ issues_root, local_project_name
252
+ )
253
+ all_issues.extend(proj_issues)
254
+ diagnostics.extend(proj_diagnostics)
96
255
 
97
256
  if recursive:
98
257
  try:
@@ -103,34 +262,76 @@ def check_integrity(issues_root: Path, recursive: bool = False) -> List[Diagnost
103
262
  if workspace_root != issues_root.parent:
104
263
  root_issues_dir = workspace_root / "Issues"
105
264
  if root_issues_dir.exists():
106
- all_issues.extend(
107
- collect_project_issues(
108
- root_issues_dir, ws_conf.project.name.lower()
109
- )
265
+ r_issues, r_diags = collect_project_issues(
266
+ root_issues_dir, ws_conf.project.name.lower()
110
267
  )
268
+ all_issues.extend(r_issues)
269
+ diagnostics.extend(r_diags)
111
270
 
112
271
  # Index all members
113
272
  for member_name, rel_path in ws_conf.project.members.items():
114
273
  member_root = (workspace_root / rel_path).resolve()
115
274
  member_issues_dir = member_root / "Issues"
116
275
  if member_issues_dir.exists() and member_issues_dir != issues_root:
117
- all_issues.extend(
118
- collect_project_issues(member_issues_dir, member_name.lower())
276
+ m_issues, m_diags = collect_project_issues(
277
+ member_issues_dir, member_name.lower()
119
278
  )
279
+ all_issues.extend(m_issues)
280
+ diagnostics.extend(m_diags)
120
281
  except Exception:
121
282
  pass
122
283
 
123
284
  # 2. Validation Phase
285
+ valid_domains = set()
286
+ # Now validate
287
+ for path, meta, project_name in all_issues:
288
+ if meta == "DOMAIN":
289
+ valid_domains.add(
290
+ project_name
291
+ ) # Record the domain name (which was stored in project_name slot)
292
+
124
293
  for path, meta, project_name in all_issues:
294
+ if meta == "DOMAIN":
295
+ # Track B: Domain Validation
296
+ # Already did semantic checks in collection phase (H1 etc)
297
+ # Now do Source Language Check
298
+ try:
299
+ from monoco.features.i18n import core as i18n_core
300
+
301
+ # We need source_lang from config.
302
+ # We have 'conf' object from earlier.
303
+ source_lang = "en"
304
+ if conf and conf.i18n and conf.i18n.source_lang:
305
+ source_lang = conf.i18n.source_lang
306
+
307
+ if not i18n_core.is_content_source_language(path, source_lang):
308
+ diagnostics.append(
309
+ Diagnostic(
310
+ range=Range(
311
+ start=Position(line=0, character=0),
312
+ end=Position(line=0, character=0),
313
+ ),
314
+ message=f"Language Mismatch: Domain definition appears not to be in source language '{source_lang}'.",
315
+ severity=DiagnosticSeverity.Warning,
316
+ source="DomainValidator",
317
+ )
318
+ )
319
+ except Exception:
320
+ pass
321
+ continue
322
+
323
+ # Track A: Issue Validation
125
324
  content = path.read_text() # Re-read content for validation
126
325
 
127
326
  # A. Run Core Validator
327
+ # Pass valid_domains kwarg (Validator needs update to accept it)
128
328
  file_diagnostics = validator.validate(
129
329
  meta,
130
330
  content,
131
331
  all_issue_ids,
132
332
  current_project=project_name,
133
333
  workspace_root=workspace_root_name,
334
+ valid_domains=valid_domains,
134
335
  )
135
336
 
136
337
  # Add context to diagnostics (Path)
@@ -183,6 +384,13 @@ def run_lint(
183
384
  except Exception:
184
385
  pass
185
386
 
387
+ # Collect valid domains
388
+ valid_domains = set()
389
+ domains_dir = issues_root / "Domains"
390
+ if domains_dir.exists():
391
+ for f in domains_dir.rglob("*.md"):
392
+ valid_domains.add(f.stem)
393
+
186
394
  validator = IssueValidator(issues_root)
187
395
 
188
396
  for file_path in file_paths:
@@ -209,7 +417,11 @@ def run_lint(
209
417
  current_project_name = conf.project.name.lower()
210
418
 
211
419
  file_diagnostics = validator.validate(
212
- meta, content, all_issue_ids, current_project=current_project_name
420
+ meta,
421
+ content,
422
+ all_issue_ids,
423
+ current_project=current_project_name,
424
+ valid_domains=valid_domains,
213
425
  )
214
426
 
215
427
  # Add context
@@ -465,12 +677,15 @@ def run_lint(
465
677
  except Exception as e:
466
678
  console.print(f"[red]Failed to fix domains for {path.name}: {e}[/red]")
467
679
 
468
- # Domain Alias Fix
680
+ # Domain Alias and Format Fix
469
681
  try:
470
- alias_fixes = [
471
- d for d in current_file_diags if "Domain Alias:" in d.message
682
+ format_fixes = [
683
+ d
684
+ for d in current_file_diags
685
+ if "Domain Format Error:" in d.message
686
+ or "Domain Alias:" in d.message
472
687
  ]
473
- if alias_fixes:
688
+ if format_fixes:
474
689
  fm_match = re.search(
475
690
  r"^---(.*?)---", new_content, re.DOTALL | re.MULTILINE
476
691
  )
@@ -483,24 +698,41 @@ def run_lint(
483
698
  domain_changed = False
484
699
  if "domains" in data and isinstance(data["domains"], list):
485
700
  domains = data["domains"]
486
- for d in alias_fixes:
487
- # Parse message: Domain Alias: 'alias' is an alias for 'canonical'.
488
- m = re.search(
489
- r"Domain Alias: '([^']+)' is an alias for '([^']+)'",
490
- d.message,
491
- )
701
+ for d in format_fixes:
702
+ if "Domain Format Error:" in d.message:
703
+ # Message: Domain Format Error: 'alias' must be PascalCase (e.g., 'canonical').
704
+ m = re.search(
705
+ r"Domain Format Error: '([^']+)' must be PascalCase \(e.g\., '([^']+)'\)",
706
+ d.message,
707
+ )
708
+ else:
709
+ # Message: Domain Alias: 'alias' is an alias for 'canonical'.
710
+ m = re.search(
711
+ r"Domain Alias: '([^']+)' is an alias for '([^']+)'",
712
+ d.message,
713
+ )
714
+
492
715
  if m:
493
716
  old_d = m.group(1)
494
717
  new_d = m.group(2)
495
718
 
496
719
  if old_d in domains:
720
+ # Replace exact match
497
721
  domains = [
498
722
  new_d if x == old_d else x for x in domains
499
723
  ]
500
724
  domain_changed = True
501
725
 
502
726
  if domain_changed:
503
- data["domains"] = domains
727
+ # Deduplicate while preserving order if needed, but set is easier
728
+ seen = set()
729
+ unique_domains = []
730
+ for dom in domains:
731
+ if dom not in seen:
732
+ unique_domains.append(dom)
733
+ seen.add(dom)
734
+
735
+ data["domains"] = unique_domains
504
736
  new_fm_text = yaml.dump(
505
737
  data, sort_keys=False, allow_unicode=True
506
738
  )
@@ -508,25 +740,17 @@ def run_lint(
508
740
  fm_match.group(1), "\n" + new_fm_text
509
741
  )
510
742
  has_changes = True
511
-
512
- # Write immediately if not handled by previous block?
513
- # We are in standard flow where has_changes flag handles write at end of loop?
514
- # Wait, the previous block (Missing domains) logic wrote internally ONLY if has_changes.
515
- # AND it reset has_changes=False at start of try?
516
- # Actually the previous block structure was separate try-except blocks.
517
- # But here I am inserting AFTER the Missing Domains try-except (which was lines 390-442).
518
- # But I need to write if I changed it.
519
743
  path.write_text(new_content)
520
744
  if not any(path == p for p in processed_paths):
521
745
  fixed_count += 1
522
746
  processed_paths.add(path)
523
747
  console.print(
524
- f"[dim]Fixed (Domain Alias): {path.name}[/dim]"
748
+ f"[dim]Fixed (Domain Normalization): {path.name}[/dim]"
525
749
  )
526
750
 
527
751
  except Exception as e:
528
752
  console.print(
529
- f"[red]Failed to fix domain aliases for {path.name}: {e}[/red]"
753
+ f"[red]Failed to fix domain normalization for {path.name}: {e}[/red]"
530
754
  )
531
755
 
532
756
  console.print(f"[green]Applied auto-fixes to {fixed_count} files.[/green]")
@@ -548,7 +772,12 @@ def run_lint(
548
772
  try:
549
773
  meta = core.parse_issue(file)
550
774
  content = file.read_text()
551
- file_diagnostics = validator.validate(meta, content, all_issue_ids)
775
+ file_diagnostics = validator.validate(
776
+ meta,
777
+ content,
778
+ all_issue_ids,
779
+ valid_domains=valid_domains,
780
+ )
552
781
  for d in file_diagnostics:
553
782
  d.source = meta.id
554
783
  d.data = {"path": file}
@@ -1,9 +1,19 @@
1
1
  from enum import Enum
2
2
  from typing import List, Optional, Any, Dict
3
- from pydantic import BaseModel, Field, model_validator
3
+ from pydantic import BaseModel, Field, model_validator, ConfigDict, field_validator
4
4
  from datetime import datetime
5
5
  import hashlib
6
6
  import secrets
7
+ import re
8
+
9
+ from .criticality import CriticalityLevel, Policy, PolicyResolver
10
+
11
+
12
+ # Forward reference for type hints
13
+ class CommitResult:
14
+ """Result of a commit operation (defined in git_service)."""
15
+
16
+ pass
7
17
 
8
18
 
9
19
  class IssueID:
@@ -105,13 +115,24 @@ class IssueAction(BaseModel):
105
115
 
106
116
 
107
117
  class IssueMetadata(BaseModel):
108
- model_config = {"extra": "allow"}
118
+ model_config = ConfigDict(extra="allow", validate_assignment=True)
119
+
120
+ id: str = Field()
121
+
122
+ @field_validator("id")
123
+ @classmethod
124
+ def validate_id_format(cls, v: str) -> str:
125
+ if not re.match(r"^[A-Z]+-\d{4}$", v):
126
+ raise ValueError(
127
+ f"Invalid Issue ID format: '{v}'. Expected 'TYPE-XXXX' (e.g., FEAT-1234). "
128
+ "For sub-features or sub-tasks, please use the 'parent' field instead of adding suffixes to the ID."
129
+ )
130
+ return v
109
131
 
110
- id: str
111
132
  uid: Optional[str] = None # Global unique identifier for cross-project identity
112
- type: str
113
- status: str = "open"
114
- stage: Optional[str] = None
133
+ type: IssueType
134
+ status: IssueStatus = IssueStatus.OPEN
135
+ stage: Optional[IssueStage] = None
115
136
  title: str
116
137
 
117
138
  # Time Anchors
@@ -122,7 +143,7 @@ class IssueMetadata(BaseModel):
122
143
 
123
144
  parent: Optional[str] = None
124
145
  sprint: Optional[str] = None
125
- solution: Optional[str] = None
146
+ solution: Optional[IssueSolution] = None
126
147
  isolation: Optional[IssueIsolation] = None
127
148
  dependencies: List[str] = []
128
149
  related: List[str] = []
@@ -131,10 +152,28 @@ class IssueMetadata(BaseModel):
131
152
  files: List[str] = []
132
153
  path: Optional[str] = None # Absolute path to the issue file
133
154
 
155
+ # Criticality System (FEAT-0114)
156
+ criticality: Optional[CriticalityLevel] = Field(
157
+ default=None,
158
+ description="Issue criticality level (low, medium, high, critical)",
159
+ )
160
+
134
161
  # Proxy UI Actions (Excluded from file persistence)
135
162
  # Modified: Remove exclude=True to allow API/CLI inspection. Must be manually excluded during YAML Dump.
136
163
  actions: List[IssueAction] = Field(default=[])
137
164
 
165
+ # Runtime-only field for commit result (FEAT-0115)
166
+ # Not persisted to YAML, only available in memory after update_issue
167
+ commit_result: Optional[Any] = Field(default=None, exclude=True)
168
+
169
+ @property
170
+ def resolved_policy(self) -> Policy:
171
+ """Get the resolved policy based on criticality level."""
172
+ if self.criticality:
173
+ return PolicyResolver.resolve(self.criticality)
174
+ # Default to medium policy if not set
175
+ return PolicyResolver.resolve(CriticalityLevel.MEDIUM)
176
+
138
177
  @model_validator(mode="before")
139
178
  @classmethod
140
179
  def normalize_fields(cls, v: Any) -> Any:
@@ -161,26 +200,64 @@ class IssueMetadata(BaseModel):
161
200
  # Normalize type and status to lowercase for compatibility
162
201
  if "type" in v and isinstance(v["type"], str):
163
202
  v["type"] = v["type"].lower()
203
+ try:
204
+ v["type"] = IssueType(v["type"])
205
+ except ValueError:
206
+ pass
207
+
164
208
  if "status" in v and isinstance(v["status"], str):
165
209
  v["status"] = v["status"].lower()
210
+ try:
211
+ v["status"] = IssueStatus(v["status"])
212
+ except ValueError:
213
+ pass
214
+
166
215
  if "solution" in v and isinstance(v["solution"], str):
167
216
  v["solution"] = v["solution"].lower()
217
+ try:
218
+ v["solution"] = IssueSolution(v["solution"])
219
+ except ValueError:
220
+ pass
221
+
168
222
  # Stage normalization
169
223
  if "stage" in v and isinstance(v["stage"], str):
170
224
  v["stage"] = v["stage"].lower()
171
225
  if v["stage"] == "todo":
172
226
  v["stage"] = "draft"
227
+ try:
228
+ v["stage"] = IssueStage(v["stage"])
229
+ except ValueError:
230
+ pass
231
+
232
+ # Criticality normalization
233
+ if "criticality" in v and isinstance(v["criticality"], str):
234
+ v["criticality"] = v["criticality"].lower()
235
+ try:
236
+ v["criticality"] = CriticalityLevel(v["criticality"])
237
+ except ValueError:
238
+ pass
173
239
  return v
174
240
 
175
241
  @model_validator(mode="after")
176
242
  def validate_lifecycle(self) -> "IssueMetadata":
177
- # Logic Definition:
178
- # status: backlog -> stage: freezed
179
- # status: closed -> stage: done
180
- # status: open -> stage: draft | doing | review | done (default draft)
181
-
182
- # NOTE: We do NOT auto-correct state here anymore to allow Linter to detect inconsistencies.
183
- # Auto-correction should be applied explicitly by 'create' or 'update' commands via core logic.
243
+ # 1. Solution Consistency: Closed issues MUST have a solution
244
+ if self.status == IssueStatus.CLOSED and not self.solution:
245
+ raise ValueError(f"Issue '{self.id}' is closed but 'solution' is missing.")
246
+
247
+ # 2. Hierarchy Consistency: non-epic types MUST have a parent (except specific root seeds)
248
+ if self.type != IssueType.EPIC and not self.parent:
249
+ # We allow exceptions for very specific bootstrap cases if needed, but currently enforce it.
250
+ if self.id not in ["FEAT-BOOTSTRAP"]: # Example exception
251
+ raise ValueError(
252
+ f"Issue '{self.id}' of type '{self.type}' must have a 'parent' reference."
253
+ )
254
+
255
+ # 3. State/Stage Consistency (Warnings or Errors)
256
+ # Note: In Monoco, status: closed is tightly coupled with stage: done
257
+ if self.status == IssueStatus.CLOSED and self.stage != IssueStage.DONE:
258
+ # We could auto-fix here, but let's be strict for Validation purposes
259
+ # raise ValueError(f"Issue '{self.id}' is closed but stage is '{self.stage}' (expected 'done').")
260
+ pass
184
261
 
185
262
  return self
186
263