@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 +17 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/onemancompany/agents/product_tools.py +126 -0
- package/src/onemancompany/api/routes.py +126 -17
- package/src/onemancompany/core/config.py +1 -0
- package/src/onemancompany/core/models.py +9 -0
- package/src/onemancompany/core/product.py +241 -1
- package/src/onemancompany/core/product_triggers.py +40 -1
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
package/pyproject.toml
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
725
|
-
if not
|
|
726
|
-
raise HTTPException(status_code=503, detail="
|
|
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
|
|
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
|
|
745
|
-
|
|
760
|
+
# Create execution node under the followup node
|
|
761
|
+
exec_child = tree.add_child(
|
|
746
762
|
parent_id=followup_node.id,
|
|
747
|
-
employee_id=
|
|
763
|
+
employee_id=assignee_id,
|
|
748
764
|
description=followup_task,
|
|
749
765
|
acceptance_criteria=[],
|
|
750
766
|
)
|
|
751
|
-
schedule_node_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 +
|
|
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
|
-
|
|
777
|
+
exec_child = tree.add_child(
|
|
762
778
|
parent_id=ceo_root.id,
|
|
763
|
-
employee_id=
|
|
779
|
+
employee_id=assignee_id,
|
|
764
780
|
description=instructions,
|
|
765
781
|
acceptance_criteria=[],
|
|
766
782
|
)
|
|
767
|
-
schedule_node_id =
|
|
783
|
+
schedule_node_id = exec_child.id
|
|
768
784
|
|
|
769
785
|
_save_project_tree(pdir, tree)
|
|
770
786
|
|
|
771
|
-
# Schedule the
|
|
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(
|
|
776
|
-
employee_manager._schedule_next(
|
|
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
|
|
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:
|
|
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)
|