devcoach 0.3.9__tar.gz → 0.3.10__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 (93) hide show
  1. {devcoach-0.3.9 → devcoach-0.3.10}/.gitignore +2 -0
  2. {devcoach-0.3.9 → devcoach-0.3.10}/PKG-INFO +30 -7
  3. {devcoach-0.3.9 → devcoach-0.3.10}/README.md +29 -6
  4. devcoach-0.3.10/docs/how-it-works.md +102 -0
  5. {devcoach-0.3.9 → devcoach-0.3.10}/docs/index.md +26 -21
  6. {devcoach-0.3.9 → devcoach-0.3.10}/pyproject.toml +1 -1
  7. {devcoach-0.3.9 → devcoach-0.3.10}/sonar-project.properties +1 -1
  8. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/mcp/server.py +42 -13
  9. {devcoach-0.3.9 → devcoach-0.3.10}/tests/test_mcp_server.py +151 -0
  10. {devcoach-0.3.9 → devcoach-0.3.10}/uv.lock +1 -1
  11. devcoach-0.3.9/docs/how-it-works.md +0 -112
  12. {devcoach-0.3.9 → devcoach-0.3.10}/.github/dependabot.yml +0 -0
  13. {devcoach-0.3.9 → devcoach-0.3.10}/.github/scripts/export_backup.py +0 -0
  14. {devcoach-0.3.9 → devcoach-0.3.10}/.github/scripts/fixtures/devcoach-backup.zip +0 -0
  15. {devcoach-0.3.9 → devcoach-0.3.10}/.github/scripts/take_screenshots.py +0 -0
  16. {devcoach-0.3.9 → devcoach-0.3.10}/.github/workflows/ci.yml +0 -0
  17. {devcoach-0.3.9 → devcoach-0.3.10}/.github/workflows/ruff-autofix.yml +0 -0
  18. {devcoach-0.3.9 → devcoach-0.3.10}/.github/workflows/update-screenshots.yml +0 -0
  19. {devcoach-0.3.9 → devcoach-0.3.10}/CLAUDE.md +0 -0
  20. {devcoach-0.3.9 → devcoach-0.3.10}/LICENSE +0 -0
  21. {devcoach-0.3.9 → devcoach-0.3.10}/NOTICE +0 -0
  22. {devcoach-0.3.9 → devcoach-0.3.10}/SKILL.md +0 -0
  23. {devcoach-0.3.9 → devcoach-0.3.10}/docs/PLAN.md +0 -0
  24. {devcoach-0.3.9 → devcoach-0.3.10}/docs/cli.md +0 -0
  25. {devcoach-0.3.9 → devcoach-0.3.10}/docs/configuration.md +0 -0
  26. {devcoach-0.3.9 → devcoach-0.3.10}/docs/favicon.svg +0 -0
  27. {devcoach-0.3.9 → devcoach-0.3.10}/docs/getting-started.md +0 -0
  28. {devcoach-0.3.9 → devcoach-0.3.10}/docs/mcp-server.md +0 -0
  29. {devcoach-0.3.9 → devcoach-0.3.10}/docs/screenshots/knowledge-map-dark.png +0 -0
  30. {devcoach-0.3.9 → devcoach-0.3.10}/docs/screenshots/knowledge-map-light.png +0 -0
  31. {devcoach-0.3.9 → devcoach-0.3.10}/docs/screenshots/lesson-ci-cd-pipeline-stages-dark.png +0 -0
  32. {devcoach-0.3.9 → devcoach-0.3.10}/docs/screenshots/lesson-ci-cd-pipeline-stages-light.png +0 -0
  33. {devcoach-0.3.9 → devcoach-0.3.10}/docs/screenshots/lesson-docker-layer-cache-dark.png +0 -0
  34. {devcoach-0.3.9 → devcoach-0.3.10}/docs/screenshots/lesson-docker-layer-cache-light.png +0 -0
  35. {devcoach-0.3.9 → devcoach-0.3.10}/docs/screenshots/lesson-git-interactive-rebase-dark.png +0 -0
  36. {devcoach-0.3.9 → devcoach-0.3.10}/docs/screenshots/lesson-git-interactive-rebase-light.png +0 -0
  37. {devcoach-0.3.9 → devcoach-0.3.10}/docs/screenshots/lesson-postgresql-explain-analyze-dark.png +0 -0
  38. {devcoach-0.3.9 → devcoach-0.3.10}/docs/screenshots/lesson-postgresql-explain-analyze-light.png +0 -0
  39. {devcoach-0.3.9 → devcoach-0.3.10}/docs/screenshots/lesson-redis-cache-stampede-dark.png +0 -0
  40. {devcoach-0.3.9 → devcoach-0.3.10}/docs/screenshots/lesson-redis-cache-stampede-light.png +0 -0
  41. {devcoach-0.3.9 → devcoach-0.3.10}/docs/screenshots/lessons-dark.png +0 -0
  42. {devcoach-0.3.9 → devcoach-0.3.10}/docs/screenshots/lessons-light.png +0 -0
  43. {devcoach-0.3.9 → devcoach-0.3.10}/docs/screenshots/settings-dark.png +0 -0
  44. {devcoach-0.3.9 → devcoach-0.3.10}/docs/screenshots/settings-light.png +0 -0
  45. {devcoach-0.3.9 → devcoach-0.3.10}/docs/web-ui.md +0 -0
  46. {devcoach-0.3.9 → devcoach-0.3.10}/mkdocs.yml +0 -0
  47. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/SKILL.md +0 -0
  48. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/__init__.py +0 -0
  49. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/cli/__init__.py +0 -0
  50. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/cli/commands.py +0 -0
  51. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/core/__init__.py +0 -0
  52. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/core/coach.py +0 -0
  53. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/core/db.py +0 -0
  54. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/core/detect.py +0 -0
  55. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/core/git.py +0 -0
  56. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/core/models.py +0 -0
  57. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/core/prompts.py +0 -0
  58. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/mcp/__init__.py +0 -0
  59. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/web/__init__.py +0 -0
  60. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/web/app.py +0 -0
  61. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/web/static/favicon.svg +0 -0
  62. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/web/static/relative-time.js +0 -0
  63. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/web/static/style.css +0 -0
  64. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/web/static/vendor/alpinejs.min.js +0 -0
  65. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/web/static/vendor/flatpickr-dark.min.css +0 -0
  66. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/web/static/vendor/flatpickr.min.css +0 -0
  67. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/web/static/vendor/flatpickr.min.js +0 -0
  68. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/web/static/vendor/highlight.min.js +0 -0
  69. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/web/static/vendor/hljs-dark.min.css +0 -0
  70. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/web/static/vendor/hljs-light.min.css +0 -0
  71. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/web/static/vendor/htmx.min.js +0 -0
  72. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/web/static/vendor/icons/bitbucket.svg +0 -0
  73. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/web/static/vendor/icons/github.svg +0 -0
  74. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/web/static/vendor/icons/gitlab.svg +0 -0
  75. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/web/static/vendor/icons/vscode.svg +0 -0
  76. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/web/static/vendor/marked.min.js +0 -0
  77. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/web/static/vendor/tailwind.js +0 -0
  78. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/web/templates/base.html +0 -0
  79. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/web/templates/lesson_detail.html +0 -0
  80. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/web/templates/lessons.html +0 -0
  81. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/web/templates/profile.html +0 -0
  82. {devcoach-0.3.9 → devcoach-0.3.10}/src/devcoach/web/templates/settings.html +0 -0
  83. {devcoach-0.3.9 → devcoach-0.3.10}/tests/__init__.py +0 -0
  84. {devcoach-0.3.9 → devcoach-0.3.10}/tests/conftest.py +0 -0
  85. {devcoach-0.3.9 → devcoach-0.3.10}/tests/test_cli.py +0 -0
  86. {devcoach-0.3.9 → devcoach-0.3.10}/tests/test_cli_commands.py +0 -0
  87. {devcoach-0.3.9 → devcoach-0.3.10}/tests/test_coach.py +0 -0
  88. {devcoach-0.3.9 → devcoach-0.3.10}/tests/test_db_extra.py +0 -0
  89. {devcoach-0.3.9 → devcoach-0.3.10}/tests/test_detect.py +0 -0
  90. {devcoach-0.3.9 → devcoach-0.3.10}/tests/test_git.py +0 -0
  91. {devcoach-0.3.9 → devcoach-0.3.10}/tests/test_prompts.py +0 -0
  92. {devcoach-0.3.9 → devcoach-0.3.10}/tests/test_web.py +0 -0
  93. {devcoach-0.3.9 → devcoach-0.3.10}/tests/test_web_extra.py +0 -0
