gobby 0.2.7__py3-none-any.whl → 0.2.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. gobby/adapters/claude_code.py +96 -35
  2. gobby/adapters/gemini.py +140 -38
  3. gobby/agents/isolation.py +130 -0
  4. gobby/agents/registry.py +11 -0
  5. gobby/agents/session.py +1 -0
  6. gobby/agents/spawn_executor.py +43 -13
  7. gobby/agents/spawners/macos.py +26 -1
  8. gobby/cli/__init__.py +0 -2
  9. gobby/cli/memory.py +185 -0
  10. gobby/clones/git.py +177 -0
  11. gobby/config/skills.py +31 -0
  12. gobby/hooks/event_handlers.py +109 -10
  13. gobby/hooks/hook_manager.py +19 -1
  14. gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
  15. gobby/mcp_proxy/instructions.py +2 -2
  16. gobby/mcp_proxy/registries.py +21 -4
  17. gobby/mcp_proxy/tools/agent_messaging.py +93 -44
  18. gobby/mcp_proxy/tools/agents.py +45 -9
  19. gobby/mcp_proxy/tools/artifacts.py +43 -9
  20. gobby/mcp_proxy/tools/sessions/_commits.py +31 -24
  21. gobby/mcp_proxy/tools/sessions/_crud.py +5 -5
  22. gobby/mcp_proxy/tools/sessions/_handoff.py +45 -41
  23. gobby/mcp_proxy/tools/sessions/_messages.py +35 -7
  24. gobby/mcp_proxy/tools/spawn_agent.py +44 -6
  25. gobby/mcp_proxy/tools/tasks/_context.py +18 -0
  26. gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
  27. gobby/mcp_proxy/tools/tasks/_lifecycle.py +29 -14
  28. gobby/mcp_proxy/tools/tasks/_session.py +22 -7
  29. gobby/mcp_proxy/tools/workflows.py +84 -34
  30. gobby/mcp_proxy/tools/worktrees.py +32 -7
  31. gobby/memory/extractor.py +15 -1
  32. gobby/runner.py +13 -0
  33. gobby/servers/routes/mcp/hooks.py +50 -3
  34. gobby/servers/websocket.py +57 -1
  35. gobby/sessions/analyzer.py +2 -2
  36. gobby/sessions/manager.py +9 -0
  37. gobby/sessions/transcripts/gemini.py +100 -34
  38. gobby/storage/database.py +9 -2
  39. gobby/storage/memories.py +32 -21
  40. gobby/storage/migrations.py +23 -4
  41. gobby/storage/sessions.py +4 -2
  42. gobby/storage/skills.py +43 -3
  43. gobby/workflows/detection_helpers.py +38 -24
  44. gobby/workflows/enforcement/blocking.py +13 -1
  45. gobby/workflows/engine.py +93 -0
  46. gobby/workflows/evaluator.py +110 -0
  47. gobby/workflows/hooks.py +41 -0
  48. gobby/workflows/memory_actions.py +11 -0
  49. gobby/workflows/safe_evaluator.py +8 -0
  50. gobby/workflows/summary_actions.py +123 -50
  51. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/METADATA +1 -1
  52. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/RECORD +56 -80
  53. gobby/cli/tui.py +0 -34
  54. gobby/tui/__init__.py +0 -5
  55. gobby/tui/api_client.py +0 -278
  56. gobby/tui/app.py +0 -329
  57. gobby/tui/screens/__init__.py +0 -25
  58. gobby/tui/screens/agents.py +0 -333
  59. gobby/tui/screens/chat.py +0 -450
  60. gobby/tui/screens/dashboard.py +0 -377
  61. gobby/tui/screens/memory.py +0 -305
  62. gobby/tui/screens/metrics.py +0 -231
  63. gobby/tui/screens/orchestrator.py +0 -903
  64. gobby/tui/screens/sessions.py +0 -412
  65. gobby/tui/screens/tasks.py +0 -440
  66. gobby/tui/screens/workflows.py +0 -289
  67. gobby/tui/screens/worktrees.py +0 -174
  68. gobby/tui/widgets/__init__.py +0 -21
  69. gobby/tui/widgets/chat.py +0 -210
  70. gobby/tui/widgets/conductor.py +0 -104
  71. gobby/tui/widgets/menu.py +0 -132
  72. gobby/tui/widgets/message_panel.py +0 -160
  73. gobby/tui/widgets/review_gate.py +0 -224
  74. gobby/tui/widgets/task_tree.py +0 -99
  75. gobby/tui/widgets/token_budget.py +0 -166
  76. gobby/tui/ws_client.py +0 -258
  77. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/WHEEL +0 -0
  78. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
  79. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
  80. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/top_level.txt +0 -0
