nc1709 1.15.4__py3-none-any.whl → 1.18.8__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.
@@ -0,0 +1,526 @@
1
+ """
2
+ Project Requirements Tracker
3
+ Persistent tracking of project requirements and user requests.
4
+ """
5
+ import json
6
+ import os
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Dict, List, Optional, Any
10
+ from dataclasses import dataclass, field, asdict
11
+ from enum import Enum
12
+ from datetime import datetime
13
+
14
+
15
+ class RequirementStatus(Enum):
16
+ """Status of a requirement"""
17
+ PENDING = "pending" # Not started
18
+ IN_PROGRESS = "in_progress" # Currently working on
19
+ COMPLETED = "completed" # Done
20
+ DEFERRED = "deferred" # Postponed
21
+ CANCELLED = "cancelled" # No longer needed
22
+
23
+
24
+ class RequirementPriority(Enum):
25
+ """Priority levels"""
26
+ HIGH = "high"
27
+ MEDIUM = "medium"
28
+ LOW = "low"
29
+
30
+
31
+ @dataclass
32
+ class Requirement:
33
+ """A single project requirement"""
34
+ id: str
35
+ title: str
36
+ description: str
37
+ status: str = RequirementStatus.PENDING.value
38
+ priority: str = RequirementPriority.MEDIUM.value
39
+ created_at: str = ""
40
+ updated_at: str = ""
41
+ completed_at: str = ""
42
+ notes: List[str] = field(default_factory=list)
43
+ sub_tasks: List[str] = field(default_factory=list)
44
+ tags: List[str] = field(default_factory=list)
45
+
46
+ def __post_init__(self):
47
+ if not self.created_at:
48
+ self.created_at = datetime.now().isoformat()
49
+ if not self.updated_at:
50
+ self.updated_at = self.created_at
51
+
52
+ def to_dict(self) -> Dict:
53
+ return asdict(self)
54
+
55
+ @classmethod
56
+ def from_dict(cls, data: Dict) -> "Requirement":
57
+ return cls(**data)
58
+
59
+
60
+ @dataclass
61
+ class Project:
62
+ """Project containing requirements"""
63
+ name: str
64
+ description: str = ""
65
+ created_at: str = ""
66
+ updated_at: str = ""
67
+ requirements: List[Requirement] = field(default_factory=list)
68
+
69
+ def __post_init__(self):
70
+ if not self.created_at:
71
+ self.created_at = datetime.now().isoformat()
72
+ if not self.updated_at:
73
+ self.updated_at = self.created_at
74
+
75
+ def to_dict(self) -> Dict:
76
+ return {
77
+ "name": self.name,
78
+ "description": self.description,
79
+ "created_at": self.created_at,
80
+ "updated_at": self.updated_at,
81
+ "requirements": [r.to_dict() for r in self.requirements]
82
+ }
83
+
84
+ @classmethod
85
+ def from_dict(cls, data: Dict) -> "Project":
86
+ reqs = [Requirement.from_dict(r) for r in data.get("requirements", [])]
87
+ return cls(
88
+ name=data["name"],
89
+ description=data.get("description", ""),
90
+ created_at=data.get("created_at", ""),
91
+ updated_at=data.get("updated_at", ""),
92
+ requirements=reqs
93
+ )
94
+
95
+
96
+ class RequirementsTracker:
97
+ """
98
+ Manages project requirements with persistence.
99
+
100
+ Features:
101
+ - Add/update/remove requirements
102
+ - Track status and priority
103
+ - Persist to file
104
+ - Query and filter requirements
105
+ """
106
+
107
+ # Storage location for requirements
108
+ STORAGE_DIR = ".nc1709"
109
+ STORAGE_FILE = "requirements.json"
110
+
111
+ def __init__(self, project_root: Optional[Path] = None):
112
+ """
113
+ Initialize the tracker.
114
+
115
+ Args:
116
+ project_root: Root directory of the project (defaults to cwd)
117
+ """
118
+ self.project_root = project_root or Path.cwd()
119
+ self.storage_path = self.project_root / self.STORAGE_DIR / self.STORAGE_FILE
120
+ self.project: Optional[Project] = None
121
+ self._load()
122
+
123
+ def _ensure_storage_dir(self) -> None:
124
+ """Ensure storage directory exists"""
125
+ storage_dir = self.project_root / self.STORAGE_DIR
126
+ storage_dir.mkdir(exist_ok=True)
127
+
128
+ def _load(self) -> None:
129
+ """Load requirements from disk"""
130
+ if self.storage_path.exists():
131
+ try:
132
+ data = json.loads(self.storage_path.read_text())
133
+ self.project = Project.from_dict(data)
134
+ except (json.JSONDecodeError, KeyError):
135
+ self.project = None
136
+ else:
137
+ self.project = None
138
+
139
+ def _save(self) -> None:
140
+ """Save requirements to disk"""
141
+ if self.project:
142
+ self._ensure_storage_dir()
143
+ self.project.updated_at = datetime.now().isoformat()
144
+ self.storage_path.write_text(
145
+ json.dumps(self.project.to_dict(), indent=2)
146
+ )
147
+
148
+ def _generate_id(self) -> str:
149
+ """Generate a unique requirement ID"""
150
+ import hashlib
151
+ timestamp = str(time.time()).encode()
152
+ return f"REQ-{hashlib.md5(timestamp).hexdigest()[:8].upper()}"
153
+
154
+ def init_project(self, name: str, description: str = "") -> Project:
155
+ """
156
+ Initialize a new project.
157
+
158
+ Args:
159
+ name: Project name
160
+ description: Project description
161
+
162
+ Returns:
163
+ The created project
164
+ """
165
+ self.project = Project(name=name, description=description)
166
+ self._save()
167
+ return self.project
168
+
169
+ def has_project(self) -> bool:
170
+ """Check if a project is initialized"""
171
+ return self.project is not None
172
+
173
+ def get_project(self) -> Optional[Project]:
174
+ """Get the current project"""
175
+ return self.project
176
+
177
+ def add_requirement(
178
+ self,
179
+ title: str,
180
+ description: str = "",
181
+ priority: str = RequirementPriority.MEDIUM.value,
182
+ tags: List[str] = None
183
+ ) -> Requirement:
184
+ """
185
+ Add a new requirement.
186
+
187
+ Args:
188
+ title: Short requirement title
189
+ description: Detailed description
190
+ priority: Priority level
191
+ tags: Tags for categorization
192
+
193
+ Returns:
194
+ The created requirement
195
+ """
196
+ if not self.project:
197
+ # Auto-create project based on directory name
198
+ self.init_project(self.project_root.name)
199
+
200
+ req = Requirement(
201
+ id=self._generate_id(),
202
+ title=title,
203
+ description=description,
204
+ priority=priority,
205
+ tags=tags or []
206
+ )
207
+
208
+ self.project.requirements.append(req)
209
+ self._save()
210
+ return req
211
+
212
+ def update_requirement(
213
+ self,
214
+ req_id: str,
215
+ title: Optional[str] = None,
216
+ description: Optional[str] = None,
217
+ status: Optional[str] = None,
218
+ priority: Optional[str] = None,
219
+ notes: Optional[List[str]] = None,
220
+ sub_tasks: Optional[List[str]] = None,
221
+ tags: Optional[List[str]] = None
222
+ ) -> Optional[Requirement]:
223
+ """
224
+ Update an existing requirement.
225
+
226
+ Args:
227
+ req_id: Requirement ID
228
+ title: New title (if changing)
229
+ description: New description (if changing)
230
+ status: New status (if changing)
231
+ priority: New priority (if changing)
232
+ notes: New notes (if changing)
233
+ sub_tasks: New sub-tasks (if changing)
234
+ tags: New tags (if changing)
235
+
236
+ Returns:
237
+ Updated requirement or None if not found
238
+ """
239
+ if not self.project:
240
+ return None
241
+
242
+ req = self.get_requirement(req_id)
243
+ if not req:
244
+ return None
245
+
246
+ if title is not None:
247
+ req.title = title
248
+ if description is not None:
249
+ req.description = description
250
+ if status is not None:
251
+ req.status = status
252
+ if status == RequirementStatus.COMPLETED.value:
253
+ req.completed_at = datetime.now().isoformat()
254
+ if priority is not None:
255
+ req.priority = priority
256
+ if notes is not None:
257
+ req.notes = notes
258
+ if sub_tasks is not None:
259
+ req.sub_tasks = sub_tasks
260
+ if tags is not None:
261
+ req.tags = tags
262
+
263
+ req.updated_at = datetime.now().isoformat()
264
+ self._save()
265
+ return req
266
+
267
+ def add_note(self, req_id: str, note: str) -> Optional[Requirement]:
268
+ """Add a note to a requirement"""
269
+ if not self.project:
270
+ return None
271
+
272
+ req = self.get_requirement(req_id)
273
+ if not req:
274
+ return None
275
+
276
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
277
+ req.notes.append(f"[{timestamp}] {note}")
278
+ req.updated_at = datetime.now().isoformat()
279
+ self._save()
280
+ return req
281
+
282
+ def add_sub_task(self, req_id: str, sub_task: str) -> Optional[Requirement]:
283
+ """Add a sub-task to a requirement"""
284
+ if not self.project:
285
+ return None
286
+
287
+ req = self.get_requirement(req_id)
288
+ if not req:
289
+ return None
290
+
291
+ req.sub_tasks.append(sub_task)
292
+ req.updated_at = datetime.now().isoformat()
293
+ self._save()
294
+ return req
295
+
296
+ def set_status(self, req_id: str, status: RequirementStatus) -> Optional[Requirement]:
297
+ """Set requirement status"""
298
+ return self.update_requirement(req_id, status=status.value)
299
+
300
+ def remove_requirement(self, req_id: str) -> bool:
301
+ """
302
+ Remove a requirement.
303
+
304
+ Args:
305
+ req_id: Requirement ID
306
+
307
+ Returns:
308
+ True if removed, False if not found
309
+ """
310
+ if not self.project:
311
+ return False
312
+
313
+ for i, req in enumerate(self.project.requirements):
314
+ if req.id == req_id:
315
+ self.project.requirements.pop(i)
316
+ self._save()
317
+ return True
318
+ return False
319
+
320
+ def get_requirement(self, req_id: str) -> Optional[Requirement]:
321
+ """Get a requirement by ID"""
322
+ if not self.project:
323
+ return None
324
+
325
+ for req in self.project.requirements:
326
+ if req.id == req_id:
327
+ return req
328
+ return None
329
+
330
+ def get_requirements(
331
+ self,
332
+ status: Optional[str] = None,
333
+ priority: Optional[str] = None,
334
+ tag: Optional[str] = None
335
+ ) -> List[Requirement]:
336
+ """
337
+ Get filtered requirements.
338
+
339
+ Args:
340
+ status: Filter by status
341
+ priority: Filter by priority
342
+ tag: Filter by tag
343
+
344
+ Returns:
345
+ List of matching requirements
346
+ """
347
+ if not self.project:
348
+ return []
349
+
350
+ reqs = self.project.requirements
351
+
352
+ if status:
353
+ reqs = [r for r in reqs if r.status == status]
354
+ if priority:
355
+ reqs = [r for r in reqs if r.priority == priority]
356
+ if tag:
357
+ reqs = [r for r in reqs if tag in r.tags]
358
+
359
+ return reqs
360
+
361
+ def get_pending(self) -> List[Requirement]:
362
+ """Get all pending requirements"""
363
+ return self.get_requirements(status=RequirementStatus.PENDING.value)
364
+
365
+ def get_in_progress(self) -> List[Requirement]:
366
+ """Get all in-progress requirements"""
367
+ return self.get_requirements(status=RequirementStatus.IN_PROGRESS.value)
368
+
369
+ def get_completed(self) -> List[Requirement]:
370
+ """Get all completed requirements"""
371
+ return self.get_requirements(status=RequirementStatus.COMPLETED.value)
372
+
373
+ def get_summary(self) -> Dict[str, Any]:
374
+ """
375
+ Get a summary of requirements.
376
+
377
+ Returns:
378
+ Dictionary with counts and stats
379
+ """
380
+ if not self.project:
381
+ return {
382
+ "project": None,
383
+ "total": 0,
384
+ "pending": 0,
385
+ "in_progress": 0,
386
+ "completed": 0,
387
+ "deferred": 0,
388
+ "cancelled": 0,
389
+ }
390
+
391
+ reqs = self.project.requirements
392
+ return {
393
+ "project": self.project.name,
394
+ "description": self.project.description,
395
+ "total": len(reqs),
396
+ "pending": len([r for r in reqs if r.status == RequirementStatus.PENDING.value]),
397
+ "in_progress": len([r for r in reqs if r.status == RequirementStatus.IN_PROGRESS.value]),
398
+ "completed": len([r for r in reqs if r.status == RequirementStatus.COMPLETED.value]),
399
+ "deferred": len([r for r in reqs if r.status == RequirementStatus.DEFERRED.value]),
400
+ "cancelled": len([r for r in reqs if r.status == RequirementStatus.CANCELLED.value]),
401
+ }
402
+
403
+ def format_requirement(self, req: Requirement, verbose: bool = False) -> str:
404
+ """
405
+ Format a requirement for display.
406
+
407
+ Args:
408
+ req: The requirement
409
+ verbose: Include full details
410
+
411
+ Returns:
412
+ Formatted string
413
+ """
414
+ # Status icons
415
+ status_icons = {
416
+ RequirementStatus.PENDING.value: "○",
417
+ RequirementStatus.IN_PROGRESS.value: "●",
418
+ RequirementStatus.COMPLETED.value: "✓",
419
+ RequirementStatus.DEFERRED.value: "◐",
420
+ RequirementStatus.CANCELLED.value: "✗",
421
+ }
422
+
423
+ # Priority colors (ANSI)
424
+ priority_colors = {
425
+ RequirementPriority.HIGH.value: "\033[91m", # Red
426
+ RequirementPriority.MEDIUM.value: "\033[93m", # Yellow
427
+ RequirementPriority.LOW.value: "\033[92m", # Green
428
+ }
429
+ reset = "\033[0m"
430
+
431
+ icon = status_icons.get(req.status, "○")
432
+ priority_color = priority_colors.get(req.priority, "")
433
+
434
+ line = f"{icon} [{req.id}] {priority_color}{req.title}{reset}"
435
+
436
+ if req.priority == RequirementPriority.HIGH.value:
437
+ line += " 🔥"
438
+
439
+ if verbose:
440
+ line += f"\n Status: {req.status}"
441
+ line += f"\n Priority: {req.priority}"
442
+ if req.description:
443
+ line += f"\n Description: {req.description}"
444
+ if req.tags:
445
+ line += f"\n Tags: {', '.join(req.tags)}"
446
+ if req.sub_tasks:
447
+ line += f"\n Sub-tasks:"
448
+ for task in req.sub_tasks:
449
+ line += f"\n - {task}"
450
+ if req.notes:
451
+ line += f"\n Notes:"
452
+ for note in req.notes[-3:]: # Show last 3 notes
453
+ line += f"\n {note}"
454
+ line += f"\n Created: {req.created_at[:16]}"
455
+ if req.completed_at:
456
+ line += f"\n Completed: {req.completed_at[:16]}"
457
+
458
+ return line
459
+
460
+ def format_all(self, verbose: bool = False, include_completed: bool = False) -> str:
461
+ """
462
+ Format all requirements for display.
463
+
464
+ Args:
465
+ verbose: Include full details
466
+ include_completed: Include completed requirements
467
+
468
+ Returns:
469
+ Formatted string of all requirements
470
+ """
471
+ if not self.project:
472
+ return "No project initialized. Use /requirements init <name> to start."
473
+
474
+ lines = []
475
+ lines.append(f"📋 Project: {self.project.name}")
476
+ if self.project.description:
477
+ lines.append(f" {self.project.description}")
478
+ lines.append("")
479
+
480
+ # Group by status
481
+ statuses = [
482
+ (RequirementStatus.IN_PROGRESS.value, "In Progress"),
483
+ (RequirementStatus.PENDING.value, "Pending"),
484
+ ]
485
+
486
+ if include_completed:
487
+ statuses.extend([
488
+ (RequirementStatus.COMPLETED.value, "Completed"),
489
+ (RequirementStatus.DEFERRED.value, "Deferred"),
490
+ ])
491
+
492
+ for status_value, status_name in statuses:
493
+ reqs = [r for r in self.project.requirements if r.status == status_value]
494
+ if reqs:
495
+ lines.append(f"── {status_name} ({len(reqs)}) ──")
496
+ for req in reqs:
497
+ lines.append(self.format_requirement(req, verbose))
498
+ lines.append("")
499
+
500
+ # Summary
501
+ summary = self.get_summary()
502
+ lines.append(f"Total: {summary['total']} | "
503
+ f"Pending: {summary['pending']} | "
504
+ f"In Progress: {summary['in_progress']} | "
505
+ f"Completed: {summary['completed']}")
506
+
507
+ return "\n".join(lines)
508
+
509
+
510
+ # Global instance
511
+ _tracker: Optional[RequirementsTracker] = None
512
+
513
+
514
+ def get_tracker() -> RequirementsTracker:
515
+ """Get the global requirements tracker"""
516
+ global _tracker
517
+ if _tracker is None:
518
+ _tracker = RequirementsTracker()
519
+ return _tracker
520
+
521
+
522
+ def reset_tracker(project_root: Optional[Path] = None) -> RequirementsTracker:
523
+ """Reset the tracker with a new project root"""
524
+ global _tracker
525
+ _tracker = RequirementsTracker(project_root)
526
+ return _tracker