jean-agent 0.1.0__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 (75) hide show
  1. jean_agent-0.1.0/.github/workflows/release.yml +69 -0
  2. jean_agent-0.1.0/.gitignore +168 -0
  3. jean_agent-0.1.0/AGENTS.md +333 -0
  4. jean_agent-0.1.0/LICENSE +21 -0
  5. jean_agent-0.1.0/PKG-INFO +279 -0
  6. jean_agent-0.1.0/README.md +212 -0
  7. jean_agent-0.1.0/jean/__init__.py +3 -0
  8. jean_agent-0.1.0/jean/__main__.py +4 -0
  9. jean_agent-0.1.0/jean/app.py +228 -0
  10. jean_agent-0.1.0/jean/auth/__init__.py +0 -0
  11. jean_agent-0.1.0/jean/auth/deps.py +45 -0
  12. jean_agent-0.1.0/jean/auth/siws.py +119 -0
  13. jean_agent-0.1.0/jean/builtin_skills/__init__.py +1 -0
  14. jean_agent-0.1.0/jean/builtin_skills/skill-builder/SKILL.md +97 -0
  15. jean_agent-0.1.0/jean/cli.py +853 -0
  16. jean_agent-0.1.0/jean/config.py +266 -0
  17. jean_agent-0.1.0/jean/credentials.py +176 -0
  18. jean_agent-0.1.0/jean/db.py +111 -0
  19. jean_agent-0.1.0/jean/diagnostics.py +330 -0
  20. jean_agent-0.1.0/jean/harness/__init__.py +0 -0
  21. jean_agent-0.1.0/jean/harness/io_tools.py +104 -0
  22. jean_agent-0.1.0/jean/harness/options.py +204 -0
  23. jean_agent-0.1.0/jean/harness/sessions.py +480 -0
  24. jean_agent-0.1.0/jean/harness/skill_tools.py +270 -0
  25. jean_agent-0.1.0/jean/harness/skills.py +168 -0
  26. jean_agent-0.1.0/jean/harness/user_skills.py +225 -0
  27. jean_agent-0.1.0/jean/migrations/001_initial.sql +61 -0
  28. jean_agent-0.1.0/jean/migrations/002_channel_approvals.sql +11 -0
  29. jean_agent-0.1.0/jean/migrations/003_pending_channel_messages.sql +8 -0
  30. jean_agent-0.1.0/jean/migrations/004_user_skills.sql +8 -0
  31. jean_agent-0.1.0/jean/migrations/005_owner_cc.sql +1 -0
  32. jean_agent-0.1.0/jean/migrations/__init__.py +1 -0
  33. jean_agent-0.1.0/jean/models.py +616 -0
  34. jean_agent-0.1.0/jean/setup_guide.py +183 -0
  35. jean_agent-0.1.0/jean/slack/__init__.py +0 -0
  36. jean_agent-0.1.0/jean/slack/approvals.py +269 -0
  37. jean_agent-0.1.0/jean/slack/attachments.py +144 -0
  38. jean_agent-0.1.0/jean/slack/bolt.py +52 -0
  39. jean_agent-0.1.0/jean/slack/formatting.py +107 -0
  40. jean_agent-0.1.0/jean/slack/handlers.py +794 -0
  41. jean_agent-0.1.0/jean/slack/identity.py +251 -0
  42. jean_agent-0.1.0/jean/slack/thread_context.py +91 -0
  43. jean_agent-0.1.0/jean/templates/Dockerfile.tmpl +55 -0
  44. jean_agent-0.1.0/jean/templates/__init__.py +1 -0
  45. jean_agent-0.1.0/jean/templates/dockerignore.tmpl +15 -0
  46. jean_agent-0.1.0/jean/templates/fly.toml.tmpl +27 -0
  47. jean_agent-0.1.0/jean/templates/jean.toml.tmpl +86 -0
  48. jean_agent-0.1.0/jean/templates/jean_system_prompt.md.tmpl +10 -0
  49. jean_agent-0.1.0/jean/web/__init__.py +0 -0
  50. jean_agent-0.1.0/jean/web/display.py +67 -0
  51. jean_agent-0.1.0/jean/web/routes.py +470 -0
  52. jean_agent-0.1.0/jean/web/static/jean.css +5 -0
  53. jean_agent-0.1.0/jean/web/templates/admin_channels.html.j2 +57 -0
  54. jean_agent-0.1.0/jean/web/templates/admin_skill_detail.html.j2 +38 -0
  55. jean_agent-0.1.0/jean/web/templates/admin_skills.html.j2 +59 -0
  56. jean_agent-0.1.0/jean/web/templates/admin_users.html.j2 +45 -0
  57. jean_agent-0.1.0/jean/web/templates/base.html.j2 +40 -0
  58. jean_agent-0.1.0/jean/web/templates/conversation_detail.html.j2 +41 -0
  59. jean_agent-0.1.0/jean/web/templates/conversations_list.html.j2 +30 -0
  60. jean_agent-0.1.0/jean/web/templates/diagnostics.html.j2 +32 -0
  61. jean_agent-0.1.0/jean/web/templates/index.html.j2 +17 -0
  62. jean_agent-0.1.0/jean/web/templates/misconfig.html.j2 +27 -0
  63. jean_agent-0.1.0/jean/wizard.py +423 -0
  64. jean_agent-0.1.0/pyproject.toml +87 -0
  65. jean_agent-0.1.0/skills/haiku/SKILL.md +23 -0
  66. jean_agent-0.1.0/tests/__init__.py +0 -0
  67. jean_agent-0.1.0/tests/conftest.py +85 -0
  68. jean_agent-0.1.0/tests/test_attachments.py +48 -0
  69. jean_agent-0.1.0/tests/test_config.py +61 -0
  70. jean_agent-0.1.0/tests/test_db.py +94 -0
  71. jean_agent-0.1.0/tests/test_diagnostics.py +37 -0
  72. jean_agent-0.1.0/tests/test_formatting.py +85 -0
  73. jean_agent-0.1.0/tests/test_imports.py +32 -0
  74. jean_agent-0.1.0/tests/test_skills.py +119 -0
  75. jean_agent-0.1.0/tests/test_user_skills.py +326 -0
@@ -0,0 +1,69 @@
1
+ name: Release to PyPI
2
+
3
+ # Triggers on either a `v*` tag push (typical release) or a manual
4
+ # workflow_dispatch (re-publish without a new tag, e.g. recovering from
5
+ # a build flake). The workflow validates that pyproject.toml's version
6
+ # matches the tag name before publishing.
7
+ on:
8
+ push:
9
+ tags:
10
+ - "v*"
11
+ workflow_dispatch:
12
+
13
+ permissions:
14
+ contents: read
15
+
16
+ jobs:
17
+ build:
18
+ name: Build wheel + sdist
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - name: Set up Python
23
+ uses: actions/setup-python@v5
24
+ with:
25
+ python-version: "3.12"
26
+ - name: Install build tooling
27
+ run: pip install --upgrade build hatchling
28
+ - name: Verify version matches tag
29
+ if: startsWith(github.ref, 'refs/tags/v')
30
+ run: |
31
+ TAG_VERSION="${GITHUB_REF_NAME#v}"
32
+ PROJ_VERSION=$(python -c "import tomllib, pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])")
33
+ if [ "$TAG_VERSION" != "$PROJ_VERSION" ]; then
34
+ echo "::error::Tag is v$TAG_VERSION but pyproject.toml says version=$PROJ_VERSION"
35
+ exit 1
36
+ fi
37
+ echo "Version $PROJ_VERSION matches tag $GITHUB_REF_NAME ✓"
38
+ - name: Build distributions
39
+ run: python -m build
40
+ - name: Upload artifacts
41
+ uses: actions/upload-artifact@v4
42
+ with:
43
+ name: dist
44
+ path: dist/
45
+
46
+ publish:
47
+ name: Publish to PyPI
48
+ runs-on: ubuntu-latest
49
+ needs: build
50
+ environment:
51
+ # The `pypi` environment must exist in the GitHub repo's Settings →
52
+ # Environments. PyPI's Trusted Publisher config is bound to it.
53
+ name: pypi
54
+ url: https://pypi.org/project/jean-agent/
55
+ permissions:
56
+ # Required for PyPI Trusted Publishing (OIDC token issuance).
57
+ id-token: write
58
+ steps:
59
+ - name: Download artifacts
60
+ uses: actions/download-artifact@v4
61
+ with:
62
+ name: dist
63
+ path: dist/
64
+ - name: Publish to PyPI via Trusted Publishing
65
+ uses: pypa/gh-action-pypi-publish@release/v1
66
+ # No api-token configured — Trusted Publishing uses the OIDC token
67
+ # signed by GitHub Actions and verified by PyPI against the
68
+ # pending-publisher config (project + owner + repo + workflow +
69
+ # environment).
@@ -0,0 +1,168 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ *.manifest
31
+ *.spec
32
+
33
+ # Installer logs
34
+ pip-log.txt
35
+ pip-delete-this-directory.txt
36
+
37
+ # Unit test / coverage reports
38
+ htmlcov/
39
+ .tox/
40
+ .nox/
41
+ .coverage
42
+ .coverage.*
43
+ .cache
44
+ nosetests.xml
45
+ coverage.xml
46
+ *.cover
47
+ *.py,cover
48
+ .hypothesis/
49
+ .pytest_cache/
50
+ cover/
51
+
52
+ # Translations
53
+ *.mo
54
+ *.pot
55
+
56
+ # Django stuff:
57
+ *.log
58
+ local_settings.py
59
+ db.sqlite3
60
+ db.sqlite3-journal
61
+
62
+ # Flask stuff:
63
+ instance/
64
+ .webassets-cache
65
+
66
+ # Scrapy stuff:
67
+ .scrapy
68
+
69
+ # Sphinx documentation
70
+ docs/_build/
71
+
72
+ # PyBuilder
73
+ .pybuilder/
74
+ target/
75
+
76
+ # Jupyter Notebook
77
+ .ipynb_checkpoints
78
+
79
+ # IPython
80
+ profile_default/
81
+ ipython_config.py
82
+
83
+ # pyenv
84
+ .python-version
85
+
86
+ # pipenv
87
+ Pipfile.lock
88
+
89
+ # poetry
90
+ poetry.lock
91
+
92
+ # pdm
93
+ .pdm.toml
94
+ .pdm-python
95
+ .pdm-build/
96
+
97
+ # PEP 582
98
+ __pypackages__/
99
+
100
+ # Celery
101
+ celerybeat-schedule
102
+ celerybeat.pid
103
+
104
+ # SageMath parsed files
105
+ *.sage.py
106
+
107
+ # Environments
108
+ .env
109
+ .env.*
110
+ !.env.example
111
+ .venv
112
+ env/
113
+ venv/
114
+ ENV/
115
+ env.bak/
116
+ venv.bak/
117
+
118
+ # Spyder project settings
119
+ .spyderproject
120
+ .spyproject
121
+
122
+ # Rope project settings
123
+ .ropeproject
124
+
125
+ # mkdocs documentation
126
+ /site
127
+
128
+ # mypy
129
+ .mypy_cache/
130
+ .dmypy.json
131
+ dmypy.json
132
+
133
+ # Pyre type checker
134
+ .pyre/
135
+
136
+ # pytype static type analyzer
137
+ .pytype/
138
+
139
+ # Cython debug symbols
140
+ cython_debug/
141
+
142
+ # Ruff
143
+ .ruff_cache/
144
+
145
+ # IDEs
146
+ .idea/
147
+ .vscode/
148
+ *.swp
149
+ *.swo
150
+ *~
151
+
152
+ # OS
153
+ .DS_Store
154
+ Thumbs.db
155
+
156
+ # Jean runtime artifacts (when self-hosting jean against this repo).
157
+ # The canonical templates live in jean/templates/; these are local copies
158
+ # from `jean init` and the local data the running app produces.
159
+ /jean.toml
160
+ /jean.local.toml
161
+ /fly.toml
162
+ /Dockerfile
163
+ /.dockerignore
164
+ /jean_system_prompt.md
165
+ /.jean/
166
+ /.jean-vendor/
167
+ /credentials.enc
168
+ /.claude/skills
@@ -0,0 +1,333 @@
1
+ # AGENTS.md
2
+
3
+ Read this before working on jean. It captures the architectural invariants
4
+ and the non-obvious gotchas we discovered the hard way.
5
+
6
+ ## What jean is
7
+
8
+ A drop-in Slack-fronted Claude Code agent for any host repo. The host adds a
9
+ `skills/` directory plus `jean init`-generated artifacts; jean handles the
10
+ Slack bot, web UI for conversation history, Sign in with Slack auth,
11
+ per-thread Claude sessions, owner-gated rollout (private mode + per-channel
12
+ approvals + non-owner CC), user-created skills via DM, two-way file
13
+ attachments, and Fly.io deploy.
14
+
15
+ The same `jean` package self-tests against itself — `skills/haiku/SKILL.md`
16
+ in this repo is the bundled smoke skill so `jean run` here gives you a
17
+ working bot.
18
+
19
+ ## Project layout
20
+
21
+ ```
22
+ jean/
23
+ cli.py typer CLI (init, doctor, run, configure-secrets,
24
+ deploy, print-manifest, migrate, grant/revoke-admin)
25
+ config.py Pydantic config model; jean.toml + env + encrypted-store merge
26
+ diagnostics.py Phase 1 (offline) + Phase 2 (Slack API) checks
27
+ setup_guide.py Walkthrough + Slack app manifest renderer
28
+ wizard.py Interactive secrets wizard (browser-open, clipboard, live auth.test)
29
+ credentials.py AES-256-GCM encrypted store; master key in OS keychain
30
+ app.py FastAPI factory + lifespan (starts SessionManager, Socket Mode)
31
+ db.py aiosqlite + WAL pragmas + migration runner
32
+ models.py Dataclasses + DAO for users, conversations, messages,
33
+ attachments, channel_approvals, pending_channel_messages,
34
+ user_skills
35
+ auth/
36
+ siws.py Sign in with Slack via authlib (OIDC)
37
+ deps.py FastAPI deps: current_user, require_user/admin/owner
38
+ harness/
39
+ options.py Build ClaudeAgentOptions from cfg; stitches MCP servers,
40
+ pipes SDK stderr into jean's logger
41
+ sessions.py SessionManager + LiveSession + idle reaper + debug dump
42
+ + resume-failure fallback + context_was_lost flag
43
+ skills.py Skill discovery + .claude/skills per-skill symlink materializer
44
+ skill_tools.py MCP server `jean_skills` (DM-only): list/read/save/delete user skills
45
+ io_tools.py MCP server `jean_io` (every session): send_file_to_user
46
+ user_skills.py Filesystem layer + reconcile loop for user-created skills
47
+ slack/
48
+ bolt.py AsyncApp + Socket Mode handler factories (pops CLIENT_ID/SECRET
49
+ from env so bolt doesn't auto-enable OAuth)
50
+ identity.py auth.test + owner resolution + UsersCache + mention resolver
51
+ handlers.py app_mention + message.im → SessionManager; EventDedup;
52
+ ThrottledPoster; ProgressReactionLoop; private mode +
53
+ channel approval gates; owner-CC; rescue context
54
+ approvals.py Block Kit approval UI + action handlers + replay-on-approve
55
+ attachments.py Download Slack files → multimodal content blocks
56
+ thread_context.py Fetch unseen replies for thread catch-up; full-thread mode
57
+ (include_bot=True) for rescue context after session loss
58
+ formatting.py mrkdwn conversion + chunking + skill-preamble fallback regex
59
+ web/
60
+ routes.py /, /conversations, /admin/(users|skills|channels),
61
+ /_diagnostics, /attachments
62
+ display.py Slack ID → human handle resolver (DB-first, cache fallback)
63
+ templates/ Jinja2 (base, index, conversations_list,
64
+ admin_users/skills/channels, etc.)
65
+ static/ Pico CSS + jean.css
66
+ migrations/ 001_initial, 002_channel_approvals, 003_pending_channel_messages,
67
+ 004_user_skills, 005_owner_cc
68
+ templates/ Files copied by `jean init` (jean.toml, fly.toml, Dockerfile, …)
69
+ builtin_skills/ Skills shipped inside the jean package (skill-builder)
70
+ skills/haiku/ Bundled self-test skill
71
+ tests/ pytest; 51 tests; uses pytest-asyncio auto mode
72
+ ```
73
+
74
+ ## Local development
75
+
76
+ ```bash
77
+ python3 -m venv .venv
78
+ source .venv/bin/activate
79
+ pip install -e .[dev]
80
+ ```
81
+
82
+ Run the package against itself (you need a real Slack app + Anthropic key —
83
+ see `jean doctor --setup-guide` for the manifest + step-by-step):
84
+
85
+ ```bash
86
+ jean doctor # Phase 1, no network
87
+ jean configure-secrets # encrypted store wizard
88
+ jean doctor --deep # Phase 2 (auth.test + scope check + owner resolve)
89
+ jean run # FastAPI :8080 + Socket Mode listener
90
+ ```
91
+
92
+ ## Tests
93
+
94
+ ```bash
95
+ pytest # 51 tests; ~0.7s
96
+ pytest tests/test_db.py # focused
97
+ ```
98
+
99
+ Fixtures in `tests/conftest.py` build a tmpdir config + SQLite DB, mock env
100
+ vars where needed. There are NO tests that hit Slack or Anthropic APIs;
101
+ `test_create_app_smoke_does_not_crash_in_force_mode` stubs identity to
102
+ exercise the full `create_app(force=True)` path offline.
103
+
104
+ ## Architecture invariants — don't break these
105
+
106
+ 1. **Single uvicorn worker, single Fly machine.** SQLite + WAL needs one
107
+ writer; the in-memory `SessionManager` needs single-process state.
108
+ 2. **Socket Mode only.** No public webhook URL needed for events; only the
109
+ SIWS callback is inbound HTTP. Don't reintroduce HTTP Events Mode without
110
+ re-evaluating the ACK story (see the `process_before_response` gotcha).
111
+ 3. **One LiveSession per (channel, thread_ts)** while warm. Resume from
112
+ disk via `ClaudeAgentOptions(resume=session_id)` after idle reap. The SDK
113
+ writes transcripts to `~/.claude/...` (per `getpwuid()` lookup) — see the
114
+ non-root user gotcha below for the symlink trick that keeps them on the
115
+ Fly volume.
116
+ 4. **Env vars override the encrypted credentials file** at runtime. Never
117
+ reverse the precedence — `fly secrets set` must always win.
118
+ 5. **Skill discovery is filesystem-based** via `setting_sources=["project"]`
119
+ and `.claude/skills/`. We materialize per-skill symlinks from three
120
+ sources (repo / builtin / user) with precedence repo > builtin > user.
121
+ 6. **DM-only invariant for skill mutations.** Three gates: visibility in
122
+ the `skills_only` system prompt, MCP server registration, in-tool check.
123
+
124
+ ## Non-obvious gotchas (read before changing related code)
125
+
126
+ These each cost real debugging time. Don't re-introduce them.
127
+
128
+ ### Bundled Claude CLI ignores `HOME`; needs `~/.claude` symlinked to the volume
129
+
130
+ The Claude CLI binary that ships inside `claude-agent-sdk` resolves home via
131
+ `getpwuid()` (not `$HOME`). The `jean` user's passwd home is `/home/jean`,
132
+ so transcripts go to `/home/jean/.claude/...` regardless of `HOME=/data/home`.
133
+ That's on the ephemeral container layer — gone on every redeploy → every
134
+ existing thread breaks with "No conversation found with session ID …".
135
+
136
+ The Dockerfile's entrypoint replaces `/home/jean/.claude` with a symlink
137
+ to `/data/home/.claude` on the persistent volume. Don't reintroduce code
138
+ that relies on `HOME=/data/home` working — verify with
139
+ `ls -la /home/jean/.claude` after a deploy that it's a symlink.
140
+
141
+ There's also a runtime fallback: when resume fails (`LiveSession._ensure_client`
142
+ catches the exception), the session retries fresh AND sets `context_was_lost`.
143
+ The handler reads that flag, fetches the full Slack thread (including the
144
+ bot's own past replies via `include_bot=True`), and feeds it to Claude as
145
+ "rescue context" so the user keeps continuity. See
146
+ `jean/slack/thread_context.py:fetch_unseen_thread_messages`.
147
+
148
+ ### slack-bolt auto-enables OAuth when `SLACK_CLIENT_ID/SECRET` are in env
149
+
150
+ If both are present in `os.environ` when constructing `AsyncApp`, bolt
151
+ silently wires up a file-based `InstallationStore` + multi-team
152
+ authorization → every event fails with "AuthorizeResult was not found"
153
+ because the install store is empty. We use those env vars for our own
154
+ SIWS code via authlib, not for bolt. `jean/slack/bolt.py:build_app` pops
155
+ them from `os.environ` for the constructor's lifetime, then restores.
156
+
157
+ ### SDK skill scaffolding lives in UserMessage TextBlocks
158
+
159
+ When a Claude Code skill activates, the SDK sends the SKILL.md content +
160
+ `ARGUMENTS: <input>` as a TextBlock inside a **UserMessage** (not an
161
+ AssistantMessage). It's input to the model, not the model's reply.
162
+
163
+ `jean/harness/sessions.py:_interpret_message` only yields TextDeltas from
164
+ **AssistantMessage** blocks for this reason. Don't add a fallback that
165
+ yields UserMessage text — you'll get the SKILL.md echoed into Slack.
166
+
167
+ A regex fallback exists in `jean/slack/formatting.py:filter_for_display`
168
+ as defense-in-depth in case a future SDK version moves scaffolding to
169
+ AssistantMessage.
170
+
171
+ ### `bot_user_id` must be the Uxxx, not the Bxxx
172
+
173
+ `auth.test` returns both `bot_id` (`Bxxx`) and `user_id` (`Uxxx`). Slack
174
+ mentions use `<@Uxxx>` — bot-mention stripping and "is this the bot?"
175
+ checks need the user_id form. `jean/slack/identity.py:workspace_identity`
176
+ prefers `user_id`. Don't switch the order.
177
+
178
+ ### `ThrottledPoster` needs `_post_lock`
179
+
180
+ Two concurrent `_do_flush` calls (loop tick + `finalize()`) can both
181
+ observe `posted_ts is None` and both call `chat.postMessage` → duplicate
182
+ replies. The `_post_lock` serializes the post-or-update decision across
183
+ the actual API call.
184
+
185
+ ### `process_before_response` defaults to False — keep it that way
186
+
187
+ If `True`, bolt waits for the listener to finish before ACKing Slack.
188
+ Claude turns take >3s, Slack re-delivers, the listener runs twice →
189
+ duplicate replies. `EventDedup` catches it too, but the ACK timing is
190
+ the right fix.
191
+
192
+ ### `client.query()` accepts `str` or `AsyncIterable[dict]`, never `list`
193
+
194
+ `to_content_blocks(text, staged_files)` returns a `list[dict]` for
195
+ multimodal messages. Pass it to `query()` directly and the SDK tries to
196
+ `async for` over the list — crash with `__aiter__` error. The fix wraps
197
+ in `_as_message_stream()` (single-item async generator) inside
198
+ `LiveSession.submit`.
199
+
200
+ ### Don't run as root in Docker — Claude CLI refuses `bypassPermissions`
201
+
202
+ `--dangerously-skip-permissions` (which `bypassPermissions` translates to)
203
+ errors out with "cannot be used with root/sudo privileges". The Dockerfile
204
+ creates a non-root `jean` user (uid 1000) and uses `gosu` in the entrypoint
205
+ to drop privileges. Even with the current default `permission_mode = "auto"`,
206
+ keep the non-root setup — it's good practice independent of the SDK
207
+ check, and lets users flip to `bypassPermissions` cleanly.
208
+
209
+ ### Honor `X-Forwarded-Proto` in uvicorn behind the Fly edge proxy
210
+
211
+ Fly terminates TLS at the edge and forwards plain HTTP to the container.
212
+ Without `proxy_headers=True` + `forwarded_allow_ips="*"`, `request.url_for()`
213
+ generates `http://...` callbacks → Slack rejects them with "redirect_uri
214
+ did not match". See the uvicorn config in `jean/cli.py:run`.
215
+
216
+ ### `pip install jean` resolves to a different PyPI package
217
+
218
+ The `jean` name on PyPI is taken by an unrelated "OOP Learning Example".
219
+ The Dockerfile installs from the local repo (`pip install /app`). When we
220
+ publish to PyPI as `shinkansen-jean`, switch the Dockerfile back to
221
+ installing from PyPI by name.
222
+
223
+ ### Hatchling: don't list packages in both `packages` and `force-include`
224
+
225
+ `packages = ["jean"]` already picks up every non-gitignored file under
226
+ `jean/`. Listing the same subdirs in `force-include` makes hatchling crash
227
+ with "second file at same path: …/__init__.py". The current `pyproject.toml`
228
+ uses only `packages`.
229
+
230
+ ### Slack app's Messages Tab is off by default
231
+
232
+ Without `features.app_home.messages_tab_enabled: true` in the manifest,
233
+ users see "Se desactivó el envío de mensajes a esta aplicación" when they
234
+ try to DM the bot. `jean/setup_guide.py:render_manifest` includes the
235
+ toggle; existing apps need to flip it in App Home settings.
236
+
237
+ ### Session must close after `save_skill_file` / `delete_skill_file`
238
+
239
+ The Claude SDK caches the skill list at client construction. New skills
240
+ written mid-turn aren't visible to the same client. The handler tracks
241
+ `SESSION_INVALIDATING_TOOLS` (in `jean/slack/handlers.py`) and calls
242
+ `SessionManager.close_session` after the turn ends — the next message in
243
+ the thread spins up a fresh client (via `resume=session_id`) that
244
+ re-discovers skills. Context survives via the SDK's on-disk transcript.
245
+
246
+ ## How to add a skill (the host's job — but useful to know)
247
+
248
+ In the host repo, create `skills/<name>/SKILL.md`:
249
+
250
+ ```markdown
251
+ ---
252
+ name: <name>
253
+ description: One-line; surfaces in `skills_only` system prompt + triggers the model.
254
+ ---
255
+
256
+ Body — Claude reads this when the skill activates.
257
+ ```
258
+
259
+ The `[claude] skills_only = true` default lists every discovered skill's
260
+ name + description in the system prompt and tells Claude to politely
261
+ decline anything that doesn't fit a skill. See
262
+ `jean/harness/options.py:_skills_only_addendum`.
263
+
264
+ Skills can call two MCP tools jean exposes:
265
+ - `send_file_to_user(local_path, title?, comment?)` — upload an artifact to the current Slack thread (every session).
266
+ - Skill-management tools (`save_skill_file`, etc.) — only available in DMs and only to users matching `[claude] user_skill_authors` policy.
267
+
268
+ The bundled `skills/haiku/` is the simplest possible example.
269
+ `jean/builtin_skills/skill-builder/SKILL.md` is the meta-skill that drives
270
+ DM-based skill creation/editing.
271
+
272
+ ## Debugging
273
+
274
+ - **Dump every SDK message** to inspect what's actually arriving:
275
+ `JEAN_DEBUG_SDK_MESSAGES=1 jean run` → writes JSON-lines to
276
+ `<db_dir>/sdk_debug.jsonl`. Used to discover the UserMessage gotcha.
277
+ - **SDK subprocess stderr** is piped into `jean.sdk.stderr` logger at
278
+ WARNING level by default — visible in `flyctl logs`. Surfaces "unknown
279
+ flag", "model not found", "No conversation found", etc.
280
+ - **SQLite shell**: `sqlite3 .jean/jean.db` (or `/data/jean.db` on Fly).
281
+ - **Web UI diagnostics page**: `/_diagnostics` (owner-only) shows the
282
+ same Phase 1 + Phase 2 checks as `jean doctor --deep`.
283
+ - **misconfig mode**: if startup checks fail, every route renders the
284
+ diagnostics page so the owner can fix things without dropping to CLI.
285
+ - **`jean run --verbose`** promotes log level to DEBUG and unmutes
286
+ upstream loggers (`httpx`, `slack_sdk`).
287
+
288
+ ## When you change X, also do Y
289
+
290
+ - **New `[<section>]` field in config.py** → add to
291
+ `jean/templates/jean.toml.tmpl` with explanatory comment.
292
+ - **New DB column / table** → add a migration as
293
+ `jean/migrations/NNN_<name>.sql`; the runner picks it up automatically.
294
+ Update `tests/test_db.py:test_migrations_apply_to_clean_db` if a new
295
+ table needs explicit assertions.
296
+ - **New required Slack bot scope** → add to
297
+ `slack/identity.py:REQUIRED_BOT_SCOPES` AND to the manifest in
298
+ `jean/setup_guide.py:render_manifest`. Users with existing apps need
299
+ to reinstall the app for new scopes.
300
+ - **New jean CLI command** → register on the `typer` app in
301
+ `jean/cli.py`. Help text becomes visible in `jean --help`.
302
+ - **New MCP tool** → decide DM-only (add to `jean_skills` server in
303
+ `skill_tools.py`) or every-session (add to `jean_io` server in
304
+ `io_tools.py`). Each is registered in `options.py:options_kwargs`.
305
+ - **New OS package needed for a skill** (poppler, libreoffice, etc.) →
306
+ add to the `apt-get install` line in `jean/templates/Dockerfile.tmpl`
307
+ with a comment for why.
308
+
309
+ ## What's intentionally NOT built (future work)
310
+
311
+ - Multi-machine / LiteFS — single machine is by design (see invariant 1).
312
+ - HTTP Events Mode for Slack — Socket Mode covers our use cases.
313
+ - Custom session store — the SDK's default disk transcripts + the symlink
314
+ to the Fly volume are enough; rescue-from-Slack covers the edge case.
315
+ - Fast Mode (`speed: "fast"`) — the SDK doesn't expose it yet (`betas`
316
+ field only accepts `context-1m-2025-08-07`). When Anthropic ships it,
317
+ wire a `[claude] speed` config option to it.
318
+ - PyPI publish as `shinkansen-jean` — until then, install from local
319
+ repo (jean-on-jean) or from a git tag.
320
+ - Multi-tenant — one jean install talks to one Slack workspace.
321
+
322
+ ## Style notes
323
+
324
+ - Async everywhere; if you need to mix sync/async, prefer async wrappers.
325
+ - Pydantic v2 is the source of truth for config validation.
326
+ - Defensive SDK probing via `getattr`/`type(x).__name__` — the SDK shape
327
+ drifts across versions; don't rely on exact class identity.
328
+ - Tests use `pytest-asyncio` auto mode; no `@pytest.mark.asyncio`
329
+ decorators needed.
330
+ - No `print()` — use `typer.echo`/`typer.secho` for CLI output and
331
+ `logging` for everything else.
332
+ - Templates copied by `jean init` end in `.tmpl` but otherwise carry the
333
+ filename they'll be written as (so `Dockerfile.tmpl` → `Dockerfile`).
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shinkansen Finance
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.