@@ -93,13 +93,21 @@ def create_lifecycle_registry(ctx: RegistryContext) -> InternalToolRegistry:
93
93
  # Auto-skip validation for certain close reasons
94
94
  should_skip = skip_validation or reason.lower() in SKIP_REASONS
95
95
 
96
+ # Resolve session_id to UUID (accepts #N, N, UUID, or prefix)
97
+ resolved_session_id = session_id
98
+ if session_id:
99
+ try:
100
+ resolved_session_id = ctx.resolve_session_id(session_id)
101
+ except ValueError:
102
+ pass # Fall back to raw value if resolution fails
103
+
96
104
  # Enforce commits if session had edits
97
- if session_id and not should_skip:
105
+ if resolved_session_id and not should_skip:
98
106
  try:
99
107
  from gobby.storage.sessions import LocalSessionManager
100
108
 
101
109
  session_manager = LocalSessionManager(ctx.task_manager.db)
102
- session = session_manager.get(session_id)
110
+ session = session_manager.get(resolved_session_id)
103
111
 
104
112
  # Check if task has commits (including the one being linked right now)
105
113
  has_commits = bool(task.commits) or bool(commit_sha)
@@ -185,9 +193,9 @@ def create_lifecycle_registry(ctx: RegistryContext) -> InternalToolRegistry:
185
193
  )
186
194
 
187
195
  # Auto-link session if provided
188
- if session_id:
196
+ if resolved_session_id:
189
197
  try:
190
- ctx.session_task_manager.link_task(session_id, resolved_id, "review")
198
+ ctx.session_task_manager.link_task(resolved_session_id, resolved_id, "review")
191
199
  except Exception:
192
200
  pass # nosec B110 - best-effort linking
193
201
 
@@ -208,15 +216,15 @@ def create_lifecycle_registry(ctx: RegistryContext) -> InternalToolRegistry:
208
216
  ctx.task_manager.close_task(
209
217
  resolved_id,
210
218
  reason=reason,
211
- closed_in_session_id=session_id,
219
+ closed_in_session_id=resolved_session_id,
212
220
  closed_commit_sha=current_commit_sha,
213
221
  validation_override_reason=override_justification if store_override else None,
214
222
  )
215
223
 
216
224
  # Auto-link session if provided
217
- if session_id:
225
+ if resolved_session_id:
218
226
  try:
219
- ctx.session_task_manager.link_task(session_id, resolved_id, "closed")
227
+ ctx.session_task_manager.link_task(resolved_session_id, resolved_id, "closed")
220
228
  except Exception:
221
229
  pass # nosec B110 - best-effort linking, don't fail the close
222
230
 
@@ -224,9 +232,9 @@ def create_lifecycle_registry(ctx: RegistryContext) -> InternalToolRegistry:
224
232
  # Respects the clear_task_on_close variable (defaults to True if not set)
225
233
  # This is done here because Claude Code's post-tool-use hook doesn't include
226
234
  # the tool result, so the detection_helpers can't verify close succeeded
227
- if session_id:
235
+ if resolved_session_id:
228
236
  try:
229
- state = ctx.workflow_state_manager.get_state(session_id)
237
+ state = ctx.workflow_state_manager.get_state(resolved_session_id)
230
238
  if state and state.variables.get("claimed_task_id") == resolved_id:
231
239
  # Check if clear_task_on_close is enabled (default: True)
232
240
  clear_on_close = state.variables.get("clear_task_on_close", True)
@@ -288,7 +296,7 @@ def create_lifecycle_registry(ctx: RegistryContext) -> InternalToolRegistry:
288
296
  },
289
297
  "session_id": {
290
298
  "type": "string",
291
- "description": "Your session ID (from system context). Pass this to track which session closed the task.",
299
+ "description": "Your session ID (accepts #N, N, UUID, or prefix). Pass this to track which session closed the task.",
292
300
  "default": None,
293
301
  },
