codee-agent 0.6.0__tar.gz → 0.6.1__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.1}/PKG-INFO +8 -1
  2. {codee_agent-0.6.0 → codee_agent-0.6.1}/README.md +7 -0
  3. {codee_agent-0.6.0 → codee_agent-0.6.1}/pyproject.toml +1 -1
  4. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/executor.py +28 -11
  5. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/lib/test_trigger_issue_skills.py +21 -0
  6. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/lib/trigger_issue_skills.py +17 -0
  7. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/test_executor.py +70 -0
  8. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_agent_abstract/provider.py +15 -0
  9. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_agent_github_copilot/provider.py +28 -0
  10. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_agent_github_copilot/test.py +45 -0
  11. {codee_agent-0.6.0 → codee_agent-0.6.1}/LICENSE +0 -0
  12. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/.gitignore +0 -0
  13. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/__init__.py +0 -0
  14. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/admin.py +0 -0
  15. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/admin_api.py +0 -0
  16. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/admin_cli.py +0 -0
  17. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/admin_service.py +0 -0
  18. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/agent_cli.py +0 -0
  19. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/coding_agents.py +0 -0
  20. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/init_cli.py +0 -0
  21. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/lib/__init__.py +0 -0
  22. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/lib/claude_key_rotation.py +0 -0
  23. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/lib/cron_describe.py +0 -0
  24. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/lib/mcp_config.py +0 -0
  25. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/lib/runs_db.py +0 -0
  26. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/lib/test_claude_key_rotation.py +0 -0
  27. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/lib/test_mcp_config.py +0 -0
  28. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/lib/test_runs_db.py +0 -0
  29. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/lib/test_trigger_cron_skills.py +0 -0
  30. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/lib/trigger_aws_sqs_skills.py +0 -0
  31. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/lib/trigger_cron_skills.py +0 -0
  32. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/lib/trigger_email_skills.py +0 -0
  33. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/mail_server.py +0 -0
  34. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/setup_wizard.py +0 -0
  35. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/start_cli.py +0 -0
  36. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/tasks_providers.py +0 -0
  37. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/templates/AGENTS.md +0 -0
  38. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/templates/CLAUDE.md +0 -0
  39. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/templates/skills/aws-sqs-alarm-response/SKILL.md +0 -0
  40. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/templates/skills/cron-research-5xx-errors/SKILL.md +0 -0
  41. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/templates/skills/story-code-reviewer/SKILL.md +0 -0
  42. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/templates/skills/story-developer/SKILL.md +0 -0
  43. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/templates/skills/story-planner/SKILL.md +0 -0
  44. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/templates/skills/story-planner/assets/readme-template.md +0 -0
  45. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/templates/skills/story-qa/SKILL.md +0 -0
  46. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/templates/skills/story-security-reviewer/SKILL.md +0 -0
  47. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/templates/skills/task-code-reviewer/SKILL.md +0 -0
  48. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/templates/skills/task-developer/SKILL.md +0 -0
  49. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/templates/skills/task-qa/SKILL.md +0 -0
  50. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/templates/skills/task-security-reviewer/SKILL.md +0 -0
  51. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/test_admin_api.py +0 -0
  52. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/test_admin_cli.py +0 -0
  53. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/test_admin_service.py +0 -0
  54. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/test_agent_cli.py +0 -0
  55. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/test_coding_agents.py +0 -0
  56. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/test_init_cli.py +0 -0
  57. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/test_memory_index.py +0 -0
  58. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/test_setup_wizard.py +0 -0
  59. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/test_start_cli.py +0 -0
  60. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee/workflow_graph.py +0 -0
  61. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_admin/__init__.py +0 -0
  62. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_admin/codee_admin.py +0 -0
  63. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_agent_abstract/__init__.py +0 -0
  64. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_agent_claude_code/__init__.py +0 -0
  65. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_agent_claude_code/account.py +0 -0
  66. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_agent_claude_code/credentials.py +0 -0
  67. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_agent_claude_code/oauth.py +0 -0
  68. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_agent_claude_code/provider.py +0 -0
  69. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_agent_claude_code/test.py +0 -0
  70. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_agent_claude_code/usage.py +0 -0
  71. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_agent_codex/__init__.py +0 -0
  72. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_agent_codex/provider.py +0 -0
  73. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_agent_codex/test.py +0 -0
  74. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_agent_github_copilot/__init__.py +0 -0
  75. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_database/__init__.py +0 -0
  76. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_database/claude_code_accounts.py +0 -0
  77. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_database/database.py +0 -0
  78. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_database/oauth_tokens.py +0 -0
  79. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_main_context/__init__.py +0 -0
  80. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_main_context/context.py +0 -0
  81. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_main_context/logging.py +0 -0
  82. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_main_context/test_context.py +0 -0
  83. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_main_context/test_logging.py +0 -0
  84. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_tasks_abstract/__init__.py +0 -0
  85. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_tasks_abstract/provider.py +0 -0
  86. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_tasks_azure_devops/__init__.py +0 -0
  87. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_tasks_azure_devops/oauth.py +0 -0
  88. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_tasks_azure_devops/provider.py +0 -0
  89. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_tasks_azure_devops/test.py +0 -0
  90. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_tasks_jira/__init__.py +0 -0
  91. {codee_agent-0.6.0 → codee_agent-0.6.1}/src/codee_tasks_jira/provider.py +0 -0
  92. {codee_agent-0.6.0 → codee_agent-0.6.1}/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.1
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.1"
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"
@@ -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,26 @@ 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,
306
317
  main_context=context)
