@1mancompany/onemancompany 0.7.19 → 0.7.23

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.
package/frontend/app.js CHANGED
@@ -795,6 +795,9 @@ class AppController {
795
795
  // Create Product modal
796
796
  this._initCreateProductModal();
797
797
 
798
+ // Product selector feedback
799
+ this._initProductSelector();
800
+
798
801
  hrBtn?.addEventListener('click', () => {
799
802
  hrBtn.disabled = true;
800
803
  this.logEntry('CEO', '🔄 Triggering quarterly review...', 'ceo');
@@ -7262,6 +7265,20 @@ class AppController {
7262
7265
  }
7263
7266
 
7264
7267
  // ===== Product Selector =====
7268
+ _initProductSelector() {
7269
+ const sel = document.getElementById('ceo-product-select');
7270
+ if (!sel) return;
7271
+ sel.addEventListener('change', () => {
7272
+ if (sel.value) {
7273
+ sel.style.borderColor = 'var(--pixel-cyan)';
7274
+ sel.style.color = 'var(--pixel-white)';
7275
+ } else {
7276
+ sel.style.borderColor = '';
7277
+ sel.style.color = '';
7278
+ }
7279
+ });
7280
+ }
7281
+
7265
7282
  async _refreshProductSelector() {
7266
7283
  try {
7267
7284
  const data = await fetch('/api/products').then(r => r.json());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1mancompany/onemancompany",
3
- "version": "0.7.19",
3
+ "version": "0.7.23",
4
4
  "description": "The AI Operating System for One-Person Companies",
5
5
  "bin": {
6
6
  "onemancompany": "bin/cli.js"
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "onemancompany"
3
- version = "0.7.19"
3
+ version = "0.7.23"
4
4
  description = "A one-man company simulation with pixel art visualization and LangChain AI agents"
5
5
  requires-python = ">=3.12"
6
6
  dependencies = [
@@ -297,6 +297,129 @@ async def update_kr_progress_tool(
297
297
  return f"Error: {e}"
298
298
 
299
299
 
300
+ # ---------------------------------------------------------------------------
301
+ # Sprint tools
302
+ # ---------------------------------------------------------------------------
303
+
304
+
305
+ @tool
306
+ async def create_sprint_tool(
307
+ product_slug: str,
308
+ name: str,
309
+ start_date: str,
310
+ end_date: str,
311
+ goal: str = "",
312
+ capacity: str = "",
313
+ ) -> str:
314
+ """Create a new sprint for a product.
315
+
316
+ Args:
317
+ product_slug: The product slug
318
+ name: Sprint name (e.g. "Sprint 3")
319
+ start_date: Start date in YYYY-MM-DD format
320
+ end_date: End date in YYYY-MM-DD format
321
+ goal: Sprint goal description
322
+ capacity: Optional capacity in story points
323
+ """
324
+ try:
325
+ cap = int(capacity) if capacity else None
326
+ sprint = prod.create_sprint(
327
+ slug=product_slug,
328
+ name=name,
329
+ start_date=start_date,
330
+ end_date=end_date,
331
+ goal=goal,
332
+ capacity=cap,
333
+ )
334
+ logger.debug("create_sprint_tool: {} in {}", sprint["id"], product_slug)
335
+ return f"Created sprint '{name}' ({sprint['id']}) for {product_slug}: {start_date} → {end_date}"
336
+ except (ValueError, FileNotFoundError) as e:
337
+ return f"Error: {e}"
338
+
339
+
340
+ @tool
341
+ async def close_sprint_tool(
342
+ product_slug: str,
343
+ sprint_id: str = "",
344
+ ) -> str:
345
+ """Close the active sprint for a product. Calculates velocity, carries over unfinished issues, generates retrospective.
346
+
347
+ Args:
348
+ product_slug: The product slug
349
+ sprint_id: Sprint ID to close. If empty, closes the active sprint.
350
+ """
351
+ try:
352
+ if not sprint_id:
353
+ active = prod.get_active_sprint(product_slug)
354
+ if not active:
355
+ return f"No active sprint found for {product_slug}"
356
+ sprint_id = active["id"]
357
+ result = prod.close_sprint(product_slug, sprint_id)
358
+ vel = result.get("velocity", 0)
359
+ rate = result.get("completion_rate", 0)
360
+ carry = result.get("carry_over_count", 0)
361
+ logger.debug("close_sprint_tool: {} closed — vel={}", sprint_id, vel)
362
+ return (
363
+ f"Sprint closed: velocity={vel} pts, completion={rate}%, "
364
+ f"carry_over={carry} issues\n\n{result.get('retrospective', '')}"
365
+ )
366
+ except (ValueError, FileNotFoundError) as e:
367
+ return f"Error: {e}"
368
+
369
+
370
+ @tool
371
+ async def get_sprint_info_tool(
372
+ product_slug: str,
373
+ sprint_id: str = "",
374
+ ) -> str:
375
+ """Get sprint information. Defaults to the active sprint if no ID given.
376
+
377
+ Args:
378
+ product_slug: The product slug
379
+ sprint_id: Sprint ID. If empty, returns the active sprint.
380
+ """
381
+ try:
382
+ if sprint_id:
383
+ sprint = prod.load_sprint(product_slug, sprint_id)
384
+ else:
385
+ sprint = prod.get_active_sprint(product_slug)
386
+
387
+ if not sprint:
388
+ # List all sprints as fallback
389
+ all_sprints = prod.list_sprints(product_slug)
390
+ if not all_sprints:
391
+ return f"No sprints found for {product_slug}"
392
+ lines = [f"No active sprint. All sprints for {product_slug}:"]
393
+ for s in all_sprints:
394
+ lines.append(f"- [{s['status']}] {s['name']} ({s['id']}) {s['start_date']}→{s['end_date']}")
395
+ return "\n".join(lines)
396
+
397
+ # Show sprint details
398
+ issues = prod.list_issues(product_slug, sprint=sprint["id"])
399
+ done = [i for i in issues if i.get("status") in ("done", "released")]
400
+ vel = sum(i.get("story_points") or 0 for i in done)
401
+ total_pts = sum(i.get("story_points") or 0 for i in issues)
402
+
403
+ lines = [
404
+ f"**{sprint['name']}** ({sprint['id']})",
405
+ f"Status: {sprint['status']}",
406
+ f"Goal: {sprint.get('goal') or 'N/A'}",
407
+ f"Period: {sprint['start_date']} → {sprint['end_date']}",
408
+ f"Issues: {len(done)}/{len(issues)} done",
409
+ f"Points: {vel}/{total_pts}",
410
+ ]
411
+ if sprint.get("capacity"):
412
+ lines.append(f"Capacity: {sprint['capacity']} pts")
413
+
414
+ suggestion = prod.suggest_capacity(product_slug)
415
+ if suggestion is not None:
416
+ lines.append(f"Suggested capacity (avg last 3): {suggestion} pts")
417
+
418
+ return "\n".join(lines)
419
+ except (ValueError, FileNotFoundError) as e:
420
+ return f"Error: {e}"
421
+
422
+
300
423
  # ---------------------------------------------------------------------------
301
424
  # Export
302
425
  # ---------------------------------------------------------------------------
@@ -309,4 +432,7 @@ PRODUCT_TOOLS = [
309
432
  get_product_context_tool,
310
433
  list_product_issues_tool,
311
434
  update_kr_progress_tool,
435
+ create_sprint_tool,
436
+ close_sprint_tool,
437
+ get_sprint_info_tool,
312
438
  ]
@@ -655,7 +655,7 @@ async def ceo_submit_task(
655
655
 
656
656
  @router.post("/api/task/{project_id}/followup")
657
657
  async def task_followup(project_id: str, body: dict) -> dict:
658
- """CEO adds follow-up instructions to an existing task, dispatched to EA with context."""
658
+ """CEO adds follow-up instructions to an existing task, dispatched to assignee (product owner or EA) with context."""
659
659
  from datetime import datetime as _dt
660
660
 
661
661
  from onemancompany.core.agent_loop import get_agent_loop
@@ -699,7 +699,23 @@ async def task_followup(project_id: str, body: dict) -> dict:
699
699
  for fname in deliverables:
700
700
  work_summary_lines.append(f" {fname}")
701
701
 
702
- # Build follow-up task for EA
702
+ # Determine assignee: product owner if product-linked, else EA
703
+ assignee_id = EA_ID
704
+ product_id = doc.get("product_id", "")
705
+ if product_id:
706
+ from onemancompany.core.product import find_slug_by_product_id, load_product
707
+ product_slug = find_slug_by_product_id(product_id)
708
+ if product_slug:
709
+ product = load_product(product_slug)
710
+ if product and product.get("owner_id"):
711
+ assignee_id = product["owner_id"]
712
+ logger.debug("[FOLLOWUP] Product-linked project {}, routing to owner {}",
713
+ project_id, assignee_id)
714
+ if assignee_id == EA_ID:
715
+ logger.debug("[FOLLOWUP] No product owner found for project {}, falling back to EA",
716
+ project_id)
717
+
718
+ # Build follow-up task for assignee
703
719
  context_parts = [
704
720
  f"CEO has added follow-up instructions to a completed task:\n",
705
721
  f"Original task: {original_task}\n",
@@ -721,11 +737,11 @@ async def task_followup(project_id: str, body: dict) -> dict:
721
737
  else:
722
738
  tree = TaskTree(project_id=project_id)
723
739
 
724
- ea_loop = get_agent_loop(EA_ID)
725
- if not ea_loop:
726
- raise HTTPException(status_code=503, detail="EA agent not available")
740
+ assignee_loop = get_agent_loop(assignee_id)
741
+ if not assignee_loop:
742
+ raise HTTPException(status_code=503, detail=f"Agent {assignee_id} not available")
727
743
 
728
- schedule_node_id = "" # will be set to the EA node to schedule
744
+ schedule_node_id = "" # will be set to the assignee node to schedule
729
745
 
730
746
  if tree.root_id:
731
747
  # Add a new subtree from CEO root — old subtree stays intact
@@ -741,39 +757,39 @@ async def task_followup(project_id: str, body: dict) -> dict:
741
757
  followup_node.node_type = NodeType.CEO_FOLLOWUP
742
758
  followup_node.status = TaskPhase.ACCEPTED.value
743
759
 
744
- # Create a new EA node under the followup node for execution
745
- ea_child = tree.add_child(
760
+ # Create execution node under the followup node
761
+ exec_child = tree.add_child(
746
762
  parent_id=followup_node.id,
747
- employee_id=EA_ID,
763
+ employee_id=assignee_id,
748
764
  description=followup_task,
749
765
  acceptance_criteria=[],
750
766
  )
751
- schedule_node_id = ea_child.id
767
+ schedule_node_id = exec_child.id
752
768
 
753
769
  # Keep CEO root in PROCESSING while new subtree runs
754
770
  if root and root.node_type == NodeType.CEO_PROMPT:
755
771
  root.status = TaskPhase.PROCESSING.value
756
772
  else:
757
- # No root yet — create CEO root + EA child
773
+ # No root yet — create CEO root + assignee child
758
774
  ceo_root = tree.create_root(employee_id=CEO_ID, description=instructions)
759
775
  ceo_root.node_type = NodeType.CEO_PROMPT
760
776
  ceo_root.set_status(TaskPhase.PROCESSING)
761
- ea_child = tree.add_child(
777
+ exec_child = tree.add_child(
762
778
  parent_id=ceo_root.id,
763
- employee_id=EA_ID,
779
+ employee_id=assignee_id,
764
780
  description=instructions,
765
781
  acceptance_criteria=[],
766
782
  )
767
- schedule_node_id = ea_child.id
783
+ schedule_node_id = exec_child.id
768
784
 
769
785
  _save_project_tree(pdir, tree)
770
786
 
771
- # Schedule the EA node for execution
787
+ # Schedule the assignee node for execution
772
788
  if schedule_node_id:
773
789
  tree_path = str(Path(pdir) / TASK_TREE_FILENAME)
774
790
  from onemancompany.core.agent_loop import employee_manager
775
- employee_manager.schedule_node(EA_ID, schedule_node_id, tree_path)
776
- employee_manager._schedule_next(EA_ID)
791
+ employee_manager.schedule_node(assignee_id, schedule_node_id, tree_path)
792
+ employee_manager._schedule_next(assignee_id)
777
793
 
778
794
  # Update project.yaml status back to in_progress
779
795
  doc["status"] = "in_progress"
@@ -7315,11 +7331,19 @@ async def api_product_detail(slug: str) -> dict:
7315
7331
  all_projects = list_projects()
7316
7332
  linked_projects = [p for p in all_projects if p.get("product_id") == product.get("id")]
7317
7333
 
7334
+ # Sprints
7335
+ sprints = prod.list_sprints(slug)
7336
+ active_sprint = prod.get_active_sprint(slug)
7337
+ suggested_capacity = prod.suggest_capacity(slug)
7338
+
7318
7339
  return {
7319
7340
  "product": product,
7320
7341
  "issues": issues,
7321
7342
  "versions": versions,
7322
7343
  "projects": linked_projects,
7344
+ "sprints": sprints,
7345
+ "active_sprint": active_sprint,
7346
+ "suggested_capacity": suggested_capacity,
7323
7347
  }
7324
7348
 
7325
7349
 
@@ -7399,3 +7423,88 @@ async def api_start_product_planning(slug: str) -> dict:
7399
7423
  product_id=product["id"],
7400
7424
  )
7401
7425
  return {"conversation_id": conv.id, "existing": False}
7426
+
7427
+
7428
+ # ── Sprints ──────────────────────────────────────────────────────────────────
7429
+
7430
+
7431
+ @router.post("/api/product/{slug}/sprint")
7432
+ async def api_create_sprint(slug: str, request: Request) -> dict:
7433
+ """Create a sprint for a product."""
7434
+ from onemancompany.core import product as prod
7435
+
7436
+ body = await request.json()
7437
+ name = body.get("name")
7438
+ start_date = body.get("start_date")
7439
+ end_date = body.get("end_date")
7440
+ if not name or not start_date or not end_date:
7441
+ raise HTTPException(status_code=400, detail="Missing required fields: name, start_date, end_date")
7442
+ try:
7443
+ result = prod.create_sprint(
7444
+ slug=slug,
7445
+ name=name,
7446
+ start_date=start_date,
7447
+ end_date=end_date,
7448
+ goal=body.get("goal", ""),
7449
+ capacity=int(body["capacity"]) if body.get("capacity") else None,
7450
+ )
7451
+ except ValueError as exc:
7452
+ raise HTTPException(status_code=404, detail=str(exc))
7453
+ return result
7454
+
7455
+
7456
+ @router.get("/api/product/{slug}/sprints")
7457
+ async def api_list_sprints(slug: str, status: str = "") -> list[dict]:
7458
+ """List sprints for a product, optionally filtered by status."""
7459
+ from onemancompany.core import product as prod
7460
+
7461
+ return prod.list_sprints(slug, status=status or None)
7462
+
7463
+
7464
+ @router.get("/api/product/{slug}/sprint/{sprint_id}")
7465
+ async def api_get_sprint(slug: str, sprint_id: str) -> dict:
7466
+ """Get a single sprint by ID."""
7467
+ from onemancompany.core import product as prod
7468
+
7469
+ sprint = prod.load_sprint(slug, sprint_id)
7470
+ if not sprint:
7471
+ raise HTTPException(status_code=404, detail=f"Sprint '{sprint_id}' not found")
7472
+ return sprint
7473
+
7474
+
7475
+ @router.put("/api/product/{slug}/sprint/{sprint_id}")
7476
+ async def api_update_sprint(slug: str, sprint_id: str, request: Request) -> dict:
7477
+ """Update sprint fields (name, goal, start_date, end_date, capacity, status)."""
7478
+ from onemancompany.core import product as prod
7479
+
7480
+ body = await request.json()
7481
+ SPRINT_MUTABLE_FIELDS = {"name", "goal", "start_date", "end_date", "capacity", "status"}
7482
+ filtered = {k: v for k, v in body.items() if k in SPRINT_MUTABLE_FIELDS}
7483
+ if "capacity" in filtered and filtered["capacity"] is not None:
7484
+ filtered["capacity"] = int(filtered["capacity"])
7485
+ try:
7486
+ result = prod.update_sprint(slug, sprint_id, **filtered)
7487
+ except ValueError as exc:
7488
+ raise HTTPException(status_code=400, detail=str(exc))
7489
+ return result
7490
+
7491
+
7492
+ @router.post("/api/product/{slug}/sprint/{sprint_id}/close")
7493
+ async def api_close_sprint(slug: str, sprint_id: str) -> dict:
7494
+ """Close a sprint: calculate velocity, carry over unfinished issues, generate retrospective."""
7495
+ from onemancompany.core import product as prod
7496
+
7497
+ try:
7498
+ result = prod.close_sprint(slug, sprint_id)
7499
+ except ValueError as exc:
7500
+ raise HTTPException(status_code=400, detail=str(exc))
7501
+ return result
7502
+
7503
+
7504
+ @router.get("/api/product/{slug}/sprint/suggest-capacity")
7505
+ async def api_suggest_sprint_capacity(slug: str) -> dict:
7506
+ """Suggest sprint capacity based on historical velocity (sliding average of last 3)."""
7507
+ from onemancompany.core import product as prod
7508
+
7509
+ suggestion = prod.suggest_capacity(slug)
7510
+ return {"suggested_capacity": suggestion}
@@ -61,6 +61,7 @@ VESSEL_YAML_FILENAME = "vessel.yaml"
61
61
  PRODUCT_YAML_FILENAME = "product.yaml"
62
62
  ISSUES_DIR_NAME = "issues"
63
63
  VERSIONS_DIR_NAME = "versions"
64
+ SPRINTS_DIR_NAME = "sprints"
64
65
  TALENT_PERSONA_FILENAME = "talent_persona.md"
65
66
  MCP_CONFIG_FILENAME = "mcp_config.json"
66
67
  CONVERSATIONS_DIR_NAME = "conversations"
@@ -162,6 +162,8 @@ class EventType(str, Enum):
162
162
  ISSUE_ASSIGNED = "issue_assigned"
163
163
  KR_UPDATED = "kr_updated"
164
164
  VERSION_RELEASED = "version_released"
165
+ SPRINT_CREATED = "sprint_created"
166
+ SPRINT_CLOSED = "sprint_closed"
165
167
 
166
168
 
167
169
  class ProductStatus(str, Enum):
@@ -197,6 +199,13 @@ class IssueResolution(str, Enum):
197
199
  BY_DESIGN = "by_design"
198
200
 
199
201
 
202
+ class SprintStatus(str, Enum):
203
+ """Sprint lifecycle status."""
204
+ PLANNING = "planning"
205
+ ACTIVE = "active"
206
+ CLOSED = "closed"
207
+
208
+
200
209
  # ---------------------------------------------------------------------------
201
210
  # Performance
202
211
  # ---------------------------------------------------------------------------
@@ -1,7 +1,8 @@
1
- """Product management — product records, key results, and issues.
1
+ """Product management — product records, key results, issues, and sprints.
2
2
 
3
3
  Products stored at: PRODUCTS_DIR/{slug}/product.yaml
4
4
  Issues stored at: PRODUCTS_DIR/{slug}/issues/{issue_id}.yaml
5
+ Sprints stored at: PRODUCTS_DIR/{slug}/sprints/{sprint_id}.yaml
5
6
 
6
7
  All YAML I/O through store._read_yaml / _write_yaml.
7
8
  Disk is the single source of truth — no in-memory caching.
@@ -20,6 +21,7 @@ from onemancompany.core.config import (
20
21
  ISSUES_DIR_NAME,
21
22
  PRODUCT_YAML_FILENAME,
22
23
  PRODUCTS_DIR,
24
+ SPRINTS_DIR_NAME,
23
25
  VERSIONS_DIR_NAME,
24
26
  DirtyCategory,
25
27
  )
@@ -28,6 +30,7 @@ from onemancompany.core.models import (
28
30
  IssuePriority,
29
31
  IssueStatus,
30
32
  ProductStatus,
33
+ SprintStatus,
31
34
  )
32
35
  from onemancompany.core.store import _read_yaml, _write_yaml, mark_dirty
33
36
 
@@ -330,6 +333,7 @@ def list_issues(
330
333
  status: IssueStatus | None = None,
331
334
  priority: IssuePriority | None = None,
332
335
  labels: list[str] | None = None,
336
+ sprint: str | None = None,
333
337
  ) -> list[dict]:
334
338
  """List issues for a product, optionally filtered."""
335
339
  issues_path = _issues_dir(slug)
@@ -351,6 +355,8 @@ def list_issues(
351
355
  issue_labels = set(data.get("labels", []))
352
356
  if not set(labels).intersection(issue_labels):
353
357
  continue
358
+ if sprint is not None and data.get("sprint") != sprint:
359
+ continue
354
360
  results.append(data)
355
361
  return results
356
362
 
@@ -806,3 +812,237 @@ def sync_issue_statuses(slug: str) -> list[dict]:
806
812
  logger.debug("[PRODUCT] Issue {} status derived: {} → {}", issue["id"], current, derived.value)
807
813
 
808
814
  return changed
815
+
816
+
817
+ # ---------------------------------------------------------------------------
818
+ # Sprint management
819
+ # ---------------------------------------------------------------------------
820
+
821
+ _DONE_STATUSES = {IssueStatus.DONE.value, IssueStatus.RELEASED.value}
822
+
823
+
824
+ def _sprints_dir(slug: str) -> Path:
825
+ return PRODUCTS_DIR / slug / SPRINTS_DIR_NAME
826
+
827
+
828
+ def create_sprint(
829
+ *,
830
+ slug: str,
831
+ name: str,
832
+ start_date: str,
833
+ end_date: str,
834
+ goal: str = "",
835
+ capacity: int | None = None,
836
+ ) -> dict:
837
+ """Create a sprint for a product. Returns the sprint dict."""
838
+ product = load_product(slug)
839
+ if not product:
840
+ raise ValueError(f"Product '{slug}' not found")
841
+
842
+ sprint_id = _gen_id("sprint_")
843
+ now = datetime.now().isoformat()
844
+
845
+ data = {
846
+ "id": sprint_id,
847
+ "product_id": product["id"],
848
+ "name": name,
849
+ "goal": goal,
850
+ "status": SprintStatus.PLANNING.value,
851
+ "start_date": start_date,
852
+ "end_date": end_date,
853
+ "capacity": capacity,
854
+ "velocity": None,
855
+ "carry_over_count": 0,
856
+ "completion_rate": None,
857
+ "retrospective": None,
858
+ "created_at": now,
859
+ "closed_at": None,
860
+ }
861
+
862
+ sdir = _sprints_dir(slug)
863
+ sdir.mkdir(parents=True, exist_ok=True)
864
+ with _get_slug_lock(slug):
865
+ _write_yaml(sdir / f"{sprint_id}.yaml", data)
866
+ mark_dirty(DirtyCategory.PRODUCTS)
867
+ logger.debug("[PRODUCT] Sprint created: {} in {}", sprint_id, slug)
868
+ return data
869
+
870
+
871
+ def load_sprint(slug: str, sprint_id: str) -> dict | None:
872
+ """Load a single sprint by ID."""
873
+ path = _sprints_dir(slug) / f"{sprint_id}.yaml"
874
+ if not path.exists():
875
+ return None
876
+ return _read_yaml(path)
877
+
878
+
879
+ def list_sprints(slug: str, status: str | None = None) -> list[dict]:
880
+ """List all sprints for a product, optionally filtered by status."""
881
+ sdir = _sprints_dir(slug)
882
+ if not sdir.exists():
883
+ return []
884
+ sprints = []
885
+ for f in sorted(sdir.iterdir()):
886
+ if f.suffix == ".yaml":
887
+ data = _read_yaml(f)
888
+ if data and (status is None or data.get("status") == status):
889
+ sprints.append(data)
890
+ return sprints
891
+
892
+
893
+ def update_sprint(slug: str, sprint_id: str, **fields) -> dict:
894
+ """Update sprint fields. Enforces single-active-sprint constraint."""
895
+ sprint = load_sprint(slug, sprint_id)
896
+ if not sprint:
897
+ raise ValueError(f"Sprint '{sprint_id}' not found in '{slug}'")
898
+
899
+ # Enforce: only one active sprint per product
900
+ new_status = fields.get("status")
901
+ if new_status == SprintStatus.ACTIVE.value:
902
+ existing_active = get_active_sprint(slug)
903
+ if existing_active and existing_active["id"] != sprint_id:
904
+ raise ValueError(f"Product '{slug}' already has an active sprint: {existing_active['id']}")
905
+
906
+ sprint.update(fields)
907
+ with _get_slug_lock(slug):
908
+ _write_yaml(_sprints_dir(slug) / f"{sprint_id}.yaml", sprint)
909
+ mark_dirty(DirtyCategory.PRODUCTS)
910
+ return sprint
911
+
912
+
913
+ def get_active_sprint(slug: str) -> dict | None:
914
+ """Return the current active sprint for a product, or None."""
915
+ active = list_sprints(slug, status=SprintStatus.ACTIVE.value)
916
+ return active[0] if active else None
917
+
918
+
919
+ def get_sprint_velocity(slug: str, sprint_id: str) -> int:
920
+ """Calculate velocity: sum of story_points for done/released issues in this sprint."""
921
+ issues = list_issues(slug, sprint=sprint_id)
922
+ total = 0
923
+ for issue in issues:
924
+ if issue.get("status") in _DONE_STATUSES:
925
+ total += issue.get("story_points") or 0
926
+ return total
927
+
928
+
929
+ def close_sprint(slug: str, sprint_id: str) -> dict:
930
+ """Close a sprint: calculate velocity, carry-over, generate retrospective."""
931
+ sprint = load_sprint(slug, sprint_id)
932
+ if not sprint:
933
+ raise ValueError(f"Sprint '{sprint_id}' not found in '{slug}'")
934
+ if sprint.get("status") != SprintStatus.ACTIVE.value:
935
+ raise ValueError(f"Sprint '{sprint_id}' is not active")
936
+
937
+ # 1. Calculate velocity
938
+ velocity = get_sprint_velocity(slug, sprint_id)
939
+
940
+ # 2. Identify unfinished issues
941
+ all_issues = list_issues(slug, sprint=sprint_id)
942
+ done_count = sum(1 for i in all_issues if i.get("status") in _DONE_STATUSES)
943
+ total_count = len(all_issues)
944
+ unfinished = [i for i in all_issues if i.get("status") not in _DONE_STATUSES]
945
+
946
+ # 3. Carry-over: find next planning sprint
947
+ planning_sprints = list_sprints(slug, status=SprintStatus.PLANNING.value)
948
+ next_sprint = planning_sprints[0] if planning_sprints else None
949
+
950
+ for issue in unfinished:
951
+ if next_sprint:
952
+ update_issue(slug, issue["id"], sprint=next_sprint["id"], carried_over=True)
953
+ else:
954
+ update_issue(slug, issue["id"], sprint="", status=IssueStatus.BACKLOG.value, carried_over=True)
955
+
956
+ # 4. Completion rate
957
+ completion_rate = round((done_count / total_count) * 100, 2) if total_count > 0 else 0.0
958
+
959
+ # 5. Retrospective
960
+ retrospective = build_sprint_retrospective(slug, sprint_id)
961
+
962
+ # 6. Update sprint record
963
+ now = datetime.now().isoformat()
964
+ updated = update_sprint(
965
+ slug, sprint_id,
966
+ status=SprintStatus.CLOSED.value,
967
+ velocity=velocity,
968
+ carry_over_count=len(unfinished),
969
+ completion_rate=completion_rate,
970
+ retrospective=retrospective,
971
+ closed_at=now,
972
+ )
973
+
974
+ logger.debug(
975
+ "[PRODUCT] Sprint {} closed — velocity={}, completion={}%, carry_over={}",
976
+ sprint_id, velocity, completion_rate, len(unfinished),
977
+ )
978
+ return updated
979
+
980
+
981
+ def suggest_capacity(slug: str) -> int | None:
982
+ """Suggest sprint capacity based on sliding average of last 3 closed sprints.
983
+
984
+ Returns None if fewer than 3 closed sprints exist.
985
+ """
986
+ closed = list_sprints(slug, status=SprintStatus.CLOSED.value)
987
+ if len(closed) < 3:
988
+ return None
989
+ # Take last 3 by closed_at
990
+ recent = sorted(closed, key=lambda s: s.get("closed_at") or "")[-3:]
991
+ velocities = [s.get("velocity") or 0 for s in recent]
992
+ return round(sum(velocities) / len(velocities))
993
+
994
+
995
+ def build_sprint_retrospective(slug: str, sprint_id: str) -> str:
996
+ """Generate a sprint retrospective report string."""
997
+ sprint = load_sprint(slug, sprint_id)
998
+ if not sprint:
999
+ return ""
1000
+
1001
+ issues = list_issues(slug, sprint=sprint_id)
1002
+ done = [i for i in issues if i.get("status") in _DONE_STATUSES]
1003
+ unfinished = [i for i in issues if i.get("status") not in _DONE_STATUSES]
1004
+ velocity = sum(i.get("story_points") or 0 for i in done)
1005
+ total_points = sum(i.get("story_points") or 0 for i in issues)
1006
+ total_count = len(issues)
1007
+ done_count = len(done)
1008
+
1009
+ # Compare with previous sprint velocity
1010
+ closed = list_sprints(slug, status=SprintStatus.CLOSED.value)
1011
+ closed_sorted = sorted(closed, key=lambda s: s.get("closed_at") or "")
1012
+ prev_velocity = None
1013
+ for cs in closed_sorted:
1014
+ if cs["id"] != sprint_id and cs.get("velocity") is not None:
1015
+ prev_velocity = cs["velocity"]
1016
+
1017
+ lines = [
1018
+ f"## Sprint Retrospective: {sprint['name']}",
1019
+ f"**Goal**: {sprint.get('goal') or 'N/A'}",
1020
+ f"**Period**: {sprint.get('start_date')} → {sprint.get('end_date')}",
1021
+ "",
1022
+ f"### Metrics",
1023
+ f"- **Velocity**: {velocity} story points",
1024
+ f"- **Completion**: {done_count}/{total_count} issues ({round(done_count / total_count * 100, 1) if total_count else 0}%)",
1025
+ f"- **Story points completed**: {velocity}/{total_points}",
1026
+ f"- **Carry-over**: {len(unfinished)} issues",
1027
+ ]
1028
+
1029
+ if prev_velocity is not None:
1030
+ delta = velocity - prev_velocity
1031
+ direction = "↑" if delta > 0 else ("↓" if delta < 0 else "→")
1032
+ lines.append(f"- **vs Previous Sprint**: {direction} {abs(delta)} points ({prev_velocity} → {velocity})")
1033
+
1034
+ if done:
1035
+ lines.append("")
1036
+ lines.append("### Completed")
1037
+ for i in done:
1038
+ pts = f" ({i.get('story_points') or 0}pts)" if i.get("story_points") else ""
1039
+ lines.append(f"- ✓ {i['title']}{pts}")
1040
+
1041
+ if unfinished:
1042
+ lines.append("")
1043
+ lines.append("### Carried Over")
1044
+ for i in unfinished:
1045
+ pts = f" ({i.get('story_points') or 0}pts)" if i.get("story_points") else ""
1046
+ lines.append(f"- ○ {i['title']}{pts}")
1047
+
1048
+ return "\n".join(lines)
@@ -469,7 +469,31 @@ async def run_product_check(product_slug: str) -> dict:
469
469
  actions_taken.append(f"Created issue for KR: {kr_title}")
470
470
  all_issues.append(issue) # prevent duplicate creation in same cycle
471
471
 
472
- # --- Step 3: Check if owner review is needed ---
472
+ # --- Step 3: Sprint expiry check ---
473
+ from datetime import date as _date
474
+
475
+ active_sprint = prod.get_active_sprint(product_slug)
476
+ if active_sprint:
477
+ end_date_str = active_sprint.get("end_date", "")
478
+ try:
479
+ end_date = _date.fromisoformat(end_date_str)
480
+ if _date.today() > end_date:
481
+ actions_taken.append(f"Sprint '{active_sprint['name']}' expired on {end_date_str}")
482
+ except (ValueError, TypeError):
483
+ logger.debug("[PRODUCT_CHECK] Invalid end_date '{}' on sprint {}", end_date_str, active_sprint.get("id"))
484
+
485
+ # --- Step 4: Backlog grooming reminder ---
486
+ _BACKLOG_GROOMING_THRESHOLD = 5
487
+ unscheduled_low = [
488
+ i for i in all_issues
489
+ if i.get("priority") in (IssuePriority.P2.value, IssuePriority.P3.value)
490
+ and not i.get("sprint")
491
+ and i.get("status") not in (IssueStatus.DONE.value, IssueStatus.RELEASED.value)
492
+ ]
493
+ if len(unscheduled_low) >= _BACKLOG_GROOMING_THRESHOLD:
494
+ actions_taken.append(f"{len(unscheduled_low)} P2/P3 issues unscheduled — backlog grooming needed")
495
+
496
+ # --- Step 5: Check if owner review is needed ---
473
497
  # Conditions: backlog issues with no one working, or KRs at 0% with completed projects
474
498
  needs_review = False
475
499
  review_reasons = []
@@ -491,6 +515,21 @@ async def run_product_check(product_slug: str) -> dict:
491
515
  needs_review = True
492
516
  review_reasons.append(f"{len(stale_krs)} KRs at 0% despite {len(completed_projects)} completed projects")
493
517
 
518
+ # Sprint expired → needs owner review
519
+ if active_sprint:
520
+ try:
521
+ end_date = _date.fromisoformat(active_sprint.get("end_date", ""))
522
+ if _date.today() > end_date:
523
+ needs_review = True
524
+ review_reasons.append(f"Sprint '{active_sprint['name']}' expired")
525
+ except (ValueError, TypeError):
526
+ logger.debug("[PRODUCT_CHECK] Invalid end_date on sprint {} for review check", active_sprint.get("id"))
527
+
528
+ # Backlog grooming threshold → needs owner review
529
+ if len(unscheduled_low) >= _BACKLOG_GROOMING_THRESHOLD:
530
+ needs_review = True
531
+ review_reasons.append(f"{len(unscheduled_low)} P2/P3 issues need sprint assignment")
532
+
494
533
  if needs_review:
495
534
  reason = "; ".join(review_reasons)
496
535
  notified = await notify_owner(product_slug, reason=reason)