codee-agent 0.6.0__tar.gz → 0.6.2__tar.gz

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 (92) hide show
  1. {codee_agent-0.6.0 → codee_agent-0.6.2}/PKG-INFO +8 -1
  2. {codee_agent-0.6.0 → codee_agent-0.6.2}/README.md +7 -0
  3. {codee_agent-0.6.0 → codee_agent-0.6.2}/pyproject.toml +1 -1
  4. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/admin.py +17 -2
  5. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/executor.py +30 -11
  6. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/lib/runs_db.py +14 -6
  7. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/lib/test_runs_db.py +21 -5
  8. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/lib/test_trigger_issue_skills.py +21 -0
  9. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/lib/trigger_aws_sqs_skills.py +2 -0
  10. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/lib/trigger_cron_skills.py +2 -0
  11. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/lib/trigger_email_skills.py +2 -0
  12. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/lib/trigger_issue_skills.py +17 -0
  13. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/test_executor.py +73 -0
  14. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_agent_abstract/provider.py +15 -0
  15. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_agent_github_copilot/provider.py +28 -0
  16. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_agent_github_copilot/test.py +45 -0
  17. {codee_agent-0.6.0 → codee_agent-0.6.2}/LICENSE +0 -0
  18. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/.gitignore +0 -0
  19. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/__init__.py +0 -0
  20. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/admin_api.py +0 -0
  21. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/admin_cli.py +0 -0
  22. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/admin_service.py +0 -0
  23. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/agent_cli.py +0 -0
  24. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/coding_agents.py +0 -0
  25. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/init_cli.py +0 -0
  26. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/lib/__init__.py +0 -0
  27. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/lib/claude_key_rotation.py +0 -0
  28. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/lib/cron_describe.py +0 -0
  29. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/lib/mcp_config.py +0 -0
  30. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/lib/test_claude_key_rotation.py +0 -0
  31. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/lib/test_mcp_config.py +0 -0
  32. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/lib/test_trigger_cron_skills.py +0 -0
  33. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/mail_server.py +0 -0
  34. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/setup_wizard.py +0 -0
  35. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/start_cli.py +0 -0
  36. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/tasks_providers.py +0 -0
  37. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/templates/AGENTS.md +0 -0
  38. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/templates/CLAUDE.md +0 -0
  39. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/templates/skills/aws-sqs-alarm-response/SKILL.md +0 -0
  40. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/templates/skills/cron-research-5xx-errors/SKILL.md +0 -0
  41. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/templates/skills/story-code-reviewer/SKILL.md +0 -0
  42. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/templates/skills/story-developer/SKILL.md +0 -0
  43. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/templates/skills/story-planner/SKILL.md +0 -0
  44. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/templates/skills/story-planner/assets/readme-template.md +0 -0
  45. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/templates/skills/story-qa/SKILL.md +0 -0
  46. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/templates/skills/story-security-reviewer/SKILL.md +0 -0
  47. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/templates/skills/task-code-reviewer/SKILL.md +0 -0
  48. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/templates/skills/task-developer/SKILL.md +0 -0
  49. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/templates/skills/task-qa/SKILL.md +0 -0
  50. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/templates/skills/task-security-reviewer/SKILL.md +0 -0
  51. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/test_admin_api.py +0 -0
  52. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/test_admin_cli.py +0 -0
  53. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/test_admin_service.py +0 -0
  54. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/test_agent_cli.py +0 -0
  55. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/test_coding_agents.py +0 -0
  56. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/test_init_cli.py +0 -0
  57. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/test_memory_index.py +0 -0
  58. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/test_setup_wizard.py +0 -0
  59. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/test_start_cli.py +0 -0
  60. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee/workflow_graph.py +0 -0
  61. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_admin/__init__.py +0 -0
  62. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_admin/codee_admin.py +0 -0
  63. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_agent_abstract/__init__.py +0 -0
  64. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_agent_claude_code/__init__.py +0 -0
  65. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_agent_claude_code/account.py +0 -0
  66. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_agent_claude_code/credentials.py +0 -0
  67. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_agent_claude_code/oauth.py +0 -0
  68. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_agent_claude_code/provider.py +0 -0
  69. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_agent_claude_code/test.py +0 -0
  70. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_agent_claude_code/usage.py +0 -0
  71. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_agent_codex/__init__.py +0 -0
  72. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_agent_codex/provider.py +0 -0
  73. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_agent_codex/test.py +0 -0
  74. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_agent_github_copilot/__init__.py +0 -0
  75. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_database/__init__.py +0 -0
  76. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_database/claude_code_accounts.py +0 -0
  77. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_database/database.py +0 -0
  78. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_database/oauth_tokens.py +0 -0
  79. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_main_context/__init__.py +0 -0
  80. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_main_context/context.py +0 -0
  81. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_main_context/logging.py +0 -0
  82. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_main_context/test_context.py +0 -0
  83. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_main_context/test_logging.py +0 -0
  84. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_tasks_abstract/__init__.py +0 -0
  85. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_tasks_abstract/provider.py +0 -0
  86. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_tasks_azure_devops/__init__.py +0 -0
  87. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_tasks_azure_devops/oauth.py +0 -0
  88. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_tasks_azure_devops/provider.py +0 -0
  89. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_tasks_azure_devops/test.py +0 -0
  90. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_tasks_jira/__init__.py +0 -0
  91. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_tasks_jira/provider.py +0 -0
  92. {codee_agent-0.6.0 → codee_agent-0.6.2}/src/codee_tasks_jira/test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codee-agent
3
- Version: 0.6.0
3
+ Version: 0.6.2
4
4
  Summary: A virtual co-worker that picks up Jira and Azure DevOps tasks and solves them with coding agents.
5
5
  Keywords: ai,agent,jira,azure-devops,claude-code,github-copilot,codex,automation
6
6
  Author: Denis Kibalko
@@ -41,6 +41,13 @@ Codee works with Jira and Azure DevOps as tasks providers, new providers are qui
41
41
 
42
42
  As agents it currently works with Claude Code, Github Copilot and Codex.
43
43
 
44
+ ## Documentation
45
+
46
+ Full documentation is at [fusebase-dev.github.io/codee](https://fusebase-dev.github.io/codee/), covering
47
+ installation, configuration and tutorials for working on tasks in Jira and Azure DevOps. There is also a
48
+ [click-through demo of the admin UI](https://fusebase-dev.github.io/codee/demo/) that runs without installing
49
+ anything.
50
+
44
51
  ## Run Codee
45
52
 
46
53
  Codee lives in its own directory, where it keeps skills, memory, temp files and config.
@@ -6,6 +6,13 @@ Codee works with Jira and Azure DevOps as tasks providers, new providers are qui
6
6
 
7
7
  As agents it currently works with Claude Code, Github Copilot and Codex.
8
8
 
9
+ ## Documentation
10
+
11
+ Full documentation is at [fusebase-dev.github.io/codee](https://fusebase-dev.github.io/codee/), covering
12
+ installation, configuration and tutorials for working on tasks in Jira and Azure DevOps. There is also a
13
+ [click-through demo of the admin UI](https://fusebase-dev.github.io/codee/demo/) that runs without installing
14
+ anything.
15
+
9
16
  ## Run Codee
10
17
 
11
18
  Codee lives in its own directory, where it keeps skills, memory, temp files and config.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codee-agent"
3
- version = "0.6.0"
3
+ version = "0.6.2"
4
4
  description = "A virtual co-worker that picks up Jira and Azure DevOps tasks and solves them with coding agents."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -254,7 +254,10 @@ class RunRecord(BaseModel):
254
254
  status: str
255
255
  error: str
256
256
  started_at: str
257
+ session_id: str
257
258
  message: str
259
+ user_message: str
260
+ response: str
258
261
  preview: str
259
262
  viewer_url: str
260
263
 
@@ -798,7 +801,10 @@ class AdminState(rx.State):
798
801
  status=run["status"],
799
802
  error=run.get("error") or "",
800
803
  started_at=run["started_at"],
804
+ session_id=run.get("session_id") or "",
801
805
  message=message,
806
+ user_message=(run.get("user_message") or message).strip(),
807
+ response=(run.get("response") or "").strip(),
802
808
  preview=preview[:120] + ("..." if len(preview) > 120 else ""),
803
809
  viewer_url=(SERVICE.session_viewer.format(session_id=run["session_id"])
804
810
  if SERVICE.session_viewer and run.get("session_id") else ""),
@@ -2386,6 +2392,9 @@ def run_row(run: RunRecord) -> rx.Component:
2386
2392
  rx.badge(run.status, color_scheme=rx.cond(run.status == "succeeded", "green", "red"))),
2387
2393
  rx.text(run.started_at, color=MUTED, font_size="0.8rem",
2388
2394
  font_family="IBM Plex Mono, monospace"),
2395
+ rx.text("Thread ID: ", run.session_id, color=MUTED,
2396
+ font_size="0.8rem",
2397
+ font_family="IBM Plex Mono, monospace"),
2389
2398
  rx.text(run.preview, color=MUTED),
2390
2399
  rx.cond(run.error != "", rx.text(
2391
2400
  run.error, color="#b42318", font_size="0.85rem")),
@@ -2394,8 +2403,14 @@ def run_row(run: RunRecord) -> rx.Component:
2394
2403
  rx.cond(run.viewer_url != "", rx.link(rx.icon("external-link", size=16), href=run.viewer_url,
2395
2404
  is_external=True, aria_label="View session", color=ACCENT)),
2396
2405
  gap="1rem", align="start", width="100%"),
2397
- rx.cond(run.message != "", rx.accordion.root(rx.accordion.item(
2398
- header="Full message", content=rx.text(run.message, white_space="pre-wrap"), value=run.started_at),
2406
+ rx.cond((run.user_message != "") | (run.response != ""), rx.accordion.root(rx.accordion.item(
2407
+ header="Run info", content=rx.vstack(
2408
+ rx.text("User message", font_weight="600"),
2409
+ rx.text(run.user_message, white_space="pre-wrap"),
2410
+ rx.cond(run.response != "", rx.fragment(
2411
+ rx.text("LLM response", font_weight="600", margin_top="0.75rem"),
2412
+ rx.text(run.response, white_space="pre-wrap"))),
2413
+ spacing="2", align="start", width="100%"), value=run.started_at),
2399
2414
  collapsible=True, width="100%")),
2400
2415
  padding="1rem", background=SURFACE, border=BORDER, width="100%")
2401
2416
 
@@ -44,7 +44,8 @@ POLL_INTERVAL = 60 # 1 minute
44
44
  SESSIONS_FILE = context.data_dir / "sessions.json"
45
45
  # The project Codee operates on — same root the trigger modules scan for
46
46
  # `.claude/skills`. The coding agent is spawned with this as its cwd, so the
47
- # `/<slug>` messages we build from those skills actually resolve.
47
+ # invocations we build from those skills — a `/<slug>` command, or a path to the
48
+ # skill file for agents that don't resolve one — actually resolve.
48
49
  REPO_ROOT = project_root()
49
50
 
50
51
  # Task agents run concurrently, one thread each, so one long agent (up to 2h)
@@ -246,7 +247,7 @@ def _pull_latest_code() -> bool:
246
247
 
247
248
 
248
249
  def _run_agent(user_message: str, session_id: str, model: str = "",
249
- agent_code: str = "") -> str:
250
+ agent_code: str = "", label: str = "") -> str:
250
251
  """Run the skill's coding agent and return its response text.
251
252
 
252
253
  ``model`` comes from the triggering skill's ``model:`` frontmatter; agents
@@ -254,9 +255,14 @@ def _run_agent(user_message: str, session_id: str, model: str = "",
254
255
  its ``x-codee-agent:`` frontmatter and picks which agent runs at all, empty
255
256
  for the default one. Wraps the agent run in job tracking; the agent itself
256
257
  raises on any failure so callers can retry.
258
+
259
+ ``label`` is how the run should read on the dashboard when that differs
260
+ from the prompt — an issue run is always shown as ``/<slug> <task id>``,
261
+ whatever wording the agent needed. Empty means the prompt is the label.
257
262
  """
258
263
  agent = _agent_for_skill(agent_code)
259
- job_id = runs_db.start_job(session_id, user_message, agent=agent.DISPLAY_NAME,
264
+ job_id = runs_db.start_job(session_id, label or user_message,
265
+ agent=agent.DISPLAY_NAME,
260
266
  model=model, main_context=context)
261
267
  log.debug("job %s started: session=%s message=%r model=%r agent=%s",
262
268
  job_id, session_id, user_message, model, agent.describe())
@@ -288,21 +294,27 @@ def _run_agent(user_message: str, session_id: str, model: str = "",
288
294
 
289
295
 
290
296
  def _run_task(task_id: str, message: str, session_id: str, skill_name: str,
291
- model: str = "", agent_code: str = "") -> None:
297
+ model: str = "", agent_code: str = "", label: str = "") -> None:
292
298
  """Pool worker: run one task's coding agent, then release its in-flight slot.
293
299
 
294
300
  Logs the outcome to the runs table like the cron/email/sqs triggers do, so
295
301
  issue-triggered coding runs show up on the dashboard too. Stamped with the
296
302
  launch time (not the finish time) so the hourly chart buckets it where it
297
303
  actually started — an agent can run for hours.
304
+
305
+ ``label`` is what the dashboard and the run log show instead of ``message``,
306
+ so every issue run reads as its ``/<slug> <task id>`` command no matter how
307
+ the agent had to be asked. The prompt itself is in the debug log.
298
308
  """
299
309
  started_at = datetime.now(timezone.utc).isoformat()
310
+ shown = label or message
300
311
  try:
301
- response = _run_agent(message, session_id, model, agent_code)
312
+ response = _run_agent(message, session_id, model, agent_code, label)
302
313
  log.info("Agent response for %s (%d chars): %s",
303
314
  task_id, len(response), response)
304
315
  runs_db.record_run(skill_name, "issue", session_id, "succeeded",
305
- started_at=started_at, message=message,
316
+ started_at=started_at, message=shown,
317
+ user_message=message, response=response,
306
318
  main_context=context)
307
319
  except Exception as exc:
308
320
  # Over-limit / transient failure: leave the task in its current
@@ -311,14 +323,15 @@ def _run_task(task_id: str, message: str, session_id: str, skill_name: str,
311
323
  log.debug("%s failed with:\n%s", task_id, traceback.format_exc())
312
324
  runs_db.record_run(skill_name, "issue", session_id, "failed",
313
325
  error=str(exc)[:500], started_at=started_at,
314
- message=message, main_context=context)
326
+ message=shown, user_message=message,
327
+ main_context=context)
315
328
  finally:
316
329
  with _inflight_lock:
317
330
  _inflight.discard(task_id)
318
331
 
319
332
 
320
333
  def _submit_task(task_id: str, message: str, session_id: str, skill_name: str,
321
- model: str = "", agent_code: str = "") -> bool:
334
+ model: str = "", agent_code: str = "", label: str = "") -> bool:
322
335
  """Start a task's agent unless one is already in flight or we're at the cap.
323
336
 
324
337
  Returns True if launched, False if skipped — as a duplicate, or because
@@ -342,7 +355,7 @@ def _submit_task(task_id: str, message: str, session_id: str, skill_name: str,
342
355
  # killing it mid-run, which is what the thread pool used to give us.
343
356
  threading.Thread(target=_run_task, name=f"task-agent-{task_id}",
344
357
  args=(task_id, message, session_id, skill_name, model,
345
- agent_code)).start()
358
+ agent_code, label)).start()
346
359
  log.info("Started an agent for %s (%d running, max %d).",
347
360
  task_id, depth, limit)
348
361
  return True
@@ -424,13 +437,19 @@ def run_once() -> None:
424
437
  log.debug("No issue trigger matches %s (%s, %s); skipping",
425
438
  task_id, status, issue_type)
426
439
  continue
427
- message = f"/{skill.slug} {task_id}"
440
+ # How a skill is invoked is the agent's business: Claude Code resolves
441
+ # the slash command out of `.claude/skills`, Copilot has to be pointed
442
+ # at the file. The command stays the label either way, so one issue run
443
+ # reads the same on the dashboard whichever agent picked it up.
444
+ label = f"/{skill.slug} {task_id}"
445
+ message = _agent_for_skill(skill.agent).skill_prompt(
446
+ skill.slug, skill.path, task_id, skill.argument_name)
428
447
 
429
448
  log.info("Processing %s (%s, %s): %s session-id=%s",
430
449
  task_id, status, issue_type, summary, session_id)
431
450
 
432
451
  _submit_task(task_id, message, session_id, skill.name,
433
- skill.model, skill.agent)
452
+ skill.model, skill.agent, label)
434
453
 
435
454
 
436
455
  def main() -> None:
@@ -11,7 +11,7 @@ from codee_main_context.context import CodeeMainContext
11
11
  from codee_database.database import get_db_connection
12
12
 
13
13
  _COLUMNS = ("id", "skill_name", "trigger_type", "session_id", "status", "error",
14
- "started_at", "message")
14
+ "started_at", "message", "user_message", "response")
15
15
 
16
16
  # Codee names a session before the agent runs, but not every agent runs under
17
17
  # the name it was given: Codex mints its own thread id and reports it back mid
@@ -50,7 +50,9 @@ def init(main_context: CodeeMainContext) -> None:
50
50
  status TEXT NOT NULL,
51
51
  error TEXT,
52
52
  started_at TEXT NOT NULL,
53
- message TEXT
53
+ message TEXT,
54
+ user_message TEXT,
55
+ response TEXT
54
56
  )"""
55
57
  )
56
58
  conn.execute(
@@ -59,6 +61,10 @@ def init(main_context: CodeeMainContext) -> None:
59
61
  cols = {row[1] for row in conn.execute("PRAGMA table_info(runs)")}
60
62
  if "message" not in cols:
61
63
  conn.execute("ALTER TABLE runs ADD COLUMN message TEXT")
64
+ if "response" not in cols:
65
+ conn.execute("ALTER TABLE runs ADD COLUMN response TEXT")
66
+ if "user_message" not in cols:
67
+ conn.execute("ALTER TABLE runs ADD COLUMN user_message TEXT")
62
68
  # In-flight claude runs; a row lives only while its subprocess is running.
63
69
  conn.execute(
64
70
  """CREATE TABLE IF NOT EXISTS active_jobs (
@@ -78,7 +84,8 @@ def init(main_context: CodeeMainContext) -> None:
78
84
 
79
85
 
80
86
  def record_run(skill_name, trigger_type, session_id, status, error=None, started_at=None,
81
- message=None, *, main_context: CodeeMainContext) -> None:
87
+ message=None, user_message=None, response=None,
88
+ *, main_context: CodeeMainContext) -> None:
82
89
  """Insert one run row. Never raises to the caller (FR-009)."""
83
90
  try:
84
91
  init(main_context)
@@ -88,9 +95,10 @@ def record_run(skill_name, trigger_type, session_id, status, error=None, started
88
95
  with get_db_connection(main_context) as conn:
89
96
  conn.execute(
90
97
  "INSERT INTO runs (skill_name, trigger_type, session_id, status, error,"
91
- " started_at, message) VALUES (?, ?, ?, ?, ?, ?, ?)",
98
+ " started_at, message, user_message, response)"
99
+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
92
100
  (skill_name, trigger_type, session_id,
93
- status, error, started_at, message),
101
+ status, error, started_at, message, user_message, response),
94
102
  )
95
103
  except Exception as exc: # ponytail: a logging miss must never abort the skill run
96
104
  print(f"[runs_db] Failed to record run for {skill_name}: {exc}")
@@ -104,7 +112,7 @@ def recent_runs(limit: int = 100, offset: int = 0, *,
104
112
  with get_db_connection(main_context) as conn:
105
113
  rows = conn.execute(
106
114
  "SELECT id, skill_name, trigger_type, session_id, status, error,"
107
- " started_at, message"
115
+ " started_at, message, user_message, response"
108
116
  " FROM runs ORDER BY started_at DESC, id DESC LIMIT ? OFFSET ?",
109
117
  (limit, max(offset, 0)),
110
118
  ).fetchall()
@@ -130,6 +130,17 @@ def test_message_round_trip(tmp_path):
130
130
  assert by_skill == {"a": "hello", "b": "", "c": None}
131
131
 
132
132
 
133
+ def test_response_round_trip(tmp_path):
134
+ ctx = _ctx(tmp_path)
135
+ runs_db.record_run("a", "cron", "s1", "succeeded", message="question",
136
+ user_message="full question", response="final answer",
137
+ main_context=ctx)
138
+
139
+ run, = runs_db.recent_runs(main_context=ctx)
140
+ assert run["user_message"] == "full question"
141
+ assert run["response"] == "final answer"
142
+
143
+
133
144
  # ---------------------------------------------------------------- active_jobs
134
145
  def test_active_job_lifecycle(tmp_path):
135
146
  ctx = _ctx(tmp_path)
@@ -210,7 +221,7 @@ def test_fmt_elapsed():
210
221
  assert runs_db.fmt_elapsed(3700) == "1h 1m"
211
222
 
212
223
 
213
- def test_migration_adds_message_column_without_data_loss(tmp_path):
224
+ def test_migration_adds_run_detail_columns_without_data_loss(tmp_path):
214
225
  ctx = _ctx(tmp_path)
215
226
  # Build an old-schema DB (spec 001, no message column) with one row.
216
227
  with sqlite3.connect(_db_file(tmp_path)) as conn:
@@ -231,10 +242,15 @@ def test_migration_adds_message_column_without_data_loss(tmp_path):
231
242
  assert len(rows) == 1
232
243
  assert rows[0]["skill_name"] == "old"
233
244
  assert rows[0]["message"] is None # pre-feature row reads as NULL
234
-
235
- # New writes carry message; the upgraded DB round-trips it.
236
- runs_db.record_run("new", "cron", "sid-new", "succeeded", message="m", main_context=ctx)
237
- assert runs_db.recent_runs(main_context=ctx)[0]["message"] == "m"
245
+ assert rows[0]["user_message"] is None
246
+ assert rows[0]["response"] is None
247
+
248
+ # New writes carry both details; the upgraded DB round-trips them.
249
+ runs_db.record_run("new", "cron", "sid-new", "succeeded", message="m",
250
+ user_message="u", response="r", main_context=ctx)
251
+ newest = runs_db.recent_runs(main_context=ctx)[0]
252
+ assert (newest["message"], newest["user_message"], newest["response"]) == (
253
+ "m", "u", "r")
238
254
 
239
255
 
240
256
  # The agent Codee names a session for is not always the agent that runs under
@@ -19,6 +19,27 @@ class IssueTriggeredSkillsTest(unittest.TestCase):
19
19
  directory.mkdir()
20
20
  (directory / "SKILL.md").write_text(f"---\n{frontmatter}---\nBody\n")
21
21
 
22
+ def test_reads_the_argument_name_out_of_the_argument_hint(self) -> None:
23
+ with tempfile.TemporaryDirectory() as temporary_directory:
24
+ root = Path(temporary_directory)
25
+ common = ("disable-model-invocation: true\nx-codee-trigger: issue\n"
26
+ "x-codee-issue-status: [Ready]\nx-codee-issue-type: story\n")
27
+ self._skill(root, "bracketed",
28
+ common + "argument-hint: <STORY_ID>\n")
29
+ self._skill(root, "bare", common + "argument-hint: STORY_ID\n")
30
+ self._skill(root, "several",
31
+ common + "argument-hint: <TASK_ID> [continue]\n")
32
+ self._skill(root, "none", common)
33
+
34
+ names = {skill.slug: skill.argument_name
35
+ for skill in find_issue_triggered_skills(root)}
36
+
37
+ self.assertEqual(names["bracketed"], "STORY_ID")
38
+ self.assertEqual(names["bare"], "STORY_ID")
39
+ # Only the first argument is passed; optional extras are dropped.
40
+ self.assertEqual(names["several"], "TASK_ID")
41
+ self.assertEqual(names["none"], "")
42
+
22
43
  def test_reads_the_model_frontmatter_when_present(self) -> None:
23
44
  with tempfile.TemporaryDirectory() as temporary_directory:
24
45
  root = Path(temporary_directory)
@@ -80,11 +80,13 @@ def trigger_aws_sqs_skills(
80
80
  sqs_message_source.delete(message)
81
81
  runs_db.record_run(skill.name, "aws-sqs",
82
82
  session_id, "succeeded", message=prompt,
83
+ user_message=prompt, response=response,
83
84
  main_context=main_context)
84
85
  except Exception as exc:
85
86
  print(f"[aws_sqs_skills] Failed to run {skill.name}: {exc}")
86
87
  runs_db.record_run(skill.name, "aws-sqs", session_id, "failed",
87
88
  error=str(exc)[:500], message=prompt,
89
+ user_message=prompt,
88
90
  main_context=main_context)
89
91
 
90
92
 
@@ -126,6 +126,7 @@ def trigger_cron_skills(
126
126
  f"[cron_skills] Claude response for {skill.name} ({len(response)} chars)")
127
127
  runs_db.record_run(skill.name, "cron", session_id,
128
128
  "succeeded", message=skill.body,
129
+ user_message=skill.body, response=response,
129
130
  main_context=main_context)
130
131
  except Exception as exc:
131
132
  # Don't advance the state slot: leave the job "due" so a later tick
@@ -135,6 +136,7 @@ def trigger_cron_skills(
135
136
  f"[cron_skills] Failed to run {skill.name}, will retry: {exc}")
136
137
  runs_db.record_run(skill.name, "cron", session_id, "failed",
137
138
  error=str(exc)[:500], message=skill.body,
139
+ user_message=skill.body,
138
140
  main_context=main_context)
139
141
  continue
140
142
  if is_forced:
@@ -89,12 +89,14 @@ def trigger_email_skills(
89
89
  path.unlink(missing_ok=True)
90
90
  runs_db.record_run(skill.name, "email", session_id,
91
91
  "succeeded", message=prompt,
92
+ user_message=prompt, response=response,
92
93
  main_context=main_context)
93
94
  except Exception as exc:
94
95
  print(
95
96
  f"[email_skills] Failed to run {skill.name} for {path.name}: {exc}")
96
97
  runs_db.record_run(skill.name, "email", session_id, "failed",
97
98
  error=str(exc)[:500], message=prompt,
99
+ user_message=prompt,
98
100
  main_context=main_context)
99
101
 
100
102
 
@@ -34,6 +34,10 @@ class IssueTriggeredSkill:
34
34
  # The agent this skill asks to be run by (``x-codee-agent``), empty when it
35
35
  # names none and the default agent from Settings should drive it.
36
36
  agent: str = ""
37
+ # What the skill calls the value it's invoked with (``argument-hint:
38
+ # <STORY_ID>``), empty when it names none. Agents that spell the invocation
39
+ # out instead of sending a slash command label the task id with it.
40
+ argument_name: str = ""
37
41
 
38
42
 
39
43
  def find_issue_triggered_skills(
@@ -91,6 +95,7 @@ def find_issue_triggered_skills(
91
95
  issue_type=issue_type,
92
96
  model=str(metadata.get("model", "")).strip(),
93
97
  agent=str(metadata.get("x-codee-agent", "") or "").strip(),
98
+ argument_name=_argument_name(metadata.get("argument-hint")),
94
99
  ))
95
100
  return skills
96
101
 
@@ -124,6 +129,18 @@ def _parse_frontmatter(contents: str) -> dict[str, Any]:
124
129
  return parsed if isinstance(parsed, dict) else {}
125
130
 
126
131
 
132
+ def _argument_name(value: Any) -> str:
133
+ """The name a skill's ``argument-hint`` gives its first argument.
134
+
135
+ ``<STORY_ID>`` names ``STORY_ID`` and ``<TASK_ID> [continue]`` names
136
+ ``TASK_ID``: an issue trigger passes exactly one value, and that value is
137
+ the first the hint describes. Anything optional behind it is the skill's
138
+ own business and never reaches the invocation.
139
+ """
140
+ words = str(value or "").strip().split()
141
+ return words[0].strip("<>[]").strip() if words else ""
142
+
143
+
127
144
  def _status_values(value: Any) -> tuple[str, ...]:
128
145
  values = value if isinstance(value, list) else [value]
129
146
  return tuple(
@@ -4,6 +4,7 @@ import unittest
4
4
  from pathlib import Path
5
5
  from unittest.mock import Mock, patch
6
6
 
7
+ from codee_agent_claude_code.provider import ClaudeCodeAgent
7
8
  from codee_agent_codex.provider import CodexAgent
8
9
  from codee_agent_github_copilot.provider import GitHubCopilotAgent
9
10
  from codee_main_context.context import (
@@ -186,6 +187,33 @@ class AgentForSkillTest(unittest.TestCase):
186
187
  executor.coding_agent)
187
188
 
188
189
 
190
+ class SkillPromptTest(unittest.TestCase):
191
+ """The invocation a skill gets is phrased by the agent that will run it."""
192
+
193
+ SKILL = Path("/repo/.claude/skills/story-code-reviewer/SKILL.md")
194
+
195
+ def test_claude_code_gets_the_slash_command(self) -> None:
196
+ agent = ClaudeCodeAgent(Settings(), Path("/repo"))
197
+
198
+ self.assertEqual(
199
+ agent.skill_prompt("story-code-reviewer", self.SKILL, "90939",
200
+ "STORY_ID"),
201
+ "/story-code-reviewer 90939",
202
+ )
203
+
204
+ def test_copilot_gets_told_to_read_the_skill_file(self) -> None:
205
+ # `copilot` resolves no slash command and the skill hides itself with
206
+ # disable-model-invocation, so the path has to be spelled out.
207
+ agent = GitHubCopilotAgent(Settings(), Path("/repo"))
208
+
209
+ self.assertEqual(
210
+ agent.skill_prompt("story-code-reviewer", self.SKILL, "90939",
211
+ "STORY_ID"),
212
+ "Read .claude/skills/story-code-reviewer/SKILL.md and follow its "
213
+ "instructions exactly. STORY_ID = 90939",
214
+ )
215
+
216
+
189
217
  class RunAgentSelectionTest(unittest.TestCase):
190
218
  """The agent a run lands on is the one the triggering skill asked for."""
191
219
 
@@ -238,6 +266,8 @@ class RunTaskLoggingTest(unittest.TestCase):
238
266
  self.assertEqual(run["status"], "succeeded")
239
267
  self.assertEqual(run["session_id"], "sid-1")
240
268
  self.assertEqual(run["message"], "/story-developer NIM-1")
269
+ self.assertEqual(run["user_message"], "/story-developer NIM-1")
270
+ self.assertEqual(run["response"], "done")
241
271
 
242
272
  def test_failed_run_is_recorded_with_the_error(self) -> None:
243
273
  with patch.object(executor, "_run_agent", side_effect=RuntimeError("over limit")):
@@ -258,6 +288,49 @@ class RunTaskLoggingTest(unittest.TestCase):
258
288
  self.assertEqual(executor._run_agent("/story-developer NIM-4", "sid-4"),
259
289
  "done")
260
290
 
291
+ def test_the_run_log_shows_the_command_not_the_agents_wording(self) -> None:
292
+ # A Copilot run is prompted with the skill's file path, but the run log
293
+ # is still the slash command, so both agents' runs read the same.
294
+ prompt = ("Read .claude/skills/story-developer/SKILL.md and follow its "
295
+ "instructions exactly. STORY_ID = 4124")
296
+
297
+ with patch.object(executor, "_run_agent", return_value="done") as run:
298
+ executor._run_task("NIM-5", prompt, "sid-5", "story-developer",
299
+ label="/story-developer 4124")
300
+
301
+ run_record, = self._runs()
302
+ self.assertEqual(run_record["message"], "/story-developer 4124")
303
+ self.assertEqual(run_record["user_message"], prompt)
304
+ # The agent still gets the wording it can act on.
305
+ self.assertEqual(run.call_args.args[0], prompt)
306
+
307
+ def test_a_failed_run_is_logged_under_the_command_too(self) -> None:
308
+ with patch.object(executor, "_run_agent", side_effect=RuntimeError("boom")):
309
+ executor._run_task("NIM-6", "Read .claude/skills/x/SKILL.md ...",
310
+ "sid-6", "story-developer",
311
+ label="/story-developer 4124")
312
+
313
+ run_record, = self._runs()
314
+ self.assertEqual(run_record["message"], "/story-developer 4124")
315
+
316
+ def test_the_live_job_row_carries_the_command(self) -> None:
317
+ with patch.object(executor.coding_agent, "run", return_value="done"), \
318
+ patch.object(runs_db, "start_job",
319
+ return_value="job-1") as started:
320
+ executor._run_agent("Read .claude/skills/x/SKILL.md ...", "sid-7",
321
+ label="/story-developer 4124")
322
+
323
+ self.assertEqual(started.call_args.args[1], "/story-developer 4124")
324
+
325
+ def test_a_run_with_no_label_is_shown_as_its_prompt(self) -> None:
326
+ # Cron, email and SQS hand over a skill body and have no command to show.
327
+ with patch.object(executor, "_run_agent", return_value="done"):
328
+ executor._run_task("NIM-7", "Do the nightly sweep", "sid-8",
329
+ "nightly")
330
+
331
+ run_record, = self._runs()
332
+ self.assertEqual(run_record["message"], "Do the nightly sweep")
333
+
261
334
  def test_counts_include_issue_runs(self) -> None:
262
335
  with patch.object(executor, "_run_agent", return_value="done"):
263
336
  executor._run_task("NIM-3", "/story-developer NIM-3",
@@ -69,6 +69,21 @@ class AbstractCodingAgent(ABC):
69
69
  """
70
70
  ...
71
71
 
72
+ def skill_prompt(self, slug: str, path: Path, argument: str = "",
73
+ argument_name: str = "") -> str:
74
+ """The message that makes this agent run the skill stored at ``path``.
75
+
76
+ The default is the slash command Claude Code resolves out of
77
+ ``.claude/skills``. Agents that don't read those skills — every
78
+ issue-triggered one carries ``disable-model-invocation: true``, so they
79
+ won't find it on their own either — override this and name the file.
80
+
81
+ ``argument`` is the single value the trigger passes (a task id) and
82
+ ``argument_name`` is what the skill's ``argument-hint`` calls it, empty
83
+ when it names none.
84
+ """
85
+ return f"/{slug} {argument}".strip()
86
+
72
87
  @classmethod
73
88
  def best_model(cls) -> str:
74
89
  """The id to pass for "the most capable model", for callers with no skill.
@@ -35,6 +35,10 @@ DEFAULT_MAX_AI_CREDITS = 1000
35
35
  # than a misconfigured cap, so a smaller override is raised to it instead.
36
36
  MIN_AI_CREDITS = 30
37
37
 
38
+ # What to call the value an issue trigger passes when the skill's frontmatter
39
+ # carries no ``argument-hint`` to name it.
40
+ DEFAULT_ARGUMENT_NAME = "ARGUMENT"
41
+
38
42
 
39
43
  def _max_ai_credits() -> str:
40
44
  """The per-run credit cap, read fresh so a changed env applies to the next run."""
@@ -67,6 +71,22 @@ class GitHubCopilotAgent(AbstractCodingAgent):
67
71
  def best_model(cls) -> str:
68
72
  return cls.BEST_MODEL
69
73
 
74
+ def skill_prompt(self, slug: str, path: Path, argument: str = "",
75
+ argument_name: str = "") -> str:
76
+ """Point copilot at the skill file instead of sending a slash command.
77
+
78
+ `copilot` has no slash command for `.claude/skills`, and every
79
+ issue-triggered skill sets ``disable-model-invocation: true``, so it
80
+ won't pick the skill up on its own either. Naming the file and telling
81
+ it to follow what's inside is the only way in. The path is relative to
82
+ the working directory the run gets, which is where the CLI starts.
83
+ """
84
+ prompt = (f"Read {_relative(path, self._cwd)} and follow its "
85
+ "instructions exactly.")
86
+ if argument:
87
+ prompt += f" {argument_name or DEFAULT_ARGUMENT_NAME} = {argument}"
88
+ return prompt
89
+
70
90
  @classmethod
71
91
  def list_models(cls) -> list[AgentModel]:
72
92
  try:
@@ -149,6 +169,14 @@ class GitHubCopilotAgent(AbstractCodingAgent):
149
169
  return reply
150
170
 
151
171
 
172
+ def _relative(path: Path, cwd: Path) -> str:
173
+ """``path`` as the CLI will see it from ``cwd``, or absolute if it's outside."""
174
+ try:
175
+ return str(path.relative_to(cwd))
176
+ except ValueError:
177
+ return str(path)
178
+
179
+
152
180
  def _fetch_acp_models() -> list[AgentModel]:
153
181
  """Ask ``copilot --acp`` for the account's model catalog.
154
182
 
@@ -29,6 +29,51 @@ def _completed(stdout: str = "", stderr: str = "", returncode: int = 0):
29
29
  args=["copilot"], returncode=returncode, stdout=stdout, stderr=stderr)
30
30
 
31
31
 
32
+ class CopilotSkillPromptTest(unittest.TestCase):
33
+ def setUp(self) -> None:
34
+ self.agent = GitHubCopilotAgent(Settings(), Path("/repo"))
35
+
36
+ def _prompt(self, path: Path, argument: str = "", argument_name: str = "") -> str:
37
+ return self.agent.skill_prompt("story-code-reviewer", path, argument,
38
+ argument_name)
39
+
40
+ def test_names_the_skill_file_instead_of_a_slash_command(self) -> None:
41
+ prompt = self._prompt(
42
+ Path("/repo/.claude/skills/story-code-reviewer/SKILL.md"),
43
+ "90939", "STORY_ID")
44
+
45
+ self.assertEqual(
46
+ prompt,
47
+ "Read .claude/skills/story-code-reviewer/SKILL.md and follow its "
48
+ "instructions exactly. STORY_ID = 90939",
49
+ )
50
+
51
+ def test_a_skill_that_names_no_argument_gets_a_generic_label(self) -> None:
52
+ prompt = self._prompt(
53
+ Path("/repo/.claude/skills/story-code-reviewer/SKILL.md"), "90939")
54
+
55
+ self.assertTrue(prompt.endswith("ARGUMENT = 90939"), prompt)
56
+
57
+ def test_no_argument_leaves_the_instruction_alone(self) -> None:
58
+ prompt = self._prompt(
59
+ Path("/repo/.claude/skills/story-code-reviewer/SKILL.md"))
60
+
61
+ self.assertEqual(
62
+ prompt,
63
+ "Read .claude/skills/story-code-reviewer/SKILL.md and follow its "
64
+ "instructions exactly.",
65
+ )
66
+
67
+ def test_a_skill_outside_the_working_directory_keeps_its_full_path(self) -> None:
68
+ prompt = self._prompt(Path("/elsewhere/skills/reviewer/SKILL.md"), "7", "ID")
69
+
70
+ self.assertEqual(
71
+ prompt,
72
+ "Read /elsewhere/skills/reviewer/SKILL.md and follow its "
73
+ "instructions exactly. ID = 7",
74
+ )
75
+
76
+
32
77
  class CopilotRunTest(unittest.TestCase):
33
78
  def setUp(self) -> None:
34
79
  self.agent = GitHubCopilotAgent(Settings(), Path("/repo"))
File without changes