diff --git a/poetry.lock b/poetry.lock index c47200c..2c8440f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand. [[package]] name = "annotated-types" @@ -7,7 +7,7 @@ description = "Reusable constraint types to use with typing.Annotated" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"claude\" or extra == \"openai\"" +markers = "extra == \"claude-code\" or extra == \"openai\"" files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, @@ -20,7 +20,7 @@ description = "High level compatibility layer for multiple asynchronous event lo optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"claude\" or extra == \"openai\"" +markers = "extra == \"claude-code\" or extra == \"openai\"" files = [ {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, @@ -43,7 +43,7 @@ description = "Classes Without Boilerplate" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"claude\"" +markers = "extra == \"claude-code\"" files = [ {file = "attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3"}, {file = "attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"}, @@ -64,7 +64,7 @@ description = "Python package for providing Mozilla's CA Bundle." optional = true python-versions = ">=3.7" groups = ["main"] -markers = "extra == \"claude\" or extra == \"openai\"" +markers = "extra == \"claude-code\" or extra == \"openai\"" files = [ {file = "certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057"}, {file = "certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b"}, @@ -77,7 +77,7 @@ description = "Python SDK for Claude Code" optional = true python-versions = ">=3.10" groups = ["main"] -markers = "extra == \"claude\"" +markers = "extra == \"claude-code\"" files = [ {file = "claude_code_sdk-0.0.22-py3-none-any.whl", hash = "sha256:c66f5d01e2c9c803a100d5f22579cbafa338e6ce604e58033fcc03e8f5ddd7df"}, {file = "claude_code_sdk-0.0.22.tar.gz", hash = "sha256:67dc234fcd3454fd0e229621df6ae1265e5efdeac8f94c840f55b9ffa6699853"}, @@ -97,7 +97,7 @@ description = "Composable command line interface toolkit" optional = true python-versions = ">=3.10" groups = ["main"] -markers = "extra == \"claude\" and sys_platform != \"emscripten\"" +markers = "extra == \"claude-code\" and sys_platform != \"emscripten\"" files = [ {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"}, {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"}, @@ -117,7 +117,7 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {main = "(extra == \"claude\" or extra == \"openai\") and platform_system == \"Windows\" and (sys_platform != \"emscripten\" or extra == \"openai\")", dev = "sys_platform == \"win32\""} +markers = {main = "(extra == \"claude-code\" or extra == \"openai\") and platform_system == \"Windows\" and (sys_platform != \"emscripten\" or extra == \"openai\")", dev = "sys_platform == \"win32\""} [[package]] name = "coverage" @@ -231,7 +231,7 @@ description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"claude\" or extra == \"openai\"" +markers = "extra == \"claude-code\" or extra == \"openai\"" files = [ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, @@ -244,7 +244,7 @@ description = "A minimal low-level HTTP client." optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"claude\" or extra == \"openai\"" +markers = "extra == \"claude-code\" or extra == \"openai\"" files = [ {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, @@ -267,7 +267,7 @@ description = "The next generation HTTP client." optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"claude\" or extra == \"openai\"" +markers = "extra == \"claude-code\" or extra == \"openai\"" files = [ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, @@ -293,7 +293,7 @@ description = "Consume Server-Sent Event (SSE) messages with HTTPX." optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"claude\"" +markers = "extra == \"claude-code\"" files = [ {file = "httpx_sse-0.4.1-py3-none-any.whl", hash = "sha256:cba42174344c3a5b06f255ce65b350880f962d99ead85e776f23c6618a377a37"}, {file = "httpx_sse-0.4.1.tar.gz", hash = "sha256:8f44d34414bc7b21bf3602713005c5df4917884f76072479b21f68befa4ea26e"}, @@ -306,7 +306,7 @@ description = "Internationalized Domain Names in Applications (IDNA)" optional = true python-versions = ">=3.6" groups = ["main"] -markers = "extra == \"claude\" or extra == \"openai\"" +markers = "extra == \"claude-code\" or extra == \"openai\"" files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -440,7 +440,7 @@ description = "An implementation of JSON Schema validation for Python" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"claude\"" +markers = "extra == \"claude-code\"" files = [ {file = "jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63"}, {file = "jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85"}, @@ -463,7 +463,7 @@ description = "The JSON Schema meta-schemas and vocabularies, exposed as a Regis optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"claude\"" +markers = "extra == \"claude-code\"" files = [ {file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"}, {file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"}, @@ -550,7 +550,7 @@ description = "Model Context Protocol SDK" optional = true python-versions = ">=3.10" groups = ["main"] -markers = "extra == \"claude\"" +markers = "extra == \"claude-code\"" files = [ {file = "mcp-1.14.0-py3-none-any.whl", hash = "sha256:b2d27feba27b4c53d41b58aa7f4d090ae0cb740cbc4e339af10f8cbe54c4e19d"}, {file = "mcp-1.14.0.tar.gz", hash = "sha256:2e7d98b195e08b2abc1dc6191f6f3dc0059604ac13ee6a40f88676274787fac4"}, @@ -824,7 +824,7 @@ description = "Data validation using Python type hints" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"claude\" or extra == \"openai\"" +markers = "extra == \"claude-code\" or extra == \"openai\"" files = [ {file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"}, {file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"}, @@ -847,7 +847,7 @@ description = "Core functionality for Pydantic validation and serialization" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"claude\" or extra == \"openai\"" +markers = "extra == \"claude-code\" or extra == \"openai\"" files = [ {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, @@ -960,7 +960,7 @@ description = "Settings management using Pydantic" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"claude\"" +markers = "extra == \"claude-code\"" files = [ {file = "pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796"}, {file = "pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee"}, @@ -1041,7 +1041,7 @@ description = "Read key-value pairs from a .env file and set them as environment optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"claude\"" +markers = "extra == \"claude-code\"" files = [ {file = "python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc"}, {file = "python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab"}, @@ -1057,7 +1057,7 @@ description = "A streaming multipart parser for Python" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"claude\"" +markers = "extra == \"claude-code\"" files = [ {file = "python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104"}, {file = "python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13"}, @@ -1070,7 +1070,7 @@ description = "Python for Window Extensions" optional = true python-versions = "*" groups = ["main"] -markers = "extra == \"claude\" and sys_platform == \"win32\"" +markers = "extra == \"claude-code\" and sys_platform == \"win32\"" files = [ {file = "pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3"}, {file = "pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b"}, @@ -1101,7 +1101,7 @@ description = "JSON Referencing + Python" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"claude\"" +markers = "extra == \"claude-code\"" files = [ {file = "referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0"}, {file = "referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa"}, @@ -1119,7 +1119,7 @@ description = "Python bindings to Rust's persistent data structures (rpds)" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"claude\"" +markers = "extra == \"claude-code\"" files = [ {file = "rpds_py-0.27.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:68afeec26d42ab3b47e541b272166a0b4400313946871cba3ed3a4fc0cab1cef"}, {file = "rpds_py-0.27.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74e5b2f7bb6fa38b1b10546d27acbacf2a022a8b5543efb06cfebc72a59c85be"}, @@ -1313,7 +1313,7 @@ description = "Sniff out which async library your code is running under" optional = true python-versions = ">=3.7" groups = ["main"] -markers = "extra == \"claude\" or extra == \"openai\"" +markers = "extra == \"claude-code\" or extra == \"openai\"" files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -1326,7 +1326,7 @@ description = "SSE plugin for Starlette" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"claude\"" +markers = "extra == \"claude-code\"" files = [ {file = "sse_starlette-3.0.2-py3-none-any.whl", hash = "sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a"}, {file = "sse_starlette-3.0.2.tar.gz", hash = "sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a"}, @@ -1348,7 +1348,7 @@ description = "The little ASGI library that shines." optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"claude\"" +markers = "extra == \"claude-code\"" files = [ {file = "starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659"}, {file = "starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46"}, @@ -1452,7 +1452,7 @@ files = [ {file = "typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af"}, {file = "typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4"}, ] -markers = {main = "extra == \"claude\" or extra == \"openai\""} +markers = {main = "extra == \"claude-code\" or extra == \"openai\""} [[package]] name = "typing-inspection" @@ -1461,7 +1461,7 @@ description = "Runtime typing introspection tools" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"claude\" or extra == \"openai\"" +markers = "extra == \"claude-code\" or extra == \"openai\"" files = [ {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, @@ -1477,7 +1477,7 @@ description = "The lightning-fast ASGI server." optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"claude\" and sys_platform != \"emscripten\"" +markers = "extra == \"claude-code\" and sys_platform != \"emscripten\"" files = [ {file = "uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a"}, {file = "uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01"}, @@ -1530,10 +1530,10 @@ files = [ termcolor = ">=2.2.0,<2.4.0" [extras] -claude = ["claude-code-sdk"] +claude-code = ["claude-code-sdk"] openai = ["openai"] [metadata] lock-version = "2.1" python-versions = ">=3.12,<4" -content-hash = "4475eaa7f3b6df172b18b9228799d4c03378485a1a45934b80a6af12478df458" +content-hash = "e1aa784ff4ebbf16297fabf5e64de2b5bac0bf92d241ed0e9ab4f2e6cc852d0d" diff --git a/pyproject.toml b/pyproject.toml index 3aacd16..3ff3bab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ ] [project.optional-dependencies] -claude = ["claude-code-sdk (==0.0.22)"] +claude-code = ["claude-code-sdk (==0.0.22)"] openai = ["openai (>=1.64.0,<2)"] [project.scripts] diff --git a/src/git_draft/bots/claude_code.py b/src/git_draft/bots/claude_code.py index fea4033..11876d1 100644 --- a/src/git_draft/bots/claude_code.py +++ b/src/git_draft/bots/claude_code.py @@ -9,6 +9,7 @@ from collections.abc import Mapping import dataclasses import logging +import re from typing import Any import claude_code_sdk as sdk @@ -25,9 +26,14 @@ def new_bot() -> Bot: _PROMPT_SUFFIX = reindent(""" - ALWAYS use the feedback's MCP server ask_user tool if you need to request - any information from the user. NEVER repeat yourself by also asking your - question to the user in other ways. + ALWAYS use the `ask_user` tool if you need to request any information from + the user. NEVER repeat yourself by also asking your question to the user in + other ways. + + When you are done performing all the changes the user asked for, ALWAYS use + the `summarize_change` tool to describe what you have done. This should be + the last action you perform. NEVER stop responding to the user without + calling this tool first. """) @@ -47,7 +53,9 @@ async def act( options = dataclasses.replace( self._options, cwd=tree_path, - mcp_servers={"feedback": _feedback_mcp_server(feedback)}, + mcp_servers={ + "feedback": _feedback_mcp_server(feedback, summary), + }, ) async with sdk.ClaudeSDKClient(options) as client: await client.query(goal.prompt) @@ -81,17 +89,21 @@ def _token_count(usage: Mapping[str, Any]) -> int: ) +_text_suffix = re.compile(":$") + + def _notify( feedback: UserFeedback, content: str | list[sdk.ContentBlock] ) -> None: if isinstance(content, str): feedback.notify(content) return - for block in content: match block: case sdk.TextBlock(text): - feedback.notify(text) + # Text blocks end with a colon by default (likely to look + # better in the default CLI), which looks off here. + feedback.notify(_text_suffix.sub(".", text)) case sdk.ThinkingBlock(thinking, signature): feedback.notify(thinking) feedback.notify(signature) @@ -101,10 +113,32 @@ def _notify( raise UnreachableError() -def _feedback_mcp_server(feedback: UserFeedback) -> sdk.McpServerConfig: +def _feedback_mcp_server( + feedback: UserFeedback, + summary: ActionSummary, +) -> sdk.McpServerConfig: @sdk.tool("ask_user", "Request feedback from the user", {"question": str}) async def ask_user(args: Any) -> Any: question = args["question"] return {"content": [{"type": "text", "text": feedback.ask(question)}]} - return sdk.create_sdk_mcp_server(name="feedback", tools=[ask_user]) + @sdk.tool( + "summarize_change", + reindent(""" + Describe work performed in a format suitable for a git commit + + The message should include a short title that fits on a single line + of 55 characters. This title should be followed by one or more + paragraphs which describe the change done. Avoid repeating + implementation details here, focus instead on non-obvious choices + that were made. + """), + {"commit_message": str}, + ) + async def summarize_change(args: Any) -> Any: + summary.message = args["commit_message"] + return {"content": [{"type": "text", "text": "Great, thank you."}]} + + return sdk.create_sdk_mcp_server( + name="feedback", tools=[ask_user, summarize_change] + ) diff --git a/src/git_draft/bots/common.py b/src/git_draft/bots/common.py index d483673..248d90f 100644 --- a/src/git_draft/bots/common.py +++ b/src/git_draft/bots/common.py @@ -70,7 +70,7 @@ class ActionSummary: fields incrementally. """ - title: str | None = None + message: str | None = None turn_count: int | None = None token_count: int | None = None # TODO: Split into input and output. cost: float | None = None diff --git a/src/git_draft/drafter.py b/src/git_draft/drafter.py index c0b030c..fbbcc25 100644 --- a/src/git_draft/drafter.py +++ b/src/git_draft/drafter.py @@ -203,7 +203,7 @@ async def generate_draft( with self._progress.spinner("Creating draft commit...") as spinner: if dirty: parent_commit_rev = self._commit_tree( - tree.sha(), "HEAD", "sync(prompt)" + tree.sha(), "HEAD", "sync(act)" ) _logger.info( "Created sync commit. [sha=%s]", parent_commit_rev @@ -388,12 +388,16 @@ async def _generate_change( end_time = time.perf_counter() walltime = end_time - start_time - title = action.title or _default_title(goal.prompt) + message = ( + reindent(action.message, width=72) + if action.message + else _default_message(goal.prompt) + ) new_tree_sha = tree.sha() return _Change( walltime=timedelta(seconds=walltime), action=action, - commit_message=f"prompt: {title}\n\n{goal.prompt}", + commit_message=f"act: {message}", tree_sha=new_tree_sha, is_noop=new_tree_sha == old_tree_sha, ) @@ -535,5 +539,5 @@ def _format_event(event: Event) -> str: raise UnreachableError() -def _default_title(prompt: str) -> str: +def _default_message(prompt: str) -> str: return textwrap.shorten(prompt, break_on_hyphens=False, width=55)