@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.
@@ -26,11 +26,11 @@ const STATUS_STYLES = {
26
26
  blocked: { color: '#f97316', label: '\u2298 Blocked' },
27
27
  };
28
28
 
29
- const CARD_W = 240;
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 = 6; // cap description lines
33
- const DESC_CHARS_PER_LINE = 34;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1mancompany/onemancompany",
3
- "version": "0.7.44",
3
+ "version": "0.7.48",
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.44"
3
+ version = "0.7.48"
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 = [
@@ -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 (bypass validation release is a system operation)
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 issue and issue.get("status") != IssueStatus.RELEASED.value:
1006
- update_issue(product_slug, issue_id, _skip_transition_check=True, status=IssueStatus.RELEASED.value)
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
- try:
240
- from pathlib import Path
241
- from onemancompany.core.config import CEO_ID, TASK_TREE_FILENAME
242
- from onemancompany.core.project_archive import list_projects, get_project_dir
243
- from onemancompany.core.task_tree import get_tree
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
- # Find existing active project for this product
247
- all_projects = list_projects()
248
- active_product_projects = [
249
- p for p in all_projects
250
- if p.get("product_id") == product["id"] and p.get("status") == "active"
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
- if active_product_projects:
254
- # Add task to existing project's tree
255
- proj = active_product_projects[0]
256
- pdir = get_project_dir(proj["project_id"])
257
- tree_path = Path(pdir) / TASK_TREE_FILENAME
258
- if not tree_path.exists():
259
- logger.debug("[PRODUCT_TRIGGER] Tree not found for project {}", proj["project_id"])
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
- tree = get_tree(str(tree_path))
263
-
264
- # Check if owner already has a pending/processing review task — skip if so
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
- # Schedule owner to execute
288
- from onemancompany.core.agent_loop import employee_manager
289
- employee_manager.schedule_node(owner_id, child.id, str(tree_path))
290
- employee_manager._schedule_next(owner_id)
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
- logger.info("[PRODUCT_TRIGGER] Pushed review task to owner {} on project {} (reason: {})",
293
- owner_id, proj["project_id"], reason)
294
- else:
295
- # No active project — create one
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
- return True
308
- except Exception:
309
- logger.exception("[PRODUCT_TRIGGER] Failed to push review task for '{}'", product_slug)
310
- return False
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) >= MAX_ACTIVE_PROJECTS:
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) >= MAX_ACTIVE_PROJECTS:
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
- # Check if any open issue is related to this KR (by title match)
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
- kr_title in i.get("title", "") or kr.get("id", "") in i.get("title", "")
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