294
302
  "override_justification": {
@@ -528,8 +536,15 @@ def create_lifecycle_registry(ctx: RegistryContext) -> InternalToolRegistry:
528
536
  if not task:
529
537
  return {"success": False, "error": f"Task {task_id} not found"}
530
538
 
539
+ # Resolve session_id to UUID (accepts #N, N, UUID, or prefix)
540
+ resolved_session_id = session_id
541
+ try:
542
+ resolved_session_id = ctx.resolve_session_id(session_id)
543
+ except ValueError:
544
+ pass # Fall back to raw value if resolution fails
545
+
531
546
  # Check if already claimed by another session
532
- if task.assignee and task.assignee != session_id and not force:
547
+ if task.assignee and task.assignee != resolved_session_id and not force:
533
548
  return {
534
549
  "success": False,
535
550
  "error": "Task already claimed by another session",
@@ -540,7 +555,7 @@ def create_lifecycle_registry(ctx: RegistryContext) -> InternalToolRegistry:
540
555
  # Update task with assignee and status in single atomic call
541
556
  updated = ctx.task_manager.update_task(
542
557
  resolved_id,
543
- assignee=session_id,
558
+ assignee=resolved_session_id,
544
559
  status="in_progress",
545
560
  )
546
561
  if not updated:
@@ -548,7 +563,7 @@ def create_lifecycle_registry(ctx: RegistryContext) -> InternalToolRegistry:
548
563
 
549
564
  # Link task to session (best-effort, don't fail the claim if this fails)
550
565
  try:
551
- ctx.session_task_manager.link_task(session_id, resolved_id, "claimed")
566
+ ctx.session_task_manager.link_task(resolved_session_id, resolved_id, "claimed")
552
567
  except Exception:
553
568
  pass # nosec B110 - best-effort linking
554
569
 
@@ -566,7 +581,7 @@ def create_lifecycle_registry(ctx: RegistryContext) -> InternalToolRegistry:
566
581
  },
567
582
  "session_id": {
568
583
  "type": "string",
569
- "description": "Your session ID (from system context). The session claiming the task.",
584
+ "description": "Your session ID (accepts #N, N, UUID, or prefix). The session claiming the task.",
570
585
  },
571
586
  "force": {
572
587
  "type": "boolean",
@@ -40,15 +40,21 @@ def create_session_registry(ctx: RegistryContext) -> InternalToolRegistry:
40
40
  except (TaskNotFoundError, ValueError) as e:
41
41
  return {"error": str(e)}
42
42
 
43
+ # Resolve session_id to UUID (accepts #N, N, UUID, or prefix)
43
44
  try:
44
- ctx.session_task_manager.link_task(session_id, resolved_id, action)
45
+ resolved_session_id = ctx.resolve_session_id(session_id)
46
+ except ValueError as e:
47
+ return {"error": f"Invalid session_id '{session_id}': {e}"}
48
+
49
+ try:
50
+ ctx.session_task_manager.link_task(resolved_session_id, resolved_id, action)
45
51
  return {}
46
52
  except ValueError as e:
47
53
  return {"error": str(e)}
48
54
 
49
55
  registry.register(
50
56
  name="link_task_to_session",
51
- description="Link a task to a session.",
57
+ description="Link a task to a session. Accepts #N, N, UUID, or prefix for session_id.",
52
58
  input_schema={
53
59
  "type": "object",
54
60
  "properties": {
@@ -58,7 +64,7 @@ def create_session_registry(ctx: RegistryContext) -> InternalToolRegistry:
58
64
  },
59
65
  "session_id": {
60
66
  "type": "string",
61
- "description": "Session ID (optional, defaults to linking context if available)",
67
+ "description": "Session reference (accepts #N, N, UUID, or prefix)",
62
68
  "default": None,
63
69
  },
64
70
  "action": {
@@ -74,16 +80,25 @@ def create_session_registry(ctx: RegistryContext) -> InternalToolRegistry:
74
80
 
75
81
  def get_session_tasks(session_id: str) -> dict[str, Any]:
76
82
  """Get all tasks associated with a session."""
77
- tasks = ctx.session_task_manager.get_session_tasks(session_id)
78
- return {"session_id": session_id, "tasks": tasks}
83
+ # Resolve session_id to UUID (accepts #N, N, UUID, or prefix)
84
+ try:
85
+ resolved_session_id = ctx.resolve_session_id(session_id)
86
+ except ValueError as e:
87
+ return {"error": f"Invalid session_id '{session_id}': {e}"}
88
+
89
+ tasks = ctx.session_task_manager.get_session_tasks(resolved_session_id)
90
+ return {"session_id": resolved_session_id, "tasks": tasks}
79
91
 
80
92
  registry.register(
81
93
  name="get_session_tasks",
82
- description="Get all tasks associated with a session.",
94
+ description="Get all tasks associated with a session. Accepts #N, N, UUID, or prefix for session_id.",
83
95
  input_schema={
84
96
  "type": "object",
85
97
  "properties": {
86
- "session_id": {"type": "string", "description": "Session ID"},
98
+ "session_id": {
99
+ "type": "string",
100
+ "description": "Session reference (accepts #N, N, UUID, or prefix)",
101
+ },
87
102
  },
88
103
  "required": ["session_id"],
89
104
  },
@@ -113,12 +113,20 @@ def create_workflows_registry(
113
113
  Returns:
114
114
  InternalToolRegistry with workflow tools registered
115
115
  """
116
+ from gobby.utils.project_context import get_project_context
117
+
116
118
  # Create defaults if not provided
117
119
  _db = db or LocalDatabase()
118
120
  _loader = loader or WorkflowLoader()
119
121
  _state_manager = state_manager or WorkflowStateManager(_db)
120
122
  _session_manager = session_manager or LocalSessionManager(_db)
121
123
 
124
+ def _resolve_session_id(ref: str) -> str:
125
+ """Resolve session reference (#N, N, UUID, or prefix) to UUID."""
126
+ project_ctx = get_project_context()
127
+ project_id = project_ctx.get("id") if project_ctx else None
128
+ return _session_manager.resolve_session_reference(ref, project_id)
129
+
122
130
  registry = InternalToolRegistry(
123
131
  name="gobby-workflows",
124
132
  description="Workflow management - list, activate, status, transition, end",
@@ -264,7 +272,7 @@ def create_workflows_registry(
264
272
 
265
273
  @registry.tool(
266
274
  name="activate_workflow",
267
- description="Activate a step-based workflow for the current session.",
275
+ description="Activate a step-based workflow for the current session. Accepts #N, N, UUID, or prefix for session_id.",
268
276
  )
269
277
  def activate_workflow(
270
278
  name: str,
@@ -278,7 +286,7 @@ def create_workflows_registry(
278
286
 
279
287
  Args:
280
288
  name: Workflow name (e.g., "plan-act-reflect", "auto-task")
281
- session_id: Required session ID (must be provided to prevent cross-session bleed)
289
+ session_id: Session reference (accepts #N, N, UUID, or prefix) - required to prevent cross-session bleed
282
290
  initial_step: Optional starting step (defaults to first step)
283
291
  variables: Optional initial variables to set (merged with workflow defaults)
284
292
  project_path: Project directory path. Auto-discovered from cwd if not provided.
@@ -290,7 +298,7 @@ def create_workflows_registry(
290
298
  activate_workflow(
291
299
  name="auto-task",
292
300
  variables={"session_task": "#47"},
293
- session_id="..."
301
+ session_id="#5"
294
302
  )
295
303
 
296
304
  Errors if:
@@ -325,12 +333,18 @@ def create_workflows_registry(
325
333
  "error": "session_id is required. Pass the session ID explicitly to prevent cross-session variable bleed.",
326
334
  }
327
335
 
336
+ # Resolve session_id to UUID (accepts #N, N, UUID, or prefix)
337
+ try:
338
+ resolved_session_id = _resolve_session_id(session_id)
339
+ except ValueError as e:
340
+ return {"success": False, "error": str(e)}
341
+
328
342
  # Check for existing workflow
329
343
  # Allow if:
330
344
  # - No existing state
331
345
  # - Existing is __lifecycle__ placeholder
332
346
  # - Existing is a lifecycle-type workflow (they run concurrently with step workflows)
333
- existing = _state_manager.get_state(session_id)
347
+ existing = _state_manager.get_state(resolved_session_id)
334
348
  if existing and existing.workflow_name != "__lifecycle__":
335
349
  # Check if existing workflow is a lifecycle type
336
350
  existing_def = _loader.load_workflow(existing.workflow_name, proj)
@@ -366,12 +380,12 @@ def create_workflows_registry(
366
380
  session_task_val = merged_variables["session_task"]
367
381
  if isinstance(session_task_val, str):
368
382
  merged_variables["session_task"] = _resolve_session_task_value(
369
- session_task_val, session_id, _session_manager, _db
383
+ session_task_val, resolved_session_id, _session_manager, _db
370
384
  )
371
385
 
372
386
  # Create state
373
387
  state = WorkflowState(
374
- session_id=session_id,
388
+ session_id=resolved_session_id,
375
389
  workflow_name=name,
376
390
  step=step,
377
391
  step_entered_at=datetime.now(UTC),
@@ -391,7 +405,7 @@ def create_workflows_registry(
391
405
 
392
406
  return {
393
407
  "success": True,
394
- "session_id": session_id,
408
+ "session_id": resolved_session_id,
395
409
  "workflow": name,
396
410
  "step": step,
397
411
  "steps": [s.name for s in definition.steps],
@@ -400,7 +414,7 @@ def create_workflows_registry(
400
414
 
401
415
  @registry.tool(
402
416
  name="end_workflow",
403
- description="End the currently active step-based workflow.",
417
+ description="End the currently active step-based workflow. Accepts #N, N, UUID, or prefix for session_id.",
404
418
  )
405
419
  def end_workflow(
406
420
  session_id: str | None = None,
@@ -413,7 +427,7 @@ def create_workflows_registry(
413
427
  Does not affect lifecycle workflows (they continue running).
414
428
 
415
429
  Args:
416
- session_id: Required session ID (must be provided to prevent cross-session bleed)
430
+ session_id: Session reference (accepts #N, N, UUID, or prefix) - required to prevent cross-session bleed
417
431
  reason: Optional reason for ending
418
432
 
419
433
  Returns:
@@ -426,24 +440,30 @@ def create_workflows_registry(
426
440
  "error": "session_id is required. Pass the session ID explicitly to prevent cross-session variable bleed.",
427
441
  }
428
442
 
429
- state = _state_manager.get_state(session_id)
443
+ # Resolve session_id to UUID (accepts #N, N, UUID, or prefix)
444
+ try:
445
+ resolved_session_id = _resolve_session_id(session_id)
446
+ except ValueError as e:
447
+ return {"success": False, "error": str(e)}
448
+
449
+ state = _state_manager.get_state(resolved_session_id)
430
450
  if not state:
431
451
  return {"error": "No workflow active for session"}
432
452
 
433
- _state_manager.delete_state(session_id)
453
+ _state_manager.delete_state(resolved_session_id)
434
454
 
435
455
  return {}
436
456
 
437
457
  @registry.tool(
438
458
  name="get_workflow_status",
439
- description="Get current workflow step and state.",
459
+ description="Get current workflow step and state. Accepts #N, N, UUID, or prefix for session_id.",
440
460
  )
441
461
  def get_workflow_status(session_id: str | None = None) -> dict[str, Any]:
442
462
  """
443
463
  Get current workflow step and state.
444
464
 
445
465
  Args:
446
- session_id: Required session ID (must be provided to prevent cross-session bleed)
466
+ session_id: Session reference (accepts #N, N, UUID, or prefix) - required to prevent cross-session bleed
447
467
 
448
468
  Returns:
449
469
  Workflow state including step, action counts, artifacts
@@ -455,13 +475,19 @@ def create_workflows_registry(
455
475
  "error": "session_id is required. Pass the session ID explicitly to prevent cross-session variable bleed.",
456
476
  }
457
477
 
458
- state = _state_manager.get_state(session_id)
478
+ # Resolve session_id to UUID (accepts #N, N, UUID, or prefix)
479
+ try:
480
+ resolved_session_id = _resolve_session_id(session_id)
481
+ except ValueError as e:
482
+ return {"has_workflow": False, "error": str(e)}
483
+
484
+ state = _state_manager.get_state(resolved_session_id)
459
485
  if not state:
460
- return {"has_workflow": False, "session_id": session_id}
486
+ return {"has_workflow": False, "session_id": resolved_session_id}
461
487
 
462
488
  return {
463
489
  "has_workflow": True,
464
- "session_id": session_id,
490
+ "session_id": resolved_session_id,
465
491
  "workflow_name": state.workflow_name,
466
492
  "step": state.step,
467
493
  "step_action_count": state.step_action_count,
@@ -479,7 +505,7 @@ def create_workflows_registry(
479
505
 
480
506
  @registry.tool(
481
507
  name="request_step_transition",
482
- description="Request transition to a different step.",
508
+ description="Request transition to a different step. Accepts #N, N, UUID, or prefix for session_id.",
483
509
  )
484
510
  def request_step_transition(
485
511
  to_step: str,
@@ -494,7 +520,7 @@ def create_workflows_registry(
494
520
  Args:
495
521
  to_step: Target step name
496
522
  reason: Reason for transition
497
- session_id: Required session ID (must be provided to prevent cross-session bleed)
523
+ session_id: Session reference (accepts #N, N, UUID, or prefix) - required to prevent cross-session bleed
498
524
  force: Skip exit condition checks
499
525
  project_path: Project directory path. Auto-discovered from cwd if not provided.
500
526
 
@@ -516,7 +542,13 @@ def create_workflows_registry(
516
542
  "error": "session_id is required. Pass the session ID explicitly to prevent cross-session variable bleed.",
517
543
  }
518
544
 
519
- state = _state_manager.get_state(session_id)
545
+ # Resolve session_id to UUID (accepts #N, N, UUID, or prefix)
546
+ try:
547
+ resolved_session_id = _resolve_session_id(session_id)
548
+ except ValueError as e:
549
+ return {"success": False, "error": str(e)}
550
+
551
+ state = _state_manager.get_state(resolved_session_id)
520
552
  if not state:
521
553
  return {"success": False, "error": "No workflow active for session"}
522
554
 
@@ -565,7 +597,7 @@ def create_workflows_registry(
565
597
 
566
598
  @registry.tool(
567
599
  name="mark_artifact_complete",
568
- description="Register an artifact as complete (plan, spec, etc.).",
600
+ description="Register an artifact as complete (plan, spec, etc.). Accepts #N, N, UUID, or prefix for session_id.",
569
601
  )
570
602
  def mark_artifact_complete(
571
603
  artifact_type: str,
@@ -578,7 +610,7 @@ def create_workflows_registry(
578
610
  Args:
579
611
  artifact_type: Type of artifact (e.g., "plan", "spec", "test")
580
612
  file_path: Path to the artifact file
581
- session_id: Required session ID (must be provided to prevent cross-session bleed)
613
+ session_id: Session reference (accepts #N, N, UUID, or prefix) - required to prevent cross-session bleed
582
614
 
583
615
  Returns:
584
616
  Success status
@@ -590,7 +622,13 @@ def create_workflows_registry(
590
622
  "error": "session_id is required. Pass the session ID explicitly to prevent cross-session variable bleed.",
591
623
  }
592
624
 
593
- state = _state_manager.get_state(session_id)
625
+ # Resolve session_id to UUID (accepts #N, N, UUID, or prefix)
626
+ try:
627
+ resolved_session_id = _resolve_session_id(session_id)
628
+ except ValueError as e:
629
+ return {"success": False, "error": str(e)}
630
+
631
+ state = _state_manager.get_state(resolved_session_id)
594
632
  if not state:
595
633
  return {"error": "No workflow active for session"}
596
634
 
@@ -602,7 +640,7 @@ def create_workflows_registry(
602
640
 
603
641
  @registry.tool(
604
642
  name="set_variable",
605
- description="Set a workflow variable for the current session (session-scoped, not persisted to YAML).",
643
+ description="Set a workflow variable for the current session (session-scoped, not persisted to YAML). Accepts #N, N, UUID, or prefix for session_id.",
606
644
  )
607
645
  def set_variable(
608
646
  name: str,
@@ -623,7 +661,7 @@ def create_workflows_registry(
623
661
  Args:
624
662
  name: Variable name (e.g., "session_epic", "is_worktree")
625
663
  value: Variable value (string, number, boolean, or null)
626
- session_id: Required session ID (must be provided to prevent cross-session bleed)
664
+ session_id: Session reference (accepts #N, N, UUID, or prefix) - required to prevent cross-session bleed
627
665
 
628
666
  Returns:
629
667
  Success status and updated variables
@@ -635,12 +673,18 @@ def create_workflows_registry(
635
673
  "error": "session_id is required. Pass the session ID explicitly to prevent cross-session variable bleed.",
636
674
  }
637
675
 
676
+ # Resolve session_id to UUID (accepts #N, N, UUID, or prefix)
677
+ try:
678
+ resolved_session_id = _resolve_session_id(session_id)
679
+ except ValueError as e:
680
+ return {"success": False, "error": str(e)}
681
+
638
682
  # Get or create state
639
- state = _state_manager.get_state(session_id)
683
+ state = _state_manager.get_state(resolved_session_id)
640
684
  if not state:
641
685
  # Create a minimal lifecycle state for variable storage
642
686
  state = WorkflowState(
643
- session_id=session_id,
687
+ session_id=resolved_session_id,
644
688
  workflow_name="__lifecycle__",
645
689
  step="",
646
690
  step_entered_at=datetime.now(UTC),
@@ -664,7 +708,7 @@ def create_workflows_registry(
664
708
  # Resolve session_task references (#N or N) to UUIDs upfront
665
709
  # This prevents repeated resolution failures in condition evaluation
666
710
  if name == "session_task" and isinstance(value, str):
667
- value = _resolve_session_task_value(value, session_id, _session_manager, _db)
711
+ value = _resolve_session_task_value(value, resolved_session_id, _session_manager, _db)
668
712
 
669
713
  # Set the variable
670
714
  state.variables[name] = value
@@ -684,7 +728,7 @@ def create_workflows_registry(
684
728
 
685
729
  @registry.tool(
686
730
  name="get_variable",
687
- description="Get workflow variable(s) for the current session.",
731
+ description="Get workflow variable(s) for the current session. Accepts #N, N, UUID, or prefix for session_id.",
688
732
  )
689
733
  def get_variable(
690
734
  name: str | None = None,
@@ -695,7 +739,7 @@ def create_workflows_registry(
695
739
 
696
740
  Args:
697
741
  name: Variable name to get (if None, returns all variables)
698
- session_id: Required session ID (must be provided to prevent cross-session bleed)
742
+ session_id: Session reference (accepts #N, N, UUID, or prefix) - required to prevent cross-session bleed
699
743
 
700
744
  Returns:
701
745
  Variable value(s) and session info
@@ -707,19 +751,25 @@ def create_workflows_registry(
707
751
  "error": "session_id is required. Pass the session ID explicitly to prevent cross-session variable bleed.",
708
752
  }
709
753
 
710
- state = _state_manager.get_state(session_id)
754
+ # Resolve session_id to UUID (accepts #N, N, UUID, or prefix)
755
+ try:
756
+ resolved_session_id = _resolve_session_id(session_id)
757
+ except ValueError as e:
758
+ return {"success": False, "error": str(e)}
759
+
760
+ state = _state_manager.get_state(resolved_session_id)
711
761
  if not state:
712
762
  if name:
713
763
  return {
714
764
  "success": True,
715
- "session_id": session_id,
765
+ "session_id": resolved_session_id,
716
766
  "variable": name,
717
767
  "value": None,
718
768
  "exists": False,
719
769
  }
720
770
  return {
721
771
  "success": True,
722
- "session_id": session_id,
772
+ "session_id": resolved_session_id,
723
773
  "variables": {},
724
774
  }
725
775
 
@@ -727,7 +777,7 @@ def create_workflows_registry(
727
777
  value = state.variables.get(name)
728
778
  return {
729
779
  "success": True,
730
- "session_id": session_id,
780
+ "session_id": resolved_session_id,
731
781
  "variable": name,
732
782
  "value": value,
733
783
  "exists": name in state.variables,
@@ -735,7 +785,7 @@ def create_workflows_registry(
735
785
 
736
786
  return {
737
787
  "success": True,
738
- "session_id": session_id,
788
+ "session_id": resolved_session_id,
739
789
  "variables": state.variables,
740
790
  }
741
791
 
@@ -287,6 +287,7 @@ def create_worktrees_registry(
287
287
  worktree_storage: LocalWorktreeManager,
288
288
  git_manager: WorktreeGitManager | None = None,
289
289
  project_id: str | None = None,
290
+ session_manager: Any | None = None,
290
291
  ) -> InternalToolRegistry:
291
292
  """
292
293
  Create a worktree tool registry with all worktree-related tools.
@@ -295,10 +296,20 @@ def create_worktrees_registry(
295
296
  worktree_storage: LocalWorktreeManager for database operations.
296
297
  git_manager: WorktreeGitManager for git operations.
297
298
  project_id: Default project ID for operations.
299
+ session_manager: Session manager for resolving session references.
298
300
 
299
301
  Returns:
300
302
  InternalToolRegistry with all worktree tools registered.
301
303
  """
304
+
305
+ def _resolve_session_id(ref: str) -> str:
306
+ """Resolve session reference (#N, N, UUID, or prefix) to UUID."""
307
+ if session_manager is None:
308
+ return ref # No resolution available, return as-is
309
+ ctx = get_project_context()
310
+ proj_id = ctx.get("id") if ctx else project_id
311
+ return str(session_manager.resolve_session_reference(ref, proj_id))
312
+
302
313
  registry = InternalToolRegistry(
303
314
  name="gobby-worktrees",
304
315
  description="Git worktree management - create, manage, and cleanup isolated development directories",
@@ -435,7 +446,7 @@ def create_worktrees_registry(
435
446
 
436
447
  @registry.tool(
437
448
  name="list_worktrees",
438
- description="List worktrees with optional filters.",
449
+ description="List worktrees with optional filters. Accepts #N, N, UUID, or prefix for agent_session_id.",
439
450
  )
440
451
  async def list_worktrees(
441
452
  status: str | None = None,
@@ -447,16 +458,24 @@ def create_worktrees_registry(
447
458
 
448
459
  Args:
449
460
  status: Filter by status (active, stale, merged, abandoned).
450
- agent_session_id: Filter by owning session.
461
+ agent_session_id: Session reference (accepts #N, N, UUID, or prefix) to filter by owning session.
451
462
  limit: Maximum results (default: 50).
452
463
 
453
464
  Returns:
454
465
  Dict with list of worktrees.
455
466
  """
467
+ # Resolve session_id to UUID (accepts #N, N, UUID, or prefix)
468
+ resolved_session_id = agent_session_id
469
+ if agent_session_id:
470
+ try:
471
+ resolved_session_id = _resolve_session_id(agent_session_id)
472
+ except ValueError as e:
473
+ return {"success": False, "error": str(e)}
474
+
456
475
  worktrees = worktree_storage.list_worktrees(
457
476
  project_id=project_id,
458
477
  status=status,
459
- agent_session_id=agent_session_id,
478
+ agent_session_id=resolved_session_id,
460
479
  limit=limit,
461
480
  )
462
481
 
@@ -479,7 +498,7 @@ def create_worktrees_registry(
479
498
 
480
499
  @registry.tool(
481
500
  name="claim_worktree",
482
- description="Claim ownership of a worktree for an agent session.",
501
+ description="Claim ownership of a worktree for an agent session. Accepts #N, N, UUID, or prefix for session_id.",
483
502
  )
484
503
  async def claim_worktree(
485
504
  worktree_id: str,
@@ -490,11 +509,17 @@ def create_worktrees_registry(
490
509
 
491
510
  Args:
492
511
  worktree_id: The worktree ID to claim.
493
- session_id: The session ID claiming ownership.
512
+ session_id: Session reference (accepts #N, N, UUID, or prefix) claiming ownership.
494
513
 
495
514
  Returns:
496
515
  Dict with success status.
497
516
  """
517
+ # Resolve session_id to UUID (accepts #N, N, UUID, or prefix)
518
+ try:
519
+ resolved_session_id = _resolve_session_id(session_id)
520
+ except ValueError as e:
521
+ return {"success": False, "error": str(e)}
522
+
498
523
  worktree = worktree_storage.get(worktree_id)
499
524
  if not worktree:
500
525
  return {
@@ -502,13 +527,13 @@ def create_worktrees_registry(
502
527
  "error": f"Worktree '{worktree_id}' not found",
503
528
  }
504
529
 
505
- if worktree.agent_session_id and worktree.agent_session_id != session_id:
530
+ if worktree.agent_session_id and worktree.agent_session_id != resolved_session_id:
506
531
  return {
507
532
  "success": False,
508
533
  "error": f"Worktree already claimed by session '{worktree.agent_session_id}'",
509
534
  }
510
535
 
511
- updated = worktree_storage.claim(worktree_id, session_id)
536
+ updated = worktree_storage.claim(worktree_id, resolved_session_id)
512
537
  if not updated:
513
538
  return {"error": "Failed to claim worktree"}
514
539
 
gobby/memory/extractor.py CHANGED
@@ -153,10 +153,15 @@ class SessionMemoryExtractor:
153
153
  """
154
154
  session = self.session_manager.get(session_id)
155
155
  if not session:
156
+ logger.warning(f"Session not found for memory extraction: {session_id}")
156
157
  return None
157
158
 
158
- # Get project info
159
+ # Get project info - log for debugging NULL project_id issues
159
160
  project_id = session.project_id
161
+ logger.debug(
162
+ f"Memory extraction context: session={session_id}, "
163
+ f"project_id={project_id!r} (type={type(project_id).__name__})"
164
+ )
160
165
  project_name = "Unknown Project"
161
166
 
162
167
  if project_id:
@@ -461,6 +466,15 @@ class SessionMemoryExtractor:
461
466
  session_id: Source session ID
462
467
  project_id: Project ID for the memories
463
468
  """
469
+ # Log project_id for debugging NULL project_id issues
470
+ if project_id is None:
471
+ logger.warning(
472
+ f"Storing memories with NULL project_id for session {session_id}. "
473
+ "This may cause duplicate detection issues."
474
+ )
475
+ else:
476
+ logger.debug(f"Storing {len(candidates)} memories with project_id={project_id}")
477
+
464
478
  for candidate in candidates:
465
479
  try:
466
480
  await self.memory_manager.remember(