@@ -197,6 +197,8 @@ cython_debug/
197
197
  # Claude Code
198
198
  .claude/
199
199
  .playwright-mcp/
200
+ .playwright-cli/
201
+ config.example.json
200
202
 
201
203
  # Cursor
202
204
  # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devcoach
3
- Version: 0.3.9
3
+ Version: 0.3.10
4
4
  Summary: A local MCP server that acts as a progressive technical coach for Claude Code and Claude Desktop
5
5
  Project-URL: Homepage, https://github.com/UltimaPhoenix/dev-coach
6
6
  Project-URL: Repository, https://github.com/UltimaPhoenix/dev-coach
@@ -245,12 +245,35 @@ Description-Content-Type: text/markdown
245
245
 
246
246
  ## How it works
247
247
 
248
- | Step | What happens |
249
- |------|-------------|
250
- | You complete a task with Claude | Claude finishes the work as normal |
251
- | devcoach checks your knowledge map | Finds a topic where you have room to grow, related to what you just did |
252
- | A lesson appears at the end of the response | Calibrated to your level (junior / mid / senior), never repeated |
253
- | You mark it know / don't know | Confidence scores update, shaping future lessons |
248
+ ```mermaid
249
+ flowchart TD
250
+ A([Task completed]) --> B[Check rate limit]
251
+ B -->|denied| Z([Silent])
252
+ B -->|allowed| D
253
+
254
+ subgraph loop["coaching loop"]
255
+ D[Select topic & depth]
256
+ E[Compose & deliver]
257
+ G[log_lesson]
258
+ end
259
+
260
+ D -->|nothing| Z
261
+ D -->|found| E
262
+ E --> G
263
+ G --> F([Done])
264
+ G -.->|prompts| U(["You: ✅ ❌ ⏭"])
265
+
266
+ style loop fill:none,stroke:#AAAAAA,stroke-dasharray:5 5,color:#757575
267
+ classDef action fill:#D4E4D8,stroke:#8BAF96,color:#1E1E1E
268
+ classDef term fill:#E8E8E4,stroke:#AAAAAA,color:#1E1E1E
269
+ classDef user fill:#F5EDE3,stroke:#D4A27F,color:#1E1E1E
270
+
271
+ class B,D,E,G action
272
+ class A,F,Z term
273
+ class U user
274
+ ```
275
+
276
+ → [Full decision flow: session startup · lesson selection · depth calibration](https://ultimaphoenix.github.io/dev-coach/how-it-works/)
254
277
 
255
278
  Everything runs **locally**. No data leaves your machine. One SQLite file at `~/.devcoach/coaching.db`.
256
279
 
@@ -14,12 +14,35 @@
14
14
 
15
15
  ## How it works
16
16
 
17
- | Step | What happens |
18
- |------|-------------|
19
- | You complete a task with Claude | Claude finishes the work as normal |
20
- | devcoach checks your knowledge map | Finds a topic where you have room to grow, related to what you just did |
21
- | A lesson appears at the end of the response | Calibrated to your level (junior / mid / senior), never repeated |
22
- | You mark it know / don't know | Confidence scores update, shaping future lessons |
17
+ ```mermaid
18
+ flowchart TD
19
+ A([Task completed]) --> B[Check rate limit]
20
+ B -->|denied| Z([Silent])
21
+ B -->|allowed| D
22
+
23
+ subgraph loop["coaching loop"]
24
+ D[Select topic & depth]
25
+ E[Compose & deliver]
26
+ G[log_lesson]
27
+ end
28
+
29
+ D -->|nothing| Z
30
+ D -->|found| E
31
+ E --> G
32
+ G --> F([Done])
33
+ G -.->|prompts| U(["You: ✅ ❌ ⏭"])
34
+
35
+ style loop fill:none,stroke:#AAAAAA,stroke-dasharray:5 5,color:#757575
36
+ classDef action fill:#D4E4D8,stroke:#8BAF96,color:#1E1E1E
37
+ classDef term fill:#E8E8E4,stroke:#AAAAAA,color:#1E1E1E
38
+ classDef user fill:#F5EDE3,stroke:#D4A27F,color:#1E1E1E
39
+
40
+ class B,D,E,G action
41
+ class A,F,Z term
42
+ class U user
43
+ ```
44
+
45
+ → [Full decision flow: session startup · lesson selection · depth calibration](https://ultimaphoenix.github.io/dev-coach/how-it-works/)
23
46
 
24
47
  Everything runs **locally**. No data leaves your machine. One SQLite file at `~/.devcoach/coaching.db`.
25
48
 
@@ -0,0 +1,102 @@
1
+ # How it works
2
+
3
+ devcoach is a silent technical coach that hooks into every Claude response.
4
+ The diagrams below show the three main flows: session startup, the coaching loop,
5
+ and how a lesson topic is selected.
6
+
7
+ ---
8
+
9
+ ## Session startup
10
+
11
+ At the start of each Claude session devcoach checks whether the user is set up,
12
+ loads prior coaching context, and primes lesson selection before any task is done.
13
+
14
+ ```mermaid
15
+ flowchart LR
16
+ A([Start]) --> B{First run?}
17
+ B -- yes --> C[Detect stack]
18
+ C --> D[Confirm topics\n& groups]
19
+ D --> E[Save profile]
20
+ B -- no --> F[Load profile\n& notebook]
21
+ E & F --> G([Ready])
22
+
23
+ subgraph onboarding["onboarding"]
24
+ C
25
+ D
26
+ E
27
+ end
28
+
29
+ style onboarding fill:none,stroke:#AAAAAA,stroke-dasharray:5 5,color:#757575
30
+ classDef action fill:#D4E4D8,stroke:#8BAF96,color:#1E1E1E
31
+ classDef term fill:#E8E8E4,stroke:#AAAAAA,color:#1E1E1E
32
+
33
+ class C,D,E,F action
34
+ class A,G term
35
+ ```
36
+
37
+ ---
38
+
39
+ ## Coaching loop
40
+
41
+ After every technical task Claude evaluates whether to deliver a lesson.
42
+ The loop is silent when nothing is worth teaching or when the rate limit is reached.
43
+
44
+ ```mermaid
45
+ flowchart TD
46
+ A([Task completed]) --> B[Check rate limit]
47
+ B -->|denied| Z([Silent])
48
+ B -->|allowed| D
49
+
50
+ subgraph loop["coaching loop"]
51
+ D[Select topic & depth]
52
+ E[Compose & deliver]
53
+ G[log_lesson]
54
+ end
55
+
56
+ D -->|nothing| Z
57
+ D -->|found| E
58
+ E --> G
59
+ G --> F([Done])
60
+ G -.->|prompts| U(["You: ✅ ❌ ⏭"])
61
+
62
+ style loop fill:none,stroke:#AAAAAA,stroke-dasharray:5 5,color:#757575
63
+ classDef action fill:#D4E4D8,stroke:#8BAF96,color:#1E1E1E
64
+ classDef term fill:#E8E8E4,stroke:#AAAAAA,color:#1E1E1E
65
+ classDef user fill:#F5EDE3,stroke:#D4A27F,color:#1E1E1E
66
+
67
+ class B,D,E,G action
68
+ class A,F,Z term
69
+ class U user
70
+ ```
71
+
72
+ ---
73
+
74
+ ## Lesson selection
75
+
76
+ When a teachable concept is found, devcoach walks this priority list from top to bottom
77
+ and picks the first match. Depth is then calibrated to the per-topic confidence score.
78
+
79
+ | Priority | Trigger | Condition |
80
+ |:---:|---|---|
81
+ | ① | Notebook follow-up | The coaching notebook flagged an angle relevant to the current task |
82
+ | ② | Profile pitfall | A pitfall committed or avoided on a profile topic |
83
+ | ③ | Profile pattern | An interesting pattern on a profile topic worth formalising |
84
+ | ④ | Off-profile pitfall | A pitfall on a topic prominent in the task but absent from the profile |
85
+ | ⑤ | Knowledge gap | A profile topic with confidence < 5 |
86
+ | ⑥ | Deep-dive | A profile topic at confidence 4–6, not yet mastered |
87
+
88
+ First match wins. No match → silent.
89
+
90
+ ---
91
+
92
+ ## Depth calibration
93
+
94
+ The lesson level is determined by the confidence score for the **specific topic being taught**,
95
+ adjusted by observations in the coaching notebook.
96
+
97
+ | Confidence | Level | Lesson angle |
98
+ |---|---|---|
99
+ | 0 – 3 | Junior | Introduce correct practice, explain from scratch, use analogies |
100
+ | 4 – 6 | Mid | Explain the why, mention trade-offs and alternatives |
101
+ | 7 – 9 | Senior | Edge cases, historical context, architectural implications |
102
+ | 10 | Cutting-edge | Latest developments — ignores level floor and taught-topics filter |
@@ -9,29 +9,34 @@ Everything runs **locally**. No data leaves your machine. One SQLite file at `~/
9
9
  ## How it works
10
10
 
11
11
  ```mermaid
12
- sequenceDiagram
13
- actor User
14
- participant Claude as Claude (AI)
15
- participant devcoach as devcoach (MCP)
16
-
17
- User->>+Claude: Complete a technical task
18
- Claude-->>User: Work completed normally
19
-
20
- Claude->>devcoach: check rate-limit + profile + taught topics
21
- devcoach-->>Claude: knowledge map · lesson history · coaching notebook
22
-
23
- Claude->>Claude: select topic · calibrate depth · compose lesson
24
-
25
- Claude->>devcoach: log_lesson(id, topic, level, body, …)
26
- Claude-->>-User: Response + 🎓 lesson at the bottom
27
-
28
- User->>+Claude: ✅ know · ❌ don't know · ⏭ skip
29
- Claude->>devcoach: submit_feedback confidence ±1
30
- Claude->>Claude: update coaching notebook if warranted
31
- Claude-->>-User: acknowledged
12
+ flowchart TD
13
+ A([Task completed]) --> B[Check rate limit]
14
+ B -->|denied| Z([Silent])
15
+ B -->|allowed| D
16
+
17
+ subgraph loop["coaching loop"]
18
+ D[Select topic & depth]
19
+ E[Compose & deliver]
20
+ G[log_lesson]
21
+ end
22
+
23
+ D -->|nothing| Z
24
+ D -->|found| E
25
+ E --> G
26
+ G --> F([Done])
27
+ G -.->|prompts| U(["You: ✅ ❌ ⏭"])
28
+
29
+ style loop fill:none,stroke:#AAAAAA,stroke-dasharray:5 5,color:#757575
30
+ classDef action fill:#D4E4D8,stroke:#8BAF96,color:#1E1E1E
31
+ classDef term fill:#E8E8E4,stroke:#AAAAAA,color:#1E1E1E
32
+ classDef user fill:#F5EDE3,stroke:#D4A27F,color:#1E1E1E
33
+
34
+ class B,D,E,G action
35
+ class A,F,Z term
36
+ class U user
32
37
  ```
33
38
 
34
- See [How it works](how-it-works.md) for the full decision flow.
39
+ [Full decision flow: session startup · lesson selection · depth calibration](how-it-works.md)
35
40
 
36
41
  ---
37
42
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devcoach"
3
- version = "0.3.9"
3
+ version = "0.3.10"
4
4
  description = "A local MCP server that acts as a progressive technical coach for Claude Code and Claude Desktop"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -2,7 +2,7 @@ sonar.projectKey=UltimaPhoenix_dev-coach
2
2
  sonar.organization=ultimaphoenix
3
3
 
4
4
  sonar.projectName=devcoach
5
- sonar.projectVersion=0.3.9
5
+ sonar.projectVersion=0.3.10
6
6
 
7
7
  sonar.sources=src
8
8
  sonar.tests=tests
@@ -9,6 +9,7 @@ from pathlib import Path
9
9
  from typing import Literal
10
10
 
11
11
  from fastmcp import Context, FastMCP
12
+ from mcp.types import ToolAnnotations
12
13
 
13
14
  from devcoach.core import coach, db
14
15
  from devcoach.core.detect import detect_stack
@@ -29,7 +30,9 @@ mcp = FastMCP(
29
30
  # ── MCP Tools ─────────────────────────────────────────────────────────────
30
31
 
31
32
 
32
- @mcp.tool
33
+ @mcp.tool(
34
+ annotations=ToolAnnotations(destructiveHint=False, idempotentHint=True, openWorldHint=False)
35
+ )
33
36
  async def log_lesson(
34
37
  ctx: Context,
35
38
  id: str,
@@ -107,7 +110,9 @@ async def log_lesson(
107
110
  return lesson
108
111
 
109
112
 
110
- @mcp.tool
113
+ @mcp.tool(
114
+ annotations=ToolAnnotations(destructiveHint=False, idempotentHint=True, openWorldHint=False)
115
+ )
111
116
  async def update_knowledge(ctx: Context, topic: str, delta: int) -> int:
112
117
  """Adjust the confidence score for a topic by delta (e.g. +1 or -1).
113
118
 
@@ -122,7 +127,11 @@ async def update_knowledge(ctx: Context, topic: str, delta: int) -> int:
122
127
  raise
123
128
 
124
129
 
125
- @mcp.tool
130
+ @mcp.tool(
131
+ annotations=ToolAnnotations(
132
+ readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False
133
+ )
134
+ )
126
135
  def get_lessons(
127
136
  period: Literal["today", "week", "month", "year", "all"] | None = None,
128
137
  category: str | None = None,
@@ -173,7 +182,9 @@ def get_lessons(
173
182
  return []
174
183
 
175
184
 
176
- @mcp.tool
185
+ @mcp.tool(
186
+ annotations=ToolAnnotations(destructiveHint=False, idempotentHint=True, openWorldHint=False)
187
+ )
177
188
  async def star_lesson(ctx: Context, lesson_id: str, starred: bool) -> bool:
178
189
  """Set the starred (favourite) flag on a lesson to the given value.
179
190
 
@@ -192,7 +203,9 @@ async def star_lesson(ctx: Context, lesson_id: str, starred: bool) -> bool:
192
203
  return False
193
204
 
194
205
 
195
- @mcp.tool
206
+ @mcp.tool(
207
+ annotations=ToolAnnotations(destructiveHint=True, idempotentHint=True, openWorldHint=False)
208
+ )
196
209
  async def delete_lesson(ctx: Context, lesson_id: str) -> bool:
197
210
  """Permanently delete a lesson by ID.
198
211
 
@@ -209,7 +222,9 @@ async def delete_lesson(ctx: Context, lesson_id: str) -> bool:
209
222
  return False
210
223
 
211
224
 
212
- @mcp.tool
225
+ @mcp.tool(
226
+ annotations=ToolAnnotations(destructiveHint=False, idempotentHint=True, openWorldHint=False)
227
+ )
213
228
  async def submit_feedback(
214
229
  ctx: Context, lesson_id: str, feedback: Literal["know", "dont_know", "clear"]
215
230
  ) -> bool:
@@ -244,7 +259,9 @@ async def submit_feedback(
244
259
  return False
245
260
 
246
261
 
247
- @mcp.tool
262
+ @mcp.tool(
263
+ annotations=ToolAnnotations(destructiveHint=False, idempotentHint=True, openWorldHint=False)
264
+ )
248
265
  async def add_topic(
249
266
  ctx: Context, topic: str, confidence: int = 5, group: str | None = None
250
267
  ) -> bool:
@@ -267,7 +284,9 @@ async def add_topic(
267
284
  return False
268
285
 
269
286
 
270
- @mcp.tool
287
+ @mcp.tool(
288
+ annotations=ToolAnnotations(destructiveHint=True, idempotentHint=True, openWorldHint=False)
289
+ )
271
290
  async def remove_topic(ctx: Context, topic: str) -> bool:
272
291
  """Remove a topic from the knowledge map entirely.
273
292
 
@@ -284,7 +303,9 @@ async def remove_topic(ctx: Context, topic: str) -> bool:
284
303
  return False
285
304
 
286
305
 
287
- @mcp.tool
306
+ @mcp.tool(
307
+ annotations=ToolAnnotations(destructiveHint=False, idempotentHint=True, openWorldHint=False)
308
+ )
288
309
  async def add_group(ctx: Context, name: str) -> bool:
289
310
  """Create a new (initially empty) knowledge group.
290
311
 
@@ -305,7 +326,9 @@ async def add_group(ctx: Context, name: str) -> bool:
305
326
  return False
306
327
 
307
328
 
308
- @mcp.tool
329
+ @mcp.tool(
330
+ annotations=ToolAnnotations(destructiveHint=True, idempotentHint=True, openWorldHint=False)
331
+ )
309
332
  async def remove_group(ctx: Context, name: str) -> bool:
310
333
  """Delete a knowledge group. Topics in the group move to Other.
311
334
 
@@ -322,7 +345,9 @@ async def remove_group(ctx: Context, name: str) -> bool:
322
345
  return False
323
346
 
324
347
 
325
- @mcp.tool
348
+ @mcp.tool(
349
+ annotations=ToolAnnotations(destructiveHint=False, idempotentHint=True, openWorldHint=False)
350
+ )
326
351
  def update_settings(key: Literal["max_per_day", "min_gap_minutes"], value: str) -> Settings:
327
352
  """Update a coaching setting.
328
353
 
@@ -345,7 +370,9 @@ def update_settings(key: Literal["max_per_day", "min_gap_minutes"], value: str)
345
370
  return db.get_settings(conn)
346
371
 
347
372
 
348
- @mcp.tool
373
+ @mcp.tool(
374
+ annotations=ToolAnnotations(destructiveHint=False, idempotentHint=False, openWorldHint=True)
375
+ )
349
376
  def open_ui(port: int = 7860) -> str:
350
377
  """Launch the devcoach web dashboard in the background.
351
378
 
@@ -363,7 +390,9 @@ def open_ui(port: int = 7860) -> str:
363
390
  return f"devcoach UI starting at http://localhost:{port}"
364
391
 
365
392
 
366
- @mcp.tool
393
+ @mcp.tool(
394
+ annotations=ToolAnnotations(destructiveHint=True, idempotentHint=False, openWorldHint=False)
395
+ )
367
396
  async def complete_onboarding(
368
397
  ctx: Context,
369
398
  topics: dict[str, int],
@@ -479,6 +479,12 @@ class TestCompleteOnboarding:
479
479
  _run(server.complete_onboarding(mock_ctx, topics={"python": 7}))
480
480
  mock_ctx.info.assert_called_once()
481
481
 
482
+ def test_exception_returns_empty_profile(self, mock_ctx):
483
+ with patch.object(db, "connection", side_effect=sqlite3.OperationalError("err")):
484
+ result = _run(server.complete_onboarding(mock_ctx, topics={"python": 7}))
485
+ assert result.knowledge == []
486
+ assert result.groups == []
487
+
482
488
 
483
489
  # ── Resources ──────────────────────────────────────────────────────────────
484
490
 
@@ -557,6 +563,151 @@ class TestPrompt:
557
563
  assert any(word in result.lower() for word in ("lesson", "coach", "devcoach", "knowledge"))
558
564
 
559
565
 
566
+ # ── Tools — delete_lesson ─────────────────────────────────────────────────
567
+
568
+
569
+ class TestDeleteLesson:
570
+ def test_found_returns_true(self, mock_ctx):
571
+ result = _run(server.delete_lesson(mock_ctx, "lesson-sqlite3-row-factory-001"))
572
+ assert result is True
573
+
574
+ def test_not_found_returns_false_and_logs_warning(self, mock_ctx):
575
+ result = _run(server.delete_lesson(mock_ctx, "nonexistent-id"))
576
+ assert result is False
577
+ mock_ctx.warning.assert_called_once()
578
+
579
+ def test_exception_returns_false_and_logs_error(self, mock_ctx):
580
+ with patch.object(db, "connection", side_effect=sqlite3.OperationalError("err")):
581
+ result = _run(server.delete_lesson(mock_ctx, "any-id"))
582
+ assert result is False
583
+ mock_ctx.error.assert_called_once()
584
+
585
+
586
+ # ── Exception paths ────────────────────────────────────────────────────────
587
+
588
+
589
+ class TestExceptionPaths:
590
+ """Cover all exception-handler branches that return safe fallbacks."""
591
+
592
+ def test_log_lesson_usage_defaults_exception_falls_back(self, mock_ctx):
593
+ with patch(
594
+ "devcoach.core.db.get_usage_defaults", side_effect=sqlite3.OperationalError("err")
595
+ ):
596
+ result = _run(
597
+ server.log_lesson(
598
+ mock_ctx,
599
+ id="exc-log-001",
600
+ timestamp=datetime.now(UTC).isoformat(),
601
+ topic_id="exc_topic",
602
+ categories=["test"],
603
+ title="Exc test",
604
+ level="mid",
605
+ summary="exc summary",
606
+ )
607
+ )
608
+ assert result.id == "exc-log-001"
609
+
610
+ def test_update_knowledge_exception_raises_and_logs_error(self, mock_ctx):
611
+ with patch.object(db, "connection", side_effect=sqlite3.OperationalError("err")):
612
+ with pytest.raises(sqlite3.OperationalError):
613
+ _run(server.update_knowledge(mock_ctx, "python", 1))
614
+ mock_ctx.error.assert_called_once()
615
+
616
+ def test_get_lessons_exception_returns_empty_list(self):
617
+ with patch.object(db, "connection", side_effect=sqlite3.OperationalError("err")):
618
+ result = server.get_lessons()
619
+ assert result == []
620
+
621
+ def test_star_lesson_exception_returns_false(self, mock_ctx):
622
+ with patch.object(db, "connection", side_effect=sqlite3.OperationalError("err")):
623
+ result = _run(server.star_lesson(mock_ctx, "any-id", starred=True))
624
+ assert result is False
625
+ mock_ctx.error.assert_called_once()
626
+
627
+ def test_submit_feedback_exception_returns_false(self, mock_ctx):
628
+ with patch.object(db, "connection", side_effect=sqlite3.OperationalError("err")):
629
+ result = _run(server.submit_feedback(mock_ctx, "any-id", "know"))
630
+ assert result is False
631
+ mock_ctx.error.assert_called_once()
632
+
633
+ def test_add_topic_exception_returns_false(self, mock_ctx):
634
+ with patch.object(db, "connection", side_effect=sqlite3.OperationalError("err")):
635
+ result = _run(server.add_topic(mock_ctx, "new_topic", 5))
636
+ assert result is False
637
+ mock_ctx.error.assert_called_once()
638
+
639
+ def test_remove_topic_exception_returns_false(self, mock_ctx):
640
+ with patch.object(db, "connection", side_effect=sqlite3.OperationalError("err")):
641
+ result = _run(server.remove_topic(mock_ctx, "python"))
642
+ assert result is False
643
+ mock_ctx.error.assert_called_once()
644
+
645
+ def test_add_group_exception_returns_false(self, mock_ctx):
646
+ with patch.object(db, "connection", side_effect=sqlite3.OperationalError("err")):
647
+ result = _run(server.add_group(mock_ctx, "SomeGroup"))
648
+ assert result is False
649
+ mock_ctx.error.assert_called_once()
650
+
651
+ def test_remove_group_exception_returns_false(self, mock_ctx):
652
+ with patch.object(db, "connection", side_effect=sqlite3.OperationalError("err")):
653
+ result = _run(server.remove_group(mock_ctx, "SomeGroup"))
654
+ assert result is False
655
+ mock_ctx.error.assert_called_once()
656
+
657
+ def test_profile_resource_exception_returns_error_dict(self):
658
+ with patch.object(db, "connection", side_effect=sqlite3.OperationalError("err")):
659
+ result = server.profile_resource()
660
+ assert "error" in result
661
+
662
+ def test_settings_resource_exception_returns_error_dict(self):
663
+ with patch.object(db, "connection", side_effect=sqlite3.OperationalError("err")):
664
+ result = server.settings_resource()
665
+ assert "error" in result
666
+
667
+ def test_recent_lessons_resource_exception_returns_error_list(self):
668
+ with patch.object(db, "connection", side_effect=sqlite3.OperationalError("err")):
669
+ result = server.recent_lessons_resource()
670
+ assert isinstance(result, list)
671
+ assert "error" in result[0]
672
+
673
+ def test_stats_resource_exception_returns_error_dict(self):
674
+ with patch.object(db, "connection", side_effect=sqlite3.OperationalError("err")):
675
+ result = server.stats_resource()
676
+ assert "error" in result
677
+
678
+ def test_taught_topics_resource_exception_returns_empty_list(self):
679
+ with patch.object(db, "connection", side_effect=sqlite3.OperationalError("err")):
680
+ result = server.taught_topics_resource()
681
+ assert result == []
682
+
683
+ def test_rate_limit_resource_exception_returns_not_allowed(self):
684
+ with patch.object(db, "connection", side_effect=sqlite3.OperationalError("err")):
685
+ result = server.rate_limit_resource()
686
+ assert result["allowed"] is False
687
+
688
+ def test_context_resource_exception_returns_error_dict(self):
689
+ with patch.object(db, "connection", side_effect=sqlite3.OperationalError("err")):
690
+ result = server.context_resource()
691
+ assert "error" in result
692
+
693
+ def test_onboarding_resource_exception_returns_error_dict(self):
694
+ with patch.object(db, "connection", side_effect=sqlite3.OperationalError("err")):
695
+ result = server.onboarding_resource()
696
+ assert "error" in result
697
+
698
+ def test_lesson_resource_exception_returns_error_dict(self):
699
+ with patch.object(db, "connection", side_effect=sqlite3.OperationalError("err")):
700
+ result = server.lesson_resource("any-id")
701
+ assert "error" in result
702
+
703
+ def test_devcoach_instructions_exception_returns_fallback(self):
704
+ with patch(
705
+ "devcoach.mcp.server.importlib.resources.files", side_effect=Exception("not found")
706
+ ):
707
+ result = server.devcoach_instructions()
708
+ assert "unavailable" in result
709
+
710
+
560
711
  # ── Entry point ────────────────────────────────────────────────────────────
561
712
 
562
713
 
@@ -443,7 +443,7 @@ wheels = [
443
443
 
444
444
  [[package]]
445
445
  name = "devcoach"
446
- version = "0.3.8"
446
+ version = "0.3.9"
447
447
  source = { editable = "." }
448
448
  dependencies = [
449
449
  { name = "fastapi" },
@@ -1,112 +0,0 @@
1
- # How it works
2
-
3
- devcoach is a silent technical coach that hooks into every Claude response.
4
- The diagrams below show the three main flows: session startup, the coaching loop,
5
- and how a lesson topic is selected.
6
-
7
- ---
8
-
9
- ## Session startup
10
-
11
- At the start of each Claude session devcoach checks whether the user is set up,
12
- loads prior coaching context, and primes lesson selection before any task is done.
13
-
14
- ```mermaid
15
- flowchart TD
16
- A([Session starts]) --> B[Read devcoach://onboarding]
17
- B --> C{needs_onboarding?}
18
- C -- Yes --> D{Existing backup\nto restore?}
19
- D -- Yes --> E[Restore backup\n→ mark onboarding done]
20
- D -- No --> F[Detect stack\nautomatically or manually]
21
- F --> G[Confirm topics + confidence\nPropose groups]
22
- G --> H[complete_onboarding]
23
- E & H --> I
24
- C -- No --> I[Read ~/.devcoach/learning-state.md]
25
- I --> J{Notebook\nnon-empty?}
26
- J -- Yes --> K[Load patterns, hypotheses\nand recommended angles]
27
- J -- No --> L[No prior context\nstart fresh]
28
- K & L --> M([Ready to coach])
29
- ```
30
-
31
- ---
32
-
33
- ## Coaching loop
34
-
35
- After every technical task Claude evaluates whether to deliver a lesson.
36
- The loop is silent when nothing is worth teaching or when the rate limit is reached.
37
-
38
- ```mermaid
39
- flowchart TD
40
- A([Technical task\ncompleted]) --> B[Read devcoach://rate-limit]
41
- B --> C{Allowed?}
42
- C -- No\nnormal task --> Z([Silent —\nno lesson])
43
- C -- No\nexplicit request --> D
44
- C -- Yes --> D[Read profile\ntaught topics\ncoaching notebook]
45
- D --> E[Analyse task for\nteachable concepts]
46
- E --> F[Select topic\nsee Lesson selection]
47
- F --> G{Topic\nfound?}
48
- G -- No --> Z
49
- G -- Yes --> H[Compose lesson\ncalibrate depth per-topic]
50
- H --> I[log_lesson to MCP]
51
- I --> J([Lesson appears\nat end of response])
52
- J --> K{User\nfeedback}
53
- K -- ✅ know --> L{Confidence\nbelow level band?}
54
- L -- Yes --> M[submit_feedback\nconfidence +1]
55
- L -- No --> N[Skip — already calibrated]
56
- K -- ❌ don't know --> O[submit_feedback\nconfidence −1]
57
- K -- ⏭ skip --> P[No change]
58
- M & N & O & P --> Q{New observation\nworth saving?}
59
- Q -- Yes --> R[Write ~/.devcoach/\nlearning-state.md]
60
- Q -- No --> S
61
- R --> S([Loop ends])
62
- ```
63
-
64
- ---
65
-
66
- ## Lesson selection
67
-
68
- When a teachable concept is found, devcoach picks the highest-priority angle
69
- and calibrates the lesson level to the **per-topic** confidence score — not an average.
70
-
71
- ```mermaid
72
- flowchart TD
73
- A([Concepts identified\nin current task]) --> B{Notebook flags\na follow-up angle\nrelevant to this task?}
74
- B -- Yes --> P1["① Deliver notebook\nfollow-up"]
75
-
76
- B -- No --> C{Pitfall on a\nprofile topic?}
77
- C -- Yes --> P2["② Profile pitfall"]
78
-
79
- C -- No --> D{Interesting pattern\non a profile topic?}
80
- D -- Yes --> P3["③ Profile pattern"]
81
-
82
- D -- No --> E{Off-profile concept\nprominent in task?}
83
- E -- Yes --> P4["④ Off-profile pitfall"]
84
-
85
- E -- No --> F{Profile topic with\nconfidence < 5?}
86
- F -- Yes --> P5["⑤ Knowledge gap"]
87
-
88
- F -- No --> G{Profile topic at\nconfidence 4–6?}
89
- G -- Yes --> P6["⑥ Deep-dive"]
90
-
91
- G -- No --> Z([Nothing to teach\n— stay silent])
92
-
93
- P1 & P2 & P3 & P4 & P5 & P6 --> L[Check taught-topics\nno repeats]
94
- L --> M{Already taught\nor confidence ≥ 10?}
95
- M -- Already taught\nnot confidence 10 --> Z
96
- M -- OK or\nconfidence = 10 --> N[Calibrate level\nper-topic confidence]
97
- N --> O([Compose and\ndeliver lesson])
98
- ```
99
-
100
- ---
101
-
102
- ## Depth calibration
103
-
104
- The lesson level is determined by the confidence score for the **specific topic being taught**,
105
- adjusted by observations in the coaching notebook.
106
-
107
- | Confidence | Level | Lesson angle |
108
- |---|---|---|
109
- | 0 – 3 | Junior | Introduce correct practice, explain from scratch, use analogies |
110
- | 4 – 6 | Mid | Explain the why, mention trade-offs and alternatives |
111
- | 7 – 9 | Senior | Edge cases, historical context, architectural implications |
112
- | 10 | Cutting-edge | Latest developments — ignores level floor and taught-topics filter |
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes