@1mancompany/onemancompany 0.7.44 → 0.7.48
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/task-tree-g6.js +3 -3
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/onemancompany/agents/base.py +4 -0
- package/src/onemancompany/agents/product_tools.py +42 -0
- package/src/onemancompany/core/product.py +61 -4
- package/src/onemancompany/core/product_triggers.py +147 -70
package/frontend/task-tree-g6.js
CHANGED
|
@@ -26,11 +26,11 @@ const STATUS_STYLES = {
|
|
|
26
26
|
blocked: { color: '#f97316', label: '\u2298 Blocked' },
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
const CARD_W =
|
|
29
|
+
const CARD_W = 260;
|
|
30
30
|
const CARD_MIN_H = 72; // minimum card height (no description)
|
|
31
31
|
const DESC_LINE_H = 14; // height per description line
|
|
32
|
-
const DESC_MAX_LINES =
|
|
33
|
-
const DESC_CHARS_PER_LINE =
|
|
32
|
+
const DESC_MAX_LINES = 12; // cap description lines (was 6 — caused overflow/truncation)
|
|
33
|
+
const DESC_CHARS_PER_LINE = 38;
|
|
34
34
|
const CHILDREN_PAGE_SIZE = 5;
|
|
35
35
|
|
|
36
36
|
/* ─────────────────── Word-wrap helper (shared with old code) ────── */
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -641,6 +641,7 @@ class BaseAgentRunner:
|
|
|
641
641
|
content = last_msg.content or ""
|
|
642
642
|
if isinstance(content, str):
|
|
643
643
|
on_log("llm_input", f"[{type(last_msg).__name__}] {content}")
|
|
644
|
+
logger.debug("[LLM INPUT] employee={}: {}", self.employee_id, content[:3000])
|
|
644
645
|
elif kind == "on_chat_model_end":
|
|
645
646
|
output = data.get("output", None)
|
|
646
647
|
if output:
|
|
@@ -671,6 +672,7 @@ class BaseAgentRunner:
|
|
|
671
672
|
if text.strip():
|
|
672
673
|
final_content = text # track last AI output
|
|
673
674
|
on_log("llm_output", text)
|
|
675
|
+
logger.debug("[LLM OUTPUT] employee={}: {}", self.employee_id, text[:3000])
|
|
674
676
|
tool_calls = getattr(output, "tool_calls", None)
|
|
675
677
|
if tool_calls:
|
|
676
678
|
last_tool_calls = []
|
|
@@ -685,11 +687,13 @@ class BaseAgentRunner:
|
|
|
685
687
|
"tool_args": args_dict,
|
|
686
688
|
"content": f"{name}({args})",
|
|
687
689
|
})
|
|
690
|
+
logger.debug("[TOOL CALL] employee={}: {}({})", self.employee_id, name, args[:1000])
|
|
688
691
|
elif kind == "on_tool_end":
|
|
689
692
|
output = data.get("output", "")
|
|
690
693
|
name = event.get("name", "tool")
|
|
691
694
|
result_str = str(output)
|
|
692
695
|
last_tool_results.append(f"{name} → {result_str}")
|
|
696
|
+
logger.debug("[TOOL RESULT] employee={}: {} → {}", self.employee_id, name, result_str[:2000])
|
|
693
697
|
on_log("tool_result", {
|
|
694
698
|
"tool_name": name,
|
|
695
699
|
"tool_result": result_str,
|
|
@@ -743,6 +743,46 @@ async def delete_product_tool(product_slug: str) -> str:
|
|
|
743
743
|
return f"Error: {e}"
|
|
744
744
|
|
|
745
745
|
|
|
746
|
+
@tool
|
|
747
|
+
async def assign_issue_tool(
|
|
748
|
+
product_slug: str,
|
|
749
|
+
issue_id: str,
|
|
750
|
+
assignee_id: str,
|
|
751
|
+
) -> str:
|
|
752
|
+
"""Assign (or reassign) an issue to an employee.
|
|
753
|
+
|
|
754
|
+
Args:
|
|
755
|
+
product_slug: The product slug
|
|
756
|
+
issue_id: The issue ID
|
|
757
|
+
assignee_id: Employee ID to assign
|
|
758
|
+
"""
|
|
759
|
+
try:
|
|
760
|
+
issue = prod.update_issue(product_slug, issue_id, assignee_id=assignee_id)
|
|
761
|
+
return f"Issue {issue_id} assigned to {assignee_id}"
|
|
762
|
+
except (ValueError, FileNotFoundError) as e:
|
|
763
|
+
return f"Error: {e}"
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
@tool
|
|
767
|
+
async def transfer_product_ownership_tool(
|
|
768
|
+
product_slug: str,
|
|
769
|
+
new_owner_id: str,
|
|
770
|
+
) -> str:
|
|
771
|
+
"""Transfer product ownership to a different employee.
|
|
772
|
+
|
|
773
|
+
Args:
|
|
774
|
+
product_slug: The product slug
|
|
775
|
+
new_owner_id: Employee ID of the new owner
|
|
776
|
+
"""
|
|
777
|
+
try:
|
|
778
|
+
result = prod.update_product(product_slug, owner_id=new_owner_id)
|
|
779
|
+
if result is None:
|
|
780
|
+
return f"Error: product '{product_slug}' not found"
|
|
781
|
+
return f"Product '{product_slug}' ownership transferred to {new_owner_id}"
|
|
782
|
+
except (ValueError, FileNotFoundError) as e:
|
|
783
|
+
return f"Error: {e}"
|
|
784
|
+
|
|
785
|
+
|
|
746
786
|
# ---------------------------------------------------------------------------
|
|
747
787
|
# Export
|
|
748
788
|
# ---------------------------------------------------------------------------
|
|
@@ -770,4 +810,6 @@ PRODUCT_TOOLS = [
|
|
|
770
810
|
version_management_tool,
|
|
771
811
|
update_product_tool,
|
|
772
812
|
delete_product_tool,
|
|
813
|
+
assign_issue_tool,
|
|
814
|
+
transfer_product_ownership_tool,
|
|
773
815
|
]
|
|
@@ -19,6 +19,7 @@ from loguru import logger
|
|
|
19
19
|
|
|
20
20
|
from onemancompany.core.config import (
|
|
21
21
|
ACTIVITY_LOG_DIR_NAME,
|
|
22
|
+
EMPLOYEES_DIR,
|
|
22
23
|
ISSUES_DIR_NAME,
|
|
23
24
|
PRODUCT_YAML_FILENAME,
|
|
24
25
|
PRODUCTS_DIR,
|
|
@@ -99,6 +100,17 @@ def _gen_id(prefix: str) -> str:
|
|
|
99
100
|
return f"{prefix}{uuid.uuid4().hex[:8]}"
|
|
100
101
|
|
|
101
102
|
|
|
103
|
+
def _validate_employee_id(emp_id: str, label: str = "Employee") -> None:
|
|
104
|
+
"""Raise ValueError if emp_id does not correspond to a valid employee directory.
|
|
105
|
+
|
|
106
|
+
Empty string is allowed (means "no owner/assignee assigned").
|
|
107
|
+
"""
|
|
108
|
+
if not emp_id:
|
|
109
|
+
return # empty = unassigned, valid
|
|
110
|
+
if not (EMPLOYEES_DIR / emp_id).is_dir():
|
|
111
|
+
raise ValueError(f"{label} '{emp_id}' not found in employee registry")
|
|
112
|
+
|
|
113
|
+
|
|
102
114
|
# ---------------------------------------------------------------------------
|
|
103
115
|
# Product CRUD
|
|
104
116
|
# ---------------------------------------------------------------------------
|
|
@@ -112,6 +124,7 @@ def create_product(
|
|
|
112
124
|
current_version: str = "0.1.0",
|
|
113
125
|
) -> dict:
|
|
114
126
|
"""Create a new product. Returns the product dict."""
|
|
127
|
+
_validate_employee_id(owner_id, label="Owner")
|
|
115
128
|
slug = _dedup_slug(_slugify(name))
|
|
116
129
|
product_id = _gen_id("prod_")
|
|
117
130
|
now = datetime.now().isoformat()
|
|
@@ -165,6 +178,8 @@ def list_products() -> list[dict]:
|
|
|
165
178
|
|
|
166
179
|
def update_product(slug: str, **fields) -> dict | None:
|
|
167
180
|
"""Update product fields. Returns updated dict or None if not found."""
|
|
181
|
+
if "owner_id" in fields and fields["owner_id"] is not None:
|
|
182
|
+
_validate_employee_id(fields["owner_id"], label="Owner")
|
|
168
183
|
with _get_slug_lock(slug):
|
|
169
184
|
path = _product_yaml_path(slug)
|
|
170
185
|
data = _read_yaml(path)
|
|
@@ -314,6 +329,8 @@ def create_issue(
|
|
|
314
329
|
product = load_product(slug)
|
|
315
330
|
if not product:
|
|
316
331
|
raise ValueError(f"Product '{slug}' not found")
|
|
332
|
+
if assignee_id:
|
|
333
|
+
_validate_employee_id(assignee_id, label="Assignee")
|
|
317
334
|
issue_id = _gen_id("issue_")
|
|
318
335
|
product_id = product["id"]
|
|
319
336
|
now = datetime.now().isoformat()
|
|
@@ -424,6 +441,9 @@ def update_issue(slug: str, issue_id: str, *, _skip_transition_check: bool = Fal
|
|
|
424
441
|
_skip_transition_check: internal flag for system-derived status updates
|
|
425
442
|
that may jump non-adjacent states (e.g. sync_issue_statuses).
|
|
426
443
|
"""
|
|
444
|
+
new_assignee = fields.get("assignee_id")
|
|
445
|
+
if new_assignee is not None and new_assignee != "":
|
|
446
|
+
_validate_employee_id(new_assignee, label="Assignee")
|
|
427
447
|
with _get_slug_lock(slug):
|
|
428
448
|
path = _issues_dir(slug) / f"{issue_id}.yaml"
|
|
429
449
|
data = _read_yaml(path)
|
|
@@ -441,6 +461,11 @@ def update_issue(slug: str, issue_id: str, *, _skip_transition_check: bool = Fal
|
|
|
441
461
|
if old_value != value:
|
|
442
462
|
_append_history(data, key, old_value, value, changed_by="system")
|
|
443
463
|
data[key] = value
|
|
464
|
+
# Auto-set closed_at and resolution when status transitions to DONE
|
|
465
|
+
if new_status == IssueStatus.DONE.value and not data.get("closed_at"):
|
|
466
|
+
data["closed_at"] = datetime.now().isoformat()
|
|
467
|
+
if not data.get("resolution"):
|
|
468
|
+
data["resolution"] = IssueResolution.FIXED.value
|
|
444
469
|
_write_yaml(path, data)
|
|
445
470
|
mark_dirty(DirtyCategory.PRODUCTS)
|
|
446
471
|
return data
|
|
@@ -999,12 +1024,25 @@ def release_version(
|
|
|
999
1024
|
product["current_version"] = new_version
|
|
1000
1025
|
_write_yaml(_product_yaml_path(product_slug), product)
|
|
1001
1026
|
|
|
1002
|
-
# Mark resolved issues as released
|
|
1027
|
+
# Mark resolved issues as released — only DONE issues are eligible
|
|
1028
|
+
skipped_issues: list[str] = []
|
|
1003
1029
|
for issue_id in resolved_issue_ids:
|
|
1004
1030
|
issue = load_issue(product_slug, issue_id)
|
|
1005
|
-
if
|
|
1006
|
-
|
|
1031
|
+
if not issue:
|
|
1032
|
+
skipped_issues.append(issue_id)
|
|
1033
|
+
continue
|
|
1034
|
+
if issue.get("status") == IssueStatus.RELEASED.value:
|
|
1035
|
+
continue # already released
|
|
1036
|
+
if issue.get("status") != IssueStatus.DONE.value:
|
|
1037
|
+
skipped_issues.append(issue_id)
|
|
1038
|
+
logger.warning(
|
|
1039
|
+
"[VERSION] Skipping issue {} — status '{}' is not DONE",
|
|
1040
|
+
issue_id, issue.get("status"),
|
|
1041
|
+
)
|
|
1042
|
+
continue
|
|
1043
|
+
update_issue(product_slug, issue_id, _skip_transition_check=True, status=IssueStatus.RELEASED.value)
|
|
1007
1044
|
|
|
1045
|
+
version_record["skipped_issues"] = skipped_issues
|
|
1008
1046
|
mark_dirty(DirtyCategory.PRODUCTS)
|
|
1009
1047
|
logger.info("[VERSION] Released {} for product '{}'", new_version, product_slug)
|
|
1010
1048
|
return version_record
|
|
@@ -1355,6 +1393,24 @@ def create_sprint(
|
|
|
1355
1393
|
f"End date '{end_date}' must be after start date '{start_date}'"
|
|
1356
1394
|
)
|
|
1357
1395
|
|
|
1396
|
+
# Check for date overlap with non-closed sprints
|
|
1397
|
+
existing_sprints = list_sprints(slug)
|
|
1398
|
+
for existing in existing_sprints:
|
|
1399
|
+
if existing.get("status") == SprintStatus.CLOSED.value:
|
|
1400
|
+
continue
|
|
1401
|
+
try:
|
|
1402
|
+
ex_sd = datetime.strptime(existing["start_date"], "%Y-%m-%d")
|
|
1403
|
+
ex_ed = datetime.strptime(existing["end_date"], "%Y-%m-%d")
|
|
1404
|
+
except (ValueError, KeyError):
|
|
1405
|
+
logger.debug("Skipping overlap check for sprint with invalid dates: {}", existing.get("id"))
|
|
1406
|
+
continue
|
|
1407
|
+
# Overlap: ranges overlap if start < other_end AND other_start < end
|
|
1408
|
+
if sd < ex_ed and ex_sd < ed:
|
|
1409
|
+
raise ValueError(
|
|
1410
|
+
f"Sprint dates {start_date}..{end_date} overlap with "
|
|
1411
|
+
f"'{existing['name']}' ({existing['start_date']}..{existing['end_date']})"
|
|
1412
|
+
)
|
|
1413
|
+
|
|
1358
1414
|
sprint_id = _gen_id("sprint_")
|
|
1359
1415
|
now = datetime.now().isoformat()
|
|
1360
1416
|
|
|
@@ -1478,8 +1534,9 @@ def close_sprint(slug: str, sprint_id: str) -> dict:
|
|
|
1478
1534
|
total_count = len(all_issues)
|
|
1479
1535
|
unfinished = [i for i in all_issues if i.get("status") not in _DONE_STATUSES]
|
|
1480
1536
|
|
|
1481
|
-
# 3. Carry-over: find next planning sprint
|
|
1537
|
+
# 3. Carry-over: find next planning sprint (sorted by start_date, earliest first)
|
|
1482
1538
|
planning_sprints = list_sprints(slug, status=SprintStatus.PLANNING.value)
|
|
1539
|
+
planning_sprints.sort(key=lambda s: s.get("start_date", ""))
|
|
1483
1540
|
next_sprint = planning_sprints[0] if planning_sprints else None
|
|
1484
1541
|
|
|
1485
1542
|
for issue in unfinished:
|
|
@@ -34,6 +34,12 @@ STALE_REVIEW_HOURS: int = 24 # Hours before an open review is conside
|
|
|
34
34
|
BLOCKED_DAYS_THRESHOLD: int = 7 # Days before a blocked issue is flagged
|
|
35
35
|
UNHANDLED_BACKLOG_THRESHOLD: int = 2 # Unhandled backlog issues before alert
|
|
36
36
|
|
|
37
|
+
def _get_threshold(product: dict, key: str, default: int) -> int:
|
|
38
|
+
"""Read per-product config threshold, falling back to module-level default."""
|
|
39
|
+
config = product.get("config") or {}
|
|
40
|
+
return config.get(key, default)
|
|
41
|
+
|
|
42
|
+
|
|
37
43
|
# ---------------------------------------------------------------------------
|
|
38
44
|
# Trigger handlers
|
|
39
45
|
# ---------------------------------------------------------------------------
|
|
@@ -148,6 +154,76 @@ async def _create_project_for_issue(slug: str, issue: dict) -> str:
|
|
|
148
154
|
return ""
|
|
149
155
|
|
|
150
156
|
|
|
157
|
+
async def _create_review_project(product_slug: str, reason: str) -> str:
|
|
158
|
+
"""Create a standalone review project for the product owner.
|
|
159
|
+
|
|
160
|
+
Unlike _create_project_for_issue, this doesn't take an issue dict —
|
|
161
|
+
it constructs a proper review-scoped project.
|
|
162
|
+
Returns project_id or empty string.
|
|
163
|
+
"""
|
|
164
|
+
from pathlib import Path
|
|
165
|
+
from onemancompany.core.config import CEO_ID, EA_ID, TASK_TREE_FILENAME
|
|
166
|
+
from onemancompany.core.project_archive import async_create_project_from_task, get_project_dir
|
|
167
|
+
from onemancompany.core.task_lifecycle import NodeType, TaskPhase
|
|
168
|
+
|
|
169
|
+
product = prod.load_product(product_slug)
|
|
170
|
+
if not product:
|
|
171
|
+
return ""
|
|
172
|
+
product_id = product["id"]
|
|
173
|
+
owner_id = product.get("owner_id", "")
|
|
174
|
+
task_description = f"Product review for '{product['name']}': {reason}"
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
project_id, iter_id = await async_create_project_from_task(
|
|
178
|
+
task_description,
|
|
179
|
+
product_id=product_id,
|
|
180
|
+
)
|
|
181
|
+
pdir = get_project_dir(project_id)
|
|
182
|
+
ctx_id = f"{project_id}/{iter_id}" if iter_id else project_id
|
|
183
|
+
|
|
184
|
+
product_ctx = prod.build_product_context(product_slug)
|
|
185
|
+
review_task = (
|
|
186
|
+
f"Product review needed: {reason}\n\n"
|
|
187
|
+
f"{product_ctx}\n\n"
|
|
188
|
+
f"[Project ID: {ctx_id}] [Project workspace: {pdir}]"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
from onemancompany.core.task_tree import TaskTree
|
|
192
|
+
from onemancompany.core.vessel import _save_project_tree
|
|
193
|
+
|
|
194
|
+
tree = TaskTree(project_id=ctx_id, mode="standard")
|
|
195
|
+
ceo_root = tree.create_root(employee_id=CEO_ID, description=task_description)
|
|
196
|
+
ceo_root.node_type = NodeType.CEO_PROMPT.value
|
|
197
|
+
ceo_root.set_status(TaskPhase.PROCESSING)
|
|
198
|
+
|
|
199
|
+
owner_node = tree.add_child(
|
|
200
|
+
parent_id=ceo_root.id,
|
|
201
|
+
employee_id=owner_id or EA_ID,
|
|
202
|
+
description=review_task,
|
|
203
|
+
acceptance_criteria=[],
|
|
204
|
+
title=f"Product review: {reason[:50]}",
|
|
205
|
+
)
|
|
206
|
+
_save_project_tree(pdir, tree)
|
|
207
|
+
|
|
208
|
+
from onemancompany.core.agent_loop import employee_manager
|
|
209
|
+
target_id = owner_id or EA_ID
|
|
210
|
+
tree_path = str(Path(pdir) / TASK_TREE_FILENAME)
|
|
211
|
+
employee_manager.schedule_node(target_id, owner_node.id, tree_path)
|
|
212
|
+
employee_manager._schedule_next(target_id)
|
|
213
|
+
|
|
214
|
+
logger.info(
|
|
215
|
+
"[PRODUCT_TRIGGER] Created review project {} for product '{}' (reason: {})",
|
|
216
|
+
project_id, product_slug, reason,
|
|
217
|
+
)
|
|
218
|
+
return project_id
|
|
219
|
+
except Exception:
|
|
220
|
+
logger.exception(
|
|
221
|
+
"[PRODUCT_TRIGGER] Failed to create review project for '{}'",
|
|
222
|
+
product_slug,
|
|
223
|
+
)
|
|
224
|
+
return ""
|
|
225
|
+
|
|
226
|
+
|
|
151
227
|
async def handle_project_complete(event: CompanyEvent) -> None:
|
|
152
228
|
"""When a project with product context completes, close issues + release version."""
|
|
153
229
|
slug = event.payload.get("product_slug", "")
|
|
@@ -236,78 +312,69 @@ async def notify_owner(product_slug: str, reason: str = "") -> bool:
|
|
|
236
312
|
f"[skill: product-review]"
|
|
237
313
|
)
|
|
238
314
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
from onemancompany.core.vessel import _save_project_tree
|
|
315
|
+
from pathlib import Path
|
|
316
|
+
from onemancompany.core.config import CEO_ID, TASK_TREE_FILENAME
|
|
317
|
+
from onemancompany.core.project_archive import list_projects, get_project_dir
|
|
318
|
+
from onemancompany.core.task_tree import get_tree
|
|
319
|
+
from onemancompany.core.vessel import _save_project_tree
|
|
245
320
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
321
|
+
# Find existing active project for this product
|
|
322
|
+
all_projects = list_projects()
|
|
323
|
+
active_product_projects = [
|
|
324
|
+
p for p in all_projects
|
|
325
|
+
if p.get("product_id") == product["id"] and p.get("status") == "active"
|
|
326
|
+
]
|
|
252
327
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
328
|
+
if active_product_projects:
|
|
329
|
+
# Add task to existing project's tree
|
|
330
|
+
proj = active_product_projects[0]
|
|
331
|
+
pdir = get_project_dir(proj["project_id"])
|
|
332
|
+
tree_path = Path(pdir) / TASK_TREE_FILENAME
|
|
333
|
+
if not tree_path.exists():
|
|
334
|
+
logger.debug("[PRODUCT_TRIGGER] Tree not found for project {}", proj["project_id"])
|
|
335
|
+
return False
|
|
336
|
+
|
|
337
|
+
tree = get_tree(str(tree_path))
|
|
338
|
+
|
|
339
|
+
# Check if owner already has a pending/processing review task — skip if so
|
|
340
|
+
from onemancompany.core.task_lifecycle import TaskPhase
|
|
341
|
+
for node in tree.all_nodes():
|
|
342
|
+
if (node.employee_id == owner_id
|
|
343
|
+
and node.status in (TaskPhase.PENDING.value, TaskPhase.PROCESSING.value)
|
|
344
|
+
and "review" in (node.title or node.description or "").lower()):
|
|
345
|
+
logger.debug("[PRODUCT_TRIGGER] Owner {} already has pending review task {}, skip",
|
|
346
|
+
owner_id, node.id)
|
|
260
347
|
return False
|
|
261
348
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
from onemancompany.core.task_lifecycle import TaskPhase
|
|
266
|
-
for node in tree.all_nodes():
|
|
267
|
-
if (node.employee_id == owner_id
|
|
268
|
-
and node.status in (TaskPhase.PENDING.value, TaskPhase.PROCESSING.value)
|
|
269
|
-
and "review" in (node.title or node.description or "").lower()):
|
|
270
|
-
logger.debug("[PRODUCT_TRIGGER] Owner {} already has pending review task {}, skip",
|
|
271
|
-
owner_id, node.id)
|
|
272
|
-
return False
|
|
273
|
-
|
|
274
|
-
# Find a suitable parent (EA node or root)
|
|
275
|
-
ea_node = tree.get_ea_node()
|
|
276
|
-
parent_id = ea_node.id if ea_node else tree.root_id
|
|
277
|
-
|
|
278
|
-
child = tree.add_child(
|
|
279
|
-
parent_id=parent_id,
|
|
280
|
-
employee_id=owner_id,
|
|
281
|
-
description=task_desc,
|
|
282
|
-
acceptance_criteria=[],
|
|
283
|
-
title=f"Product review: {reason[:50]}",
|
|
284
|
-
)
|
|
285
|
-
_save_project_tree(pdir, tree)
|
|
349
|
+
# Find a suitable parent (EA node or root)
|
|
350
|
+
ea_node = tree.get_ea_node()
|
|
351
|
+
parent_id = ea_node.id if ea_node else tree.root_id
|
|
286
352
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
353
|
+
child = tree.add_child(
|
|
354
|
+
parent_id=parent_id,
|
|
355
|
+
employee_id=owner_id,
|
|
356
|
+
description=task_desc,
|
|
357
|
+
acceptance_criteria=[],
|
|
358
|
+
title=f"Product review: {reason[:50]}",
|
|
359
|
+
)
|
|
360
|
+
_save_project_tree(pdir, tree)
|
|
291
361
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
project_id = await _create_project_for_issue(product_slug, {
|
|
297
|
-
"id": f"review_{product_slug}",
|
|
298
|
-
"title": f"Product review: {product['name']}",
|
|
299
|
-
"description": task_desc,
|
|
300
|
-
"priority": IssuePriority.P2.value,
|
|
301
|
-
})
|
|
302
|
-
if not project_id:
|
|
303
|
-
return False
|
|
304
|
-
logger.info("[PRODUCT_TRIGGER] Created review project {} for owner {} (reason: {})",
|
|
305
|
-
project_id, owner_id, reason)
|
|
362
|
+
# Schedule owner to execute
|
|
363
|
+
from onemancompany.core.agent_loop import employee_manager
|
|
364
|
+
employee_manager.schedule_node(owner_id, child.id, str(tree_path))
|
|
365
|
+
employee_manager._schedule_next(owner_id)
|
|
306
366
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
367
|
+
logger.info("[PRODUCT_TRIGGER] Pushed review task to owner {} on project {} (reason: {})",
|
|
368
|
+
owner_id, proj["project_id"], reason)
|
|
369
|
+
else:
|
|
370
|
+
# No active project — create a dedicated review project
|
|
371
|
+
project_id = await _create_review_project(product_slug, reason)
|
|
372
|
+
if not project_id:
|
|
373
|
+
return False
|
|
374
|
+
logger.info("[PRODUCT_TRIGGER] Created review project {} for owner {} (reason: {})",
|
|
375
|
+
project_id, owner_id, reason)
|
|
376
|
+
|
|
377
|
+
return True
|
|
311
378
|
|
|
312
379
|
|
|
313
380
|
def sync_issue_statuses(product_slug: str) -> list[dict]:
|
|
@@ -398,6 +465,8 @@ async def run_product_check(product_slug: str) -> dict:
|
|
|
398
465
|
if not owner_id:
|
|
399
466
|
return {"skipped": True, "reason": "no owner"}
|
|
400
467
|
|
|
468
|
+
max_active = _get_threshold(product, "max_active_projects", MAX_ACTIVE_PROJECTS)
|
|
469
|
+
|
|
401
470
|
from onemancompany.core.project_archive import list_projects
|
|
402
471
|
all_projects = list_projects()
|
|
403
472
|
active_for_product = [
|
|
@@ -425,7 +494,7 @@ async def run_product_check(product_slug: str) -> dict:
|
|
|
425
494
|
|
|
426
495
|
# High priority + no active project → create project
|
|
427
496
|
if priority in _AUTO_PROJECT_PRIORITIES and not linked:
|
|
428
|
-
if len(active_for_product) >=
|
|
497
|
+
if len(active_for_product) >= max_active:
|
|
429
498
|
logger.debug("[PRODUCT_CHECK] Skipping project for issue {} — 3+ active projects", issue["id"])
|
|
430
499
|
continue
|
|
431
500
|
project_id = await _create_project_for_issue(product_slug, issue)
|
|
@@ -440,7 +509,7 @@ async def run_product_check(product_slug: str) -> dict:
|
|
|
440
509
|
|
|
441
510
|
# Has assignee but no project → create project
|
|
442
511
|
elif issue.get("assignee_id") and not linked:
|
|
443
|
-
if len(active_for_product) >=
|
|
512
|
+
if len(active_for_product) >= max_active:
|
|
444
513
|
continue
|
|
445
514
|
project_id = await _create_project_for_issue(product_slug, issue)
|
|
446
515
|
if project_id:
|
|
@@ -460,10 +529,12 @@ async def run_product_check(product_slug: str) -> dict:
|
|
|
460
529
|
if target <= 0 or current >= target:
|
|
461
530
|
continue # met or invalid
|
|
462
531
|
|
|
532
|
+
kr_id = kr.get("id", "")
|
|
463
533
|
kr_title = kr.get("title", "")
|
|
464
|
-
|
|
534
|
+
kr_label = f"kr:{kr_id}"
|
|
535
|
+
# Check if any open issue is already tracking this KR (by kr_id label)
|
|
465
536
|
has_issue = any(
|
|
466
|
-
|
|
537
|
+
kr_label in i.get("labels", [])
|
|
467
538
|
for i in all_issues
|
|
468
539
|
if i.get("status") not in (IssueStatus.DONE.value, IssueStatus.RELEASED.value)
|
|
469
540
|
)
|
|
@@ -475,7 +546,7 @@ async def run_product_check(product_slug: str) -> dict:
|
|
|
475
546
|
description=f"Key result '{kr_title}' is at {current}/{target}. Create and execute work to advance this metric.",
|
|
476
547
|
priority=IssuePriority.P2,
|
|
477
548
|
created_by="system",
|
|
478
|
-
labels=["kr-tracking", "auto-created"],
|
|
549
|
+
labels=["kr-tracking", "auto-created", kr_label],
|
|
479
550
|
)
|
|
480
551
|
actions_taken.append(f"Created issue for KR: {kr_title}")
|
|
481
552
|
all_issues.append(issue) # prevent duplicate creation in same cycle
|
|
@@ -665,6 +736,12 @@ async def handle_issue_assigned(event: CompanyEvent) -> None:
|
|
|
665
736
|
logger.debug("[PRODUCT_TRIGGER] Issue {} already has linked tasks {}, skip", issue_id, linked)
|
|
666
737
|
return
|
|
667
738
|
|
|
739
|
+
# Re-read to guard against race with handle_issue_created
|
|
740
|
+
fresh_issue = prod.load_issue(slug, issue_id)
|
|
741
|
+
if fresh_issue and fresh_issue.get("linked_task_ids"):
|
|
742
|
+
logger.debug("[PRODUCT_TRIGGER] Race guard: issue {} got linked_task_ids before project creation", issue_id)
|
|
743
|
+
return
|
|
744
|
+
|
|
668
745
|
logger.info("[PRODUCT_TRIGGER] Issue {} assigned to {} — creating project", issue_id, assignee_id)
|
|
669
746
|
project_id = await _create_project_for_issue(slug, issue)
|
|
670
747
|
|