307
318
  except Exception as exc:
308
319
  # Over-limit / transient failure: leave the task in its current
@@ -311,14 +322,14 @@ def _run_task(task_id: str, message: str, session_id: str, skill_name: str,
311
322
  log.debug("%s failed with:\n%s", task_id, traceback.format_exc())
312
323
  runs_db.record_run(skill_name, "issue", session_id, "failed",
313
324
  error=str(exc)[:500], started_at=started_at,
314
- message=message, main_context=context)
325
+ message=shown, main_context=context)
315
326
  finally:
316
327
  with _inflight_lock:
317
328
  _inflight.discard(task_id)
318
329
 
319
330
 
320
331
  def _submit_task(task_id: str, message: str, session_id: str, skill_name: str,
321
- model: str = "", agent_code: str = "") -> bool:
332
+ model: str = "", agent_code: str = "", label: str = "") -> bool:
322
333
  """Start a task's agent unless one is already in flight or we're at the cap.
323
334
 
324
335
  Returns True if launched, False if skipped — as a duplicate, or because
@@ -342,7 +353,7 @@ def _submit_task(task_id: str, message: str, session_id: str, skill_name: str,
342
353
  # killing it mid-run, which is what the thread pool used to give us.
343
354
  threading.Thread(target=_run_task, name=f"task-agent-{task_id}",
344
355
  args=(task_id, message, session_id, skill_name, model,
345
- agent_code)).start()
356
+ agent_code, label)).start()
346
357
  log.info("Started an agent for %s (%d running, max %d).",
347
358
  task_id, depth, limit)
348
359
  return True
@@ -424,13 +435,19 @@ def run_once() -> None:
424
435
  log.debug("No issue trigger matches %s (%s, %s); skipping",
425
436
  task_id, status, issue_type)
426
437
  continue
427
- message = f"/{skill.slug} {task_id}"
438
+ # How a skill is invoked is the agent's business: Claude Code resolves
439
+ # the slash command out of `.claude/skills`, Copilot has to be pointed
440
+ # at the file. The command stays the label either way, so one issue run
441
+ # reads the same on the dashboard whichever agent picked it up.
442
+ label = f"/{skill.slug} {task_id}"
443
+ message = _agent_for_skill(skill.agent).skill_prompt(
444
+ skill.slug, skill.path, task_id, skill.argument_name)
428
445
 
429
446
  log.info("Processing %s (%s, %s): %s session-id=%s",
430
447
  task_id, status, issue_type, summary, session_id)
431
448
 
432
449
  _submit_task(task_id, message, session_id, skill.name,
433
- skill.model, skill.agent)
450
+ skill.model, skill.agent, label)
434
451
 
435
452
 
436
453
  def main() -> None:
@@ -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)
@@ -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
 
@@ -258,6 +286,48 @@ class RunTaskLoggingTest(unittest.TestCase):
258
286
  self.assertEqual(executor._run_agent("/story-developer NIM-4", "sid-4"),
259
287
  "done")
260
288
 
289
+ def test_the_run_log_shows_the_command_not_the_agents_wording(self) -> None:
290
+ # A Copilot run is prompted with the skill's file path, but the run log
291
+ # is still the slash command, so both agents' runs read the same.
292
+ prompt = ("Read .claude/skills/story-developer/SKILL.md and follow its "
293
+ "instructions exactly. STORY_ID = 4124")
294
+
295
+ with patch.object(executor, "_run_agent", return_value="done") as run:
296
+ executor._run_task("NIM-5", prompt, "sid-5", "story-developer",
297
+ label="/story-developer 4124")
298
+
299
+ run_record, = self._runs()
300
+ self.assertEqual(run_record["message"], "/story-developer 4124")
301
+ # The agent still gets the wording it can act on.
302
+ self.assertEqual(run.call_args.args[0], prompt)
303
+
304
+ def test_a_failed_run_is_logged_under_the_command_too(self) -> None:
305
+ with patch.object(executor, "_run_agent", side_effect=RuntimeError("boom")):
306
+ executor._run_task("NIM-6", "Read .claude/skills/x/SKILL.md ...",
307
+ "sid-6", "story-developer",
308
+ label="/story-developer 4124")
309
+
310
+ run_record, = self._runs()
311
+ self.assertEqual(run_record["message"], "/story-developer 4124")
312
+
313
+ def test_the_live_job_row_carries_the_command(self) -> None:
314
+ with patch.object(executor.coding_agent, "run", return_value="done"), \
315
+ patch.object(runs_db, "start_job",
316
+ return_value="job-1") as started:
317
+ executor._run_agent("Read .claude/skills/x/SKILL.md ...", "sid-7",
318
+ label="/story-developer 4124")
319
+
320
+ self.assertEqual(started.call_args.args[1], "/story-developer 4124")
321
+
322
+ def test_a_run_with_no_label_is_shown_as_its_prompt(self) -> None:
323
+ # Cron, email and SQS hand over a skill body and have no command to show.
324
+ with patch.object(executor, "_run_agent", return_value="done"):
325
+ executor._run_task("NIM-7", "Do the nightly sweep", "sid-8",
326
+ "nightly")
327
+
328
+ run_record, = self._runs()
329
+ self.assertEqual(run_record["message"], "Do the nightly sweep")
330
+
261
331
  def test_counts_include_issue_runs(self) -> None:
262
332
  with patch.object(executor, "_run_agent", return_value="done"):
263
333
  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