tina4-python 3.11.14__tar.gz → 3.11.16__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.
- {tina4_python-3.11.14 → tina4_python-3.11.16}/PKG-INFO +1 -1
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/__init__.py +1 -1
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/dev_admin/__init__.py +190 -0
- tina4_python-3.11.16/tina4_python/dev_admin/plan.py +454 -0
- tina4_python-3.11.16/tina4_python/dev_admin/project_index.py +417 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/mcp/__init__.py +19 -0
- tina4_python-3.11.16/tina4_python/mcp/tools.py +742 -0
- tina4_python-3.11.16/tina4_python/public/js/tina4-dev-admin.js +1279 -0
- tina4_python-3.11.16/tina4_python/public/js/tina4-dev-admin.min.js +1279 -0
- tina4_python-3.11.14/tina4_python/mcp/tools.py +0 -348
- tina4_python-3.11.14/tina4_python/public/js/tina4-dev-admin.js +0 -565
- tina4_python-3.11.14/tina4_python/public/js/tina4-dev-admin.min.js +0 -565
- {tina4_python-3.11.14 → tina4_python-3.11.16}/.gitignore +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/README.md +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/pyproject.toml +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/CLAUDE.md +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/Testing.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/ai/__init__.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/api/__init__.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/auth/__init__.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/cache/__init__.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/cli/__init__.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/core/__init__.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/core/events.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/core/middleware.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/core/rate_limiter.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/core/request.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/core/response.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/core/router.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/core/server.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/crud/__init__.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/database/__init__.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/database/adapter.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/database/connection.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/database/firebird.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/database/mongodb.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/database/mssql.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/database/mysql.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/database/odbc.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/database/postgres.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/database/sqlite.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/debug/__init__.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/debug/error_overlay.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/dev_admin/metrics.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/dotenv/__init__.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/frond/engine.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/graphql/__init__.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/mcp/protocol.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/messenger/__init__.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/migration/__init__.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/migration/runner.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/orm/__init__.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/orm/fields.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/orm/model.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/public/js/frond.min.js +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/public/js/tina4js.min.js +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/query_builder/__init__.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/queue/__init__.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/queue/job.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/queue/kafka_backend.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/queue/lite_backend.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/queue/mongo_backend.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/queue/rabbitmq_backend.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/queue_backends/__init__.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/queue_backends/kafka_backend.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/queue_backends/mongo_backend.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/__init__.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/_alerts.scss +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/_badges.scss +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/_buttons.scss +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/_cards.scss +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/_forms.scss +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/_grid.scss +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/_modals.scss +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/_nav.scss +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/_reset.scss +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/_tables.scss +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/_typography.scss +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/_utilities.scss +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/_variables.scss +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/base.scss +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/colors.scss +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/tina4.scss +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/seeder/__init__.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/service/__init__.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/session/__init__.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/session_handlers/redis_handler.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/session_handlers/valkey_handler.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/swagger/__init__.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/test_client/__init__.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/validator/__init__.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/websocket/__init__.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/websocket/backplane.py +0 -0
- {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/wsdl/__init__.py +0 -0
|
@@ -348,6 +348,20 @@ def get_api_handlers() -> dict:
|
|
|
348
348
|
"/__dev/api/deps/search": ("GET", _api_deps_search),
|
|
349
349
|
"/__dev/api/deps/install": ("POST", _api_deps_install),
|
|
350
350
|
"/__dev/api/git/status": ("GET", _api_git_status),
|
|
351
|
+
# ── MCP REST shim ──
|
|
352
|
+
# Dev-admin speaks a REST flavour of MCP (plain GET/POST with
|
|
353
|
+
# JSON bodies) rather than the JSON-RPC SSE protocol used by
|
|
354
|
+
# Claude Desktop et al. Both surfaces share the same
|
|
355
|
+
# `_default_server` tool registry, so tools registered via the
|
|
356
|
+
# @mcp_tool decorator appear in both immediately.
|
|
357
|
+
"/__dev/api/mcp/tools": ("GET", _api_mcp_tools),
|
|
358
|
+
"/__dev/api/mcp/call": ("POST", _api_mcp_call),
|
|
359
|
+
# ── Scaffold REST shim ──
|
|
360
|
+
# Wraps the tina4python CLI's `generate <kind> <name>` so the
|
|
361
|
+
# + Route / + Model / + Migration / + Middleware buttons work
|
|
362
|
+
# without shelling out from the browser.
|
|
363
|
+
"/__dev/api/scaffold": ("GET", _api_scaffold_list),
|
|
364
|
+
"/__dev/api/scaffold/run": ("POST", _api_scaffold_run),
|
|
351
365
|
}
|
|
352
366
|
|
|
353
367
|
|
|
@@ -2056,5 +2070,181 @@ async def _api_git_status(request, response):
|
|
|
2056
2070
|
return response(result)
|
|
2057
2071
|
|
|
2058
2072
|
|
|
2073
|
+
# ─── MCP REST shim ─────────────────────────────────────────────────
|
|
2074
|
+
#
|
|
2075
|
+
# Exposes the framework's MCP tool registry (`_default_server`) over
|
|
2076
|
+
# plain GET/POST JSON so the dev-admin browser panel and any other
|
|
2077
|
+
# REST client can enumerate and invoke tools without speaking the full
|
|
2078
|
+
# JSON-RPC 2.0 over SSE protocol.
|
|
2079
|
+
#
|
|
2080
|
+
# The JSON-RPC endpoint at `/__dev/mcp/{message,sse}` stays live for
|
|
2081
|
+
# proper MCP clients (Claude Desktop et al.) — the two surfaces share
|
|
2082
|
+
# the same registry, so tools registered via the `@mcp_tool` decorator
|
|
2083
|
+
# show up on both immediately.
|
|
2084
|
+
|
|
2085
|
+
async def _api_mcp_tools(request, response):
|
|
2086
|
+
"""GET — return the MCP tool registry as a plain JSON list.
|
|
2087
|
+
|
|
2088
|
+
Shape matches what dev-admin's `listMcpTools()` expects:
|
|
2089
|
+
{"tools": [{"name": "...", "description": "...", "schema": {...}}, ...]}
|
|
2090
|
+
"""
|
|
2091
|
+
try:
|
|
2092
|
+
from tina4_python.mcp import _get_default_server
|
|
2093
|
+
server = _get_default_server()
|
|
2094
|
+
tools = [
|
|
2095
|
+
{
|
|
2096
|
+
"name": t["name"],
|
|
2097
|
+
"description": t.get("description", ""),
|
|
2098
|
+
"schema": t.get("inputSchema") or {"type": "object", "properties": {}},
|
|
2099
|
+
}
|
|
2100
|
+
for t in server._tools.values()
|
|
2101
|
+
]
|
|
2102
|
+
return response({"tools": tools})
|
|
2103
|
+
except Exception as exc:
|
|
2104
|
+
return response({"tools": [], "error": str(exc)}, 500)
|
|
2105
|
+
|
|
2106
|
+
|
|
2107
|
+
async def _api_mcp_call(request, response):
|
|
2108
|
+
"""POST — invoke an MCP tool by name.
|
|
2109
|
+
|
|
2110
|
+
Request: {"name": "tool_name", "arguments": {...}}
|
|
2111
|
+
Response: {"ok": true, "name": "...", "result": ...}
|
|
2112
|
+
or {"ok": false, "error": "..."}
|
|
2113
|
+
|
|
2114
|
+
The wrapper uses the tool's handler directly rather than routing
|
|
2115
|
+
through `handle_message` — we already know the name and args, no
|
|
2116
|
+
need to round-trip through JSON-RPC framing.
|
|
2117
|
+
"""
|
|
2118
|
+
body = request.body or {}
|
|
2119
|
+
if not isinstance(body, dict):
|
|
2120
|
+
return response({"ok": False, "error": "body must be a JSON object"}, 400)
|
|
2121
|
+
|
|
2122
|
+
name = body.get("name")
|
|
2123
|
+
if not name or not isinstance(name, str):
|
|
2124
|
+
return response({"ok": False, "error": "missing 'name'"}, 400)
|
|
2125
|
+
|
|
2126
|
+
args = body.get("arguments") or body.get("args") or {}
|
|
2127
|
+
if not isinstance(args, dict):
|
|
2128
|
+
return response({"ok": False, "error": "'arguments' must be an object"}, 400)
|
|
2129
|
+
|
|
2130
|
+
try:
|
|
2131
|
+
from tina4_python.mcp import _get_default_server
|
|
2132
|
+
server = _get_default_server()
|
|
2133
|
+
tool = server._tools.get(name)
|
|
2134
|
+
if tool is None:
|
|
2135
|
+
return response({"ok": False, "error": f"unknown tool: {name}"}, 404)
|
|
2136
|
+
|
|
2137
|
+
handler = tool["handler"]
|
|
2138
|
+
# Tools are registered as regular functions or coroutines;
|
|
2139
|
+
# await the result when the handler returns an awaitable.
|
|
2140
|
+
import inspect
|
|
2141
|
+
if inspect.iscoroutinefunction(handler):
|
|
2142
|
+
result = await handler(**args)
|
|
2143
|
+
else:
|
|
2144
|
+
result = handler(**args)
|
|
2145
|
+
|
|
2146
|
+
return response({"ok": True, "name": name, "result": result})
|
|
2147
|
+
except TypeError as exc:
|
|
2148
|
+
# Bad args shape — surface the Python error cleanly rather
|
|
2149
|
+
# than returning a 500. Callers see "argument X missing" etc.
|
|
2150
|
+
return response({"ok": False, "name": name, "error": f"argument error: {exc}"}, 400)
|
|
2151
|
+
except Exception as exc:
|
|
2152
|
+
return response({"ok": False, "name": name, "error": str(exc)}, 500)
|
|
2153
|
+
|
|
2154
|
+
|
|
2155
|
+
# ─── Scaffold REST shim ────────────────────────────────────────────
|
|
2156
|
+
#
|
|
2157
|
+
# Wraps the tina4python `generate <kind> <name>` CLI commands so the
|
|
2158
|
+
# + Route / + Model / + Migration / + Middleware buttons in dev-admin
|
|
2159
|
+
# can call them without shelling out from the browser. The handlers
|
|
2160
|
+
# import the generator functions directly rather than shelling out —
|
|
2161
|
+
# avoids spawning a subprocess per click and surfaces errors as JSON.
|
|
2162
|
+
|
|
2163
|
+
_SCAFFOLD_KINDS = [
|
|
2164
|
+
{"kind": "route", "label": "+ Route", "needs_name": True},
|
|
2165
|
+
{"kind": "model", "label": "+ Model", "needs_name": True},
|
|
2166
|
+
{"kind": "migration", "label": "+ Migration", "needs_name": True},
|
|
2167
|
+
{"kind": "middleware", "label": "+ Middleware", "needs_name": True},
|
|
2168
|
+
]
|
|
2169
|
+
|
|
2170
|
+
|
|
2171
|
+
async def _api_scaffold_list(request, response):
|
|
2172
|
+
"""GET — list the scaffold kinds this framework knows how to emit.
|
|
2173
|
+
|
|
2174
|
+
Dev-admin renders one button per item. Each carries a `kind` the
|
|
2175
|
+
/scaffold/run endpoint expects and a human `label` for the UI.
|
|
2176
|
+
"""
|
|
2177
|
+
return response({"kinds": _SCAFFOLD_KINDS})
|
|
2178
|
+
|
|
2179
|
+
|
|
2180
|
+
async def _api_scaffold_run(request, response):
|
|
2181
|
+
"""POST — invoke a generator.
|
|
2182
|
+
|
|
2183
|
+
Request: {"kind": "route", "name": "contact"}
|
|
2184
|
+
Response: {"ok": true, "files": ["src/routes/contact.py"]}
|
|
2185
|
+
or {"ok": false, "error": "..."}
|
|
2186
|
+
|
|
2187
|
+
Uses tina4_python.cli's module-level generator functions rather
|
|
2188
|
+
than shelling out via subprocess — faster, no path/env lookup.
|
|
2189
|
+
"""
|
|
2190
|
+
body = request.body or {}
|
|
2191
|
+
if not isinstance(body, dict):
|
|
2192
|
+
return response({"ok": False, "error": "body must be a JSON object"}, 400)
|
|
2193
|
+
|
|
2194
|
+
kind = (body.get("kind") or "").strip().lower()
|
|
2195
|
+
name = (body.get("name") or "").strip()
|
|
2196
|
+
if not kind:
|
|
2197
|
+
return response({"ok": False, "error": "missing 'kind'"}, 400)
|
|
2198
|
+
if not name and kind != "auth":
|
|
2199
|
+
return response({"ok": False, "error": "missing 'name'"}, 400)
|
|
2200
|
+
|
|
2201
|
+
# Guard against path traversal / shell-metacharacter injection in
|
|
2202
|
+
# the name — generator functions pass it into file paths.
|
|
2203
|
+
import re
|
|
2204
|
+
if not re.match(r"^[A-Za-z][A-Za-z0-9_\-]*$", name):
|
|
2205
|
+
return response({"ok": False, "error": "name must match [A-Za-z][A-Za-z0-9_-]*"}, 400)
|
|
2206
|
+
|
|
2207
|
+
generator_map = {
|
|
2208
|
+
"route": "generate_route",
|
|
2209
|
+
"model": "generate_model",
|
|
2210
|
+
"migration": "generate_migration",
|
|
2211
|
+
"middleware": "generate_middleware",
|
|
2212
|
+
}
|
|
2213
|
+
fn_name = generator_map.get(kind)
|
|
2214
|
+
if fn_name is None:
|
|
2215
|
+
return response({"ok": False, "error": f"unknown scaffold kind: {kind}"}, 400)
|
|
2216
|
+
|
|
2217
|
+
try:
|
|
2218
|
+
from tina4_python import cli as cli_module
|
|
2219
|
+
fn = getattr(cli_module, fn_name, None)
|
|
2220
|
+
if fn is None:
|
|
2221
|
+
# Fall back to shelling out — keeps the endpoint useful
|
|
2222
|
+
# even if the generator function names drift.
|
|
2223
|
+
import subprocess
|
|
2224
|
+
cp = subprocess.run(
|
|
2225
|
+
["tina4python", "generate", kind, name],
|
|
2226
|
+
capture_output=True, text=True, timeout=30,
|
|
2227
|
+
)
|
|
2228
|
+
if cp.returncode != 0:
|
|
2229
|
+
return response({"ok": False, "error": cp.stderr.strip() or cp.stdout.strip()}, 500)
|
|
2230
|
+
return response({"ok": True, "output": cp.stdout.strip()})
|
|
2231
|
+
|
|
2232
|
+
# Invoke the generator directly. The CLI functions typically
|
|
2233
|
+
# print to stdout + write files; we don't capture their
|
|
2234
|
+
# output here — the file tree will refresh and show the new
|
|
2235
|
+
# files, which is what the user actually cares about.
|
|
2236
|
+
result = fn(name) if fn.__code__.co_argcount == 1 else fn(name, None)
|
|
2237
|
+
|
|
2238
|
+
# Most generators return a path or list of paths; normalise.
|
|
2239
|
+
files: list[str] = []
|
|
2240
|
+
if isinstance(result, str):
|
|
2241
|
+
files = [result]
|
|
2242
|
+
elif isinstance(result, list):
|
|
2243
|
+
files = [str(p) for p in result]
|
|
2244
|
+
return response({"ok": True, "kind": kind, "name": name, "files": files})
|
|
2245
|
+
except Exception as exc:
|
|
2246
|
+
return response({"ok": False, "error": str(exc)}, 500)
|
|
2247
|
+
|
|
2248
|
+
|
|
2059
2249
|
__all__ = ["MessageLog", "RequestInspector", "BrokenTracker",
|
|
2060
2250
|
"get_api_handlers", "render_dev_toolbar"]
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""Project plan management — persistent, human-readable task state.
|
|
2
|
+
|
|
3
|
+
A "plan" is a markdown file under ``plan/`` at the project root with a
|
|
4
|
+
lightweight structure the AI (and the human) can both read and edit:
|
|
5
|
+
|
|
6
|
+
# Add user authentication
|
|
7
|
+
|
|
8
|
+
Goal: JWT login/registration with refresh tokens.
|
|
9
|
+
|
|
10
|
+
## Steps
|
|
11
|
+
|
|
12
|
+
- [x] Create User model in src/orm/User.py
|
|
13
|
+
- [x] Add auth middleware in src/app/middleware.py
|
|
14
|
+
- [ ] Add /api/login route
|
|
15
|
+
- [ ] Add /api/register route
|
|
16
|
+
|
|
17
|
+
## Notes
|
|
18
|
+
|
|
19
|
+
Use tina4_python.auth.get_token, 60-minute tokens.
|
|
20
|
+
|
|
21
|
+
At any moment exactly one plan is "current" — recorded by the filename
|
|
22
|
+
stored in ``plan/.current``. That plan's contents are injected into
|
|
23
|
+
every chat turn's system prompt so the AI keeps working on the same
|
|
24
|
+
thing across conversations, and the step list gives a clear "done /
|
|
25
|
+
not done" snapshot.
|
|
26
|
+
|
|
27
|
+
This module is the storage + manipulation layer. MCP tools in
|
|
28
|
+
``tina4_python.mcp.tools`` expose it to the AI; the dev-admin UI surfaces
|
|
29
|
+
it to the human.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import re
|
|
37
|
+
import time
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
PLAN_DIR = "plan"
|
|
41
|
+
CURRENT_FILE = ".current"
|
|
42
|
+
ARCHIVE_SUBDIR = "done"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _project_root() -> Path:
|
|
46
|
+
return Path(os.getcwd()).resolve()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _plan_dir() -> Path:
|
|
50
|
+
p = _project_root() / PLAN_DIR
|
|
51
|
+
p.mkdir(exist_ok=True)
|
|
52
|
+
return p
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _current_pointer() -> Path:
|
|
56
|
+
return _plan_dir() / CURRENT_FILE
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _archive_dir() -> Path:
|
|
60
|
+
p = _plan_dir() / ARCHIVE_SUBDIR
|
|
61
|
+
p.mkdir(exist_ok=True)
|
|
62
|
+
return p
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _slugify(title: str) -> str:
|
|
66
|
+
"""File-system-safe filename stem for a plan title."""
|
|
67
|
+
slug = re.sub(r"[^A-Za-z0-9_-]+", "-", title.strip().lower()).strip("-")
|
|
68
|
+
return slug[:80] or f"plan-{int(time.time())}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ── Plan file shape ──────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
_STEP_RE = re.compile(r"^\s*[-*]\s*\[(?P<box>[ xX])\]\s*(?P<text>.+?)\s*$")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _parse(text: str) -> dict:
|
|
77
|
+
"""Parse a plan markdown file into a structured dict.
|
|
78
|
+
|
|
79
|
+
The parser is tolerant — missing sections are fine; unknown sections
|
|
80
|
+
are preserved verbatim under ``extra``. We only pick apart what we
|
|
81
|
+
need to manipulate programmatically (title, goal, steps).
|
|
82
|
+
"""
|
|
83
|
+
lines = text.splitlines()
|
|
84
|
+
title = ""
|
|
85
|
+
goal = ""
|
|
86
|
+
steps: list = []
|
|
87
|
+
notes_lines: list = []
|
|
88
|
+
section = None # None | "steps" | "notes"
|
|
89
|
+
|
|
90
|
+
for raw in lines:
|
|
91
|
+
line = raw.rstrip()
|
|
92
|
+
if not title and line.startswith("# "):
|
|
93
|
+
title = line[2:].strip()
|
|
94
|
+
continue
|
|
95
|
+
low = line.strip().lower()
|
|
96
|
+
if low.startswith("goal:") and not goal:
|
|
97
|
+
goal = line.split(":", 1)[1].strip()
|
|
98
|
+
continue
|
|
99
|
+
if low == "## steps":
|
|
100
|
+
section = "steps"
|
|
101
|
+
continue
|
|
102
|
+
if low == "## notes":
|
|
103
|
+
section = "notes"
|
|
104
|
+
continue
|
|
105
|
+
if line.startswith("## "):
|
|
106
|
+
section = "other"
|
|
107
|
+
continue
|
|
108
|
+
if section == "steps":
|
|
109
|
+
m = _STEP_RE.match(line)
|
|
110
|
+
if m:
|
|
111
|
+
steps.append({
|
|
112
|
+
"text": m.group("text").strip(),
|
|
113
|
+
"done": m.group("box").lower() == "x",
|
|
114
|
+
})
|
|
115
|
+
elif section == "notes" and line.strip():
|
|
116
|
+
notes_lines.append(line)
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
"title": title,
|
|
120
|
+
"goal": goal,
|
|
121
|
+
"steps": steps,
|
|
122
|
+
"notes": "\n".join(notes_lines).strip(),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _render(plan: dict) -> str:
|
|
127
|
+
"""Serialise a parsed dict back to canonical markdown."""
|
|
128
|
+
parts: list = [f"# {plan.get('title', 'Untitled plan')}", ""]
|
|
129
|
+
goal = plan.get("goal")
|
|
130
|
+
if goal:
|
|
131
|
+
parts += [f"Goal: {goal}", ""]
|
|
132
|
+
parts.append("## Steps")
|
|
133
|
+
parts.append("")
|
|
134
|
+
for step in plan.get("steps", []):
|
|
135
|
+
box = "x" if step.get("done") else " "
|
|
136
|
+
parts.append(f"- [{box}] {step.get('text', '').strip()}")
|
|
137
|
+
notes = (plan.get("notes") or "").strip()
|
|
138
|
+
if notes:
|
|
139
|
+
parts += ["", "## Notes", "", notes]
|
|
140
|
+
return "\n".join(parts) + "\n"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ── Public API ───────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def list_plans() -> list:
|
|
147
|
+
"""All plan files at the root of plan/ (excludes done/)."""
|
|
148
|
+
d = _plan_dir()
|
|
149
|
+
current = current_name() or ""
|
|
150
|
+
out: list = []
|
|
151
|
+
for p in sorted(d.glob("*.md")):
|
|
152
|
+
parsed = _parse(p.read_text(encoding="utf-8", errors="replace"))
|
|
153
|
+
total = len(parsed["steps"])
|
|
154
|
+
done = sum(1 for s in parsed["steps"] if s["done"])
|
|
155
|
+
out.append({
|
|
156
|
+
"name": p.name,
|
|
157
|
+
"title": parsed["title"] or p.stem,
|
|
158
|
+
"steps_total": total,
|
|
159
|
+
"steps_done": done,
|
|
160
|
+
"is_current": p.name == current,
|
|
161
|
+
})
|
|
162
|
+
return out
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def current_name() -> str:
|
|
166
|
+
"""Filename of the current plan, or '' when none is set."""
|
|
167
|
+
ptr = _current_pointer()
|
|
168
|
+
if not ptr.exists():
|
|
169
|
+
return ""
|
|
170
|
+
return ptr.read_text(encoding="utf-8").strip()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def set_current(name: str) -> dict:
|
|
174
|
+
"""Point .current at the given plan file. Creates plan/ if needed."""
|
|
175
|
+
name = name.strip()
|
|
176
|
+
if not name.endswith(".md"):
|
|
177
|
+
name += ".md"
|
|
178
|
+
plan_path = _plan_dir() / name
|
|
179
|
+
if not plan_path.exists():
|
|
180
|
+
return {"ok": False, "error": f"No such plan: {name}"}
|
|
181
|
+
_current_pointer().write_text(name, encoding="utf-8")
|
|
182
|
+
return {"ok": True, "current": name}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def clear_current() -> dict:
|
|
186
|
+
"""Unset the current plan (e.g. when everything's done and nothing's queued)."""
|
|
187
|
+
p = _current_pointer()
|
|
188
|
+
if p.exists():
|
|
189
|
+
p.unlink()
|
|
190
|
+
return {"ok": True}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def current() -> dict:
|
|
194
|
+
"""The whole current plan: title, goal, steps (with index + done), notes,
|
|
195
|
+
and a cheap 'next' pointer to the first unchecked step. Returns
|
|
196
|
+
{current: null} when no plan is active — the AI treats that as
|
|
197
|
+
'no specific plan, proceed normally'."""
|
|
198
|
+
name = current_name()
|
|
199
|
+
if not name:
|
|
200
|
+
return {"current": None}
|
|
201
|
+
path = _plan_dir() / name
|
|
202
|
+
if not path.exists():
|
|
203
|
+
# Pointer is dangling — clean up, report.
|
|
204
|
+
clear_current()
|
|
205
|
+
return {"current": None, "warning": f"Current pointer referenced missing file: {name}"}
|
|
206
|
+
parsed = _parse(path.read_text(encoding="utf-8", errors="replace"))
|
|
207
|
+
indexed_steps = [
|
|
208
|
+
{"index": i, "text": s["text"], "done": s["done"]}
|
|
209
|
+
for i, s in enumerate(parsed["steps"])
|
|
210
|
+
]
|
|
211
|
+
next_step = next((s for s in indexed_steps if not s["done"]), None)
|
|
212
|
+
return {
|
|
213
|
+
"current": name,
|
|
214
|
+
"title": parsed["title"],
|
|
215
|
+
"goal": parsed["goal"],
|
|
216
|
+
"steps": indexed_steps,
|
|
217
|
+
"next_step": next_step,
|
|
218
|
+
"notes": parsed["notes"],
|
|
219
|
+
"progress": {
|
|
220
|
+
"done": sum(1 for s in indexed_steps if s["done"]),
|
|
221
|
+
"total": len(indexed_steps),
|
|
222
|
+
},
|
|
223
|
+
# Attach the execution summary so callers get the full picture
|
|
224
|
+
# in one call — no second round-trip needed just to find out
|
|
225
|
+
# which files this plan has already touched.
|
|
226
|
+
"execution": summarise_execution(name),
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def read(name: str) -> dict:
|
|
231
|
+
"""Full structured view of any plan by filename."""
|
|
232
|
+
if not name.endswith(".md"):
|
|
233
|
+
name += ".md"
|
|
234
|
+
path = _plan_dir() / name
|
|
235
|
+
if not path.exists():
|
|
236
|
+
return {"error": f"No such plan: {name}"}
|
|
237
|
+
return _parse(path.read_text(encoding="utf-8", errors="replace")) | {"name": name}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def create(title: str, goal: str = "", steps: list | None = None, make_current: bool = True) -> dict:
|
|
241
|
+
"""Write a new plan file and (by default) make it the active one.
|
|
242
|
+
``steps`` is a list of strings — all start unchecked."""
|
|
243
|
+
title = (title or "").strip()
|
|
244
|
+
if not title:
|
|
245
|
+
return {"ok": False, "error": "title is required"}
|
|
246
|
+
stem = _slugify(title)
|
|
247
|
+
name = f"{stem}.md"
|
|
248
|
+
path = _plan_dir() / name
|
|
249
|
+
if path.exists():
|
|
250
|
+
return {"ok": False, "error": f"Plan already exists: {name}. Pick a different title or edit the existing one."}
|
|
251
|
+
plan = {
|
|
252
|
+
"title": title,
|
|
253
|
+
"goal": goal.strip(),
|
|
254
|
+
"steps": [{"text": s.strip(), "done": False} for s in (steps or []) if s.strip()],
|
|
255
|
+
"notes": "",
|
|
256
|
+
}
|
|
257
|
+
path.write_text(_render(plan), encoding="utf-8")
|
|
258
|
+
if make_current:
|
|
259
|
+
_current_pointer().write_text(name, encoding="utf-8")
|
|
260
|
+
return {"ok": True, "name": name, "title": title, "is_current": make_current}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _load_parsed(name: str) -> tuple[Path, dict]:
|
|
264
|
+
if not name.endswith(".md"):
|
|
265
|
+
name += ".md"
|
|
266
|
+
path = _plan_dir() / name
|
|
267
|
+
if not path.exists():
|
|
268
|
+
raise FileNotFoundError(f"No such plan: {name}")
|
|
269
|
+
return path, _parse(path.read_text(encoding="utf-8", errors="replace"))
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def complete_step(index: int, name: str = "") -> dict:
|
|
273
|
+
"""Tick a step (by 0-based index) on the current plan (or a named one).
|
|
274
|
+
Returns the updated plan summary + what's next."""
|
|
275
|
+
name = name or current_name()
|
|
276
|
+
if not name:
|
|
277
|
+
return {"ok": False, "error": "No current plan and no name given"}
|
|
278
|
+
try:
|
|
279
|
+
path, plan = _load_parsed(name)
|
|
280
|
+
except FileNotFoundError as e:
|
|
281
|
+
return {"ok": False, "error": str(e)}
|
|
282
|
+
if not 0 <= index < len(plan["steps"]):
|
|
283
|
+
return {"ok": False, "error": f"Step index {index} out of range (0..{len(plan['steps']) - 1})"}
|
|
284
|
+
plan["steps"][index]["done"] = True
|
|
285
|
+
path.write_text(_render(plan), encoding="utf-8")
|
|
286
|
+
remaining = [i for i, s in enumerate(plan["steps"]) if not s["done"]]
|
|
287
|
+
return {
|
|
288
|
+
"ok": True,
|
|
289
|
+
"completed": plan["steps"][index]["text"],
|
|
290
|
+
"remaining": len(remaining),
|
|
291
|
+
"next_step": plan["steps"][remaining[0]]["text"] if remaining else None,
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def uncomplete_step(index: int, name: str = "") -> dict:
|
|
296
|
+
"""Uncheck a step — useful when a change turns out to have regressed."""
|
|
297
|
+
name = name or current_name()
|
|
298
|
+
if not name:
|
|
299
|
+
return {"ok": False, "error": "No current plan and no name given"}
|
|
300
|
+
try:
|
|
301
|
+
path, plan = _load_parsed(name)
|
|
302
|
+
except FileNotFoundError as e:
|
|
303
|
+
return {"ok": False, "error": str(e)}
|
|
304
|
+
if not 0 <= index < len(plan["steps"]):
|
|
305
|
+
return {"ok": False, "error": f"Step index {index} out of range"}
|
|
306
|
+
plan["steps"][index]["done"] = False
|
|
307
|
+
path.write_text(_render(plan), encoding="utf-8")
|
|
308
|
+
return {"ok": True, "step": plan["steps"][index]["text"]}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def add_step(text: str, name: str = "") -> dict:
|
|
312
|
+
"""Append a new unchecked step."""
|
|
313
|
+
text = (text or "").strip()
|
|
314
|
+
if not text:
|
|
315
|
+
return {"ok": False, "error": "text is required"}
|
|
316
|
+
name = name or current_name()
|
|
317
|
+
if not name:
|
|
318
|
+
return {"ok": False, "error": "No current plan and no name given"}
|
|
319
|
+
try:
|
|
320
|
+
path, plan = _load_parsed(name)
|
|
321
|
+
except FileNotFoundError as e:
|
|
322
|
+
return {"ok": False, "error": str(e)}
|
|
323
|
+
plan["steps"].append({"text": text, "done": False})
|
|
324
|
+
path.write_text(_render(plan), encoding="utf-8")
|
|
325
|
+
return {"ok": True, "step": text, "index": len(plan["steps"]) - 1}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def append_note(text: str, name: str = "") -> dict:
|
|
329
|
+
"""Add a line to the plan's Notes section — great for 'here's a
|
|
330
|
+
gotcha I hit' breadcrumbs the AI drops as it works."""
|
|
331
|
+
text = (text or "").strip()
|
|
332
|
+
if not text:
|
|
333
|
+
return {"ok": False, "error": "text is required"}
|
|
334
|
+
name = name or current_name()
|
|
335
|
+
if not name:
|
|
336
|
+
return {"ok": False, "error": "No current plan and no name given"}
|
|
337
|
+
try:
|
|
338
|
+
path, plan = _load_parsed(name)
|
|
339
|
+
except FileNotFoundError as e:
|
|
340
|
+
return {"ok": False, "error": str(e)}
|
|
341
|
+
existing = (plan.get("notes") or "").strip()
|
|
342
|
+
stamp = time.strftime("%Y-%m-%d %H:%M")
|
|
343
|
+
plan["notes"] = (existing + f"\n- [{stamp}] {text}").strip()
|
|
344
|
+
path.write_text(_render(plan), encoding="utf-8")
|
|
345
|
+
return {"ok": True, "appended": text}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# ── Execution ledger — "what has this plan touched so far?" ──────────
|
|
349
|
+
#
|
|
350
|
+
# Every file_write / file_patch / migration_create that lands while a
|
|
351
|
+
# plan is current gets recorded here. The AI reads the *summary* at the
|
|
352
|
+
# top of each chat turn (via `summarise_execution()`) so it doesn't
|
|
353
|
+
# re-create files it already made — that's the "two migrations for the
|
|
354
|
+
# same table" bug we saw.
|
|
355
|
+
#
|
|
356
|
+
# Stored next to the plan file as `<plan-stem>.log.json` to keep all
|
|
357
|
+
# plan state in one place. Not committed (plan/.gitignore covers it).
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _ledger_path(name: str = "") -> Path | None:
|
|
361
|
+
name = name or current_name()
|
|
362
|
+
if not name:
|
|
363
|
+
return None
|
|
364
|
+
if not name.endswith(".md"):
|
|
365
|
+
name += ".md"
|
|
366
|
+
return _plan_dir() / f"{name[:-3]}.log.json"
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def record_action(action: str, path: str, note: str = "") -> None:
|
|
370
|
+
"""Append an entry to the current plan's ledger. Silently no-ops
|
|
371
|
+
when no plan is active — tools call this unconditionally."""
|
|
372
|
+
lp = _ledger_path()
|
|
373
|
+
if lp is None:
|
|
374
|
+
return
|
|
375
|
+
entries: list = []
|
|
376
|
+
if lp.exists():
|
|
377
|
+
try:
|
|
378
|
+
entries = json.loads(lp.read_text(encoding="utf-8"))
|
|
379
|
+
except (OSError, json.JSONDecodeError):
|
|
380
|
+
entries = []
|
|
381
|
+
entries.append({
|
|
382
|
+
"t": int(time.time()),
|
|
383
|
+
"action": action, # "created" | "patched" | "migration"
|
|
384
|
+
"path": path,
|
|
385
|
+
"note": note,
|
|
386
|
+
})
|
|
387
|
+
# Bound the log so it can't grow forever on long-running plans.
|
|
388
|
+
if len(entries) > 500:
|
|
389
|
+
entries = entries[-500:]
|
|
390
|
+
try:
|
|
391
|
+
lp.write_text(json.dumps(entries, indent=2), encoding="utf-8")
|
|
392
|
+
except OSError:
|
|
393
|
+
pass # ledger is best-effort
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def summarise_execution(name: str = "") -> dict:
|
|
397
|
+
"""Grouped view of what the current plan has touched — small
|
|
398
|
+
enough to inline into every system prompt. Groups:
|
|
399
|
+
- `created`: files written for the first time
|
|
400
|
+
- `patched`: files edited
|
|
401
|
+
- `migrations`: migration files generated
|
|
402
|
+
Each bucket is de-duped and capped at 20 paths."""
|
|
403
|
+
lp = _ledger_path(name)
|
|
404
|
+
if lp is None or not lp.exists():
|
|
405
|
+
return {"created": [], "patched": [], "migrations": [], "total": 0}
|
|
406
|
+
try:
|
|
407
|
+
entries = json.loads(lp.read_text(encoding="utf-8"))
|
|
408
|
+
except (OSError, json.JSONDecodeError):
|
|
409
|
+
return {"created": [], "patched": [], "migrations": [], "total": 0}
|
|
410
|
+
|
|
411
|
+
created: list[str] = []
|
|
412
|
+
patched: list[str] = []
|
|
413
|
+
migrations: list[str] = []
|
|
414
|
+
for e in entries:
|
|
415
|
+
action = e.get("action")
|
|
416
|
+
path = e.get("path")
|
|
417
|
+
if not path:
|
|
418
|
+
continue
|
|
419
|
+
bucket = (
|
|
420
|
+
migrations if action == "migration"
|
|
421
|
+
else created if action == "created"
|
|
422
|
+
else patched if action == "patched"
|
|
423
|
+
else None
|
|
424
|
+
)
|
|
425
|
+
if bucket is not None and path not in bucket:
|
|
426
|
+
bucket.append(path)
|
|
427
|
+
return {
|
|
428
|
+
"created": created[-20:],
|
|
429
|
+
"patched": patched[-20:],
|
|
430
|
+
"migrations": migrations[-20:],
|
|
431
|
+
"total": len(entries),
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def archive(name: str = "") -> dict:
|
|
436
|
+
"""Move a plan to plan/done/ and clear the current pointer if it
|
|
437
|
+
was pointing at this plan. A no-op + warning if it's already
|
|
438
|
+
archived."""
|
|
439
|
+
name = name or current_name()
|
|
440
|
+
if not name:
|
|
441
|
+
return {"ok": False, "error": "No current plan and no name given"}
|
|
442
|
+
if not name.endswith(".md"):
|
|
443
|
+
name += ".md"
|
|
444
|
+
src = _plan_dir() / name
|
|
445
|
+
if not src.exists():
|
|
446
|
+
return {"ok": False, "error": f"No such plan: {name}"}
|
|
447
|
+
dest = _archive_dir() / name
|
|
448
|
+
# Handle name collisions — prefix with timestamp on conflict
|
|
449
|
+
if dest.exists():
|
|
450
|
+
dest = _archive_dir() / f"{int(time.time())}-{name}"
|
|
451
|
+
src.rename(dest)
|
|
452
|
+
if current_name() == name:
|
|
453
|
+
clear_current()
|
|
454
|
+
return {"ok": True, "archived_to": str(dest.relative_to(_project_root()))}
|