From 98bf0dfc281186f374acd08b57945580dba642c6 Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Wed, 1 Jul 2026 12:19:14 +0800 Subject: [PATCH 01/68] feat: decode zstd request bodies --- middleware/app.py | 40 ++++++++++++++++++++++++++- middleware/creds.py | 3 ++ pyproject.toml | 1 + tests/test_middleware.py | 25 +++++++++++++++-- uv.lock | 59 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 125 insertions(+), 3 deletions(-) diff --git a/middleware/app.py b/middleware/app.py index 5c0c3ac..45f890f 100644 --- a/middleware/app.py +++ b/middleware/app.py @@ -7,11 +7,13 @@ from __future__ import annotations import contextlib +import io import json import logging from typing import Any import httpx +import zstandard as zstd from starlette.applications import Starlette from starlette.requests import Request from starlette.responses import JSONResponse, Response, StreamingResponse @@ -31,6 +33,24 @@ log = logging.getLogger("middleware.app") +class BodyDecodeError(ValueError): + pass + + +def _decode_request_body(raw: bytes, encoding: str | None) -> bytes: + enc = (encoding or "").strip().lower() + if not enc or enc == "identity": + return raw + if enc != "zstd": + raise BodyDecodeError(f"unsupported request content-encoding: {enc}") + + try: + with zstd.ZstdDecompressor().stream_reader(io.BytesIO(raw)) as reader: + return reader.read() + except zstd.ZstdError as exc: + raise BodyDecodeError("invalid zstd request body") from exc + + def _header_base(request: Request) -> str | None: """The non-blank Responses-API-Base header value, or None (case-insensitive).""" v = request.headers.get("responses-api-base") @@ -96,10 +116,28 @@ async def handle_responses(request: Request) -> Response: cfg: Config = request.app.state.cfg client: httpx.AsyncClient = request.app.state.client - raw = await request.body() + wire_raw = await request.body() + try: + raw = _decode_request_body(wire_raw, request.headers.get("content-encoding")) + except BodyDecodeError as exc: + log.warning( + "request body decode failed: content-type=%s content-encoding=%s len=%d error=%s", + request.headers.get("content-type"), + request.headers.get("content-encoding"), + len(wire_raw), + exc, + ) + return JSONResponse({"error": str(exc)}, status_code=400) + try: body: dict[str, Any] = json.loads(raw) except (json.JSONDecodeError, UnicodeDecodeError): + log.warning( + "invalid JSON body: content-type=%s content-encoding=%s len=%d", + request.headers.get("content-type"), + request.headers.get("content-encoding"), + len(raw), + ) return JSONResponse({"error": "invalid JSON body"}, status_code=400) if not isinstance(body, dict): return JSONResponse({"error": "body must be a JSON object"}, status_code=400) diff --git a/middleware/creds.py b/middleware/creds.py index 0c261ac..88e9251 100644 --- a/middleware/creds.py +++ b/middleware/creds.py @@ -5,6 +5,8 @@ its own). Two exceptions: 1. client-owned headers (Host, Content-Length, ...) are dropped so httpx sets them correctly — the body length changes when we merge `include`. + Content-Encoding is also dropped because encoded agent bodies are decoded + before the middleware inspects or forwards them. 2. credentials (Authorization, chatgpt-account-id) follow the auth mode, with the token / account id supplied directly from config.toml `[auth]`. """ @@ -25,6 +27,7 @@ "proxy-connection", "transfer-encoding", "accept-encoding", + "content-encoding", } _AUTH = "authorization" diff --git a/pyproject.toml b/pyproject.toml index 9ab4732..99553fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ dependencies = [ "httpx>=0.27", "starlette>=0.37", "uvicorn>=0.30", + "zstandard>=0.23", ] [dependency-groups] diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 216605a..5e18892 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -16,9 +16,15 @@ FIXTURES = Path(__file__).resolve().parent / "fixtures" sys.path.insert(0, str(ROOT)) +import zstandard as zstd from starlette.datastructures import Headers -from middleware.app import _make_client, _resolve_upstream_url, _url_is_from_header +from middleware.app import ( + _decode_request_body, + _make_client, + _resolve_upstream_url, + _url_is_from_header, +) from middleware.codex import ( continue_call_id, is_truncation_pattern, @@ -391,6 +397,7 @@ def test_header_transparency(): ("User-Agent", "codex_cli_rs/1.0"), ("Host", "drop.me"), ("Content-Length", "123"), + ("Content-Encoding", "zstd"), ("Accept-Encoding", "gzip"), ("Responses-API-Base", "https://override/responses"), ("X-Custom", "keep"), @@ -401,10 +408,23 @@ def test_header_transparency(): check("hdr keeps user-agent", low.get("user-agent") == "codex_cli_rs/1.0") check("hdr keeps custom", low.get("x-custom") == "keep") check("hdr keeps authorization", low.get("authorization") == "Bearer agent") - for dropped in ("host", "content-length", "accept-encoding", "responses-api-base"): + for dropped in ( + "host", + "content-length", + "content-encoding", + "accept-encoding", + "responses-api-base", + ): check(f"hdr drops {dropped}", dropped not in low) +def test_zstd_request_body_decode(): + raw = b'{"model":"gpt-5.5","stream":true}' + encoded = zstd.ZstdCompressor().compress(raw) + check("zstd body decodes", _decode_request_body(encoded, "zstd") == raw) + check("identity body unchanged", _decode_request_body(raw, None) == raw) + + # --- upstream URL resolution via Responses-API-Base header ------------------ @@ -623,6 +643,7 @@ async def _main(): await test_tool_pair_continuation_payload() await test_forward_marker_emits_downstream() test_header_transparency() + test_zstd_request_body_decode() test_upstream_url_resolution() test_auth_safety_guard() test_auth_injection() diff --git a/uv.lock b/uv.lock index f36cfb0..a12d7e4 100644 --- a/uv.lock +++ b/uv.lock @@ -53,6 +53,7 @@ dependencies = [ { name = "httpx" }, { name = "starlette" }, { name = "uvicorn" }, + { name = "zstandard" }, ] [package.metadata] @@ -60,6 +61,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27" }, { name = "starlette", specifier = ">=0.37" }, { name = "uvicorn", specifier = ">=0.30" }, + { name = "zstandard", specifier = ">=0.23" }, ] [package.metadata.requires-dev] @@ -145,3 +147,60 @@ sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069 wheels = [ { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, ] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, +] From aa113c223e253cdfc706d0adc367af67e7d175ec Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Wed, 1 Jul 2026 12:19:59 +0800 Subject: [PATCH 02/68] chore: add Trellis workflow and CPA migration record --- .agents/skills/trellis-before-dev/SKILL.md | 40 + .agents/skills/trellis-brainstorm/SKILL.md | 112 +++ .agents/skills/trellis-break-loop/SKILL.md | 130 +++ .agents/skills/trellis-check/SKILL.md | 98 +++ .agents/skills/trellis-continue/SKILL.md | 61 ++ .agents/skills/trellis-finish-work/SKILL.md | 71 ++ .agents/skills/trellis-meta/SKILL.md | 73 ++ .../add-project-local-conventions.md | 83 ++ .../customize-local/change-agents.md | 54 ++ .../customize-local/change-context-loading.md | 84 ++ .../customize-local/change-hooks.md | 57 ++ .../change-skills-or-commands.md | 78 ++ .../customize-local/change-spec-structure.md | 83 ++ .../customize-local/change-task-lifecycle.md | 90 ++ .../customize-local/change-workflow.md | 65 ++ .../references/customize-local/overview.md | 55 ++ .../local-architecture/context-injection.md | 68 ++ .../local-architecture/generated-files.md | 80 ++ .../references/local-architecture/overview.md | 51 ++ .../local-architecture/spec-system.md | 102 +++ .../local-architecture/task-system.md | 130 +++ .../references/local-architecture/workflow.md | 75 ++ .../local-architecture/workspace-memory.md | 71 ++ .../references/platform-files/agents.md | 80 ++ .../platform-files/hooks-and-settings.md | 69 ++ .../references/platform-files/overview.md | 59 ++ .../references/platform-files/platform-map.md | 74 ++ .../platform-files/skills-and-commands.md | 83 ++ .../skills/trellis-session-insight/SKILL.md | 81 ++ .../references/cli-quick-reference.md | 66 ++ .../references/triggering-patterns.md | 93 ++ .../skills/trellis-spec-bootstrap/SKILL.md | 41 + .../references/mcp-setup.md | 90 ++ .../references/repository-analysis.md | 59 ++ .../references/spec-task-planning.md | 61 ++ .../references/spec-writing.md | 70 ++ .agents/skills/trellis-start/SKILL.md | 64 ++ .agents/skills/trellis-update-spec/SKILL.md | 356 ++++++++ .claude/agents/trellis-check.md | 115 +++ .claude/agents/trellis-implement.md | 110 +++ .claude/agents/trellis-research.md | 137 +++ .claude/commands/trellis/continue.md | 56 ++ .claude/commands/trellis/finish-work.md | 66 ++ .claude/hooks/inject-subagent-context.py | 771 ++++++++++++++++ .claude/hooks/inject-workflow-state.py | 363 ++++++++ .claude/hooks/session-start.py | 831 ++++++++++++++++++ .claude/settings.json | 73 ++ .claude/skills/trellis-before-dev/SKILL.md | 40 + .claude/skills/trellis-brainstorm/SKILL.md | 112 +++ .claude/skills/trellis-break-loop/SKILL.md | 130 +++ .claude/skills/trellis-check/SKILL.md | 98 +++ .claude/skills/trellis-meta/SKILL.md | 73 ++ .../add-project-local-conventions.md | 83 ++ .../customize-local/change-agents.md | 54 ++ .../customize-local/change-context-loading.md | 84 ++ .../customize-local/change-hooks.md | 57 ++ .../change-skills-or-commands.md | 78 ++ .../customize-local/change-spec-structure.md | 83 ++ .../customize-local/change-task-lifecycle.md | 90 ++ .../customize-local/change-workflow.md | 65 ++ .../references/customize-local/overview.md | 55 ++ .../local-architecture/context-injection.md | 68 ++ .../local-architecture/generated-files.md | 80 ++ .../references/local-architecture/overview.md | 51 ++ .../local-architecture/spec-system.md | 102 +++ .../local-architecture/task-system.md | 130 +++ .../references/local-architecture/workflow.md | 75 ++ .../local-architecture/workspace-memory.md | 71 ++ .../references/platform-files/agents.md | 80 ++ .../platform-files/hooks-and-settings.md | 69 ++ .../references/platform-files/overview.md | 59 ++ .../references/platform-files/platform-map.md | 74 ++ .../platform-files/skills-and-commands.md | 83 ++ .../skills/trellis-session-insight/SKILL.md | 81 ++ .../references/cli-quick-reference.md | 66 ++ .../references/triggering-patterns.md | 93 ++ .../skills/trellis-spec-bootstrap/SKILL.md | 41 + .../references/mcp-setup.md | 90 ++ .../references/repository-analysis.md | 59 ++ .../references/spec-task-planning.md | 61 ++ .../references/spec-writing.md | 70 ++ .claude/skills/trellis-update-spec/SKILL.md | 356 ++++++++ .codex/agents/trellis-check.toml | 84 ++ .codex/agents/trellis-implement.toml | 65 ++ .codex/agents/trellis-research.toml | 73 ++ .codex/hooks.json | 15 + .codex/hooks/inject-workflow-state.py | 363 ++++++++ .codex/hooks/session-start.py | 545 ++++++++++++ .trellis/.gitignore | 32 + .trellis/.template-hashes.json | 127 +++ .trellis/.version | 1 + .trellis/agents/check.md | 70 ++ .trellis/agents/implement.md | 71 ++ .trellis/config.yaml | 110 +++ .trellis/scripts/__init__.py | 5 + .trellis/scripts/add_session.py | 547 ++++++++++++ .trellis/scripts/common/__init__.py | 92 ++ .trellis/scripts/common/active_task.py | 626 +++++++++++++ .trellis/scripts/common/cli_adapter.py | 811 +++++++++++++++++ .trellis/scripts/common/config.py | 445 ++++++++++ .trellis/scripts/common/developer.py | 190 ++++ .trellis/scripts/common/git.py | 31 + .trellis/scripts/common/git_context.py | 106 +++ .trellis/scripts/common/io.py | 37 + .trellis/scripts/common/log.py | 45 + .trellis/scripts/common/packages_context.py | 238 +++++ .trellis/scripts/common/paths.py | 447 ++++++++++ .trellis/scripts/common/safe_commit.py | 285 ++++++ .trellis/scripts/common/session_context.py | 821 +++++++++++++++++ .trellis/scripts/common/task_context.py | 223 +++++ .trellis/scripts/common/task_queue.py | 188 ++++ .trellis/scripts/common/task_store.py | 746 ++++++++++++++++ .trellis/scripts/common/task_utils.py | 274 ++++++ .trellis/scripts/common/tasks.py | 112 +++ .trellis/scripts/common/trellis_config.py | 131 +++ .trellis/scripts/common/types.py | 110 +++ .trellis/scripts/common/workflow_phase.py | 212 +++++ .trellis/scripts/get_context.py | 16 + .trellis/scripts/get_developer.py | 26 + .trellis/scripts/hooks/linear_sync.py | 243 +++++ .trellis/scripts/init_developer.py | 51 ++ .trellis/scripts/task.py | 500 +++++++++++ .../backend/codex-continuation-contracts.md | 67 ++ .trellis/spec/backend/database-guidelines.md | 51 ++ .trellis/spec/backend/directory-structure.md | 54 ++ .trellis/spec/backend/error-handling.md | 51 ++ .trellis/spec/backend/index.md | 39 + .trellis/spec/backend/logging-guidelines.md | 51 ++ .trellis/spec/backend/quality-guidelines.md | 51 ++ .../spec/guides/code-reuse-thinking-guide.md | 223 +++++ .../spec/guides/cross-layer-thinking-guide.md | 327 +++++++ .trellis/spec/guides/index.md | 97 ++ .trellis/tasks/00-bootstrap-guidelines/prd.md | 126 +++ .../tasks/00-bootstrap-guidelines/task.json | 28 + .../check.jsonl | 1 + .../design.md | 53 ++ .../implement.jsonl | 1 + .../implement.md | 75 ++ .../prd.md | 45 + .../task.json | 26 + .trellis/workflow.md | 710 +++++++++++++++ .trellis/workspace/dxt98/index.md | 40 + .trellis/workspace/dxt98/journal-1.md | 7 + .trellis/workspace/index.md | 125 +++ AGENTS.md | 21 + 145 files changed, 20023 insertions(+) create mode 100644 .agents/skills/trellis-before-dev/SKILL.md create mode 100644 .agents/skills/trellis-brainstorm/SKILL.md create mode 100644 .agents/skills/trellis-break-loop/SKILL.md create mode 100644 .agents/skills/trellis-check/SKILL.md create mode 100644 .agents/skills/trellis-continue/SKILL.md create mode 100644 .agents/skills/trellis-finish-work/SKILL.md create mode 100644 .agents/skills/trellis-meta/SKILL.md create mode 100644 .agents/skills/trellis-meta/references/customize-local/add-project-local-conventions.md create mode 100644 .agents/skills/trellis-meta/references/customize-local/change-agents.md create mode 100644 .agents/skills/trellis-meta/references/customize-local/change-context-loading.md create mode 100644 .agents/skills/trellis-meta/references/customize-local/change-hooks.md create mode 100644 .agents/skills/trellis-meta/references/customize-local/change-skills-or-commands.md create mode 100644 .agents/skills/trellis-meta/references/customize-local/change-spec-structure.md create mode 100644 .agents/skills/trellis-meta/references/customize-local/change-task-lifecycle.md create mode 100644 .agents/skills/trellis-meta/references/customize-local/change-workflow.md create mode 100644 .agents/skills/trellis-meta/references/customize-local/overview.md create mode 100644 .agents/skills/trellis-meta/references/local-architecture/context-injection.md create mode 100644 .agents/skills/trellis-meta/references/local-architecture/generated-files.md create mode 100644 .agents/skills/trellis-meta/references/local-architecture/overview.md create mode 100644 .agents/skills/trellis-meta/references/local-architecture/spec-system.md create mode 100644 .agents/skills/trellis-meta/references/local-architecture/task-system.md create mode 100644 .agents/skills/trellis-meta/references/local-architecture/workflow.md create mode 100644 .agents/skills/trellis-meta/references/local-architecture/workspace-memory.md create mode 100644 .agents/skills/trellis-meta/references/platform-files/agents.md create mode 100644 .agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md create mode 100644 .agents/skills/trellis-meta/references/platform-files/overview.md create mode 100644 .agents/skills/trellis-meta/references/platform-files/platform-map.md create mode 100644 .agents/skills/trellis-meta/references/platform-files/skills-and-commands.md create mode 100644 .agents/skills/trellis-session-insight/SKILL.md create mode 100644 .agents/skills/trellis-session-insight/references/cli-quick-reference.md create mode 100644 .agents/skills/trellis-session-insight/references/triggering-patterns.md create mode 100644 .agents/skills/trellis-spec-bootstrap/SKILL.md create mode 100644 .agents/skills/trellis-spec-bootstrap/references/mcp-setup.md create mode 100644 .agents/skills/trellis-spec-bootstrap/references/repository-analysis.md create mode 100644 .agents/skills/trellis-spec-bootstrap/references/spec-task-planning.md create mode 100644 .agents/skills/trellis-spec-bootstrap/references/spec-writing.md create mode 100644 .agents/skills/trellis-start/SKILL.md create mode 100644 .agents/skills/trellis-update-spec/SKILL.md create mode 100644 .claude/agents/trellis-check.md create mode 100644 .claude/agents/trellis-implement.md create mode 100644 .claude/agents/trellis-research.md create mode 100644 .claude/commands/trellis/continue.md create mode 100644 .claude/commands/trellis/finish-work.md create mode 100644 .claude/hooks/inject-subagent-context.py create mode 100644 .claude/hooks/inject-workflow-state.py create mode 100644 .claude/hooks/session-start.py create mode 100644 .claude/settings.json create mode 100644 .claude/skills/trellis-before-dev/SKILL.md create mode 100644 .claude/skills/trellis-brainstorm/SKILL.md create mode 100644 .claude/skills/trellis-break-loop/SKILL.md create mode 100644 .claude/skills/trellis-check/SKILL.md create mode 100644 .claude/skills/trellis-meta/SKILL.md create mode 100644 .claude/skills/trellis-meta/references/customize-local/add-project-local-conventions.md create mode 100644 .claude/skills/trellis-meta/references/customize-local/change-agents.md create mode 100644 .claude/skills/trellis-meta/references/customize-local/change-context-loading.md create mode 100644 .claude/skills/trellis-meta/references/customize-local/change-hooks.md create mode 100644 .claude/skills/trellis-meta/references/customize-local/change-skills-or-commands.md create mode 100644 .claude/skills/trellis-meta/references/customize-local/change-spec-structure.md create mode 100644 .claude/skills/trellis-meta/references/customize-local/change-task-lifecycle.md create mode 100644 .claude/skills/trellis-meta/references/customize-local/change-workflow.md create mode 100644 .claude/skills/trellis-meta/references/customize-local/overview.md create mode 100644 .claude/skills/trellis-meta/references/local-architecture/context-injection.md create mode 100644 .claude/skills/trellis-meta/references/local-architecture/generated-files.md create mode 100644 .claude/skills/trellis-meta/references/local-architecture/overview.md create mode 100644 .claude/skills/trellis-meta/references/local-architecture/spec-system.md create mode 100644 .claude/skills/trellis-meta/references/local-architecture/task-system.md create mode 100644 .claude/skills/trellis-meta/references/local-architecture/workflow.md create mode 100644 .claude/skills/trellis-meta/references/local-architecture/workspace-memory.md create mode 100644 .claude/skills/trellis-meta/references/platform-files/agents.md create mode 100644 .claude/skills/trellis-meta/references/platform-files/hooks-and-settings.md create mode 100644 .claude/skills/trellis-meta/references/platform-files/overview.md create mode 100644 .claude/skills/trellis-meta/references/platform-files/platform-map.md create mode 100644 .claude/skills/trellis-meta/references/platform-files/skills-and-commands.md create mode 100644 .claude/skills/trellis-session-insight/SKILL.md create mode 100644 .claude/skills/trellis-session-insight/references/cli-quick-reference.md create mode 100644 .claude/skills/trellis-session-insight/references/triggering-patterns.md create mode 100644 .claude/skills/trellis-spec-bootstrap/SKILL.md create mode 100644 .claude/skills/trellis-spec-bootstrap/references/mcp-setup.md create mode 100644 .claude/skills/trellis-spec-bootstrap/references/repository-analysis.md create mode 100644 .claude/skills/trellis-spec-bootstrap/references/spec-task-planning.md create mode 100644 .claude/skills/trellis-spec-bootstrap/references/spec-writing.md create mode 100644 .claude/skills/trellis-update-spec/SKILL.md create mode 100644 .codex/agents/trellis-check.toml create mode 100644 .codex/agents/trellis-implement.toml create mode 100644 .codex/agents/trellis-research.toml create mode 100644 .codex/hooks.json create mode 100644 .codex/hooks/inject-workflow-state.py create mode 100644 .codex/hooks/session-start.py create mode 100644 .trellis/.gitignore create mode 100644 .trellis/.template-hashes.json create mode 100644 .trellis/.version create mode 100644 .trellis/agents/check.md create mode 100644 .trellis/agents/implement.md create mode 100644 .trellis/config.yaml create mode 100644 .trellis/scripts/__init__.py create mode 100644 .trellis/scripts/add_session.py create mode 100644 .trellis/scripts/common/__init__.py create mode 100644 .trellis/scripts/common/active_task.py create mode 100644 .trellis/scripts/common/cli_adapter.py create mode 100644 .trellis/scripts/common/config.py create mode 100644 .trellis/scripts/common/developer.py create mode 100644 .trellis/scripts/common/git.py create mode 100644 .trellis/scripts/common/git_context.py create mode 100644 .trellis/scripts/common/io.py create mode 100644 .trellis/scripts/common/log.py create mode 100644 .trellis/scripts/common/packages_context.py create mode 100644 .trellis/scripts/common/paths.py create mode 100644 .trellis/scripts/common/safe_commit.py create mode 100644 .trellis/scripts/common/session_context.py create mode 100644 .trellis/scripts/common/task_context.py create mode 100644 .trellis/scripts/common/task_queue.py create mode 100644 .trellis/scripts/common/task_store.py create mode 100644 .trellis/scripts/common/task_utils.py create mode 100644 .trellis/scripts/common/tasks.py create mode 100644 .trellis/scripts/common/trellis_config.py create mode 100644 .trellis/scripts/common/types.py create mode 100644 .trellis/scripts/common/workflow_phase.py create mode 100644 .trellis/scripts/get_context.py create mode 100644 .trellis/scripts/get_developer.py create mode 100644 .trellis/scripts/hooks/linear_sync.py create mode 100644 .trellis/scripts/init_developer.py create mode 100644 .trellis/scripts/task.py create mode 100644 .trellis/spec/backend/codex-continuation-contracts.md create mode 100644 .trellis/spec/backend/database-guidelines.md create mode 100644 .trellis/spec/backend/directory-structure.md create mode 100644 .trellis/spec/backend/error-handling.md create mode 100644 .trellis/spec/backend/index.md create mode 100644 .trellis/spec/backend/logging-guidelines.md create mode 100644 .trellis/spec/backend/quality-guidelines.md create mode 100644 .trellis/spec/guides/code-reuse-thinking-guide.md create mode 100644 .trellis/spec/guides/cross-layer-thinking-guide.md create mode 100644 .trellis/spec/guides/index.md create mode 100644 .trellis/tasks/00-bootstrap-guidelines/prd.md create mode 100644 .trellis/tasks/00-bootstrap-guidelines/task.json create mode 100644 .trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/check.jsonl create mode 100644 .trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/design.md create mode 100644 .trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/implement.jsonl create mode 100644 .trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/implement.md create mode 100644 .trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/prd.md create mode 100644 .trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/task.json create mode 100644 .trellis/workflow.md create mode 100644 .trellis/workspace/dxt98/index.md create mode 100644 .trellis/workspace/dxt98/journal-1.md create mode 100644 .trellis/workspace/index.md create mode 100644 AGENTS.md diff --git a/.agents/skills/trellis-before-dev/SKILL.md b/.agents/skills/trellis-before-dev/SKILL.md new file mode 100644 index 0000000..096f8bf --- /dev/null +++ b/.agents/skills/trellis-before-dev/SKILL.md @@ -0,0 +1,40 @@ +--- +name: trellis-before-dev +description: "Discovers and injects project-specific coding guidelines from .trellis/spec/ before implementation begins. Reads spec indexes, pre-development checklists, and shared thinking guides for the target package. Use when starting a new coding task, before writing any code, switching to a different package, or needing to refresh project conventions and standards." +--- + +Read the relevant development guidelines before starting your task. + +Execute these steps: + +1. **Read current task artifacts**: + - `prd.md` for requirements and acceptance criteria + - `design.md` if present for technical design + - `implement.md` if present for execution order and validation plan + +2. **Discover packages and their spec layers**: + ```bash + python ./.trellis/scripts/get_context.py --mode packages + ``` + +3. **Identify which specs apply** to your task based on: + - Which package you're modifying (e.g., `cli/`, `docs-site/`) + - What type of work (backend, frontend, unit-test, docs, etc.) + - Any spec/research paths referenced by the task artifacts + +4. **Read the spec index** for each relevant module: + ```bash + cat .trellis/spec///index.md + ``` + Follow the **"Pre-Development Checklist"** section in the index. + +5. **Read the specific guideline files** listed in the Pre-Development Checklist that are relevant to your task. The index is NOT the goal — it points you to the actual guideline files (e.g., `error-handling.md`, `conventions.md`, `mock-strategies.md`). Read those files to understand the coding standards and patterns. + +6. **Always read shared guides**: + ```bash + cat .trellis/spec/guides/index.md + ``` + +7. Understand the coding standards and patterns you need to follow, then proceed with your development plan. + +This step is **mandatory** before writing any code. diff --git a/.agents/skills/trellis-brainstorm/SKILL.md b/.agents/skills/trellis-brainstorm/SKILL.md new file mode 100644 index 0000000..bd7aeb4 --- /dev/null +++ b/.agents/skills/trellis-brainstorm/SKILL.md @@ -0,0 +1,112 @@ +--- +name: trellis-brainstorm +description: "Guides collaborative requirements discovery before implementation. Creates task directory, seeds PRD, asks high-value questions one at a time, researches technical choices, and converges on MVP scope. Use when requirements are unclear, there are multiple valid approaches, or the user describes a new feature or complex task." +--- + +# Trellis Brainstorm + +## Non-Negotiable Interview Contract + +Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer. + +Ask the questions one at a time. + +## Non-Negotiable Evidence Rule + +If a question can be answered by exploring the codebase, explore the codebase instead. + +This is mandatory. Before asking the user a question, first check whether the answer is already available in code, tests, configs, docs, existing specs, or task history. + +Do not ask the user to confirm facts that the repository can answer. Ask only for product intent, preference, scope, risk tolerance, or decisions that remain ambiguous after inspection. + +--- + +Use this skill during Phase 1 planning to turn the user's request into clear requirements and planning artifacts. + +## Preconditions + +Use this skill only after task-creation consent has been given and the user is ready to enter Trellis planning. + +If no task exists yet, create one: + +```bash +TASK_DIR=$(python ./.trellis/scripts/task.py create "" --slug ) +``` + +Use a concise title from the user's request. Use a slug without a date prefix. `task.py create` adds the `MM-DD-` directory prefix automatically. + +`task.py create` creates the default `prd.md`. Update that file with the current understanding before asking follow-up questions. + +## Planning Flow + +1. Capture the user's request and initial known facts in `prd.md`. +2. Inspect available evidence before asking questions: + - code, tests, fixtures, and configs + - README files, docs, existing specs, and domain notes + - related Trellis tasks, research files, and session history when present +3. Separate what you found into: + - confirmed facts + - product intent still needed from the user + - scope or risk decisions still needed from the user + - likely out-of-scope items +4. Ask the single highest-value remaining question. +5. Include your recommended answer with the question. +6. After each user answer, update `prd.md` before continuing. +7. For complex tasks, create or update `design.md` and `implement.md` before implementation starts. + +Do not invent a project-specific product/spec hierarchy. If the repository already has product, domain, or spec docs, use them. If it does not, proceed with the evidence that exists. + +## Question Rules + +Ask only one question per message. + +Each question must include: + +- the decision needed +- why the answer matters +- your recommended answer +- the trade-off if the user chooses differently + +Do not ask process questions such as whether to search, inspect files, or continue brainstorming. Do the evidence work directly. Ask the user only when the remaining issue is a product decision, preference, scope boundary, or risk tolerance choice. + +## Artifact Rules + +`prd.md` records requirements and acceptance: + +- goal and user value +- confirmed facts +- requirements +- acceptance criteria +- out of scope +- open questions that still block planning + +`design.md` records technical design for complex tasks: + +- architecture and boundaries +- data flow and contracts +- compatibility and migration notes +- important trade-offs +- operational or rollback considerations + +`implement.md` records execution planning for complex tasks: + +- ordered implementation checklist +- validation commands +- risky files or rollback points +- follow-up checks before `task.py start` + +Lightweight tasks may have only `prd.md`. Complex tasks must have `prd.md`, `design.md`, and `implement.md` before `task.py start`. + +`implement.md` is not a replacement for `implement.jsonl`. Use JSONL files only for manifest-style spec and research references when the task needs them. + +## Quality Bar + +Before declaring planning ready: + +- `prd.md` contains testable acceptance criteria. +- Repository-answerable questions have already been answered through inspection. +- Remaining open questions are genuinely about user intent or scope. +- Complex tasks have `design.md` and `implement.md`. +- The user has reviewed the final planning artifacts or explicitly approved proceeding. + +Do not start implementation until the user approves or asks for implementation. diff --git a/.agents/skills/trellis-break-loop/SKILL.md b/.agents/skills/trellis-break-loop/SKILL.md new file mode 100644 index 0000000..ef2b50c --- /dev/null +++ b/.agents/skills/trellis-break-loop/SKILL.md @@ -0,0 +1,130 @@ +--- +name: trellis-break-loop +description: "Deep bug analysis to break the fix-forget-repeat cycle. Analyzes root cause category, why fixes failed, prevention mechanisms, and captures knowledge into specs. Use after fixing a bug to prevent the same class of bugs." +--- + +# Break the Loop - Deep Bug Analysis + +When debug is complete, use this for deep analysis to break the "fix bug -> forget -> repeat" cycle. + +--- + +## Analysis Framework + +Analyze the bug you just fixed from these 5 dimensions: + +### 1. Root Cause Category + +Which category does this bug belong to? + +| Category | Characteristics | Example | +|----------|-----------------|---------| +| **A. Missing Spec** | No documentation on how to do it | New feature without checklist | +| **B. Cross-Layer Contract** | Interface between layers unclear | API returns different format than expected | +| **C. Change Propagation Failure** | Changed one place, missed others | Changed function signature, missed call sites | +| **D. Test Coverage Gap** | Unit test passes, integration fails | Works alone, breaks when combined | +| **E. Implicit Assumption** | Code relies on undocumented assumption | Timestamp seconds vs milliseconds | + +### 2. Why Fixes Failed (if applicable) + +If you tried multiple fixes before succeeding, analyze each failure: + +- **Surface Fix**: Fixed symptom, not root cause +- **Incomplete Scope**: Found root cause, didn't cover all cases +- **Tool Limitation**: grep missed it, type check wasn't strict +- **Mental Model**: Kept looking in same layer, didn't think cross-layer + +### 3. Prevention Mechanisms + +What mechanisms would prevent this from happening again? + +| Type | Description | Example | +|------|-------------|---------| +| **Documentation** | Write it down so people know | Update thinking guide | +| **Architecture** | Make the error impossible structurally | Type-safe wrappers | +| **Compile-time** | Strict type checking, no escape hatches | Signature change causes compile error | +| **Runtime** | Monitoring, alerts, scans | Detect orphan entities | +| **Test Coverage** | E2E tests, integration tests | Verify full flow | +| **Code Review** | Checklist, PR template | "Did you check X?" | + +### 4. Systematic Expansion + +What broader problems does this bug reveal? + +- **Similar Issues**: Where else might this problem exist? +- **Design Flaw**: Is there a fundamental architecture issue? +- **Process Flaw**: Is there a development process improvement? +- **Knowledge Gap**: Is the team missing some understanding? + +### 5. Knowledge Capture + +Solidify insights into the system: + +- [ ] Update `.trellis/spec/guides/` thinking guides +- [ ] Update relevant `.trellis/spec/` docs +- [ ] Create issue record (if applicable) +- [ ] Create feature ticket for root fix +- [ ] Update check guidelines if needed + +--- + +## Output Format + +Please output analysis in this format: + +```markdown +## Bug Analysis: [Short Description] + +### 1. Root Cause Category +- **Category**: [A/B/C/D/E] - [Category Name] +- **Specific Cause**: [Detailed description] + +### 2. Why Fixes Failed (if applicable) +1. [First attempt]: [Why it failed] +2. [Second attempt]: [Why it failed] +... + +### 3. Prevention Mechanisms +| Priority | Mechanism | Specific Action | Status | +|----------|-----------|-----------------|--------| +| P0 | ... | ... | TODO/DONE | + +### 4. Systematic Expansion +- **Similar Issues**: [List places with similar problems] +- **Design Improvement**: [Architecture-level suggestions] +- **Process Improvement**: [Development process suggestions] + +### 5. Knowledge Capture +- [ ] [Documents to update / tickets to create] +``` + +--- + +## Core Philosophy + +> **The value of debugging is not in fixing the bug, but in making this class of bugs never happen again.** + +Three levels of insight: +1. **Tactical**: How to fix THIS bug +2. **Strategic**: How to prevent THIS CLASS of bugs +3. **Philosophical**: How to expand thinking patterns + +30 minutes of analysis saves 30 hours of future debugging. + +--- + +## After Analysis: Immediate Actions + +**IMPORTANT**: After completing the analysis above, you MUST immediately: + +1. **Update spec/guides** - Don't just list TODOs, actually update the relevant files: + - If it's a cross-platform issue → update `cross-platform-thinking-guide.md` + - If it's a cross-layer issue → update `cross-layer-thinking-guide.md` + - If it's a code reuse issue → update `code-reuse-thinking-guide.md` + - If it's domain-specific → update `backend/*.md` or `frontend/*.md` + +2. **Sync templates** - After updating `.trellis/spec/`, sync to `src/templates/markdown/spec/` + +3. **Commit the spec updates** - This is the primary output, not just the analysis text + +> **The analysis is worthless if it stays in chat. The value is in the updated specs.** diff --git a/.agents/skills/trellis-check/SKILL.md b/.agents/skills/trellis-check/SKILL.md new file mode 100644 index 0000000..856ee0d --- /dev/null +++ b/.agents/skills/trellis-check/SKILL.md @@ -0,0 +1,98 @@ +--- +name: trellis-check +description: "Comprehensive quality verification: spec compliance, lint, type-check, tests, cross-layer data flow, code reuse, and consistency checks. Use when code is written and needs quality verification, before committing changes, or to catch context drift during long sessions." +--- + +# Code Quality Check + +Comprehensive quality verification for recently written code. Combines spec compliance, cross-layer safety, and pre-commit checks. + +--- + +## Step 1: Identify What Changed + +```bash +git diff --name-only HEAD +git status +``` + +## Step 2: Read Task Artifacts and Applicable Specs + +Read the current task artifacts in order: + +- `prd.md` +- `design.md` if present +- `implement.md` if present + +```bash +python ./.trellis/scripts/get_context.py --mode packages +``` + +For each changed package/layer, read the spec index and follow its **Quality Check** section: + +```bash +cat .trellis/spec///index.md +``` + +Read the specific guideline files referenced — the index is a pointer, not the goal. + +## Step 3: Run Project Checks + +Run the project's lint, type-check, and test commands. Fix any failures before proceeding. + +## Step 4: Review Against Checklist + +### Code Quality + +- [ ] Linter passes? +- [ ] Type checker passes (if applicable)? +- [ ] Tests pass? +- [ ] No debug logging left in? +- [ ] No suppressed warnings or type-safety bypasses? + +### Test Coverage + +- [ ] New function → unit test added? +- [ ] Bug fix → regression test added? +- [ ] Changed behavior → existing tests updated? + +### Spec Sync + +- [ ] Does `.trellis/spec/` need updates? (new patterns, conventions, lessons learned) + +> "If I fixed a bug or discovered something non-obvious, should I document it so future me won't hit the same issue?" → If YES, update the relevant spec doc. + +## Step 5: Cross-Layer Dimensions (if applicable) + +Skip this step if your change is confined to a single layer. + +### A. Data Flow (changes touch 3+ layers) + +- [ ] Read flow traces correctly: Storage → Service → API → UI +- [ ] Write flow traces correctly: UI → API → Service → Storage +- [ ] Types/schemas correctly passed between layers? +- [ ] Errors properly propagated to caller? + +### B. Code Reuse (modifying constants, creating utilities) + +- [ ] Searched for existing similar code before creating new? + ```bash + grep -r "pattern" src/ + ``` +- [ ] If 2+ places define same value → extracted to shared constant? +- [ ] After batch modification, all occurrences updated? + +### C. Import/Dependency (creating new files) + +- [ ] Correct import paths (relative vs absolute)? +- [ ] No circular dependencies? + +### D. Same-Layer Consistency + +- [ ] Other places using the same concept are consistent? + +--- + +## Step 6: Report and Fix + +Report violations found and fix them directly. Re-run project checks after fixes. diff --git a/.agents/skills/trellis-continue/SKILL.md b/.agents/skills/trellis-continue/SKILL.md new file mode 100644 index 0000000..a83bb07 --- /dev/null +++ b/.agents/skills/trellis-continue/SKILL.md @@ -0,0 +1,61 @@ +--- +name: trellis-continue +description: "Resume work on the current task. Loads the workflow Phase Index, figures out which phase/step to pick up at, then pulls the step-level detail via get_context.py --mode phase. Use when coming back to an in-progress task and you need to know what to do next." +--- + +# Continue Current Task + +Resume work on the current task — pick up at the right phase/step in `.trellis/workflow.md`. + +--- + +## Step 1: Load Current Context + +```bash +python ./.trellis/scripts/get_context.py +``` + +Confirms: current task, git state, recent commits. + +## Step 2: Load the Phase Index + +```bash +python ./.trellis/scripts/get_context.py --mode phase +``` + +Shows the Phase Index (Plan / Execute / Finish) with routing + skill mapping. + +## Step 3: Decide Where You Are + +`get_context.py` shows the active task's `status` field. Route by `status` + artifact presence. This command replaces the user needing to remember the Trellis flow; it does not itself approve implementation. + +- `status=planning` + no `prd.md` → **1.1** (load `trellis-brainstorm`) +- `status=planning` + `prd.md` only → decide whether the task is lightweight or complex. Lightweight can move to **1.4** review; complex returns to **1.1** to add `design.md` + `implement.md`. +- `status=planning` + complex artifacts complete + sub-agent jsonl not curated (only the seed `_example` row) → **1.3** +- `status=planning` + required artifacts complete + required jsonl curated or inline mode → **1.4** (ask for start review; only run `task.py start` after user confirms) +- `status=in_progress` + implementation not started → **2.1** +- `status=in_progress` + implementation done, not yet checked → **2.2** +- `status=in_progress` + check passed → **3.1** +- `status=completed` (rare; usually archived immediately) → archive flow + +Phase rules (full detail in `.trellis/workflow.md`): + +1. Run steps **in order** within a phase — `[required]` steps must not be skipped +2. `[once]` steps are already done if the required output exists. `prd.md` alone can be enough only for lightweight tasks; complex tasks also need `design.md` and `implement.md`. +3. You may go back to an earlier phase if discoveries require it + +## Step 4: Load the Specific Step + +Once you know which step to resume at: + +```bash +python ./.trellis/scripts/get_context.py --mode phase --step --platform codex +``` + +Follow the loaded instructions. After each `[required]` step completes, move to the next. + +--- + +## Reference + +Full workflow and detailed phase steps live in `.trellis/workflow.md`. This command is only an entry point — the canonical guidance is there. diff --git a/.agents/skills/trellis-finish-work/SKILL.md b/.agents/skills/trellis-finish-work/SKILL.md new file mode 100644 index 0000000..02ad8f1 --- /dev/null +++ b/.agents/skills/trellis-finish-work/SKILL.md @@ -0,0 +1,71 @@ +--- +name: trellis-finish-work +description: "Wrap up the current session: verify quality gate passed, remind user to commit, archive completed tasks, and record session progress to the developer journal. Use when done coding and ready to end the session." +--- + +# Finish Work + +Wrap up the current session: archive the active task (and any other completed-but-unarchived tasks the user wants to clean up) and record the session journal. Code commits are NOT done here — those happen in workflow Phase 3.4 before you invoke this command. + +## Step 1: Survey current state + +```bash +python ./.trellis/scripts/get_context.py --mode record +``` + +This prints: + +- **My active tasks** — review whether any besides the current one are actually done (code merged, AC met) and should be archived this round. +- **Git status** — quick visual on what's dirty. +- **Recent commits** — you'll need their hashes in Step 4 for `--commit`. + +If `--mode record` surfaces other completed tasks not tied to the current session, surface them to the user with a one-shot confirmation: "These N tasks look done — archive them too in this round? [y/N]". Default is no; the current active task is always archived in Step 3 regardless. + +## Step 2: Sanity check — classify dirty paths + +Run: + +```bash +git status --porcelain +``` + +Filter out paths under `.trellis/workspace/` and `.trellis/tasks/` — those are managed by `add_session.py` and `task.py archive` auto-commits and will appear dirty as part of this skill's own work. + +For each remaining dirty path, decide whether it belongs to **the current task** or to **other parallel work** (e.g., another terminal window editing the same repo). Heuristics: + +- Paths referenced in the current task's `prd.md` / `implement.jsonl` / `check.jsonl` → current task +- Paths in code areas matching the task's stated scope, or that you remember editing this session → current task +- Paths in unrelated areas you have no recollection of touching this session → other parallel work + +Then route: + +- **Any remaining path looks like current-task work** — bail out with: + > "Working tree has uncommitted code changes from this task: ``. Return to workflow Phase 3.4 to commit them before running ``finish-work` (Trellis command)`." + + Do NOT run `git commit` here. Do NOT prompt the user to commit. The user goes back to Phase 3.4 and the AI drives the batched commit there. +- **All remaining paths look unrelated** (other parallel-window work) — report them once and continue to Step 3: + > "FYI, dirty files outside this task's scope — leaving them for the other window: ``." +- **Genuinely unsure** — ask the user once: "Are `` this task's work I forgot to commit, or another window's? (commit / ignore)" — then route per their answer. + +## Step 3: Archive task(s) + +```bash +python ./.trellis/scripts/task.py archive +``` + +At minimum: the current active task (if any). Plus any extra tasks the user confirmed in Step 1. Each archive produces a `chore(task): archive ...` commit via the script's auto-commit. + +If there is no active task and the user did not confirm any cleanup archives, skip this step. + +## Step 4: Record session journal + +```bash +python ./.trellis/scripts/add_session.py \ + --title "Session Title" \ + --commit "hash1,hash2" \ + --summary "Brief summary" +``` + +Use the work-commit hashes produced in Phase 3.4 (visible in Step 1's `Recent commits` list, or via `git log --oneline`) for `--commit`. Do not include the archive commit hashes from Step 3. This produces a `chore: record journal` commit. + +Final git log order: `` → `chore(task): archive ...` (one or more) → `chore: record journal`. diff --git a/.agents/skills/trellis-meta/SKILL.md b/.agents/skills/trellis-meta/SKILL.md new file mode 100644 index 0000000..590bfac --- /dev/null +++ b/.agents/skills/trellis-meta/SKILL.md @@ -0,0 +1,73 @@ +--- +name: trellis-meta +description: "Understand and customize the local Trellis architecture inside a user project. Use when modifying .trellis plus platform hooks, settings, agents, skills, commands, prompts, or workflows generated by trellis init." +--- + +# Trellis Meta + +This skill is for local Trellis users who have already run `trellis init` in a project. After reading it, an AI should understand the Trellis architecture, operating model, and customization entry points inside that user project, then modify the generated `.trellis/` and platform directory files according to the user's request. + +The default operating scope is local files in the user project: + +- `.trellis/`: workflow, config, tasks, spec, workspace, scripts, and runtime state. +- Platform directories: `.claude/`, `.codex/`, `.cursor/`, `.opencode/`, `.kiro/`, `.gemini/`, `.qoder/`, `.codebuddy/`, `.github/`, `.factory/`, `.pi/`, `.kilocode/`, `.agent/`, `.windsurf/`, and similar directories. +- Shared skill layer: `.agents/skills/`. + +Do not assume the user has the Trellis source repository. Do not default to modifying the global npm install directory or `node_modules`. + +## How To Use + +1. Read `references/local-architecture/overview.md` first to establish the local Trellis system model. +2. If the request involves a specific AI tool, read `references/platform-files/platform-map.md` and the relevant platform file notes. +3. If the user wants to change behavior, read `references/customize-local/overview.md`, then open the specific customization topic. +4. Before editing, read the actual files in the user project and treat local content as authoritative. + +## References + +### Local Architecture + +- `references/local-architecture/overview.md`: The three-layer local Trellis architecture and customization principles. +- `references/local-architecture/generated-files.md`: Files generated by `trellis init` and their customization boundaries. +- `references/local-architecture/workflow.md`: Phases, routing, and workflow-state blocks in `.trellis/workflow.md`. +- `references/local-architecture/task-system.md`: Task directories, active tasks, JSONL context, and task runtime. +- `references/local-architecture/spec-system.md`: How `.trellis/spec/` is organized and injected. +- `references/local-architecture/workspace-memory.md`: `.trellis/workspace/`, journals, and cross-session memory. +- `references/local-architecture/context-injection.md`: Hooks, sub-agent preludes, and context injection paths. + +### Platform Files + +- `references/platform-files/overview.md`: How shared `.trellis/` files relate to platform directories. +- `references/platform-files/platform-map.md`: Platform directories and paths for skills, agents, hooks, and extensions. +- `references/platform-files/hooks-and-settings.md`: How settings/config files, hooks, plugins, and extensions connect to Trellis. +- `references/platform-files/agents.md`: Local file responsibilities for `trellis-research`, `trellis-implement`, and `trellis-check`. +- `references/platform-files/skills-and-commands.md`: Differences between skills, commands, prompts, and workflows, plus how to change them. + +### Local Customization + +- `references/customize-local/overview.md`: Choose the right local customization entry point for the user's request. +- `references/customize-local/change-workflow.md`: Change phases, routing, next actions, and workflow-state. +- `references/customize-local/change-task-lifecycle.md`: Change task creation, status, archive behavior, and hooks. +- `references/customize-local/change-context-loading.md`: Change how tasks, specs, journals, and hook context are loaded. +- `references/customize-local/change-hooks.md`: Change platform hooks, settings, and shell session bridges. +- `references/customize-local/change-agents.md`: Change research, implement, and check agent behavior. +- `references/customize-local/change-skills-or-commands.md`: Add or modify local skills, commands, prompts, and workflows. +- `references/customize-local/change-spec-structure.md`: Adjust the project spec structure under `.trellis/spec/`. +- `references/customize-local/add-project-local-conventions.md`: Put team rules into project-local specs or local skills. + +## Current Rules + +- `.trellis/workflow.md` is the local workflow source of truth. +- `.trellis/config.yaml` is the project-level Trellis configuration and task hook configuration entry point. +- `.trellis/spec/` stores the user's project-specific coding conventions and design constraints. +- `.trellis/tasks/` stores task PRDs, technical notes, research files, and JSONL context. +- `.trellis/workspace/` stores developer journals and cross-session memory. +- Platform settings/config files decide which hooks, agents, skills, commands, prompts, and workflows actually run. +- `.trellis/.template-hashes.json` and `.trellis/.runtime/` are management/runtime state files. Confirm necessity before editing them. + +## Do Not + +- Do not treat Trellis upstream source code as the default target for local customization. +- Do not modify the global npm install directory or `node_modules/@mindfoldhq/trellis` to implement project needs. +- Do not overwrite user-modified local files with default templates. +- Do not put team-private project rules into the public `trellis-meta`; put project rules in `.trellis/spec/` or a project-local skill. +- Do not describe removed historical mechanisms as current Trellis behavior. diff --git a/.agents/skills/trellis-meta/references/customize-local/add-project-local-conventions.md b/.agents/skills/trellis-meta/references/customize-local/add-project-local-conventions.md new file mode 100644 index 0000000..d32ca2d --- /dev/null +++ b/.agents/skills/trellis-meta/references/customize-local/add-project-local-conventions.md @@ -0,0 +1,83 @@ +# Add Project-Local Conventions + +Often the user does not need to change Trellis mechanics; they need local AI to understand their team's conventions. In that case, prefer `.trellis/spec/` or a project-local skill instead of editing `trellis-meta`. + +## Where To Put Things + +| Content type | Location | +| --- | --- | +| Rules code must follow | `.trellis/spec//` | +| Cross-layer thinking methods | `.trellis/spec/guides/` | +| AI capability for a project-specific flow | Platform-local skill | +| One-off task material | `.trellis/tasks//` | +| Session summary | `.trellis/workspace//journal-N.md` | + +## Create A Project-Local Skill + +If the user wants AI to know "how this project customizes Trellis," create a local skill: + +```text +.claude/skills/trellis-local/ +└── SKILL.md +``` + +Example: + +```md +--- +name: trellis-local +description: "Project-local Trellis customizations for this repository. Use when changing this project's Trellis workflow, hooks, local agents, or team-specific conventions." +--- + +# Trellis Local + +## Local Scope + +This skill documents this repository's Trellis customizations only. + +## Custom Workflow Rules + +- ... + +## Local Hook Changes + +- ... + +## Local Agent Changes + +- ... +``` + +For multi-platform projects, place equivalent versions in other platform skill directories, or use `.agents/skills/` for platforms that support the shared layer. + +## Write To `.trellis/spec/` + +If the content is a coding convention, write it to spec. Examples: + +```text +.trellis/spec/backend/error-handling.md +.trellis/spec/frontend/components.md +.trellis/spec/guides/cross-platform-thinking-guide.md +``` + +After writing it, update the corresponding `index.md` so AI can find the new rule from the entry point. + +## Make The Current Task Use New Conventions + +After writing a spec, add it to the current task context: + +```bash +python ./.trellis/scripts/task.py add-context implement ".trellis/spec/backend/error-handling.md" "Error handling conventions" +python ./.trellis/scripts/task.py add-context check ".trellis/spec/backend/error-handling.md" "Review error handling" +``` + +## Do Not Store Project-Private Rules In `trellis-meta` + +`trellis-meta` is a public skill for understanding Trellis architecture and local customization entry points. Put project-private content in: + +- `.trellis/spec/` +- a project-local skill +- the current task +- workspace journal + +This prevents future updates to Trellis's built-in `trellis-meta` from overwriting the team's own conventions. diff --git a/.agents/skills/trellis-meta/references/customize-local/change-agents.md b/.agents/skills/trellis-meta/references/customize-local/change-agents.md new file mode 100644 index 0000000..9b63531 --- /dev/null +++ b/.agents/skills/trellis-meta/references/customize-local/change-agents.md @@ -0,0 +1,54 @@ +# Change Local Agents + +When the user wants to change `trellis-research`, `trellis-implement`, or `trellis-check` behavior, edit platform agent files in the user project. + +## Read These Files First + +1. Target platform agent directory +2. `.trellis/workflow.md` Phase 2 / research routing +3. Current task `prd.md` +4. Current task `implement.jsonl` / `check.jsonl` +5. Relevant hook or agent prelude + +## Common Paths + +| Platform | Path | +| --- | --- | +| Claude Code | `.claude/agents/trellis-*.md` | +| Cursor | `.cursor/agents/trellis-*.md` | +| OpenCode | `.opencode/agents/trellis-*.md` | +| Codex | `.codex/agents/trellis-*.toml` | +| Kiro | `.kiro/agents/trellis-*.json` | +| Gemini CLI | `.gemini/agents/trellis-*.md` | +| Qoder | `.qoder/agents/trellis-*.md` | +| CodeBuddy | `.codebuddy/agents/trellis-*.md` | +| Factory Droid | `.factory/droids/trellis-*.md` | +| Pi Agent | `.pi/agents/trellis-*.md` | + +Use the actual paths in the user project as authoritative. + +## Common Needs + +| Need | Which agent to edit | +| --- | --- | +| Research must write files, not only reply in chat | `trellis-research` | +| Certain local specs must be read before implementation | `trellis-implement` + `implement.jsonl` configuration rules | +| Specific commands must run during checking | `trellis-check` | +| Agent must not modify certain directories | The corresponding agent's write boundary instructions | +| Agent output format must be fixed | The corresponding agent's final/reporting instructions | + +## Modification Principles + +1. **Preserve role boundaries**: research investigates and persists; implement writes implementation; check reviews and fixes. +2. **Do not hard-code project specs into agents**: long-term specs belong in `.trellis/spec/`; agents are responsible for reading them. +3. **Make read order explicit**: active task -> PRD -> info -> JSONL -> spec/research. +4. **Make write boundaries explicit**: which directories may be written and which may not. +5. **Synchronize across platforms**: when the user configured multiple platforms, decide whether to change only the current platform or all platform agents. + +## Agent Pull Platforms + +If an agent file contains a prelude for "read task/context after startup," do not remove those steps when editing. Otherwise the agent will work only from chat context and bypass Trellis's core mechanism. + +## Hook Push Platforms + +If context is injected by a hook, the agent file should still retain responsibility boundaries. Do not remove PRD/spec requirements from the agent just because a hook injects context. diff --git a/.agents/skills/trellis-meta/references/customize-local/change-context-loading.md b/.agents/skills/trellis-meta/references/customize-local/change-context-loading.md new file mode 100644 index 0000000..83bcd63 --- /dev/null +++ b/.agents/skills/trellis-meta/references/customize-local/change-context-loading.md @@ -0,0 +1,84 @@ +# Change Local Context Loading + +Context loading determines when AI reads workflow, task, spec, research, workspace, and git status. Read this page when the user says "AI does not know the current task," "the agent did not read specs," or "there is too much/too little context." + +## Read These Files First + +1. `.trellis/workflow.md` +2. `.trellis/scripts/get_context.py` +3. `.trellis/scripts/common/session_context.py` +4. `.trellis/scripts/common/task_context.py` +5. `.trellis/scripts/common/active_task.py` +6. Current platform hooks or agent files +7. The current task's `implement.jsonl` / `check.jsonl` + +## Context Sources + +| Source | Purpose | +| --- | --- | +| `.trellis/workflow.md` | Workflow and next-action hints. | +| `.trellis/tasks//prd.md` | Current task requirements. | +| `.trellis/tasks//design.md` | Complex task technical design. | +| `.trellis/tasks//implement.md` | Complex task execution plan. | +| `.trellis/tasks//implement.jsonl` | Spec/research to read before implementation. | +| `.trellis/tasks//check.jsonl` | Spec/research to read during checking. | +| `.trellis/spec/` | Project specs. | +| `.trellis/workspace/` | Session records. | +| git status | Current working tree changes. | + +## Common Needs And Edit Points + +| Need | Edit point | +| --- | --- | +| Inject more/less information in new sessions | `session_context.py` or the platform `session-start` hook. | +| Change hints on each user input | `[workflow-state:STATUS]` block in `.trellis/workflow.md`. The `inject-workflow-state` hook is parser-only and reads the block verbatim. | +| Agent did not read specs | Task JSONL, agent prelude, `inject-subagent-context` hook. | +| Active task is lost | `active_task.py` and platform session identity propagation. | +| Change JSONL validation rules | `task_context.py`. | + +## JSONL Rules + +`implement.jsonl` / `check.jsonl` are the key context loading interface: + +```jsonl +{"file": ".trellis/spec/backend/index.md", "reason": "Backend conventions"} +{"file": ".trellis/tasks/04-28-x/research/api.md", "reason": "API research"} +``` + +Include only spec/research files. Do not put code files that will be modified into these manifests; agents read code files themselves during implementation. + +## Change Session Context + +If the user wants every new session to see more project state, edit: + +- `.trellis/scripts/common/session_context.py` +- the corresponding platform `session-start` hook + +Context cannot grow without bound. Prefer injecting indexes and paths so the AI can read detailed files on demand. + +## Change Sub-Agent Context + +First determine which mode the platform uses: + +- hook push: edit the `inject-subagent-context` hook. +- agent pull: edit the read steps in the corresponding `trellis-implement` / `trellis-check` agent file. + +In both modes, make sure the agent ultimately reads: + +1. active task +2. the corresponding JSONL +3. spec/research referenced by the JSONL +4. `prd.md` +5. `design.md` if present +6. `implement.md` if present + +## Troubleshooting Order + +```bash +python ./.trellis/scripts/task.py current --source +python ./.trellis/scripts/task.py list-context +python ./.trellis/scripts/task.py validate +python ./.trellis/scripts/get_context.py --mode packages +``` + +Confirm the task and JSONL are correct before editing hooks/agents. diff --git a/.agents/skills/trellis-meta/references/customize-local/change-hooks.md b/.agents/skills/trellis-meta/references/customize-local/change-hooks.md new file mode 100644 index 0000000..093a171 --- /dev/null +++ b/.agents/skills/trellis-meta/references/customize-local/change-hooks.md @@ -0,0 +1,57 @@ +# Change Local Hooks + +Hooks are the automation layer that connects a platform to Trellis. When the user wants to change "when context is injected," "how shell commands inherit a session," or "which files are read before an agent starts," hooks are usually the edit point. + +## Read These Files First + +1. Target platform settings/config, such as `.claude/settings.json`, `.codex/hooks.json`, `.cursor/hooks.json` +2. Target platform hooks directory +3. `.trellis/scripts/common/active_task.py` +4. `.trellis/scripts/common/session_context.py` +5. `.trellis/workflow.md` + +## Common Hook Types + +| Hook | Purpose | +| --- | --- | +| session-start | Injects a Trellis overview when a session starts, clears, or compacts. | +| workflow-state | Injects a state hint on each user input. | +| sub-agent context | Injects PRD/spec/research before an agent starts. | +| shell session bridge | Lets `task.py` commands in shell see the same session identity. | + +## Modification Steps + +1. Find the hook registration in settings/config. +2. Confirm the registered script path exists. +3. Read the hook script and identify inputs, outputs, and called `.trellis/scripts/`. +4. Modify hook behavior. +5. If the hook depends on workflow content, synchronize `.trellis/workflow.md`. + +## Example: Change New-Session Injection Content + +First find the session-start hook: + +```text +.claude/settings.json +.claude/hooks/session-start.py +``` + +If the hook ultimately calls `.trellis/scripts/get_context.py` or `session_context.py`, editing the local script is usually more robust than hard-coding content in the hook. + +## Example: Agent Did Not Read JSONL + +First confirm: + +```bash +python ./.trellis/scripts/task.py current --source +python ./.trellis/scripts/task.py validate +``` + +If the task and JSONL are correct, determine whether the platform uses hook push or agent pull. For hook push, edit `inject-subagent-context`; for agent pull, edit the agent file. + +## Notes + +- Settings handle registration, hook scripts handle behavior; inspect both together. +- Different platforms support different hook events. Do not directly copy another platform's settings. +- Hooks should read project-local `.trellis/`; they should not depend on Trellis upstream source paths. +- Hook failures should produce visible errors so AI does not silently lose context. diff --git a/.agents/skills/trellis-meta/references/customize-local/change-skills-or-commands.md b/.agents/skills/trellis-meta/references/customize-local/change-skills-or-commands.md new file mode 100644 index 0000000..84590a1 --- /dev/null +++ b/.agents/skills/trellis-meta/references/customize-local/change-skills-or-commands.md @@ -0,0 +1,78 @@ +# Change Local Skills, Commands, Prompts, And Workflows + +When the user wants to change AI entry points, auto-trigger rules, or explicit command behavior, edit skills, commands, prompts, or workflows in local platform directories. + +## Read These Files First + +1. `.trellis/workflow.md` +2. Target platform skill/command/prompt/workflow directory +3. Related agent or hook files +4. Whether project rules already exist in `.trellis/spec/` + +## Which Entry Type To Choose + +| Goal | Recommendation | +| --- | --- | +| AI should automatically know a capability | Add or modify a skill. | +| User wants to trigger manually with a command | Add or modify a command/prompt/workflow. | +| Team project conventions | Prefer `.trellis/spec/` or a project-local skill. | +| Change Trellis flow semantics | Synchronize `.trellis/workflow.md`. | + +## Modify A Skill + +A skill is usually: + +```text +/ +├── SKILL.md +└── references/ +``` + +`SKILL.md` should be short and responsible for triggering/routing. Put long content in `references/` so AI can read it on demand. + +The frontmatter description should specify when to use the skill. Example: + +```yaml +description: "Use when customizing this project's deployment workflow and release checklist." +``` + +Do not write vague descriptions such as "helpful project skill"; they can trigger incorrectly. + +## Modify A Command/Prompt/Workflow + +Explicit entry points should state: + +- How the user triggers it. +- Which `.trellis/` files to read. +- Which scripts to run. +- How to report after completion. + +If a command only repeats workflow rules, prefer making it reference/read `.trellis/workflow.md` instead of maintaining a second copy of the flow. + +## Common Paths + +| Platform | Entry directories | +| --- | --- | +| Claude Code | `.claude/skills/`, `.claude/commands/` | +| Cursor | `.cursor/skills/`, `.cursor/commands/` | +| OpenCode | `.opencode/skills/`, `.opencode/commands/` | +| Codex | `.agents/skills/`, `.codex/skills/` | +| GitHub Copilot | `.github/skills/`, `.github/prompts/` | +| Kilo / Antigravity / Windsurf | workflows + skills | + +## Add A Project-Local Skill + +If the user wants to document team-private customizations, create a project-local skill, for example: + +```text +.claude/skills/project-trellis-local/ +└── SKILL.md +``` + +For multi-platform projects, add equivalent versions in each platform skill directory, or use `.agents/skills/` on platforms that support the shared layer. + +## Notes + +- Do not mix every platform's syntax into one file. +- Do not change only one platform entry point while claiming all platforms are supported. +- Do not hide long-term engineering conventions inside a command; write them to `.trellis/spec/`. diff --git a/.agents/skills/trellis-meta/references/customize-local/change-spec-structure.md b/.agents/skills/trellis-meta/references/customize-local/change-spec-structure.md new file mode 100644 index 0000000..14e0cd8 --- /dev/null +++ b/.agents/skills/trellis-meta/references/customize-local/change-spec-structure.md @@ -0,0 +1,83 @@ +# Change Local Spec Structure + +When the user wants to change the engineering conventions AI follows, add new spec layers, or adjust monorepo package mapping, edit `.trellis/spec/` and `.trellis/config.yaml`. + +## Read These Files First + +1. `.trellis/config.yaml` +2. `.trellis/spec/` +3. `.trellis/workflow.md` planning artifact guidance and Phase 3.3 +4. Current task `implement.jsonl` / `check.jsonl` + +## Common Needs + +| Need | Edit location | +| --- | --- | +| Add backend/frontend/docs/test spec layer | `.trellis/spec//` or `.trellis/spec///` | +| Add shared thinking guides | `.trellis/spec/guides/` | +| Adjust monorepo packages | `packages` in `.trellis/config.yaml` | +| Change default package | `default_package` in `.trellis/config.yaml` | +| Control spec scanning scope | `spec_scope` in `.trellis/config.yaml` | +| Make a task read a new spec | Task `implement.jsonl` / `check.jsonl` | + +## Add A Spec Layer + +Single-repository example: + +```text +.trellis/spec/security/ +├── index.md +└── auth.md +``` + +Monorepo example: + +```text +.trellis/spec/webapp/security/ +├── index.md +└── auth.md +``` + +`index.md` should include: + +- What code this layer applies to. +- Pre-Development Checklist. +- Quality Check. +- Links to specific guideline files. + +## Update Context + +Adding a spec does not mean every task automatically reads it. The current task must reference it in JSONL: + +```bash +python ./.trellis/scripts/task.py add-context implement ".trellis/spec/webapp/security/index.md" "Security conventions" +python ./.trellis/scripts/task.py add-context check ".trellis/spec/webapp/security/index.md" "Security review rules" +``` + +## Change Monorepo Packages + +Example `.trellis/config.yaml`: + +```yaml +packages: + webapp: + path: apps/web + api: + path: apps/api +default_package: webapp +``` + +After editing, run: + +```bash +python ./.trellis/scripts/get_context.py --mode packages +``` + +Use this output to confirm AI can see the correct packages and spec layers. + +## Notes + +- Specs are user project conventions and can be changed according to project needs. +- Do not put temporary task information into specs; put temporary information in the task. +- Do not put long-term conventions only in agents or commands; preserve them in specs. +- After changing spec structure, check whether existing task JSONL files still point to files that exist. diff --git a/.agents/skills/trellis-meta/references/customize-local/change-task-lifecycle.md b/.agents/skills/trellis-meta/references/customize-local/change-task-lifecycle.md new file mode 100644 index 0000000..208e0da --- /dev/null +++ b/.agents/skills/trellis-meta/references/customize-local/change-task-lifecycle.md @@ -0,0 +1,90 @@ +# Change Local Task Lifecycle + +Task lifecycle includes creation, start, context configuration, finish, archive, parent/child tasks, and lifecycle hooks. The default customization targets are `.trellis/tasks/`, `.trellis/config.yaml`, and `.trellis/scripts/`. + +## Read These Files First + +1. `.trellis/workflow.md` +2. `.trellis/config.yaml` +3. `.trellis/scripts/task.py` +4. `.trellis/scripts/common/task_store.py` +5. `.trellis/scripts/common/task_utils.py` +6. The current task's `.trellis/tasks//task.json` + +## Common Needs And Edit Points + +| Need | Edit point | +| --- | --- | +| Automatically sync an external system after task creation | `hooks.after_create` in `.trellis/config.yaml`. | +| Automatically update status after task start | `hooks.after_start` in `.trellis/config.yaml`. | +| Run a script after task finish | `hooks.after_finish` in `.trellis/config.yaml`. | +| Clean external resources after archive | `hooks.after_archive` in `.trellis/config.yaml`. | +| Change default task fields | `.trellis/scripts/common/task_store.py`. | +| Change task parsing/search | `.trellis/scripts/common/task_utils.py`. | +| Change active task behavior | `.trellis/scripts/common/active_task.py`. | + +## lifecycle hooks + +`.trellis/config.yaml` supports: + +```yaml +hooks: + after_create: + - "python .trellis/scripts/hooks/my_sync.py create" + after_start: + - "python .trellis/scripts/hooks/my_sync.py start" + after_finish: + - "python .trellis/scripts/hooks/my_sync.py finish" + after_archive: + - "python .trellis/scripts/hooks/my_sync.py archive" +``` + +Hook commands receive the `TASK_JSON_PATH` environment variable, pointing to the current task's `task.json`. Hook failures should usually warn, but not block the main task operation. + +## Change Task Fields + +If the user wants to add project-local fields, prefer putting them under `meta` in `task.json` to avoid breaking existing scripts' assumptions about standard fields. + +Example: + +```json +"meta": { + "linearIssue": "ENG-123", + "risk": "high" +} +``` + +If standard fields really need to change, inspect every local script that reads `task.json`. + +## Change Active Task + +Active task is session-level state stored in `.trellis/.runtime/sessions/`. Do not fall back to a global `.current-task` model. If the user wants to change active task behavior, edit: + +- `.trellis/scripts/common/active_task.py` +- platform hooks or shell session bridges +- active task descriptions in `.trellis/workflow.md` + +### `task.py create` Sets the Active Pointer + +`cmd_create` in `.trellis/scripts/common/task_store.py` calls `set_active_task` best-effort right after writing the new task directory. The behavior: + +- When the calling shell carries session identity (`TRELLIS_CONTEXT_ID` env var, or any platform-specific session env that `resolve_context_key` recognizes — see `active_task.py:_ENV_SESSION_KEYS`), the per-session pointer at `.trellis/.runtime/sessions/.json` is rewritten to point at the new task. The task's `status=planning` and `[workflow-state:planning]` fires on the very next `UserPromptSubmit`. +- When session identity is unavailable (raw CLI invocation outside an AI session, or a platform that doesn't propagate identity to shell), the task directory is still created and `status=planning` is still written, but the active pointer is left untouched. The user can attach the task later with `task.py start ` once they're back in an AI session. + +This makes `[workflow-state:planning]` the live breadcrumb during the brainstorm and JSONL curation work that follows `task.py create`. The pre-R7 behavior left the breadcrumb stuck on `no_task` until `task.py start`, so the planning block was effectively dead text. + +If you fork `task.py` to add a new creation path (e.g. an external import that bypasses `cmd_create`), audit whether your path also calls `set_active_task`. Without that call, your created tasks will not surface as active. The full status writer table is in `.trellis/spec/cli/backend/workflow-state-contract.md`. + +## Modification Steps + +1. Confirm the current task with `python ./.trellis/scripts/task.py current --source`. +2. Read the current task's `task.json` and confirm status and fields. +3. For configuration needs, edit `.trellis/config.yaml` first. +4. For script behavior needs, then edit `.trellis/scripts/`. +5. If the AI flow changed, synchronize `.trellis/workflow.md`. + +## Do Not + +- Do not directly edit `.trellis/.runtime/sessions/` to "fix" business state. +- Do not hard-code project-private fields into scripts; prefer `meta`. +- Do not default to asking the user to fork Trellis CLI. diff --git a/.agents/skills/trellis-meta/references/customize-local/change-workflow.md b/.agents/skills/trellis-meta/references/customize-local/change-workflow.md new file mode 100644 index 0000000..aa2e663 --- /dev/null +++ b/.agents/skills/trellis-meta/references/customize-local/change-workflow.md @@ -0,0 +1,65 @@ +# Change Local Workflow + +When the user wants to change Trellis phases, next-action hints, whether to create tasks, whether to use sub-agents, or when to check/wrap up, edit `.trellis/workflow.md` first. + +## Read These Files First + +1. `.trellis/workflow.md` +2. Entry files for the current platform, such as skills/commands/prompts/workflows +3. The current task's `task.json` and `prd.md` + +## Common Needs And Edit Points + +| Need | Edit point | +| --- | --- | +| Change phase names or phase order | `Phase Index` and the corresponding Phase sections. | +| Change whether to create a task when there is no task | `[workflow-state:no_task]` state block. | +| Change the next step during planning | Phase 1 and `[workflow-state:planning]`. | +| Change whether an agent is required during in_progress | Phase 2 and `[workflow-state:in_progress]`. | +| Change wrap-up after completion | Phase 3 and `[workflow-state:completed]`. | +| Change which skill a user intent triggers | `Skill Routing` table. | + +## Modification Steps + +1. Find the relevant section in `.trellis/workflow.md`. +2. When changing rules, keep explicit trigger conditions and next actions. +3. If adding or renaming a skill/agent, synchronize the corresponding files in platform directories. +4. Workflow-state changes only need an edit to the `[workflow-state:STATUS]` block in `.trellis/workflow.md`. The hook is parser-only — it reads whatever you put in the block. Keep the opening and closing tags' STATUS strings identical (`[workflow-state:foo]…[/workflow-state:foo]`); mismatched STATUS pairs are silently dropped. +5. Make the AI reread `.trellis/workflow.md`; do not keep using rules from the old conversation. + +## Example: Relax Task Creation Requirements + +To change when task creation can be skipped, usually edit `[workflow-state:no_task]`: + +```md +[workflow-state:no_task] +Task is not required when the answer is a one-reply explanation, no files are changed, and no research is needed. +[/workflow-state:no_task] +``` + +If the formal Phase 1 flow also needs to change, synchronize the Phase 1 section. + +## Example: One Platform Does Not Use Sub-Agents + +If the user wants only one platform to avoid sub-agents, first confirm whether that platform has a separate group in the workflow. Then change Phase 2 routing for that platform group instead of deleting all `trellis-implement` / `trellis-check` instructions across platforms. + +## `/trellis:continue` Route Table + +`/trellis:continue` resumes a task by deciding which phase step to load next. The decision combines `task.json.status` with the presence of artifacts inside the task directory. The mapping is fixed in the command itself; forks that add custom statuses must extend both the workflow.md tag block and this table. + +| `status` | Artifact state | Resume at | +| --- | --- | --- | +| `planning` | `prd.md` missing | Phase 1.1 (load `trellis-brainstorm`) | +| `planning` | lightweight task with `prd.md` complete | ask for start review, then run `task.py start` | +| `planning` | complex task missing `design.md` or `implement.md` | complete missing planning artifacts | +| `planning` | complex task has `prd.md`, `design.md`, and `implement.md` | ask for start review, then run `task.py start` | +| `in_progress` | no implementation in conversation history | Phase 2.1 (`trellis-implement`) | +| `in_progress` | implementation done, no `trellis-check` run | Phase 2.2 (`trellis-check`) | +| `in_progress` | check passed | Phase 3.1 (verify quality + spec update) | +| `completed` | task is still in active tree | Phase 3.5 (run `/trellis:finish-work` to archive) | + +When you add a custom status (e.g. `in-review`), add a `[workflow-state:in-review]` block in `.trellis/workflow.md` for the per-turn breadcrumb AND extend this route table — usually by editing the `/trellis:continue` command file (`.{platform}/commands/trellis/continue.md` or equivalent) to add a row that decides where to resume from. Without the route entry, `/trellis:continue` will fall through to a default branch and the user will not land on the step you intended. + +## Notes + +`.trellis/workflow.md` is the local project workflow, not an immutable template. The user can adapt it to team habits. After editing it, platform entry files may still contain old descriptions, so inspect them too. diff --git a/.agents/skills/trellis-meta/references/customize-local/overview.md b/.agents/skills/trellis-meta/references/customize-local/overview.md new file mode 100644 index 0000000..ac16a4c --- /dev/null +++ b/.agents/skills/trellis-meta/references/customize-local/overview.md @@ -0,0 +1,55 @@ +# Local Customization Overview + +This directory is for local AI working in a user project where Trellis was installed through npm and `trellis init` has already been run. The AI should modify generated `.trellis/` and platform directories inside the project, not Trellis CLI upstream source code. + +## First Determine What The User Actually Wants To Change + +| User wording | Read first | +| --- | --- | +| "Change the Trellis flow / phases / next prompt" | `change-workflow.md` | +| "Change task creation, status, archive, or hooks" | `change-task-lifecycle.md` | +| "AI did not read context / change injected content" | `change-context-loading.md` | +| "A platform hook is not behaving as expected" | `change-hooks.md` | +| "Change implement/check/research agent behavior" | `change-agents.md` | +| "Add a skill/command/workflow/prompt" | `change-skills-or-commands.md` | +| "Adjust the project spec structure" | `change-spec-structure.md` | +| "Add team conventions and local notes" | `add-project-local-conventions.md` | + +## General Operation Order + +1. **Confirm platform and directories**: inspect which directories exist, such as `.claude/`, `.codex/`, `.cursor/`. +2. **Confirm the current active task**: run `python ./.trellis/scripts/task.py current --source`. +3. **Read the local source of truth**: prefer `.trellis/workflow.md`, `.trellis/config.yaml`, and relevant platform files. +4. **Modify narrowly**: edit only files related to the user's request. +5. **Synchronize semantics**: if a shared flow changes, check whether platform entry points also need changes; if a platform entry changes, check whether `.trellis/workflow.md` still agrees. + +## Local File Priority + +| Layer | Files | +| --- | --- | +| Workflow | `.trellis/workflow.md` | +| Project configuration | `.trellis/config.yaml` | +| Task material | `.trellis/tasks//` | +| Project specs | `.trellis/spec/` | +| Runtime scripts | `.trellis/scripts/` | +| Platform integration | `.claude/`, `.codex/`, `.cursor/`, `.opencode/`, and similar directories | +| Shared skill | `.agents/skills/` | + +## Things Not To Do By Default + +- Do not edit the global npm install directory. +- Do not edit `node_modules/@mindfoldhq/trellis`. +- Do not assume the user has the Trellis GitHub repository. +- Do not overwrite local files already modified by the user with default templates. +- Do not put team project rules into public `trellis-meta`; project rules belong in `.trellis/spec/` or a local skill. + +## When To Inspect Upstream Source + +Switch to an upstream source-code perspective only when the user explicitly expresses one of these goals: + +- "I want to open a PR to Trellis" +- "I want to change npm package publish contents" +- "I want to fork Trellis" +- "I want to modify the generation logic for `trellis init/update`" + +Otherwise, default to modifying local Trellis files inside the user project. diff --git a/.agents/skills/trellis-meta/references/local-architecture/context-injection.md b/.agents/skills/trellis-meta/references/local-architecture/context-injection.md new file mode 100644 index 0000000..4a7517b --- /dev/null +++ b/.agents/skills/trellis-meta/references/local-architecture/context-injection.md @@ -0,0 +1,68 @@ +# Local Context Injection System + +Trellis context injection aims to make AI read the right files at the right time instead of relying on model memory. In a user project, injection is implemented by `.trellis/` scripts together with platform hooks, agents, and skills. + +## Injected Context Types + +| Type | Source | Purpose | +| --- | --- | --- | +| session context | `.trellis/scripts/get_context.py` | Current developer, git status, active task, active tasks, journal, packages. | +| workflow context | `.trellis/workflow.md` | Current Trellis flow and next action. | +| spec context | `.trellis/spec/` + task JSONL | Specs that must be followed during implementation/checking. | +| task context | `.trellis/tasks//prd.md`, `design.md`, `implement.md`, `research/` | Current task requirements, design, execution plan, and research. | +| platform context | Platform hooks/settings/agents | Lets different AI tools read the files above through their own mechanisms. | + +## session-start + +Platforms with session-start support inject a Trellis overview when a session starts, clears, compacts, or receives a similar event. Injected content usually includes: + +- workflow summary. +- current task status. +- active tasks. +- spec index paths. +- developer identity and git status. + +If the user feels the AI does not know the current task in a new session, first check whether the platform's session-start hook or equivalent mechanism is installed and running. + +## workflow-state + +workflow-state is a lightweight hint injected around each user turn. Based on current task status, it selects a block from `.trellis/workflow.md`, such as `no_task`, `planning`, `in_progress`, or `completed`. + +If the user wants to change "what the AI should do next in a given state," edit the corresponding state block in `.trellis/workflow.md` first. + +## sub-agent context + +Implement and check agents need task context. Trellis has two loading modes: + +1. **hook push**: a platform hook injects jsonl-referenced files plus `prd.md`, `design.md` if present, and `implement.md` if present before the agent starts. +2. **agent pull**: the agent definition instructs the agent to read the active task, jsonl context, and task artifacts after startup. + +In both modes, JSONL files in the task directory are the manifest for spec/research context. Task artifacts are read separately in this order: `prd.md` -> `design.md if present` -> `implement.md if present`. + +## JSONL Reading Rules + +`implement.jsonl` and `check.jsonl` contain one JSON object per line: + +```jsonl +{"file": ".trellis/spec/backend/index.md", "reason": "Backend rules"} +``` + +Readers should skip seed rows without a `file` field. When configuring JSONL, the AI should include only spec/research files, not pre-register code files that will be modified. + +## Active Task And Context Key + +Active task state lives in `.trellis/.runtime/sessions/` and is isolated per session. Hooks try to resolve the context key from platform events, environment variables, transcript paths, or `TRELLIS_CONTEXT_ID`. + +If shell commands cannot see the same context key, `task.py current --source` may report no active task. In that case, check whether the platform passes session identity into the shell instead of hand-writing a global current-task file. + +## Local Customization Points + +| Need | Edit location | +| --- | --- | +| Change session-start injected content | The platform's `session-start` hook or plugin file. | +| Change per-turn workflow-state rules | `[workflow-state:STATUS]` block in `.trellis/workflow.md`. The platform workflow-state hook parses these blocks verbatim and embeds no fallback text. | +| Change how sub-agents read context | Platform agent definitions, the `inject-subagent-context` hook, or agent preludes. | +| Change JSONL validation/display | `.trellis/scripts/common/task_context.py`. | +| Change active task resolution | `.trellis/scripts/common/active_task.py`. | + +When modifying context injection, verify two things: new sessions can see the correct task, and sub-agents can see the correct task artifacts/spec/research. diff --git a/.agents/skills/trellis-meta/references/local-architecture/generated-files.md b/.agents/skills/trellis-meta/references/local-architecture/generated-files.md new file mode 100644 index 0000000..66f832d --- /dev/null +++ b/.agents/skills/trellis-meta/references/local-architecture/generated-files.md @@ -0,0 +1,80 @@ +# Local Files Generated After Init + +`trellis init` writes the Trellis runtime into the user project. Later, `trellis update` tries to update Trellis-managed template files, but it uses `.trellis/.template-hashes.json` to determine which files have already been modified by the user. + +This page only describes files that are visible and editable inside the user project. + +## `.trellis/` + +```text +.trellis/ +├── workflow.md +├── config.yaml +├── .developer +├── .version +├── .template-hashes.json +├── .runtime/ +├── scripts/ +├── spec/ +├── tasks/ +└── workspace/ +``` + +| Path | Usually editable? | Notes | +| --- | --- | --- | +| `.trellis/workflow.md` | Yes | Local workflow documentation and AI routing rules. | +| `.trellis/config.yaml` | Yes | Project configuration, hooks, packages, journal line limits, and related settings. | +| `.trellis/spec/` | Yes | Project specs, intended to be updated regularly by users and AI. | +| `.trellis/tasks/` | Yes | Task material and research artifacts, maintained by the task workflow. | +| `.trellis/workspace/` | Yes | Session records, usually written by `add_session.py`. | +| `.trellis/scripts/` | Carefully | Local runtime. It can be customized, but only after understanding the call chain. | +| `.trellis/.runtime/` | No | Runtime state, usually written automatically by hooks/scripts. | +| `.trellis/.developer` | Carefully | Current developer identity. | +| `.trellis/.version` | No | Trellis version record used by update/migration logic. | +| `.trellis/.template-hashes.json` | No | Template hash record. Do not hand-write business rules here. | + +## Platform Directories + +Different platforms generate different directories. Common categories: + +| Category | Example paths | Purpose | +| --- | --- | --- | +| hooks | `.claude/hooks/`, `.codex/hooks/`, `.cursor/hooks/` | Inject session context, workflow-state, and sub-agent context. | +| settings | `.claude/settings.json`, `.codex/hooks.json`, `.qoder/settings.json` | Tell the platform when to run hooks or plugins. | +| agents | `.claude/agents/`, `.codex/agents/`, `.kiro/agents/` | Define agents such as `trellis-research`, `trellis-implement`, and `trellis-check`. | +| skills | `.claude/skills/`, `.agents/skills/`, `.qoder/skills/` | Skills that auto-trigger or can be read by AI. | +| commands/prompts/workflows | `.cursor/commands/`, `.github/prompts/`, `.windsurf/workflows/` | Explicit user-invoked command or workflow entry points. | + +When modifying a platform directory, also confirm whether `.trellis/workflow.md` still describes the same flow. + +## Meaning Of Template Hashes + +`.trellis/.template-hashes.json` records the content hash from the last time Trellis wrote a template file. `trellis update` uses it to distinguish three cases: + +| Case | Update behavior | +| --- | --- | +| File was not modified by the user | It can be updated automatically. | +| File was modified by the user | Prompt the user to overwrite, keep, or generate `.new`. | +| File is no longer a current template | It may be deleted, renamed, or preserved according to migration rules. | + +When an AI customizes local Trellis files, it does not need to maintain hashes manually. It is normal for Trellis update to recognize the result as "modified by the user." + +## Local Customization Boundaries + +Editable by default: + +- `.trellis/workflow.md` +- `.trellis/config.yaml` +- `.trellis/spec/**` +- `.trellis/scripts/**` +- Platform hooks, settings, agents, skills, commands, prompts, and workflows + +Do not edit by default: + +- Global npm install directory +- `node_modules/@mindfoldhq/trellis` +- Trellis GitHub repository source code +- Concrete state files under `.trellis/.runtime/**` +- Hash contents inside `.trellis/.template-hashes.json` + +Switch to the Trellis CLI source-code perspective only when the user explicitly wants to contribute upstream. diff --git a/.agents/skills/trellis-meta/references/local-architecture/overview.md b/.agents/skills/trellis-meta/references/local-architecture/overview.md new file mode 100644 index 0000000..99c7f73 --- /dev/null +++ b/.agents/skills/trellis-meta/references/local-architecture/overview.md @@ -0,0 +1,51 @@ +# Local Trellis Architecture Overview + +`trellis-meta` is for user projects that have already run `trellis init`. The user's machine usually has only the npm-installed `trellis` command plus the Trellis files generated inside the project; it may not have the Trellis CLI source code. + +Therefore, when an AI uses this skill, the default customization target is local files inside the user project: + +- `.trellis/`: workflow, tasks, specs, memory, scripts, and runtime state. +- Platform directories: `.claude/`, `.codex/`, `.cursor/`, `.opencode/`, `.kiro/`, `.gemini/`, `.qoder/`, `.codebuddy/`, `.github/`, `.factory/`, `.pi/`, `.kilocode/`, `.agent/`, `.windsurf/`, and similar directories. +- Shared skill layer: `.agents/skills/`. + +Do not default to guiding the user to fork the Trellis CLI repository. Treat upstream source code as the operating target only when the user explicitly says they want to change Trellis upstream source, publish an npm package, or contribute a PR. + +## Local System Model + +Trellis provides three layers inside a user project: + +1. **Workflow layer**: `.trellis/workflow.md` defines phases, routing, next actions, and prompt blocks. +2. **Persistence layer**: `.trellis/tasks/`, `.trellis/spec/`, and `.trellis/workspace/` store tasks, specs, and session memory. +3. **Platform integration layer**: hooks, settings, agents, skills, commands, prompts, and workflows in platform directories connect the Trellis workflow to different AI tools. + +All three layers live inside the user project, so an AI can read and modify them directly. + +## Core Paths + +| Path | Purpose | +| --- | --- | +| `.trellis/workflow.md` | Workflow phases, skill routing, and workflow-state prompt blocks. | +| `.trellis/config.yaml` | Project configuration, task lifecycle hooks, monorepo package configuration, and journal configuration. | +| `.trellis/spec/` | The user's project-specific coding conventions and thinking guides. | +| `.trellis/tasks/` | Each task's PRD, technical notes, research files, and JSONL context. | +| `.trellis/workspace/` | Per-developer journals and cross-session memory. | +| `.trellis/scripts/` | Local Python runtime used by commands, hooks, and context injection. | +| `.trellis/.runtime/` | Session-level runtime state, such as the current task pointer. | +| `.trellis/.template-hashes.json` | Template hashes for Trellis-managed files, used by update to determine whether local files were modified by the user. | + +## AI Customization Principles + +1. **Find the local source of truth first**: Do not edit from memory. Read `.trellis/workflow.md`, `.trellis/config.yaml`, the relevant platform directory, and related task files first. +2. **Edit the user project, not the npm package cache**: Modify generated files inside the project, not `node_modules` or the global npm install directory. +3. **Keep platform files aligned with `.trellis/`**: If workflow routing changes, also check whether platform skills or commands still describe the same flow. +4. **Put project-specific rules in `.trellis/spec/` or a local skill**: Do not put team conventions into `trellis-meta`. +5. **Preserve user changes**: If a file was already modified locally, work from the current content instead of overwriting it with a default template. + +## How To Use This Directory + +- To understand which files exist after init, read `generated-files.md`. +- To change phases, routing, or next actions, read `workflow.md`. +- To change the task model, JSONL context, or active task behavior, read `task-system.md`. +- To change coding convention injection, read `spec-system.md`. +- To understand journals and cross-session memory, read `workspace-memory.md`. +- To change hooks or sub-agent context loading, read `context-injection.md`. diff --git a/.agents/skills/trellis-meta/references/local-architecture/spec-system.md b/.agents/skills/trellis-meta/references/local-architecture/spec-system.md new file mode 100644 index 0000000..1ff49f4 --- /dev/null +++ b/.agents/skills/trellis-meta/references/local-architecture/spec-system.md @@ -0,0 +1,102 @@ +# Local Spec System + +`.trellis/spec/` is the user's project-specific engineering spec library. Trellis is not about making AI memorize conventions; it injects relevant specs or requires the AI to read them at the right time. + +## Directory Model + +A common single-repository structure: + +```text +.trellis/spec/ +├── backend/ +│ ├── index.md +│ └── ... +├── frontend/ +│ ├── index.md +│ └── ... +└── guides/ + ├── index.md + └── ... +``` + +A common monorepo structure: + +```text +.trellis/spec/ +├── cli/ +│ ├── backend/ +│ │ ├── index.md +│ │ └── ... +│ └── unit-test/ +│ ├── index.md +│ └── ... +├── docs-site/ +│ └── docs/ +│ ├── index.md +│ └── ... +└── guides/ + ├── index.md + └── ... +``` + +`index.md` is the entry point for each layer. It should list the Pre-Development Checklist and Quality Check. Specific guidelines live in other Markdown files in the same directory. + +## Package Configuration + +`.trellis/config.yaml` can declare packages: + +```yaml +packages: + cli: + path: packages/cli + docs-site: + path: docs-site + type: submodule +default_package: cli +``` + +The AI can run: + +```bash +python ./.trellis/scripts/get_context.py --mode packages +``` + +This command lists packages and spec layers for the current project. Use this output as the reference when configuring context JSONL. + +## How Specs Enter Tasks + +Before a task enters implementation, planning may write relevant specs into `implement.jsonl` / `check.jsonl` when the task needs spec or research context beyond the task artifacts: + +```jsonl +{"file": ".trellis/spec/cli/backend/index.md", "reason": "CLI backend conventions"} +{"file": ".trellis/spec/cli/unit-test/conventions.md", "reason": "Test expectations"} +``` + +Sub-agents or platform preludes read these JSONL files and load the referenced specs. On platforms without sub-agent support, the AI should read the relevant specs directly according to the workflow. + +## What Specs Should Contain + +Specs should contain executable engineering conventions for the project, not generic best practices: + +- Where files should live. +- How error handling should be expressed. +- Input/output contracts for APIs, hooks, and commands. +- Patterns that are forbidden. +- Cases that require tests. +- Project-specific pitfalls and how to avoid them. + +When the AI learns a new rule during implementation or debugging, it should update `.trellis/spec/` rather than only summarizing it in chat. + +## Local Customization Points + +| Need | Edit location | +| --- | --- | +| Add a new spec layer | `.trellis/spec///index.md` and corresponding guideline files. | +| Change monorepo spec mapping | `packages` / `default_package` / `spec_scope` in `.trellis/config.yaml`. | +| Change which specs AI reads before implementation | The task's `implement.jsonl`. | +| Change which specs AI reads during checking | The task's `check.jsonl`. | +| Change when specs should be updated | Phase 3.3 in `.trellis/workflow.md` and the `trellis-update-spec` skill. | + +## Boundaries + +`.trellis/spec/` is the user's project specification, not a permanent copy of Trellis built-in templates. The AI should encourage the user to update it according to the actual project code instead of treating Trellis default templates as immutable documents. diff --git a/.agents/skills/trellis-meta/references/local-architecture/task-system.md b/.agents/skills/trellis-meta/references/local-architecture/task-system.md new file mode 100644 index 0000000..9dfe5bb --- /dev/null +++ b/.agents/skills/trellis-meta/references/local-architecture/task-system.md @@ -0,0 +1,130 @@ +# Local Task System + +The Trellis task system is stored entirely under `.trellis/tasks/` in the user project. Each task is a directory containing requirements, context, research, state, and relationship information. + +## Task Directory Structure + +```text +.trellis/tasks/ +├── 04-28-example-task/ +│ ├── task.json +│ ├── prd.md +│ ├── design.md +│ ├── implement.md +│ ├── implement.jsonl +│ ├── check.jsonl +│ └── research/ +└── archive/ + └── 2026-04/ +``` + +| File | Purpose | +| --- | --- | +| `task.json` | Task metadata: status, assignee, priority, branch, parent/child tasks, and similar fields. | +| `prd.md` | Requirements, constraints, and acceptance criteria. Lightweight tasks may be PRD-only. | +| `design.md` | Technical design for complex tasks: boundaries, contracts, data flow, compatibility, tradeoffs. | +| `implement.md` | Execution plan for complex tasks: ordered checklist, validation commands, review gates, rollback points. | +| `implement.jsonl` | List of spec/research files the implement agent must read first. | +| `check.jsonl` | List of spec/research files the check agent must read first. | +| `research/` | Research artifacts. Complex findings should not live only in chat. | + +## `task.json` + +`task.json` records task status and metadata. Common fields: + +| Field | Meaning | +| --- | --- | +| `id` / `name` / `title` | Task identity and title. | +| `status` | Status such as `planning`, `in_progress`, `review`, or `completed`. | +| `priority` | `P0`, `P1`, `P2`, `P3`. | +| `creator` / `assignee` | Creator and assignee. | +| `package` | Target package in a monorepo; may be empty. | +| `branch` / `base_branch` | Working branch and PR target branch. | +| `children` / `parent` | Parent/child task relationships. | +| `commit` / `pr_url` | Commit and PR information after completion. | +| `meta` | Extension fields. | + +## Parent / Child Task Trees + +Parent/child task relationships are for work structure. A parent task groups related deliverables under one source requirement set; it is not a dependency scheduler and does not replace the child task's own planning artifacts. + +Use a parent task when a request has multiple independently verifiable deliverables. The parent owns: + +- Source requirements and user-facing scope. +- The map of child tasks and their responsibility boundaries. +- Cross-child acceptance criteria and final integration review. + +Use child tasks for deliverables that can move through planning, implementation, check, and archive independently. If one child depends on another, write that dependency in the child `prd.md` / `implement.md`; do not rely on tree position to imply ordering. + +Create new children with: + +```bash +python ./.trellis/scripts/task.py create "" --slug --parent +``` + +Link or unlink existing tasks with: + +```bash +python ./.trellis/scripts/task.py add-subtask +python ./.trellis/scripts/task.py remove-subtask +``` + +`children` on the parent is a historical list. When a child is archived, Trellis keeps that child name in the parent so progress like `[2/3 done]` remains meaningful after completed children move to `archive/`. + +The AI should not treat phase numbers as task status. Task progress is mainly determined by `status`, artifact presence (`prd.md`, optional `design.md` / `implement.md`), whether JSONL context is configured for sub-agent mode, and the phase descriptions in `workflow.md`. + +## Active Task + +The user sees a "current task," but Trellis stores active task state per session. + +```text +.trellis/.runtime/sessions/.json +``` + +`task.py start` writes the task path into the runtime session file for the current session. `task.py current --source` shows the current task and where it came from. Different AI windows can point to different tasks without overwriting each other. + +If the platform or shell environment has no stable session identity, `task.py start` may be unable to set the active task. The AI should read the error, inspect the platform hook/session environment, and not fall back to a shared global pointer. + +## JSONL Context + +`implement.jsonl` and `check.jsonl` are context manifests for sub-agents to read first. They do not replace `implement.md`; `implement.md` is the human-readable execution plan. + +Format: + +```jsonl +{"file": ".trellis/spec/cli/backend/index.md", "reason": "Backend conventions"} +{"file": ".trellis/tasks/04-28-example/research/api.md", "reason": "API research"} +``` + +Rules: + +- Include spec and research files. +- Do not include code files that are about to be modified. +- Do not treat temporary conclusions in chat as the only context. +- Seed rows have no `file` field; they only prompt the AI to fill in real entries. + +## Common Commands + +```bash +python ./.trellis/scripts/task.py create "" --slug <slug> +python ./.trellis/scripts/task.py start <task> +python ./.trellis/scripts/task.py current --source +python ./.trellis/scripts/task.py add-context <task> implement <file> <reason> +python ./.trellis/scripts/task.py validate <task> +python ./.trellis/scripts/task.py finish +python ./.trellis/scripts/task.py archive <task> +``` + +When modifying the task system, the AI should prefer script commands to maintain structure. Edit JSON/Markdown directly only when scripts do not cover the need. + +## Local Customization Points + +| Need | Edit location | +| --- | --- | +| Change the default task template | `.trellis/scripts/common/task_store.py` and task creation instructions. | +| Change status semantics | `.trellis/workflow.md`, workflow-state hook logic, and task usage conventions. | +| Add task lifecycle actions | `hooks.after_*` in `.trellis/config.yaml`. | +| Change context rules | Planning artifact guidance in `.trellis/workflow.md` and related platform agent/hook instructions. | +| Change archive policy | `.trellis/scripts/common/task_store.py` / `task_utils.py`. | + +These are local files in the user project. Do not default to editing Trellis CLI source code unless the user wants to contribute upstream. diff --git a/.agents/skills/trellis-meta/references/local-architecture/workflow.md b/.agents/skills/trellis-meta/references/local-architecture/workflow.md new file mode 100644 index 0000000..f0659ff --- /dev/null +++ b/.agents/skills/trellis-meta/references/local-architecture/workflow.md @@ -0,0 +1,75 @@ +# Local Workflow System + +`.trellis/workflow.md` is the Trellis workflow source of truth inside the user project. An AI does not need Trellis source code to understand how the current project should move tasks forward; this file is enough. + +## File Responsibilities + +`.trellis/workflow.md` has three responsibilities: + +1. **Explain workflow phases**: Plan, Execute, Finish. +2. **Define skill routing**: which skill or agent the AI should use when the user expresses a certain intent. +3. **Provide workflow-state prompt blocks**: hooks can inject the prompt block for the current state into the conversation. + +## Current Phase Model + +```text +Phase 1: Plan -> clarify what to build, produce prd.md and required research +Phase 2: Execute -> implement against the PRD and specs, then check +Phase 3: Finish -> final verification, preserve lessons, and wrap up +``` + +Each phase contains numbered steps, such as `1.3 Configure context`. These numbers are not runtime fields in `task.json`; they are workflow structure for AI and humans to read. + +## Skill Routing + +`workflow.md` separates routing by platform capability: + +- Platforms with sub-agent support: dispatch `trellis-implement` by default for implementation and `trellis-check` for checking. +- Platforms without sub-agent support: the main session reads skills such as `trellis-before-dev`, then executes directly. + +When changing local AI behavior, update the routing descriptions in `workflow.md` first, then check whether the corresponding platform skill, command, or agent files need to stay in sync. + +## Workflow-State Prompt Blocks + +The bottom of `workflow.md` can contain state blocks like this: + +```text +[workflow-state:no_task] +... +[/workflow-state:no_task] +``` + +Hooks choose the right block based on current task status and inject it into the conversation. Common states include: + +| State | Meaning | +| --- | --- | +| `no_task` | The current session has no active task. | +| `planning` | The task is still in requirements, research, or context configuration. | +| `in_progress` | The task has entered implementation and checking. | +| `completed` | The task is complete and waiting for wrap-up or archive. | + +If the user wants to change policies such as "whether to create a task when there is no task," "when task creation may be skipped," or "whether sub-agents are required," edit these state blocks and the routing table above them. + +## Local Modification Patterns + +Common changes: + +| Goal | Edit point | +| --- | --- | +| Add a phase | Update the Phase Index, phase body, routing, and state blocks. | +| Change task creation policy | Update the `no_task` state block and Phase 1 description. | +| Change the default implementation/check path | Update Phase 2 and skill routing. | +| Change the wrap-up flow | Update Phase 3 and `finish-work` related descriptions. Note the current split: Phase 3.4 = AI-driven code commits (batched, user-confirmed), Phase 3.5 = `/finish-work` (archive + record session). `/finish-work` refuses to run if the working tree is dirty. | +| Change platform differences | Update routing descriptions grouped by platform. | + +After editing, make the AI reread `.trellis/workflow.md`; do not assume the flow from the old conversation is still valid. + +## Relationship To Platform Files + +`workflow.md` is the semantic center of the local workflow, but each platform can also have its own entry files: + +- skills, such as `trellis-brainstorm` and `trellis-check`. +- commands/prompts/workflows, such as continue and finish-work. +- hooks, such as session-start or workflow-state injection. + +If only `workflow.md` changes, platform entry files may still contain old language. When the user wants to change "what the AI actually does," also inspect the relevant platform directory. diff --git a/.agents/skills/trellis-meta/references/local-architecture/workspace-memory.md b/.agents/skills/trellis-meta/references/local-architecture/workspace-memory.md new file mode 100644 index 0000000..92d29f4 --- /dev/null +++ b/.agents/skills/trellis-meta/references/local-architecture/workspace-memory.md @@ -0,0 +1,71 @@ +# Local Workspace Memory System + +`.trellis/workspace/` stores cross-session memory. Its purpose is to let AI and humans understand what happened before across different windows and different days. + +## Directory Structure + +```text +.trellis/workspace/ +├── index.md +└── <developer>/ + ├── index.md + ├── journal-1.md + └── journal-2.md +``` + +| File | Purpose | +| --- | --- | +| `.trellis/.developer` | Current developer identity. | +| `.trellis/workspace/index.md` | Global workspace overview. | +| `.trellis/workspace/<developer>/index.md` | Session index for a developer. | +| `.trellis/workspace/<developer>/journal-N.md` | Session journal. | + +## Developer Identity + +Run this the first time: + +```bash +python ./.trellis/scripts/init_developer.py <name> +``` + +This creates `.trellis/.developer` and the corresponding workspace directory. The AI should not change developer identity casually; if the identity is wrong, first confirm who is using the current project. + +## Journal + +`journal-N.md` records completed or partially completed work from each session. By default, each journal holds about 2000 lines; after that it rotates to the next file. + +Common command for recording a session: + +```bash +python ./.trellis/scripts/add_session.py \ + --title "Session title" \ + --summary "What changed" \ + --commit "abc1234" +``` + +Planning or review work without a commit can also be recorded by using `--no-commit` or an empty commit value. + +## Relationship Between Workspace Memory And Tasks + +| System | What it stores | +| --- | --- | +| `.trellis/tasks/` | Requirements, design, research, and state for a specific task. | +| `.trellis/workspace/` | Work records across tasks and sessions. | +| `.trellis/spec/` | Engineering knowledge preserved as long-term conventions. | + +If information is only useful for the current task, put it in the task directory. +If information describes what happened in the current session, put it in the workspace journal. +If information should be followed every time code is written in the future, put it in spec. + +## Local Customization Points + +| Need | Edit location | +| --- | --- | +| Change maximum journal lines | `max_journal_lines` in `.trellis/config.yaml`. | +| Change session auto-commit message | `session_commit_message` in `.trellis/config.yaml`. | +| Change session content format | `.trellis/scripts/add_session.py`. | +| Change how workspace is displayed in context | `.trellis/scripts/common/session_context.py`. | + +## AI Usage Rules + +The AI should not treat workspace as the only source of truth. When resuming a task, read the current task first, then use workspace for background. After a task is complete, record important process notes in workspace; if long-term rules emerged, update spec. diff --git a/.agents/skills/trellis-meta/references/platform-files/agents.md b/.agents/skills/trellis-meta/references/platform-files/agents.md new file mode 100644 index 0000000..3976987 --- /dev/null +++ b/.agents/skills/trellis-meta/references/platform-files/agents.md @@ -0,0 +1,80 @@ +# Agents + +Trellis agent files define specialized roles. Common Trellis agents in a user project are: + +- `trellis-research` +- `trellis-implement` +- `trellis-check` + +File locations and formats differ by platform, but responsibility boundaries should stay consistent. + +## Agent Responsibilities + +| Agent | Responsibility | +| --- | --- | +| `trellis-research` | Investigate the question and write findings into the current task's `research/`. | +| `trellis-implement` | Implement against `prd.md`, optional `design.md` / `implement.md`, `implement.jsonl`, and related spec/research. | +| `trellis-check` | Review changes, fix discovered issues, and run necessary checks. | + +Agent files should not become generic chat prompts. They should define input sources, write boundaries, whether code may be changed, and how results are reported. + +## Common Paths + +| Platform | Agent path | +| --- | --- | +| Claude Code | `.claude/agents/trellis-*.md` | +| Cursor | `.cursor/agents/trellis-*.md` | +| OpenCode | `.opencode/agents/trellis-*.md` | +| Codex | `.codex/agents/trellis-*.toml` | +| Kiro | `.kiro/agents/trellis-*.json` | +| Gemini CLI | `.gemini/agents/trellis-*.md` | +| Qoder | `.qoder/agents/trellis-*.md` | +| CodeBuddy | `.codebuddy/agents/trellis-*.md` | +| Factory Droid | `.factory/droids/trellis-*.md` | +| Pi Agent | `.pi/agents/trellis-*.md` | + +GitHub Copilot agent/prompt support is provided by a combination of directories such as `.github/agents/`, `.github/prompts/`, and `.github/skills/`; inspect the files actually generated in the user project. + +Main-session workflow platforms such as Kilo, Antigravity, and Windsurf may not have Trellis sub-agent files. They usually rely on workflows/skills to guide the main session. + +## Two Context Loading Modes + +### hook push + +The platform hook injects task context before the agent starts. The agent file itself can focus more on responsibilities and boundaries. + +Common on platforms that support agent hooks. + +### agent pull + +The agent file instructs the agent to read after startup: + +- `python ./.trellis/scripts/task.py current --source` +- `implement.jsonl` or `check.jsonl` +- spec/research files referenced by JSONL +- current task `prd.md` +- `design.md` if present +- `implement.md` if present + +This mode fits platforms whose hooks cannot reliably rewrite sub-agent prompts. + +## Local Change Scenarios + +| User need | Edit location | +| --- | --- | +| Implement agent must follow extra restrictions | The platform's `trellis-implement` agent file. | +| Check agent must run project-specific commands | `trellis-check` agent file, and `.trellis/spec/` if needed. | +| Research agent must output a fixed format | `trellis-research` agent file. | +| Agent cannot read task context | Agent prelude or `inject-subagent-context` hook. | +| Add a project-specific agent | Platform agent directory + related workflow/command/skill entry point. | + +## Modification Principles + +1. **Keep responsibilities single-purpose**. Do not mix research, implement, and check responsibilities into one agent. +2. **Specify the read order**. Agents must know to start from the active task, read jsonl/spec context, then read `prd.md`, `design.md` if present, and `implement.md` if present. +3. **Specify write boundaries**. Research usually only writes `research/`; implement can write code; check can fix issues. +4. **Keep semantics synchronized in multi-platform projects**. If the user configured Claude, Codex, and Cursor together, decide whether changes to one platform's agent also need to be applied to others. + +## Do Not Default To Editing Upstream Templates + +Local AI should default to modifying platform agent files inside the user project. Discuss upstream template source only when the user explicitly wants to contribute the change back to Trellis. diff --git a/.agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md b/.agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md new file mode 100644 index 0000000..94156a8 --- /dev/null +++ b/.agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md @@ -0,0 +1,69 @@ +# Hooks And Settings + +Hooks/settings are the entry layer that connects a platform to Trellis. They decide which scripts, plugins, or extensions a platform runs for which events. + +## Settings Responsibilities + +settings/config files usually register: + +- session-start hook: injects a Trellis overview when a new session starts or context resets. +- workflow-state hook: parses `[workflow-state:STATUS]` blocks from `.trellis/workflow.md` and emits the body matching the current task `status` on each user input. Parser-only; the script does not embed fallback content. +- sub-agent context hook: injects task context when implementation/check/research agents start. +- shell/session bridge: lets shell commands see the same Trellis session identity. +- platform plugin or extension entry points. + +Common files: + +| Platform | settings/config | +| --- | --- | +| Claude Code | `.claude/settings.json` | +| Cursor | `.cursor/hooks.json` | +| Codex | `.codex/hooks.json`, `.codex/config.toml` | +| OpenCode | `.opencode/package.json`, `.opencode/plugins/*` | +| Kiro | `.kiro/hooks/` + platform config | +| Gemini CLI | `.gemini/settings.json` | +| Qoder | `.qoder/settings.json` | +| CodeBuddy | `.codebuddy/settings.json` | +| GitHub Copilot | `.github/copilot/hooks.json` | +| Factory Droid | `.factory/settings.json` | +| Pi Agent | `.pi/settings.json`, `.pi/extensions/trellis/` | + +Whether these files exist in a project depends on which `trellis init --<platform>` flags the user ran. + +## Hook Script Types + +| Script | Purpose | +| --- | --- | +| `session-start.py` | Generates session-start context. | +| `inject-workflow-state.py` | Parses `[workflow-state:STATUS]` blocks in `.trellis/workflow.md` and emits the body matching the current task status. Falls back to `Refer to workflow.md for current step.` when no matching block exists. | +| `inject-subagent-context.py` | Injects PRD, JSONL context, and related spec/research into sub-agents. | +| `inject-shell-session-context.py` | Lets shell commands inherit Trellis session identity. | + +Not every platform has every hook. Do not copy files from another platform just because a platform lacks a hook; first confirm whether that platform supports the corresponding event. + +## Local Change Scenarios + +| User need | Edit location | +| --- | --- | +| AI should see more/less context in a new session | Platform `session-start` hook. | +| Per-turn hint policy should change | `[workflow-state:STATUS]` block in `.trellis/workflow.md`. The hook parses workflow.md verbatim — no script edit required. | +| Sub-agent cannot read PRD/spec | `inject-subagent-context` hook or agent prelude. | +| `task.py current` in shell has no active task | Shell/session bridge hook or platform environment variable configuration. | +| Disable an automatic injection | The corresponding hook registration in settings/config. | + +## Modification Principles + +1. **Settings wire things up; hooks define behavior**. If only the hook changes, the platform may never call it. If only settings change, behavior may not change. +2. **Confirm platform event names first**. Different platforms use different names for SessionStart, UserPromptSubmit, AgentSpawn, shell execution, and similar events. +3. **Hooks read local `.trellis/`, not upstream source**. `.trellis/scripts/` and `.trellis/workflow.md` in the user project are the default targets. +4. **Errors must be visible**. Hook failures should tell the user what was not injected instead of silently leaving the AI without context. + +## Troubleshooting Path + +If the user says "AI did not read Trellis state": + +1. Check whether the platform settings register the hook. +2. Check whether the hook file exists. +3. Manually run the `.trellis/scripts/get_context.py` or `task.py current --source` command that the hook depends on. +4. Check whether active task state exists in `.trellis/.runtime/sessions/`. +5. Check whether the platform shell passes session identity. diff --git a/.agents/skills/trellis-meta/references/platform-files/overview.md b/.agents/skills/trellis-meta/references/platform-files/overview.md new file mode 100644 index 0000000..60ae1df --- /dev/null +++ b/.agents/skills/trellis-meta/references/platform-files/overview.md @@ -0,0 +1,59 @@ +# Platform Files Overview + +Trellis connects the same local architecture to different AI tools. `.trellis/` stores the shared runtime; platform directories store adapter files that define how each AI tool enters Trellis. + +When a local AI modifies Trellis, it should distinguish two file categories first: + +- **Shared files**: `.trellis/workflow.md`, `.trellis/tasks/`, `.trellis/spec/`, `.trellis/scripts/`. +- **Platform files**: `.claude/`, `.codex/`, `.cursor/`, `.opencode/`, `.kiro/`, `.gemini/`, `.qoder/`, `.codebuddy/`, `.github/`, `.factory/`, `.pi/`, `.kilocode/`, `.agent/`, `.windsurf/`, and similar directories. + +Platform files do not store business state. They let the corresponding AI tool read Trellis state, call Trellis scripts, and load Trellis skills/agents/hooks. + +## Platform File Categories + +| Category | Common paths | Purpose | +| --- | --- | --- | +| settings/config | `.claude/settings.json`, `.codex/hooks.json`, `.qoder/settings.json` | Register hooks, plugins, extensions, or platform behavior. | +| hooks/plugins/extensions | `.claude/hooks/`, `.opencode/plugins/`, `.pi/extensions/` | Inject context at session start, user input, agent startup, shell execution, and similar events. | +| agents | `.claude/agents/`, `.codex/agents/`, `.kiro/agents/` | Define `trellis-research`, `trellis-implement`, and `trellis-check`. | +| skills | `.claude/skills/`, `.agents/skills/`, `.qoder/skills/` | Capability descriptions that auto-trigger or can be read on demand. | +| commands/prompts/workflows | `.cursor/commands/`, `.github/prompts/`, `.windsurf/workflows/` | Entry points explicitly invoked by the user. | + +## Three Platform Integration Modes + +### 1. Hook / Extension Driven + +These platforms can trigger scripts or plugins on specific events and actively inject Trellis context into AI. + +Common capabilities: + +- session-start injection of a `.trellis/` overview. +- workflow-state hints for each user turn. +- PRD/spec/research injection when sub-agents start. +- Shell commands inheriting session identity. + +To change "when the AI knows what," inspect hooks/plugins/extensions and settings first. + +### 2. Agent Prelude / Pull-Based + +Some platforms cannot reliably let hooks rewrite sub-agent prompts, so the agent file itself instructs the agent to read the active task, PRD, and JSONL context after startup. + +To change how sub-agents load context, inspect the agent files themselves. + +### 3. Main-Session Workflow + +Some platforms do not have Trellis sub-agent or hook capabilities. They rely on workflows/skills/commands to guide the main-session AI to read files, run scripts, and move tasks forward. + +To change behavior, inspect platform workflows/skills/commands and `.trellis/workflow.md`. + +## Local Modification Order + +When the user asks to customize behavior for a platform, the AI should inspect files in this order: + +1. Read `.trellis/workflow.md` to confirm the shared flow. +2. Read the target platform's settings/config to see which hooks/agents/skills/commands are registered. +3. Read the target platform's agents/skills/commands/hooks. +4. Modify the local file closest to the user's need. +5. If the change affects the shared flow, synchronize `.trellis/workflow.md` or `.trellis/spec/`. + +Do not modify only platform files and forget the shared workflow. Do not modify only `.trellis/workflow.md` and forget that platform entry points may still contain old descriptions. diff --git a/.agents/skills/trellis-meta/references/platform-files/platform-map.md b/.agents/skills/trellis-meta/references/platform-files/platform-map.md new file mode 100644 index 0000000..b5576f4 --- /dev/null +++ b/.agents/skills/trellis-meta/references/platform-files/platform-map.md @@ -0,0 +1,74 @@ +# Platform File Map + +This page lists common Trellis file locations in a user project by platform. Whether a platform directory exists in an actual project depends on which `trellis init --<platform>` commands the user ran. + +## Matrix + +| Platform | CLI flag | Main directory | Skill directory | Agent directory | Hooks/extensions | +| --- | --- | --- | --- | --- | --- | +| Claude Code | `--claude` | `.claude/` | `.claude/skills/` | `.claude/agents/` | `.claude/hooks/` + `.claude/settings.json` | +| Cursor | `--cursor` | `.cursor/` | `.cursor/skills/` | `.cursor/agents/` | `.cursor/hooks.json` + `.cursor/hooks/` | +| OpenCode | `--opencode` | `.opencode/` | `.opencode/skills/` | `.opencode/agents/` | `.opencode/plugins/` | +| Codex | `--codex` | `.codex/` | `.agents/skills/` | `.codex/agents/` | `.codex/hooks/` + `.codex/hooks.json` | +| Kilo | `--kilo` | `.kilocode/` | `.kilocode/skills/` | Usually none | `.kilocode/workflows/` | +| Kiro | `--kiro` | `.kiro/` | `.kiro/skills/` | `.kiro/agents/` | `.kiro/hooks/` | +| Gemini CLI | `--gemini` | `.gemini/` | `.agents/skills/` | `.gemini/agents/` | `.gemini/settings.json` + `.gemini/hooks/` | +| Antigravity | `--antigravity` | `.agent/` | `.agent/skills/` | Usually none | `.agent/workflows/` | +| Windsurf | `--windsurf` | `.windsurf/` | `.windsurf/skills/` | Usually none | `.windsurf/workflows/` | +| Qoder | `--qoder` | `.qoder/` | `.qoder/skills/` | `.qoder/agents/` | `.qoder/hooks/` + `.qoder/settings.json` | +| CodeBuddy | `--codebuddy` | `.codebuddy/` | `.codebuddy/skills/` | `.codebuddy/agents/` | `.codebuddy/hooks/` + `.codebuddy/settings.json` | +| GitHub Copilot | `--copilot` | `.github/` | `.github/skills/` | `.github/agents/` | `.github/copilot/hooks/` + prompts | +| Factory Droid | `--droid` | `.factory/` | `.factory/skills/` | `.factory/droids/` | `.factory/hooks/` + settings | +| Pi Agent | `--pi` | `.pi/` | `.pi/skills/` | `.pi/agents/` | `.pi/extensions/trellis/` + `.pi/settings.json` | + +## Capability Groups + +### Trellis Sub-Agent Support + +These platforms usually have `trellis-research`, `trellis-implement`, and `trellis-check` files: + +- Claude Code +- Cursor +- OpenCode +- Codex +- Kiro +- Gemini CLI +- Qoder +- CodeBuddy +- GitHub Copilot +- Factory Droid +- Pi Agent + +When changing implementation/check/research behavior, look for the corresponding platform agent files first. + +### Main-Session Workflow Platforms + +These platforms rely more on workflows/skills to guide the main session: + +- Kilo +- Antigravity +- Windsurf + +When changing behavior, inspect workflows and skills first. Do not assume Trellis sub-agents exist. + +### Shared `.agents/skills/` + +Codex writes the shared `.agents/skills/` layer. Some tools that support agentskills.io can also read this directory. If the user wants multiple compatible tools to share one skill, consider `.agents/skills/` first, but do not assume every platform reads it. + +## Decision Rules When Modifying Platform Files + +1. User specified a platform: modify only that platform directory unless shared workflow/spec files must also change. +2. User says "all platforms should do this": synchronize equivalent entry points platform by platform; do not modify only one directory. +3. User only says "my AI": inspect the configuration directories that actually exist in the project and infer the current AI platform. +4. User wants project rules: prefer `.trellis/spec/` or a project-local skill. +5. User wants Trellis behavior: edit `.trellis/workflow.md` plus platform hooks/agents/skills/commands. + +## When Paths Differ + +Platform ecosystems change, and user projects may already be customized. If this table disagrees with local files, use the actual settings/config in the user project as authoritative: + +- Check the hook that settings registers. +- Check the script that a command/prompt/workflow points to. +- Judge behavior by the read rules currently written in the agent file. + +Do not delete a custom file just because it is not listed in this path table. diff --git a/.agents/skills/trellis-meta/references/platform-files/skills-and-commands.md b/.agents/skills/trellis-meta/references/platform-files/skills-and-commands.md new file mode 100644 index 0000000..816c666 --- /dev/null +++ b/.agents/skills/trellis-meta/references/platform-files/skills-and-commands.md @@ -0,0 +1,83 @@ +# Skills, Commands, Prompts, And Workflows + +Skills and commands are textual entry points for user interaction with Trellis. Different platforms use different names, but their core purpose is the same: tell the AI how to enter the Trellis flow when the user expresses a certain intent. + +## Conceptual Differences + +| Type | Trigger mode | Best for | +| --- | --- | --- | +| skill | AI auto-match or explicit user mention | Long-term capabilities, workflow rules, modification guides. | +| command | Explicit user invocation | Clear operation entry points such as continue and finish-work. | +| prompt | Explicit user invocation or platform selection | Similar to command, but in a platform prompt format. | +| workflow | Explicit user selection or platform auto-match | Guides the main session when no sub-agent/hook exists. | + +Trellis workflow skills usually share one semantic set: brainstorm, before-dev, check, update-spec, break-loop. Multi-file built-in skills such as `trellis-meta` use layered references. + +## Common Paths + +| Platform | Common entries | +| --- | --- | +| Claude Code | `.claude/skills/`, `.claude/commands/` | +| Cursor | `.cursor/skills/`, `.cursor/commands/` | +| OpenCode | `.opencode/skills/`, `.opencode/commands/` | +| Codex | `.agents/skills/`, `.codex/skills/` | +| Kilo | `.kilocode/skills/`, `.kilocode/workflows/` | +| Kiro | `.kiro/skills/` | +| Gemini CLI | `.agents/skills/`, `.gemini/commands/` | +| Antigravity | `.agent/skills/`, `.agent/workflows/` | +| Windsurf | `.windsurf/skills/`, `.windsurf/workflows/` | +| Qoder | `.qoder/skills/`, `.qoder/commands/` | +| CodeBuddy | `.codebuddy/skills/`, `.codebuddy/commands/` | +| GitHub Copilot | `.github/skills/`, `.github/prompts/` | +| Factory Droid | `.factory/skills/`, `.factory/commands/` | +| Pi Agent | `.pi/skills/` | + +In a user project, use the files actually generated by init as authoritative. + +## Skill Structure + +A common skill is a directory: + +```text +trellis-meta/ +├── SKILL.md +└── references/ +``` + +`SKILL.md` should tell the AI: + +- When to use this skill. +- Which reference to read first for the current task. +- What not to do. + +References hold longer explanations so the entry file does not contain everything. + +## Command/Prompt/Workflow Structure + +Commands, prompts, and workflows are usually single files. Their content should include: + +- When to use it. +- Which `.trellis/` files to read. +- Which scripts to run. +- How to report after completion. + +They should not store task state; task state belongs in `.trellis/tasks/` and `.trellis/.runtime/`. + +## Local Change Scenarios + +| User need | Edit location | +| --- | --- | +| Change AI auto-trigger rules | The corresponding skill's frontmatter description. | +| Change user command behavior | The corresponding command/prompt/workflow file. | +| Add a project-local skill | Platform skill directory, or shared `.agents/skills/`. | +| Let multiple platforms share one capability | Write equivalent skills in each platform skill directory, or use the `.agents/skills/` shared layer on platforms that support it. | +| Change finish/continue entry points | Platform commands/prompts/workflows. | + +## Modification Principles + +1. **Keep entry files short; references carry long content**. This matters especially for multi-file skills like `trellis-meta`. +2. **Make trigger descriptions specific**. A description that is too broad can mis-trigger; one that is too narrow may not trigger. +3. **Keep the same semantics consistent across platforms**. File formats can differ, but behavior descriptions should match. +4. **Put project-specific capabilities in local skills**. Do not put team-private flows into public `trellis-meta`. + +If the user only wants local AI to know one more project rule, usually create a project-local skill or update `.trellis/spec/` instead of changing a Trellis built-in workflow skill. diff --git a/.agents/skills/trellis-session-insight/SKILL.md b/.agents/skills/trellis-session-insight/SKILL.md new file mode 100644 index 0000000..3670739 --- /dev/null +++ b/.agents/skills/trellis-session-insight/SKILL.md @@ -0,0 +1,81 @@ +--- +name: trellis-session-insight +description: "Reach into past AI conversation history through the `trellis mem` CLI. Use whenever the user asks 'how did we solve X last time', 'have we discussed this before', 'what was the decision on X', 'remind me what we did in this task', '上次怎么解的', '之前讨论过吗', '想起一段对话', or when starting a brainstorm that overlaps prior work, debugging a familiar bug, continuing a task across sessions, or doing a finish-work review. Returns raw past dialogue; decide for the moment whether to update spec, append to task notes, quote inline in the answer, or just internalize." +--- + +# Trellis Session Insight + +This skill teaches an AI **how to call `trellis mem`** — the project's cross-session memory feedstock — and **when reaching for it is the right move**. + +It is intentionally a **capability skill, not a workflow**. There is no fixed output file, no required write-back step, no "always run after finish-work" rule. What to do with what `mem` returns is a judgement call made in the moment of the conversation. The skill exists so the AI knows the capability is there and can decide. + +## What `trellis mem` is + +A local CLI that indexes the user's past Claude Code and Codex conversation logs (the JSONL files each platform stores under `~/.claude/projects/` and `~/.codex/sessions/`) and lets you list, search, slice by Trellis task boundaries, and dump cleaned dialogue from them. OpenCode logs are not yet indexable (provider adapter pending) — when an OpenCode session is the obvious target, surface that limitation rather than guessing. + +Nothing in `mem` is uploaded. All reads are local. + +## When to reach for it + +The bar is "would a senior teammate ask 'didn't we already talk about this?'" — those are the moments. Some concrete patterns: + +- **Brainstorm rerun risk.** Starting a new task that touches an area the user has been in before, and you want to check whether a decision was already made — before re-asking the user. +- **Familiar-bug debugging.** The current bug pattern feels like one the user reported / fixed before. Pulling the relevant past session can save a full debugging loop. +- **Cross-session continuation.** The user resumes work after a gap and says "where were we" / "继续上次的" without being specific. +- **Decision retrieval.** The user references "the decision we made about X" but the decision lives in an old brainstorm, not in any `prd.md` / `spec/`. +- **Finish-work retrospective.** When the user explicitly asks for a wrap-up of what was decided / what hurt / what surprised them in this task — not as a forced step on every finish-work. +- **Pattern-spotting across past work.** The user asks "do I keep making the same mistake on X" / "我每次都踩这个坑吗" — search across sessions answers that. + +If none of these apply, don't call `mem`. It is a tool, not a ceremony. + +## When NOT to reach for it + +- The relevant context is already in the current turn, `prd.md`, `design.md`, recent `git log`, or the open files. `mem` is for stuff that has fallen out of immediate reach. +- The user is asking about a fact in the code, not a fact from a past conversation. `git log -p` / `grep` / reading the file directly is faster and more authoritative. +- You are in a sub-agent (`trellis-implement` / `trellis-check`) whose dispatch prompt already includes the curated `implement.jsonl` / `check.jsonl` context. Adding `mem` on top usually just clutters. +- The user has explicitly said "don't dig through history, just answer what I asked". + +## What to do with what `mem` returns + +Treat the output as **raw material**, not a deliverable. Once you have it, decide based on the live conversation: + +- **Quote inline in your reply** if a specific past exchange answers the user's current question — and cite the session-id / phase so the user can verify. +- **Update `<task>/prd.md` or `<task>/design.md`** if `mem` surfaced a load-bearing decision that should have been written down but wasn't. Surface the proposed edit to the user first. +- **Append to a task-local notes file** (e.g. `<task>/notes.md` or extending an existing one) if the finding belongs to the current task's record but doesn't fit the PRD. +- **Update `.trellis/spec/`** if the finding is a project-wide convention or gotcha that would help future tasks. Run the `trellis-update-spec` skill for that — `session-insight` ends at the discovery. +- **Just absorb it** for the next few turns and answer better, without writing anything. This is often the right move for one-off recall. + +Trellis does not prescribe a single destination. Forcing every recall into a fixed file makes the file grow into noise. Let the situation decide. + +## How to call it + +Full CLI reference is in `references/cli-quick-reference.md`. The 80% case is one of: + +```bash +# Find sessions whose contents mention a keyword (project-scope is default; +# add --global to search every project on this machine). +trellis mem search "<keyword>" + +# Dump dialogue from one session, optionally filtered by phase or keyword. +trellis mem extract <session-id> --phase brainstorm +trellis mem extract <session-id> --grep "<keyword>" + +# Drill into a session: top-N hit turns + surrounding context. +trellis mem context <session-id> --turns 3 --around 2 + +# When you do not know the session id yet, start with list + filter. +trellis mem list --task <task-dir> +trellis mem projects # → list active project cwds, then narrow +``` + +Phase slicing (`--phase brainstorm|implement|all`) cuts the session at `task.py create` and `task.py start` boundaries. For a finish-work review of the current task, `--phase brainstorm` recovers the planning discussion and `--phase implement` recovers the execution loop. Default is `all`. + +## Triggering patterns + +`references/triggering-patterns.md` lists more verbatim user phrasings (English + Chinese) that should make you think "reach for `mem`" — keep that handy when training instinct. + +## Out of scope + +- `mem` does not edit code or update files. Any write-back is your decision in the moment. +- `mem` is read-only on the platform JSONL stores. It does not push or sync to remote. +- This skill does not replace `trellis-update-spec` (which is the right tool for promoting a finding into project-wide guidance) or the platform-native task / spec workflow. diff --git a/.agents/skills/trellis-session-insight/references/cli-quick-reference.md b/.agents/skills/trellis-session-insight/references/cli-quick-reference.md new file mode 100644 index 0000000..3d5f95c --- /dev/null +++ b/.agents/skills/trellis-session-insight/references/cli-quick-reference.md @@ -0,0 +1,66 @@ +# `trellis mem` CLI Reference + +Full flag reference for the five subcommands. Pin this as the authoritative source — `trellis mem help` prints the same content at runtime, so anything here that drifts is a bug. + +## Subcommands + +| Command | Purpose | +|---|---| +| `list` | List sessions. Default subcommand when none is given. | +| `search <keyword>` | Find sessions whose contents match a keyword. | +| `context <session-id>` | Drill into one session: top-N hit turns + surrounding context. Pair with `--grep` for keyword anchoring. | +| `extract <session-id>` | Dump cleaned dialogue. Combine with `--phase` / `--grep` to slice. | +| `projects` | List active project `cwd` values with session counts. Use this to discover which `--cwd` to pass to other subcommands. | + +## Flags (apply where meaningful) + +| Flag | Subcommands | Meaning | +|---|---|---| +| `--platform claude\|codex\|opencode\|all` | all | Default `all`. OpenCode adapter is currently a stub on `0.6.0-beta.*` — see "Caveats" below. | +| `--since YYYY-MM-DD` | list / search | Inclusive lower date bound. | +| `--until YYYY-MM-DD` | list / search | Inclusive upper date bound. | +| `--global` | list / search | Include sessions from every project on this machine. Default is the current project `cwd`. | +| `--cwd <path>` | list / search | Force a specific project cwd instead of inferring from where you are. | +| `--limit N` | list / search | Cap output rows. Default `50`. | +| `--grep KW` | extract / context | Filter turns by keyword. Multi-token AND when whitespace-separated. | +| `--phase brainstorm\|implement\|all` | extract | Slice session by Trellis task boundaries. `brainstorm` = `[task.py create, task.py start)`. `implement` = `[task.py start, task.py finish)` window. Default `all`. | +| `--turns N` | context | Number of hit turns to return. Default `3`. | +| `--around N` | context | Surrounding turns to include per hit. Default `1`. | +| `--max-chars N` | context | Total character budget. Default `6000` (~1500 tokens). | +| `--include-children` | search / context | Merge OpenCode sub-agent sessions into their parent session. | +| `--json` | all | Emit machine-parseable JSON instead of human-readable output. | +| `--task <task-dir>` | list | Narrow to sessions whose context-key resolved to a given task directory (uses `.trellis/.runtime/sessions/*.json`). | + +## Common one-liners + +```bash +# What past sessions discussed "deadlock" anywhere on this machine? +trellis mem search "deadlock" --global --limit 20 + +# Inside a specific session, surface the top 5 turns that mention "lock contention" +# plus 2 turns of surrounding context. +trellis mem context 5842592d --grep "lock contention" --turns 5 --around 2 + +# Recover the brainstorm window for a session — useful when continuing a task +# the user started a week ago. +trellis mem extract 5842592d --phase brainstorm + +# List every project this machine has Trellis sessions for, with counts. +trellis mem projects +``` + +## Output shapes + +- **Default human output** (no `--json`): wrapped to a terminal, with session ids highlighted and turn markers visible. Suitable to read inline but messy to paste into a markdown file. +- **`--json`**: stable schema, safe to parse and process. When piping `mem` output into a follow-up step (e.g. summarizing for a Lessons section), prefer `--json`. + +## Caveats + +- **OpenCode adapter is a stub on `0.6.0-beta.*`.** When `--platform` resolves to OpenCode (or `all` and OpenCode would be included), `mem` prints a one-line "reader unavailable" notice and continues with the other platforms. Don't promise OpenCode coverage in your reply until the adapter ships. +- **`--phase` slicing depends on `task.py create` / `task.py start` invocations appearing in the recorded bash calls of the session.** Sessions where the user ran `task.py` from a different terminal — outside the recorded AI loop — will not have phase boundaries. `--phase all` is the safe fallback. +- **`mem` indexes platform JSONL files directly.** If the user has cleared their Claude / Codex session storage, `mem` cannot recover what is no longer on disk. +- **`mem` is read-only.** No remote sync, no edits to platform JSONL. Any write you do based on `mem` findings is your own follow-up call into the editing tools available to you. + +## When you need more than this reference + +Run `trellis mem help` in the user's shell. The runtime help is authoritative and will be ahead of this reference during fast-moving beta releases. diff --git a/.agents/skills/trellis-session-insight/references/triggering-patterns.md b/.agents/skills/trellis-session-insight/references/triggering-patterns.md new file mode 100644 index 0000000..66021ca --- /dev/null +++ b/.agents/skills/trellis-session-insight/references/triggering-patterns.md @@ -0,0 +1,93 @@ +# Triggering Patterns + +Verbatim user phrasings that should make an AI reach for `trellis mem`. Calibrate instinct against these — if a user message hits one of these patterns and you do not reach for `mem`, you probably missed an obvious recall. + +Patterns are grouped by the *intent* behind the phrasing, not the surface words. The same intent shows up in different languages and registers. + +## Past-solution recall + +The user is asking "how did we (or I) solve this before". Past dialogue holds the answer; the codebase shows the result but not the reasoning. + +- "How did we solve this last time?" +- "What did we end up doing about X?" +- "We dealt with this once already, didn't we?" +- "上次怎么解的?" +- "之前是怎么搞定 X 的?" +- "我记得以前修过类似的" + +Reach: `trellis mem search "<symptom keyword>" --global --limit 10`, then `context` into the hit that looks closest. + +## Decision retrieval + +The user is referencing a decision that lives in old dialogue, not in any committed file. Look in brainstorm windows. + +- "What was the decision on X?" +- "Did we decide to use Postgres or SQLite?" +- "The rationale for choosing X over Y was…?" +- "我们当时为啥选了 X 而不是 Y?" +- "关于 X 我们之前是怎么定的?" +- "之前讨论过 X 的方案吗?" + +Reach: `trellis mem search "<decision keyword>"` to find the session, then `extract <id> --phase brainstorm` to recover the discussion. + +## Cross-session continuation + +The user resumed work after a gap and the context is implicit. + +- "Where were we?" +- "Continue from last time." +- "Pick up where we left off." +- "继续上次的" +- "我们上次做到哪了" +- "接着昨天那个任务" + +Reach: `trellis mem list --task <current-task-dir>` to find the most recent sessions tied to the active task, then `extract` the last one. + +## Familiar-bug debugging + +The current bug feels like one already seen. Past sessions probably hold the resolution path. + +- "I feel like I've hit this before." +- "Doesn't this look like that bug from last month?" +- "Same kind of timeout I had in X." +- "这个错好像之前见过" +- "这个 bug 是不是上次那个?" +- "怎么又是这个 error?" + +Reach: `trellis mem search "<error message fragment>" --global`. Anchor on a short, distinctive token from the actual error string. + +## Self-pattern spotting + +The user is asking whether they keep repeating the same kind of mistake or decision. + +- "Do I always make this mistake?" +- "How often have I run into X?" +- "Is this a recurring thing for me?" +- "我每次都踩这个坑吗?" +- "我老犯这个错?" +- "这类问题之前出现过几次?" + +Reach: `trellis mem search "<topic>" --global --limit 50` and scan the dates / projects in the listing. Optionally `extract` two or three for comparison. + +## Finish-work retrospective (on demand) + +The user explicitly wants to look back at this task — not as a forced step, only when they ask. + +- "Summarize what we did in this task." +- "What were the key decisions / surprises?" +- "Write up the lessons from this round." +- "总结一下这次的经验" +- "记一下这次踩的坑" +- "复盘下这个任务" + +Reach: identify the current task's session id (from `.trellis/.runtime/sessions/*.json` or `mem list --task <task-dir>`), then `extract <id> --phase brainstorm` and `--phase implement`. Present a summary — surface concrete file:line citations where possible. Whether to also write the summary somewhere (PRD, spec, notes file) is the user's call; offer, don't auto-write. + +## Anti-patterns: do NOT reach for `mem` here + +- "What does this function do?" → read the file. +- "Why is this test failing?" → read the test output and the file. +- "What's the right pattern for X in our codebase?" → grep / read spec files. +- "What's the latest npm version of Y?" → call `npm view`. +- "Fix this bug." → debug. Reach for `mem` only if you suspect prior context exists; otherwise it is noise. + +The bar stays: would a senior teammate ask "didn't we already talk about this?" before answering? If yes, reach for `mem`. If no, don't. diff --git a/.agents/skills/trellis-spec-bootstrap/SKILL.md b/.agents/skills/trellis-spec-bootstrap/SKILL.md new file mode 100644 index 0000000..e1650df --- /dev/null +++ b/.agents/skills/trellis-spec-bootstrap/SKILL.md @@ -0,0 +1,41 @@ +--- +name: trellis-spec-bootstrap +description: "Bootstrap project-specific Trellis coding specs with a platform-neutral single-agent workflow. Use when creating or refreshing .trellis/spec guidelines, analyzing a codebase with GitNexus, ABCoder, or source inspection, decomposing package/layer spec work, and writing real codebase-backed spec docs without placeholder text." +--- + +# Trellis Spec Bootstrap + +Use this skill to create or refresh `.trellis/spec/` guidelines from the real codebase. One capable agent owns the full loop: analyze the repository, choose the spec boundaries, write the docs, and verify the result. The workflow does not depend on a specific host, CLI, or agent brand. + +## Workflow + +1. Confirm Trellis is initialized and inspect the current `.trellis/spec/` tree. +2. Analyze the repository architecture with the best available tools: GitNexus, ABCoder, language tooling, and direct source reads. +3. Decompose the spec work by package and layer only when that reflects the actual codebase. +4. Fill or reshape the spec files with concrete patterns, file paths, examples, and anti-patterns from the project. +5. Verify that the final specs are internally consistent and contain no template placeholders. + +## Reference Routing + +| Need | Read | +|------|------| +| Repository architecture analysis | [references/repository-analysis.md](references/repository-analysis.md) | +| Spec work decomposition and task planning | [references/spec-task-planning.md](references/spec-task-planning.md) | +| Writing high-signal Trellis spec files | [references/spec-writing.md](references/spec-writing.md) | +| GitNexus and ABCoder MCP setup | [references/mcp-setup.md](references/mcp-setup.md) | + +## Operating Rules + +- Treat templates as starting points, not contracts. Delete, rename, split, or add spec files when the repository calls for it. +- Prefer source-backed rules over generic advice. Every important recommendation should point at a real file or repeated local pattern. +- Keep execution single-owner by default. Optional helper agents are an implementation detail, not a requirement or user-visible dependency. +- Do not write platform-specific instructions unless the target project already standardizes on that platform. +- Do not leave placeholder text, empty headings, or copied boilerplate in `.trellis/spec/`. + +## Done Criteria + +- `.trellis/spec/` describes the project as it exists now. +- Each relevant package or layer has practical coding guidance with real examples. +- Non-applicable template sections are removed. +- `index.md` files match the final spec file set. +- Any required setup or analysis assumptions are documented in the relevant spec or task notes. diff --git a/.agents/skills/trellis-spec-bootstrap/references/mcp-setup.md b/.agents/skills/trellis-spec-bootstrap/references/mcp-setup.md new file mode 100644 index 0000000..629fcbd --- /dev/null +++ b/.agents/skills/trellis-spec-bootstrap/references/mcp-setup.md @@ -0,0 +1,90 @@ +# MCP Setup + +GitNexus and ABCoder are recommended when bootstrapping Trellis specs because they expose architecture and AST context to the agent. They are tool choices, not platform requirements. Configure them through whatever MCP mechanism your agent host provides. + +## GitNexus + +GitNexus builds a code knowledge graph from the repository. Use it for module boundaries, execution flows, dependency relationships, blast radius, and graph queries. + +### Install and Index + +```bash +# Run from the repository root. +npx gitnexus analyze + +# Check index status. +npx gitnexus status + +# Re-index after code changes when the analysis is stale. +npx gitnexus analyze +``` + +The index is written to `.gitnexus/`. Keep embeddings only if the project already uses them; otherwise a normal index is enough for spec bootstrapping. + +### MCP Server Command + +Use this server command in the host's MCP configuration: + +```bash +npx -y gitnexus mcp +``` + +### Useful Tools + +| Tool | Purpose | +|------|---------| +| `gitnexus_query` | Find execution flows and functional areas by concept | +| `gitnexus_context` | Inspect callers, callees, references, and process participation for a symbol | +| `gitnexus_impact` | Understand blast radius before changing a symbol | +| `gitnexus_detect_changes` | Check changed symbols and affected flows before finishing | +| `gitnexus_cypher` | Run direct graph queries | +| `gitnexus_list_repos` | List indexed repositories | + +## ABCoder + +ABCoder parses code into UniAST and gives precise package, file, and node-level structure. Use it for signatures, type shapes, implementations, dependencies, and reverse references. + +### Install + +```bash +go install github.com/cloudwego/abcoder@latest +abcoder --help +``` + +### Parse Repositories + +```bash +abcoder parse /absolute/path/to/package \ + --lang typescript \ + --name package-name \ + --output ~/abcoder-asts +``` + +For monorepos, parse each package with a stable `--name` so task notes can reference the same repository names. + +### MCP Server Command + +Use this server command in the host's MCP configuration: + +```bash +abcoder mcp ~/abcoder-asts +``` + +### Useful Tools + +| Tool | Layer | Purpose | +|------|-------|---------| +| `list_repos` | 1 | List parsed repositories | +| `get_repo_structure` | 2 | Inspect packages and files | +| `get_package_structure` | 3 | Inspect nodes within a package | +| `get_file_structure` | 3 | Inspect functions, classes, types, and signatures in a file | +| `get_ast_node` | 4 | Retrieve code, dependencies, references, and implementations | + +## Verification + +After configuration, verify from the agent host that both MCP servers are visible. Then run one simple query against each server before starting the spec writing pass. + +```bash +ls .gitnexus/meta.json +ls ~/abcoder-asts/*.json +``` diff --git a/.agents/skills/trellis-spec-bootstrap/references/repository-analysis.md b/.agents/skills/trellis-spec-bootstrap/references/repository-analysis.md new file mode 100644 index 0000000..1309d29 --- /dev/null +++ b/.agents/skills/trellis-spec-bootstrap/references/repository-analysis.md @@ -0,0 +1,59 @@ +# Repository Analysis + +The goal is to discover the project's real architecture before writing rules. Do not start from generic spec templates and fill blanks. Start from the code, then let the spec structure follow. + +## Analysis Order + +1. Read the existing `.trellis/spec/` tree and note which files are templates, outdated, or already project-specific. +2. Inspect package manifests, build scripts, workspace config, and top-level documentation to identify packages and runtime layers. +3. Use GitNexus for execution flows, module clusters, dependency hubs, and impact-sensitive areas. +4. Use ABCoder or language-native tooling for exact signatures, types, class boundaries, and implementation examples. +5. Read representative source and test files directly before turning any finding into a spec rule. + +## What To Capture + +| Area | Questions | +|------|-----------| +| Package boundaries | What does each package own? What imports cross boundaries? | +| Runtime layers | Which code is CLI, backend, frontend, worker, shared library, test-only, or tooling? | +| Core abstractions | Which types, services, stores, commands, routes, or adapters define the system shape? | +| Data flow | Where does user input enter, how is it validated, and where does state persist? | +| Error handling | How are failures represented, logged, surfaced, and tested? | +| Configuration | Where do defaults, environment config, generated files, and templates live? | +| Tests | Which test styles are trusted examples for new work? | + +## GitNexus Usage + +Start broad, then inspect specific symbols: + +```text +gitnexus_query({query: "CLI command execution flow"}) +gitnexus_query({query: "template generation and migration"}) +gitnexus_context({name: "SymbolName"}) +gitnexus_cypher({query: "MATCH (n)-[r]->(m) RETURN n.name, type(r), m.name LIMIT 30"}) +``` + +Use GitNexus results to find important files and flows. Do not quote graph output as the final authority until you have checked the relevant source files. + +## ABCoder Usage + +Use ABCoder when the spec needs exact code shapes: + +```text +list_repos() +get_repo_structure({repo_name: "package-name"}) +get_file_structure({repo_name: "package-name", file_path: "src/example.ts"}) +get_ast_node({repo_name: "package-name", node_ids: [{mod_path: "...", pkg_path: "...", name: "SymbolName"}]}) +``` + +ABCoder is most valuable for documenting constructor patterns, function signatures, type contracts, and reference chains. + +## Analysis Notes + +Keep short notes while analyzing. The notes should include: + +- Package or layer name. +- Files that define the local pattern. +- Rules the spec should teach. +- Anti-patterns found in old code, comments, tests, or migration paths. +- Spec files that should be created, deleted, renamed, or merged. diff --git a/.agents/skills/trellis-spec-bootstrap/references/spec-task-planning.md b/.agents/skills/trellis-spec-bootstrap/references/spec-task-planning.md new file mode 100644 index 0000000..dca2687 --- /dev/null +++ b/.agents/skills/trellis-spec-bootstrap/references/spec-task-planning.md @@ -0,0 +1,61 @@ +# Spec Task Planning + +Use a single agent as the default execution model. The agent may create Trellis tasks for traceability, but the skill should not require a specific platform, CLI, or parallel worker model. + +## Decomposition + +Create spec work units around real ownership boundaries: + +- One package when a package has its own conventions. +- One layer when the same package has distinct frontend, backend, CLI, worker, or shared-library rules. +- One cross-cutting guide when a pattern spans packages and is not owned by one layer. + +Avoid artificial decomposition. A small library usually needs one focused spec pass, not several tasks. + +## Task Shape + +When a Trellis task is useful, write a concise PRD with these sections: + +```markdown +# Fill <package-or-layer> Trellis Specs + +## Goal +Write project-specific `.trellis/spec/` guidance for <scope>. + +## Scope +- Spec directory: +- Source directories to inspect: +- Tests to inspect: +- Out of scope: + +## Architecture Context +Summarize the concrete findings from repository analysis. + +## Files To Create Or Update +- `.trellis/spec/.../index.md` +- `.trellis/spec/.../<topic>.md` + +## Rules +- Adapt the spec file set to the real codebase. +- Use real source examples with file paths. +- Remove template-only sections that do not apply. +- Do not modify product source code unless the task explicitly asks for it. + +## Acceptance Criteria +- [ ] Specs contain concrete examples and anti-patterns from the repository. +- [ ] No placeholder text remains. +- [ ] Index files match the final spec files. +- [ ] Claims are backed by source files, tests, or project docs. +``` + +## Optional Helper Agents + +If the host supports subagents, helpers can inspect independent packages or run verification. They are optional. The main agent still owns integration and final quality. + +Helper tasks must have clear ownership: + +- Read-only research tasks may inspect any source needed for the assigned scope. +- Write tasks should own disjoint spec directories. +- Verification tasks should check placeholder removal, broken links, and consistency. + +Do not encode helper-agent names, vendor-specific commands, or platform-specific routing in the skill. Put only the required work and acceptance criteria in the task. diff --git a/.agents/skills/trellis-spec-bootstrap/references/spec-writing.md b/.agents/skills/trellis-spec-bootstrap/references/spec-writing.md new file mode 100644 index 0000000..6bc7dec --- /dev/null +++ b/.agents/skills/trellis-spec-bootstrap/references/spec-writing.md @@ -0,0 +1,70 @@ +# Spec Writing + +Trellis specs are coding guidance for future agents. They should explain how to work in this repository, not how a generic project might be organized. + +## Write From Evidence + +Each important rule should be backed by one of these: + +- A source file that demonstrates the preferred pattern. +- A test file that shows expected behavior. +- A project document that defines the convention. +- A repeated pattern across multiple files. + +Use short snippets only when they make the rule clearer. Prefer linking to the file path and naming the symbol or behavior. + +## File Structure + +Keep the spec tree aligned with the project: + +- Keep `index.md` as the navigation file for the spec directory. +- Split topics when developers would look for them independently. +- Merge topics when separate files would repeat the same rule. +- Delete template files that do not apply. +- Add new files for important local patterns the template missed. + +## Content Standards + +Good spec sections include: + +- When the rule applies. +- The local pattern to follow. +- The source or test files that prove the pattern. +- Common mistakes or anti-patterns. +- Verification commands or checks when they are specific and reliable. + +Avoid: + +- Placeholder prose. +- Generic framework advice. +- Tool instructions that only work in one agent host. +- Long copied code blocks. +- Rules based on a single accidental implementation detail. + +## Example Shape + +```markdown +## Command Handlers + +Command handlers should keep argument parsing, validation, and side effects separate. The local pattern is: + +- Parse CLI flags at the command boundary. +- Convert raw inputs into typed task options before invoking core logic. +- Keep filesystem writes in the command or service layer, not in template helpers. + +Reference files: +- `packages/cli/src/commands/example.ts` +- `packages/cli/test/commands/example.test.ts` + +Avoid passing raw `process.argv` or unvalidated config objects into shared helpers. +``` + +## Final Pass + +Before finishing: + +```bash +grep -R "To be filled\\|TODO: fill\\|placeholder" .trellis/spec +``` + +Also check links, index files, and whether any spec still describes a template rather than this repository. diff --git a/.agents/skills/trellis-start/SKILL.md b/.agents/skills/trellis-start/SKILL.md new file mode 100644 index 0000000..e557bff --- /dev/null +++ b/.agents/skills/trellis-start/SKILL.md @@ -0,0 +1,64 @@ +--- +name: trellis-start +description: "Initializes an AI development session by reading workflow guides, developer identity, git status, active tasks, and project guidelines from .trellis/. Classifies incoming tasks and routes to brainstorm, direct edit, or task workflow. Use when beginning a new coding session, resuming work, starting a new task, or re-establishing project context." +--- + +# Start Session + +Initialize a Trellis-managed development session. This platform has no session-start hook, so manually load the equivalent compact context by following these steps. + +--- + +## Step 1: Current state +Identity, git status, current task, active tasks, journal location. + +```bash +python ./.trellis/scripts/get_context.py +``` + +If this output includes a line beginning `Trellis update available:`, copy the full line verbatim when summarizing session context. Do not shorten operational command hints. + +## Step 2: Workflow overview +Compact Phase Index, request triage rules, planning artifact contract, and the step-detail command. + +```bash +python ./.trellis/scripts/get_context.py --mode phase +``` + +Full guide in `.trellis/workflow.md` (read on demand). + +## Step 3: Guideline indexes +Discover packages + spec layers, then read each relevant index file. + +```bash +python ./.trellis/scripts/get_context.py --mode packages +cat .trellis/spec/guides/index.md +cat .trellis/spec/<package>/<layer>/index.md # for each relevant layer +``` + +Index files list the specific guideline docs to read when you actually start coding. + +## Step 4: Decide next action +From Step 1 you know the current task and status. Check the task directory: + +- **Active task status `planning` + no `prd.md`** → Phase 1.1. Load the `trellis-brainstorm` skill. +- **Active task status `planning` + `prd.md` exists** → stay in Phase 1. Lightweight tasks can be PRD-only; complex tasks need `design.md` + `implement.md`. Load the relevant Phase 1 step detail before `task.py start`. +- **Active task status `in_progress`** → Phase 2 step 2.1. Load the step detail: + ```bash + python ./.trellis/scripts/get_context.py --mode phase --step 2.1 --platform codex + ``` +- **No active task** → classify first. For simple conversation / small task, ask only whether this turn should create a Trellis task. For complex work, ask whether you may create a Trellis task and enter planning. If the user says no, skip Trellis for this session. + +--- + +## Skill routing (quick reference) + +| User intent | Skill | +|---|---| +| New feature / unclear requirements | `trellis-brainstorm` | +| About to write code | `trellis-before-dev` | +| Done coding / quality check | `trellis-check` | +| Stuck / fixed same bug multiple times | `trellis-break-loop` | +| Learned something worth capturing | `trellis-update-spec` | + +Full rules + anti-rationalization table in `.trellis/workflow.md`. diff --git a/.agents/skills/trellis-update-spec/SKILL.md b/.agents/skills/trellis-update-spec/SKILL.md new file mode 100644 index 0000000..81bad08 --- /dev/null +++ b/.agents/skills/trellis-update-spec/SKILL.md @@ -0,0 +1,356 @@ +--- +name: trellis-update-spec +description: "Captures executable contracts and coding conventions into .trellis/spec/ documents. Use when learning something valuable from debugging, implementing, or discussion that should be preserved for future sessions." +--- + +# Update Code-Spec - Capture Executable Contracts + +When you learn something valuable (from debugging, implementing, or discussion), use this to update the relevant code-spec documents. + +**Timing**: After completing a task, fixing a bug, or discovering a new pattern + +--- + +## Code-Spec First Rule (CRITICAL) + +In this project, "spec" for implementation work means **code-spec**: +- Executable contracts (not principle-only text) +- Concrete signatures, payload fields, env keys, and boundary behavior +- Testable validation/error behavior + +If the change touches infra or cross-layer contracts, code-spec depth is mandatory. + +### Mandatory Triggers + +Apply code-spec depth when the change includes any of: +- New/changed command or API signature +- Cross-layer request/response contract change +- Database schema/migration change +- Infra integration (storage, queue, cache, secrets, env wiring) + +### Mandatory Output (7 Sections) + +For triggered tasks, include all sections below: +1. Scope / Trigger +2. Signatures (command/API/DB) +3. Contracts (request/response/env) +4. Validation & Error Matrix +5. Good/Base/Bad Cases +6. Tests Required (with assertion points) +7. Wrong vs Correct (at least one pair) + +--- + +## When to Update Code-Specs + +| Trigger | Example | Target Spec | +|---------|---------|-------------| +| **Implemented a feature** | Added a new integration or module | Relevant spec file | +| **Made a design decision** | Chose extensibility pattern over simplicity | Relevant spec + "Design Decisions" section | +| **Fixed a bug** | Found a subtle issue with error handling | Relevant spec (e.g., error-handling docs) | +| **Discovered a pattern** | Found a better way to structure code | Relevant spec file | +| **Hit a gotcha** | Learned that X must be done before Y | Relevant spec + "Common Mistakes" section | +| **Established a convention** | Team agreed on naming pattern | Quality guidelines | +| **New thinking trigger** | "Don't forget to check X before doing Y" | `guides/*.md` (as a checklist item) | + +**Key Insight**: Code-spec updates are NOT just for problems. Every feature implementation contains design decisions and contracts that future AI/developers need to execute safely. + +--- + +## Spec Structure Overview + +``` +.trellis/spec/ +├── <layer>/ # Per-layer coding standards (e.g., backend/, frontend/, api/) +│ ├── index.md # Overview and links +│ └── *.md # Topic-specific guidelines +└── guides/ # Thinking checklists (NOT coding specs!) + ├── index.md # Guide index + └── *.md # Topic-specific guides +``` + +### CRITICAL: Code-Spec vs Guide - Know the Difference + +| Type | Location | Purpose | Content Style | +|------|----------|---------|---------------| +| **Code-Spec** | `<layer>/*.md` | Tell AI "how to implement safely" | Signatures, contracts, matrices, cases, test points | +| **Guide** | `guides/*.md` | Help AI "what to think about" | Checklists, questions, pointers to specs | + +**Decision Rule**: Ask yourself: + +- "This is **how to write** the code" → Put in a spec layer directory +- "This is **what to consider** before writing" → Put in `guides/` + +**Example**: + +| Learning | Wrong Location | Correct Location | +|----------|----------------|------------------| +| "Use API X not API Y for this task" | ❌ `guides/` (too specific for a thinking guide) | ✅ Relevant spec file (concrete convention) | +| "Remember to check X when doing Y" | ❌ Spec file (too abstract for a spec) | ✅ `guides/` (thinking checklist) | + +**Guides should be short checklists that point to specs**, not duplicate the detailed rules. + +--- + +## Update Process + +### Step 1: Identify What You Learned + +Answer these questions: + +1. **What did you learn?** (Be specific) +2. **Why is it important?** (What problem does it prevent?) +3. **Where does it belong?** (Which spec file?) + +### Step 2: Classify the Update Type + +| Type | Description | Action | +|------|-------------|--------| +| **Design Decision** | Why we chose approach X over Y | Add to "Design Decisions" section | +| **Project Convention** | How we do X in this project | Add to relevant section with examples | +| **New Pattern** | A reusable approach discovered | Add to "Patterns" section | +| **Forbidden Pattern** | Something that causes problems | Add to "Anti-patterns" or "Don't" section | +| **Common Mistake** | Easy-to-make error | Add to "Common Mistakes" section | +| **Convention** | Agreed-upon standard | Add to relevant section | +| **Gotcha** | Non-obvious behavior | Add warning callout | + +### Step 3: Read the Target Code-Spec + +Before editing, read the current code-spec to: +- Understand existing structure +- Avoid duplicating content +- Find the right section for your update + +```bash +cat .trellis/spec/<category>/<file>.md +``` + +### Step 4: Make the Update + +Follow these principles: + +1. **Be Specific**: Include concrete examples, not just abstract rules +2. **Explain Why**: State the problem this prevents +3. **Show Contracts**: Add signatures, payload fields, and error behavior +4. **Show Code**: Add code snippets for key patterns +5. **Keep it Short**: One concept per section + +### Step 5: Update the Index (if needed) + +If you added a new section or the code-spec status changed, update the category's `index.md`. + +--- + +## Update Templates + +### Mandatory Template for Infra/Cross-Layer Work + +```markdown +## Scenario: <name> + +### 1. Scope / Trigger +- Trigger: <why this requires code-spec depth> + +### 2. Signatures +- Backend command/API/DB signature(s) + +### 3. Contracts +- Request fields (name, type, constraints) +- Response fields (name, type, constraints) +- Environment keys (required/optional) + +### 4. Validation & Error Matrix +- <condition> -> <error> + +### 5. Good/Base/Bad Cases +- Good: ... +- Base: ... +- Bad: ... + +### 6. Tests Required +- Unit/Integration/E2E with assertion points + +### 7. Wrong vs Correct +#### Wrong +... +#### Correct +... +``` + +### Adding a Design Decision + +```markdown +### Design Decision: [Decision Name] + +**Context**: What problem were we solving? + +**Options Considered**: +1. Option A - brief description +2. Option B - brief description + +**Decision**: We chose Option X because... + +**Example**: +\`\`\`typescript +// How it's implemented +code example +\`\`\` + +**Extensibility**: How to extend this in the future... +``` + +### Adding a Project Convention + +```markdown +### Convention: [Convention Name] + +**What**: Brief description of the convention. + +**Why**: Why we do it this way in this project. + +**Example**: +\`\`\`typescript +// How to follow this convention +code example +\`\`\` + +**Related**: Links to related conventions or specs. +``` + +### Adding a New Pattern + +```markdown +### Pattern Name + +**Problem**: What problem does this solve? + +**Solution**: Brief description of the approach. + +**Example**: +\`\`\` +// Good +code example + +// Bad +code example +\`\`\` + +**Why**: Explanation of why this works better. +``` + +### Adding a Forbidden Pattern + +```markdown +### Don't: Pattern Name + +**Problem**: +\`\`\` +// Don't do this +bad code example +\`\`\` + +**Why it's bad**: Explanation of the issue. + +**Instead**: +\`\`\` +// Do this instead +good code example +\`\`\` +``` + +### Adding a Common Mistake + +```markdown +### Common Mistake: Description + +**Symptom**: What goes wrong + +**Cause**: Why this happens + +**Fix**: How to correct it + +**Prevention**: How to avoid it in the future +``` + +### Adding a Gotcha + +```markdown +> **Warning**: Brief description of the non-obvious behavior. +> +> Details about when this happens and how to handle it. +``` + +--- + +## Interactive Mode + +If you're unsure what to update, answer these prompts: + +1. **What did you just finish?** + - [ ] Fixed a bug + - [ ] Implemented a feature + - [ ] Refactored code + - [ ] Had a discussion about approach + +2. **What did you learn or decide?** + - Design decision (why X over Y) + - Project convention (how we do X) + - Non-obvious behavior (gotcha) + - Better approach (pattern) + +3. **Would future AI/developers need to know this?** + - To understand how the code works → Yes, update spec + - To maintain or extend the feature → Yes, update spec + - To avoid repeating mistakes → Yes, update spec + - Purely one-off implementation detail → Maybe skip + +4. **Which area does it relate to?** + - [ ] Backend code + - [ ] Frontend code + - [ ] Cross-layer data flow + - [ ] Code organization/reuse + - [ ] Quality/testing + +--- + +## Quality Checklist + +Before finishing your code-spec update: + +- [ ] Is the content specific and actionable? +- [ ] Did you include a code example? +- [ ] Did you explain WHY, not just WHAT? +- [ ] Did you include executable signatures/contracts? +- [ ] Did you include validation and error matrix? +- [ ] Did you include Good/Base/Bad cases? +- [ ] Did you include required tests with assertion points? +- [ ] Is it in the right code-spec file? +- [ ] Does it duplicate existing content? +- [ ] Would a new team member understand it? + +--- + +## Relationship to Other Commands + +``` +Development Flow: + Learn something → `update-spec` (Trellis command) → Knowledge captured + ↑ ↓ + `break-loop` (Trellis command) ←──────────────────── Future sessions benefit + (deep bug analysis) +``` + +- ``break-loop` (Trellis command)` - Analyzes bugs deeply, often reveals spec updates needed +- ``update-spec` (Trellis command)` - Actually makes the updates +- ``finish-work` (Trellis command)` - Reminds you to check if specs need updates + +--- + +## Core Philosophy + +> **Code-specs are living documents. Every debugging session, every "aha moment" is an opportunity to make the implementation contract clearer.** + +The goal is **institutional memory**: +- What one person learns, everyone benefits from +- What AI learns in one session, persists to future sessions +- Mistakes become documented guardrails diff --git a/.claude/agents/trellis-check.md b/.claude/agents/trellis-check.md new file mode 100644 index 0000000..7883deb --- /dev/null +++ b/.claude/agents/trellis-check.md @@ -0,0 +1,115 @@ +--- +name: trellis-check +description: | + Code quality check expert. Reviews code changes against specs and self-fixes issues. +tools: Read, Write, Edit, Bash, Glob, Grep, mcp__exa__web_search_exa, mcp__exa__get_code_context_exa +--- +# Check Agent + +You are the Check Agent in the Trellis workflow. + +## Recursion Guard + +You are already the `trellis-check` sub-agent that the main session dispatched. Do the review and fixes directly. + +- Do NOT spawn another `trellis-check` or `trellis-implement` sub-agent. +- If SessionStart context, workflow-state breadcrumbs, or workflow.md say to dispatch `trellis-implement` / `trellis-check`, treat that as a main-session instruction that is already satisfied by your current role. +- Only the main session may dispatch Trellis implement/check agents. If more implementation work is needed, report that recommendation instead of spawning. + +## Trellis Context Loading Protocol + +Look for the `<!-- trellis-hook-injected -->` marker in your input above. + +- **If the marker is present**: task artifacts, spec, and research files have already been auto-loaded for you above. Proceed with the check work directly. +- **If the marker is absent**: hook injection didn't fire (Windows + Claude Code, `--continue` resume, fork distribution, hooks disabled, etc.). Find the active task path from your dispatch prompt's first line `Active task: <path>`, then Read `<task-path>/check.jsonl`, each listed file, `<task-path>/prd.md`, `<task-path>/design.md` if present, and `<task-path>/implement.md` if present before doing the work. + +## Context + +Before checking, read: +- `.trellis/spec/` - Development guidelines +- Task `prd.md` - Requirements document +- Task `design.md` - Technical design (if exists) +- Task `implement.md` - Execution plan (if exists) +- Pre-commit checklist for quality standards + +## Core Responsibilities + +1. **Get code changes** - Use git diff to get uncommitted code +2. **Review task artifacts** - Check changes against prd.md, design.md if present, and implement.md if present +3. **Check against specs** - Verify code follows guidelines +4. **Self-fix** - Fix issues yourself, not just report them +5. **Run verification** - typecheck and lint + +## Important + +**Fix issues yourself**, don't just report them. + +You have write and edit tools, you can modify code directly. + +--- + +## Workflow + +### Step 1: Get Changes + +```bash +git diff --name-only # List changed files +git diff # View specific changes +``` + +### Step 2: Check Against Specs and Task Artifacts + +Read the task's prd.md, design.md if present, and implement.md if present, then read relevant specs in `.trellis/spec/` to check code: + +- Does it satisfy the task requirements +- Does it follow the technical design and implementation plan when present +- Does it follow directory structure conventions +- Does it follow naming conventions +- Does it follow code patterns +- Are there missing types +- Are there potential bugs + +### Step 3: Self-Fix + +After finding issues: + +1. Fix the issue directly (use edit tool) +2. Record what was fixed +3. Continue checking other issues + +### Step 4: Run Verification + +Run project's lint and typecheck commands to verify changes. + +If failed, fix issues and re-run. + +--- + +## Report Format + +```markdown +## Self-Check Complete + +### Files Checked + +- src/components/Feature.tsx +- src/hooks/useFeature.ts + +### Issues Found and Fixed + +1. `<file>:<line>` - <what was fixed> +2. `<file>:<line>` - <what was fixed> + +### Issues Not Fixed + +(If there are issues that cannot be self-fixed, list them here with reasons) + +### Verification Results + +- TypeCheck: Passed +- Lint: Passed + +### Summary + +Checked X files, found Y issues, all fixed. +``` diff --git a/.claude/agents/trellis-implement.md b/.claude/agents/trellis-implement.md new file mode 100644 index 0000000..37e1b96 --- /dev/null +++ b/.claude/agents/trellis-implement.md @@ -0,0 +1,110 @@ +--- +name: trellis-implement +description: | + Code implementation expert. Understands specs and requirements, then implements features. No git commit allowed. +tools: Read, Write, Edit, Bash, Glob, Grep, mcp__exa__web_search_exa, mcp__exa__get_code_context_exa +--- +# Implement Agent + +You are the Implement Agent in the Trellis workflow. + +## Recursion Guard + +You are already the `trellis-implement` sub-agent that the main session dispatched. Do the implementation work directly. + +- Do NOT spawn another `trellis-implement` or `trellis-check` sub-agent. +- If SessionStart context, workflow-state breadcrumbs, or workflow.md say to dispatch `trellis-implement` / `trellis-check`, treat that as a main-session instruction that is already satisfied by your current role. +- Only the main session may dispatch Trellis implement/check agents. If more parallel work is needed, report that recommendation instead of spawning. + +## Trellis Context Loading Protocol + +Look for the `<!-- trellis-hook-injected -->` marker in your input above. + +- **If the marker is present**: prd / spec / research files have already been auto-loaded for you above. Proceed with the implementation work directly. +- **If the marker is absent**: hook injection didn't fire (Windows + Claude Code, `--continue` resume, fork distribution, hooks disabled, etc.). Find the active task path from your dispatch prompt's first line `Active task: <path>`, then Read `<task-path>/implement.jsonl`, each listed file, `<task-path>/prd.md`, `<task-path>/design.md` if present, and `<task-path>/implement.md` if present before doing the work. + +## Context + +Before implementing, read: +- `.trellis/workflow.md` - Project workflow +- `.trellis/spec/` - Development guidelines +- Task `prd.md` - Requirements document +- Task `design.md` - Technical design (if exists) +- Task `implement.md` - Execution plan (if exists) + +## Core Responsibilities + +1. **Understand specs** - Read relevant spec files in `.trellis/spec/` +2. **Understand task artifacts** - Read prd.md, design.md if present, and implement.md if present +3. **Implement features** - Write code following specs and task artifacts +4. **Self-check** - Ensure code quality +5. **Report results** - Report completion status + +## Forbidden Operations + +**Do NOT execute these git commands:** + +- `git commit` +- `git push` +- `git merge` + +--- + +## Workflow + +### 1. Understand Specs + +Read relevant specs based on task type: + +- Spec layers: `.trellis/spec/<package>/<layer>/` +- Shared guides: `.trellis/spec/guides/` + +### 2. Understand Requirements + +Read the task's prd.md, design.md if present, and implement.md if present: + +- What are the core requirements +- Key points of technical design +- Implementation order, validation commands, and rollback points + +### 3. Implement Features + +- Write code following specs and task artifacts +- Follow existing code patterns +- Only do what's required, no over-engineering + +### 4. Verify + +Run project's lint and typecheck commands to verify changes. + +--- + +## Report Format + +```markdown +## Implementation Complete + +### Files Modified + +- `src/components/Feature.tsx` - New component +- `src/hooks/useFeature.ts` - New hook + +### Implementation Summary + +1. Created Feature component... +2. Added useFeature hook... + +### Verification Results + +- Lint: Passed +- TypeCheck: Passed +``` + +--- + +## Code Standards + +- Follow existing code patterns +- Don't add unnecessary abstractions +- Only do what's required, no over-engineering +- Keep code readable diff --git a/.claude/agents/trellis-research.md b/.claude/agents/trellis-research.md new file mode 100644 index 0000000..4d984de --- /dev/null +++ b/.claude/agents/trellis-research.md @@ -0,0 +1,137 @@ +--- +name: trellis-research +description: | + Code and tech search expert. Finds files, patterns, and tech solutions, and PERSISTS every finding to the current task's research/ directory. No code modifications outside that directory. +tools: Read, Write, Glob, Grep, Bash, mcp__exa__web_search_exa, mcp__exa__get_code_context_exa, Skill, mcp__chrome-devtools__* +--- +# Research Agent + +You are the Research Agent in the Trellis workflow. + +## Core Principle + +**You do one thing: find, explain, and PERSIST information.** + +Conversations get compacted; files don't. Every research output MUST end up as a file under `{TASK_DIR}/research/`. Returning findings only through the chat reply is a failure — the caller cannot read them next session. + +--- + +## Core Responsibilities + +1. **Internal Search** — locate files/components, understand code logic, discover patterns (Glob, Grep, Read) +2. **External Search** — library docs, API references, best practices (web search) +3. **Persist** — write each research topic to `{TASK_DIR}/research/<topic>.md` +4. **Report** — return file paths + one-line summaries to the main agent (not full content) + +--- + +## Workflow + +### Step 1: Resolve Current Task + +Run `python ./.trellis/scripts/task.py current --source` → active task path. If no active task is set, ask the user where to write output; do NOT guess. + +Ensure `{TASK_DIR}/research/` exists: + +```bash +mkdir -p <TASK_DIR>/research +``` + +### Step 2: Understand Search Request + +Classify: internal / external / mixed. Determine scope (global / specific directory) and expected shape (file list / pattern notes / tech comparison). + +### Step 3: Execute Search + +Run independent searches in parallel (Glob + Grep + web) for efficiency. + +### Step 4: Persist Each Topic + +For each distinct research topic, Write a markdown file at `{TASK_DIR}/research/<topic-slug>.md`. Use the File Format below. + +### Step 5: Report to Main Agent + +Reply with ONLY: + +- List of files written (paths relative to repo root) +- One-line summary per file +- Any critical caveats that the main agent needs to know right now + +Do NOT paste full research content into the reply. The files are the contract. + +--- + +## Scope Limits (Strict) + +### Write ALLOWED + +- `{TASK_DIR}/research/*.md` — your own output +- Creating `{TASK_DIR}/research/` if it doesn't exist (via `mkdir -p`) + +### Write FORBIDDEN + +- Code files (`src/`, `lib/`, …) +- Spec files (`.trellis/spec/`) — main agent should use `update-spec` skill instead +- `.trellis/scripts/`, `.trellis/workflow.md`, platform config (`.claude/`, `.cursor/`, etc.) +- Other task directories +- Any git operation (commit / push / branch / merge) + +If the user asks you to edit code, decline and suggest spawning `implement` instead. + +--- + +## File Format + +Each `{TASK_DIR}/research/<topic>.md` should follow: + +```markdown +# Research: <topic> + +- **Query**: <original query> +- **Scope**: <internal / external / mixed> +- **Date**: <YYYY-MM-DD> + +## Findings + +### Files Found + +| File Path | Description | +|---|---| +| `src/services/xxx.ts` | Main implementation | +| `src/types/xxx.ts` | Type definitions | + +### Code Patterns + +<describe patterns, cite file:line> + +### External References + +- [Library X docs](url) — <why relevant, version constraints> + +### Related Specs + +- `.trellis/spec/xxx.md` — <description> + +## Caveats / Not Found + +<anything incomplete or uncertain> +``` + +--- + +## Guidelines + +### DO + +- Provide specific file paths and line numbers +- Quote actual code snippets +- Persist every topic to its own file +- Return file paths in your reply, not the full content +- Mark "not found" explicitly when searches come up empty + +### DON'T + +- Don't write code or modify files outside `{TASK_DIR}/research/` +- Don't guess uncertain info +- Don't paste full research text into the reply (files are the deliverable) +- Don't propose improvements or critique implementation (that's not your role) diff --git a/.claude/commands/trellis/continue.md b/.claude/commands/trellis/continue.md new file mode 100644 index 0000000..b83d926 --- /dev/null +++ b/.claude/commands/trellis/continue.md @@ -0,0 +1,56 @@ +# Continue Current Task + +Resume work on the current task — pick up at the right phase/step in `.trellis/workflow.md`. + +--- + +## Step 1: Load Current Context + +```bash +python ./.trellis/scripts/get_context.py +``` + +Confirms: current task, git state, recent commits. + +## Step 2: Load the Phase Index + +```bash +python ./.trellis/scripts/get_context.py --mode phase +``` + +Shows the Phase Index (Plan / Execute / Finish) with routing + skill mapping. + +## Step 3: Decide Where You Are + +`get_context.py` shows the active task's `status` field. Route by `status` + artifact presence. This command replaces the user needing to remember the Trellis flow; it does not itself approve implementation. + +- `status=planning` + no `prd.md` → **1.1** (load `trellis-brainstorm`) +- `status=planning` + `prd.md` only → decide whether the task is lightweight or complex. Lightweight can move to **1.4** review; complex returns to **1.1** to add `design.md` + `implement.md`. +- `status=planning` + complex artifacts complete + sub-agent jsonl not curated (only the seed `_example` row) → **1.3** +- `status=planning` + required artifacts complete + required jsonl curated or inline mode → **1.4** (ask for start review; only run `task.py start` after user confirms) +- `status=in_progress` + implementation not started → **2.1** +- `status=in_progress` + implementation done, not yet checked → **2.2** +- `status=in_progress` + check passed → **3.1** +- `status=completed` (rare; usually archived immediately) → archive flow + +Phase rules (full detail in `.trellis/workflow.md`): + +1. Run steps **in order** within a phase — `[required]` steps must not be skipped +2. `[once]` steps are already done if the required output exists. `prd.md` alone can be enough only for lightweight tasks; complex tasks also need `design.md` and `implement.md`. +3. You may go back to an earlier phase if discoveries require it + +## Step 4: Load the Specific Step + +Once you know which step to resume at: + +```bash +python ./.trellis/scripts/get_context.py --mode phase --step <X.X> --platform claude +``` + +Follow the loaded instructions. After each `[required]` step completes, move to the next. + +--- + +## Reference + +Full workflow and detailed phase steps live in `.trellis/workflow.md`. This command is only an entry point — the canonical guidance is there. diff --git a/.claude/commands/trellis/finish-work.md b/.claude/commands/trellis/finish-work.md new file mode 100644 index 0000000..f095dcb --- /dev/null +++ b/.claude/commands/trellis/finish-work.md @@ -0,0 +1,66 @@ +# Finish Work + +Wrap up the current session: archive the active task (and any other completed-but-unarchived tasks the user wants to clean up) and record the session journal. Code commits are NOT done here — those happen in workflow Phase 3.4 before you invoke this command. + +## Step 1: Survey current state + +```bash +python ./.trellis/scripts/get_context.py --mode record +``` + +This prints: + +- **My active tasks** — review whether any besides the current one are actually done (code merged, AC met) and should be archived this round. +- **Git status** — quick visual on what's dirty. +- **Recent commits** — you'll need their hashes in Step 4 for `--commit`. + +If `--mode record` surfaces other completed tasks not tied to the current session, surface them to the user with a one-shot confirmation: "These N tasks look done — archive them too in this round? [y/N]". Default is no; the current active task is always archived in Step 3 regardless. + +## Step 2: Sanity check — classify dirty paths + +Run: + +```bash +git status --porcelain +``` + +Filter out paths under `.trellis/workspace/` and `.trellis/tasks/` — those are managed by `add_session.py` and `task.py archive` auto-commits and will appear dirty as part of this skill's own work. + +For each remaining dirty path, decide whether it belongs to **the current task** or to **other parallel work** (e.g., another terminal window editing the same repo). Heuristics: + +- Paths referenced in the current task's `prd.md` / `implement.jsonl` / `check.jsonl` → current task +- Paths in code areas matching the task's stated scope, or that you remember editing this session → current task +- Paths in unrelated areas you have no recollection of touching this session → other parallel work + +Then route: + +- **Any remaining path looks like current-task work** — bail out with: + > "Working tree has uncommitted code changes from this task: `<list>`. Return to workflow Phase 3.4 to commit them before running `/trellis:finish-work`." + + Do NOT run `git commit` here. Do NOT prompt the user to commit. The user goes back to Phase 3.4 and the AI drives the batched commit there. +- **All remaining paths look unrelated** (other parallel-window work) — report them once and continue to Step 3: + > "FYI, dirty files outside this task's scope — leaving them for the other window: `<list>`." +- **Genuinely unsure** — ask the user once: "Are `<list>` this task's work I forgot to commit, or another window's? (commit / ignore)" — then route per their answer. + +## Step 3: Archive task(s) + +```bash +python ./.trellis/scripts/task.py archive <task-name> +``` + +At minimum: the current active task (if any). Plus any extra tasks the user confirmed in Step 1. Each archive produces a `chore(task): archive ...` commit via the script's auto-commit. + +If there is no active task and the user did not confirm any cleanup archives, skip this step. + +## Step 4: Record session journal + +```bash +python ./.trellis/scripts/add_session.py \ + --title "Session Title" \ + --commit "hash1,hash2" \ + --summary "Brief summary" +``` + +Use the work-commit hashes produced in Phase 3.4 (visible in Step 1's `Recent commits` list, or via `git log --oneline`) for `--commit`. Do not include the archive commit hashes from Step 3. This produces a `chore: record journal` commit. + +Final git log order: `<work commits from 3.4>` → `chore(task): archive ...` (one or more) → `chore: record journal`. diff --git a/.claude/hooks/inject-subagent-context.py b/.claude/hooks/inject-subagent-context.py new file mode 100644 index 0000000..9547ca9 --- /dev/null +++ b/.claude/hooks/inject-subagent-context.py @@ -0,0 +1,771 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Multi-Platform Sub-Agent Context Injection Hook + +Injects task-specific context when sub-agents (implement, check, research) are spawned. + +Core Design Philosophy: +- Hook is responsible for injecting all context, subagent works autonomously with complete info +- Each agent has a dedicated jsonl file defining its context +- No resume needed, no segmentation, behavior controlled by code not prompt + +Trigger: PreToolUse (before Task tool call) + +Context Source: Trellis active task resolver points to task directory +- implement.jsonl - Implement agent dedicated context +- check.jsonl - Check agent dedicated context +- prd.md - Requirements document +- design.md - Technical design for complex tasks +- implement.md - Execution plan for complex tasks +- codex-review-output.txt - Code Review results +""" +from __future__ import annotations + +# IMPORTANT: Suppress all warnings FIRST +import warnings +warnings.filterwarnings("ignore") + +import json +import os +import sys +from pathlib import Path +from typing import Any + +# IMPORTANT: Force stdout to use UTF-8 on Windows +# This fixes UnicodeEncodeError when outputting non-ASCII characters +if sys.platform.startswith("win"): + import io as _io + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] + elif hasattr(sys.stdout, "detach"): + sys.stdout = _io.TextIOWrapper(sys.stdout.detach(), encoding="utf-8", errors="replace") # type: ignore[union-attr] + + +# ============================================================================= +# Path Constants (change here to rename directories) +# ============================================================================= + +DIR_WORKFLOW = ".trellis" +DIR_SPEC = "spec" +FILE_TASK_JSON = "task.json" + +# ============================================================================= +# Subagent Constants (change here to rename subagent types) +# ============================================================================= + +AGENT_IMPLEMENT = "trellis-implement" +AGENT_CHECK = "trellis-check" +AGENT_RESEARCH = "trellis-research" + +# Agents that require a task directory +AGENTS_REQUIRE_TASK = (AGENT_IMPLEMENT, AGENT_CHECK) +# All supported agents +AGENTS_ALL = (AGENT_IMPLEMENT, AGENT_CHECK, AGENT_RESEARCH) + + +def find_repo_root(start_path: str) -> str | None: + """ + Find git repo root from start_path upwards + + Returns: + Repo root path, or None if not found + """ + current = Path(start_path).resolve() + while current != current.parent: + if (current / ".git").exists(): + return str(current) + current = current.parent + return None + + +def _detect_platform(input_data: dict) -> str | None: + if isinstance(input_data.get("cursor_version"), str): + return "cursor" + env_map = { + "CLAUDE_PROJECT_DIR": "claude", + "CURSOR_PROJECT_DIR": "cursor", + "CODEBUDDY_PROJECT_DIR": "codebuddy", + "FACTORY_PROJECT_DIR": "droid", + "GEMINI_PROJECT_DIR": "gemini", + "QODER_PROJECT_DIR": "qoder", + "KIRO_PROJECT_DIR": "kiro", + "COPILOT_PROJECT_DIR": "copilot", + } + for env_name, platform in env_map.items(): + if os.environ.get(env_name): + return platform + script_parts = set(Path(sys.argv[0]).parts) + if ".claude" in script_parts: + return "claude" + if ".cursor" in script_parts: + return "cursor" + if ".gemini" in script_parts: + return "gemini" + if ".qoder" in script_parts: + return "qoder" + if ".codebuddy" in script_parts: + return "codebuddy" + if ".factory" in script_parts: + return "droid" + if ".kiro" in script_parts: + return "kiro" + return None + + +def get_current_task(repo_root: str, input_data: dict) -> str | None: + """Resolve current task directory through the unified active task resolver.""" + scripts_dir = Path(repo_root) / DIR_WORKFLOW / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + try: + from common.active_task import resolve_active_task # type: ignore[import-not-found] + except Exception: + return None + + active = resolve_active_task( + Path(repo_root), + input_data, + platform=_detect_platform(input_data), + ) + return active.task_path + + +def read_file_content(base_path: str, file_path: str) -> str | None: + """Read file content, return None if file doesn't exist""" + full_path = os.path.join(base_path, file_path) + if os.path.exists(full_path) and os.path.isfile(full_path): + try: + with open(full_path, "r", encoding="utf-8") as f: + return f.read() + except Exception: + return None + return None + + +def read_directory_contents( + base_path: str, dir_path: str, max_files: int = 20 +) -> list[tuple[str, str]]: + """ + Read all .md files in a directory + + Args: + base_path: Base path (usually repo_root) + dir_path: Directory relative path + max_files: Max files to read (prevent huge directories) + + Returns: + [(file_path, content), ...] + """ + full_path = os.path.join(base_path, dir_path) + if not os.path.exists(full_path) or not os.path.isdir(full_path): + return [] + + results = [] + try: + # Only read .md files, sorted by filename + md_files = sorted( + [ + f + for f in os.listdir(full_path) + if f.endswith(".md") and os.path.isfile(os.path.join(full_path, f)) + ] + ) + + for filename in md_files[:max_files]: + file_full_path = os.path.join(full_path, filename) + relative_path = os.path.join(dir_path, filename) + try: + with open(file_full_path, "r", encoding="utf-8") as f: + content = f.read() + results.append((relative_path, content)) + except Exception: + continue + except Exception: + pass + + return results + + +def read_jsonl_entries(base_path: str, jsonl_path: str) -> list[tuple[str, str]]: + """ + Read all file/directory contents referenced in jsonl file + + Schema: + {"file": "path/to/file.md", "reason": "..."} + {"file": "path/to/dir/", "type": "directory", "reason": "..."} + {"_example": "..."} # seed row — skipped (no `file` field) + + Rows without a ``file`` field (e.g. the self-describing seed line written + by ``task.py create`` before the agent has curated entries) are skipped + silently. If the resulting entry list is empty, a stderr warning is + emitted so the operator can debug missing context. + + Returns: + [(path, content), ...] + """ + full_path = os.path.join(base_path, jsonl_path) + if not os.path.exists(full_path): + print( + f"[inject-subagent-context] WARN: {jsonl_path} not found — " + f"sub-agent will receive only task artifacts", + file=sys.stderr, + ) + return [] + + results = [] + saw_real_entry = False + try: + with open(full_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + item = json.loads(line) + file_path = item.get("file") or item.get("path") + entry_type = item.get("type", "file") + + if not file_path: + # Seed / comment row — skip silently + continue + + saw_real_entry = True + if entry_type == "directory": + # Read all .md files in directory + dir_contents = read_directory_contents(base_path, file_path) + results.extend(dir_contents) + else: + # Read single file + content = read_file_content(base_path, file_path) + if content: + results.append((file_path, content)) + except json.JSONDecodeError: + continue + except Exception: + pass + + if not saw_real_entry: + print( + f"[inject-subagent-context] WARN: {jsonl_path} has no curated " + f"entries (only seed / empty) — sub-agent will receive only " + f"task artifacts. See workflow.md planning artifact guidance.", + file=sys.stderr, + ) + + return results + + + + +def get_agent_context(repo_root: str, task_dir: str, agent_type: str) -> str: + """ + Get context from {agent_type}.jsonl for the specified agent. + Only reads implement.jsonl or check.jsonl (the two JSONL files the task system creates). + """ + context_parts = [] + + agent_jsonl = f"{task_dir}/{agent_type}.jsonl" + for file_path, content in read_jsonl_entries(repo_root, agent_jsonl): + context_parts.append(f"=== {file_path} ===\n{content}") + + return "\n\n".join(context_parts) + + +def get_implement_context(repo_root: str, task_dir: str) -> str: + """ + Complete context for Implement Agent + + Read order: + 1. All files in implement.jsonl (spec/research manifests) + 2. prd.md (requirements) + 3. design.md if present (technical design) + 4. implement.md if present (execution plan) + """ + context_parts = [] + + # 1. Read implement.jsonl + base_context = get_agent_context(repo_root, task_dir, "implement") + if base_context: + context_parts.append(base_context) + + # 2. Requirements document + prd_content = read_file_content(repo_root, f"{task_dir}/prd.md") + if prd_content: + context_parts.append(f"=== {task_dir}/prd.md (Requirements) ===\n{prd_content}") + + # 3. Technical design for complex tasks + design_content = read_file_content(repo_root, f"{task_dir}/design.md") + if design_content: + context_parts.append( + f"=== {task_dir}/design.md (Technical Design) ===\n{design_content}" + ) + + # 4. Execution plan for complex tasks + implement_plan_content = read_file_content(repo_root, f"{task_dir}/implement.md") + if implement_plan_content: + context_parts.append( + f"=== {task_dir}/implement.md (Execution Plan) ===\n{implement_plan_content}" + ) + + return "\n\n".join(context_parts) + + +def get_check_context(repo_root: str, task_dir: str) -> str: + """ + Context for Check Agent: check.jsonl + task artifacts. + """ + context_parts = [] + + for file_path, content in read_jsonl_entries(repo_root, f"{task_dir}/check.jsonl"): + context_parts.append(f"=== {file_path} ===\n{content}") + + prd_content = read_file_content(repo_root, f"{task_dir}/prd.md") + if prd_content: + context_parts.append(f"=== {task_dir}/prd.md (Requirements) ===\n{prd_content}") + + design_content = read_file_content(repo_root, f"{task_dir}/design.md") + if design_content: + context_parts.append( + f"=== {task_dir}/design.md (Technical Design) ===\n{design_content}" + ) + + implement_plan_content = read_file_content(repo_root, f"{task_dir}/implement.md") + if implement_plan_content: + context_parts.append( + f"=== {task_dir}/implement.md (Execution Plan) ===\n{implement_plan_content}" + ) + + return "\n\n".join(context_parts) + + +def get_finish_context(repo_root: str, task_dir: str) -> str: + """ + Context for Finish phase: reuses check.jsonl + prd.md + (Finish is a final check, same context source.) + """ + return get_check_context(repo_root, task_dir) + + + +def build_implement_prompt(original_prompt: str, context: str) -> str: + """Build complete prompt for Implement""" + return f"""<!-- trellis-hook-injected --> +# Implement Agent Task + +You are the Implement Agent in the Multi-Agent Pipeline. + +## Your Context + +All the information you need has been prepared for you: + +{context} + +--- + +## Your Task + +{original_prompt} + +--- + +## Workflow + +1. **Understand specs** - All dev specs are injected above, understand them + 2. **Understand task artifacts** - Read requirements, technical design if present, and execution plan if present + 3. **Implement feature** - Implement following specs and task artifacts +4. **Self-check** - Ensure code quality against check specs + +## Important Constraints + +- Do NOT execute git commit, only code modifications +- Follow all dev specs injected above +- Report list of modified/created files when done""" + + +def build_check_prompt(original_prompt: str, context: str) -> str: + """Build complete prompt for Check""" + return f"""<!-- trellis-hook-injected --> +# Check Agent Task + +You are the Check Agent in the Multi-Agent Pipeline (code and cross-layer checker). + +## Your Context + +All check specs and dev specs you need: + +{context} + +--- + +## Your Task + +{original_prompt} + +--- + +## Workflow + +1. **Get changes** - Run `git diff --name-only` and `git diff` to get code changes +2. **Check against specs** - Check item by item against specs above +3. **Self-fix** - Fix issues directly, don't just report +4. **Run verification** - Run project's lint and typecheck commands + +## Important Constraints + +- Fix issues yourself, don't just report +- Must execute complete checklist in check specs +- Pay special attention to impact radius analysis (L1-L5)""" + + +def build_finish_prompt(original_prompt: str, context: str) -> str: + """Build complete prompt for Finish (final check before PR)""" + return f"""<!-- trellis-hook-injected --> +# Finish Agent Task + +You are performing the final check before creating a PR. + +## Your Context + +Finish checklist and requirements: + +{context} + +--- + +## Your Task + +{original_prompt} + +--- + +## Workflow + +1. **Review changes** - Run `git diff --name-only` to see all changed files + 2. **Verify task artifacts** - Check requirements in prd.md and, when present, design.md / implement.md +3. **Spec sync** - Analyze whether changes introduce new patterns, contracts, or conventions + - If new pattern/convention found: read target spec file → update it → update index.md if needed + - If infra/cross-layer change: follow the 7-section mandatory template from update-spec.md + - If pure code fix with no new patterns: skip this step +4. **Run final checks** - Execute lint and typecheck +5. **Confirm ready** - Ensure code is ready for PR + +## Important Constraints + +- You MAY update spec files when gaps are detected (use update-spec.md as guide) +- MUST read the target spec file BEFORE editing (avoid duplicating existing content) +- Do NOT update specs for trivial changes (typos, formatting, obvious fixes) +- If critical CODE issues found, report them clearly (fix specs, not code) +- Verify all acceptance criteria in prd.md are met +- Verify design.md and implement.md constraints when those files are present""" + + + +def get_research_context(repo_root: str, task_dir: str | None) -> str: + """ + Context for Research Agent — project structure overview for spec directories. + + `task_dir` kept for signature parity with get_implement_context / get_check_context + so the dispatcher can call them uniformly. + """ + _ = task_dir + context_parts = [] + + # 1. Project structure overview (dynamically discover spec directories) + spec_path = f"{DIR_WORKFLOW}/{DIR_SPEC}" + spec_root = Path(repo_root) / DIR_WORKFLOW / DIR_SPEC + + # Build spec tree dynamically + tree_lines = [f"{spec_path}/"] + if spec_root.is_dir(): + pkg_dirs = sorted(d for d in spec_root.iterdir() if d.is_dir()) + for i, pkg_dir in enumerate(pkg_dirs): + is_last = i == len(pkg_dirs) - 1 + prefix = "└── " if is_last else "├── " + layers = sorted(d.name for d in pkg_dir.iterdir() if d.is_dir()) + layer_info = f" ({', '.join(layers)})" if layers else "" + tree_lines.append(f"{prefix}{pkg_dir.name}/{layer_info}") + + spec_tree = "\n".join(tree_lines) + + project_structure = f"""## Project Spec Directory Structure + +``` +{spec_tree} +``` + +To get structured package info, run: `python ./{DIR_WORKFLOW}/scripts/get_context.py --mode packages` + +## Search Tips + +- Spec files: `{spec_path}/**/*.md` +- Code search: Use Glob and Grep tools +- Tech solutions: Use mcp__exa__web_search_exa or mcp__exa__get_code_context_exa""" + + context_parts.append(project_structure) + + return "\n\n".join(context_parts) + + +def build_research_prompt(original_prompt: str, context: str) -> str: + """Build complete prompt for Research""" + return f"""# Research Agent Task + +You are the Research Agent in the Multi-Agent Pipeline (search researcher). + +## Core Principle + +**You do one thing: find and explain information.** + +You are a documenter, not a reviewer. + +## Project Info + +{context} + +--- + +## Your Task + +{original_prompt} + +--- + +## Workflow + +1. **Understand query** - Determine search type (internal/external) and scope +2. **Plan search** - List search steps for complex queries +3. **Execute search** - Execute multiple independent searches in parallel +4. **Organize results** - Output structured report + +## Search Tools + +| Tool | Purpose | +|------|---------| +| Glob | Search by filename pattern | +| Grep | Search by content | +| Read | Read file content | +| mcp__exa__web_search_exa | External web search | +| mcp__exa__get_code_context_exa | External code/doc search | + +## Strict Boundaries + +**Only allowed**: Describe what exists, where it is, how it works + +**Forbidden** (unless explicitly asked): +- Suggest improvements +- Criticize implementation +- Recommend refactoring +- Modify any files + +## Report Format + +Provide structured search results including: +- List of files found (with paths) +- Code pattern analysis (if applicable) +- Related spec documents +- External references (if any)""" + + +def _string_value(value: Any) -> str: + if isinstance(value, str): + stripped = value.strip() + return stripped + return "" + + +def _extract_subagent_name(value: Any) -> str: + """Extract a sub-agent name from common platform encodings. + + Cursor's native Task args encode custom sub-agents as a protobuf oneof, + which can appear in hook JSON as either ``{"custom": {"name": "..."}}`` + or ``{"type": {"case": "custom", "value": {"name": "..."}}}``. + """ + direct = _string_value(value) + if direct: + return direct + + if not isinstance(value, dict): + return "" + + for key in ("name", "subagent_type_name", "subagentTypeName"): + direct = _string_value(value.get(key)) + if direct: + return direct + + custom = value.get("custom") + if isinstance(custom, dict): + custom_name = _string_value(custom.get("name")) + if custom_name: + return custom_name + + oneof = value.get("type") + if isinstance(oneof, dict): + case_name = _string_value(oneof.get("case")) + if case_name == "custom": + nested_value = oneof.get("value") + if isinstance(nested_value, dict): + custom_name = _string_value(nested_value.get("name")) + if custom_name: + return custom_name + if case_name: + return case_name + + case_name = _string_value(value.get("case")) + if case_name == "custom": + nested_value = value.get("value") + if isinstance(nested_value, dict): + custom_name = _string_value(nested_value.get("name")) + if custom_name: + return custom_name + if case_name: + return case_name + + for agent_name in AGENTS_ALL: + if agent_name in value: + return agent_name + + return "" + + +def _extract_subagent_type(tool_input: dict) -> str: + for key in ( + "subagent_type", + "subagentType", + "subagent_type_name", + "subagentTypeName", + "agent_type", + "agentType", + "name", + ): + agent_name = _extract_subagent_name(tool_input.get(key)) + if agent_name: + return agent_name + return "" + + +def _parse_hook_input(input_data: dict) -> tuple[str, str, dict]: + """Parse hook input across different platform formats. + + Returns (subagent_type, original_prompt, tool_input). + Handles: + - Claude Code / Qoder / CodeBuddy / Droid: tool_name=Task|Agent, tool_input.subagent_type + - Cursor: tool_name=Task|Subagent, tool_input.subagent_type + - Copilot CLI: toolName=task (camelCase key, lowercase value) + - Gemini CLI: tool_name IS the agent name (BeforeTool matcher already filtered) + - Kiro: agentSpawn hook, agent_name field at top level + """ + tool_input = input_data.get("tool_input", {}) + + # Standard format: Task/Agent tool with subagent_type + tool_name = input_data.get("tool_name", "") or input_data.get("toolName", "") + if tool_name.lower() in ("task", "agent", "subagent"): + return ( + _extract_subagent_type(tool_input), + tool_input.get("prompt", ""), + tool_input, + ) + + # Kiro: agentSpawn hook passes agent_name at top level + agent_name = input_data.get("agent_name", "") + if agent_name: + return agent_name, tool_input.get("prompt", input_data.get("prompt", "")), tool_input + + # Gemini CLI: BeforeTool where tool_name IS the agent name + # (matcher already ensured it's one of our agents) + if tool_name in AGENTS_ALL: + return tool_name, tool_input.get("prompt", ""), tool_input + + # Copilot CLI: toolName field (camelCase), value might be the agent name + tool_name_camel = input_data.get("toolName", "") + if tool_name_camel in AGENTS_ALL: + return tool_name_camel, input_data.get("toolArgs", ""), tool_input + + return "", "", tool_input + + +def main(): + if os.environ.get("TRELLIS_HOOKS") == "0" or os.environ.get("TRELLIS_DISABLE_HOOKS") == "1": + sys.exit(0) + + try: + input_data = json.load(sys.stdin) + except json.JSONDecodeError: + sys.exit(0) + + subagent_type, original_prompt, tool_input = _parse_hook_input(input_data) + cwd = input_data.get("cwd", os.getcwd()) + + # Only handle subagent types we care about + if subagent_type not in AGENTS_ALL: + sys.exit(0) + + # Find repo root + repo_root = find_repo_root(cwd) + if not repo_root: + sys.exit(0) + + # Get current task directory (research doesn't require it) + task_dir = get_current_task(repo_root, input_data) + + # implement/check need task directory + if subagent_type in AGENTS_REQUIRE_TASK: + if not task_dir: + sys.exit(0) + # Check if task directory exists + task_dir_full = os.path.join(repo_root, task_dir) + if not os.path.exists(task_dir_full): + sys.exit(0) + + # Check for [finish] marker in prompt (check agent with finish context) + is_finish_phase = "[finish]" in original_prompt.lower() + + # Get context and build prompt based on subagent type + if subagent_type == AGENT_IMPLEMENT: + assert task_dir is not None # validated above + context = get_implement_context(repo_root, task_dir) + new_prompt = build_implement_prompt(original_prompt, context) + elif subagent_type == AGENT_CHECK: + assert task_dir is not None # validated above + if is_finish_phase: + # Finish phase: use finish context (lighter, focused on final verification) + context = get_finish_context(repo_root, task_dir) + new_prompt = build_finish_prompt(original_prompt, context) + else: + # Regular check phase: use check context (full specs for self-fix loop) + context = get_check_context(repo_root, task_dir) + new_prompt = build_check_prompt(original_prompt, context) + elif subagent_type == AGENT_RESEARCH: + # Research can work without task directory + context = get_research_context(repo_root, task_dir) + new_prompt = build_research_prompt(original_prompt, context) + else: + sys.exit(0) + + if not context: + sys.exit(0) + + # Return updated input — use a multi-format output that covers all platforms. + # Most platforms ignore unrecognized fields, so we include multiple formats. + # The platform picks whichever fields it understands. + updated = {**tool_input, "prompt": new_prompt} + output = { + # Claude Code / Qoder / CodeBuddy / Droid format + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "updatedInput": updated, + }, + # Cursor format + "permission": "allow", + "updated_input": updated, + # Gemini format + "updatedInput": updated, + } + + print(json.dumps(output, ensure_ascii=False)) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/inject-workflow-state.py b/.claude/hooks/inject-workflow-state.py new file mode 100644 index 0000000..fda556b --- /dev/null +++ b/.claude/hooks/inject-workflow-state.py @@ -0,0 +1,363 @@ +#!/usr/bin/env python3 +"""Trellis per-turn breadcrumb hook (UserPromptSubmit / BeforeAgent equivalent). + +Runs on every user prompt. Resolves the active task through Trellis' +session-aware active task resolver and emits a short <workflow-state> +block reminding the main AI what task is active and its expected flow. + +The emitted ``hookEventName`` field is platform-aware: most hosts expect +``UserPromptSubmit`` (Claude Code naming, also accepted by Cursor / Qoder / +CodeBuddy / Droid / Codex / Copilot wiring), but Gemini CLI 0.40.x renamed +its per-turn event to ``BeforeAgent`` and its schema validator rejects the +legacy name. ``_detect_platform`` picks the right value at runtime. +Breadcrumb text is pulled exclusively from workflow.md +[workflow-state:STATUS] tag blocks — workflow.md is the single source of +truth. There are no fallback dicts in this script: when workflow.md is +missing or a tag is absent, the breadcrumb degrades to a generic +"Refer to workflow.md for current step." line so users see (and fix) +the broken state instead of the hook silently masking it. + +Shared across all hook-capable platforms (Claude, Cursor, Codex, Qoder, +CodeBuddy, Droid, Gemini, Copilot). Kiro is not wired (no per-turn +hook entry point). Written to each platform's hooks directory via +writeSharedHooks() at init time. + +Silent exit 0 cases (no output): + - No .trellis/ directory found (not a Trellis project) + - task.json malformed or missing status +""" +from __future__ import annotations + +import json +import os +import re +import sys +from pathlib import Path + +# Force UTF-8 on stdin/stdout/stderr on Windows. Default codepage there is +# cp936 / cp1252 / etc. — non-ASCII content (Chinese task names, prd snippets) +# both in stdin (hook payload from host CLI) and stdout (our emitted blocks) +# raises UnicodeDecodeError / UnicodeEncodeError. Equivalent to `python -X utf8` +# but applied per-stream so we don't depend on host CLI's command wiring. +if sys.platform.startswith("win"): + import io as _io + for _stream_name in ("stdin", "stdout", "stderr"): + _stream = getattr(sys, _stream_name, None) + if _stream is None: + continue + if hasattr(_stream, "reconfigure"): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] + except Exception: + pass + elif hasattr(_stream, "detach"): + try: + setattr(sys, _stream_name, _io.TextIOWrapper(_stream.detach(), encoding="utf-8", errors="replace")) + except Exception: + pass +from typing import Optional + + +# Bootstrap notice for Codex while the session has no active task. Codex does not +# get the full SessionStart overview; this short reminder points the main session +# at the start skill once and leaves the per-turn state block compact. +CODEX_NO_TASK_BOOTSTRAP_NOTICE = """<trellis-bootstrap> +If you have not already loaded Trellis context this session, read the `trellis-start` skill once. +</trellis-bootstrap>""" + + +# --------------------------------------------------------------------------- +# CWD-robust Trellis root discovery (fixes hook-path-robustness for this hook) +# --------------------------------------------------------------------------- + +def find_trellis_root(start: Path) -> Optional[Path]: + """Walk up from start to find directory containing .trellis/. + + Handles CWD drift: subdirectory launches, monorepo packages, etc. + Returns None if no .trellis/ found (silent no-op). + """ + cur = start.resolve() + while cur != cur.parent: + if (cur / ".trellis").is_dir(): + return cur + cur = cur.parent + return None + + +# --------------------------------------------------------------------------- +# Active task discovery +# --------------------------------------------------------------------------- + +def _detect_platform(input_data: dict) -> str | None: + if isinstance(input_data.get("cursor_version"), str): + return "cursor" + env_map = { + "CLAUDE_PROJECT_DIR": "claude", + "CURSOR_PROJECT_DIR": "cursor", + "CODEBUDDY_PROJECT_DIR": "codebuddy", + "FACTORY_PROJECT_DIR": "droid", + "GEMINI_PROJECT_DIR": "gemini", + "QODER_PROJECT_DIR": "qoder", + "KIRO_PROJECT_DIR": "kiro", + "COPILOT_PROJECT_DIR": "copilot", + } + for env_name, platform in env_map.items(): + if os.environ.get(env_name): + return platform + script_parts = set(Path(sys.argv[0]).parts) + if ".claude" in script_parts: + return "claude" + if ".cursor" in script_parts: + return "cursor" + if ".codex" in script_parts: + return "codex" + if ".gemini" in script_parts: + return "gemini" + if ".qoder" in script_parts: + return "qoder" + if ".codebuddy" in script_parts: + return "codebuddy" + if ".factory" in script_parts: + return "droid" + if ".kiro" in script_parts: + return "kiro" + return None + + +def _resolve_active_task(root: Path, input_data: dict): + scripts_dir = root / ".trellis" / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + from common.active_task import resolve_active_task # type: ignore[import-not-found] + + return resolve_active_task(root, input_data, platform=_detect_platform(input_data)) + + +def get_active_task(root: Path, input_data: dict) -> Optional[tuple[str, str, str]]: + """Return (task_id, status, source) from the current active task.""" + active = _resolve_active_task(root, input_data) + if not active.task_path: + return None + + task_dir = Path(active.task_path) + if not task_dir.is_absolute(): + task_dir = root / task_dir + if active.stale: + return task_dir.name, f"stale_{active.source_type}", active.source + + task_json = task_dir / "task.json" + if not task_json.is_file(): + return None + try: + data = json.loads(task_json.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return None + + task_id = data.get("id") or task_dir.name + status = data.get("status", "") + if not isinstance(status, str) or not status: + return None + return task_id, status, active.source + + +# --------------------------------------------------------------------------- +# Breadcrumb loading: parse workflow.md, fall back to hardcoded defaults +# --------------------------------------------------------------------------- + +# Supports STATUS values with letters, digits, underscores, hyphens +# (so "in-review" / "blocked-by-team" work alongside "in_progress"). +_TAG_RE = re.compile( + r"\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n(.*?)\n\s*\[/workflow-state:\1\]", + re.DOTALL, +) + +def load_breadcrumbs(root: Path) -> dict[str, str]: + """Parse workflow.md for [workflow-state:STATUS] blocks. + + Returns {status: body_text}. workflow.md is the single source of + truth — there are no fallback dicts in this script. Missing tags + (or a missing/unreadable workflow.md) fall back to a generic line + in build_breadcrumb so users see the broken state and fix + workflow.md, rather than the hook silently masking the issue. + """ + workflow = root / ".trellis" / "workflow.md" + if not workflow.is_file(): + return {} + try: + content = workflow.read_text(encoding="utf-8") + except OSError: + return {} + + result: dict[str, str] = {} + for match in _TAG_RE.finditer(content): + status = match.group(1) + body = match.group(2).strip() + if body: + result[status] = body + return result + + +def _read_trellis_config(root: Path) -> dict: + """Load .trellis/config.yaml via the bundled trellis_config helper. + + The helper lives in .trellis/scripts/common; the hook lives outside the + scripts tree, so we extend sys.path before importing. + """ + scripts_dir = root / ".trellis" / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + try: + from common.trellis_config import read_trellis_config # type: ignore[import-not-found] + except Exception: + return {} + try: + return read_trellis_config(root) + except Exception: + return {} + + +def _codex_mode_banner(config: dict) -> str: + """Emit a `<codex-mode>` banner for the additionalContext payload. + + Reads `codex.dispatch_mode` from .trellis/config.yaml; defaults to + `inline` when missing or invalid because Codex sub-agents run with + `fork_turns="none"` isolation and can't inherit the parent session's + task context. The banner makes the active mode explicit to Codex AI + per turn, complementing the workflow-state body which is per-status. + Mode tells AI which dispatch protocol to follow; workflow-state tells + AI what step it's at. + """ + mode = "inline" + if isinstance(config, dict): + codex_cfg = config.get("codex") + if isinstance(codex_cfg, dict): + cfg_mode = codex_cfg.get("dispatch_mode") + if cfg_mode in ("inline", "sub-agent"): + mode = cfg_mode + if mode == "sub-agent": + meaning = ( + "sub-agent: implement/check work defaults to Trellis sub-agents; " + "the main session still coordinates, clarifies, updates specs, commits, and finishes." + ) + else: + meaning = ( + "inline: the main session implements/checks directly; " + "do not dispatch implement/check sub-agents." + ) + return f"<codex-mode>{meaning}</codex-mode>" + + +def resolve_breadcrumb_key( + status: str, platform: str | None, config: dict +) -> str: + """Pick the breadcrumb tag key based on Codex dispatch_mode. + + Codex defaults to ``inline`` because sub-agents run with ``fork_turns="none"`` + isolation and can't inherit the parent session's task context. Users can + opt into ``codex.dispatch_mode: sub-agent`` in ``.trellis/config.yaml`` + to use the parallel ``<status>-inline`` tag → ``<status>`` flip. Invalid + or missing values fall back to inline. + + Non-codex platforms return the plain status unchanged. + """ + if platform == "codex": + mode = "inline" + if isinstance(config, dict): + codex_cfg = config.get("codex") + if isinstance(codex_cfg, dict): + cfg_mode = codex_cfg.get("dispatch_mode") + if cfg_mode in ("inline", "sub-agent"): + mode = cfg_mode + return f"{status}-inline" if mode == "inline" else status + return status + + +def build_breadcrumb( + task_id: Optional[str], + status: str, + templates: dict[str, str], + source: str | None = None, + breadcrumb_key: str | None = None, +) -> str: + """Build the <workflow-state>...</workflow-state> block. + + - Known status (tag present in workflow.md) → detailed template body + - Unknown status (no tag, or workflow.md missing) → generic + "Refer to workflow.md for current step." line + - `no_task` pseudo-status (task_id is None) → header omits task info + """ + lookup_key = breadcrumb_key or status + body = templates.get(lookup_key) + if body is None and lookup_key != status: + body = templates.get(status) + if body is None: + body = "Refer to workflow.md for current step." + header = f"Status: {status}" if task_id is None else f"Task: {task_id} ({status})" + return f"<workflow-state>\n{header}\n{body}\n</workflow-state>" + + +# --------------------------------------------------------------------------- +# Entry +# --------------------------------------------------------------------------- + +def main() -> int: + if os.environ.get("TRELLIS_HOOKS") == "0" or os.environ.get("TRELLIS_DISABLE_HOOKS") == "1": + return 0 + + try: + data = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + data = {} + + cwd_str = data.get("cwd") or os.getcwd() + cwd = Path(cwd_str) + + root = find_trellis_root(cwd) + if root is None: + return 0 # not a Trellis project + + templates = load_breadcrumbs(root) + platform = _detect_platform(data) + config = _read_trellis_config(root) + task = get_active_task(root, data) + if task is None: + # No active task — still emit a breadcrumb nudging AI toward + # trellis-brainstorm + task.py create when user describes real work. + no_task_key = resolve_breadcrumb_key("no_task", platform, config) + breadcrumb = build_breadcrumb( + None, "no_task", templates, breadcrumb_key=no_task_key + ) + else: + task_id, status, source = task + status_key = resolve_breadcrumb_key(status, platform, config) + source_for_breadcrumb = None if platform == "codex" else source + breadcrumb = build_breadcrumb( + task_id, status, templates, source_for_breadcrumb, breadcrumb_key=status_key + ) + if platform == "codex": + parts: list[str] = [] + if task is None: + parts.append(CODEX_NO_TASK_BOOTSTRAP_NOTICE) + parts.append(_codex_mode_banner(config)) + parts.append(breadcrumb) + breadcrumb = "\n\n".join(parts) + + # Gemini CLI 0.40.x rejects "UserPromptSubmit" — its per-turn event is + # named "BeforeAgent". Other platforms (Claude/Cursor/Qoder/CodeBuddy/ + # Droid/Codex/Copilot) accept the original Claude-style name. + hook_event_name = ( + "BeforeAgent" if platform == "gemini" else "UserPromptSubmit" + ) + + output = { + "hookSpecificOutput": { + "hookEventName": hook_event_name, + "additionalContext": breadcrumb, + } + } + print(json.dumps(output)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/hooks/session-start.py b/.claude/hooks/session-start.py new file mode 100644 index 0000000..238076e --- /dev/null +++ b/.claude/hooks/session-start.py @@ -0,0 +1,831 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Session Start Hook - Inject structured context +""" +from __future__ import annotations + +# IMPORTANT: Suppress all warnings FIRST +import warnings +warnings.filterwarnings("ignore") + +import json +import os +import re +import shlex +import subprocess +import sys +from io import StringIO +from pathlib import Path + + +def _normalize_windows_shell_path(path_str: str) -> str: + """Normalize Unix-style shell paths to real Windows paths. + + On Windows, shells like Git Bash / MSYS2 / Cygwin may report paths like + `/d/Users/...` or `/cygdrive/d/Users/...`. `Path.resolve()` will misinterpret + these as `D:/d/Users...` on drive D: (or similar), breaking repo root + detection. + + This function is intentionally conservative: it only rewrites patterns that + unambiguously represent a drive letter mount. + """ + if not isinstance(path_str, str) or not path_str: + return path_str + + # Only relevant on Windows; keep other platforms untouched. + if not sys.platform.startswith("win"): + return path_str + + p = path_str.strip() + + # Already a Windows drive path (C:\... or C:/...) + if re.match(r"^[A-Za-z]:[\/]", p): + return p + + # MSYS/Git-Bash style: /c/Users/... or /d/Work/... + m = re.match(r"^/([A-Za-z])/(.*)", p) + if m: + drive, rest = m.group(1).upper(), m.group(2) + rest = rest.replace('/', '\\') + return f"{drive}:\\{rest}" + + # Cygwin style: /cygdrive/c/Users/... + m = re.match(r"^/cygdrive/([A-Za-z])/(.*)", p) + if m: + drive, rest = m.group(1).upper(), m.group(2) + rest = rest.replace('/', '\\') + return f"{drive}:\\{rest}" + + # WSL mounted drive (sometimes leaked into env): /mnt/c/Users/... + m = re.match(r"^/mnt/([A-Za-z])/(.*)", p) + if m: + drive, rest = m.group(1).upper(), m.group(2) + rest = rest.replace('/', '\\') + return f"{drive}:\\{rest}" + + return path_str + + +FIRST_REPLY_NOTICE = """<first-reply-notice> +First visible reply: say once in Chinese that Trellis SessionStart context is loaded, then answer directly. +This notice is one-shot: do not repeat it after the first assistant reply in the same session. +</first-reply-notice>""" + +# Force UTF-8 on stdin/stdout/stderr on Windows. Default codepage there is +# cp936 / cp1252 / etc. — non-ASCII content (Chinese task names, prd snippets) +# both in stdin (hook payload from host CLI) and stdout (our emitted blocks) +# raises UnicodeDecodeError / UnicodeEncodeError. Equivalent to `python -X utf8` +# but applied per-stream so we don't depend on host CLI's command wiring. +if sys.platform.startswith("win"): + import io as _io + for _stream_name in ("stdin", "stdout", "stderr"): + _stream = getattr(sys, _stream_name, None) + if _stream is None: + continue + if hasattr(_stream, "reconfigure"): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] + except Exception: + pass + elif hasattr(_stream, "detach"): + try: + setattr(sys, _stream_name, _io.TextIOWrapper(_stream.detach(), encoding="utf-8", errors="replace")) + except Exception: + pass + + + +def _has_curated_jsonl_entry(jsonl_path: Path) -> bool: + """Return True iff jsonl has at least one row with a ``file`` field. + + A freshly seeded jsonl only contains a ``{"_example": ...}`` row (no + ``file`` key) — that is NOT "ready". Readiness requires at least one + curated entry. Matches the contract used by hook-inject and pull-based + sub-agent context loaders. + """ + try: + for line in jsonl_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + row = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(row, dict) and row.get("file"): + return True + except (OSError, UnicodeDecodeError): + return False + return False + + +def should_skip_injection() -> bool: + """Check if any platform's non-interactive flag is set, or if Trellis + hooks are explicitly disabled via TRELLIS_HOOKS=0 / TRELLIS_DISABLE_HOOKS=1. + """ + if os.environ.get("TRELLIS_HOOKS") == "0": + return True + if os.environ.get("TRELLIS_DISABLE_HOOKS") == "1": + return True + non_interactive_vars = [ + "CLAUDE_NON_INTERACTIVE", + "QODER_NON_INTERACTIVE", + "CODEBUDDY_NON_INTERACTIVE", + "FACTORY_NON_INTERACTIVE", + "CURSOR_NON_INTERACTIVE", + "GEMINI_NON_INTERACTIVE", + "KIRO_NON_INTERACTIVE", + "COPILOT_NON_INTERACTIVE", + ] + return any(os.environ.get(var) == "1" for var in non_interactive_vars) + + +def read_file(path: Path, fallback: str = "") -> str: + try: + return path.read_text(encoding="utf-8") + except (FileNotFoundError, PermissionError): + return fallback + + +def _repo_relative(repo_root: Path, path: Path) -> str: + try: + return path.relative_to(repo_root).as_posix() + except ValueError: + return str(path) + + +def _run_git(repo_root: Path, args: list[str]) -> str: + try: + result = subprocess.run( + ["git", *args], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=3, + cwd=str(repo_root), + ) + except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError): + return "" + if result.returncode != 0: + return "" + return result.stdout.strip() + + +def _format_git_state(repo_root: Path) -> str: + branch = _run_git(repo_root, ["branch", "--show-current"]) or "(detached)" + dirty_lines = [ + line for line in _run_git(repo_root, ["status", "--porcelain"]).splitlines() + if line.strip() + ] + dirty_text = "clean" if not dirty_lines else f"dirty {len(dirty_lines)} paths" + return f"Git: branch {branch}; {dirty_text}." + + +def _detect_platform(input_data: dict) -> str | None: + if isinstance(input_data.get("cursor_version"), str): + return "cursor" + env_map = { + "CLAUDE_PROJECT_DIR": "claude", + "CURSOR_PROJECT_DIR": "cursor", + "CODEBUDDY_PROJECT_DIR": "codebuddy", + "FACTORY_PROJECT_DIR": "droid", + "GEMINI_PROJECT_DIR": "gemini", + "QODER_PROJECT_DIR": "qoder", + "KIRO_PROJECT_DIR": "kiro", + "COPILOT_PROJECT_DIR": "copilot", + } + for env_name, platform in env_map.items(): + if os.environ.get(env_name): + return platform + script_parts = set(Path(sys.argv[0]).parts) + if ".claude" in script_parts: + return "claude" + if ".cursor" in script_parts: + return "cursor" + if ".codex" in script_parts: + return "codex" + if ".gemini" in script_parts: + return "gemini" + if ".qoder" in script_parts: + return "qoder" + if ".codebuddy" in script_parts: + return "codebuddy" + if ".factory" in script_parts: + return "droid" + if ".kiro" in script_parts: + return "kiro" + return None + + +def _resolve_context_key(trellis_dir: Path, input_data: dict) -> str | None: + scripts_dir = trellis_dir / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + from common.active_task import resolve_context_key # type: ignore[import-not-found] + + return resolve_context_key(input_data, platform=_detect_platform(input_data)) + + +def _persist_context_key_for_bash(context_key: str | None) -> None: + """Expose Trellis session identity to later Claude Code Bash commands. + + Claude Code SessionStart hooks can append exports to CLAUDE_ENV_FILE; those + variables are then available to Bash tools in the same conversation. Without + this bridge, `task.py start` has hook stdin during SessionStart but no + session identity when the AI later runs it as a normal shell command. + """ + if not context_key: + return + env_file = os.environ.get("CLAUDE_ENV_FILE") + if not env_file: + return + try: + with open(env_file, "a", encoding="utf-8") as handle: + handle.write(f"export TRELLIS_CONTEXT_ID={shlex.quote(context_key)}\n") + except OSError: + pass + + +def _resolve_active_task(trellis_dir: Path, input_data: dict): + scripts_dir = trellis_dir / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + from common.active_task import resolve_active_task # type: ignore[import-not-found] + + return resolve_active_task( + trellis_dir.parent, + input_data, + platform=_detect_platform(input_data), + ) + + +def run_script(script_path: Path, context_key: str | None = None) -> str: + try: + if script_path.suffix == ".py": + # Add PYTHONIOENCODING to force UTF-8 in subprocess + env = os.environ.copy() + env["PYTHONIOENCODING"] = "utf-8" + if context_key: + env["TRELLIS_CONTEXT_ID"] = context_key + cmd = [sys.executable, "-W", "ignore", str(script_path)] + else: + env = os.environ.copy() + if context_key: + env["TRELLIS_CONTEXT_ID"] = context_key + cmd = [str(script_path)] + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=5, + cwd=script_path.parent.parent.parent, + env=env, + ) + return result.stdout if result.returncode == 0 else "No context available" + except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError): + return "No context available" + + +def _normalize_task_ref(task_ref: str) -> str: + normalized = task_ref.strip() + if not normalized: + return "" + + path_obj = Path(normalized) + if path_obj.is_absolute(): + return str(path_obj) + + normalized = normalized.replace("\\", "/") + while normalized.startswith("./"): + normalized = normalized[2:] + + if normalized.startswith("tasks/"): + return f".trellis/{normalized}" + + return normalized + + +def _resolve_task_dir(trellis_dir: Path, task_ref: str) -> Path: + normalized = _normalize_task_ref(task_ref) + path_obj = Path(normalized) + if path_obj.is_absolute(): + return path_obj + if normalized.startswith(".trellis/"): + return trellis_dir.parent / path_obj + return trellis_dir / "tasks" / path_obj + + +def _get_task_status(trellis_dir: Path, input_data: dict) -> str: + """Return compact active-task status, artifact presence, and next action.""" + active = _resolve_active_task(trellis_dir, input_data) + + if not active.task_path: + return ( + "Status: NO ACTIVE TASK\n" + "Next-Action: Classify the current turn before creating any Trellis task. " + "Simple conversation / small task asks only whether this turn should create a Trellis task. " + "Complex task asks whether task creation and planning are allowed." + ) + + task_ref = active.task_path + task_dir = _resolve_task_dir(trellis_dir, task_ref) + if active.stale or not task_dir.is_dir(): + return ( + f"Status: STALE POINTER\nTask: {task_ref}\n" + f"Next-Action: Run `python ./.trellis/scripts/task.py finish` to clear the stale pointer, " + "then ask the user what to work on next." + ) + + task_json_path = task_dir / "task.json" + task_data = {} + if task_json_path.is_file(): + try: + task_data = json.loads(task_json_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, PermissionError): + pass + + task_title = task_data.get("title", task_ref) + task_status = task_data.get("status", "unknown") + artifact_names = ("prd.md", "design.md", "implement.md", "implement.jsonl", "check.jsonl") + present = [name for name in artifact_names if (task_dir / name).is_file()] + if (task_dir / "research").is_dir(): + present.append("research/") + present_line = ", ".join(present) if present else "(none)" + + if task_status == "completed": + return ( + f"Status: COMPLETED\nTask: {task_title}\n" + f"Present: {present_line}\n" + "Next-Action: Run `/trellis:finish-work`. If the working tree is dirty, return to Phase 3.4 first." + ) + + has_prd = (task_dir / "prd.md").is_file() + has_design = (task_dir / "design.md").is_file() + has_implement_plan = (task_dir / "implement.md").is_file() + implement_jsonl = task_dir / "implement.jsonl" + check_jsonl = task_dir / "check.jsonl" + jsonl_ready = ( + (not implement_jsonl.is_file() or _has_curated_jsonl_entry(implement_jsonl)) + and (not check_jsonl.is_file() or _has_curated_jsonl_entry(check_jsonl)) + ) + + if task_status == "planning" and not has_prd: + return ( + f"Status: PLANNING\nTask: {task_title}\n" + f"Present: {present_line}\n" + "Next-Action: Load `trellis-brainstorm` and write `prd.md`. Stay in planning." + ) + + if task_status == "planning": + missing_complex = [ + name for name, exists in ( + ("design.md", has_design), + ("implement.md", has_implement_plan), + ) + if not exists + ] + next_bits: list[str] = [] + if missing_complex: + next_bits.append( + "Lightweight task can request start review with PRD-only; " + f"complex task must add {', '.join(missing_complex)} before start" + ) + else: + next_bits.append("Planning artifacts are present; ask for review before `task.py start`") + if not jsonl_ready: + next_bits.append("curate `implement.jsonl` and `check.jsonl` before sub-agent mode start") + return ( + f"Status: PLANNING\nTask: {task_title}\n" + f"Present: {present_line}\n" + f"Next-Action: {'; '.join(next_bits)}. Do not enter implementation until the user confirms start." + ) + + return ( + f"Status: {str(task_status).upper()}\nTask: {task_title}\n" + f"Present: {present_line}\n" + "Next-Action: Follow the matching per-turn workflow-state. " + "Implementation/check context order is jsonl entries -> `prd.md` -> `design.md if present` -> `implement.md if present`." + ) + + +def _load_trellis_config(trellis_dir: Path, input_data: dict) -> tuple: + """Load Trellis config for session-start decisions. + + Returns: + (is_mono, packages_dict, spec_scope, task_pkg, default_pkg) + """ + scripts_dir = trellis_dir / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + + try: + from common.config import get_default_package, get_packages, get_spec_scope, is_monorepo # type: ignore[import-not-found] + from common.paths import get_current_task # type: ignore[import-not-found] + + repo_root = trellis_dir.parent + is_mono = is_monorepo(repo_root) + packages = get_packages(repo_root) or {} + scope = get_spec_scope(repo_root) + + # Get active task's package + task_pkg = None + current = get_current_task( + repo_root, + input_data, + platform=_detect_platform(input_data), + ) + if current: + task_json = repo_root / current / "task.json" + if task_json.is_file(): + try: + data = json.loads(task_json.read_text(encoding="utf-8")) + if isinstance(data, dict): + tp = data.get("package") + if isinstance(tp, str) and tp: + task_pkg = tp + except (json.JSONDecodeError, OSError): + pass + + default_pkg = get_default_package(repo_root) + return is_mono, packages, scope, task_pkg, default_pkg + except Exception: + return False, {}, None, None, None + + +def _check_legacy_spec(trellis_dir: Path, is_mono: bool, packages: dict) -> str | None: + """Check for legacy spec directory structure in monorepo. + + Returns warning message if legacy structure detected, None otherwise. + """ + if not is_mono or not packages: + return None + + spec_dir = trellis_dir / "spec" + if not spec_dir.is_dir(): + return None + + # Check for legacy flat spec dirs (spec/backend/, spec/frontend/ with index.md) + has_legacy = False + for legacy_name in ("backend", "frontend"): + legacy_dir = spec_dir / legacy_name + if legacy_dir.is_dir() and (legacy_dir / "index.md").is_file(): + has_legacy = True + break + + if not has_legacy: + return None + + # Check which packages are missing spec/<pkg>/ directory + missing = [ + name for name in sorted(packages.keys()) + if not (spec_dir / name).is_dir() + ] + + if not missing: + return None # All packages have spec dirs + + if len(missing) == len(packages): + return ( + f"[!] Legacy spec structure detected: found `spec/backend/` or `spec/frontend/` " + f"but no package-scoped `spec/<package>/` directories.\n" + f"Monorepo packages: {', '.join(sorted(packages.keys()))}\n" + f"Please reorganize: `spec/backend/` -> `spec/<package>/backend/`" + ) + return ( + f"[!] Partial spec migration detected: packages {', '.join(missing)} " + f"still missing `spec/<pkg>/` directory.\n" + f"Please complete migration for all packages." + ) + + +def _resolve_spec_scope( + is_mono: bool, + packages: dict, + scope, + task_pkg: str | None, + default_pkg: str | None, +) -> set | None: + """Resolve which packages should have their specs injected. + + Returns: + Set of package names to include, or None for full scan. + """ + if not is_mono or not packages: + return None # Single-repo: full scan + + if scope is None: + return None # No scope configured: full scan + + if isinstance(scope, str) and scope == "active_task": + if task_pkg and task_pkg in packages: + return {task_pkg} + if default_pkg and default_pkg in packages: + return {default_pkg} + return None # Fallback to full scan + + if isinstance(scope, list): + valid = set() + for entry in scope: + if entry in packages: + valid.add(entry) + else: + print( + f"Warning: spec_scope contains unknown package: {entry}, ignoring", + file=sys.stderr, + ) + + if valid: + # Warn if active task is out of scope + if task_pkg and task_pkg not in valid: + print( + f"Warning: active task package '{task_pkg}' is out of configured spec_scope", + file=sys.stderr, + ) + return valid + + # All entries invalid: fallback chain + print( + "Warning: all spec_scope entries invalid, falling back to task/default/full", + file=sys.stderr, + ) + if task_pkg and task_pkg in packages: + return {task_pkg} + if default_pkg and default_pkg in packages: + return {default_pkg} + return None # Full scan + + return None # Unknown scope type: full scan + + +def _collect_spec_index_paths(trellis_dir: Path, allowed_pkgs: set | None) -> list[str]: + paths: list[str] = [] + guides_index = trellis_dir / "spec" / "guides" / "index.md" + if guides_index.is_file(): + paths.append(".trellis/spec/guides/index.md") + + spec_dir = trellis_dir / "spec" + if not spec_dir.is_dir(): + return paths + + for sub in sorted(spec_dir.iterdir()): + if not sub.is_dir() or sub.name.startswith(".") or sub.name == "guides": + continue + + index_file = sub / "index.md" + if index_file.is_file(): + paths.append(f".trellis/spec/{sub.name}/index.md") + continue + + if allowed_pkgs is not None and sub.name not in allowed_pkgs: + continue + for nested in sorted(sub.iterdir()): + if not nested.is_dir(): + continue + nested_index = nested / "index.md" + if nested_index.is_file(): + paths.append(f".trellis/spec/{sub.name}/{nested.name}/index.md") + + return paths + + +def _build_compact_current_state( + trellis_dir: Path, + input_data: dict, + spec_index_paths: list[str], +) -> str: + repo_root = trellis_dir.parent + lines: list[str] = [] + + try: + from common.paths import get_active_journal_file, get_developer, get_tasks_dir, count_lines # type: ignore[import-not-found] + from common.tasks import iter_active_tasks # type: ignore[import-not-found] + except Exception: + get_active_journal_file = None # type: ignore[assignment] + get_developer = None # type: ignore[assignment] + get_tasks_dir = None # type: ignore[assignment] + count_lines = None # type: ignore[assignment] + iter_active_tasks = None # type: ignore[assignment] + + developer = get_developer(repo_root) if get_developer else None + lines.append(f"Developer: {developer or '(not initialized)'}") + lines.append(_format_git_state(repo_root)) + + active = _resolve_active_task(trellis_dir, input_data) + if active.task_path: + task_dir = _resolve_task_dir(trellis_dir, active.task_path) + status = "unknown" + task_json = task_dir / "task.json" + if task_json.is_file(): + try: + data = json.loads(task_json.read_text(encoding="utf-8")) + if isinstance(data, dict): + status = str(data.get("status") or "unknown") + except (json.JSONDecodeError, OSError): + pass + lines.append(f"Current task: {_repo_relative(repo_root, task_dir)}; status={status}.") + else: + lines.append("Current task: none.") + + if get_tasks_dir and iter_active_tasks: + try: + task_count = sum(1 for _ in iter_active_tasks(get_tasks_dir(repo_root))) + lines.append( + f"Active tasks: {task_count} total. Use `python ./.trellis/scripts/task.py list --mine` only if needed." + ) + except Exception: + pass + + if get_active_journal_file and count_lines: + journal = get_active_journal_file(repo_root) + if journal: + lines.append( + f"Journal: {_repo_relative(repo_root, journal)}, {count_lines(journal)} / 2000 lines." + ) + + if spec_index_paths: + lines.append(f"Spec indexes: {len(spec_index_paths)} available.") + + return "\n".join(lines) + + +def _extract_range(content: str, start_header: str, end_header: str) -> str: + """Extract lines starting at `## start_header` up to (but excluding) `## end_header`. + + Both parameters are full header lines WITHOUT the `## ` prefix (e.g. "Phase Index"). + Returns empty string if start header is not found. + End header missing → extracts to end of file. + """ + lines = content.splitlines() + start: int | None = None + end: int = len(lines) + start_match = f"## {start_header}" + end_match = f"## {end_header}" + for i, line in enumerate(lines): + stripped = line.strip() + if start is None and stripped == start_match: + start = i + continue + if start is not None and stripped == end_match: + end = i + break + if start is None: + return "" + return "\n".join(lines[start:end]).rstrip() + + +_BREADCRUMB_TAG_RE = re.compile( + r"\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n.*?\n\s*\[/workflow-state:\1\]", + re.DOTALL, +) + + +def _strip_breadcrumb_tag_blocks(content: str) -> str: + """Remove `[workflow-state:STATUS]...[/workflow-state:STATUS]` blocks. + + The tag blocks live inside `## Phase Index` (since v0.5.0-rc.0, when + they were colocated with their phase summaries) and are consumed by the + UserPromptSubmit hook (`inject-workflow-state.py`). The session-start + payload already covers the full step bodies, so re-inlining the + breadcrumbs here would just duplicate context. + """ + stripped = _BREADCRUMB_TAG_RE.sub("", content) + stripped = re.sub(r"<!--.*?-->", "", stripped, flags=re.DOTALL) + stripped = re.sub(r"^\[(?!/?workflow-state:)/?[^\]\n]+\]\s*\n?", "", stripped, flags=re.MULTILINE) + return re.sub(r"\n{3,}", "\n\n", stripped).strip() + + +def _build_workflow_overview(workflow_path: Path) -> str: + """Inject only the compact Phase Index summary for SessionStart.""" + content = read_file(workflow_path) + if not content: + return "No workflow.md found" + + out_lines = [ + "# Development Workflow - Session Summary", + "Full guide: .trellis/workflow.md. Step detail: `python ./.trellis/scripts/get_context.py --mode phase --step <X.Y>`.", + "", + ] + + phases = _extract_range(content, "Phase Index", "Phase 1: Plan") + if phases: + out_lines.append(_strip_breadcrumb_tag_blocks(phases).rstrip()) + + return "\n".join(out_lines).rstrip() + + +def main(): + if should_skip_injection(): + sys.exit(0) + + try: + hook_input = json.loads(sys.stdin.read()) + if not isinstance(hook_input, dict): + hook_input = {} + except (json.JSONDecodeError, ValueError): + hook_input = {} + + # Try platform-specific env vars, hook cwd, fallback to cwd + project_dir_env_vars = [ + "CLAUDE_PROJECT_DIR", + "QODER_PROJECT_DIR", + "CODEBUDDY_PROJECT_DIR", + "FACTORY_PROJECT_DIR", + "CURSOR_PROJECT_DIR", + "GEMINI_PROJECT_DIR", + "KIRO_PROJECT_DIR", + "COPILOT_PROJECT_DIR", + ] + project_dir = None + for var in project_dir_env_vars: + val = os.environ.get(var) + if val: + project_dir = Path(_normalize_windows_shell_path(val)).resolve() + break + if project_dir is None: + project_dir = Path(_normalize_windows_shell_path(hook_input.get("cwd", "."))).resolve() + + trellis_dir = project_dir / ".trellis" + context_key = _resolve_context_key(trellis_dir, hook_input) + _persist_context_key_for_bash(context_key) + + # Load config for scope filtering and legacy detection + is_mono, packages, scope_config, task_pkg, default_pkg = _load_trellis_config( + trellis_dir, + hook_input, + ) + allowed_pkgs = _resolve_spec_scope(is_mono, packages, scope_config, task_pkg, default_pkg) + + output = StringIO() + + spec_index_paths = _collect_spec_index_paths(trellis_dir, allowed_pkgs) + + output.write("""<session-context> +Trellis compact SessionStart context. Use it to orient the session; load details on demand. +</session-context> + +""") + output.write(FIRST_REPLY_NOTICE) + output.write("\n\n") + + # Legacy migration warning + legacy_warning = _check_legacy_spec(trellis_dir, is_mono, packages) + if legacy_warning: + output.write(f"<migration-warning>\n{legacy_warning}\n</migration-warning>\n\n") + + output.write("<current-state>\n") + output.write(_build_compact_current_state(trellis_dir, hook_input, spec_index_paths)) + output.write("\n</current-state>\n\n") + + output.write("<trellis-workflow>\n") + output.write(_build_workflow_overview(trellis_dir / "workflow.md")) + output.write("\n</trellis-workflow>\n\n") + + output.write("<guidelines>\n") + output.write( + "Task context order for implementation/check: jsonl entries -> `prd.md` -> " + "`design.md if present` -> `implement.md if present`. Missing optional artifacts " + "are skipped for lightweight tasks.\n\n" + ) + + if spec_index_paths: + output.write("## Available indexes (read on demand)\n") + for p in spec_index_paths: + output.write(f"- {p}\n") + output.write("\n") + + output.write( + "Discover more via: " + "`python ./.trellis/scripts/get_context.py --mode packages`\n" + ) + output.write("</guidelines>\n\n") + + # Check task status and inject structured tag + task_status = _get_task_status(trellis_dir, hook_input) + output.write(f"<task-status>\n{task_status}\n</task-status>\n\n") + + output.write("""<ready> +Context loaded. Follow <task-status>. Load workflow/spec/task details only when needed. +</ready>""") + + context_text = output.getvalue() + result = { + # Claude Code / Qoder / CodeBuddy / Droid / Gemini / Copilot format + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": context_text, + }, + # Cursor sessionStart format (top-level snake_case per Cursor docs) + "additional_context": context_text, + } + + # Output JSON - stdout is already configured for UTF-8 + print(json.dumps(result, ensure_ascii=False), flush=True) + + +if __name__ == "__main__": + main() diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..5c46b7f --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,73 @@ +{ + "env": { + "CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR": "1" + }, + "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [ + { + "type": "command", + "command": "python .claude/hooks/session-start.py", + "timeout": 30 + } + ] + }, + { + "matcher": "clear", + "hooks": [ + { + "type": "command", + "command": "python .claude/hooks/session-start.py", + "timeout": 30 + } + ] + }, + { + "matcher": "compact", + "hooks": [ + { + "type": "command", + "command": "python .claude/hooks/session-start.py", + "timeout": 30 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Task", + "hooks": [ + { + "type": "command", + "command": "python .claude/hooks/inject-subagent-context.py", + "timeout": 30 + } + ] + }, + { + "matcher": "Agent", + "hooks": [ + { + "type": "command", + "command": "python .claude/hooks/inject-subagent-context.py", + "timeout": 30 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python .claude/hooks/inject-workflow-state.py", + "timeout": 15 + } + ] + } + ] + }, + "enabledPlugins": {} +} diff --git a/.claude/skills/trellis-before-dev/SKILL.md b/.claude/skills/trellis-before-dev/SKILL.md new file mode 100644 index 0000000..096f8bf --- /dev/null +++ b/.claude/skills/trellis-before-dev/SKILL.md @@ -0,0 +1,40 @@ +--- +name: trellis-before-dev +description: "Discovers and injects project-specific coding guidelines from .trellis/spec/ before implementation begins. Reads spec indexes, pre-development checklists, and shared thinking guides for the target package. Use when starting a new coding task, before writing any code, switching to a different package, or needing to refresh project conventions and standards." +--- + +Read the relevant development guidelines before starting your task. + +Execute these steps: + +1. **Read current task artifacts**: + - `prd.md` for requirements and acceptance criteria + - `design.md` if present for technical design + - `implement.md` if present for execution order and validation plan + +2. **Discover packages and their spec layers**: + ```bash + python ./.trellis/scripts/get_context.py --mode packages + ``` + +3. **Identify which specs apply** to your task based on: + - Which package you're modifying (e.g., `cli/`, `docs-site/`) + - What type of work (backend, frontend, unit-test, docs, etc.) + - Any spec/research paths referenced by the task artifacts + +4. **Read the spec index** for each relevant module: + ```bash + cat .trellis/spec/<package>/<layer>/index.md + ``` + Follow the **"Pre-Development Checklist"** section in the index. + +5. **Read the specific guideline files** listed in the Pre-Development Checklist that are relevant to your task. The index is NOT the goal — it points you to the actual guideline files (e.g., `error-handling.md`, `conventions.md`, `mock-strategies.md`). Read those files to understand the coding standards and patterns. + +6. **Always read shared guides**: + ```bash + cat .trellis/spec/guides/index.md + ``` + +7. Understand the coding standards and patterns you need to follow, then proceed with your development plan. + +This step is **mandatory** before writing any code. diff --git a/.claude/skills/trellis-brainstorm/SKILL.md b/.claude/skills/trellis-brainstorm/SKILL.md new file mode 100644 index 0000000..bd7aeb4 --- /dev/null +++ b/.claude/skills/trellis-brainstorm/SKILL.md @@ -0,0 +1,112 @@ +--- +name: trellis-brainstorm +description: "Guides collaborative requirements discovery before implementation. Creates task directory, seeds PRD, asks high-value questions one at a time, researches technical choices, and converges on MVP scope. Use when requirements are unclear, there are multiple valid approaches, or the user describes a new feature or complex task." +--- + +# Trellis Brainstorm + +## Non-Negotiable Interview Contract + +Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer. + +Ask the questions one at a time. + +## Non-Negotiable Evidence Rule + +If a question can be answered by exploring the codebase, explore the codebase instead. + +This is mandatory. Before asking the user a question, first check whether the answer is already available in code, tests, configs, docs, existing specs, or task history. + +Do not ask the user to confirm facts that the repository can answer. Ask only for product intent, preference, scope, risk tolerance, or decisions that remain ambiguous after inspection. + +--- + +Use this skill during Phase 1 planning to turn the user's request into clear requirements and planning artifacts. + +## Preconditions + +Use this skill only after task-creation consent has been given and the user is ready to enter Trellis planning. + +If no task exists yet, create one: + +```bash +TASK_DIR=$(python ./.trellis/scripts/task.py create "<short task title>" --slug <slug>) +``` + +Use a concise title from the user's request. Use a slug without a date prefix. `task.py create` adds the `MM-DD-` directory prefix automatically. + +`task.py create` creates the default `prd.md`. Update that file with the current understanding before asking follow-up questions. + +## Planning Flow + +1. Capture the user's request and initial known facts in `prd.md`. +2. Inspect available evidence before asking questions: + - code, tests, fixtures, and configs + - README files, docs, existing specs, and domain notes + - related Trellis tasks, research files, and session history when present +3. Separate what you found into: + - confirmed facts + - product intent still needed from the user + - scope or risk decisions still needed from the user + - likely out-of-scope items +4. Ask the single highest-value remaining question. +5. Include your recommended answer with the question. +6. After each user answer, update `prd.md` before continuing. +7. For complex tasks, create or update `design.md` and `implement.md` before implementation starts. + +Do not invent a project-specific product/spec hierarchy. If the repository already has product, domain, or spec docs, use them. If it does not, proceed with the evidence that exists. + +## Question Rules + +Ask only one question per message. + +Each question must include: + +- the decision needed +- why the answer matters +- your recommended answer +- the trade-off if the user chooses differently + +Do not ask process questions such as whether to search, inspect files, or continue brainstorming. Do the evidence work directly. Ask the user only when the remaining issue is a product decision, preference, scope boundary, or risk tolerance choice. + +## Artifact Rules + +`prd.md` records requirements and acceptance: + +- goal and user value +- confirmed facts +- requirements +- acceptance criteria +- out of scope +- open questions that still block planning + +`design.md` records technical design for complex tasks: + +- architecture and boundaries +- data flow and contracts +- compatibility and migration notes +- important trade-offs +- operational or rollback considerations + +`implement.md` records execution planning for complex tasks: + +- ordered implementation checklist +- validation commands +- risky files or rollback points +- follow-up checks before `task.py start` + +Lightweight tasks may have only `prd.md`. Complex tasks must have `prd.md`, `design.md`, and `implement.md` before `task.py start`. + +`implement.md` is not a replacement for `implement.jsonl`. Use JSONL files only for manifest-style spec and research references when the task needs them. + +## Quality Bar + +Before declaring planning ready: + +- `prd.md` contains testable acceptance criteria. +- Repository-answerable questions have already been answered through inspection. +- Remaining open questions are genuinely about user intent or scope. +- Complex tasks have `design.md` and `implement.md`. +- The user has reviewed the final planning artifacts or explicitly approved proceeding. + +Do not start implementation until the user approves or asks for implementation. diff --git a/.claude/skills/trellis-break-loop/SKILL.md b/.claude/skills/trellis-break-loop/SKILL.md new file mode 100644 index 0000000..ef2b50c --- /dev/null +++ b/.claude/skills/trellis-break-loop/SKILL.md @@ -0,0 +1,130 @@ +--- +name: trellis-break-loop +description: "Deep bug analysis to break the fix-forget-repeat cycle. Analyzes root cause category, why fixes failed, prevention mechanisms, and captures knowledge into specs. Use after fixing a bug to prevent the same class of bugs." +--- + +# Break the Loop - Deep Bug Analysis + +When debug is complete, use this for deep analysis to break the "fix bug -> forget -> repeat" cycle. + +--- + +## Analysis Framework + +Analyze the bug you just fixed from these 5 dimensions: + +### 1. Root Cause Category + +Which category does this bug belong to? + +| Category | Characteristics | Example | +|----------|-----------------|---------| +| **A. Missing Spec** | No documentation on how to do it | New feature without checklist | +| **B. Cross-Layer Contract** | Interface between layers unclear | API returns different format than expected | +| **C. Change Propagation Failure** | Changed one place, missed others | Changed function signature, missed call sites | +| **D. Test Coverage Gap** | Unit test passes, integration fails | Works alone, breaks when combined | +| **E. Implicit Assumption** | Code relies on undocumented assumption | Timestamp seconds vs milliseconds | + +### 2. Why Fixes Failed (if applicable) + +If you tried multiple fixes before succeeding, analyze each failure: + +- **Surface Fix**: Fixed symptom, not root cause +- **Incomplete Scope**: Found root cause, didn't cover all cases +- **Tool Limitation**: grep missed it, type check wasn't strict +- **Mental Model**: Kept looking in same layer, didn't think cross-layer + +### 3. Prevention Mechanisms + +What mechanisms would prevent this from happening again? + +| Type | Description | Example | +|------|-------------|---------| +| **Documentation** | Write it down so people know | Update thinking guide | +| **Architecture** | Make the error impossible structurally | Type-safe wrappers | +| **Compile-time** | Strict type checking, no escape hatches | Signature change causes compile error | +| **Runtime** | Monitoring, alerts, scans | Detect orphan entities | +| **Test Coverage** | E2E tests, integration tests | Verify full flow | +| **Code Review** | Checklist, PR template | "Did you check X?" | + +### 4. Systematic Expansion + +What broader problems does this bug reveal? + +- **Similar Issues**: Where else might this problem exist? +- **Design Flaw**: Is there a fundamental architecture issue? +- **Process Flaw**: Is there a development process improvement? +- **Knowledge Gap**: Is the team missing some understanding? + +### 5. Knowledge Capture + +Solidify insights into the system: + +- [ ] Update `.trellis/spec/guides/` thinking guides +- [ ] Update relevant `.trellis/spec/` docs +- [ ] Create issue record (if applicable) +- [ ] Create feature ticket for root fix +- [ ] Update check guidelines if needed + +--- + +## Output Format + +Please output analysis in this format: + +```markdown +## Bug Analysis: [Short Description] + +### 1. Root Cause Category +- **Category**: [A/B/C/D/E] - [Category Name] +- **Specific Cause**: [Detailed description] + +### 2. Why Fixes Failed (if applicable) +1. [First attempt]: [Why it failed] +2. [Second attempt]: [Why it failed] +... + +### 3. Prevention Mechanisms +| Priority | Mechanism | Specific Action | Status | +|----------|-----------|-----------------|--------| +| P0 | ... | ... | TODO/DONE | + +### 4. Systematic Expansion +- **Similar Issues**: [List places with similar problems] +- **Design Improvement**: [Architecture-level suggestions] +- **Process Improvement**: [Development process suggestions] + +### 5. Knowledge Capture +- [ ] [Documents to update / tickets to create] +``` + +--- + +## Core Philosophy + +> **The value of debugging is not in fixing the bug, but in making this class of bugs never happen again.** + +Three levels of insight: +1. **Tactical**: How to fix THIS bug +2. **Strategic**: How to prevent THIS CLASS of bugs +3. **Philosophical**: How to expand thinking patterns + +30 minutes of analysis saves 30 hours of future debugging. + +--- + +## After Analysis: Immediate Actions + +**IMPORTANT**: After completing the analysis above, you MUST immediately: + +1. **Update spec/guides** - Don't just list TODOs, actually update the relevant files: + - If it's a cross-platform issue → update `cross-platform-thinking-guide.md` + - If it's a cross-layer issue → update `cross-layer-thinking-guide.md` + - If it's a code reuse issue → update `code-reuse-thinking-guide.md` + - If it's domain-specific → update `backend/*.md` or `frontend/*.md` + +2. **Sync templates** - After updating `.trellis/spec/`, sync to `src/templates/markdown/spec/` + +3. **Commit the spec updates** - This is the primary output, not just the analysis text + +> **The analysis is worthless if it stays in chat. The value is in the updated specs.** diff --git a/.claude/skills/trellis-check/SKILL.md b/.claude/skills/trellis-check/SKILL.md new file mode 100644 index 0000000..856ee0d --- /dev/null +++ b/.claude/skills/trellis-check/SKILL.md @@ -0,0 +1,98 @@ +--- +name: trellis-check +description: "Comprehensive quality verification: spec compliance, lint, type-check, tests, cross-layer data flow, code reuse, and consistency checks. Use when code is written and needs quality verification, before committing changes, or to catch context drift during long sessions." +--- + +# Code Quality Check + +Comprehensive quality verification for recently written code. Combines spec compliance, cross-layer safety, and pre-commit checks. + +--- + +## Step 1: Identify What Changed + +```bash +git diff --name-only HEAD +git status +``` + +## Step 2: Read Task Artifacts and Applicable Specs + +Read the current task artifacts in order: + +- `prd.md` +- `design.md` if present +- `implement.md` if present + +```bash +python ./.trellis/scripts/get_context.py --mode packages +``` + +For each changed package/layer, read the spec index and follow its **Quality Check** section: + +```bash +cat .trellis/spec/<package>/<layer>/index.md +``` + +Read the specific guideline files referenced — the index is a pointer, not the goal. + +## Step 3: Run Project Checks + +Run the project's lint, type-check, and test commands. Fix any failures before proceeding. + +## Step 4: Review Against Checklist + +### Code Quality + +- [ ] Linter passes? +- [ ] Type checker passes (if applicable)? +- [ ] Tests pass? +- [ ] No debug logging left in? +- [ ] No suppressed warnings or type-safety bypasses? + +### Test Coverage + +- [ ] New function → unit test added? +- [ ] Bug fix → regression test added? +- [ ] Changed behavior → existing tests updated? + +### Spec Sync + +- [ ] Does `.trellis/spec/` need updates? (new patterns, conventions, lessons learned) + +> "If I fixed a bug or discovered something non-obvious, should I document it so future me won't hit the same issue?" → If YES, update the relevant spec doc. + +## Step 5: Cross-Layer Dimensions (if applicable) + +Skip this step if your change is confined to a single layer. + +### A. Data Flow (changes touch 3+ layers) + +- [ ] Read flow traces correctly: Storage → Service → API → UI +- [ ] Write flow traces correctly: UI → API → Service → Storage +- [ ] Types/schemas correctly passed between layers? +- [ ] Errors properly propagated to caller? + +### B. Code Reuse (modifying constants, creating utilities) + +- [ ] Searched for existing similar code before creating new? + ```bash + grep -r "pattern" src/ + ``` +- [ ] If 2+ places define same value → extracted to shared constant? +- [ ] After batch modification, all occurrences updated? + +### C. Import/Dependency (creating new files) + +- [ ] Correct import paths (relative vs absolute)? +- [ ] No circular dependencies? + +### D. Same-Layer Consistency + +- [ ] Other places using the same concept are consistent? + +--- + +## Step 6: Report and Fix + +Report violations found and fix them directly. Re-run project checks after fixes. diff --git a/.claude/skills/trellis-meta/SKILL.md b/.claude/skills/trellis-meta/SKILL.md new file mode 100644 index 0000000..590bfac --- /dev/null +++ b/.claude/skills/trellis-meta/SKILL.md @@ -0,0 +1,73 @@ +--- +name: trellis-meta +description: "Understand and customize the local Trellis architecture inside a user project. Use when modifying .trellis plus platform hooks, settings, agents, skills, commands, prompts, or workflows generated by trellis init." +--- + +# Trellis Meta + +This skill is for local Trellis users who have already run `trellis init` in a project. After reading it, an AI should understand the Trellis architecture, operating model, and customization entry points inside that user project, then modify the generated `.trellis/` and platform directory files according to the user's request. + +The default operating scope is local files in the user project: + +- `.trellis/`: workflow, config, tasks, spec, workspace, scripts, and runtime state. +- Platform directories: `.claude/`, `.codex/`, `.cursor/`, `.opencode/`, `.kiro/`, `.gemini/`, `.qoder/`, `.codebuddy/`, `.github/`, `.factory/`, `.pi/`, `.kilocode/`, `.agent/`, `.windsurf/`, and similar directories. +- Shared skill layer: `.agents/skills/`. + +Do not assume the user has the Trellis source repository. Do not default to modifying the global npm install directory or `node_modules`. + +## How To Use + +1. Read `references/local-architecture/overview.md` first to establish the local Trellis system model. +2. If the request involves a specific AI tool, read `references/platform-files/platform-map.md` and the relevant platform file notes. +3. If the user wants to change behavior, read `references/customize-local/overview.md`, then open the specific customization topic. +4. Before editing, read the actual files in the user project and treat local content as authoritative. + +## References + +### Local Architecture + +- `references/local-architecture/overview.md`: The three-layer local Trellis architecture and customization principles. +- `references/local-architecture/generated-files.md`: Files generated by `trellis init` and their customization boundaries. +- `references/local-architecture/workflow.md`: Phases, routing, and workflow-state blocks in `.trellis/workflow.md`. +- `references/local-architecture/task-system.md`: Task directories, active tasks, JSONL context, and task runtime. +- `references/local-architecture/spec-system.md`: How `.trellis/spec/` is organized and injected. +- `references/local-architecture/workspace-memory.md`: `.trellis/workspace/`, journals, and cross-session memory. +- `references/local-architecture/context-injection.md`: Hooks, sub-agent preludes, and context injection paths. + +### Platform Files + +- `references/platform-files/overview.md`: How shared `.trellis/` files relate to platform directories. +- `references/platform-files/platform-map.md`: Platform directories and paths for skills, agents, hooks, and extensions. +- `references/platform-files/hooks-and-settings.md`: How settings/config files, hooks, plugins, and extensions connect to Trellis. +- `references/platform-files/agents.md`: Local file responsibilities for `trellis-research`, `trellis-implement`, and `trellis-check`. +- `references/platform-files/skills-and-commands.md`: Differences between skills, commands, prompts, and workflows, plus how to change them. + +### Local Customization + +- `references/customize-local/overview.md`: Choose the right local customization entry point for the user's request. +- `references/customize-local/change-workflow.md`: Change phases, routing, next actions, and workflow-state. +- `references/customize-local/change-task-lifecycle.md`: Change task creation, status, archive behavior, and hooks. +- `references/customize-local/change-context-loading.md`: Change how tasks, specs, journals, and hook context are loaded. +- `references/customize-local/change-hooks.md`: Change platform hooks, settings, and shell session bridges. +- `references/customize-local/change-agents.md`: Change research, implement, and check agent behavior. +- `references/customize-local/change-skills-or-commands.md`: Add or modify local skills, commands, prompts, and workflows. +- `references/customize-local/change-spec-structure.md`: Adjust the project spec structure under `.trellis/spec/`. +- `references/customize-local/add-project-local-conventions.md`: Put team rules into project-local specs or local skills. + +## Current Rules + +- `.trellis/workflow.md` is the local workflow source of truth. +- `.trellis/config.yaml` is the project-level Trellis configuration and task hook configuration entry point. +- `.trellis/spec/` stores the user's project-specific coding conventions and design constraints. +- `.trellis/tasks/` stores task PRDs, technical notes, research files, and JSONL context. +- `.trellis/workspace/` stores developer journals and cross-session memory. +- Platform settings/config files decide which hooks, agents, skills, commands, prompts, and workflows actually run. +- `.trellis/.template-hashes.json` and `.trellis/.runtime/` are management/runtime state files. Confirm necessity before editing them. + +## Do Not + +- Do not treat Trellis upstream source code as the default target for local customization. +- Do not modify the global npm install directory or `node_modules/@mindfoldhq/trellis` to implement project needs. +- Do not overwrite user-modified local files with default templates. +- Do not put team-private project rules into the public `trellis-meta`; put project rules in `.trellis/spec/` or a project-local skill. +- Do not describe removed historical mechanisms as current Trellis behavior. diff --git a/.claude/skills/trellis-meta/references/customize-local/add-project-local-conventions.md b/.claude/skills/trellis-meta/references/customize-local/add-project-local-conventions.md new file mode 100644 index 0000000..d32ca2d --- /dev/null +++ b/.claude/skills/trellis-meta/references/customize-local/add-project-local-conventions.md @@ -0,0 +1,83 @@ +# Add Project-Local Conventions + +Often the user does not need to change Trellis mechanics; they need local AI to understand their team's conventions. In that case, prefer `.trellis/spec/` or a project-local skill instead of editing `trellis-meta`. + +## Where To Put Things + +| Content type | Location | +| --- | --- | +| Rules code must follow | `.trellis/spec/<layer>/` | +| Cross-layer thinking methods | `.trellis/spec/guides/` | +| AI capability for a project-specific flow | Platform-local skill | +| One-off task material | `.trellis/tasks/<task>/` | +| Session summary | `.trellis/workspace/<developer>/journal-N.md` | + +## Create A Project-Local Skill + +If the user wants AI to know "how this project customizes Trellis," create a local skill: + +```text +.claude/skills/trellis-local/ +└── SKILL.md +``` + +Example: + +```md +--- +name: trellis-local +description: "Project-local Trellis customizations for this repository. Use when changing this project's Trellis workflow, hooks, local agents, or team-specific conventions." +--- + +# Trellis Local + +## Local Scope + +This skill documents this repository's Trellis customizations only. + +## Custom Workflow Rules + +- ... + +## Local Hook Changes + +- ... + +## Local Agent Changes + +- ... +``` + +For multi-platform projects, place equivalent versions in other platform skill directories, or use `.agents/skills/` for platforms that support the shared layer. + +## Write To `.trellis/spec/` + +If the content is a coding convention, write it to spec. Examples: + +```text +.trellis/spec/backend/error-handling.md +.trellis/spec/frontend/components.md +.trellis/spec/guides/cross-platform-thinking-guide.md +``` + +After writing it, update the corresponding `index.md` so AI can find the new rule from the entry point. + +## Make The Current Task Use New Conventions + +After writing a spec, add it to the current task context: + +```bash +python ./.trellis/scripts/task.py add-context <task> implement ".trellis/spec/backend/error-handling.md" "Error handling conventions" +python ./.trellis/scripts/task.py add-context <task> check ".trellis/spec/backend/error-handling.md" "Review error handling" +``` + +## Do Not Store Project-Private Rules In `trellis-meta` + +`trellis-meta` is a public skill for understanding Trellis architecture and local customization entry points. Put project-private content in: + +- `.trellis/spec/` +- a project-local skill +- the current task +- workspace journal + +This prevents future updates to Trellis's built-in `trellis-meta` from overwriting the team's own conventions. diff --git a/.claude/skills/trellis-meta/references/customize-local/change-agents.md b/.claude/skills/trellis-meta/references/customize-local/change-agents.md new file mode 100644 index 0000000..9b63531 --- /dev/null +++ b/.claude/skills/trellis-meta/references/customize-local/change-agents.md @@ -0,0 +1,54 @@ +# Change Local Agents + +When the user wants to change `trellis-research`, `trellis-implement`, or `trellis-check` behavior, edit platform agent files in the user project. + +## Read These Files First + +1. Target platform agent directory +2. `.trellis/workflow.md` Phase 2 / research routing +3. Current task `prd.md` +4. Current task `implement.jsonl` / `check.jsonl` +5. Relevant hook or agent prelude + +## Common Paths + +| Platform | Path | +| --- | --- | +| Claude Code | `.claude/agents/trellis-*.md` | +| Cursor | `.cursor/agents/trellis-*.md` | +| OpenCode | `.opencode/agents/trellis-*.md` | +| Codex | `.codex/agents/trellis-*.toml` | +| Kiro | `.kiro/agents/trellis-*.json` | +| Gemini CLI | `.gemini/agents/trellis-*.md` | +| Qoder | `.qoder/agents/trellis-*.md` | +| CodeBuddy | `.codebuddy/agents/trellis-*.md` | +| Factory Droid | `.factory/droids/trellis-*.md` | +| Pi Agent | `.pi/agents/trellis-*.md` | + +Use the actual paths in the user project as authoritative. + +## Common Needs + +| Need | Which agent to edit | +| --- | --- | +| Research must write files, not only reply in chat | `trellis-research` | +| Certain local specs must be read before implementation | `trellis-implement` + `implement.jsonl` configuration rules | +| Specific commands must run during checking | `trellis-check` | +| Agent must not modify certain directories | The corresponding agent's write boundary instructions | +| Agent output format must be fixed | The corresponding agent's final/reporting instructions | + +## Modification Principles + +1. **Preserve role boundaries**: research investigates and persists; implement writes implementation; check reviews and fixes. +2. **Do not hard-code project specs into agents**: long-term specs belong in `.trellis/spec/`; agents are responsible for reading them. +3. **Make read order explicit**: active task -> PRD -> info -> JSONL -> spec/research. +4. **Make write boundaries explicit**: which directories may be written and which may not. +5. **Synchronize across platforms**: when the user configured multiple platforms, decide whether to change only the current platform or all platform agents. + +## Agent Pull Platforms + +If an agent file contains a prelude for "read task/context after startup," do not remove those steps when editing. Otherwise the agent will work only from chat context and bypass Trellis's core mechanism. + +## Hook Push Platforms + +If context is injected by a hook, the agent file should still retain responsibility boundaries. Do not remove PRD/spec requirements from the agent just because a hook injects context. diff --git a/.claude/skills/trellis-meta/references/customize-local/change-context-loading.md b/.claude/skills/trellis-meta/references/customize-local/change-context-loading.md new file mode 100644 index 0000000..83bcd63 --- /dev/null +++ b/.claude/skills/trellis-meta/references/customize-local/change-context-loading.md @@ -0,0 +1,84 @@ +# Change Local Context Loading + +Context loading determines when AI reads workflow, task, spec, research, workspace, and git status. Read this page when the user says "AI does not know the current task," "the agent did not read specs," or "there is too much/too little context." + +## Read These Files First + +1. `.trellis/workflow.md` +2. `.trellis/scripts/get_context.py` +3. `.trellis/scripts/common/session_context.py` +4. `.trellis/scripts/common/task_context.py` +5. `.trellis/scripts/common/active_task.py` +6. Current platform hooks or agent files +7. The current task's `implement.jsonl` / `check.jsonl` + +## Context Sources + +| Source | Purpose | +| --- | --- | +| `.trellis/workflow.md` | Workflow and next-action hints. | +| `.trellis/tasks/<task>/prd.md` | Current task requirements. | +| `.trellis/tasks/<task>/design.md` | Complex task technical design. | +| `.trellis/tasks/<task>/implement.md` | Complex task execution plan. | +| `.trellis/tasks/<task>/implement.jsonl` | Spec/research to read before implementation. | +| `.trellis/tasks/<task>/check.jsonl` | Spec/research to read during checking. | +| `.trellis/spec/` | Project specs. | +| `.trellis/workspace/` | Session records. | +| git status | Current working tree changes. | + +## Common Needs And Edit Points + +| Need | Edit point | +| --- | --- | +| Inject more/less information in new sessions | `session_context.py` or the platform `session-start` hook. | +| Change hints on each user input | `[workflow-state:STATUS]` block in `.trellis/workflow.md`. The `inject-workflow-state` hook is parser-only and reads the block verbatim. | +| Agent did not read specs | Task JSONL, agent prelude, `inject-subagent-context` hook. | +| Active task is lost | `active_task.py` and platform session identity propagation. | +| Change JSONL validation rules | `task_context.py`. | + +## JSONL Rules + +`implement.jsonl` / `check.jsonl` are the key context loading interface: + +```jsonl +{"file": ".trellis/spec/backend/index.md", "reason": "Backend conventions"} +{"file": ".trellis/tasks/04-28-x/research/api.md", "reason": "API research"} +``` + +Include only spec/research files. Do not put code files that will be modified into these manifests; agents read code files themselves during implementation. + +## Change Session Context + +If the user wants every new session to see more project state, edit: + +- `.trellis/scripts/common/session_context.py` +- the corresponding platform `session-start` hook + +Context cannot grow without bound. Prefer injecting indexes and paths so the AI can read detailed files on demand. + +## Change Sub-Agent Context + +First determine which mode the platform uses: + +- hook push: edit the `inject-subagent-context` hook. +- agent pull: edit the read steps in the corresponding `trellis-implement` / `trellis-check` agent file. + +In both modes, make sure the agent ultimately reads: + +1. active task +2. the corresponding JSONL +3. spec/research referenced by the JSONL +4. `prd.md` +5. `design.md` if present +6. `implement.md` if present + +## Troubleshooting Order + +```bash +python ./.trellis/scripts/task.py current --source +python ./.trellis/scripts/task.py list-context <task> +python ./.trellis/scripts/task.py validate <task> +python ./.trellis/scripts/get_context.py --mode packages +``` + +Confirm the task and JSONL are correct before editing hooks/agents. diff --git a/.claude/skills/trellis-meta/references/customize-local/change-hooks.md b/.claude/skills/trellis-meta/references/customize-local/change-hooks.md new file mode 100644 index 0000000..093a171 --- /dev/null +++ b/.claude/skills/trellis-meta/references/customize-local/change-hooks.md @@ -0,0 +1,57 @@ +# Change Local Hooks + +Hooks are the automation layer that connects a platform to Trellis. When the user wants to change "when context is injected," "how shell commands inherit a session," or "which files are read before an agent starts," hooks are usually the edit point. + +## Read These Files First + +1. Target platform settings/config, such as `.claude/settings.json`, `.codex/hooks.json`, `.cursor/hooks.json` +2. Target platform hooks directory +3. `.trellis/scripts/common/active_task.py` +4. `.trellis/scripts/common/session_context.py` +5. `.trellis/workflow.md` + +## Common Hook Types + +| Hook | Purpose | +| --- | --- | +| session-start | Injects a Trellis overview when a session starts, clears, or compacts. | +| workflow-state | Injects a state hint on each user input. | +| sub-agent context | Injects PRD/spec/research before an agent starts. | +| shell session bridge | Lets `task.py` commands in shell see the same session identity. | + +## Modification Steps + +1. Find the hook registration in settings/config. +2. Confirm the registered script path exists. +3. Read the hook script and identify inputs, outputs, and called `.trellis/scripts/`. +4. Modify hook behavior. +5. If the hook depends on workflow content, synchronize `.trellis/workflow.md`. + +## Example: Change New-Session Injection Content + +First find the session-start hook: + +```text +.claude/settings.json +.claude/hooks/session-start.py +``` + +If the hook ultimately calls `.trellis/scripts/get_context.py` or `session_context.py`, editing the local script is usually more robust than hard-coding content in the hook. + +## Example: Agent Did Not Read JSONL + +First confirm: + +```bash +python ./.trellis/scripts/task.py current --source +python ./.trellis/scripts/task.py validate <task> +``` + +If the task and JSONL are correct, determine whether the platform uses hook push or agent pull. For hook push, edit `inject-subagent-context`; for agent pull, edit the agent file. + +## Notes + +- Settings handle registration, hook scripts handle behavior; inspect both together. +- Different platforms support different hook events. Do not directly copy another platform's settings. +- Hooks should read project-local `.trellis/`; they should not depend on Trellis upstream source paths. +- Hook failures should produce visible errors so AI does not silently lose context. diff --git a/.claude/skills/trellis-meta/references/customize-local/change-skills-or-commands.md b/.claude/skills/trellis-meta/references/customize-local/change-skills-or-commands.md new file mode 100644 index 0000000..84590a1 --- /dev/null +++ b/.claude/skills/trellis-meta/references/customize-local/change-skills-or-commands.md @@ -0,0 +1,78 @@ +# Change Local Skills, Commands, Prompts, And Workflows + +When the user wants to change AI entry points, auto-trigger rules, or explicit command behavior, edit skills, commands, prompts, or workflows in local platform directories. + +## Read These Files First + +1. `.trellis/workflow.md` +2. Target platform skill/command/prompt/workflow directory +3. Related agent or hook files +4. Whether project rules already exist in `.trellis/spec/` + +## Which Entry Type To Choose + +| Goal | Recommendation | +| --- | --- | +| AI should automatically know a capability | Add or modify a skill. | +| User wants to trigger manually with a command | Add or modify a command/prompt/workflow. | +| Team project conventions | Prefer `.trellis/spec/` or a project-local skill. | +| Change Trellis flow semantics | Synchronize `.trellis/workflow.md`. | + +## Modify A Skill + +A skill is usually: + +```text +<skill-name>/ +├── SKILL.md +└── references/ +``` + +`SKILL.md` should be short and responsible for triggering/routing. Put long content in `references/` so AI can read it on demand. + +The frontmatter description should specify when to use the skill. Example: + +```yaml +description: "Use when customizing this project's deployment workflow and release checklist." +``` + +Do not write vague descriptions such as "helpful project skill"; they can trigger incorrectly. + +## Modify A Command/Prompt/Workflow + +Explicit entry points should state: + +- How the user triggers it. +- Which `.trellis/` files to read. +- Which scripts to run. +- How to report after completion. + +If a command only repeats workflow rules, prefer making it reference/read `.trellis/workflow.md` instead of maintaining a second copy of the flow. + +## Common Paths + +| Platform | Entry directories | +| --- | --- | +| Claude Code | `.claude/skills/`, `.claude/commands/` | +| Cursor | `.cursor/skills/`, `.cursor/commands/` | +| OpenCode | `.opencode/skills/`, `.opencode/commands/` | +| Codex | `.agents/skills/`, `.codex/skills/` | +| GitHub Copilot | `.github/skills/`, `.github/prompts/` | +| Kilo / Antigravity / Windsurf | workflows + skills | + +## Add A Project-Local Skill + +If the user wants to document team-private customizations, create a project-local skill, for example: + +```text +.claude/skills/project-trellis-local/ +└── SKILL.md +``` + +For multi-platform projects, add equivalent versions in each platform skill directory, or use `.agents/skills/` on platforms that support the shared layer. + +## Notes + +- Do not mix every platform's syntax into one file. +- Do not change only one platform entry point while claiming all platforms are supported. +- Do not hide long-term engineering conventions inside a command; write them to `.trellis/spec/`. diff --git a/.claude/skills/trellis-meta/references/customize-local/change-spec-structure.md b/.claude/skills/trellis-meta/references/customize-local/change-spec-structure.md new file mode 100644 index 0000000..14e0cd8 --- /dev/null +++ b/.claude/skills/trellis-meta/references/customize-local/change-spec-structure.md @@ -0,0 +1,83 @@ +# Change Local Spec Structure + +When the user wants to change the engineering conventions AI follows, add new spec layers, or adjust monorepo package mapping, edit `.trellis/spec/` and `.trellis/config.yaml`. + +## Read These Files First + +1. `.trellis/config.yaml` +2. `.trellis/spec/` +3. `.trellis/workflow.md` planning artifact guidance and Phase 3.3 +4. Current task `implement.jsonl` / `check.jsonl` + +## Common Needs + +| Need | Edit location | +| --- | --- | +| Add backend/frontend/docs/test spec layer | `.trellis/spec/<layer>/` or `.trellis/spec/<package>/<layer>/` | +| Add shared thinking guides | `.trellis/spec/guides/` | +| Adjust monorepo packages | `packages` in `.trellis/config.yaml` | +| Change default package | `default_package` in `.trellis/config.yaml` | +| Control spec scanning scope | `spec_scope` in `.trellis/config.yaml` | +| Make a task read a new spec | Task `implement.jsonl` / `check.jsonl` | + +## Add A Spec Layer + +Single-repository example: + +```text +.trellis/spec/security/ +├── index.md +└── auth.md +``` + +Monorepo example: + +```text +.trellis/spec/webapp/security/ +├── index.md +└── auth.md +``` + +`index.md` should include: + +- What code this layer applies to. +- Pre-Development Checklist. +- Quality Check. +- Links to specific guideline files. + +## Update Context + +Adding a spec does not mean every task automatically reads it. The current task must reference it in JSONL: + +```bash +python ./.trellis/scripts/task.py add-context <task> implement ".trellis/spec/webapp/security/index.md" "Security conventions" +python ./.trellis/scripts/task.py add-context <task> check ".trellis/spec/webapp/security/index.md" "Security review rules" +``` + +## Change Monorepo Packages + +Example `.trellis/config.yaml`: + +```yaml +packages: + webapp: + path: apps/web + api: + path: apps/api +default_package: webapp +``` + +After editing, run: + +```bash +python ./.trellis/scripts/get_context.py --mode packages +``` + +Use this output to confirm AI can see the correct packages and spec layers. + +## Notes + +- Specs are user project conventions and can be changed according to project needs. +- Do not put temporary task information into specs; put temporary information in the task. +- Do not put long-term conventions only in agents or commands; preserve them in specs. +- After changing spec structure, check whether existing task JSONL files still point to files that exist. diff --git a/.claude/skills/trellis-meta/references/customize-local/change-task-lifecycle.md b/.claude/skills/trellis-meta/references/customize-local/change-task-lifecycle.md new file mode 100644 index 0000000..208e0da --- /dev/null +++ b/.claude/skills/trellis-meta/references/customize-local/change-task-lifecycle.md @@ -0,0 +1,90 @@ +# Change Local Task Lifecycle + +Task lifecycle includes creation, start, context configuration, finish, archive, parent/child tasks, and lifecycle hooks. The default customization targets are `.trellis/tasks/`, `.trellis/config.yaml`, and `.trellis/scripts/`. + +## Read These Files First + +1. `.trellis/workflow.md` +2. `.trellis/config.yaml` +3. `.trellis/scripts/task.py` +4. `.trellis/scripts/common/task_store.py` +5. `.trellis/scripts/common/task_utils.py` +6. The current task's `.trellis/tasks/<task>/task.json` + +## Common Needs And Edit Points + +| Need | Edit point | +| --- | --- | +| Automatically sync an external system after task creation | `hooks.after_create` in `.trellis/config.yaml`. | +| Automatically update status after task start | `hooks.after_start` in `.trellis/config.yaml`. | +| Run a script after task finish | `hooks.after_finish` in `.trellis/config.yaml`. | +| Clean external resources after archive | `hooks.after_archive` in `.trellis/config.yaml`. | +| Change default task fields | `.trellis/scripts/common/task_store.py`. | +| Change task parsing/search | `.trellis/scripts/common/task_utils.py`. | +| Change active task behavior | `.trellis/scripts/common/active_task.py`. | + +## lifecycle hooks + +`.trellis/config.yaml` supports: + +```yaml +hooks: + after_create: + - "python .trellis/scripts/hooks/my_sync.py create" + after_start: + - "python .trellis/scripts/hooks/my_sync.py start" + after_finish: + - "python .trellis/scripts/hooks/my_sync.py finish" + after_archive: + - "python .trellis/scripts/hooks/my_sync.py archive" +``` + +Hook commands receive the `TASK_JSON_PATH` environment variable, pointing to the current task's `task.json`. Hook failures should usually warn, but not block the main task operation. + +## Change Task Fields + +If the user wants to add project-local fields, prefer putting them under `meta` in `task.json` to avoid breaking existing scripts' assumptions about standard fields. + +Example: + +```json +"meta": { + "linearIssue": "ENG-123", + "risk": "high" +} +``` + +If standard fields really need to change, inspect every local script that reads `task.json`. + +## Change Active Task + +Active task is session-level state stored in `.trellis/.runtime/sessions/`. Do not fall back to a global `.current-task` model. If the user wants to change active task behavior, edit: + +- `.trellis/scripts/common/active_task.py` +- platform hooks or shell session bridges +- active task descriptions in `.trellis/workflow.md` + +### `task.py create` Sets the Active Pointer + +`cmd_create` in `.trellis/scripts/common/task_store.py` calls `set_active_task` best-effort right after writing the new task directory. The behavior: + +- When the calling shell carries session identity (`TRELLIS_CONTEXT_ID` env var, or any platform-specific session env that `resolve_context_key` recognizes — see `active_task.py:_ENV_SESSION_KEYS`), the per-session pointer at `.trellis/.runtime/sessions/<context_key>.json` is rewritten to point at the new task. The task's `status=planning` and `[workflow-state:planning]` fires on the very next `UserPromptSubmit`. +- When session identity is unavailable (raw CLI invocation outside an AI session, or a platform that doesn't propagate identity to shell), the task directory is still created and `status=planning` is still written, but the active pointer is left untouched. The user can attach the task later with `task.py start <dir>` once they're back in an AI session. + +This makes `[workflow-state:planning]` the live breadcrumb during the brainstorm and JSONL curation work that follows `task.py create`. The pre-R7 behavior left the breadcrumb stuck on `no_task` until `task.py start`, so the planning block was effectively dead text. + +If you fork `task.py` to add a new creation path (e.g. an external import that bypasses `cmd_create`), audit whether your path also calls `set_active_task`. Without that call, your created tasks will not surface as active. The full status writer table is in `.trellis/spec/cli/backend/workflow-state-contract.md`. + +## Modification Steps + +1. Confirm the current task with `python ./.trellis/scripts/task.py current --source`. +2. Read the current task's `task.json` and confirm status and fields. +3. For configuration needs, edit `.trellis/config.yaml` first. +4. For script behavior needs, then edit `.trellis/scripts/`. +5. If the AI flow changed, synchronize `.trellis/workflow.md`. + +## Do Not + +- Do not directly edit `.trellis/.runtime/sessions/` to "fix" business state. +- Do not hard-code project-private fields into scripts; prefer `meta`. +- Do not default to asking the user to fork Trellis CLI. diff --git a/.claude/skills/trellis-meta/references/customize-local/change-workflow.md b/.claude/skills/trellis-meta/references/customize-local/change-workflow.md new file mode 100644 index 0000000..aa2e663 --- /dev/null +++ b/.claude/skills/trellis-meta/references/customize-local/change-workflow.md @@ -0,0 +1,65 @@ +# Change Local Workflow + +When the user wants to change Trellis phases, next-action hints, whether to create tasks, whether to use sub-agents, or when to check/wrap up, edit `.trellis/workflow.md` first. + +## Read These Files First + +1. `.trellis/workflow.md` +2. Entry files for the current platform, such as skills/commands/prompts/workflows +3. The current task's `task.json` and `prd.md` + +## Common Needs And Edit Points + +| Need | Edit point | +| --- | --- | +| Change phase names or phase order | `Phase Index` and the corresponding Phase sections. | +| Change whether to create a task when there is no task | `[workflow-state:no_task]` state block. | +| Change the next step during planning | Phase 1 and `[workflow-state:planning]`. | +| Change whether an agent is required during in_progress | Phase 2 and `[workflow-state:in_progress]`. | +| Change wrap-up after completion | Phase 3 and `[workflow-state:completed]`. | +| Change which skill a user intent triggers | `Skill Routing` table. | + +## Modification Steps + +1. Find the relevant section in `.trellis/workflow.md`. +2. When changing rules, keep explicit trigger conditions and next actions. +3. If adding or renaming a skill/agent, synchronize the corresponding files in platform directories. +4. Workflow-state changes only need an edit to the `[workflow-state:STATUS]` block in `.trellis/workflow.md`. The hook is parser-only — it reads whatever you put in the block. Keep the opening and closing tags' STATUS strings identical (`[workflow-state:foo]…[/workflow-state:foo]`); mismatched STATUS pairs are silently dropped. +5. Make the AI reread `.trellis/workflow.md`; do not keep using rules from the old conversation. + +## Example: Relax Task Creation Requirements + +To change when task creation can be skipped, usually edit `[workflow-state:no_task]`: + +```md +[workflow-state:no_task] +Task is not required when the answer is a one-reply explanation, no files are changed, and no research is needed. +[/workflow-state:no_task] +``` + +If the formal Phase 1 flow also needs to change, synchronize the Phase 1 section. + +## Example: One Platform Does Not Use Sub-Agents + +If the user wants only one platform to avoid sub-agents, first confirm whether that platform has a separate group in the workflow. Then change Phase 2 routing for that platform group instead of deleting all `trellis-implement` / `trellis-check` instructions across platforms. + +## `/trellis:continue` Route Table + +`/trellis:continue` resumes a task by deciding which phase step to load next. The decision combines `task.json.status` with the presence of artifacts inside the task directory. The mapping is fixed in the command itself; forks that add custom statuses must extend both the workflow.md tag block and this table. + +| `status` | Artifact state | Resume at | +| --- | --- | --- | +| `planning` | `prd.md` missing | Phase 1.1 (load `trellis-brainstorm`) | +| `planning` | lightweight task with `prd.md` complete | ask for start review, then run `task.py start` | +| `planning` | complex task missing `design.md` or `implement.md` | complete missing planning artifacts | +| `planning` | complex task has `prd.md`, `design.md`, and `implement.md` | ask for start review, then run `task.py start` | +| `in_progress` | no implementation in conversation history | Phase 2.1 (`trellis-implement`) | +| `in_progress` | implementation done, no `trellis-check` run | Phase 2.2 (`trellis-check`) | +| `in_progress` | check passed | Phase 3.1 (verify quality + spec update) | +| `completed` | task is still in active tree | Phase 3.5 (run `/trellis:finish-work` to archive) | + +When you add a custom status (e.g. `in-review`), add a `[workflow-state:in-review]` block in `.trellis/workflow.md` for the per-turn breadcrumb AND extend this route table — usually by editing the `/trellis:continue` command file (`.{platform}/commands/trellis/continue.md` or equivalent) to add a row that decides where to resume from. Without the route entry, `/trellis:continue` will fall through to a default branch and the user will not land on the step you intended. + +## Notes + +`.trellis/workflow.md` is the local project workflow, not an immutable template. The user can adapt it to team habits. After editing it, platform entry files may still contain old descriptions, so inspect them too. diff --git a/.claude/skills/trellis-meta/references/customize-local/overview.md b/.claude/skills/trellis-meta/references/customize-local/overview.md new file mode 100644 index 0000000..ac16a4c --- /dev/null +++ b/.claude/skills/trellis-meta/references/customize-local/overview.md @@ -0,0 +1,55 @@ +# Local Customization Overview + +This directory is for local AI working in a user project where Trellis was installed through npm and `trellis init` has already been run. The AI should modify generated `.trellis/` and platform directories inside the project, not Trellis CLI upstream source code. + +## First Determine What The User Actually Wants To Change + +| User wording | Read first | +| --- | --- | +| "Change the Trellis flow / phases / next prompt" | `change-workflow.md` | +| "Change task creation, status, archive, or hooks" | `change-task-lifecycle.md` | +| "AI did not read context / change injected content" | `change-context-loading.md` | +| "A platform hook is not behaving as expected" | `change-hooks.md` | +| "Change implement/check/research agent behavior" | `change-agents.md` | +| "Add a skill/command/workflow/prompt" | `change-skills-or-commands.md` | +| "Adjust the project spec structure" | `change-spec-structure.md` | +| "Add team conventions and local notes" | `add-project-local-conventions.md` | + +## General Operation Order + +1. **Confirm platform and directories**: inspect which directories exist, such as `.claude/`, `.codex/`, `.cursor/`. +2. **Confirm the current active task**: run `python ./.trellis/scripts/task.py current --source`. +3. **Read the local source of truth**: prefer `.trellis/workflow.md`, `.trellis/config.yaml`, and relevant platform files. +4. **Modify narrowly**: edit only files related to the user's request. +5. **Synchronize semantics**: if a shared flow changes, check whether platform entry points also need changes; if a platform entry changes, check whether `.trellis/workflow.md` still agrees. + +## Local File Priority + +| Layer | Files | +| --- | --- | +| Workflow | `.trellis/workflow.md` | +| Project configuration | `.trellis/config.yaml` | +| Task material | `.trellis/tasks/<task>/` | +| Project specs | `.trellis/spec/` | +| Runtime scripts | `.trellis/scripts/` | +| Platform integration | `.claude/`, `.codex/`, `.cursor/`, `.opencode/`, and similar directories | +| Shared skill | `.agents/skills/` | + +## Things Not To Do By Default + +- Do not edit the global npm install directory. +- Do not edit `node_modules/@mindfoldhq/trellis`. +- Do not assume the user has the Trellis GitHub repository. +- Do not overwrite local files already modified by the user with default templates. +- Do not put team project rules into public `trellis-meta`; project rules belong in `.trellis/spec/` or a local skill. + +## When To Inspect Upstream Source + +Switch to an upstream source-code perspective only when the user explicitly expresses one of these goals: + +- "I want to open a PR to Trellis" +- "I want to change npm package publish contents" +- "I want to fork Trellis" +- "I want to modify the generation logic for `trellis init/update`" + +Otherwise, default to modifying local Trellis files inside the user project. diff --git a/.claude/skills/trellis-meta/references/local-architecture/context-injection.md b/.claude/skills/trellis-meta/references/local-architecture/context-injection.md new file mode 100644 index 0000000..4a7517b --- /dev/null +++ b/.claude/skills/trellis-meta/references/local-architecture/context-injection.md @@ -0,0 +1,68 @@ +# Local Context Injection System + +Trellis context injection aims to make AI read the right files at the right time instead of relying on model memory. In a user project, injection is implemented by `.trellis/` scripts together with platform hooks, agents, and skills. + +## Injected Context Types + +| Type | Source | Purpose | +| --- | --- | --- | +| session context | `.trellis/scripts/get_context.py` | Current developer, git status, active task, active tasks, journal, packages. | +| workflow context | `.trellis/workflow.md` | Current Trellis flow and next action. | +| spec context | `.trellis/spec/` + task JSONL | Specs that must be followed during implementation/checking. | +| task context | `.trellis/tasks/<task>/prd.md`, `design.md`, `implement.md`, `research/` | Current task requirements, design, execution plan, and research. | +| platform context | Platform hooks/settings/agents | Lets different AI tools read the files above through their own mechanisms. | + +## session-start + +Platforms with session-start support inject a Trellis overview when a session starts, clears, compacts, or receives a similar event. Injected content usually includes: + +- workflow summary. +- current task status. +- active tasks. +- spec index paths. +- developer identity and git status. + +If the user feels the AI does not know the current task in a new session, first check whether the platform's session-start hook or equivalent mechanism is installed and running. + +## workflow-state + +workflow-state is a lightweight hint injected around each user turn. Based on current task status, it selects a block from `.trellis/workflow.md`, such as `no_task`, `planning`, `in_progress`, or `completed`. + +If the user wants to change "what the AI should do next in a given state," edit the corresponding state block in `.trellis/workflow.md` first. + +## sub-agent context + +Implement and check agents need task context. Trellis has two loading modes: + +1. **hook push**: a platform hook injects jsonl-referenced files plus `prd.md`, `design.md` if present, and `implement.md` if present before the agent starts. +2. **agent pull**: the agent definition instructs the agent to read the active task, jsonl context, and task artifacts after startup. + +In both modes, JSONL files in the task directory are the manifest for spec/research context. Task artifacts are read separately in this order: `prd.md` -> `design.md if present` -> `implement.md if present`. + +## JSONL Reading Rules + +`implement.jsonl` and `check.jsonl` contain one JSON object per line: + +```jsonl +{"file": ".trellis/spec/backend/index.md", "reason": "Backend rules"} +``` + +Readers should skip seed rows without a `file` field. When configuring JSONL, the AI should include only spec/research files, not pre-register code files that will be modified. + +## Active Task And Context Key + +Active task state lives in `.trellis/.runtime/sessions/` and is isolated per session. Hooks try to resolve the context key from platform events, environment variables, transcript paths, or `TRELLIS_CONTEXT_ID`. + +If shell commands cannot see the same context key, `task.py current --source` may report no active task. In that case, check whether the platform passes session identity into the shell instead of hand-writing a global current-task file. + +## Local Customization Points + +| Need | Edit location | +| --- | --- | +| Change session-start injected content | The platform's `session-start` hook or plugin file. | +| Change per-turn workflow-state rules | `[workflow-state:STATUS]` block in `.trellis/workflow.md`. The platform workflow-state hook parses these blocks verbatim and embeds no fallback text. | +| Change how sub-agents read context | Platform agent definitions, the `inject-subagent-context` hook, or agent preludes. | +| Change JSONL validation/display | `.trellis/scripts/common/task_context.py`. | +| Change active task resolution | `.trellis/scripts/common/active_task.py`. | + +When modifying context injection, verify two things: new sessions can see the correct task, and sub-agents can see the correct task artifacts/spec/research. diff --git a/.claude/skills/trellis-meta/references/local-architecture/generated-files.md b/.claude/skills/trellis-meta/references/local-architecture/generated-files.md new file mode 100644 index 0000000..66f832d --- /dev/null +++ b/.claude/skills/trellis-meta/references/local-architecture/generated-files.md @@ -0,0 +1,80 @@ +# Local Files Generated After Init + +`trellis init` writes the Trellis runtime into the user project. Later, `trellis update` tries to update Trellis-managed template files, but it uses `.trellis/.template-hashes.json` to determine which files have already been modified by the user. + +This page only describes files that are visible and editable inside the user project. + +## `.trellis/` + +```text +.trellis/ +├── workflow.md +├── config.yaml +├── .developer +├── .version +├── .template-hashes.json +├── .runtime/ +├── scripts/ +├── spec/ +├── tasks/ +└── workspace/ +``` + +| Path | Usually editable? | Notes | +| --- | --- | --- | +| `.trellis/workflow.md` | Yes | Local workflow documentation and AI routing rules. | +| `.trellis/config.yaml` | Yes | Project configuration, hooks, packages, journal line limits, and related settings. | +| `.trellis/spec/` | Yes | Project specs, intended to be updated regularly by users and AI. | +| `.trellis/tasks/` | Yes | Task material and research artifacts, maintained by the task workflow. | +| `.trellis/workspace/` | Yes | Session records, usually written by `add_session.py`. | +| `.trellis/scripts/` | Carefully | Local runtime. It can be customized, but only after understanding the call chain. | +| `.trellis/.runtime/` | No | Runtime state, usually written automatically by hooks/scripts. | +| `.trellis/.developer` | Carefully | Current developer identity. | +| `.trellis/.version` | No | Trellis version record used by update/migration logic. | +| `.trellis/.template-hashes.json` | No | Template hash record. Do not hand-write business rules here. | + +## Platform Directories + +Different platforms generate different directories. Common categories: + +| Category | Example paths | Purpose | +| --- | --- | --- | +| hooks | `.claude/hooks/`, `.codex/hooks/`, `.cursor/hooks/` | Inject session context, workflow-state, and sub-agent context. | +| settings | `.claude/settings.json`, `.codex/hooks.json`, `.qoder/settings.json` | Tell the platform when to run hooks or plugins. | +| agents | `.claude/agents/`, `.codex/agents/`, `.kiro/agents/` | Define agents such as `trellis-research`, `trellis-implement`, and `trellis-check`. | +| skills | `.claude/skills/`, `.agents/skills/`, `.qoder/skills/` | Skills that auto-trigger or can be read by AI. | +| commands/prompts/workflows | `.cursor/commands/`, `.github/prompts/`, `.windsurf/workflows/` | Explicit user-invoked command or workflow entry points. | + +When modifying a platform directory, also confirm whether `.trellis/workflow.md` still describes the same flow. + +## Meaning Of Template Hashes + +`.trellis/.template-hashes.json` records the content hash from the last time Trellis wrote a template file. `trellis update` uses it to distinguish three cases: + +| Case | Update behavior | +| --- | --- | +| File was not modified by the user | It can be updated automatically. | +| File was modified by the user | Prompt the user to overwrite, keep, or generate `.new`. | +| File is no longer a current template | It may be deleted, renamed, or preserved according to migration rules. | + +When an AI customizes local Trellis files, it does not need to maintain hashes manually. It is normal for Trellis update to recognize the result as "modified by the user." + +## Local Customization Boundaries + +Editable by default: + +- `.trellis/workflow.md` +- `.trellis/config.yaml` +- `.trellis/spec/**` +- `.trellis/scripts/**` +- Platform hooks, settings, agents, skills, commands, prompts, and workflows + +Do not edit by default: + +- Global npm install directory +- `node_modules/@mindfoldhq/trellis` +- Trellis GitHub repository source code +- Concrete state files under `.trellis/.runtime/**` +- Hash contents inside `.trellis/.template-hashes.json` + +Switch to the Trellis CLI source-code perspective only when the user explicitly wants to contribute upstream. diff --git a/.claude/skills/trellis-meta/references/local-architecture/overview.md b/.claude/skills/trellis-meta/references/local-architecture/overview.md new file mode 100644 index 0000000..99c7f73 --- /dev/null +++ b/.claude/skills/trellis-meta/references/local-architecture/overview.md @@ -0,0 +1,51 @@ +# Local Trellis Architecture Overview + +`trellis-meta` is for user projects that have already run `trellis init`. The user's machine usually has only the npm-installed `trellis` command plus the Trellis files generated inside the project; it may not have the Trellis CLI source code. + +Therefore, when an AI uses this skill, the default customization target is local files inside the user project: + +- `.trellis/`: workflow, tasks, specs, memory, scripts, and runtime state. +- Platform directories: `.claude/`, `.codex/`, `.cursor/`, `.opencode/`, `.kiro/`, `.gemini/`, `.qoder/`, `.codebuddy/`, `.github/`, `.factory/`, `.pi/`, `.kilocode/`, `.agent/`, `.windsurf/`, and similar directories. +- Shared skill layer: `.agents/skills/`. + +Do not default to guiding the user to fork the Trellis CLI repository. Treat upstream source code as the operating target only when the user explicitly says they want to change Trellis upstream source, publish an npm package, or contribute a PR. + +## Local System Model + +Trellis provides three layers inside a user project: + +1. **Workflow layer**: `.trellis/workflow.md` defines phases, routing, next actions, and prompt blocks. +2. **Persistence layer**: `.trellis/tasks/`, `.trellis/spec/`, and `.trellis/workspace/` store tasks, specs, and session memory. +3. **Platform integration layer**: hooks, settings, agents, skills, commands, prompts, and workflows in platform directories connect the Trellis workflow to different AI tools. + +All three layers live inside the user project, so an AI can read and modify them directly. + +## Core Paths + +| Path | Purpose | +| --- | --- | +| `.trellis/workflow.md` | Workflow phases, skill routing, and workflow-state prompt blocks. | +| `.trellis/config.yaml` | Project configuration, task lifecycle hooks, monorepo package configuration, and journal configuration. | +| `.trellis/spec/` | The user's project-specific coding conventions and thinking guides. | +| `.trellis/tasks/` | Each task's PRD, technical notes, research files, and JSONL context. | +| `.trellis/workspace/` | Per-developer journals and cross-session memory. | +| `.trellis/scripts/` | Local Python runtime used by commands, hooks, and context injection. | +| `.trellis/.runtime/` | Session-level runtime state, such as the current task pointer. | +| `.trellis/.template-hashes.json` | Template hashes for Trellis-managed files, used by update to determine whether local files were modified by the user. | + +## AI Customization Principles + +1. **Find the local source of truth first**: Do not edit from memory. Read `.trellis/workflow.md`, `.trellis/config.yaml`, the relevant platform directory, and related task files first. +2. **Edit the user project, not the npm package cache**: Modify generated files inside the project, not `node_modules` or the global npm install directory. +3. **Keep platform files aligned with `.trellis/`**: If workflow routing changes, also check whether platform skills or commands still describe the same flow. +4. **Put project-specific rules in `.trellis/spec/` or a local skill**: Do not put team conventions into `trellis-meta`. +5. **Preserve user changes**: If a file was already modified locally, work from the current content instead of overwriting it with a default template. + +## How To Use This Directory + +- To understand which files exist after init, read `generated-files.md`. +- To change phases, routing, or next actions, read `workflow.md`. +- To change the task model, JSONL context, or active task behavior, read `task-system.md`. +- To change coding convention injection, read `spec-system.md`. +- To understand journals and cross-session memory, read `workspace-memory.md`. +- To change hooks or sub-agent context loading, read `context-injection.md`. diff --git a/.claude/skills/trellis-meta/references/local-architecture/spec-system.md b/.claude/skills/trellis-meta/references/local-architecture/spec-system.md new file mode 100644 index 0000000..1ff49f4 --- /dev/null +++ b/.claude/skills/trellis-meta/references/local-architecture/spec-system.md @@ -0,0 +1,102 @@ +# Local Spec System + +`.trellis/spec/` is the user's project-specific engineering spec library. Trellis is not about making AI memorize conventions; it injects relevant specs or requires the AI to read them at the right time. + +## Directory Model + +A common single-repository structure: + +```text +.trellis/spec/ +├── backend/ +│ ├── index.md +│ └── ... +├── frontend/ +│ ├── index.md +│ └── ... +└── guides/ + ├── index.md + └── ... +``` + +A common monorepo structure: + +```text +.trellis/spec/ +├── cli/ +│ ├── backend/ +│ │ ├── index.md +│ │ └── ... +│ └── unit-test/ +│ ├── index.md +│ └── ... +├── docs-site/ +│ └── docs/ +│ ├── index.md +│ └── ... +└── guides/ + ├── index.md + └── ... +``` + +`index.md` is the entry point for each layer. It should list the Pre-Development Checklist and Quality Check. Specific guidelines live in other Markdown files in the same directory. + +## Package Configuration + +`.trellis/config.yaml` can declare packages: + +```yaml +packages: + cli: + path: packages/cli + docs-site: + path: docs-site + type: submodule +default_package: cli +``` + +The AI can run: + +```bash +python ./.trellis/scripts/get_context.py --mode packages +``` + +This command lists packages and spec layers for the current project. Use this output as the reference when configuring context JSONL. + +## How Specs Enter Tasks + +Before a task enters implementation, planning may write relevant specs into `implement.jsonl` / `check.jsonl` when the task needs spec or research context beyond the task artifacts: + +```jsonl +{"file": ".trellis/spec/cli/backend/index.md", "reason": "CLI backend conventions"} +{"file": ".trellis/spec/cli/unit-test/conventions.md", "reason": "Test expectations"} +``` + +Sub-agents or platform preludes read these JSONL files and load the referenced specs. On platforms without sub-agent support, the AI should read the relevant specs directly according to the workflow. + +## What Specs Should Contain + +Specs should contain executable engineering conventions for the project, not generic best practices: + +- Where files should live. +- How error handling should be expressed. +- Input/output contracts for APIs, hooks, and commands. +- Patterns that are forbidden. +- Cases that require tests. +- Project-specific pitfalls and how to avoid them. + +When the AI learns a new rule during implementation or debugging, it should update `.trellis/spec/` rather than only summarizing it in chat. + +## Local Customization Points + +| Need | Edit location | +| --- | --- | +| Add a new spec layer | `.trellis/spec/<package>/<layer>/index.md` and corresponding guideline files. | +| Change monorepo spec mapping | `packages` / `default_package` / `spec_scope` in `.trellis/config.yaml`. | +| Change which specs AI reads before implementation | The task's `implement.jsonl`. | +| Change which specs AI reads during checking | The task's `check.jsonl`. | +| Change when specs should be updated | Phase 3.3 in `.trellis/workflow.md` and the `trellis-update-spec` skill. | + +## Boundaries + +`.trellis/spec/` is the user's project specification, not a permanent copy of Trellis built-in templates. The AI should encourage the user to update it according to the actual project code instead of treating Trellis default templates as immutable documents. diff --git a/.claude/skills/trellis-meta/references/local-architecture/task-system.md b/.claude/skills/trellis-meta/references/local-architecture/task-system.md new file mode 100644 index 0000000..9dfe5bb --- /dev/null +++ b/.claude/skills/trellis-meta/references/local-architecture/task-system.md @@ -0,0 +1,130 @@ +# Local Task System + +The Trellis task system is stored entirely under `.trellis/tasks/` in the user project. Each task is a directory containing requirements, context, research, state, and relationship information. + +## Task Directory Structure + +```text +.trellis/tasks/ +├── 04-28-example-task/ +│ ├── task.json +│ ├── prd.md +│ ├── design.md +│ ├── implement.md +│ ├── implement.jsonl +│ ├── check.jsonl +│ └── research/ +└── archive/ + └── 2026-04/ +``` + +| File | Purpose | +| --- | --- | +| `task.json` | Task metadata: status, assignee, priority, branch, parent/child tasks, and similar fields. | +| `prd.md` | Requirements, constraints, and acceptance criteria. Lightweight tasks may be PRD-only. | +| `design.md` | Technical design for complex tasks: boundaries, contracts, data flow, compatibility, tradeoffs. | +| `implement.md` | Execution plan for complex tasks: ordered checklist, validation commands, review gates, rollback points. | +| `implement.jsonl` | List of spec/research files the implement agent must read first. | +| `check.jsonl` | List of spec/research files the check agent must read first. | +| `research/` | Research artifacts. Complex findings should not live only in chat. | + +## `task.json` + +`task.json` records task status and metadata. Common fields: + +| Field | Meaning | +| --- | --- | +| `id` / `name` / `title` | Task identity and title. | +| `status` | Status such as `planning`, `in_progress`, `review`, or `completed`. | +| `priority` | `P0`, `P1`, `P2`, `P3`. | +| `creator` / `assignee` | Creator and assignee. | +| `package` | Target package in a monorepo; may be empty. | +| `branch` / `base_branch` | Working branch and PR target branch. | +| `children` / `parent` | Parent/child task relationships. | +| `commit` / `pr_url` | Commit and PR information after completion. | +| `meta` | Extension fields. | + +## Parent / Child Task Trees + +Parent/child task relationships are for work structure. A parent task groups related deliverables under one source requirement set; it is not a dependency scheduler and does not replace the child task's own planning artifacts. + +Use a parent task when a request has multiple independently verifiable deliverables. The parent owns: + +- Source requirements and user-facing scope. +- The map of child tasks and their responsibility boundaries. +- Cross-child acceptance criteria and final integration review. + +Use child tasks for deliverables that can move through planning, implementation, check, and archive independently. If one child depends on another, write that dependency in the child `prd.md` / `implement.md`; do not rely on tree position to imply ordering. + +Create new children with: + +```bash +python ./.trellis/scripts/task.py create "<child title>" --slug <child-slug> --parent <parent-dir> +``` + +Link or unlink existing tasks with: + +```bash +python ./.trellis/scripts/task.py add-subtask <parent-dir> <child-dir> +python ./.trellis/scripts/task.py remove-subtask <parent-dir> <child-dir> +``` + +`children` on the parent is a historical list. When a child is archived, Trellis keeps that child name in the parent so progress like `[2/3 done]` remains meaningful after completed children move to `archive/`. + +The AI should not treat phase numbers as task status. Task progress is mainly determined by `status`, artifact presence (`prd.md`, optional `design.md` / `implement.md`), whether JSONL context is configured for sub-agent mode, and the phase descriptions in `workflow.md`. + +## Active Task + +The user sees a "current task," but Trellis stores active task state per session. + +```text +.trellis/.runtime/sessions/<context-key>.json +``` + +`task.py start` writes the task path into the runtime session file for the current session. `task.py current --source` shows the current task and where it came from. Different AI windows can point to different tasks without overwriting each other. + +If the platform or shell environment has no stable session identity, `task.py start` may be unable to set the active task. The AI should read the error, inspect the platform hook/session environment, and not fall back to a shared global pointer. + +## JSONL Context + +`implement.jsonl` and `check.jsonl` are context manifests for sub-agents to read first. They do not replace `implement.md`; `implement.md` is the human-readable execution plan. + +Format: + +```jsonl +{"file": ".trellis/spec/cli/backend/index.md", "reason": "Backend conventions"} +{"file": ".trellis/tasks/04-28-example/research/api.md", "reason": "API research"} +``` + +Rules: + +- Include spec and research files. +- Do not include code files that are about to be modified. +- Do not treat temporary conclusions in chat as the only context. +- Seed rows have no `file` field; they only prompt the AI to fill in real entries. + +## Common Commands + +```bash +python ./.trellis/scripts/task.py create "<title>" --slug <slug> +python ./.trellis/scripts/task.py start <task> +python ./.trellis/scripts/task.py current --source +python ./.trellis/scripts/task.py add-context <task> implement <file> <reason> +python ./.trellis/scripts/task.py validate <task> +python ./.trellis/scripts/task.py finish +python ./.trellis/scripts/task.py archive <task> +``` + +When modifying the task system, the AI should prefer script commands to maintain structure. Edit JSON/Markdown directly only when scripts do not cover the need. + +## Local Customization Points + +| Need | Edit location | +| --- | --- | +| Change the default task template | `.trellis/scripts/common/task_store.py` and task creation instructions. | +| Change status semantics | `.trellis/workflow.md`, workflow-state hook logic, and task usage conventions. | +| Add task lifecycle actions | `hooks.after_*` in `.trellis/config.yaml`. | +| Change context rules | Planning artifact guidance in `.trellis/workflow.md` and related platform agent/hook instructions. | +| Change archive policy | `.trellis/scripts/common/task_store.py` / `task_utils.py`. | + +These are local files in the user project. Do not default to editing Trellis CLI source code unless the user wants to contribute upstream. diff --git a/.claude/skills/trellis-meta/references/local-architecture/workflow.md b/.claude/skills/trellis-meta/references/local-architecture/workflow.md new file mode 100644 index 0000000..f0659ff --- /dev/null +++ b/.claude/skills/trellis-meta/references/local-architecture/workflow.md @@ -0,0 +1,75 @@ +# Local Workflow System + +`.trellis/workflow.md` is the Trellis workflow source of truth inside the user project. An AI does not need Trellis source code to understand how the current project should move tasks forward; this file is enough. + +## File Responsibilities + +`.trellis/workflow.md` has three responsibilities: + +1. **Explain workflow phases**: Plan, Execute, Finish. +2. **Define skill routing**: which skill or agent the AI should use when the user expresses a certain intent. +3. **Provide workflow-state prompt blocks**: hooks can inject the prompt block for the current state into the conversation. + +## Current Phase Model + +```text +Phase 1: Plan -> clarify what to build, produce prd.md and required research +Phase 2: Execute -> implement against the PRD and specs, then check +Phase 3: Finish -> final verification, preserve lessons, and wrap up +``` + +Each phase contains numbered steps, such as `1.3 Configure context`. These numbers are not runtime fields in `task.json`; they are workflow structure for AI and humans to read. + +## Skill Routing + +`workflow.md` separates routing by platform capability: + +- Platforms with sub-agent support: dispatch `trellis-implement` by default for implementation and `trellis-check` for checking. +- Platforms without sub-agent support: the main session reads skills such as `trellis-before-dev`, then executes directly. + +When changing local AI behavior, update the routing descriptions in `workflow.md` first, then check whether the corresponding platform skill, command, or agent files need to stay in sync. + +## Workflow-State Prompt Blocks + +The bottom of `workflow.md` can contain state blocks like this: + +```text +[workflow-state:no_task] +... +[/workflow-state:no_task] +``` + +Hooks choose the right block based on current task status and inject it into the conversation. Common states include: + +| State | Meaning | +| --- | --- | +| `no_task` | The current session has no active task. | +| `planning` | The task is still in requirements, research, or context configuration. | +| `in_progress` | The task has entered implementation and checking. | +| `completed` | The task is complete and waiting for wrap-up or archive. | + +If the user wants to change policies such as "whether to create a task when there is no task," "when task creation may be skipped," or "whether sub-agents are required," edit these state blocks and the routing table above them. + +## Local Modification Patterns + +Common changes: + +| Goal | Edit point | +| --- | --- | +| Add a phase | Update the Phase Index, phase body, routing, and state blocks. | +| Change task creation policy | Update the `no_task` state block and Phase 1 description. | +| Change the default implementation/check path | Update Phase 2 and skill routing. | +| Change the wrap-up flow | Update Phase 3 and `finish-work` related descriptions. Note the current split: Phase 3.4 = AI-driven code commits (batched, user-confirmed), Phase 3.5 = `/finish-work` (archive + record session). `/finish-work` refuses to run if the working tree is dirty. | +| Change platform differences | Update routing descriptions grouped by platform. | + +After editing, make the AI reread `.trellis/workflow.md`; do not assume the flow from the old conversation is still valid. + +## Relationship To Platform Files + +`workflow.md` is the semantic center of the local workflow, but each platform can also have its own entry files: + +- skills, such as `trellis-brainstorm` and `trellis-check`. +- commands/prompts/workflows, such as continue and finish-work. +- hooks, such as session-start or workflow-state injection. + +If only `workflow.md` changes, platform entry files may still contain old language. When the user wants to change "what the AI actually does," also inspect the relevant platform directory. diff --git a/.claude/skills/trellis-meta/references/local-architecture/workspace-memory.md b/.claude/skills/trellis-meta/references/local-architecture/workspace-memory.md new file mode 100644 index 0000000..92d29f4 --- /dev/null +++ b/.claude/skills/trellis-meta/references/local-architecture/workspace-memory.md @@ -0,0 +1,71 @@ +# Local Workspace Memory System + +`.trellis/workspace/` stores cross-session memory. Its purpose is to let AI and humans understand what happened before across different windows and different days. + +## Directory Structure + +```text +.trellis/workspace/ +├── index.md +└── <developer>/ + ├── index.md + ├── journal-1.md + └── journal-2.md +``` + +| File | Purpose | +| --- | --- | +| `.trellis/.developer` | Current developer identity. | +| `.trellis/workspace/index.md` | Global workspace overview. | +| `.trellis/workspace/<developer>/index.md` | Session index for a developer. | +| `.trellis/workspace/<developer>/journal-N.md` | Session journal. | + +## Developer Identity + +Run this the first time: + +```bash +python ./.trellis/scripts/init_developer.py <name> +``` + +This creates `.trellis/.developer` and the corresponding workspace directory. The AI should not change developer identity casually; if the identity is wrong, first confirm who is using the current project. + +## Journal + +`journal-N.md` records completed or partially completed work from each session. By default, each journal holds about 2000 lines; after that it rotates to the next file. + +Common command for recording a session: + +```bash +python ./.trellis/scripts/add_session.py \ + --title "Session title" \ + --summary "What changed" \ + --commit "abc1234" +``` + +Planning or review work without a commit can also be recorded by using `--no-commit` or an empty commit value. + +## Relationship Between Workspace Memory And Tasks + +| System | What it stores | +| --- | --- | +| `.trellis/tasks/` | Requirements, design, research, and state for a specific task. | +| `.trellis/workspace/` | Work records across tasks and sessions. | +| `.trellis/spec/` | Engineering knowledge preserved as long-term conventions. | + +If information is only useful for the current task, put it in the task directory. +If information describes what happened in the current session, put it in the workspace journal. +If information should be followed every time code is written in the future, put it in spec. + +## Local Customization Points + +| Need | Edit location | +| --- | --- | +| Change maximum journal lines | `max_journal_lines` in `.trellis/config.yaml`. | +| Change session auto-commit message | `session_commit_message` in `.trellis/config.yaml`. | +| Change session content format | `.trellis/scripts/add_session.py`. | +| Change how workspace is displayed in context | `.trellis/scripts/common/session_context.py`. | + +## AI Usage Rules + +The AI should not treat workspace as the only source of truth. When resuming a task, read the current task first, then use workspace for background. After a task is complete, record important process notes in workspace; if long-term rules emerged, update spec. diff --git a/.claude/skills/trellis-meta/references/platform-files/agents.md b/.claude/skills/trellis-meta/references/platform-files/agents.md new file mode 100644 index 0000000..3976987 --- /dev/null +++ b/.claude/skills/trellis-meta/references/platform-files/agents.md @@ -0,0 +1,80 @@ +# Agents + +Trellis agent files define specialized roles. Common Trellis agents in a user project are: + +- `trellis-research` +- `trellis-implement` +- `trellis-check` + +File locations and formats differ by platform, but responsibility boundaries should stay consistent. + +## Agent Responsibilities + +| Agent | Responsibility | +| --- | --- | +| `trellis-research` | Investigate the question and write findings into the current task's `research/`. | +| `trellis-implement` | Implement against `prd.md`, optional `design.md` / `implement.md`, `implement.jsonl`, and related spec/research. | +| `trellis-check` | Review changes, fix discovered issues, and run necessary checks. | + +Agent files should not become generic chat prompts. They should define input sources, write boundaries, whether code may be changed, and how results are reported. + +## Common Paths + +| Platform | Agent path | +| --- | --- | +| Claude Code | `.claude/agents/trellis-*.md` | +| Cursor | `.cursor/agents/trellis-*.md` | +| OpenCode | `.opencode/agents/trellis-*.md` | +| Codex | `.codex/agents/trellis-*.toml` | +| Kiro | `.kiro/agents/trellis-*.json` | +| Gemini CLI | `.gemini/agents/trellis-*.md` | +| Qoder | `.qoder/agents/trellis-*.md` | +| CodeBuddy | `.codebuddy/agents/trellis-*.md` | +| Factory Droid | `.factory/droids/trellis-*.md` | +| Pi Agent | `.pi/agents/trellis-*.md` | + +GitHub Copilot agent/prompt support is provided by a combination of directories such as `.github/agents/`, `.github/prompts/`, and `.github/skills/`; inspect the files actually generated in the user project. + +Main-session workflow platforms such as Kilo, Antigravity, and Windsurf may not have Trellis sub-agent files. They usually rely on workflows/skills to guide the main session. + +## Two Context Loading Modes + +### hook push + +The platform hook injects task context before the agent starts. The agent file itself can focus more on responsibilities and boundaries. + +Common on platforms that support agent hooks. + +### agent pull + +The agent file instructs the agent to read after startup: + +- `python ./.trellis/scripts/task.py current --source` +- `implement.jsonl` or `check.jsonl` +- spec/research files referenced by JSONL +- current task `prd.md` +- `design.md` if present +- `implement.md` if present + +This mode fits platforms whose hooks cannot reliably rewrite sub-agent prompts. + +## Local Change Scenarios + +| User need | Edit location | +| --- | --- | +| Implement agent must follow extra restrictions | The platform's `trellis-implement` agent file. | +| Check agent must run project-specific commands | `trellis-check` agent file, and `.trellis/spec/` if needed. | +| Research agent must output a fixed format | `trellis-research` agent file. | +| Agent cannot read task context | Agent prelude or `inject-subagent-context` hook. | +| Add a project-specific agent | Platform agent directory + related workflow/command/skill entry point. | + +## Modification Principles + +1. **Keep responsibilities single-purpose**. Do not mix research, implement, and check responsibilities into one agent. +2. **Specify the read order**. Agents must know to start from the active task, read jsonl/spec context, then read `prd.md`, `design.md` if present, and `implement.md` if present. +3. **Specify write boundaries**. Research usually only writes `research/`; implement can write code; check can fix issues. +4. **Keep semantics synchronized in multi-platform projects**. If the user configured Claude, Codex, and Cursor together, decide whether changes to one platform's agent also need to be applied to others. + +## Do Not Default To Editing Upstream Templates + +Local AI should default to modifying platform agent files inside the user project. Discuss upstream template source only when the user explicitly wants to contribute the change back to Trellis. diff --git a/.claude/skills/trellis-meta/references/platform-files/hooks-and-settings.md b/.claude/skills/trellis-meta/references/platform-files/hooks-and-settings.md new file mode 100644 index 0000000..94156a8 --- /dev/null +++ b/.claude/skills/trellis-meta/references/platform-files/hooks-and-settings.md @@ -0,0 +1,69 @@ +# Hooks And Settings + +Hooks/settings are the entry layer that connects a platform to Trellis. They decide which scripts, plugins, or extensions a platform runs for which events. + +## Settings Responsibilities + +settings/config files usually register: + +- session-start hook: injects a Trellis overview when a new session starts or context resets. +- workflow-state hook: parses `[workflow-state:STATUS]` blocks from `.trellis/workflow.md` and emits the body matching the current task `status` on each user input. Parser-only; the script does not embed fallback content. +- sub-agent context hook: injects task context when implementation/check/research agents start. +- shell/session bridge: lets shell commands see the same Trellis session identity. +- platform plugin or extension entry points. + +Common files: + +| Platform | settings/config | +| --- | --- | +| Claude Code | `.claude/settings.json` | +| Cursor | `.cursor/hooks.json` | +| Codex | `.codex/hooks.json`, `.codex/config.toml` | +| OpenCode | `.opencode/package.json`, `.opencode/plugins/*` | +| Kiro | `.kiro/hooks/` + platform config | +| Gemini CLI | `.gemini/settings.json` | +| Qoder | `.qoder/settings.json` | +| CodeBuddy | `.codebuddy/settings.json` | +| GitHub Copilot | `.github/copilot/hooks.json` | +| Factory Droid | `.factory/settings.json` | +| Pi Agent | `.pi/settings.json`, `.pi/extensions/trellis/` | + +Whether these files exist in a project depends on which `trellis init --<platform>` flags the user ran. + +## Hook Script Types + +| Script | Purpose | +| --- | --- | +| `session-start.py` | Generates session-start context. | +| `inject-workflow-state.py` | Parses `[workflow-state:STATUS]` blocks in `.trellis/workflow.md` and emits the body matching the current task status. Falls back to `Refer to workflow.md for current step.` when no matching block exists. | +| `inject-subagent-context.py` | Injects PRD, JSONL context, and related spec/research into sub-agents. | +| `inject-shell-session-context.py` | Lets shell commands inherit Trellis session identity. | + +Not every platform has every hook. Do not copy files from another platform just because a platform lacks a hook; first confirm whether that platform supports the corresponding event. + +## Local Change Scenarios + +| User need | Edit location | +| --- | --- | +| AI should see more/less context in a new session | Platform `session-start` hook. | +| Per-turn hint policy should change | `[workflow-state:STATUS]` block in `.trellis/workflow.md`. The hook parses workflow.md verbatim — no script edit required. | +| Sub-agent cannot read PRD/spec | `inject-subagent-context` hook or agent prelude. | +| `task.py current` in shell has no active task | Shell/session bridge hook or platform environment variable configuration. | +| Disable an automatic injection | The corresponding hook registration in settings/config. | + +## Modification Principles + +1. **Settings wire things up; hooks define behavior**. If only the hook changes, the platform may never call it. If only settings change, behavior may not change. +2. **Confirm platform event names first**. Different platforms use different names for SessionStart, UserPromptSubmit, AgentSpawn, shell execution, and similar events. +3. **Hooks read local `.trellis/`, not upstream source**. `.trellis/scripts/` and `.trellis/workflow.md` in the user project are the default targets. +4. **Errors must be visible**. Hook failures should tell the user what was not injected instead of silently leaving the AI without context. + +## Troubleshooting Path + +If the user says "AI did not read Trellis state": + +1. Check whether the platform settings register the hook. +2. Check whether the hook file exists. +3. Manually run the `.trellis/scripts/get_context.py` or `task.py current --source` command that the hook depends on. +4. Check whether active task state exists in `.trellis/.runtime/sessions/`. +5. Check whether the platform shell passes session identity. diff --git a/.claude/skills/trellis-meta/references/platform-files/overview.md b/.claude/skills/trellis-meta/references/platform-files/overview.md new file mode 100644 index 0000000..60ae1df --- /dev/null +++ b/.claude/skills/trellis-meta/references/platform-files/overview.md @@ -0,0 +1,59 @@ +# Platform Files Overview + +Trellis connects the same local architecture to different AI tools. `.trellis/` stores the shared runtime; platform directories store adapter files that define how each AI tool enters Trellis. + +When a local AI modifies Trellis, it should distinguish two file categories first: + +- **Shared files**: `.trellis/workflow.md`, `.trellis/tasks/`, `.trellis/spec/`, `.trellis/scripts/`. +- **Platform files**: `.claude/`, `.codex/`, `.cursor/`, `.opencode/`, `.kiro/`, `.gemini/`, `.qoder/`, `.codebuddy/`, `.github/`, `.factory/`, `.pi/`, `.kilocode/`, `.agent/`, `.windsurf/`, and similar directories. + +Platform files do not store business state. They let the corresponding AI tool read Trellis state, call Trellis scripts, and load Trellis skills/agents/hooks. + +## Platform File Categories + +| Category | Common paths | Purpose | +| --- | --- | --- | +| settings/config | `.claude/settings.json`, `.codex/hooks.json`, `.qoder/settings.json` | Register hooks, plugins, extensions, or platform behavior. | +| hooks/plugins/extensions | `.claude/hooks/`, `.opencode/plugins/`, `.pi/extensions/` | Inject context at session start, user input, agent startup, shell execution, and similar events. | +| agents | `.claude/agents/`, `.codex/agents/`, `.kiro/agents/` | Define `trellis-research`, `trellis-implement`, and `trellis-check`. | +| skills | `.claude/skills/`, `.agents/skills/`, `.qoder/skills/` | Capability descriptions that auto-trigger or can be read on demand. | +| commands/prompts/workflows | `.cursor/commands/`, `.github/prompts/`, `.windsurf/workflows/` | Entry points explicitly invoked by the user. | + +## Three Platform Integration Modes + +### 1. Hook / Extension Driven + +These platforms can trigger scripts or plugins on specific events and actively inject Trellis context into AI. + +Common capabilities: + +- session-start injection of a `.trellis/` overview. +- workflow-state hints for each user turn. +- PRD/spec/research injection when sub-agents start. +- Shell commands inheriting session identity. + +To change "when the AI knows what," inspect hooks/plugins/extensions and settings first. + +### 2. Agent Prelude / Pull-Based + +Some platforms cannot reliably let hooks rewrite sub-agent prompts, so the agent file itself instructs the agent to read the active task, PRD, and JSONL context after startup. + +To change how sub-agents load context, inspect the agent files themselves. + +### 3. Main-Session Workflow + +Some platforms do not have Trellis sub-agent or hook capabilities. They rely on workflows/skills/commands to guide the main-session AI to read files, run scripts, and move tasks forward. + +To change behavior, inspect platform workflows/skills/commands and `.trellis/workflow.md`. + +## Local Modification Order + +When the user asks to customize behavior for a platform, the AI should inspect files in this order: + +1. Read `.trellis/workflow.md` to confirm the shared flow. +2. Read the target platform's settings/config to see which hooks/agents/skills/commands are registered. +3. Read the target platform's agents/skills/commands/hooks. +4. Modify the local file closest to the user's need. +5. If the change affects the shared flow, synchronize `.trellis/workflow.md` or `.trellis/spec/`. + +Do not modify only platform files and forget the shared workflow. Do not modify only `.trellis/workflow.md` and forget that platform entry points may still contain old descriptions. diff --git a/.claude/skills/trellis-meta/references/platform-files/platform-map.md b/.claude/skills/trellis-meta/references/platform-files/platform-map.md new file mode 100644 index 0000000..b5576f4 --- /dev/null +++ b/.claude/skills/trellis-meta/references/platform-files/platform-map.md @@ -0,0 +1,74 @@ +# Platform File Map + +This page lists common Trellis file locations in a user project by platform. Whether a platform directory exists in an actual project depends on which `trellis init --<platform>` commands the user ran. + +## Matrix + +| Platform | CLI flag | Main directory | Skill directory | Agent directory | Hooks/extensions | +| --- | --- | --- | --- | --- | --- | +| Claude Code | `--claude` | `.claude/` | `.claude/skills/` | `.claude/agents/` | `.claude/hooks/` + `.claude/settings.json` | +| Cursor | `--cursor` | `.cursor/` | `.cursor/skills/` | `.cursor/agents/` | `.cursor/hooks.json` + `.cursor/hooks/` | +| OpenCode | `--opencode` | `.opencode/` | `.opencode/skills/` | `.opencode/agents/` | `.opencode/plugins/` | +| Codex | `--codex` | `.codex/` | `.agents/skills/` | `.codex/agents/` | `.codex/hooks/` + `.codex/hooks.json` | +| Kilo | `--kilo` | `.kilocode/` | `.kilocode/skills/` | Usually none | `.kilocode/workflows/` | +| Kiro | `--kiro` | `.kiro/` | `.kiro/skills/` | `.kiro/agents/` | `.kiro/hooks/` | +| Gemini CLI | `--gemini` | `.gemini/` | `.agents/skills/` | `.gemini/agents/` | `.gemini/settings.json` + `.gemini/hooks/` | +| Antigravity | `--antigravity` | `.agent/` | `.agent/skills/` | Usually none | `.agent/workflows/` | +| Windsurf | `--windsurf` | `.windsurf/` | `.windsurf/skills/` | Usually none | `.windsurf/workflows/` | +| Qoder | `--qoder` | `.qoder/` | `.qoder/skills/` | `.qoder/agents/` | `.qoder/hooks/` + `.qoder/settings.json` | +| CodeBuddy | `--codebuddy` | `.codebuddy/` | `.codebuddy/skills/` | `.codebuddy/agents/` | `.codebuddy/hooks/` + `.codebuddy/settings.json` | +| GitHub Copilot | `--copilot` | `.github/` | `.github/skills/` | `.github/agents/` | `.github/copilot/hooks/` + prompts | +| Factory Droid | `--droid` | `.factory/` | `.factory/skills/` | `.factory/droids/` | `.factory/hooks/` + settings | +| Pi Agent | `--pi` | `.pi/` | `.pi/skills/` | `.pi/agents/` | `.pi/extensions/trellis/` + `.pi/settings.json` | + +## Capability Groups + +### Trellis Sub-Agent Support + +These platforms usually have `trellis-research`, `trellis-implement`, and `trellis-check` files: + +- Claude Code +- Cursor +- OpenCode +- Codex +- Kiro +- Gemini CLI +- Qoder +- CodeBuddy +- GitHub Copilot +- Factory Droid +- Pi Agent + +When changing implementation/check/research behavior, look for the corresponding platform agent files first. + +### Main-Session Workflow Platforms + +These platforms rely more on workflows/skills to guide the main session: + +- Kilo +- Antigravity +- Windsurf + +When changing behavior, inspect workflows and skills first. Do not assume Trellis sub-agents exist. + +### Shared `.agents/skills/` + +Codex writes the shared `.agents/skills/` layer. Some tools that support agentskills.io can also read this directory. If the user wants multiple compatible tools to share one skill, consider `.agents/skills/` first, but do not assume every platform reads it. + +## Decision Rules When Modifying Platform Files + +1. User specified a platform: modify only that platform directory unless shared workflow/spec files must also change. +2. User says "all platforms should do this": synchronize equivalent entry points platform by platform; do not modify only one directory. +3. User only says "my AI": inspect the configuration directories that actually exist in the project and infer the current AI platform. +4. User wants project rules: prefer `.trellis/spec/` or a project-local skill. +5. User wants Trellis behavior: edit `.trellis/workflow.md` plus platform hooks/agents/skills/commands. + +## When Paths Differ + +Platform ecosystems change, and user projects may already be customized. If this table disagrees with local files, use the actual settings/config in the user project as authoritative: + +- Check the hook that settings registers. +- Check the script that a command/prompt/workflow points to. +- Judge behavior by the read rules currently written in the agent file. + +Do not delete a custom file just because it is not listed in this path table. diff --git a/.claude/skills/trellis-meta/references/platform-files/skills-and-commands.md b/.claude/skills/trellis-meta/references/platform-files/skills-and-commands.md new file mode 100644 index 0000000..816c666 --- /dev/null +++ b/.claude/skills/trellis-meta/references/platform-files/skills-and-commands.md @@ -0,0 +1,83 @@ +# Skills, Commands, Prompts, And Workflows + +Skills and commands are textual entry points for user interaction with Trellis. Different platforms use different names, but their core purpose is the same: tell the AI how to enter the Trellis flow when the user expresses a certain intent. + +## Conceptual Differences + +| Type | Trigger mode | Best for | +| --- | --- | --- | +| skill | AI auto-match or explicit user mention | Long-term capabilities, workflow rules, modification guides. | +| command | Explicit user invocation | Clear operation entry points such as continue and finish-work. | +| prompt | Explicit user invocation or platform selection | Similar to command, but in a platform prompt format. | +| workflow | Explicit user selection or platform auto-match | Guides the main session when no sub-agent/hook exists. | + +Trellis workflow skills usually share one semantic set: brainstorm, before-dev, check, update-spec, break-loop. Multi-file built-in skills such as `trellis-meta` use layered references. + +## Common Paths + +| Platform | Common entries | +| --- | --- | +| Claude Code | `.claude/skills/`, `.claude/commands/` | +| Cursor | `.cursor/skills/`, `.cursor/commands/` | +| OpenCode | `.opencode/skills/`, `.opencode/commands/` | +| Codex | `.agents/skills/`, `.codex/skills/` | +| Kilo | `.kilocode/skills/`, `.kilocode/workflows/` | +| Kiro | `.kiro/skills/` | +| Gemini CLI | `.agents/skills/`, `.gemini/commands/` | +| Antigravity | `.agent/skills/`, `.agent/workflows/` | +| Windsurf | `.windsurf/skills/`, `.windsurf/workflows/` | +| Qoder | `.qoder/skills/`, `.qoder/commands/` | +| CodeBuddy | `.codebuddy/skills/`, `.codebuddy/commands/` | +| GitHub Copilot | `.github/skills/`, `.github/prompts/` | +| Factory Droid | `.factory/skills/`, `.factory/commands/` | +| Pi Agent | `.pi/skills/` | + +In a user project, use the files actually generated by init as authoritative. + +## Skill Structure + +A common skill is a directory: + +```text +trellis-meta/ +├── SKILL.md +└── references/ +``` + +`SKILL.md` should tell the AI: + +- When to use this skill. +- Which reference to read first for the current task. +- What not to do. + +References hold longer explanations so the entry file does not contain everything. + +## Command/Prompt/Workflow Structure + +Commands, prompts, and workflows are usually single files. Their content should include: + +- When to use it. +- Which `.trellis/` files to read. +- Which scripts to run. +- How to report after completion. + +They should not store task state; task state belongs in `.trellis/tasks/` and `.trellis/.runtime/`. + +## Local Change Scenarios + +| User need | Edit location | +| --- | --- | +| Change AI auto-trigger rules | The corresponding skill's frontmatter description. | +| Change user command behavior | The corresponding command/prompt/workflow file. | +| Add a project-local skill | Platform skill directory, or shared `.agents/skills/`. | +| Let multiple platforms share one capability | Write equivalent skills in each platform skill directory, or use the `.agents/skills/` shared layer on platforms that support it. | +| Change finish/continue entry points | Platform commands/prompts/workflows. | + +## Modification Principles + +1. **Keep entry files short; references carry long content**. This matters especially for multi-file skills like `trellis-meta`. +2. **Make trigger descriptions specific**. A description that is too broad can mis-trigger; one that is too narrow may not trigger. +3. **Keep the same semantics consistent across platforms**. File formats can differ, but behavior descriptions should match. +4. **Put project-specific capabilities in local skills**. Do not put team-private flows into public `trellis-meta`. + +If the user only wants local AI to know one more project rule, usually create a project-local skill or update `.trellis/spec/` instead of changing a Trellis built-in workflow skill. diff --git a/.claude/skills/trellis-session-insight/SKILL.md b/.claude/skills/trellis-session-insight/SKILL.md new file mode 100644 index 0000000..3670739 --- /dev/null +++ b/.claude/skills/trellis-session-insight/SKILL.md @@ -0,0 +1,81 @@ +--- +name: trellis-session-insight +description: "Reach into past AI conversation history through the `trellis mem` CLI. Use whenever the user asks 'how did we solve X last time', 'have we discussed this before', 'what was the decision on X', 'remind me what we did in this task', '上次怎么解的', '之前讨论过吗', '想起一段对话', or when starting a brainstorm that overlaps prior work, debugging a familiar bug, continuing a task across sessions, or doing a finish-work review. Returns raw past dialogue; decide for the moment whether to update spec, append to task notes, quote inline in the answer, or just internalize." +--- + +# Trellis Session Insight + +This skill teaches an AI **how to call `trellis mem`** — the project's cross-session memory feedstock — and **when reaching for it is the right move**. + +It is intentionally a **capability skill, not a workflow**. There is no fixed output file, no required write-back step, no "always run after finish-work" rule. What to do with what `mem` returns is a judgement call made in the moment of the conversation. The skill exists so the AI knows the capability is there and can decide. + +## What `trellis mem` is + +A local CLI that indexes the user's past Claude Code and Codex conversation logs (the JSONL files each platform stores under `~/.claude/projects/` and `~/.codex/sessions/`) and lets you list, search, slice by Trellis task boundaries, and dump cleaned dialogue from them. OpenCode logs are not yet indexable (provider adapter pending) — when an OpenCode session is the obvious target, surface that limitation rather than guessing. + +Nothing in `mem` is uploaded. All reads are local. + +## When to reach for it + +The bar is "would a senior teammate ask 'didn't we already talk about this?'" — those are the moments. Some concrete patterns: + +- **Brainstorm rerun risk.** Starting a new task that touches an area the user has been in before, and you want to check whether a decision was already made — before re-asking the user. +- **Familiar-bug debugging.** The current bug pattern feels like one the user reported / fixed before. Pulling the relevant past session can save a full debugging loop. +- **Cross-session continuation.** The user resumes work after a gap and says "where were we" / "继续上次的" without being specific. +- **Decision retrieval.** The user references "the decision we made about X" but the decision lives in an old brainstorm, not in any `prd.md` / `spec/`. +- **Finish-work retrospective.** When the user explicitly asks for a wrap-up of what was decided / what hurt / what surprised them in this task — not as a forced step on every finish-work. +- **Pattern-spotting across past work.** The user asks "do I keep making the same mistake on X" / "我每次都踩这个坑吗" — search across sessions answers that. + +If none of these apply, don't call `mem`. It is a tool, not a ceremony. + +## When NOT to reach for it + +- The relevant context is already in the current turn, `prd.md`, `design.md`, recent `git log`, or the open files. `mem` is for stuff that has fallen out of immediate reach. +- The user is asking about a fact in the code, not a fact from a past conversation. `git log -p` / `grep` / reading the file directly is faster and more authoritative. +- You are in a sub-agent (`trellis-implement` / `trellis-check`) whose dispatch prompt already includes the curated `implement.jsonl` / `check.jsonl` context. Adding `mem` on top usually just clutters. +- The user has explicitly said "don't dig through history, just answer what I asked". + +## What to do with what `mem` returns + +Treat the output as **raw material**, not a deliverable. Once you have it, decide based on the live conversation: + +- **Quote inline in your reply** if a specific past exchange answers the user's current question — and cite the session-id / phase so the user can verify. +- **Update `<task>/prd.md` or `<task>/design.md`** if `mem` surfaced a load-bearing decision that should have been written down but wasn't. Surface the proposed edit to the user first. +- **Append to a task-local notes file** (e.g. `<task>/notes.md` or extending an existing one) if the finding belongs to the current task's record but doesn't fit the PRD. +- **Update `.trellis/spec/`** if the finding is a project-wide convention or gotcha that would help future tasks. Run the `trellis-update-spec` skill for that — `session-insight` ends at the discovery. +- **Just absorb it** for the next few turns and answer better, without writing anything. This is often the right move for one-off recall. + +Trellis does not prescribe a single destination. Forcing every recall into a fixed file makes the file grow into noise. Let the situation decide. + +## How to call it + +Full CLI reference is in `references/cli-quick-reference.md`. The 80% case is one of: + +```bash +# Find sessions whose contents mention a keyword (project-scope is default; +# add --global to search every project on this machine). +trellis mem search "<keyword>" + +# Dump dialogue from one session, optionally filtered by phase or keyword. +trellis mem extract <session-id> --phase brainstorm +trellis mem extract <session-id> --grep "<keyword>" + +# Drill into a session: top-N hit turns + surrounding context. +trellis mem context <session-id> --turns 3 --around 2 + +# When you do not know the session id yet, start with list + filter. +trellis mem list --task <task-dir> +trellis mem projects # → list active project cwds, then narrow +``` + +Phase slicing (`--phase brainstorm|implement|all`) cuts the session at `task.py create` and `task.py start` boundaries. For a finish-work review of the current task, `--phase brainstorm` recovers the planning discussion and `--phase implement` recovers the execution loop. Default is `all`. + +## Triggering patterns + +`references/triggering-patterns.md` lists more verbatim user phrasings (English + Chinese) that should make you think "reach for `mem`" — keep that handy when training instinct. + +## Out of scope + +- `mem` does not edit code or update files. Any write-back is your decision in the moment. +- `mem` is read-only on the platform JSONL stores. It does not push or sync to remote. +- This skill does not replace `trellis-update-spec` (which is the right tool for promoting a finding into project-wide guidance) or the platform-native task / spec workflow. diff --git a/.claude/skills/trellis-session-insight/references/cli-quick-reference.md b/.claude/skills/trellis-session-insight/references/cli-quick-reference.md new file mode 100644 index 0000000..3d5f95c --- /dev/null +++ b/.claude/skills/trellis-session-insight/references/cli-quick-reference.md @@ -0,0 +1,66 @@ +# `trellis mem` CLI Reference + +Full flag reference for the five subcommands. Pin this as the authoritative source — `trellis mem help` prints the same content at runtime, so anything here that drifts is a bug. + +## Subcommands + +| Command | Purpose | +|---|---| +| `list` | List sessions. Default subcommand when none is given. | +| `search <keyword>` | Find sessions whose contents match a keyword. | +| `context <session-id>` | Drill into one session: top-N hit turns + surrounding context. Pair with `--grep` for keyword anchoring. | +| `extract <session-id>` | Dump cleaned dialogue. Combine with `--phase` / `--grep` to slice. | +| `projects` | List active project `cwd` values with session counts. Use this to discover which `--cwd` to pass to other subcommands. | + +## Flags (apply where meaningful) + +| Flag | Subcommands | Meaning | +|---|---|---| +| `--platform claude\|codex\|opencode\|all` | all | Default `all`. OpenCode adapter is currently a stub on `0.6.0-beta.*` — see "Caveats" below. | +| `--since YYYY-MM-DD` | list / search | Inclusive lower date bound. | +| `--until YYYY-MM-DD` | list / search | Inclusive upper date bound. | +| `--global` | list / search | Include sessions from every project on this machine. Default is the current project `cwd`. | +| `--cwd <path>` | list / search | Force a specific project cwd instead of inferring from where you are. | +| `--limit N` | list / search | Cap output rows. Default `50`. | +| `--grep KW` | extract / context | Filter turns by keyword. Multi-token AND when whitespace-separated. | +| `--phase brainstorm\|implement\|all` | extract | Slice session by Trellis task boundaries. `brainstorm` = `[task.py create, task.py start)`. `implement` = `[task.py start, task.py finish)` window. Default `all`. | +| `--turns N` | context | Number of hit turns to return. Default `3`. | +| `--around N` | context | Surrounding turns to include per hit. Default `1`. | +| `--max-chars N` | context | Total character budget. Default `6000` (~1500 tokens). | +| `--include-children` | search / context | Merge OpenCode sub-agent sessions into their parent session. | +| `--json` | all | Emit machine-parseable JSON instead of human-readable output. | +| `--task <task-dir>` | list | Narrow to sessions whose context-key resolved to a given task directory (uses `.trellis/.runtime/sessions/*.json`). | + +## Common one-liners + +```bash +# What past sessions discussed "deadlock" anywhere on this machine? +trellis mem search "deadlock" --global --limit 20 + +# Inside a specific session, surface the top 5 turns that mention "lock contention" +# plus 2 turns of surrounding context. +trellis mem context 5842592d --grep "lock contention" --turns 5 --around 2 + +# Recover the brainstorm window for a session — useful when continuing a task +# the user started a week ago. +trellis mem extract 5842592d --phase brainstorm + +# List every project this machine has Trellis sessions for, with counts. +trellis mem projects +``` + +## Output shapes + +- **Default human output** (no `--json`): wrapped to a terminal, with session ids highlighted and turn markers visible. Suitable to read inline but messy to paste into a markdown file. +- **`--json`**: stable schema, safe to parse and process. When piping `mem` output into a follow-up step (e.g. summarizing for a Lessons section), prefer `--json`. + +## Caveats + +- **OpenCode adapter is a stub on `0.6.0-beta.*`.** When `--platform` resolves to OpenCode (or `all` and OpenCode would be included), `mem` prints a one-line "reader unavailable" notice and continues with the other platforms. Don't promise OpenCode coverage in your reply until the adapter ships. +- **`--phase` slicing depends on `task.py create` / `task.py start` invocations appearing in the recorded bash calls of the session.** Sessions where the user ran `task.py` from a different terminal — outside the recorded AI loop — will not have phase boundaries. `--phase all` is the safe fallback. +- **`mem` indexes platform JSONL files directly.** If the user has cleared their Claude / Codex session storage, `mem` cannot recover what is no longer on disk. +- **`mem` is read-only.** No remote sync, no edits to platform JSONL. Any write you do based on `mem` findings is your own follow-up call into the editing tools available to you. + +## When you need more than this reference + +Run `trellis mem help` in the user's shell. The runtime help is authoritative and will be ahead of this reference during fast-moving beta releases. diff --git a/.claude/skills/trellis-session-insight/references/triggering-patterns.md b/.claude/skills/trellis-session-insight/references/triggering-patterns.md new file mode 100644 index 0000000..66021ca --- /dev/null +++ b/.claude/skills/trellis-session-insight/references/triggering-patterns.md @@ -0,0 +1,93 @@ +# Triggering Patterns + +Verbatim user phrasings that should make an AI reach for `trellis mem`. Calibrate instinct against these — if a user message hits one of these patterns and you do not reach for `mem`, you probably missed an obvious recall. + +Patterns are grouped by the *intent* behind the phrasing, not the surface words. The same intent shows up in different languages and registers. + +## Past-solution recall + +The user is asking "how did we (or I) solve this before". Past dialogue holds the answer; the codebase shows the result but not the reasoning. + +- "How did we solve this last time?" +- "What did we end up doing about X?" +- "We dealt with this once already, didn't we?" +- "上次怎么解的?" +- "之前是怎么搞定 X 的?" +- "我记得以前修过类似的" + +Reach: `trellis mem search "<symptom keyword>" --global --limit 10`, then `context` into the hit that looks closest. + +## Decision retrieval + +The user is referencing a decision that lives in old dialogue, not in any committed file. Look in brainstorm windows. + +- "What was the decision on X?" +- "Did we decide to use Postgres or SQLite?" +- "The rationale for choosing X over Y was…?" +- "我们当时为啥选了 X 而不是 Y?" +- "关于 X 我们之前是怎么定的?" +- "之前讨论过 X 的方案吗?" + +Reach: `trellis mem search "<decision keyword>"` to find the session, then `extract <id> --phase brainstorm` to recover the discussion. + +## Cross-session continuation + +The user resumed work after a gap and the context is implicit. + +- "Where were we?" +- "Continue from last time." +- "Pick up where we left off." +- "继续上次的" +- "我们上次做到哪了" +- "接着昨天那个任务" + +Reach: `trellis mem list --task <current-task-dir>` to find the most recent sessions tied to the active task, then `extract` the last one. + +## Familiar-bug debugging + +The current bug feels like one already seen. Past sessions probably hold the resolution path. + +- "I feel like I've hit this before." +- "Doesn't this look like that bug from last month?" +- "Same kind of timeout I had in X." +- "这个错好像之前见过" +- "这个 bug 是不是上次那个?" +- "怎么又是这个 error?" + +Reach: `trellis mem search "<error message fragment>" --global`. Anchor on a short, distinctive token from the actual error string. + +## Self-pattern spotting + +The user is asking whether they keep repeating the same kind of mistake or decision. + +- "Do I always make this mistake?" +- "How often have I run into X?" +- "Is this a recurring thing for me?" +- "我每次都踩这个坑吗?" +- "我老犯这个错?" +- "这类问题之前出现过几次?" + +Reach: `trellis mem search "<topic>" --global --limit 50` and scan the dates / projects in the listing. Optionally `extract` two or three for comparison. + +## Finish-work retrospective (on demand) + +The user explicitly wants to look back at this task — not as a forced step, only when they ask. + +- "Summarize what we did in this task." +- "What were the key decisions / surprises?" +- "Write up the lessons from this round." +- "总结一下这次的经验" +- "记一下这次踩的坑" +- "复盘下这个任务" + +Reach: identify the current task's session id (from `.trellis/.runtime/sessions/*.json` or `mem list --task <task-dir>`), then `extract <id> --phase brainstorm` and `--phase implement`. Present a summary — surface concrete file:line citations where possible. Whether to also write the summary somewhere (PRD, spec, notes file) is the user's call; offer, don't auto-write. + +## Anti-patterns: do NOT reach for `mem` here + +- "What does this function do?" → read the file. +- "Why is this test failing?" → read the test output and the file. +- "What's the right pattern for X in our codebase?" → grep / read spec files. +- "What's the latest npm version of Y?" → call `npm view`. +- "Fix this bug." → debug. Reach for `mem` only if you suspect prior context exists; otherwise it is noise. + +The bar stays: would a senior teammate ask "didn't we already talk about this?" before answering? If yes, reach for `mem`. If no, don't. diff --git a/.claude/skills/trellis-spec-bootstrap/SKILL.md b/.claude/skills/trellis-spec-bootstrap/SKILL.md new file mode 100644 index 0000000..e1650df --- /dev/null +++ b/.claude/skills/trellis-spec-bootstrap/SKILL.md @@ -0,0 +1,41 @@ +--- +name: trellis-spec-bootstrap +description: "Bootstrap project-specific Trellis coding specs with a platform-neutral single-agent workflow. Use when creating or refreshing .trellis/spec guidelines, analyzing a codebase with GitNexus, ABCoder, or source inspection, decomposing package/layer spec work, and writing real codebase-backed spec docs without placeholder text." +--- + +# Trellis Spec Bootstrap + +Use this skill to create or refresh `.trellis/spec/` guidelines from the real codebase. One capable agent owns the full loop: analyze the repository, choose the spec boundaries, write the docs, and verify the result. The workflow does not depend on a specific host, CLI, or agent brand. + +## Workflow + +1. Confirm Trellis is initialized and inspect the current `.trellis/spec/` tree. +2. Analyze the repository architecture with the best available tools: GitNexus, ABCoder, language tooling, and direct source reads. +3. Decompose the spec work by package and layer only when that reflects the actual codebase. +4. Fill or reshape the spec files with concrete patterns, file paths, examples, and anti-patterns from the project. +5. Verify that the final specs are internally consistent and contain no template placeholders. + +## Reference Routing + +| Need | Read | +|------|------| +| Repository architecture analysis | [references/repository-analysis.md](references/repository-analysis.md) | +| Spec work decomposition and task planning | [references/spec-task-planning.md](references/spec-task-planning.md) | +| Writing high-signal Trellis spec files | [references/spec-writing.md](references/spec-writing.md) | +| GitNexus and ABCoder MCP setup | [references/mcp-setup.md](references/mcp-setup.md) | + +## Operating Rules + +- Treat templates as starting points, not contracts. Delete, rename, split, or add spec files when the repository calls for it. +- Prefer source-backed rules over generic advice. Every important recommendation should point at a real file or repeated local pattern. +- Keep execution single-owner by default. Optional helper agents are an implementation detail, not a requirement or user-visible dependency. +- Do not write platform-specific instructions unless the target project already standardizes on that platform. +- Do not leave placeholder text, empty headings, or copied boilerplate in `.trellis/spec/`. + +## Done Criteria + +- `.trellis/spec/` describes the project as it exists now. +- Each relevant package or layer has practical coding guidance with real examples. +- Non-applicable template sections are removed. +- `index.md` files match the final spec file set. +- Any required setup or analysis assumptions are documented in the relevant spec or task notes. diff --git a/.claude/skills/trellis-spec-bootstrap/references/mcp-setup.md b/.claude/skills/trellis-spec-bootstrap/references/mcp-setup.md new file mode 100644 index 0000000..629fcbd --- /dev/null +++ b/.claude/skills/trellis-spec-bootstrap/references/mcp-setup.md @@ -0,0 +1,90 @@ +# MCP Setup + +GitNexus and ABCoder are recommended when bootstrapping Trellis specs because they expose architecture and AST context to the agent. They are tool choices, not platform requirements. Configure them through whatever MCP mechanism your agent host provides. + +## GitNexus + +GitNexus builds a code knowledge graph from the repository. Use it for module boundaries, execution flows, dependency relationships, blast radius, and graph queries. + +### Install and Index + +```bash +# Run from the repository root. +npx gitnexus analyze + +# Check index status. +npx gitnexus status + +# Re-index after code changes when the analysis is stale. +npx gitnexus analyze +``` + +The index is written to `.gitnexus/`. Keep embeddings only if the project already uses them; otherwise a normal index is enough for spec bootstrapping. + +### MCP Server Command + +Use this server command in the host's MCP configuration: + +```bash +npx -y gitnexus mcp +``` + +### Useful Tools + +| Tool | Purpose | +|------|---------| +| `gitnexus_query` | Find execution flows and functional areas by concept | +| `gitnexus_context` | Inspect callers, callees, references, and process participation for a symbol | +| `gitnexus_impact` | Understand blast radius before changing a symbol | +| `gitnexus_detect_changes` | Check changed symbols and affected flows before finishing | +| `gitnexus_cypher` | Run direct graph queries | +| `gitnexus_list_repos` | List indexed repositories | + +## ABCoder + +ABCoder parses code into UniAST and gives precise package, file, and node-level structure. Use it for signatures, type shapes, implementations, dependencies, and reverse references. + +### Install + +```bash +go install github.com/cloudwego/abcoder@latest +abcoder --help +``` + +### Parse Repositories + +```bash +abcoder parse /absolute/path/to/package \ + --lang typescript \ + --name package-name \ + --output ~/abcoder-asts +``` + +For monorepos, parse each package with a stable `--name` so task notes can reference the same repository names. + +### MCP Server Command + +Use this server command in the host's MCP configuration: + +```bash +abcoder mcp ~/abcoder-asts +``` + +### Useful Tools + +| Tool | Layer | Purpose | +|------|-------|---------| +| `list_repos` | 1 | List parsed repositories | +| `get_repo_structure` | 2 | Inspect packages and files | +| `get_package_structure` | 3 | Inspect nodes within a package | +| `get_file_structure` | 3 | Inspect functions, classes, types, and signatures in a file | +| `get_ast_node` | 4 | Retrieve code, dependencies, references, and implementations | + +## Verification + +After configuration, verify from the agent host that both MCP servers are visible. Then run one simple query against each server before starting the spec writing pass. + +```bash +ls .gitnexus/meta.json +ls ~/abcoder-asts/*.json +``` diff --git a/.claude/skills/trellis-spec-bootstrap/references/repository-analysis.md b/.claude/skills/trellis-spec-bootstrap/references/repository-analysis.md new file mode 100644 index 0000000..1309d29 --- /dev/null +++ b/.claude/skills/trellis-spec-bootstrap/references/repository-analysis.md @@ -0,0 +1,59 @@ +# Repository Analysis + +The goal is to discover the project's real architecture before writing rules. Do not start from generic spec templates and fill blanks. Start from the code, then let the spec structure follow. + +## Analysis Order + +1. Read the existing `.trellis/spec/` tree and note which files are templates, outdated, or already project-specific. +2. Inspect package manifests, build scripts, workspace config, and top-level documentation to identify packages and runtime layers. +3. Use GitNexus for execution flows, module clusters, dependency hubs, and impact-sensitive areas. +4. Use ABCoder or language-native tooling for exact signatures, types, class boundaries, and implementation examples. +5. Read representative source and test files directly before turning any finding into a spec rule. + +## What To Capture + +| Area | Questions | +|------|-----------| +| Package boundaries | What does each package own? What imports cross boundaries? | +| Runtime layers | Which code is CLI, backend, frontend, worker, shared library, test-only, or tooling? | +| Core abstractions | Which types, services, stores, commands, routes, or adapters define the system shape? | +| Data flow | Where does user input enter, how is it validated, and where does state persist? | +| Error handling | How are failures represented, logged, surfaced, and tested? | +| Configuration | Where do defaults, environment config, generated files, and templates live? | +| Tests | Which test styles are trusted examples for new work? | + +## GitNexus Usage + +Start broad, then inspect specific symbols: + +```text +gitnexus_query({query: "CLI command execution flow"}) +gitnexus_query({query: "template generation and migration"}) +gitnexus_context({name: "SymbolName"}) +gitnexus_cypher({query: "MATCH (n)-[r]->(m) RETURN n.name, type(r), m.name LIMIT 30"}) +``` + +Use GitNexus results to find important files and flows. Do not quote graph output as the final authority until you have checked the relevant source files. + +## ABCoder Usage + +Use ABCoder when the spec needs exact code shapes: + +```text +list_repos() +get_repo_structure({repo_name: "package-name"}) +get_file_structure({repo_name: "package-name", file_path: "src/example.ts"}) +get_ast_node({repo_name: "package-name", node_ids: [{mod_path: "...", pkg_path: "...", name: "SymbolName"}]}) +``` + +ABCoder is most valuable for documenting constructor patterns, function signatures, type contracts, and reference chains. + +## Analysis Notes + +Keep short notes while analyzing. The notes should include: + +- Package or layer name. +- Files that define the local pattern. +- Rules the spec should teach. +- Anti-patterns found in old code, comments, tests, or migration paths. +- Spec files that should be created, deleted, renamed, or merged. diff --git a/.claude/skills/trellis-spec-bootstrap/references/spec-task-planning.md b/.claude/skills/trellis-spec-bootstrap/references/spec-task-planning.md new file mode 100644 index 0000000..dca2687 --- /dev/null +++ b/.claude/skills/trellis-spec-bootstrap/references/spec-task-planning.md @@ -0,0 +1,61 @@ +# Spec Task Planning + +Use a single agent as the default execution model. The agent may create Trellis tasks for traceability, but the skill should not require a specific platform, CLI, or parallel worker model. + +## Decomposition + +Create spec work units around real ownership boundaries: + +- One package when a package has its own conventions. +- One layer when the same package has distinct frontend, backend, CLI, worker, or shared-library rules. +- One cross-cutting guide when a pattern spans packages and is not owned by one layer. + +Avoid artificial decomposition. A small library usually needs one focused spec pass, not several tasks. + +## Task Shape + +When a Trellis task is useful, write a concise PRD with these sections: + +```markdown +# Fill <package-or-layer> Trellis Specs + +## Goal +Write project-specific `.trellis/spec/` guidance for <scope>. + +## Scope +- Spec directory: +- Source directories to inspect: +- Tests to inspect: +- Out of scope: + +## Architecture Context +Summarize the concrete findings from repository analysis. + +## Files To Create Or Update +- `.trellis/spec/.../index.md` +- `.trellis/spec/.../<topic>.md` + +## Rules +- Adapt the spec file set to the real codebase. +- Use real source examples with file paths. +- Remove template-only sections that do not apply. +- Do not modify product source code unless the task explicitly asks for it. + +## Acceptance Criteria +- [ ] Specs contain concrete examples and anti-patterns from the repository. +- [ ] No placeholder text remains. +- [ ] Index files match the final spec files. +- [ ] Claims are backed by source files, tests, or project docs. +``` + +## Optional Helper Agents + +If the host supports subagents, helpers can inspect independent packages or run verification. They are optional. The main agent still owns integration and final quality. + +Helper tasks must have clear ownership: + +- Read-only research tasks may inspect any source needed for the assigned scope. +- Write tasks should own disjoint spec directories. +- Verification tasks should check placeholder removal, broken links, and consistency. + +Do not encode helper-agent names, vendor-specific commands, or platform-specific routing in the skill. Put only the required work and acceptance criteria in the task. diff --git a/.claude/skills/trellis-spec-bootstrap/references/spec-writing.md b/.claude/skills/trellis-spec-bootstrap/references/spec-writing.md new file mode 100644 index 0000000..6bc7dec --- /dev/null +++ b/.claude/skills/trellis-spec-bootstrap/references/spec-writing.md @@ -0,0 +1,70 @@ +# Spec Writing + +Trellis specs are coding guidance for future agents. They should explain how to work in this repository, not how a generic project might be organized. + +## Write From Evidence + +Each important rule should be backed by one of these: + +- A source file that demonstrates the preferred pattern. +- A test file that shows expected behavior. +- A project document that defines the convention. +- A repeated pattern across multiple files. + +Use short snippets only when they make the rule clearer. Prefer linking to the file path and naming the symbol or behavior. + +## File Structure + +Keep the spec tree aligned with the project: + +- Keep `index.md` as the navigation file for the spec directory. +- Split topics when developers would look for them independently. +- Merge topics when separate files would repeat the same rule. +- Delete template files that do not apply. +- Add new files for important local patterns the template missed. + +## Content Standards + +Good spec sections include: + +- When the rule applies. +- The local pattern to follow. +- The source or test files that prove the pattern. +- Common mistakes or anti-patterns. +- Verification commands or checks when they are specific and reliable. + +Avoid: + +- Placeholder prose. +- Generic framework advice. +- Tool instructions that only work in one agent host. +- Long copied code blocks. +- Rules based on a single accidental implementation detail. + +## Example Shape + +```markdown +## Command Handlers + +Command handlers should keep argument parsing, validation, and side effects separate. The local pattern is: + +- Parse CLI flags at the command boundary. +- Convert raw inputs into typed task options before invoking core logic. +- Keep filesystem writes in the command or service layer, not in template helpers. + +Reference files: +- `packages/cli/src/commands/example.ts` +- `packages/cli/test/commands/example.test.ts` + +Avoid passing raw `process.argv` or unvalidated config objects into shared helpers. +``` + +## Final Pass + +Before finishing: + +```bash +grep -R "To be filled\\|TODO: fill\\|placeholder" .trellis/spec +``` + +Also check links, index files, and whether any spec still describes a template rather than this repository. diff --git a/.claude/skills/trellis-update-spec/SKILL.md b/.claude/skills/trellis-update-spec/SKILL.md new file mode 100644 index 0000000..557bc4e --- /dev/null +++ b/.claude/skills/trellis-update-spec/SKILL.md @@ -0,0 +1,356 @@ +--- +name: trellis-update-spec +description: "Captures executable contracts and coding conventions into .trellis/spec/ documents. Use when learning something valuable from debugging, implementing, or discussion that should be preserved for future sessions." +--- + +# Update Code-Spec - Capture Executable Contracts + +When you learn something valuable (from debugging, implementing, or discussion), use this to update the relevant code-spec documents. + +**Timing**: After completing a task, fixing a bug, or discovering a new pattern + +--- + +## Code-Spec First Rule (CRITICAL) + +In this project, "spec" for implementation work means **code-spec**: +- Executable contracts (not principle-only text) +- Concrete signatures, payload fields, env keys, and boundary behavior +- Testable validation/error behavior + +If the change touches infra or cross-layer contracts, code-spec depth is mandatory. + +### Mandatory Triggers + +Apply code-spec depth when the change includes any of: +- New/changed command or API signature +- Cross-layer request/response contract change +- Database schema/migration change +- Infra integration (storage, queue, cache, secrets, env wiring) + +### Mandatory Output (7 Sections) + +For triggered tasks, include all sections below: +1. Scope / Trigger +2. Signatures (command/API/DB) +3. Contracts (request/response/env) +4. Validation & Error Matrix +5. Good/Base/Bad Cases +6. Tests Required (with assertion points) +7. Wrong vs Correct (at least one pair) + +--- + +## When to Update Code-Specs + +| Trigger | Example | Target Spec | +|---------|---------|-------------| +| **Implemented a feature** | Added a new integration or module | Relevant spec file | +| **Made a design decision** | Chose extensibility pattern over simplicity | Relevant spec + "Design Decisions" section | +| **Fixed a bug** | Found a subtle issue with error handling | Relevant spec (e.g., error-handling docs) | +| **Discovered a pattern** | Found a better way to structure code | Relevant spec file | +| **Hit a gotcha** | Learned that X must be done before Y | Relevant spec + "Common Mistakes" section | +| **Established a convention** | Team agreed on naming pattern | Quality guidelines | +| **New thinking trigger** | "Don't forget to check X before doing Y" | `guides/*.md` (as a checklist item) | + +**Key Insight**: Code-spec updates are NOT just for problems. Every feature implementation contains design decisions and contracts that future AI/developers need to execute safely. + +--- + +## Spec Structure Overview + +``` +.trellis/spec/ +├── <layer>/ # Per-layer coding standards (e.g., backend/, frontend/, api/) +│ ├── index.md # Overview and links +│ └── *.md # Topic-specific guidelines +└── guides/ # Thinking checklists (NOT coding specs!) + ├── index.md # Guide index + └── *.md # Topic-specific guides +``` + +### CRITICAL: Code-Spec vs Guide - Know the Difference + +| Type | Location | Purpose | Content Style | +|------|----------|---------|---------------| +| **Code-Spec** | `<layer>/*.md` | Tell AI "how to implement safely" | Signatures, contracts, matrices, cases, test points | +| **Guide** | `guides/*.md` | Help AI "what to think about" | Checklists, questions, pointers to specs | + +**Decision Rule**: Ask yourself: + +- "This is **how to write** the code" → Put in a spec layer directory +- "This is **what to consider** before writing" → Put in `guides/` + +**Example**: + +| Learning | Wrong Location | Correct Location | +|----------|----------------|------------------| +| "Use API X not API Y for this task" | ❌ `guides/` (too specific for a thinking guide) | ✅ Relevant spec file (concrete convention) | +| "Remember to check X when doing Y" | ❌ Spec file (too abstract for a spec) | ✅ `guides/` (thinking checklist) | + +**Guides should be short checklists that point to specs**, not duplicate the detailed rules. + +--- + +## Update Process + +### Step 1: Identify What You Learned + +Answer these questions: + +1. **What did you learn?** (Be specific) +2. **Why is it important?** (What problem does it prevent?) +3. **Where does it belong?** (Which spec file?) + +### Step 2: Classify the Update Type + +| Type | Description | Action | +|------|-------------|--------| +| **Design Decision** | Why we chose approach X over Y | Add to "Design Decisions" section | +| **Project Convention** | How we do X in this project | Add to relevant section with examples | +| **New Pattern** | A reusable approach discovered | Add to "Patterns" section | +| **Forbidden Pattern** | Something that causes problems | Add to "Anti-patterns" or "Don't" section | +| **Common Mistake** | Easy-to-make error | Add to "Common Mistakes" section | +| **Convention** | Agreed-upon standard | Add to relevant section | +| **Gotcha** | Non-obvious behavior | Add warning callout | + +### Step 3: Read the Target Code-Spec + +Before editing, read the current code-spec to: +- Understand existing structure +- Avoid duplicating content +- Find the right section for your update + +```bash +cat .trellis/spec/<category>/<file>.md +``` + +### Step 4: Make the Update + +Follow these principles: + +1. **Be Specific**: Include concrete examples, not just abstract rules +2. **Explain Why**: State the problem this prevents +3. **Show Contracts**: Add signatures, payload fields, and error behavior +4. **Show Code**: Add code snippets for key patterns +5. **Keep it Short**: One concept per section + +### Step 5: Update the Index (if needed) + +If you added a new section or the code-spec status changed, update the category's `index.md`. + +--- + +## Update Templates + +### Mandatory Template for Infra/Cross-Layer Work + +```markdown +## Scenario: <name> + +### 1. Scope / Trigger +- Trigger: <why this requires code-spec depth> + +### 2. Signatures +- Backend command/API/DB signature(s) + +### 3. Contracts +- Request fields (name, type, constraints) +- Response fields (name, type, constraints) +- Environment keys (required/optional) + +### 4. Validation & Error Matrix +- <condition> -> <error> + +### 5. Good/Base/Bad Cases +- Good: ... +- Base: ... +- Bad: ... + +### 6. Tests Required +- Unit/Integration/E2E with assertion points + +### 7. Wrong vs Correct +#### Wrong +... +#### Correct +... +``` + +### Adding a Design Decision + +```markdown +### Design Decision: [Decision Name] + +**Context**: What problem were we solving? + +**Options Considered**: +1. Option A - brief description +2. Option B - brief description + +**Decision**: We chose Option X because... + +**Example**: +\`\`\`typescript +// How it's implemented +code example +\`\`\` + +**Extensibility**: How to extend this in the future... +``` + +### Adding a Project Convention + +```markdown +### Convention: [Convention Name] + +**What**: Brief description of the convention. + +**Why**: Why we do it this way in this project. + +**Example**: +\`\`\`typescript +// How to follow this convention +code example +\`\`\` + +**Related**: Links to related conventions or specs. +``` + +### Adding a New Pattern + +```markdown +### Pattern Name + +**Problem**: What problem does this solve? + +**Solution**: Brief description of the approach. + +**Example**: +\`\`\` +// Good +code example + +// Bad +code example +\`\`\` + +**Why**: Explanation of why this works better. +``` + +### Adding a Forbidden Pattern + +```markdown +### Don't: Pattern Name + +**Problem**: +\`\`\` +// Don't do this +bad code example +\`\`\` + +**Why it's bad**: Explanation of the issue. + +**Instead**: +\`\`\` +// Do this instead +good code example +\`\`\` +``` + +### Adding a Common Mistake + +```markdown +### Common Mistake: Description + +**Symptom**: What goes wrong + +**Cause**: Why this happens + +**Fix**: How to correct it + +**Prevention**: How to avoid it in the future +``` + +### Adding a Gotcha + +```markdown +> **Warning**: Brief description of the non-obvious behavior. +> +> Details about when this happens and how to handle it. +``` + +--- + +## Interactive Mode + +If you're unsure what to update, answer these prompts: + +1. **What did you just finish?** + - [ ] Fixed a bug + - [ ] Implemented a feature + - [ ] Refactored code + - [ ] Had a discussion about approach + +2. **What did you learn or decide?** + - Design decision (why X over Y) + - Project convention (how we do X) + - Non-obvious behavior (gotcha) + - Better approach (pattern) + +3. **Would future AI/developers need to know this?** + - To understand how the code works → Yes, update spec + - To maintain or extend the feature → Yes, update spec + - To avoid repeating mistakes → Yes, update spec + - Purely one-off implementation detail → Maybe skip + +4. **Which area does it relate to?** + - [ ] Backend code + - [ ] Frontend code + - [ ] Cross-layer data flow + - [ ] Code organization/reuse + - [ ] Quality/testing + +--- + +## Quality Checklist + +Before finishing your code-spec update: + +- [ ] Is the content specific and actionable? +- [ ] Did you include a code example? +- [ ] Did you explain WHY, not just WHAT? +- [ ] Did you include executable signatures/contracts? +- [ ] Did you include validation and error matrix? +- [ ] Did you include Good/Base/Bad cases? +- [ ] Did you include required tests with assertion points? +- [ ] Is it in the right code-spec file? +- [ ] Does it duplicate existing content? +- [ ] Would a new team member understand it? + +--- + +## Relationship to Other Commands + +``` +Development Flow: + Learn something → /trellis:update-spec → Knowledge captured + ↑ ↓ + /trellis:break-loop ←──────────────────── Future sessions benefit + (deep bug analysis) +``` + +- `/trellis:break-loop` - Analyzes bugs deeply, often reveals spec updates needed +- `/trellis:update-spec` - Actually makes the updates +- `/trellis:finish-work` - Reminds you to check if specs need updates + +--- + +## Core Philosophy + +> **Code-specs are living documents. Every debugging session, every "aha moment" is an opportunity to make the implementation contract clearer.** + +The goal is **institutional memory**: +- What one person learns, everyone benefits from +- What AI learns in one session, persists to future sessions +- Mistakes become documented guardrails diff --git a/.codex/agents/trellis-check.toml b/.codex/agents/trellis-check.toml new file mode 100644 index 0000000..820f50c --- /dev/null +++ b/.codex/agents/trellis-check.toml @@ -0,0 +1,84 @@ +name = "trellis-check" +description = "Workspace-write Trellis reviewer that self-fixes spec drift, lint/type-check failures, and missing tests." +sandbox_mode = "workspace-write" + +developer_instructions = """ +## Required: Load Trellis Context First + +This platform does NOT auto-inject task context via hook. Before doing anything else, you MUST load context yourself. + +### Step 1: Find the active task path + +Try in order — stop at the first one that yields a task path: + +1. **Look at the dispatch prompt** you received from the main agent. If its first line is `Active task: <path>` (e.g. `Active task: .trellis/tasks/04-17-foo`), use that path. The main agent is required to include this line on class-2 platforms. +2. **Run** `python ./.trellis/scripts/task.py current --source` and read the `Current task:` line. +3. **If both fail** (no `Active task:` line in the prompt and `task.py current` returns no task), ask the user which task to work on; do NOT guess. + +### Step 2: Load task context from the resolved path + +1. Read `<task-path>/check.jsonl` — JSONL list of spec/research files relevant to this agent. +2. For each entry in the JSONL, Read its `file` path — these are the specs and research notes you must follow. + **Skip rows without a `"file"` field** (e.g. `{"_example": "..."}` seed rows left over from `task.py create` before the curator ran). +3. Read the task's `prd.md` (requirements), then `design.md` if present (technical design), then `implement.md` if present (execution plan). + +If `check.jsonl` has no curated entries (only a seed row, or the file is missing), fall back to: read the task artifacts, list available specs with `python ./.trellis/scripts/get_context.py --mode packages`, and pick the specs that match the task domain yourself. Do NOT block on the missing jsonl — lightweight tasks may be PRD-only, while complex tasks may also include `design.md` and `implement.md`. + +If the resolved task path has no `prd.md`, ask the user what to work on; do NOT proceed without context. + +--- + +You are running as the `trellis-check` sub-agent. The main session has dispatched you to review and self-fix. + +CRITICAL — Recursion guard (read first): +- You MUST NOT spawn another `trellis-check` or `trellis-implement` sub-agent. Do the review and fixes directly in this turn. +- Any guidance you read in injected SessionStart context, `<guidelines>` blocks, workflow-state breadcrumbs, or workflow.md that says "dispatch trellis-implement" / "dispatch trellis-check" applies to the MAIN session, NOT to you. You are already the dispatched reviewer — that instruction is satisfied by your existence. +- Only the main session is allowed to dispatch `trellis-implement` / `trellis-check`. If more implementation work is needed, surface that as a recommendation in your final report instead of spawning. + +--- + +You are the Trellis reviewer agent. + +Your job is to review code changes against specs AND fix issues directly — not just report them. You have write access; use it. + +Review checklist: +- Verify behavior against the actual code paths, not assumptions. +- Look for missing template/update/detection touch points when platform config changes. +- Check whether tests should be added or updated. +- Check whether `.trellis/spec/` docs need sync after implementation. +- Run lint and type-check; fix any failures. +- Prefer concrete findings over speculative warnings. + +When you find an issue: +1. Fix it directly using edit/write tools. +2. Re-run lint and type-check until green. +3. Record what you changed and why. + +Output format: +## Findings (fixed) +- File: <path> +- Issue: <what was wrong> +- Fix: <what you changed> + +## Findings (not fixed) +Only list issues you could not self-fix (e.g. missing product decision, out-of-scope). Explain why. + +## Verification +- Lint: pass/fail +- TypeCheck: pass/fail +- Tests: pass/fail (if applicable) + +If no issues are found, say so explicitly after verifying lint/type-check pass. +""" + +# Disable Codex collab tools entirely for this sub-agent. With both +# multi_agent and multi_agent_v2 off, `spawn_agent` / `wait_agent` / +# `list_agents` / `close_agent` are not registered in the sub-agent's tool +# list at all — the model literally cannot call them. This is the structural +# fix for the wait_agent self-deadlock when the parent inherits its +# transcript via Codex's default `fork_turns="all"` (#240 follow-up, #241). +[features] +multi_agent = false + +[features.multi_agent_v2] +enabled = false diff --git a/.codex/agents/trellis-implement.toml b/.codex/agents/trellis-implement.toml new file mode 100644 index 0000000..e4b753b --- /dev/null +++ b/.codex/agents/trellis-implement.toml @@ -0,0 +1,65 @@ +name = "trellis-implement" +description = "Workspace-write Trellis implementer that follows specs and keeps generated templates in sync." +sandbox_mode = "workspace-write" + +developer_instructions = """ +## Required: Load Trellis Context First + +This platform does NOT auto-inject task context via hook. Before doing anything else, you MUST load context yourself. + +### Step 1: Find the active task path + +Try in order — stop at the first one that yields a task path: + +1. **Look at the dispatch prompt** you received from the main agent. If its first line is `Active task: <path>` (e.g. `Active task: .trellis/tasks/04-17-foo`), use that path. The main agent is required to include this line on class-2 platforms. +2. **Run** `python ./.trellis/scripts/task.py current --source` and read the `Current task:` line. +3. **If both fail** (no `Active task:` line in the prompt and `task.py current` returns no task), ask the user which task to work on; do NOT guess. + +### Step 2: Load task context from the resolved path + +1. Read `<task-path>/implement.jsonl` — JSONL list of spec/research files relevant to this agent. +2. For each entry in the JSONL, Read its `file` path — these are the specs and research notes you must follow. + **Skip rows without a `"file"` field** (e.g. `{"_example": "..."}` seed rows left over from `task.py create` before the curator ran). +3. Read the task's `prd.md` (requirements), then `design.md` if present (technical design), then `implement.md` if present (execution plan). + +If `implement.jsonl` has no curated entries (only a seed row, or the file is missing), fall back to: read the task artifacts, list available specs with `python ./.trellis/scripts/get_context.py --mode packages`, and pick the specs that match the task domain yourself. Do NOT block on the missing jsonl — lightweight tasks may be PRD-only, while complex tasks may also include `design.md` and `implement.md`. + +If the resolved task path has no `prd.md`, ask the user what to work on; do NOT proceed without context. + +--- + +You are running as the `trellis-implement` sub-agent. The main session has dispatched you to do the work. + +CRITICAL — Recursion guard (read first): +- You MUST NOT spawn another `trellis-implement` or `trellis-check` sub-agent. Do the implementation work directly in this turn. +- Any guidance you read in injected SessionStart context, `<guidelines>` blocks, workflow-state breadcrumbs, or workflow.md that says "dispatch trellis-implement" / "dispatch trellis-check" applies to the MAIN session, NOT to you. You are already the dispatched implementer — that instruction is satisfied by your existence. +- Only the main session is allowed to dispatch `trellis-implement` / `trellis-check`. If more parallel work is needed, surface that as a recommendation in your final report instead of spawning. + +--- + +You are the Trellis implementer agent. + +Rules: +- Read before write. Follow `.trellis/spec/` guidance relevant to the task. +- Keep changes focused on the requested scope. +- When touching platform registries or template lists, search first so you do not miss mirrored update paths. +- If you modify `.trellis/scripts/`, keep `packages/cli/src/templates/trellis/scripts/` in sync. +- Do not make destructive git changes unless explicitly asked. + +Before finishing, summarize: +- Files changed +- Tests/checks run +- Remaining risks or follow-ups +""" + +# Disable Codex collab tools entirely for this sub-agent. With both +# multi_agent and multi_agent_v2 off, `spawn_agent` / `wait_agent` / +# `list_agents` / `close_agent` are not registered in the sub-agent's tool +# list at all — the model literally cannot call them. This is the structural +# fix for the wait_agent self-deadlock when the parent inherits its +# transcript via Codex's default `fork_turns="all"` (#240 follow-up, #241). +[features] +multi_agent = false + +[features.multi_agent_v2] +enabled = false diff --git a/.codex/agents/trellis-research.toml b/.codex/agents/trellis-research.toml new file mode 100644 index 0000000..69227da --- /dev/null +++ b/.codex/agents/trellis-research.toml @@ -0,0 +1,73 @@ +name = "trellis-research" +description = "Trellis researcher for specs, code patterns, and affected files. Writes findings into {TASK_DIR}/research/ — read-only elsewhere." +sandbox_mode = "workspace-write" + +developer_instructions = """ +You are the Trellis researcher agent. + +## Core principle + +Conversations get compacted; files don't. Every research topic MUST be +persisted to `{TASK_DIR}/research/<topic>.md`. Returning findings only +through the chat reply is a failure. + +## Workflow + +1. Run `python ./.trellis/scripts/task.py current --source` to get the + active task path and source. If no active task is set, ask the user + where to write output; do not guess. +2. Run `mkdir -p <TASK_DIR>/research` to ensure the directory exists. +3. Read `.trellis/workflow.md`, relevant `.trellis/spec/` files, and + target code before forming an opinion. +4. For each research topic, write `<TASK_DIR>/research/<slug>.md` with: + - Query, scope, date + - Files found (path + one-line description) + - Code patterns (cite file:line) + - External references (docs, versions) + - Related specs + - Caveats / not-found notes +5. Reply with only: list of files written, one-line summary per file, + any critical caveats. Do not paste full research into the reply. + +## Scope limits + +Write allowed ONLY in `{TASK_DIR}/research/`. + +Write forbidden everywhere else: +- Code files (`src/`, `lib/`, …) +- Spec files (`.trellis/spec/`) — use `update-spec` skill instead +- `.trellis/scripts/`, `.trellis/workflow.md`, platform config +- Other task directories +- Any git operation + +If the user asks you to edit code, decline and tell them to spawn the +`implement` agent. + +## Output format for each research file + +``` +# Research: <topic> + +- Query: ... +- Scope: internal / external / mixed +- Date: YYYY-MM-DD + +## Findings +... + +## Caveats / Not Found +... +``` +""" + +# Disable Codex collab tools entirely for this sub-agent. With both +# multi_agent and multi_agent_v2 off, `spawn_agent` / `wait_agent` / +# `list_agents` / `close_agent` are not registered in the sub-agent's tool +# list at all — the model literally cannot call them. This is the structural +# fix for the wait_agent self-deadlock when the parent inherits its +# transcript via Codex's default `fork_turns="all"` (#240 follow-up, #241). +[features] +multi_agent = false + +[features.multi_agent_v2] +enabled = false diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 0000000..6432832 --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python -X utf8 .codex/hooks/inject-workflow-state.py", + "timeout": 15 + } + ] + } + ] + } +} diff --git a/.codex/hooks/inject-workflow-state.py b/.codex/hooks/inject-workflow-state.py new file mode 100644 index 0000000..fda556b --- /dev/null +++ b/.codex/hooks/inject-workflow-state.py @@ -0,0 +1,363 @@ +#!/usr/bin/env python3 +"""Trellis per-turn breadcrumb hook (UserPromptSubmit / BeforeAgent equivalent). + +Runs on every user prompt. Resolves the active task through Trellis' +session-aware active task resolver and emits a short <workflow-state> +block reminding the main AI what task is active and its expected flow. + +The emitted ``hookEventName`` field is platform-aware: most hosts expect +``UserPromptSubmit`` (Claude Code naming, also accepted by Cursor / Qoder / +CodeBuddy / Droid / Codex / Copilot wiring), but Gemini CLI 0.40.x renamed +its per-turn event to ``BeforeAgent`` and its schema validator rejects the +legacy name. ``_detect_platform`` picks the right value at runtime. +Breadcrumb text is pulled exclusively from workflow.md +[workflow-state:STATUS] tag blocks — workflow.md is the single source of +truth. There are no fallback dicts in this script: when workflow.md is +missing or a tag is absent, the breadcrumb degrades to a generic +"Refer to workflow.md for current step." line so users see (and fix) +the broken state instead of the hook silently masking it. + +Shared across all hook-capable platforms (Claude, Cursor, Codex, Qoder, +CodeBuddy, Droid, Gemini, Copilot). Kiro is not wired (no per-turn +hook entry point). Written to each platform's hooks directory via +writeSharedHooks() at init time. + +Silent exit 0 cases (no output): + - No .trellis/ directory found (not a Trellis project) + - task.json malformed or missing status +""" +from __future__ import annotations + +import json +import os +import re +import sys +from pathlib import Path + +# Force UTF-8 on stdin/stdout/stderr on Windows. Default codepage there is +# cp936 / cp1252 / etc. — non-ASCII content (Chinese task names, prd snippets) +# both in stdin (hook payload from host CLI) and stdout (our emitted blocks) +# raises UnicodeDecodeError / UnicodeEncodeError. Equivalent to `python -X utf8` +# but applied per-stream so we don't depend on host CLI's command wiring. +if sys.platform.startswith("win"): + import io as _io + for _stream_name in ("stdin", "stdout", "stderr"): + _stream = getattr(sys, _stream_name, None) + if _stream is None: + continue + if hasattr(_stream, "reconfigure"): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] + except Exception: + pass + elif hasattr(_stream, "detach"): + try: + setattr(sys, _stream_name, _io.TextIOWrapper(_stream.detach(), encoding="utf-8", errors="replace")) + except Exception: + pass +from typing import Optional + + +# Bootstrap notice for Codex while the session has no active task. Codex does not +# get the full SessionStart overview; this short reminder points the main session +# at the start skill once and leaves the per-turn state block compact. +CODEX_NO_TASK_BOOTSTRAP_NOTICE = """<trellis-bootstrap> +If you have not already loaded Trellis context this session, read the `trellis-start` skill once. +</trellis-bootstrap>""" + + +# --------------------------------------------------------------------------- +# CWD-robust Trellis root discovery (fixes hook-path-robustness for this hook) +# --------------------------------------------------------------------------- + +def find_trellis_root(start: Path) -> Optional[Path]: + """Walk up from start to find directory containing .trellis/. + + Handles CWD drift: subdirectory launches, monorepo packages, etc. + Returns None if no .trellis/ found (silent no-op). + """ + cur = start.resolve() + while cur != cur.parent: + if (cur / ".trellis").is_dir(): + return cur + cur = cur.parent + return None + + +# --------------------------------------------------------------------------- +# Active task discovery +# --------------------------------------------------------------------------- + +def _detect_platform(input_data: dict) -> str | None: + if isinstance(input_data.get("cursor_version"), str): + return "cursor" + env_map = { + "CLAUDE_PROJECT_DIR": "claude", + "CURSOR_PROJECT_DIR": "cursor", + "CODEBUDDY_PROJECT_DIR": "codebuddy", + "FACTORY_PROJECT_DIR": "droid", + "GEMINI_PROJECT_DIR": "gemini", + "QODER_PROJECT_DIR": "qoder", + "KIRO_PROJECT_DIR": "kiro", + "COPILOT_PROJECT_DIR": "copilot", + } + for env_name, platform in env_map.items(): + if os.environ.get(env_name): + return platform + script_parts = set(Path(sys.argv[0]).parts) + if ".claude" in script_parts: + return "claude" + if ".cursor" in script_parts: + return "cursor" + if ".codex" in script_parts: + return "codex" + if ".gemini" in script_parts: + return "gemini" + if ".qoder" in script_parts: + return "qoder" + if ".codebuddy" in script_parts: + return "codebuddy" + if ".factory" in script_parts: + return "droid" + if ".kiro" in script_parts: + return "kiro" + return None + + +def _resolve_active_task(root: Path, input_data: dict): + scripts_dir = root / ".trellis" / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + from common.active_task import resolve_active_task # type: ignore[import-not-found] + + return resolve_active_task(root, input_data, platform=_detect_platform(input_data)) + + +def get_active_task(root: Path, input_data: dict) -> Optional[tuple[str, str, str]]: + """Return (task_id, status, source) from the current active task.""" + active = _resolve_active_task(root, input_data) + if not active.task_path: + return None + + task_dir = Path(active.task_path) + if not task_dir.is_absolute(): + task_dir = root / task_dir + if active.stale: + return task_dir.name, f"stale_{active.source_type}", active.source + + task_json = task_dir / "task.json" + if not task_json.is_file(): + return None + try: + data = json.loads(task_json.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return None + + task_id = data.get("id") or task_dir.name + status = data.get("status", "") + if not isinstance(status, str) or not status: + return None + return task_id, status, active.source + + +# --------------------------------------------------------------------------- +# Breadcrumb loading: parse workflow.md, fall back to hardcoded defaults +# --------------------------------------------------------------------------- + +# Supports STATUS values with letters, digits, underscores, hyphens +# (so "in-review" / "blocked-by-team" work alongside "in_progress"). +_TAG_RE = re.compile( + r"\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n(.*?)\n\s*\[/workflow-state:\1\]", + re.DOTALL, +) + +def load_breadcrumbs(root: Path) -> dict[str, str]: + """Parse workflow.md for [workflow-state:STATUS] blocks. + + Returns {status: body_text}. workflow.md is the single source of + truth — there are no fallback dicts in this script. Missing tags + (or a missing/unreadable workflow.md) fall back to a generic line + in build_breadcrumb so users see the broken state and fix + workflow.md, rather than the hook silently masking the issue. + """ + workflow = root / ".trellis" / "workflow.md" + if not workflow.is_file(): + return {} + try: + content = workflow.read_text(encoding="utf-8") + except OSError: + return {} + + result: dict[str, str] = {} + for match in _TAG_RE.finditer(content): + status = match.group(1) + body = match.group(2).strip() + if body: + result[status] = body + return result + + +def _read_trellis_config(root: Path) -> dict: + """Load .trellis/config.yaml via the bundled trellis_config helper. + + The helper lives in .trellis/scripts/common; the hook lives outside the + scripts tree, so we extend sys.path before importing. + """ + scripts_dir = root / ".trellis" / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + try: + from common.trellis_config import read_trellis_config # type: ignore[import-not-found] + except Exception: + return {} + try: + return read_trellis_config(root) + except Exception: + return {} + + +def _codex_mode_banner(config: dict) -> str: + """Emit a `<codex-mode>` banner for the additionalContext payload. + + Reads `codex.dispatch_mode` from .trellis/config.yaml; defaults to + `inline` when missing or invalid because Codex sub-agents run with + `fork_turns="none"` isolation and can't inherit the parent session's + task context. The banner makes the active mode explicit to Codex AI + per turn, complementing the workflow-state body which is per-status. + Mode tells AI which dispatch protocol to follow; workflow-state tells + AI what step it's at. + """ + mode = "inline" + if isinstance(config, dict): + codex_cfg = config.get("codex") + if isinstance(codex_cfg, dict): + cfg_mode = codex_cfg.get("dispatch_mode") + if cfg_mode in ("inline", "sub-agent"): + mode = cfg_mode + if mode == "sub-agent": + meaning = ( + "sub-agent: implement/check work defaults to Trellis sub-agents; " + "the main session still coordinates, clarifies, updates specs, commits, and finishes." + ) + else: + meaning = ( + "inline: the main session implements/checks directly; " + "do not dispatch implement/check sub-agents." + ) + return f"<codex-mode>{meaning}</codex-mode>" + + +def resolve_breadcrumb_key( + status: str, platform: str | None, config: dict +) -> str: + """Pick the breadcrumb tag key based on Codex dispatch_mode. + + Codex defaults to ``inline`` because sub-agents run with ``fork_turns="none"`` + isolation and can't inherit the parent session's task context. Users can + opt into ``codex.dispatch_mode: sub-agent`` in ``.trellis/config.yaml`` + to use the parallel ``<status>-inline`` tag → ``<status>`` flip. Invalid + or missing values fall back to inline. + + Non-codex platforms return the plain status unchanged. + """ + if platform == "codex": + mode = "inline" + if isinstance(config, dict): + codex_cfg = config.get("codex") + if isinstance(codex_cfg, dict): + cfg_mode = codex_cfg.get("dispatch_mode") + if cfg_mode in ("inline", "sub-agent"): + mode = cfg_mode + return f"{status}-inline" if mode == "inline" else status + return status + + +def build_breadcrumb( + task_id: Optional[str], + status: str, + templates: dict[str, str], + source: str | None = None, + breadcrumb_key: str | None = None, +) -> str: + """Build the <workflow-state>...</workflow-state> block. + + - Known status (tag present in workflow.md) → detailed template body + - Unknown status (no tag, or workflow.md missing) → generic + "Refer to workflow.md for current step." line + - `no_task` pseudo-status (task_id is None) → header omits task info + """ + lookup_key = breadcrumb_key or status + body = templates.get(lookup_key) + if body is None and lookup_key != status: + body = templates.get(status) + if body is None: + body = "Refer to workflow.md for current step." + header = f"Status: {status}" if task_id is None else f"Task: {task_id} ({status})" + return f"<workflow-state>\n{header}\n{body}\n</workflow-state>" + + +# --------------------------------------------------------------------------- +# Entry +# --------------------------------------------------------------------------- + +def main() -> int: + if os.environ.get("TRELLIS_HOOKS") == "0" or os.environ.get("TRELLIS_DISABLE_HOOKS") == "1": + return 0 + + try: + data = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + data = {} + + cwd_str = data.get("cwd") or os.getcwd() + cwd = Path(cwd_str) + + root = find_trellis_root(cwd) + if root is None: + return 0 # not a Trellis project + + templates = load_breadcrumbs(root) + platform = _detect_platform(data) + config = _read_trellis_config(root) + task = get_active_task(root, data) + if task is None: + # No active task — still emit a breadcrumb nudging AI toward + # trellis-brainstorm + task.py create when user describes real work. + no_task_key = resolve_breadcrumb_key("no_task", platform, config) + breadcrumb = build_breadcrumb( + None, "no_task", templates, breadcrumb_key=no_task_key + ) + else: + task_id, status, source = task + status_key = resolve_breadcrumb_key(status, platform, config) + source_for_breadcrumb = None if platform == "codex" else source + breadcrumb = build_breadcrumb( + task_id, status, templates, source_for_breadcrumb, breadcrumb_key=status_key + ) + if platform == "codex": + parts: list[str] = [] + if task is None: + parts.append(CODEX_NO_TASK_BOOTSTRAP_NOTICE) + parts.append(_codex_mode_banner(config)) + parts.append(breadcrumb) + breadcrumb = "\n\n".join(parts) + + # Gemini CLI 0.40.x rejects "UserPromptSubmit" — its per-turn event is + # named "BeforeAgent". Other platforms (Claude/Cursor/Qoder/CodeBuddy/ + # Droid/Codex/Copilot) accept the original Claude-style name. + hook_event_name = ( + "BeforeAgent" if platform == "gemini" else "UserPromptSubmit" + ) + + output = { + "hookSpecificOutput": { + "hookEventName": hook_event_name, + "additionalContext": breadcrumb, + } + } + print(json.dumps(output)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.codex/hooks/session-start.py b/.codex/hooks/session-start.py new file mode 100644 index 0000000..4f56599 --- /dev/null +++ b/.codex/hooks/session-start.py @@ -0,0 +1,545 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Codex Session Start Hook - Inject Trellis context into Codex sessions. + +Output format follows Codex hook protocol: + stdout JSON → { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: "..." } } +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import warnings +from io import StringIO +from pathlib import Path + +# Force UTF-8 on stdin/stdout/stderr on Windows. Default codepage there is +# cp936 / cp1252 / etc. — non-ASCII content (Chinese task names, prd snippets) +# both in stdin (hook payload from host CLI) and stdout (our emitted blocks) +# raises UnicodeDecodeError / UnicodeEncodeError. Equivalent to `python -X utf8` +# but applied per-stream so we don't depend on host CLI's command wiring. +if sys.platform.startswith("win"): + import io as _io + for _stream_name in ("stdin", "stdout", "stderr"): + _stream = getattr(sys, _stream_name, None) + if _stream is None: + continue + if hasattr(_stream, "reconfigure"): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] + except Exception: + pass + elif hasattr(_stream, "detach"): + try: + setattr(sys, _stream_name, _io.TextIOWrapper(_stream.detach(), encoding="utf-8", errors="replace")) + except Exception: + pass + + +def _normalize_windows_shell_path(path_str: str) -> str: + """Normalize Unix-style shell paths to real Windows paths. + + On Windows, shells like Git Bash / MSYS2 / Cygwin may report paths like + `/d/Users/...` or `/cygdrive/d/Users/...`. `Path.resolve()` will misinterpret + these as `D:/d/Users...` on drive D: (or similar), breaking repo root + detection. + + This function is intentionally conservative: it only rewrites patterns that + unambiguously represent a drive letter mount. + """ + if not isinstance(path_str, str) or not path_str: + return path_str + + # Only relevant on Windows; keep other platforms untouched. + if not sys.platform.startswith("win"): + return path_str + + p = path_str.strip() + + # Already a Windows drive path (C:\... or C:/...) + if re.match(r"^[A-Za-z]:[\/]", p): + return p + + # MSYS/Git-Bash style: /c/Users/... or /d/Work/... + m = re.match(r"^/([A-Za-z])/(.*)", p) + if m: + drive, rest = m.group(1).upper(), m.group(2) + rest = rest.replace('/', '\\') + return f"{drive}:\\{rest}" + + # Cygwin style: /cygdrive/c/Users/... + m = re.match(r"^/cygdrive/([A-Za-z])/(.*)", p) + if m: + drive, rest = m.group(1).upper(), m.group(2) + rest = rest.replace('/', '\\') + return f"{drive}:\\{rest}" + + # WSL mounted drive (sometimes leaked into env): /mnt/c/Users/... + m = re.match(r"^/mnt/([A-Za-z])/(.*)", p) + if m: + drive, rest = m.group(1).upper(), m.group(2) + rest = rest.replace('/', '\\') + return f"{drive}:\\{rest}" + + return path_str + + +warnings.filterwarnings("ignore") + +FIRST_REPLY_NOTICE = """<first-reply-notice> +On the first visible assistant reply in this session, begin with exactly one short Chinese sentence: +Trellis SessionStart 已注入:workflow、当前任务状态、开发者身份、git 状态、active tasks、spec 索引已加载。 +Then continue directly with the user's request. This notice is one-shot: do not repeat it after the first assistant reply in the same session. +</first-reply-notice>""" + +def should_skip_injection() -> bool: + if os.environ.get("TRELLIS_HOOKS") == "0": + return True + if os.environ.get("TRELLIS_DISABLE_HOOKS") == "1": + return True + return os.environ.get("CODEX_NON_INTERACTIVE") == "1" + + +def configure_project_encoding(project_dir: Path) -> None: + """Reuse Trellis' shared Windows stdio encoding helper before JSON output.""" + scripts_dir = project_dir / ".trellis" / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + + try: + from common import configure_encoding # type: ignore[import-not-found] + + configure_encoding() + except Exception: + pass + + +def _has_curated_jsonl_entry(jsonl_path: Path) -> bool: + """Return True iff jsonl has at least one row with a ``file`` field. + + A freshly seeded jsonl only contains a ``{"_example": ...}`` row (no + ``file`` key) — that is NOT "ready". Readiness requires at least one + curated entry. Matches the contract used by ``inject-subagent-context.py``. + """ + try: + for line in jsonl_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + row = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(row, dict) and row.get("file"): + return True + except (OSError, UnicodeDecodeError): + return False + return False + + +def read_file(path: Path, fallback: str = "") -> str: + try: + return path.read_text(encoding="utf-8") + except (FileNotFoundError, PermissionError): + return fallback + + +def _resolve_context_key(project_dir: Path, hook_input: dict) -> str | None: + scripts_dir = project_dir / ".trellis" / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + try: + from common.active_task import resolve_context_key # type: ignore[import-not-found] + except Exception: + return None + return resolve_context_key(hook_input, platform="codex") + + +def _resolve_active_task(trellis_dir: Path, hook_input: dict): + scripts_dir = trellis_dir / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + from common.active_task import resolve_active_task # type: ignore[import-not-found] + + return resolve_active_task(trellis_dir.parent, hook_input, platform="codex") + + +def run_script(script_path: Path, context_key: str | None = None) -> str: + try: + env = os.environ.copy() + env["PYTHONIOENCODING"] = "utf-8" + if context_key: + env["TRELLIS_CONTEXT_ID"] = context_key + cmd = [sys.executable, "-W", "ignore", str(script_path)] + result = subprocess.run( + cmd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=5, + cwd=str(script_path.parent.parent.parent), + env=env, + ) + return result.stdout if result.returncode == 0 else "No context available" + except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError): + return "No context available" + + +def _normalize_task_ref(task_ref: str) -> str: + normalized = task_ref.strip() + if not normalized: + return "" + + path_obj = Path(normalized) + if path_obj.is_absolute(): + return str(path_obj) + + normalized = normalized.replace("\\", "/") + while normalized.startswith("./"): + normalized = normalized[2:] + + if normalized.startswith("tasks/"): + return f".trellis/{normalized}" + + return normalized + + +def _resolve_task_dir(trellis_dir: Path, task_ref: str) -> Path: + normalized = _normalize_task_ref(task_ref) + path_obj = Path(normalized) + if path_obj.is_absolute(): + return path_obj + if normalized.startswith(".trellis/"): + return trellis_dir.parent / path_obj + return trellis_dir / "tasks" / path_obj + + +def _get_task_status(trellis_dir: Path, hook_input: dict) -> str: + active = _resolve_active_task(trellis_dir, hook_input) + if not active.task_path: + return ( + "Status: NO ACTIVE TASK\n" + "Next: Classify the current turn and ask for task-creation consent " + "before creating any Trellis task." + ) + + task_ref = active.task_path + task_dir = _resolve_task_dir(trellis_dir, task_ref) + if active.stale or not task_dir.is_dir(): + return ( + f"Status: STALE POINTER\nTask: {task_ref}\n" + "Next: Task directory not found. Run: python ./.trellis/scripts/task.py finish" + ) + + task_json_path = task_dir / "task.json" + task_data: dict = {} + if task_json_path.is_file(): + try: + task_data = json.loads(task_json_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, PermissionError): + pass + + task_title = task_data.get("title", task_ref) + task_status = task_data.get("status", "unknown") + + if task_status == "completed": + return ( + f"Status: COMPLETED\nTask: {task_title}\n" + f"Next: Archive with `python ./.trellis/scripts/task.py archive {task_dir.name}` " + "or start a new task." + ) + + has_prd = (task_dir / "prd.md").is_file() + has_design = (task_dir / "design.md").is_file() + has_implement = (task_dir / "implement.md").is_file() + present = [ + name + for name in ("prd.md", "design.md", "implement.md", "implement.jsonl", "check.jsonl") + if (task_dir / name).is_file() + ] + present_line = ", ".join(present) if present else "none" + + if not has_prd: + return ( + f"Status: PLANNING\nTask: {task_title}\nPresent: {present_line}\n" + "Next: Load trellis-brainstorm and write prd.md. Stay in planning." + ) + + if task_status == "planning": + if has_design and has_implement: + next_action = "Review planning artifacts with the user before `task.py start`." + else: + next_action = ( + "Lightweight task can ask for start review with PRD-only; " + "complex task must add design.md and implement.md before `task.py start`." + ) + return ( + f"Status: PLANNING\nTask: {task_title}\nPresent: {present_line}\n" + f"Next: {next_action}" + ) + + return ( + f"Status: {task_status.upper()}\nTask: {task_title}\nPresent: {present_line}\n" + "Next: Follow the matching per-turn workflow-state. Context order is jsonl entries, " + "prd.md, design.md if present, implement.md if present." + ) + + +def _run_git(repo_root: Path, args: list[str]) -> str: + try: + result = subprocess.run( + ["git", *args], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=3, + cwd=str(repo_root), + ) + except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError): + return "" + if result.returncode != 0: + return "" + return result.stdout.strip() + + +def _format_git_state(repo_root: Path) -> str: + branch = _run_git(repo_root, ["branch", "--show-current"]) or "(detached)" + dirty_lines = [ + line for line in _run_git(repo_root, ["status", "--porcelain"]).splitlines() + if line.strip() + ] + dirty_text = "clean" if not dirty_lines else f"dirty {len(dirty_lines)} paths" + return f"Git: branch {branch}; {dirty_text}." + + +def _repo_relative(repo_root: Path, path: Path) -> str: + try: + return path.relative_to(repo_root).as_posix() + except ValueError: + return str(path) + + +def _collect_spec_index_paths(trellis_dir: Path) -> list[str]: + paths: list[str] = [] + guides_index = trellis_dir / "spec" / "guides" / "index.md" + if guides_index.is_file(): + paths.append(".trellis/spec/guides/index.md") + + spec_dir = trellis_dir / "spec" + if not spec_dir.is_dir(): + return paths + + for sub in sorted(spec_dir.iterdir()): + if not sub.is_dir() or sub.name.startswith(".") or sub.name == "guides": + continue + index_file = sub / "index.md" + if index_file.is_file(): + paths.append(f".trellis/spec/{sub.name}/index.md") + continue + for nested in sorted(sub.iterdir()): + if not nested.is_dir(): + continue + nested_index = nested / "index.md" + if nested_index.is_file(): + paths.append(f".trellis/spec/{sub.name}/{nested.name}/index.md") + + return paths + + +def _build_compact_current_state( + trellis_dir: Path, + hook_input: dict, + spec_index_paths: list[str], +) -> str: + repo_root = trellis_dir.parent + lines: list[str] = [] + + try: + from common.paths import get_active_journal_file, get_developer, get_tasks_dir, count_lines # type: ignore[import-not-found] + from common.tasks import iter_active_tasks # type: ignore[import-not-found] + except Exception: + get_active_journal_file = None # type: ignore[assignment] + get_developer = None # type: ignore[assignment] + get_tasks_dir = None # type: ignore[assignment] + count_lines = None # type: ignore[assignment] + iter_active_tasks = None # type: ignore[assignment] + + developer = get_developer(repo_root) if get_developer else None + lines.append(f"Developer: {developer or '(not initialized)'}") + lines.append(_format_git_state(repo_root)) + + active = _resolve_active_task(trellis_dir, hook_input) + if active.task_path: + task_dir = _resolve_task_dir(trellis_dir, active.task_path) + status = "unknown" + task_json = task_dir / "task.json" + if task_json.is_file(): + try: + data = json.loads(task_json.read_text(encoding="utf-8")) + if isinstance(data, dict): + status = str(data.get("status") or "unknown") + except (json.JSONDecodeError, OSError): + pass + lines.append(f"Current task: {_repo_relative(repo_root, task_dir)}; status={status}.") + else: + lines.append("Current task: none.") + + if get_tasks_dir and iter_active_tasks: + try: + task_count = sum(1 for _ in iter_active_tasks(get_tasks_dir(repo_root))) + lines.append( + f"Active tasks: {task_count} total. Use `python ./.trellis/scripts/task.py list --mine` only if needed." + ) + except Exception: + pass + + if get_active_journal_file and count_lines: + journal = get_active_journal_file(repo_root) + if journal: + lines.append( + f"Journal: {_repo_relative(repo_root, journal)}, {count_lines(journal)} / 2000 lines." + ) + + if spec_index_paths: + lines.append(f"Spec indexes: {len(spec_index_paths)} available.") + + return "\n".join(lines) + + +def _extract_range(content: str, start_header: str, end_header: str) -> str: + """Extract lines starting at `## start_header` up to (but excluding) `## end_header`.""" + lines = content.splitlines() + start: "int | None" = None + end: int = len(lines) + start_match = f"## {start_header}" + end_match = f"## {end_header}" + for i, line in enumerate(lines): + stripped = line.strip() + if start is None and stripped == start_match: + start = i + continue + if start is not None and stripped == end_match: + end = i + break + if start is None: + return "" + return "\n".join(lines[start:end]).rstrip() + + +_BREADCRUMB_TAG_RE = re.compile( + r"\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n.*?\n\s*\[/workflow-state:\1\]", + re.DOTALL, +) + + +def _strip_breadcrumb_tag_blocks(content: str) -> str: + stripped = _BREADCRUMB_TAG_RE.sub("", content) + stripped = re.sub(r"<!--.*?-->", "", stripped, flags=re.DOTALL) + stripped = re.sub(r"^\[(?!/?workflow-state:)/?[^\]\n]+\]\s*\n?", "", stripped, flags=re.MULTILINE) + return re.sub(r"\n{3,}", "\n\n", stripped).strip() + + +def _build_workflow_toc(workflow_path: Path) -> str: + """Inject only the compact Phase Index summary for SessionStart.""" + content = read_file(workflow_path) + if not content: + return "No workflow.md found" + + out_lines = [ + "# Development Workflow - Session Summary", + "Full guide: .trellis/workflow.md. Step detail: `python ./.trellis/scripts/get_context.py --mode phase --step <X.Y>`.", + "", + ] + + phases = _extract_range(content, "Phase Index", "Phase 1: Plan") + if phases: + out_lines.append(_strip_breadcrumb_tag_blocks(phases).rstrip()) + + return "\n".join(out_lines).rstrip() + + +def main() -> None: + if should_skip_injection(): + sys.exit(0) + + # Read hook input from stdin + try: + hook_input = json.loads(sys.stdin.read()) + if not isinstance(hook_input, dict): + hook_input = {} + project_dir = Path(_normalize_windows_shell_path(hook_input.get("cwd", "."))).resolve() + except (json.JSONDecodeError, KeyError): + hook_input = {} + project_dir = Path(".").resolve() + + configure_project_encoding(project_dir) + + trellis_dir = project_dir / ".trellis" + spec_index_paths = _collect_spec_index_paths(trellis_dir) + + output = StringIO() + + output.write("""<session-context> +Trellis compact SessionStart context. Use it to orient the session; load details on demand. +</session-context> + +""") + output.write(FIRST_REPLY_NOTICE) + output.write("\n\n") + + output.write("<current-state>\n") + output.write(_build_compact_current_state(trellis_dir, hook_input, spec_index_paths)) + output.write("\n</current-state>\n\n") + + output.write("<trellis-workflow>\n") + output.write(_build_workflow_toc(trellis_dir / "workflow.md")) + output.write("\n</trellis-workflow>\n\n") + + output.write("<guidelines>\n") + output.write( + "Task context order for implementation/check: jsonl entries -> `prd.md` -> " + "`design.md if present` -> `implement.md if present`. Missing optional artifacts " + "are skipped for lightweight tasks.\n\n" + ) + + if spec_index_paths: + output.write("## Available indexes (read on demand)\n") + for p in spec_index_paths: + output.write(f"- {p}\n") + output.write("\n") + + output.write( + "Discover more via: " + "`python ./.trellis/scripts/get_context.py --mode packages`\n" + ) + output.write("</guidelines>\n\n") + + task_status = _get_task_status(trellis_dir, hook_input) + output.write(f"<task-status>\n{task_status}\n</task-status>\n\n") + + output.write("""<ready> +Context loaded. Follow <task-status>. Load workflow/spec/task details only when needed. +</ready>""") + + context = output.getvalue() + result = { + "suppressOutput": True, + "systemMessage": f"Trellis context injected ({len(context)} chars)", + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": context, + }, + } + + print(json.dumps(result, ensure_ascii=False), flush=True) + + +if __name__ == "__main__": + main() diff --git a/.trellis/.gitignore b/.trellis/.gitignore new file mode 100644 index 0000000..5a991ea --- /dev/null +++ b/.trellis/.gitignore @@ -0,0 +1,32 @@ +# Developer identity (local only) +.developer + +# Current task pointer (each dev works on different task) +.current-task + +# Session/window scoped runtime state +.runtime/ + +# Ralph Loop state file +.ralph-state.json + +# Agent runtime files +.agents/ +.agent-log +.session-id + +# Task directory runtime files +.plan-log + +# Atomic update temp files +*.tmp + +# Update backup directories +.backup-* + +# Conflict resolution temp files +*.new + +# Python cache +**/__pycache__/ +**/*.pyc diff --git a/.trellis/.template-hashes.json b/.trellis/.template-hashes.json new file mode 100644 index 0000000..9ace9ed --- /dev/null +++ b/.trellis/.template-hashes.json @@ -0,0 +1,127 @@ +{ + "__version": 2, + "hashes": { + ".claude/agents/trellis-check.md": "4e4d849d91918228a288752c1196a8ad91ee090f760f04a6680319baf1f8aee5", + ".claude/agents/trellis-implement.md": "650bfb5f6bef4bdac138cde68e676631063afdf251938ec69d8f3f1504687407", + ".claude/agents/trellis-research.md": "f95e69d638266056713e79c884ead1e99d376d70284f66255b6dd139a3e712be", + ".claude/settings.json": "01226db3027908dac1260955e205877ee46c1d410912172d8bae9c53527b3b0f", + ".claude/hooks/inject-subagent-context.py": "4e7889074f93d668c6a312a7e9ddc65c8c619b17c2454bf72e5c563371c6bb98", + ".claude/hooks/inject-workflow-state.py": "f7fa9389ed7aa264597fff5de6277bec186e89a3ef539192997c6d026d88d5ec", + ".claude/hooks/session-start.py": "922691b5df26f6e9482796776dcd9d50ff3ea0aa9d0c8aa9abd2d7254b7ea38c", + ".claude/commands/trellis/continue.md": "d513aabcdb4d2d1afc75dc860bc66d1a971d64c542aa3956ec7ca583379a6ebc", + ".claude/commands/trellis/finish-work.md": "f11f661cff6d5d26dccb5e9574c3d2c7873a9dfaed7962d471ff5ea2fd48d691", + ".claude/skills/trellis-before-dev/SKILL.md": "8f897d8dd76c1eeb532cf15ba784360f45a93229f8dcd0acfc14282f014f92a0", + ".claude/skills/trellis-brainstorm/SKILL.md": "d698b386edf46abed6d0e6a14362b81287272625e06c3255e9d8d6cb810ef85a", + ".claude/skills/trellis-break-loop/SKILL.md": "35afb53fef42cd494e566f1ef170dbf442ec2be7e19931f28a14079b4dda753f", + ".claude/skills/trellis-check/SKILL.md": "2be18b6665b4da497c554acd5d76ef038cc960d25f3d4216f03e047202f2eadc", + ".claude/skills/trellis-update-spec/SKILL.md": "d975db7af166578488958751ae2c56edb827a68bddb569aa27acc3453f64e610", + ".claude/skills/trellis-meta/references/customize-local/add-project-local-conventions.md": "ef3380e71aa9f5103d37b467b1f725a8033ac516e4de31e4d790be02ec2c39e8", + ".claude/skills/trellis-meta/references/customize-local/change-agents.md": "7f2982162463f107f8b1a4fa1a41fee2bc7dbd0cc8e90c48559aba30c3ea403c", + ".claude/skills/trellis-meta/references/customize-local/change-context-loading.md": "e6aa7d938741c08b864f54e9bda8e4e68e04bc60cb46682f6acabaf37f9b2da2", + ".claude/skills/trellis-meta/references/customize-local/change-hooks.md": "c8b35dda1530de521cf6bb043188f0cbbea0c9180b1aa44e64e31e20433ef4ca", + ".claude/skills/trellis-meta/references/customize-local/change-skills-or-commands.md": "b3009ef20a4f24e5d8b196109dc9bab6bd30fc030dbc4fb796afdd2ca912e1ea", + ".claude/skills/trellis-meta/references/customize-local/change-spec-structure.md": "1a712408217ee9cc6a916d874e3d6ed1ba7a3bdc1b9dc4bb1c64393f0df1eb98", + ".claude/skills/trellis-meta/references/customize-local/change-task-lifecycle.md": "148b7442ef8106de907afd06f9d1ca96f7ec074caedced3dd4175b3a26698ca2", + ".claude/skills/trellis-meta/references/customize-local/change-workflow.md": "f7855f2db1bcb213ba843c38776ccdc1f4616ed687f84e977da2f5e6cf7195eb", + ".claude/skills/trellis-meta/references/customize-local/overview.md": "465db9cecf085b37f7aed2fc5240c92c638e937f7960ca35b0f05a780dd4fdc9", + ".claude/skills/trellis-meta/references/local-architecture/context-injection.md": "8497289bf333b3aa456f317039d1239b7ece79254aa0eb62cfc647714c866084", + ".claude/skills/trellis-meta/references/local-architecture/generated-files.md": "4356517517cef0ba7f3ba01965a4ba8953505702e4085f0797d3e36817c9669f", + ".claude/skills/trellis-meta/references/local-architecture/overview.md": "45ffd4ee95020f58201adc885f3dfc89b26483c2b350d96ca7f2f57f94d5ff5f", + ".claude/skills/trellis-meta/references/local-architecture/spec-system.md": "55f3c95033a3cbed6c06e225187071502d7dcf3f153f4becbec6aa79acc782f5", + ".claude/skills/trellis-meta/references/local-architecture/task-system.md": "25dbd2be6a2271591274b56616b853b16e6fd9f44f43073350688e89e87295a6", + ".claude/skills/trellis-meta/references/local-architecture/workflow.md": "cfcdc6e4468a5d9c816e929fcca01640cd41cfdaaa4824118b40a8e460c927b6", + ".claude/skills/trellis-meta/references/local-architecture/workspace-memory.md": "79786a1ca2980b1785a36aba8142f9d879459c47dc000c999f638e5c864d04d3", + ".claude/skills/trellis-meta/references/platform-files/agents.md": "8af9722fa637bd0cd8addd28ae5da6812a5413711924259fd4af0c2ebb447c5b", + ".claude/skills/trellis-meta/references/platform-files/hooks-and-settings.md": "6e2d6d88719c2779fe34004f63d36cff203d8f64e7fb620f7cb1cde15c37c462", + ".claude/skills/trellis-meta/references/platform-files/overview.md": "6479cd2393166b4b369b511c44b78cbc64975c8b1df96ee1d4d1bd06b75cd48d", + ".claude/skills/trellis-meta/references/platform-files/platform-map.md": "ded6751c06f31d0a701d33c9dd69c482a583539ad3ed464aaad9e705f793b212", + ".claude/skills/trellis-meta/references/platform-files/skills-and-commands.md": "85435eb8bb6921283575bca51268fc534c22fd3ca33782e841ee5c76140ae48f", + ".claude/skills/trellis-meta/SKILL.md": "942e898a6fd769a93a3ca6f43f9fe0412d0adae011654fd384e9cacbd2af4f34", + ".claude/skills/trellis-session-insight/references/cli-quick-reference.md": "b64803cee3898d70a8a9f79d701e81ebf6e96bd43ba36c0b33e2ac9182b28a5c", + ".claude/skills/trellis-session-insight/references/triggering-patterns.md": "121ecd23be83d1567e8ce15c366a81073d7a2b1d3ad616fce235c07ca1f1cc20", + ".claude/skills/trellis-session-insight/SKILL.md": "d2893a294da1fa2d920e784f7f8169bf5134d0204e6a41406f94447c498dbc8d", + ".claude/skills/trellis-spec-bootstrap/references/mcp-setup.md": "df542fc8f279edd38046d26a7c8151804b708f57b24d4aa2733cea587a88c65e", + ".claude/skills/trellis-spec-bootstrap/references/repository-analysis.md": "0dae98d774f6e34559b9f3442888ac43e3a8af110c37cbefc49ce256986858b6", + ".claude/skills/trellis-spec-bootstrap/references/spec-task-planning.md": "ef493d028c3b0807a8a534bb71fb92a68129f273db763ad27ceb464a522e799d", + ".claude/skills/trellis-spec-bootstrap/references/spec-writing.md": "e9800fe9ed4a4cd87062ea1829cf2caa8d170ec15e141678a6a30e74c497f47d", + ".claude/skills/trellis-spec-bootstrap/SKILL.md": "97bfa68c06cebb558eb4464bc1b81f7d2d56040d75baa8de1ee5ad90cca0196a", + ".agents/skills/trellis-continue/SKILL.md": "54f3148f1a15a95b149b33eb040cb818501cad4969ef75654bd149fea3ee56b6", + ".agents/skills/trellis-finish-work/SKILL.md": "79e6d165358253a7379cae647bbd50b6bf174a1107f451b768a2bb4ba7cc0b87", + ".agents/skills/trellis-before-dev/SKILL.md": "8f897d8dd76c1eeb532cf15ba784360f45a93229f8dcd0acfc14282f014f92a0", + ".agents/skills/trellis-brainstorm/SKILL.md": "d698b386edf46abed6d0e6a14362b81287272625e06c3255e9d8d6cb810ef85a", + ".agents/skills/trellis-break-loop/SKILL.md": "35afb53fef42cd494e566f1ef170dbf442ec2be7e19931f28a14079b4dda753f", + ".agents/skills/trellis-check/SKILL.md": "2be18b6665b4da497c554acd5d76ef038cc960d25f3d4216f03e047202f2eadc", + ".agents/skills/trellis-update-spec/SKILL.md": "003ce08a3404aeb50998029392c4d4e57b626edf526d3ebd585032bb92dcbb96", + ".agents/skills/trellis-meta/references/customize-local/add-project-local-conventions.md": "ef3380e71aa9f5103d37b467b1f725a8033ac516e4de31e4d790be02ec2c39e8", + ".agents/skills/trellis-meta/references/customize-local/change-agents.md": "7f2982162463f107f8b1a4fa1a41fee2bc7dbd0cc8e90c48559aba30c3ea403c", + ".agents/skills/trellis-meta/references/customize-local/change-context-loading.md": "e6aa7d938741c08b864f54e9bda8e4e68e04bc60cb46682f6acabaf37f9b2da2", + ".agents/skills/trellis-meta/references/customize-local/change-hooks.md": "c8b35dda1530de521cf6bb043188f0cbbea0c9180b1aa44e64e31e20433ef4ca", + ".agents/skills/trellis-meta/references/customize-local/change-skills-or-commands.md": "b3009ef20a4f24e5d8b196109dc9bab6bd30fc030dbc4fb796afdd2ca912e1ea", + ".agents/skills/trellis-meta/references/customize-local/change-spec-structure.md": "1a712408217ee9cc6a916d874e3d6ed1ba7a3bdc1b9dc4bb1c64393f0df1eb98", + ".agents/skills/trellis-meta/references/customize-local/change-task-lifecycle.md": "148b7442ef8106de907afd06f9d1ca96f7ec074caedced3dd4175b3a26698ca2", + ".agents/skills/trellis-meta/references/customize-local/change-workflow.md": "f7855f2db1bcb213ba843c38776ccdc1f4616ed687f84e977da2f5e6cf7195eb", + ".agents/skills/trellis-meta/references/customize-local/overview.md": "465db9cecf085b37f7aed2fc5240c92c638e937f7960ca35b0f05a780dd4fdc9", + ".agents/skills/trellis-meta/references/local-architecture/context-injection.md": "8497289bf333b3aa456f317039d1239b7ece79254aa0eb62cfc647714c866084", + ".agents/skills/trellis-meta/references/local-architecture/generated-files.md": "4356517517cef0ba7f3ba01965a4ba8953505702e4085f0797d3e36817c9669f", + ".agents/skills/trellis-meta/references/local-architecture/overview.md": "45ffd4ee95020f58201adc885f3dfc89b26483c2b350d96ca7f2f57f94d5ff5f", + ".agents/skills/trellis-meta/references/local-architecture/spec-system.md": "55f3c95033a3cbed6c06e225187071502d7dcf3f153f4becbec6aa79acc782f5", + ".agents/skills/trellis-meta/references/local-architecture/task-system.md": "25dbd2be6a2271591274b56616b853b16e6fd9f44f43073350688e89e87295a6", + ".agents/skills/trellis-meta/references/local-architecture/workflow.md": "cfcdc6e4468a5d9c816e929fcca01640cd41cfdaaa4824118b40a8e460c927b6", + ".agents/skills/trellis-meta/references/local-architecture/workspace-memory.md": "79786a1ca2980b1785a36aba8142f9d879459c47dc000c999f638e5c864d04d3", + ".agents/skills/trellis-meta/references/platform-files/agents.md": "8af9722fa637bd0cd8addd28ae5da6812a5413711924259fd4af0c2ebb447c5b", + ".agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md": "6e2d6d88719c2779fe34004f63d36cff203d8f64e7fb620f7cb1cde15c37c462", + ".agents/skills/trellis-meta/references/platform-files/overview.md": "6479cd2393166b4b369b511c44b78cbc64975c8b1df96ee1d4d1bd06b75cd48d", + ".agents/skills/trellis-meta/references/platform-files/platform-map.md": "ded6751c06f31d0a701d33c9dd69c482a583539ad3ed464aaad9e705f793b212", + ".agents/skills/trellis-meta/references/platform-files/skills-and-commands.md": "85435eb8bb6921283575bca51268fc534c22fd3ca33782e841ee5c76140ae48f", + ".agents/skills/trellis-meta/SKILL.md": "942e898a6fd769a93a3ca6f43f9fe0412d0adae011654fd384e9cacbd2af4f34", + ".agents/skills/trellis-session-insight/references/cli-quick-reference.md": "b64803cee3898d70a8a9f79d701e81ebf6e96bd43ba36c0b33e2ac9182b28a5c", + ".agents/skills/trellis-session-insight/references/triggering-patterns.md": "121ecd23be83d1567e8ce15c366a81073d7a2b1d3ad616fce235c07ca1f1cc20", + ".agents/skills/trellis-session-insight/SKILL.md": "d2893a294da1fa2d920e784f7f8169bf5134d0204e6a41406f94447c498dbc8d", + ".agents/skills/trellis-spec-bootstrap/references/mcp-setup.md": "df542fc8f279edd38046d26a7c8151804b708f57b24d4aa2733cea587a88c65e", + ".agents/skills/trellis-spec-bootstrap/references/repository-analysis.md": "0dae98d774f6e34559b9f3442888ac43e3a8af110c37cbefc49ce256986858b6", + ".agents/skills/trellis-spec-bootstrap/references/spec-task-planning.md": "ef493d028c3b0807a8a534bb71fb92a68129f273db763ad27ceb464a522e799d", + ".agents/skills/trellis-spec-bootstrap/references/spec-writing.md": "e9800fe9ed4a4cd87062ea1829cf2caa8d170ec15e141678a6a30e74c497f47d", + ".agents/skills/trellis-spec-bootstrap/SKILL.md": "97bfa68c06cebb558eb4464bc1b81f7d2d56040d75baa8de1ee5ad90cca0196a", + ".agents/skills/trellis-start/SKILL.md": "ddcb643c43e967dc7e435cb815c9eb00ddab8913190d837d8b9504424f47a81a", + ".codex/agents/trellis-check.toml": "a64bf083a35146c9b0074b98d20efcdfa9dc448f1696677bab12ea390354c1b0", + ".codex/agents/trellis-implement.toml": "719b3c332d9bec179be5cbd2728994d1add7dbfc9585944df04e58a3f434fbc5", + ".codex/agents/trellis-research.toml": "73bf9654d99ee60cec9f6d77fe60fe2e32afbdd7d3f01c8f759c131364bf3c31", + ".codex/hooks/session-start.py": "a1f2de75eb17eb419ece4854d78f1d2255e0e732e672ec8fbfb439b71a09bf11", + ".codex/hooks/inject-workflow-state.py": "f7fa9389ed7aa264597fff5de6277bec186e89a3ef539192997c6d026d88d5ec", + ".codex/hooks.json": "7bad6065612c5bd0d4e0bb587bf3c9f3950c8060f9a52f4defd7247dd9ba8aec", + ".codex/config.toml": "4224eb7df6802a623cb1bee522aed0a23ba6be862b90f1b597a313fc16864b06", + "AGENTS.md": "6cacfe99748b435d0660c2463c697bc323d53798aecf3492283ca8eac1b29682", + ".trellis/agents/check.md": "edb4f57361407249a53bf5998ebf91c40d2b969e826a2c5e1b4e813a08bcb175", + ".trellis/agents/implement.md": "66e25ad046c94869442834bc3cdfbd5a9a7412d3ff54561d64d2886552c27e87", + ".trellis/config.yaml": "3e295bf4310763240647f40b3aeee7a7c6d134142cdc826e02d850ca2407fc43", + ".trellis/scripts/add_session.py": "f26b66a539d160c739d4b88fd926b3d7f6745be326cd57131e5ef17a7b011fbe", + ".trellis/scripts/common/active_task.py": "6c88ed40ef7289bca0f6d2ecba0f8b8aef46cd58788080fbeeea88de138a431f", + ".trellis/scripts/common/cli_adapter.py": "cd844d1e84b1a09b373b3a7609e4d5606ee9d4825154c002cc9bb3f54c8e2fb9", + ".trellis/scripts/common/config.py": "25c5a53ad20d6909be5209222e4208a84528805316a4d78350529459a364edb1", + ".trellis/scripts/common/developer.py": "b2141b0145a41f8cedb4f9a24c925796edb2f0f6fde7c86b559513ec30499368", + ".trellis/scripts/common/git.py": "e14817be7de122d3a106f509c2825aeb9669d962ba73ba241642d2931cfdf1d6", + ".trellis/scripts/common/git_context.py": "fa30ced454f1a91ffc9f8b2abeb32225e3447cbdc90bad783797374eba07265d", + ".trellis/scripts/common/io.py": "6480b181f2bc505323b28ed7a66963d7b7edc96251e83b4c8e7a45907cc721c8", + ".trellis/scripts/common/log.py": "471df6895cfac80f995edebbf9974f6b7440634b7a688f28b8331c868bc0f3cf", + ".trellis/scripts/common/packages_context.py": "efe158d7c99c2268851d0216fbb08de22836e418a8dbeb73575b8cc249eed7b7", + ".trellis/scripts/common/paths.py": "05898ef136cc7c4d861b05fbf2b16d53ddd3e6f311a231d4fcfcb81bde7c45ee", + ".trellis/scripts/common/safe_commit.py": "8789bff4b30a9065469210f2efab3f59f03dddd77bef4e4b6a5bb641f93539f4", + ".trellis/scripts/common/session_context.py": "11e336b77a42e8ae080ebcea3fe45938eb8b0d1d279e968eededa57de6006db8", + ".trellis/scripts/common/tasks.py": "4436a8b0b53c270a35989e26d9dbd92669408c6562d88c02083a404562da85fe", + ".trellis/scripts/common/task_context.py": "d174684d417bbe2fafc26b6afcddb264c7dc519527bb24d2055cd27daaad9b55", + ".trellis/scripts/common/task_queue.py": "0be61f713462b1fe4574927c82fc4704e678afe72dcb9813543aedf2f9e9e0c5", + ".trellis/scripts/common/task_store.py": "b6d5089ae823fee9d53fec3d4e20449e670b7968d31ef9eac4561dd23661b64c", + ".trellis/scripts/common/task_utils.py": "f5ef4af87ba3e11d8b19630c0c96d009de1811fc9be56c2027a9c96e21ed103e", + ".trellis/scripts/common/trellis_config.py": "0839dcf90ebbd77712c276930a89335b3313927051650c91d220fb51ca2a6a3c", + ".trellis/scripts/common/types.py": "9962081cc2608fb9d1deb32c6880e336f62cdca6b338e7ae813304701e155ee9", + ".trellis/scripts/common/workflow_phase.py": "3141c0aa55109b883886221a95878fac7d0a1aedd25fb9a963c47add7383db4e", + ".trellis/scripts/common/__init__.py": "3d5e9347141f0296319a5beb29d69ae714c5a474b9078caeb3edd7c5f6562e22", + ".trellis/scripts/get_context.py": "af3ea7cd563a453227cf2cb4ab04d667390046b7febfac2217348d0892781f4b", + ".trellis/scripts/get_developer.py": "84c27076323c3e0f2c9c8ed16e8aa865e225d902a187c37e20ee1a46e7142d8f", + ".trellis/scripts/hooks/linear_sync.py": "cfc270b7ff775caa5b2434823c45414a3b37f9ba2aa1e293a26daef9fd2e577a", + ".trellis/scripts/init_developer.py": "0943f1c240993649ab89b91a2c5b379e84daa8c53b35f0490774bff05a552873", + ".trellis/scripts/task.py": "b3a43f6ef149ec8f5a288be53fe7be0a94955b78872ad7f77d0db4a1aecfa87b", + ".trellis/scripts/__init__.py": "1242be5b972094c2e141aecbe81a4efd478f6534e3d5e28306374e6a18fcf46c", + ".trellis/workflow.md": "28190a1db4ad4533c545187a8647f40003ab7b763e7bd75b3ee1c2d77909bc5e" + } +} \ No newline at end of file diff --git a/.trellis/.version b/.trellis/.version new file mode 100644 index 0000000..4124bbd --- /dev/null +++ b/.trellis/.version @@ -0,0 +1 @@ +0.6.0-beta.23 \ No newline at end of file diff --git a/.trellis/agents/check.md b/.trellis/agents/check.md new file mode 100644 index 0000000..6c1bf13 --- /dev/null +++ b/.trellis/agents/check.md @@ -0,0 +1,70 @@ +--- +name: check +description: | + Code quality auditor for the Trellis channel runtime. Reviews uncommitted diffs against task artifacts and specs, self-fixes issues, and reports verification results. +provider: claude +labels: [trellis, check] +--- + +# Check Agent (channel runtime) + +You are the Check Agent spawned by `trellis channel spawn --agent check` inside the Trellis channel runtime. You receive an `Active task: <path>` line in your inbox; use it to locate task artifacts on disk. + +## Context + +Before reviewing, read in this order: + +1. `<task-path>/check.jsonl` if present — spec manifest curated for this turn; read every listed file +2. `<task-path>/prd.md` — requirements +3. `<task-path>/design.md` if present — technical design +4. `<task-path>/implement.md` if present — execution plan +5. `.trellis/spec/` — project-wide guidelines (load only what is relevant to the diff under review) + +## Core Responsibilities + +1. **Get the diff** — `git diff` / `git diff --staged` for uncommitted changes +2. **Review against task artifacts** — does the diff satisfy `prd.md` (and `design.md` / `implement.md` if present)? +3. **Review against specs** — naming, structure, type safety, error handling, conventions in `.trellis/spec/` +4. **Self-fix** — when an issue is mechanical and small, fix it directly with the editing tools you have +5. **Run verification** — project lint and typecheck on the changed scope +6. **Report** — concrete findings with `file:line` citations and what was fixed vs. what is open + +## Forbidden Operations + +- `git commit` +- `git push` +- `git merge` + +The supervising main session owns commits. Report the post-fix state; do not commit on its behalf. + +## Workflow + +1. Run `git diff --name-only` and `git diff` to scope the changes +2. Read the task artifacts and relevant spec files +3. For each issue: + - If mechanical (lint nit, missing type, wrong import, dead branch) → fix in-place + - If a design/judgment issue → record and report, do not silently rewrite +4. Run the project's lint and typecheck on the changed scope after self-fixes +5. Report + +## Report Format + +``` +## Self-Check Complete + +### Files Checked +- <path> + +### Issues Found and Fixed +1. `<file>:<line>` — <what was wrong> → <what you changed> + +### Issues Not Fixed +- `<file>:<line>` — <issue> — <why deferred to the main session> + +### Verification Results +- TypeCheck: <pass|fail|skipped + reason> +- Lint: <pass|fail|skipped + reason> + +### Summary +Checked <N> files, found <X> issues, fixed <Y>, <X-Y> open. +``` diff --git a/.trellis/agents/implement.md b/.trellis/agents/implement.md new file mode 100644 index 0000000..3262f79 --- /dev/null +++ b/.trellis/agents/implement.md @@ -0,0 +1,71 @@ +--- +name: implement +description: | + Code implementation expert for the Trellis channel runtime. Understands specs and task artifacts, then implements features. No git commit allowed. +provider: claude +labels: [trellis, implement] +--- + +# Implement Agent (channel runtime) + +You are the Implement Agent spawned by `trellis channel spawn --agent implement` inside the Trellis channel runtime. You receive an `Active task: <path>` line in your inbox; use it to locate task artifacts on disk. + +## Context + +Before implementing, read in this order: + +1. `<task-path>/implement.jsonl` if present — spec manifest curated for this turn; read every listed file +2. `<task-path>/prd.md` — requirements +3. `<task-path>/design.md` if present — technical design +4. `<task-path>/implement.md` if present — execution plan +5. `.trellis/spec/` — project-wide guidelines (load only what is relevant to the diff you are about to write) + +## Core Responsibilities + +1. **Understand specs** — read relevant spec files in `.trellis/spec/` +2. **Understand task artifacts** — read the artifacts listed above +3. **Implement features** — write code that follows specs and existing patterns +4. **Self-check** — run lint and typecheck on the changed scope before reporting + +## Forbidden Operations + +- `git commit` +- `git push` +- `git merge` + +The supervising main session owns commits. Report what changed; do not commit on its behalf. + +## Workflow + +1. Read relevant specs based on task type and the files in `implement.jsonl` if present +2. Read the task's `prd.md`, `design.md` if present, and `implement.md` if present +3. Implement features following specs and existing patterns +4. Run the project's lint and typecheck commands on the changed scope +5. Report files touched, key decisions, and verification results back to the channel + +## Code Standards + +- Follow existing code patterns +- Don't add unnecessary abstractions +- Only do what the PRD asks for; no speculative scope expansion +- Surface uncertainty back to the channel rather than guessing + +## Report Format + +``` +## Implementation Complete + +### Files Modified +- <path> — <one-line description> + +### Implementation Summary +1. <step> +2. <step> + +### Verification Results +- Lint: <pass|fail|skipped + reason> +- TypeCheck: <pass|fail|skipped + reason> + +### Open Questions +- <if any, otherwise omit> +``` diff --git a/.trellis/config.yaml b/.trellis/config.yaml new file mode 100644 index 0000000..002a712 --- /dev/null +++ b/.trellis/config.yaml @@ -0,0 +1,110 @@ +# Trellis Configuration +# Project-level settings for the Trellis workflow system +# +# All values have sensible defaults. Only override what you need. + +#------------------------------------------------------------------------------- +# Session Recording +#------------------------------------------------------------------------------- + +# Commit message used when auto-committing journal/index changes +# after running add_session.py +session_commit_message: "chore: record journal" + +# Maximum lines per journal file before rotating to a new one +max_journal_lines: 2000 + +#------------------------------------------------------------------------------- +# Session Auto-Commit +#------------------------------------------------------------------------------- + +# Auto-commit behavior for session journal + task archive operations. +# - true (default): scripts auto-stage and auto-commit journal / task changes +# after add_session.py / task.py archive runs. +# - false: scripts do not touch git. Files (journal-*.md, task archive moves) +# are still written to disk; you decide whether to git add / commit. +# +# Use `false` if your project's .gitignore intentionally excludes `.trellis/` +# and you want session data kept local-only, or if you prefer to review +# staged changes manually before each commit. +# +# Accepts: true / false / yes / no / 1 / 0 / on / off (case-insensitive). +# +# session_auto_commit: true + +#------------------------------------------------------------------------------- +# Task Lifecycle Hooks +#------------------------------------------------------------------------------- + +# Shell commands to run after task lifecycle events. +# Each hook receives TASK_JSON_PATH environment variable pointing to task.json. +# Hook failures print a warning but do not block the main operation. +# +# hooks: +# after_create: +# - "echo 'Task created'" +# after_start: +# - "echo 'Task started'" +# after_finish: +# - "echo 'Task finished'" +# after_archive: +# - "echo 'Task archived'" + +#------------------------------------------------------------------------------- +# Monorepo / Packages +#------------------------------------------------------------------------------- + +# Declare packages for monorepo projects. +# Trellis auto-detects workspaces during `trellis init`, but you can also +# configure them manually here. +# +# packages: +# frontend: +# path: packages/frontend +# backend: +# path: packages/backend +# docs: +# path: docs-site +# type: submodule +# # For polyrepo / meta-repo layouts (independent .git in each subdir), +# # mark the package with `git: true`. The runtime treats it as an +# # independent repository for things like git-context display. +# webapp: +# path: ./webapp +# git: true + +# Default package used when --package is not specified. +# default_package: frontend + +#------------------------------------------------------------------------------- +# Channel worker OOM guard +#------------------------------------------------------------------------------- +# Default safeguards for `trellis channel spawn` workers. The guard runs +# at spawn time (cleans expired idle workers, then enforces the live-worker +# budget) and inside each supervisor (self-terminates a worker that stays +# continuously idle past `idle_timeout`). +# +# Precedence: CLI flag > env var (TRELLIS_CHANNEL_WORKER_IDLE_TIMEOUT / +# TRELLIS_CHANNEL_MAX_LIVE_WORKERS) > this config > built-in default. +# +# `idle_timeout: 0` disables idle cleanup (workers can sit idle forever +# unless explicitly killed or given `--timeout`). +# `max_live_workers: 0` disables the spawn-time budget check. +# +channel: + worker_guard: + idle_timeout: 5m + max_live_workers: 6 + +#------------------------------------------------------------------------------- +# Codex (dispatch behavior) +#------------------------------------------------------------------------------- +# Codex-only knob; other platforms ignore it. Default ("inline") makes the +# main Codex agent edit code directly because Codex sub-agents run with +# `fork_turns="none"` isolation and can't inherit the parent session's +# task context. Set to "sub-agent" to opt into the legacy dispatch model +# (main agent spawns trellis-implement / trellis-check / trellis-research +# sub-agents). +# +# codex: +# dispatch_mode: inline # or "sub-agent" to dispatch trellis-* sub-agents diff --git a/.trellis/scripts/__init__.py b/.trellis/scripts/__init__.py new file mode 100644 index 0000000..815a137 --- /dev/null +++ b/.trellis/scripts/__init__.py @@ -0,0 +1,5 @@ +""" +Trellis Python Scripts + +This module provides Python implementations of Trellis workflow scripts. +""" diff --git a/.trellis/scripts/add_session.py b/.trellis/scripts/add_session.py new file mode 100644 index 0000000..7149739 --- /dev/null +++ b/.trellis/scripts/add_session.py @@ -0,0 +1,547 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Add a new session to journal file and update index.md. + +Usage: + python add_session.py --title "Title" --commit "hash" --summary "Summary" [--package cli] + python add_session.py --title "Title" --branch "feat/my-branch" + + # Pipe detailed content via stdin (use --stdin to opt in): + cat << 'EOF' | python add_session.py --stdin --title "Title" --summary "Summary" + <session content here> + EOF + +Branch resolution order: + 1. --branch CLI arg (explicit) + 2. task.json branch field (from active task) + 3. git branch --show-current (auto-detect) + 4. None (omitted gracefully) +""" + +from __future__ import annotations + +import argparse +import re +import sys +from datetime import datetime +from pathlib import Path + +from common.paths import ( + FILE_JOURNAL_PREFIX, + get_repo_root, + get_current_task, + get_developer, + get_workspace_dir, +) +from common.developer import ensure_developer +from common.git import run_git +from common.safe_commit import ( + print_gitignore_warning, + safe_git_add, + safe_trellis_paths_to_add, +) +from common.tasks import load_task +from common.config import ( + get_packages, + get_session_auto_commit, + get_session_commit_message, + get_max_journal_lines, + is_monorepo, + resolve_package, + validate_package, +) + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +def get_latest_journal_info(dev_dir: Path) -> tuple[Path | None, int, int]: + """Get latest journal file info. + + Returns: + Tuple of (file_path, file_number, line_count). + """ + latest_file: Path | None = None + latest_num = -1 + + for f in dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"): + if not f.is_file(): + continue + + match = re.search(r"(\d+)$", f.stem) + if match: + num = int(match.group(1)) + if num > latest_num: + latest_num = num + latest_file = f + + if latest_file: + lines = len(latest_file.read_text(encoding="utf-8").splitlines()) + return latest_file, latest_num, lines + + return None, 0, 0 + + +def get_current_session(index_file: Path) -> int: + """Get current session number from index.md.""" + if not index_file.is_file(): + return 0 + + content = index_file.read_text(encoding="utf-8") + for line in content.splitlines(): + if "Total Sessions" in line: + match = re.search(r":\s*(\d+)", line) + if match: + return int(match.group(1)) + return 0 + + +def _extract_journal_num(filename: str) -> int: + """Extract journal number from filename for sorting.""" + match = re.search(r"(\d+)", filename) + return int(match.group(1)) if match else 0 + + +def count_journal_files(dev_dir: Path, active_num: int) -> str: + """Count journal files and return table rows.""" + active_file = f"{FILE_JOURNAL_PREFIX}{active_num}.md" + result_lines = [] + + files = sorted( + [f for f in dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md") if f.is_file()], + key=lambda f: _extract_journal_num(f.stem), + reverse=True + ) + + for f in files: + filename = f.name + lines = len(f.read_text(encoding="utf-8").splitlines()) + status = "Active" if filename == active_file else "Archived" + result_lines.append(f"| `{filename}` | ~{lines} | {status} |") + + return "\n".join(result_lines) + + +def create_new_journal_file( + dev_dir: Path, num: int, developer: str, today: str, max_lines: int = 2000, +) -> Path: + """Create a new journal file.""" + prev_num = num - 1 + new_file = dev_dir / f"{FILE_JOURNAL_PREFIX}{num}.md" + + content = f"""# Journal - {developer} (Part {num}) + +> Continuation from `{FILE_JOURNAL_PREFIX}{prev_num}.md` (archived at ~{max_lines} lines) +> Started: {today} + +--- + +""" + new_file.write_text(content, encoding="utf-8") + return new_file + + +def generate_session_content( + session_num: int, + title: str, + commit: str, + summary: str, + extra_content: str, + today: str, + package: str | None = None, + branch: str | None = None, +) -> str: + """Generate session content.""" + if commit and commit != "-": + commit_table = """| Hash | Message | +|------|---------|""" + for c in commit.split(","): + c = c.strip() + commit_table += f"\n| `{c}` | (see git log) |" + else: + commit_table = "(No commits - planning session)" + + package_line = f"\n**Package**: {package}" if package else "" + branch_line = f"\n**Branch**: `{branch}`" if branch else "" + + return f""" + +## Session {session_num}: {title} + +**Date**: {today} +**Task**: {title}{package_line}{branch_line} + +### Summary + +{summary} + +### Main Changes + +{extra_content} + +### Git Commits + +{commit_table} + +### Testing + +- [OK] (Add test results) + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete +""" + + +def update_index( + index_file: Path, + dev_dir: Path, + title: str, + commit: str, + new_session: int, + active_file: str, + today: str, + branch: str | None = None, +) -> bool: + """Update index.md with new session info.""" + # Format commit for display + commit_display = "-" + if commit and commit != "-": + commit_display = re.sub(r"([a-f0-9]{7,})", r"`\1`", commit.replace(",", ", ")) + + # Get file number from active_file name + match = re.search(r"(\d+)", active_file) + active_num = int(match.group(1)) if match else 0 + files_table = count_journal_files(dev_dir, active_num) + + print(f"Updating index.md for session {new_session}...") + print(f" Title: {title}") + print(f" Commit: {commit_display}") + print(f" Active File: {active_file}") + print() + + content = index_file.read_text(encoding="utf-8") + + if "@@@auto:current-status" not in content: + print("Error: Markers not found in index.md. Please ensure markers exist.", file=sys.stderr) + return False + + # Process sections + lines = content.splitlines() + new_lines = [] + + in_current_status = False + in_active_documents = False + in_session_history = False + header_written = False + + for line in lines: + if "@@@auto:current-status" in line: + new_lines.append(line) + in_current_status = True + new_lines.append(f"- **Active File**: `{active_file}`") + new_lines.append(f"- **Total Sessions**: {new_session}") + new_lines.append(f"- **Last Active**: {today}") + continue + + if "@@@/auto:current-status" in line: + in_current_status = False + new_lines.append(line) + continue + + if "@@@auto:active-documents" in line: + new_lines.append(line) + in_active_documents = True + new_lines.append("| File | Lines | Status |") + new_lines.append("|------|-------|--------|") + new_lines.append(files_table) + continue + + if "@@@/auto:active-documents" in line: + in_active_documents = False + new_lines.append(line) + continue + + if "@@@auto:session-history" in line: + new_lines.append(line) + in_session_history = True + header_written = False + continue + + if "@@@/auto:session-history" in line: + in_session_history = False + new_lines.append(line) + continue + + if in_current_status: + continue + + if in_active_documents: + continue + + if in_session_history: + # Migrate old 4/6-column headers to 5-column Branch-only history. + if re.match( + r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*Branch\s*\|\s*Base Branch\s*\|\s*$", + line, + ): + new_lines.append("| # | Date | Title | Commits | Branch |") + continue + if re.match(r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*Branch\s*\|\s*$", line): + new_lines.append("| # | Date | Title | Commits | Branch |") + continue + if re.match(r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*$", line): + new_lines.append("| # | Date | Title | Commits | Branch |") + continue + if re.match(r"^\|[-| ]+\|\s*$", line) and not header_written: + new_lines.append("|---|------|-------|---------|--------|") + new_lines.append(f"| {new_session} | {today} | {title} | {commit_display} | `{branch or '-'}` |") + header_written = True + continue + new_lines.append(line) + continue + + new_lines.append(line) + + index_file.write_text("\n".join(new_lines), encoding="utf-8") + print("[OK] Updated index.md successfully!") + return True + + +# ============================================================================= +# Main Function +# ============================================================================= + +def _auto_commit_workspace(repo_root: Path) -> None: + """Stage Trellis-owned workspace + task paths and commit. + + Path scope is restricted to specific products (journal files, index.md, + active task dirs, the archive subtree). We never `git add` the whole + `.trellis/` tree, and if `.gitignore` blocks the specific paths we + warn + skip — never retry with ``-f``. + + Honors ``session_auto_commit`` in ``.trellis/config.yaml``: when set to + ``false``, this function returns immediately without touching git + (journal/index files are still written to disk by the caller). + """ + if not get_session_auto_commit(repo_root): + print( + "[OK] session_auto_commit: false — skipping git stage/commit.", + file=sys.stderr, + ) + return + + commit_msg = get_session_commit_message(repo_root) + paths = safe_trellis_paths_to_add(repo_root) + if not paths: + print("[OK] No workspace changes to commit.", file=sys.stderr) + return + + success, _, err = safe_git_add(paths, repo_root) + if not success: + if err and "ignored by" in err.lower(): + print_gitignore_warning(paths) + else: + print( + f"[WARN] git add failed: {err.strip() if err else 'unknown error'}", + file=sys.stderr, + ) + return + + # Check if there are staged changes for the paths we just staged. + rc, _, _ = run_git( + ["diff", "--cached", "--quiet", "--", *paths], cwd=repo_root + ) + if rc == 0: + print("[OK] No workspace changes to commit.", file=sys.stderr) + return + + rc, _, commit_err = run_git(["commit", "-m", commit_msg], cwd=repo_root) + if rc == 0: + print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr) + else: + print( + f"[WARN] Auto-commit failed: {commit_err.strip()}", + file=sys.stderr, + ) + + +def add_session( + title: str, + commit: str = "-", + summary: str = "(Add summary)", + extra_content: str = "(Add details)", + auto_commit: bool = True, + package: str | None = None, + branch: str | None = None, +) -> int: + """Add a new session.""" + repo_root = get_repo_root() + ensure_developer(repo_root) + + developer = get_developer(repo_root) + if not developer: + print("Error: Developer not initialized", file=sys.stderr) + return 1 + + dev_dir = get_workspace_dir(repo_root) + if not dev_dir: + print("Error: Workspace directory not found", file=sys.stderr) + return 1 + + max_lines = get_max_journal_lines(repo_root) + + index_file = dev_dir / "index.md" + today = datetime.now().strftime("%Y-%m-%d") + + journal_file, current_num, current_lines = get_latest_journal_info(dev_dir) + current_session = get_current_session(index_file) + new_session = current_session + 1 + + session_content = generate_session_content( + new_session, title, commit, summary, extra_content, today, package, + branch, + ) + content_lines = len(session_content.splitlines()) + + print("========================================", file=sys.stderr) + print("ADD SESSION", file=sys.stderr) + print("========================================", file=sys.stderr) + print("", file=sys.stderr) + print(f"Session: {new_session}", file=sys.stderr) + print(f"Title: {title}", file=sys.stderr) + print(f"Commit: {commit}", file=sys.stderr) + print("", file=sys.stderr) + print(f"Current journal file: {FILE_JOURNAL_PREFIX}{current_num}.md", file=sys.stderr) + print(f"Current lines: {current_lines}", file=sys.stderr) + print(f"New content lines: {content_lines}", file=sys.stderr) + print(f"Total after append: {current_lines + content_lines}", file=sys.stderr) + print("", file=sys.stderr) + + target_file = journal_file + target_num = current_num + + if current_lines + content_lines > max_lines: + target_num = current_num + 1 + print(f"[!] Exceeds {max_lines} lines, creating {FILE_JOURNAL_PREFIX}{target_num}.md", file=sys.stderr) + target_file = create_new_journal_file(dev_dir, target_num, developer, today, max_lines) + print(f"Created: {target_file}", file=sys.stderr) + + # Append session content + if target_file: + with target_file.open("a", encoding="utf-8") as f: + f.write(session_content) + print(f"[OK] Appended session to {target_file.name}", file=sys.stderr) + + print("", file=sys.stderr) + + # Update index.md + active_file = f"{FILE_JOURNAL_PREFIX}{target_num}.md" + if not update_index( + index_file, + dev_dir, + title, + commit, + new_session, + active_file, + today, + branch, + ): + return 1 + + print("", file=sys.stderr) + print("========================================", file=sys.stderr) + print(f"[OK] Session {new_session} added successfully!", file=sys.stderr) + print("========================================", file=sys.stderr) + print("", file=sys.stderr) + print("Files updated:", file=sys.stderr) + print(f" - {target_file.name if target_file else 'journal'}", file=sys.stderr) + print(" - index.md", file=sys.stderr) + + # Auto-commit workspace changes + if auto_commit: + print("", file=sys.stderr) + _auto_commit_workspace(repo_root) + + return 0 + + +# ============================================================================= +# Main Entry +# ============================================================================= + +def main() -> int: + """CLI entry point.""" + parser = argparse.ArgumentParser( + description="Add a new session to journal file and update index.md" + ) + parser.add_argument("--title", required=True, help="Session title") + parser.add_argument("--commit", default="-", help="Comma-separated commit hashes") + parser.add_argument("--summary", default="(Add summary)", help="Brief summary") + parser.add_argument("--content-file", help="Path to file with detailed content") + parser.add_argument("--package", help="Package name tag (e.g., cli, docs-site)") + parser.add_argument("--branch", help="Branch name (auto-detected if omitted)") + parser.add_argument("--no-commit", action="store_true", + help="Skip auto-commit of workspace changes") + parser.add_argument("--stdin", action="store_true", + help="Read extra content from stdin (explicit opt-in)") + + args = parser.parse_args() + + extra_content = "(Add details)" + if args.content_file: + content_path = Path(args.content_file) + if content_path.is_file(): + extra_content = content_path.read_text(encoding="utf-8") + elif args.stdin: + extra_content = sys.stdin.read() + + # Load active task once — shared by package and branch resolution + repo_root = get_repo_root() + current = get_current_task(repo_root) + task_data = load_task(repo_root / current) if current else None + + package = args.package + if package: + # CLI source: fail-fast in monorepo, ignore in single-repo + if not is_monorepo(repo_root): + print("Warning: --package ignored in single-repo project", file=sys.stderr) + package = None + elif not validate_package(package, repo_root): + packages = get_packages(repo_root) + available = ", ".join(sorted(packages.keys())) if packages else "(none)" + print(f"Error: unknown package '{package}'. Available: {available}", file=sys.stderr) + return 1 + else: + # Inferred: active task's task.json.package → default_package → None + task_package = task_data.package if task_data else None + package = resolve_package(task_package, repo_root) + + # Resolve branch: CLI → task.json → git auto-detect → None + branch = args.branch + + if not branch: + if task_data and task_data.raw.get("branch"): + branch = task_data.raw["branch"] + else: + _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) + detected = branch_out.strip() + if detected: + branch = detected + + return add_session( + args.title, args.commit, args.summary, extra_content, + auto_commit=not args.no_commit, + package=package, + branch=branch, + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.trellis/scripts/common/__init__.py b/.trellis/scripts/common/__init__.py new file mode 100644 index 0000000..6d72360 --- /dev/null +++ b/.trellis/scripts/common/__init__.py @@ -0,0 +1,92 @@ +""" +Common utilities for Trellis workflow scripts. + +This module provides shared functionality used by other Trellis scripts. +""" + +import io +import sys + +# ============================================================================= +# Windows Encoding Fix (MUST be at top, before any other output) +# ============================================================================= +# On Windows, stdout defaults to the system code page (often GBK/CP936). +# This causes UnicodeEncodeError when printing non-ASCII characters. +# +# Any script that imports from common will automatically get this fix. +# ============================================================================= + + +def _configure_stream(stream: object) -> object: + """Configure a stream for UTF-8 encoding on Windows.""" + # Try reconfigure() first (Python 3.7+, more reliable) + if hasattr(stream, "reconfigure"): + stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] + return stream + # Fallback: detach and rewrap with TextIOWrapper + elif hasattr(stream, "detach"): + return io.TextIOWrapper( + stream.detach(), # type: ignore[union-attr] + encoding="utf-8", + errors="replace", + ) + return stream + + +if sys.platform == "win32": + sys.stdout = _configure_stream(sys.stdout) # type: ignore[assignment] + sys.stderr = _configure_stream(sys.stderr) # type: ignore[assignment] + sys.stdin = _configure_stream(sys.stdin) # type: ignore[assignment] + + +def configure_encoding() -> None: + """ + Configure stdout/stderr/stdin for UTF-8 encoding on Windows. + + This is automatically called when importing from common, + but can be called manually for scripts that don't import common. + + Safe to call multiple times. + """ + global sys + if sys.platform == "win32": + sys.stdout = _configure_stream(sys.stdout) # type: ignore[assignment] + sys.stderr = _configure_stream(sys.stderr) # type: ignore[assignment] + sys.stdin = _configure_stream(sys.stdin) # type: ignore[assignment] + + +from .paths import ( + DIR_WORKFLOW, + DIR_WORKSPACE, + DIR_TASKS, + DIR_ARCHIVE, + DIR_SPEC, + DIR_SCRIPTS, + FILE_DEVELOPER, + FILE_CURRENT_TASK, + FILE_TASK_JSON, + FILE_JOURNAL_PREFIX, + get_repo_root, + get_developer, + check_developer, + get_tasks_dir, + get_workspace_dir, + get_active_journal_file, + count_lines, + get_current_task, + get_current_task_abs, + normalize_task_ref, + resolve_task_ref, + set_current_task, + clear_current_task, + has_current_task, + generate_task_date_prefix, +) + +from .active_task import ( + ActiveTask, + clear_active_task, + resolve_active_task, + resolve_context_key, + set_active_task, +) diff --git a/.trellis/scripts/common/active_task.py b/.trellis/scripts/common/active_task.py new file mode 100644 index 0000000..e6597e8 --- /dev/null +++ b/.trellis/scripts/common/active_task.py @@ -0,0 +1,626 @@ +#!/usr/bin/env python3 +"""Session-scoped active task resolution. + +The user-facing concept is a single "active task". Trellis stores that pointer +per AI session/window under `.trellis/.runtime/sessions/`; without a stable +session key there is no active task. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import sys +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +DIR_WORKFLOW = ".trellis" +DIR_TASKS = "tasks" +DIR_RUNTIME = ".runtime" +DIR_SESSIONS = "sessions" +DIR_CURSOR_SHELL = "cursor-shell" +CURSOR_SHELL_TICKET_TTL_SECONDS = 30 +TASK_SESSION_COMMANDS = {"start", "current", "finish"} + +_SESSION_KEYS = ("session_id", "sessionId", "sessionID") +_CONVERSATION_KEYS = ("conversation_id", "conversationId", "conversationID") +_TRANSCRIPT_KEYS = ("transcript_path", "transcriptPath", "transcript") +_NESTED_KEYS = ("input", "properties", "event", "hook_input", "hookInput") +_KNOWN_PLATFORMS = { + "claude", + "codex", + "cursor", + "opencode", + "gemini", + "droid", + "qoder", + "codebuddy", + "kiro", + "copilot", + "pi", +} + +_ENV_SESSION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("claude", ("CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID")), + ("codex", ("CODEX_SESSION_ID", "CODEX_THREAD_ID")), + ("cursor", ("CURSOR_SESSION_ID",)), + ("opencode", ("OPENCODE_SESSION_ID", "OPENCODE_SESSIONID", "OPENCODE_RUN_ID")), + ("gemini", ("GEMINI_SESSION_ID",)), + ("droid", ("FACTORY_SESSION_ID", "DROID_SESSION_ID")), + ("qoder", ("QODER_SESSION_ID",)), + ("codebuddy", ("CODEBUDDY_SESSION_ID",)), + ("kiro", ("KIRO_SESSION_ID",)), + ("copilot", ("COPILOT_SESSION_ID", "COPILOT_SESSIONID")), + ("pi", ("PI_SESSION_ID", "PI_SESSIONID")), +) +_ENV_CONVERSATION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("cursor", ("CURSOR_CONVERSATION_ID", "CURSOR_CONVERSATIONID")), +) +_ENV_TRANSCRIPT_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("claude", ("CLAUDE_TRANSCRIPT_PATH",)), + ("codex", ("CODEX_TRANSCRIPT_PATH",)), + ("cursor", ("CURSOR_TRANSCRIPT_PATH",)), + ("gemini", ("GEMINI_TRANSCRIPT_PATH",)), + ("droid", ("FACTORY_TRANSCRIPT_PATH", "DROID_TRANSCRIPT_PATH")), + ("qoder", ("QODER_TRANSCRIPT_PATH",)), + ("codebuddy", ("CODEBUDDY_TRANSCRIPT_PATH",)), +) +_ENV_PLATFORM_ALIASES = { + "claude-code": "claude", + "factory": "droid", + "factory-ai": "droid", + "github-copilot": "copilot", +} + + +@dataclass(frozen=True) +class ActiveTask: + """Resolved active task state.""" + + task_path: str | None + source_type: str + context_key: str | None = None + stale: bool = False + + @property + def source(self) -> str: + """Human-readable source label.""" + if self.source_type == "session" and self.context_key: + return f"session:{self.context_key}" + if self.source_type == "session-fallback" and self.context_key: + return f"session-fallback:{self.context_key}" + return self.source_type + + +def normalize_task_ref(task_ref: str) -> str: + """Normalize a task ref for stable storage and comparison.""" + normalized = task_ref.strip() + if not normalized: + return "" + + path_obj = Path(normalized) + if path_obj.is_absolute(): + return str(path_obj) + + normalized = normalized.replace("\\", "/") + while normalized.startswith("./"): + normalized = normalized[2:] + + if normalized.startswith(f"{DIR_TASKS}/"): + return f"{DIR_WORKFLOW}/{normalized}" + + return normalized + + +def resolve_task_ref(task_ref: str, repo_root: Path) -> Path | None: + """Resolve a task ref to an absolute task directory.""" + normalized = normalize_task_ref(task_ref) + if not normalized: + return None + + path_obj = Path(normalized) + if path_obj.is_absolute(): + return path_obj + + if normalized.startswith(f"{DIR_WORKFLOW}/"): + return repo_root / path_obj + + return repo_root / DIR_WORKFLOW / DIR_TASKS / path_obj + + +def _runtime_sessions_dir(repo_root: Path) -> Path: + return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_SESSIONS + + +def _sanitize_key(raw: str) -> str: + safe = re.sub(r"[^A-Za-z0-9._-]+", "_", raw.strip()) + safe = safe.strip("._-") + return safe[:160] if safe else "" + + +def _hash_value(raw: str) -> str: + return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24] + + +def _as_dict(value: Any) -> dict[str, Any] | None: + return value if isinstance(value, dict) else None + + +def _string_value(value: Any) -> str | None: + if isinstance(value, str): + stripped = value.strip() + return stripped or None + return None + + +def _lookup_string(data: dict[str, Any], keys: tuple[str, ...]) -> str | None: + for key in keys: + value = _string_value(data.get(key)) + if value: + return value + + for nested_key in _NESTED_KEYS: + nested = _as_dict(data.get(nested_key)) + if not nested: + continue + value = _lookup_string(nested, keys) + if value: + return value + + return None + + +def _detect_platform(platform_input: dict[str, Any] | None, platform: str | None) -> str: + if platform: + return _sanitize_key(platform) or "session" + if platform_input: + for key in ("_trellis_platform", "trellis_platform", "platform", "source"): + value = _string_value(platform_input.get(key)) + if value: + return _sanitize_key(value) or "session" + if _string_value(platform_input.get("cursor_version")): + return "cursor" + return "session" + + +def _context_key(platform_name: str, kind: str, value: str) -> str: + if kind == "transcript": + return f"{platform_name}_transcript_{_hash_value(value)}" + safe_value = _sanitize_key(value) + if safe_value: + return f"{platform_name}_{safe_value}" + return f"{platform_name}_{_hash_value(value)}" + + +def _iter_env_keys( + env_keys: tuple[tuple[str, tuple[str, ...]], ...], + platform_name: str | None, +) -> tuple[tuple[str, tuple[str, ...]], ...]: + if not platform_name: + return env_keys + matched = tuple((name, keys) for name, keys in env_keys if name == platform_name) + return matched + + +def _env_platform_name(platform_name: str | None) -> str | None: + if not platform_name or platform_name == "session": + return None + return _ENV_PLATFORM_ALIASES.get(platform_name, platform_name) + + +def _lookup_env_context_key(platform_name: str | None) -> str | None: + """Resolve a context key from platform-provided environment variables. + + Hooks pass `TRELLIS_CONTEXT_ID` to subprocesses they launch, but an AI-run + shell command can only see session identity if the host platform exports it + in the command environment. These names are best-effort adapters; if none + are present, there is no session-scoped active task. + """ + env_platform_name = _env_platform_name(platform_name) + + for name, keys in _iter_env_keys(_ENV_SESSION_KEYS, env_platform_name): + for key in keys: + value = _string_value(os.environ.get(key)) + if value: + return _context_key(name, "session", value) + + for name, keys in _iter_env_keys(_ENV_CONVERSATION_KEYS, env_platform_name): + for key in keys: + value = _string_value(os.environ.get(key)) + if value: + return _context_key(name, "conversation", value) + + for name, keys in _iter_env_keys(_ENV_TRANSCRIPT_KEYS, env_platform_name): + for key in keys: + value = _string_value(os.environ.get(key)) + if value: + return _context_key(name, "transcript", value) + + return None + + +def _find_repo_root_from_cwd() -> Path | None: + current = Path.cwd().resolve() + while True: + if (current / DIR_WORKFLOW).is_dir(): + return current + if current == current.parent: + return None + current = current.parent + + +def _cursor_shell_ticket_dir(repo_root: Path) -> Path: + return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_CURSOR_SHELL + + +def _remove_file(path: Path) -> bool: + try: + path.unlink() + return True + except OSError: + return False + + +def _task_refs_match(left: str | None, right: str | None, repo_root: Path) -> bool: + if not left or not right: + return False + left_path = resolve_task_ref(left, repo_root) + right_path = resolve_task_ref(right, repo_root) + if left_path is not None and right_path is not None: + return left_path == right_path + return normalize_task_ref(left) == normalize_task_ref(right) + + +def _pending_ticket_matches_args(ticket: dict[str, Any], repo_root: Path) -> bool: + if Path(sys.argv[0]).name != "task.py": + return False + args = tuple(sys.argv[1:]) + if not args: + return False + + command_name = args[0] + if command_name not in TASK_SESSION_COMMANDS: + return False + + subcommands = ticket.get("subcommands") + if not isinstance(subcommands, list): + return False + + for subcommand in subcommands: + if not isinstance(subcommand, dict): + continue + if _string_value(subcommand.get("name")) != command_name: + continue + if command_name != "start": + return True + task_ref = args[1] if len(args) > 1 else None + if _task_refs_match(_string_value(subcommand.get("task_ref")), task_ref, repo_root): + return True + + return False + + +def _ticket_is_fresh(ticket: dict[str, Any], ticket_path: Path, now: float) -> bool: + expires_at = ticket.get("expires_at_epoch") + if isinstance(expires_at, (int, float)) and expires_at < now: + _remove_file(ticket_path) + return False + + created_at = ticket.get("created_at_epoch") + if isinstance(created_at, (int, float)): + if now - created_at <= CURSOR_SHELL_TICKET_TTL_SECONDS: + return True + _remove_file(ticket_path) + return False + return True + + +def _ticket_cwd_matches_repo(ticket: dict[str, Any], repo_root: Path) -> bool: + cwd = _string_value(ticket.get("cwd")) + if not cwd: + return True + try: + Path(cwd).resolve().relative_to(repo_root) + except ValueError: + return False + return True + + +def _matching_cursor_ticket_context_key( + ticket_path: Path, + repo_root: Path, + now: float, +) -> str | None: + ticket = _read_json(ticket_path) + if ticket is None or ticket.get("platform") != "cursor": + return None + if not _ticket_is_fresh(ticket, ticket_path, now): + return None + if not _ticket_cwd_matches_repo(ticket, repo_root): + return None + if not _pending_ticket_matches_args(ticket, repo_root): + return None + return _string_value(ticket.get("context_key")) + + +def _lookup_cursor_shell_ticket_context_key() -> str | None: + """Resolve Cursor conversation identity from a short-lived shell ticket. + + Cursor exposes `conversation_id` to `beforeShellExecution`, but does not + export it into the shell command environment. The Cursor hook writes a + short-lived ticket just before `task.py` runs. We accept a ticket only when + the current `task.py` subcommand matches and exactly one fresh context key + matches, which avoids cross-window pointer contamination. + """ + repo_root = _find_repo_root_from_cwd() + if repo_root is None: + return None + + ticket_dir = _cursor_shell_ticket_dir(repo_root) + if not ticket_dir.is_dir(): + return None + + now = time.time() + candidates: set[str] = set() + for ticket_path in ticket_dir.glob("*.json"): + context_key = _matching_cursor_ticket_context_key(ticket_path, repo_root, now) + if context_key: + candidates.add(context_key) + + if len(candidates) == 1: + return next(iter(candidates)) + return None + + +def resolve_context_key( + platform_input: dict[str, Any] | None = None, + platform: str | None = None, +) -> str | None: + """Resolve a stable session/window context key, if one is available. + + `TRELLIS_CONTEXT_ID` is an explicit context-key override used by CLI + scripts and subprocesses. It does not store the task itself. + """ + override = _string_value(os.environ.get("TRELLIS_CONTEXT_ID")) + if override: + return _sanitize_key(override) or _hash_value(override) + + data = _as_dict(platform_input) + platform_name = _detect_platform(data, platform) if data or platform else None + + if data: + session_id = _lookup_string(data, _SESSION_KEYS) + if session_id: + return _context_key(platform_name or "session", "session", session_id) + + conversation_id = _lookup_string(data, _CONVERSATION_KEYS) + if conversation_id: + return _context_key(platform_name or "session", "conversation", conversation_id) + + transcript_path = _lookup_string(data, _TRANSCRIPT_KEYS) + if transcript_path: + return _context_key(platform_name or "session", "transcript", transcript_path) + + env_context_key = _lookup_env_context_key(platform_name) + if env_context_key: + return env_context_key + + if platform_name in (None, "session", "cursor"): + return _lookup_cursor_shell_ticket_context_key() + return None + + +def _read_json(path: Path) -> dict[str, Any] | None: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + return data if isinstance(data, dict) else None + + +def _write_json(path: Path, data: dict[str, Any]) -> bool: + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(data, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + return True + except OSError: + return False + + +def _canonical_task_ref(task_path: str, repo_root: Path) -> str | None: + normalized = normalize_task_ref(task_path) + if not normalized: + return None + full_path = resolve_task_ref(normalized, repo_root) + if full_path is None or not full_path.is_dir(): + return None + try: + return full_path.relative_to(repo_root).as_posix() + except ValueError: + return str(full_path) + + +def _active_from_ref( + task_ref: str | None, + repo_root: Path, + source_type: str, + context_key: str | None = None, +) -> ActiveTask | None: + if not task_ref: + return None + resolved = resolve_task_ref(task_ref, repo_root) + stale = resolved is None or not resolved.is_dir() + return ActiveTask(task_ref, source_type, context_key, stale) + + +def _context_path(repo_root: Path, context_key: str) -> Path: + return _runtime_sessions_dir(repo_root) / f"{context_key}.json" + + +def resolve_active_task( + repo_root: Path, + platform_input: dict[str, Any] | None = None, + platform: str | None = None, +) -> ActiveTask: + """Resolve the active task from session runtime state only. + + A stale session task is returned as stale. Missing context identity or a + missing/empty session context falls back to single-session inference: if + exactly one session file exists in the runtime, return its task with + source_type="session-fallback" — covers class-2 platform sub-agents (codex, + copilot, gemini, qoder) that don't inherit the parent's session id. ≥2 + files or 0 files yield ActiveTask(None) — refuses to guess across windows. + """ + context_key = resolve_context_key(platform_input, platform) + if context_key: + context = _read_json(_context_path(repo_root, context_key)) or {} + task_ref = _string_value(context.get("current_task")) + active = _active_from_ref(task_ref, repo_root, "session", context_key) + if active: + return active + + fallback = _resolve_single_session_fallback(repo_root) + if fallback is not None: + return fallback + + return ActiveTask(None, "none", context_key) + + +def _resolve_single_session_fallback(repo_root: Path) -> ActiveTask | None: + """Return the task pointed at by the sole session file, if exactly one exists. + + Used when context-key resolution fails (typical for class-2 platform + sub-agents). Returns None if 0 or ≥2 session files are present — refuses + to pick across windows so 04-21's multi-session isolation contract holds. + """ + sessions_dir = _runtime_sessions_dir(repo_root) + if not sessions_dir.is_dir(): + return None + + session_files = sorted(sessions_dir.glob("*.json")) + if len(session_files) != 1: + return None + + session_file = session_files[0] + context = _read_json(session_file) or {} + task_ref = _string_value(context.get("current_task")) + if not task_ref: + return None + + fallback_key = session_file.stem + return _active_from_ref(task_ref, repo_root, "session-fallback", fallback_key) + + +def _utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _context_metadata( + platform_input: dict[str, Any] | None, + platform: str | None, + context_key: str | None = None, +) -> dict[str, Any]: + data = _as_dict(platform_input) or {} + platform_name = _detect_platform(data, platform) + if platform_name == "session" and context_key: + prefix = context_key.split("_", 1)[0] + if prefix in _KNOWN_PLATFORMS: + platform_name = prefix + metadata: dict[str, Any] = { + "platform": platform_name, + "last_seen_at": _utc_now(), + } + for key in (*_SESSION_KEYS, *_CONVERSATION_KEYS, *_TRANSCRIPT_KEYS): + value = _lookup_string(data, (key,)) + if value: + metadata[key] = value + return metadata + + +def set_active_task( + task_path: str, + repo_root: Path, + platform_input: dict[str, Any] | None = None, + platform: str | None = None, +) -> ActiveTask | None: + """Set the active task in session scope. + + Returns None when no context key is available; callers should surface a + user-facing error that explains how to provide session identity. + """ + canonical = _canonical_task_ref(task_path, repo_root) + if canonical is None: + return None + + context_key = resolve_context_key(platform_input, platform) + if not context_key: + return None + + context_path = _context_path(repo_root, context_key) + context = _read_json(context_path) or {} + context.update(_context_metadata(platform_input, platform, context_key)) + context["current_task"] = canonical + context.setdefault("current_run", None) + if not _write_json(context_path, context): + return None + return ActiveTask(canonical, "session", context_key) + + +def clear_active_task( + repo_root: Path, + platform_input: dict[str, Any] | None = None, + platform: str | None = None, +) -> ActiveTask: + """Clear the active task by deleting the current session context file.""" + context_key = resolve_context_key(platform_input, platform) + if not context_key: + return ActiveTask(None, "none") + + previous = resolve_active_task(repo_root, platform_input, platform) + context_path = _context_path(repo_root, context_key) + if context_path.is_file(): + _remove_file(context_path) + return previous + + +def clear_task_from_sessions(task_path: str, repo_root: Path) -> int: + """Delete all session runtime files that point at a task.""" + target = _canonical_task_ref(task_path, repo_root) or normalize_task_ref(task_path) + if not target: + return 0 + + cleared = 0 + sessions_dir = _runtime_sessions_dir(repo_root) + if not sessions_dir.is_dir(): + return cleared + + for session_path in sessions_dir.glob("*.json"): + context = _read_json(session_path) or {} + current = _string_value(context.get("current_task")) + if not current: + continue + current_ref = _canonical_task_ref(current, repo_root) or normalize_task_ref(current) + if current_ref != target: + continue + if session_path.is_file() and _remove_file(session_path): + cleared += 1 + + return cleared + + +def get_current_task_source( + repo_root: Path, + platform_input: dict[str, Any] | None = None, + platform: str | None = None, +) -> tuple[str, str | None, str | None]: + """Return (`source_type`, `context_key`, `task_path`) for compatibility.""" + active = resolve_active_task(repo_root, platform_input, platform) + return active.source_type, active.context_key, active.task_path diff --git a/.trellis/scripts/common/cli_adapter.py b/.trellis/scripts/common/cli_adapter.py new file mode 100644 index 0000000..b65f61a --- /dev/null +++ b/.trellis/scripts/common/cli_adapter.py @@ -0,0 +1,811 @@ +""" +CLI Adapter for Multi-Platform Support. + +Abstracts differences between Claude Code, OpenCode, Cursor, iFlow, Codex, Kilo, Kiro Code, Gemini CLI, Antigravity, Windsurf, Qoder, CodeBuddy, GitHub Copilot, Factory Droid, and Pi Agent interfaces. + +Supported platforms: +- claude: Claude Code (default) +- opencode: OpenCode +- cursor: Cursor IDE +- iflow: iFlow CLI +- codex: Codex CLI (skills-based) +- kilo: Kilo CLI +- kiro: Kiro Code (skills-based) +- gemini: Gemini CLI +- antigravity: Antigravity (workflow-based) +- windsurf: Windsurf (workflow-based) +- qoder: Qoder +- codebuddy: CodeBuddy +- copilot: GitHub Copilot (VS Code) +- droid: Factory Droid (commands-based) +- pi: Pi Agent (extension-backed) + +Usage: + from common.cli_adapter import CLIAdapter + + adapter = CLIAdapter("opencode") + cmd = adapter.build_run_command( + agent="dispatch", + session_id="abc123", + prompt="Start the pipeline" + ) +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import ClassVar, Literal + +Platform = Literal[ + "claude", + "opencode", + "cursor", + "iflow", + "codex", + "kilo", + "kiro", + "gemini", + "antigravity", + "windsurf", + "qoder", + "codebuddy", + "copilot", + "droid", + "pi", +] + + +@dataclass +class CLIAdapter: + """Adapter for different AI coding CLI tools.""" + + platform: Platform + + # ========================================================================= + # Agent Name Mapping + # ========================================================================= + + # OpenCode has built-in agents that cannot be overridden + # See: https://github.com/sst/opencode/issues/4271 + # Note: Class-level constant, not a dataclass field + _AGENT_NAME_MAP: ClassVar[dict[Platform, dict[str, str]]] = { + "claude": {}, # No mapping needed + "opencode": { + "plan": "trellis-plan", # 'plan' is built-in in OpenCode + }, + } + + def get_agent_name(self, agent: str) -> str: + """Get platform-specific agent name. + + Args: + agent: Original agent name (e.g., 'plan', 'dispatch') + + Returns: + Platform-specific agent name (e.g., 'trellis-plan' for OpenCode) + """ + mapping = self._AGENT_NAME_MAP.get(self.platform, {}) + return mapping.get(agent, agent) + + # ========================================================================= + # Agent Path + # ========================================================================= + + @property + def config_dir_name(self) -> str: + """Get platform-specific config directory name. + + Returns: + Directory name ('.claude', '.opencode', '.cursor', '.iflow', '.codex', '.kilocode', '.kiro', '.gemini', '.agent', '.windsurf', '.qoder', '.codebuddy', '.github/copilot', '.factory', or '.pi') + """ + if self.platform == "opencode": + return ".opencode" + elif self.platform == "cursor": + return ".cursor" + elif self.platform == "iflow": + return ".iflow" + elif self.platform == "codex": + return ".codex" + elif self.platform == "kilo": + return ".kilocode" + elif self.platform == "kiro": + return ".kiro" + elif self.platform == "gemini": + return ".gemini" + elif self.platform == "antigravity": + return ".agent" + elif self.platform == "windsurf": + return ".windsurf" + elif self.platform == "qoder": + return ".qoder" + elif self.platform == "codebuddy": + return ".codebuddy" + elif self.platform == "copilot": + return ".github/copilot" + elif self.platform == "droid": + return ".factory" + elif self.platform == "pi": + return ".pi" + else: + return ".claude" + + def get_config_dir(self, project_root: Path) -> Path: + """Get platform-specific config directory. + + Args: + project_root: Project root directory + + Returns: + Path to config directory (.claude, .opencode, .cursor, .iflow, .codex, .kilocode, .kiro, .gemini, .agent, .windsurf, .qoder, .codebuddy, .github/copilot, .factory, or .pi) + """ + return project_root / self.config_dir_name + + def get_agent_path(self, agent: str, project_root: Path) -> Path: + """Get path to agent definition file. + + Args: + agent: Agent name (original, before mapping) + project_root: Project root directory + + Returns: + Path to agent definition file (.md for most platforms, .toml for Codex) + """ + mapped_name = self.get_agent_name(agent) + if self.platform == "codex": + return self.get_config_dir(project_root) / "agents" / f"{mapped_name}.toml" + return self.get_config_dir(project_root) / "agents" / f"{mapped_name}.md" + + def get_commands_path(self, project_root: Path, *parts: str) -> Path: + """Get path to commands directory or specific command file. + + Args: + project_root: Project root directory + *parts: Additional path parts (e.g., 'trellis', 'finish-work.md') + + Returns: + Path to commands directory or file + + Note: + Cursor uses prefix naming: .cursor/commands/trellis-<name>.md + Antigravity uses workflow directory: .agent/workflows/<name>.md + Windsurf uses workflow directory: .windsurf/workflows/trellis-<name>.md + Copilot uses prompt files: .github/prompts/<name>.prompt.md + Pi uses prompt templates: .pi/prompts/trellis-<name>.md + Claude/OpenCode use subdirectory: .claude/commands/trellis/<name>.md + """ + if self.platform == "pi": + prompts_dir = self.get_config_dir(project_root) / "prompts" + if not parts: + return prompts_dir + if len(parts) >= 2 and parts[0] == "trellis": + filename = parts[-1] + if filename.endswith(".md"): + filename = filename[:-3] + return prompts_dir / f"trellis-{filename}.md" + return prompts_dir / Path(*parts) + + if self.platform == "windsurf": + workflow_dir = self.get_config_dir(project_root) / "workflows" + if not parts: + return workflow_dir + if len(parts) >= 2 and parts[0] == "trellis": + filename = parts[-1] + return workflow_dir / f"trellis-{filename}" + return workflow_dir / Path(*parts) + + if self.platform in ("antigravity", "kilo"): + workflow_dir = self.get_config_dir(project_root) / "workflows" + if not parts: + return workflow_dir + if len(parts) >= 2 and parts[0] == "trellis": + filename = parts[-1] + return workflow_dir / filename + return workflow_dir / Path(*parts) + + if self.platform == "copilot": + prompts_dir = project_root / ".github" / "prompts" + if not parts: + return prompts_dir + if len(parts) >= 2 and parts[0] == "trellis": + filename = parts[-1] + if filename.endswith(".md"): + filename = filename[:-3] + return prompts_dir / f"{filename}.prompt.md" + return prompts_dir / Path(*parts) + + if not parts: + return self.get_config_dir(project_root) / "commands" + + # Cursor uses prefix naming instead of subdirectory + if self.platform == "cursor" and len(parts) >= 2 and parts[0] == "trellis": + # Convert trellis/<name>.md to trellis-<name>.md + filename = parts[-1] + return ( + self.get_config_dir(project_root) / "commands" / f"trellis-{filename}" + ) + + return self.get_config_dir(project_root) / "commands" / Path(*parts) + + def get_trellis_command_path(self, name: str) -> str: + """Get relative path to a trellis command file. + + Args: + name: Command name without extension (e.g., 'finish-work', 'check') + + Returns: + Relative path string for use in JSONL entries + + Note: + Cursor: .cursor/commands/trellis-<name>.md + Codex: .agents/skills/trellis-<name>/SKILL.md + Kiro: .kiro/skills/trellis-<name>/SKILL.md + Gemini: .gemini/commands/trellis/<name>.toml + Antigravity: .agent/workflows/<name>.md + Windsurf: .windsurf/workflows/trellis-<name>.md + Pi: .pi/prompts/trellis-<name>.md + Others: .{platform}/commands/trellis/<name>.md + """ + if self.platform == "cursor": + return f".cursor/commands/trellis-{name}.md" + elif self.platform == "codex": + # 0.5.0-beta.0 renamed all skill dirs to add the `trellis-` prefix + # (see that release's manifest for the 60+ rename entries). + return f".agents/skills/trellis-{name}/SKILL.md" + elif self.platform == "kiro": + return f".kiro/skills/trellis-{name}/SKILL.md" + elif self.platform == "gemini": + return f".gemini/commands/trellis/{name}.toml" + elif self.platform == "antigravity": + return f".agent/workflows/{name}.md" + elif self.platform == "windsurf": + return f".windsurf/workflows/trellis-{name}.md" + elif self.platform == "kilo": + return f".kilocode/workflows/{name}.md" + elif self.platform == "copilot": + return f".github/prompts/{name}.prompt.md" + elif self.platform == "droid": + return f".factory/commands/trellis/{name}.md" + elif self.platform == "pi": + return f".pi/prompts/trellis-{name}.md" + else: + return f"{self.config_dir_name}/commands/trellis/{name}.md" + + # ========================================================================= + # Environment Variables + # ========================================================================= + + def get_non_interactive_env(self) -> dict[str, str]: + """Get environment variables for non-interactive mode. + + Returns: + Dict of environment variables to set + """ + if self.platform == "opencode": + return {"OPENCODE_NON_INTERACTIVE": "1"} + elif self.platform == "iflow": + return {"IFLOW_NON_INTERACTIVE": "1"} + elif self.platform == "codex": + return {"CODEX_NON_INTERACTIVE": "1"} + elif self.platform == "kiro": + return {"KIRO_NON_INTERACTIVE": "1"} + elif self.platform == "gemini": + return {} # Gemini CLI doesn't have a non-interactive env var + elif self.platform == "antigravity": + return {} + elif self.platform == "windsurf": + return {} + elif self.platform == "qoder": + return {} + elif self.platform == "codebuddy": + return {} + elif self.platform == "copilot": + return {} + elif self.platform == "droid": + return {} + elif self.platform == "pi": + return {} + else: + return {"CLAUDE_NON_INTERACTIVE": "1"} + + # ========================================================================= + # CLI Command Building + # ========================================================================= + + def build_run_command( + self, + agent: str, + prompt: str, + session_id: str | None = None, + skip_permissions: bool = True, + verbose: bool = True, + json_output: bool = True, + ) -> list[str]: + """Build CLI command for running an agent. + + Args: + agent: Agent name (will be mapped if needed) + prompt: Prompt to send to the agent + session_id: Optional session ID (Claude Code only for creation) + skip_permissions: Whether to skip permission prompts + verbose: Whether to enable verbose output + json_output: Whether to use JSON output format + + Returns: + List of command arguments + """ + mapped_agent = self.get_agent_name(agent) + + if self.platform == "opencode": + cmd = ["opencode", "run"] + cmd.extend(["--agent", mapped_agent]) + + # Note: OpenCode 'run' mode is non-interactive by default + # No equivalent to Claude Code's --dangerously-skip-permissions + # See: https://github.com/anomalyco/opencode/issues/9070 + + if json_output: + cmd.extend(["--format", "json"]) + + if verbose: + cmd.extend(["--log-level", "DEBUG", "--print-logs"]) + + # Note: OpenCode doesn't support --session-id on creation + # Session ID must be extracted from logs after startup + + cmd.append(prompt) + + elif self.platform == "iflow": + cmd = ["iflow", "-y", "-p"] + cmd.append(f"${mapped_agent} {prompt}") + elif self.platform == "codex": + cmd = ["codex", "exec"] + cmd.append(prompt) + elif self.platform == "kiro": + cmd = ["kiro", "run", prompt] + elif self.platform == "gemini": + cmd = ["gemini"] + cmd.append(prompt) + elif self.platform == "antigravity": + raise ValueError( + "Antigravity workflows are UI slash commands; CLI agent run is not supported." + ) + elif self.platform == "windsurf": + raise ValueError( + "Windsurf workflows are UI slash commands; CLI agent run is not supported." + ) + elif self.platform == "qoder": + cmd = ["qodercli", "-p", prompt] + elif self.platform == "codebuddy": + raise ValueError( + "CodeBuddy does not support non-interactive mode (no CLI agent)" + ) + elif self.platform == "copilot": + raise ValueError( + "GitHub Copilot is IDE-only; CLI agent run is not supported." + ) + elif self.platform == "droid": + raise ValueError( + "Factory Droid CLI agent run is not yet supported." + ) + elif self.platform == "pi": + cmd = ["pi", "-p", prompt] + + else: # claude + cmd = ["claude", "-p"] + cmd.extend(["--agent", mapped_agent]) + + if session_id: + cmd.extend(["--session-id", session_id]) + + if skip_permissions: + cmd.append("--dangerously-skip-permissions") + + if json_output: + cmd.extend(["--output-format", "stream-json"]) + + if verbose: + cmd.append("--verbose") + + cmd.append(prompt) + + return cmd + + def build_resume_command(self, session_id: str) -> list[str]: + """Build CLI command for resuming a session. + + Args: + session_id: Session ID to resume (ignored for iFlow) + + Returns: + List of command arguments + """ + if self.platform == "opencode": + return ["opencode", "run", "--session", session_id] + elif self.platform == "iflow": + # iFlow uses -c to continue most recent conversation + # session_id is ignored as iFlow doesn't support session IDs + return ["iflow", "-c"] + elif self.platform == "codex": + return ["codex", "resume", session_id] + elif self.platform == "kiro": + return ["kiro", "resume", session_id] + elif self.platform == "gemini": + return ["gemini", "--resume", session_id] + elif self.platform == "antigravity": + raise ValueError( + "Antigravity workflows are UI slash commands; CLI resume is not supported." + ) + elif self.platform == "windsurf": + raise ValueError( + "Windsurf workflows are UI slash commands; CLI resume is not supported." + ) + elif self.platform == "qoder": + return ["qodercli", "--resume", session_id] + elif self.platform == "codebuddy": + raise ValueError( + "CodeBuddy does not support non-interactive mode (no CLI agent)" + ) + elif self.platform == "copilot": + raise ValueError( + "GitHub Copilot is IDE-only; CLI resume is not supported." + ) + elif self.platform == "droid": + raise ValueError( + "Factory Droid CLI resume is not yet supported." + ) + elif self.platform == "pi": + return ["pi", "-c", session_id] + else: + return ["claude", "--resume", session_id] + + def get_resume_command_str(self, session_id: str, cwd: str | None = None) -> str: + """Get human-readable resume command string. + + Args: + session_id: Session ID to resume + cwd: Optional working directory to cd into + + Returns: + Command string for display + """ + cmd = self.build_resume_command(session_id) + cmd_str = " ".join(cmd) + + if cwd: + return f"cd {cwd} && {cmd_str}" + return cmd_str + + # ========================================================================= + # Platform Detection Helpers + # ========================================================================= + + @property + def is_opencode(self) -> bool: + """Check if platform is OpenCode.""" + return self.platform == "opencode" + + @property + def is_claude(self) -> bool: + """Check if platform is Claude Code.""" + return self.platform == "claude" + + @property + def is_cursor(self) -> bool: + """Check if platform is Cursor.""" + return self.platform == "cursor" + + @property + def is_iflow(self) -> bool: + """Check if platform is iFlow CLI.""" + return self.platform == "iflow" + + @property + def cli_name(self) -> str: + """Get CLI executable name. + + Note: Cursor doesn't have a CLI tool, returns None-like value. + """ + if self.is_opencode: + return "opencode" + elif self.is_cursor: + return "cursor" # Note: Cursor is IDE-only, no CLI + elif self.platform == "iflow": + return "iflow" + elif self.platform == "kiro": + return "kiro" + elif self.platform == "gemini": + return "gemini" + elif self.platform == "antigravity": + return "agy" + elif self.platform == "windsurf": + return "windsurf" + elif self.platform == "qoder": + return "qodercli" + elif self.platform == "codebuddy": + return "codebuddy" + elif self.platform == "copilot": + return "copilot" + elif self.platform == "droid": + return "droid" + elif self.platform == "pi": + return "pi" + else: + return "claude" + + @property + def supports_cli_agents(self) -> bool: + """Check if platform supports running agents via CLI. + + Claude Code, OpenCode, iFlow, and Codex support CLI agent execution. + Cursor is IDE-only and doesn't support CLI agents. + """ + return self.platform in ("claude", "opencode", "iflow", "codex", "pi") + + @property + def requires_agent_definition_file(self) -> bool: + """Check if platform requires an agent definition file (.md/.toml) to run. + + Claude Code, OpenCode, iFlow: require agent .md files (--agent flag). + Codex: auto-discovers agents from .codex/agents/*.toml, no --agent flag. + """ + return self.platform in ("claude", "opencode", "iflow") + + # ========================================================================= + # Session ID Handling + # ========================================================================= + + @property + def supports_session_id_on_create(self) -> bool: + """Check if platform supports specifying session ID on creation. + + Claude Code: Yes (--session-id) + OpenCode: No (auto-generated, extract from logs) + iFlow: No (no session ID support) + """ + return self.platform == "claude" + + def extract_session_id_from_log(self, log_content: str) -> str | None: + """Extract session ID from log output (OpenCode only). + + OpenCode generates session IDs in format: ses_xxx + + Args: + log_content: Log file content + + Returns: + Session ID if found, None otherwise + """ + import re + + # OpenCode session ID pattern + match = re.search(r"ses_[a-zA-Z0-9]+", log_content) + if match: + return match.group(0) + return None + + +# ============================================================================= +# Factory Function +# ============================================================================= + + +def get_cli_adapter(platform: str = "claude") -> CLIAdapter: + """Get CLI adapter for the specified platform. + + Args: + platform: Platform name ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'windsurf', 'qoder', 'codebuddy', 'copilot', 'droid', or 'pi') + + Returns: + CLIAdapter instance + + Raises: + ValueError: If platform is not supported + """ + if platform not in ( + "claude", + "opencode", + "cursor", + "iflow", + "codex", + "kilo", + "kiro", + "gemini", + "antigravity", + "windsurf", + "qoder", + "codebuddy", + "copilot", + "droid", + "pi", + ): + raise ValueError( + f"Unsupported platform: {platform} (must be 'claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'windsurf', 'qoder', 'codebuddy', 'copilot', 'droid', or 'pi')" + ) + + return CLIAdapter(platform=platform) # type: ignore + + +_ALL_PLATFORM_CONFIG_DIRS = ( + ".claude", + ".cursor", + ".iflow", + ".opencode", + ".codex", + ".kilocode", + ".kiro", + ".gemini", + ".agent", + ".windsurf", + ".qoder", + ".codebuddy", + ".github/copilot", + ".factory", + ".pi", +) +"""Platform-specific config directory names used by detect_platform exclusion +checks. `.agents/skills/` is NOT listed here: it is a shared cross-platform +layer (written by Codex, also consumed by Amp/Cline/Warp/etc. via the +agentskills.io standard), not a single-platform signal. Its presence must not +block detection of Kiro, Antigravity, Windsurf, or other platforms.""" + + +def _has_other_platform_dir(project_root: Path, exclude: set[str]) -> bool: + """Check if any platform config dir exists besides those in *exclude*.""" + return any( + (project_root / d).is_dir() + for d in _ALL_PLATFORM_CONFIG_DIRS + if d not in exclude + ) + + +def detect_platform(project_root: Path) -> Platform: + """Auto-detect platform based on existing config directories. + + Detection order: + 1. TRELLIS_PLATFORM environment variable (if set) + 2. .opencode directory exists → opencode + 3. .iflow directory exists → iflow + 4. .cursor directory exists (without .claude) → cursor + 5. .codex exists and no other platform dirs → codex + 6. .kilocode directory exists → kilo + 7. .kiro/skills exists and no other platform dirs → kiro + 8. .gemini directory exists → gemini + 9. .agent/workflows exists and no other platform dirs → antigravity + 10. .windsurf/workflows exists and no other platform dirs → windsurf + 11. .codebuddy directory exists → codebuddy + 12. .qoder directory exists → qoder + 13. .pi directory exists → pi + 14. Default → claude + + Args: + project_root: Project root directory + + Returns: + Detected platform ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'windsurf', 'qoder', 'codebuddy', 'copilot', 'droid', 'pi', or default 'claude') + """ + import os + + # Check environment variable first + env_platform = os.environ.get("TRELLIS_PLATFORM", "").lower() + if env_platform in ( + "claude", + "opencode", + "cursor", + "iflow", + "codex", + "kilo", + "kiro", + "gemini", + "antigravity", + "windsurf", + "qoder", + "codebuddy", + "copilot", + "droid", + "pi", + ): + return env_platform # type: ignore + + # Check for .opencode directory (OpenCode-specific) + if (project_root / ".opencode").is_dir(): + return "opencode" + + # Check for .iflow directory (iFlow-specific) + if (project_root / ".iflow").is_dir(): + return "iflow" + + # Check for .cursor directory (Cursor-specific) + # Only detect as cursor if .claude doesn't exist (to avoid confusion) + if (project_root / ".cursor").is_dir() and not (project_root / ".claude").is_dir(): + return "cursor" + + # Check for .gemini directory (Gemini CLI-specific) + if (project_root / ".gemini").is_dir(): + return "gemini" + + # Check for .codex directory (Codex-specific) + # .agents/skills/ alone does NOT trigger codex detection (it's a shared standard) + if (project_root / ".codex").is_dir() and not _has_other_platform_dir( + project_root, {".codex", ".agents"} + ): + return "codex" + + # Check for .kilocode directory (Kilo-specific) + if (project_root / ".kilocode").is_dir(): + return "kilo" + + # Check for Kiro skills directory only when no other platform config exists + if (project_root / ".kiro" / "skills").is_dir() and not _has_other_platform_dir( + project_root, {".kiro"} + ): + return "kiro" + + # Check for Antigravity workflow directory only when no other platform config exists + if ( + project_root / ".agent" / "workflows" + ).is_dir() and not _has_other_platform_dir( + project_root, {".agent", ".gemini"} + ): + return "antigravity" + + # Check for Windsurf workflow directory only when no other platform config exists + if ( + project_root / ".windsurf" / "workflows" + ).is_dir() and not _has_other_platform_dir( + project_root, {".windsurf"} + ): + return "windsurf" + + # Check for .codebuddy directory (CodeBuddy-specific) + if (project_root / ".codebuddy").is_dir(): + return "codebuddy" + + # Check for .qoder directory (Qoder-specific) + if (project_root / ".qoder").is_dir(): + return "qoder" + + # Check for .github/copilot directory (GitHub Copilot-specific) + if (project_root / ".github" / "copilot").is_dir(): + return "copilot" + + # Check for .factory directory (Factory Droid-specific) + if (project_root / ".factory").is_dir(): + return "droid" + + # Check for .pi directory (Pi Agent-specific) + if (project_root / ".pi").is_dir(): + return "pi" + + # Fallback: checkout only has the Codex shared-skills layer + # (.agents/skills/trellis-* dirs) and no explicit platform config dir. + # Happens on fresh clones where .codex/ is gitignored/absent but the + # shared skills were committed to git. Must guard against the case + # where .claude/ or any other platform dir also exists — .agents/skills/ + # can legitimately coexist with any platform as a shared consumption + # layer for Amp/Cline/Warp/etc. + agents_skills = project_root / ".agents" / "skills" + if agents_skills.is_dir() and not _has_other_platform_dir( + project_root, set() + ): + try: + for entry in agents_skills.iterdir(): + if entry.is_dir() and entry.name.startswith("trellis-"): + return "codex" + except OSError: + pass + + return "claude" + + +def get_cli_adapter_auto(project_root: Path) -> CLIAdapter: + """Get CLI adapter with auto-detected platform. + + Args: + project_root: Project root directory + + Returns: + CLIAdapter instance for detected platform + """ + platform = detect_platform(project_root) + return CLIAdapter(platform=platform) diff --git a/.trellis/scripts/common/config.py b/.trellis/scripts/common/config.py new file mode 100644 index 0000000..93df643 --- /dev/null +++ b/.trellis/scripts/common/config.py @@ -0,0 +1,445 @@ +#!/usr/bin/env python3 +""" +Trellis configuration reader. + +Reads settings from .trellis/config.yaml with sensible defaults. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from .paths import DIR_WORKFLOW, get_repo_root + + +# ============================================================================= +# YAML Simple Parser (no dependencies) +# ============================================================================= + + +def _unquote(s: str) -> str: + """Remove exactly one layer of matching surrounding quotes. + + Unlike str.strip('"'), this only removes the outermost pair, + preserving any nested quotes inside the value. + + Examples: + _unquote('"hello"') -> 'hello' + _unquote("'hello'") -> 'hello' + _unquote('"echo \\'hi\\'"') -> "echo 'hi'" + _unquote('hello') -> 'hello' + _unquote('"hello\\'') -> '"hello\\'' (mismatched, unchanged) + """ + if len(s) >= 2 and s[0] == s[-1] and s[0] in ('"', "'"): + return s[1:-1] + return s + + +def _strip_inline_comment(value: str) -> str: + """Strip ` # …` inline comments while preserving `#` inside quoted strings. + + YAML treats ` #` (space-hash) as a comment opener; bare `#` inside a token + is part of the value. Quoted strings are immune. + + Mirrors :func:`common.trellis_config._strip_inline_comment` so both + parsers handle ``key: value # comment`` identically. + """ + in_quote: str | None = None + for idx, ch in enumerate(value): + if in_quote: + if ch == in_quote: + in_quote = None + continue + if ch in ('"', "'"): + in_quote = ch + continue + if ch == "#" and (idx == 0 or value[idx - 1].isspace()): + return value[:idx] + return value + + +def parse_simple_yaml(content: str) -> dict: + """Parse simple YAML with nested dict support (no dependencies). + + Supports: + - key: value (string) + - key: (followed by list items) + - item1 + - item2 + - key: (followed by nested dict) + nested_key: value + nested_key2: + - item + + Uses indentation to detect nesting (2+ spaces deeper = child). + + Args: + content: YAML content string. + + Returns: + Parsed dict (values can be str, list[str], or dict). + """ + lines = content.splitlines() + result: dict = {} + _parse_yaml_block(lines, 0, 0, result) + return result + + +def _parse_yaml_block( + lines: list[str], start: int, min_indent: int, target: dict +) -> int: + """Parse a YAML block into target dict, returning next line index.""" + i = start + current_list: list | None = None + + while i < len(lines): + line = lines[i] + stripped = line.strip() + + # Skip empty lines and comments + if not stripped or stripped.startswith("#"): + i += 1 + continue + + # Calculate indentation + indent = len(line) - len(line.lstrip()) + + # If dedented past our block, we're done + if indent < min_indent: + break + + if stripped.startswith("- "): + if current_list is not None: + current_list.append(_unquote(stripped[2:].strip())) + i += 1 + elif ":" in stripped: + key, _, value = stripped.partition(":") + key = key.strip() + value = _strip_inline_comment(value).strip() + value = _unquote(value) + current_list = None + + if value: + # key: value + target[key] = value + i += 1 + else: + # key: (no value) — peek ahead to determine list vs nested dict + next_i, next_line = _next_content_line(lines, i + 1) + if next_i >= len(lines): + target[key] = {} + i = next_i + elif next_line.strip().startswith("- "): + # It's a list + current_list = [] + target[key] = current_list + i += 1 + else: + next_indent = len(next_line) - len(next_line.lstrip()) + if next_indent > indent: + # It's a nested dict + nested: dict = {} + target[key] = nested + i = _parse_yaml_block(lines, i + 1, next_indent, nested) + else: + # Empty value, same or less indent follows + target[key] = {} + i += 1 + else: + i += 1 + + return i + + +def _next_content_line(lines: list[str], start: int) -> tuple[int, str]: + """Find the next non-empty, non-comment line.""" + i = start + while i < len(lines): + stripped = lines[i].strip() + if stripped and not stripped.startswith("#"): + return i, lines[i] + i += 1 + return i, "" + + +# Defaults +DEFAULT_SESSION_COMMIT_MESSAGE = "chore: record journal" +DEFAULT_MAX_JOURNAL_LINES = 2000 +DEFAULT_SESSION_AUTO_COMMIT = True + +CONFIG_FILE = "config.yaml" + + +def _is_true_config_value(value: object) -> bool: + """Return True when a config value represents an enabled flag.""" + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() == "true" + return False + + +def _get_config_path(repo_root: Path | None = None) -> Path: + """Get path to config.yaml.""" + root = repo_root or get_repo_root() + return root / DIR_WORKFLOW / CONFIG_FILE + + +def _load_config(repo_root: Path | None = None) -> dict: + """Load and parse config.yaml. Returns empty dict on any error.""" + config_file = _get_config_path(repo_root) + try: + content = config_file.read_text(encoding="utf-8") + return parse_simple_yaml(content) + except (OSError, IOError): + return {} + + +def get_session_commit_message(repo_root: Path | None = None) -> str: + """Get the commit message for auto-committing session records.""" + config = _load_config(repo_root) + return config.get("session_commit_message", DEFAULT_SESSION_COMMIT_MESSAGE) + + +def get_max_journal_lines(repo_root: Path | None = None) -> int: + """Get the maximum lines per journal file.""" + config = _load_config(repo_root) + value = config.get("max_journal_lines", DEFAULT_MAX_JOURNAL_LINES) + try: + return int(value) + except (ValueError, TypeError): + return DEFAULT_MAX_JOURNAL_LINES + + +def get_session_auto_commit(repo_root: Path | None = None) -> bool: + """Whether scripts should auto-stage + auto-commit session/task changes. + + Governs both ``add_session.py:_auto_commit_workspace`` and + ``task_store.py:_auto_commit_archive``. + + Default: ``True`` (existing behavior — auto-stage + auto-commit). + Set ``session_auto_commit: false`` in ``.trellis/config.yaml`` to skip + auto-staging entirely; the journal/archive files are still written to + disk, but the user manages ``git add`` / ``git commit`` themselves. + + Accepts native YAML booleans (``true`` / ``false``) and the string + aliases ``true / false / yes / no / 1 / 0 / on / off`` (case-insensitive). + Invalid values fall back to ``True`` with a stderr warning. + """ + config = _load_config(repo_root) + raw = config.get("session_auto_commit", DEFAULT_SESSION_AUTO_COMMIT) + if isinstance(raw, bool): + return raw + s = str(raw).strip().lower() + if s in ("true", "yes", "1", "on"): + return True + if s in ("false", "no", "0", "off"): + return False + print( + f"[WARN] invalid session_auto_commit value: {raw!r}; using true (default)", + file=sys.stderr, + ) + return DEFAULT_SESSION_AUTO_COMMIT + + +def get_hooks(event: str, repo_root: Path | None = None) -> list[str]: + """Get hook commands for a lifecycle event. + + Args: + event: Event name (e.g. "after_create", "after_archive"). + repo_root: Repository root path. + + Returns: + List of shell commands to execute, empty if none configured. + """ + config = _load_config(repo_root) + hooks = config.get("hooks") + if not isinstance(hooks, dict): + return [] + commands = hooks.get(event) + if isinstance(commands, list): + return [str(c) for c in commands] + return [] + + +# ============================================================================= +# Monorepo / Packages +# ============================================================================= + + +def get_packages(repo_root: Path | None = None) -> dict[str, dict] | None: + """Get monorepo package declarations. + + Returns: + Dict mapping package name to its config (path, type, etc.), + or None if not configured (single-repo mode). + + Example return: + {"cli": {"path": "packages/cli"}, "docs-site": {"path": "docs-site", "type": "submodule"}} + """ + config = _load_config(repo_root) + packages = config.get("packages") + if not isinstance(packages, dict): + return None + # Ensure each value is a dict (filter out scalar entries) + filtered = {k: v for k, v in packages.items() if isinstance(v, dict)} + if not filtered: + return None + return filtered + + +def get_default_package(repo_root: Path | None = None) -> str | None: + """Get the default package name from config. + + Returns: + Package name string, or None if not configured. + """ + config = _load_config(repo_root) + value = config.get("default_package") + return str(value) if value else None + + +def get_submodule_packages(repo_root: Path | None = None) -> dict[str, str]: + """Get packages that are git submodules. + + Returns: + Dict mapping package name to its path for submodule-type packages. + Empty dict if none configured. + + Example return: + {"docs-site": "docs-site"} + """ + packages = get_packages(repo_root) + if packages is None: + return {} + return { + name: cfg.get("path", name) + for name, cfg in packages.items() + if cfg.get("type") == "submodule" + } + + +def get_git_packages(repo_root: Path | None = None) -> dict[str, str]: + """Get packages that have their own independent git repository. + + These are sub-directories with their own .git (not submodules), + marked with ``git: true`` in config.yaml. + + Returns: + Dict mapping package name to its path for git-repo packages. + Empty dict if none configured. + + Example config:: + + packages: + backend: + path: iqs + git: true + + Example return:: + + {"backend": "iqs"} + """ + packages = get_packages(repo_root) + if packages is None: + return {} + return { + name: cfg.get("path", name) + for name, cfg in packages.items() + if _is_true_config_value(cfg.get("git")) + } + + +def is_monorepo(repo_root: Path | None = None) -> bool: + """Check if the project is configured as a monorepo (has packages in config).""" + return get_packages(repo_root) is not None + + +def get_spec_base(package: str | None = None, repo_root: Path | None = None) -> str: + """Get the spec directory base path relative to .trellis/. + + Single-repo: returns "spec" + Monorepo with package: returns "spec/<package>" + Monorepo without package: returns "spec" (caller should specify package) + """ + if package and is_monorepo(repo_root): + return f"spec/{package}" + return "spec" + + +def validate_package(package: str, repo_root: Path | None = None) -> bool: + """Check if a package name is valid in this project. + + Single-repo (no packages configured): always returns True. + Monorepo: returns True only if package exists in config.yaml packages. + """ + packages = get_packages(repo_root) + if packages is None: + return True # Single-repo, no validation needed + return package in packages + + +def resolve_package( + task_package: str | None = None, + repo_root: Path | None = None, +) -> str | None: + """Resolve package from inferred sources with validation. + + Checks in order: task_package → default_package. + Invalid inferred values print a warning to stderr and are skipped. + + Returns: + Resolved package name, or None if no valid package found. + + Note: + CLI --package should be validated separately by the caller + (fail-fast with available packages list on error). + """ + packages = get_packages(repo_root) + if packages is None: + return None # Single-repo, no package needed + + # Try task_package (guard against non-string values from malformed JSON) + if task_package and isinstance(task_package, str): + if task_package in packages: + return task_package + print( + f"Warning: task.json package '{task_package}' not found in config, skipping", + file=sys.stderr, + ) + + # Try default_package + default = get_default_package(repo_root) + if default: + if default in packages: + return default + print( + f"Warning: default_package '{default}' not found in config, skipping", + file=sys.stderr, + ) + + return None + + +def get_spec_scope(repo_root: Path | None = None) -> list[str] | str | None: + """Get session.spec_scope configuration. + + Returns: + list[str]: Package names to include in spec scanning. + str: "active_task" to use current task's package. + None: No scope configured (scan all packages). + """ + config = _load_config(repo_root) + session = config.get("session") + if not isinstance(session, dict): + return None + + scope = session.get("spec_scope") + if scope is None: + return None + if isinstance(scope, str): + return scope # e.g. "active_task" + if isinstance(scope, list): + return [str(s) for s in scope] + return None diff --git a/.trellis/scripts/common/developer.py b/.trellis/scripts/common/developer.py new file mode 100644 index 0000000..f422778 --- /dev/null +++ b/.trellis/scripts/common/developer.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +""" +Developer management utilities. + +Provides: + init_developer - Initialize developer + ensure_developer - Ensure developer is initialized (exit if not) + show_developer_info - Show developer information +""" + +from __future__ import annotations + +import sys +from datetime import datetime +from pathlib import Path + +from .paths import ( + DIR_WORKFLOW, + DIR_WORKSPACE, + DIR_TASKS, + FILE_DEVELOPER, + FILE_JOURNAL_PREFIX, + get_repo_root, + get_developer, + check_developer, +) + + +# ============================================================================= +# Developer Initialization +# ============================================================================= + +def init_developer(name: str, repo_root: Path | None = None) -> bool: + """Initialize developer. + + Creates: + - .trellis/.developer file with developer info + - .trellis/workspace/<name>/ directory structure + - Initial journal file and index.md + + Args: + name: Developer name. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + True on success, False on error. + """ + if not name: + print("Error: developer name is required", file=sys.stderr) + return False + + if repo_root is None: + repo_root = get_repo_root() + + dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER + workspace_dir = repo_root / DIR_WORKFLOW / DIR_WORKSPACE / name + + # Create .developer file + initialized_at = datetime.now().isoformat() + try: + dev_file.write_text( + f"name={name}\ninitialized_at={initialized_at}\n", + encoding="utf-8" + ) + except (OSError, IOError) as e: + print(f"Error: Failed to create .developer file: {e}", file=sys.stderr) + return False + + # Create workspace directory structure + try: + workspace_dir.mkdir(parents=True, exist_ok=True) + except (OSError, IOError) as e: + print(f"Error: Failed to create workspace directory: {e}", file=sys.stderr) + return False + + # Create initial journal file + journal_file = workspace_dir / f"{FILE_JOURNAL_PREFIX}1.md" + if not journal_file.exists(): + today = datetime.now().strftime("%Y-%m-%d") + journal_content = f"""# Journal - {name} (Part 1) + +> AI development session journal +> Started: {today} + +--- + +""" + try: + journal_file.write_text(journal_content, encoding="utf-8") + except (OSError, IOError) as e: + print(f"Error: Failed to create journal file: {e}", file=sys.stderr) + return False + + # Create index.md with markers for auto-update + index_file = workspace_dir / "index.md" + if not index_file.exists(): + index_content = f"""# Workspace Index - {name} + +> Journal tracking for AI development sessions. + +--- + +## Current Status + +<!-- @@@auto:current-status --> +- **Active File**: `journal-1.md` +- **Total Sessions**: 0 +- **Last Active**: - +<!-- @@@/auto:current-status --> + +--- + +## Active Documents + +<!-- @@@auto:active-documents --> +| File | Lines | Status | +|------|-------|--------| +| `journal-1.md` | ~0 | Active | +<!-- @@@/auto:active-documents --> + +--- + +## Session History + +<!-- @@@auto:session-history --> +| # | Date | Title | Commits | Branch | +|---|------|-------|---------|--------| +<!-- @@@/auto:session-history --> + +--- + +## Notes + +- Sessions are appended to journal files +- New journal file created when current exceeds 2000 lines +- Use `add_session.py` to record sessions +""" + try: + index_file.write_text(index_content, encoding="utf-8") + except (OSError, IOError) as e: + print(f"Error: Failed to create index.md: {e}", file=sys.stderr) + return False + + print(f"Developer initialized: {name}") + print(f" .developer file: {dev_file}") + print(f" Workspace dir: {workspace_dir}") + + return True + + +def ensure_developer(repo_root: Path | None = None) -> None: + """Ensure developer is initialized, exit if not. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + """ + if repo_root is None: + repo_root = get_repo_root() + + if not check_developer(repo_root): + print("Error: Developer not initialized.", file=sys.stderr) + print(f"Run: python ./{DIR_WORKFLOW}/scripts/init_developer.py <your-name>", file=sys.stderr) + sys.exit(1) + + +def show_developer_info(repo_root: Path | None = None) -> None: + """Show developer information. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + """ + if repo_root is None: + repo_root = get_repo_root() + + developer = get_developer(repo_root) + + if not developer: + print("Developer: (not initialized)") + else: + print(f"Developer: {developer}") + print(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/") + print(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/") + + +# ============================================================================= +# Main Entry (for testing) +# ============================================================================= + +if __name__ == "__main__": + show_developer_info() diff --git a/.trellis/scripts/common/git.py b/.trellis/scripts/common/git.py new file mode 100644 index 0000000..c4bf29f --- /dev/null +++ b/.trellis/scripts/common/git.py @@ -0,0 +1,31 @@ +""" +Git command execution utility. + +Single source of truth for running git commands across all Trellis scripts. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def run_git(args: list[str], cwd: Path | None = None) -> tuple[int, str, str]: + """Run a git command and return (returncode, stdout, stderr). + + Uses UTF-8 encoding with -c i18n.logOutputEncoding=UTF-8 to ensure + consistent output across all platforms (Windows, macOS, Linux). + """ + try: + git_args = ["git", "-c", "i18n.logOutputEncoding=UTF-8"] + args + result = subprocess.run( + git_args, + cwd=cwd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + return result.returncode, result.stdout, result.stderr + except Exception as e: + return 1, "", str(e) diff --git a/.trellis/scripts/common/git_context.py b/.trellis/scripts/common/git_context.py new file mode 100644 index 0000000..23fc6ec --- /dev/null +++ b/.trellis/scripts/common/git_context.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Git and Session Context utilities. + +Entry shim — delegates to session_context and packages_context. + +Provides: + output_json - Output context in JSON format + output_text - Output context in text format +""" + +from __future__ import annotations + +import json + +from .git import run_git +from .session_context import ( + get_context_json, + get_context_text, + get_context_record_json, + get_context_text_record, + output_json, + output_text, +) +from .packages_context import ( + get_context_packages_text, + get_context_packages_json, +) +from .trellis_config import read_trellis_config +from .workflow_phase import ( + filter_platform, + get_phase_index, + get_step, + resolve_effective_platform, +) + +# Backward-compatible alias — external modules import this name +_run_git_command = run_git + + +# ============================================================================= +# Main Entry +# ============================================================================= + +def main() -> None: + """CLI entry point.""" + import argparse + + parser = argparse.ArgumentParser(description="Get Session Context for AI Agent") + parser.add_argument( + "--json", + "-j", + action="store_true", + help="Output in JSON format (works with any --mode)", + ) + parser.add_argument( + "--mode", + "-m", + choices=["default", "record", "packages", "phase"], + default="default", + help="Output mode: default (full context), record (for record-session), packages (package info only), phase (workflow step extraction)", + ) + parser.add_argument( + "--step", + help="Step id for --mode phase, e.g. 1.1, 2.2. Omit to get the Phase Index.", + ) + parser.add_argument( + "--platform", + help="Platform name for --mode phase, e.g. cursor, claude-code. Filters platform-tagged blocks.", + ) + + args = parser.parse_args() + + if args.mode == "record": + if args.json: + print(json.dumps(get_context_record_json(), indent=2, ensure_ascii=False)) + else: + print(get_context_text_record()) + elif args.mode == "packages": + if args.json: + print(json.dumps(get_context_packages_json(), indent=2, ensure_ascii=False)) + else: + print(get_context_packages_text()) + elif args.mode == "phase": + content = get_step(args.step) if args.step else get_phase_index() + if not content.strip(): + if args.step: + parser.exit(2, f"Step not found: {args.step}\n") + else: + parser.exit(2, "Phase Index section not found in workflow.md\n") + if args.platform: + effective = resolve_effective_platform( + args.platform, read_trellis_config() + ) + content = filter_platform(content, effective) + print(content, end="") + else: + if args.json: + output_json() + else: + output_text() + + +if __name__ == "__main__": + main() diff --git a/.trellis/scripts/common/io.py b/.trellis/scripts/common/io.py new file mode 100644 index 0000000..44288f4 --- /dev/null +++ b/.trellis/scripts/common/io.py @@ -0,0 +1,37 @@ +""" +JSON file I/O utilities. + +Provides read_json and write_json as the single source of truth +for JSON file operations across all Trellis scripts. +""" + +from __future__ import annotations + +import json +from pathlib import Path + + +def read_json(path: Path) -> dict | None: + """Read and parse a JSON file. + + Returns None if the file doesn't exist, is invalid JSON, or can't be read. + """ + try: + return json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + + +def write_json(path: Path, data: dict) -> bool: + """Write dict to JSON file with pretty formatting. + + Returns True on success, False on error. + """ + try: + path.write_text( + json.dumps(data, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + return True + except (OSError, IOError): + return False diff --git a/.trellis/scripts/common/log.py b/.trellis/scripts/common/log.py new file mode 100644 index 0000000..839c643 --- /dev/null +++ b/.trellis/scripts/common/log.py @@ -0,0 +1,45 @@ +""" +Terminal output utilities: colors and structured logging. + +Single source of truth for Colors and log_* functions +used across all Trellis scripts. +""" + +from __future__ import annotations + + +class Colors: + """ANSI color codes for terminal output.""" + + RED = "\033[0;31m" + GREEN = "\033[0;32m" + YELLOW = "\033[1;33m" + BLUE = "\033[0;34m" + CYAN = "\033[0;36m" + DIM = "\033[2m" + NC = "\033[0m" # No Color / Reset + + +def colored(text: str, color: str) -> str: + """Apply ANSI color to text.""" + return f"{color}{text}{Colors.NC}" + + +def log_info(msg: str) -> None: + """Print info-level message with [INFO] prefix.""" + print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}") + + +def log_success(msg: str) -> None: + """Print success message with [SUCCESS] prefix.""" + print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}") + + +def log_warn(msg: str) -> None: + """Print warning message with [WARN] prefix.""" + print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}") + + +def log_error(msg: str) -> None: + """Print error message with [ERROR] prefix.""" + print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}") diff --git a/.trellis/scripts/common/packages_context.py b/.trellis/scripts/common/packages_context.py new file mode 100644 index 0000000..e7d4e8c --- /dev/null +++ b/.trellis/scripts/common/packages_context.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +""" +Package discovery and context output. + +Provides: + get_packages_info - Get structured package info + get_packages_section - Build PACKAGES text section + get_context_packages_text - Full packages text output (--mode packages) + get_context_packages_json - Full packages JSON output (--mode packages --json) +""" + +from __future__ import annotations + +from pathlib import Path + +from .config import _is_true_config_value, get_default_package, get_packages, get_spec_scope +from .paths import ( + DIR_SPEC, + DIR_WORKFLOW, + get_current_task, + get_repo_root, +) +from .tasks import load_task + + +# ============================================================================= +# Internal Helpers +# ============================================================================= + +def _scan_spec_layers(spec_dir: Path, package: str | None = None) -> list[str]: + """Scan spec directory for available layers (subdirectories). + + For monorepo: scans spec/<package>/ + For single-repo: scans spec/ + """ + target = spec_dir / package if package else spec_dir + if not target.is_dir(): + return [] + return sorted( + d.name for d in target.iterdir() if d.is_dir() and d.name != "guides" + ) + + +def _get_active_task_package(repo_root: Path) -> str | None: + """Get the package field from the active task's task.json.""" + current = get_current_task(repo_root) + if not current: + return None + ct = load_task(repo_root / current) + return ct.package if ct and ct.package else None + + +def _resolve_scope_set( + packages: dict, + spec_scope, + task_pkg: str | None, + default_pkg: str | None, +) -> set | None: + """Resolve spec_scope to a set of allowed package names, or None for full scan.""" + if not packages: + return None + + if spec_scope is None: + return None + + if isinstance(spec_scope, str) and spec_scope == "active_task": + if task_pkg and task_pkg in packages: + return {task_pkg} + if default_pkg and default_pkg in packages: + return {default_pkg} + return None + + if isinstance(spec_scope, list): + valid = {e for e in spec_scope if e in packages} + if valid: + return valid + # All invalid: fallback + if task_pkg and task_pkg in packages: + return {task_pkg} + if default_pkg and default_pkg in packages: + return {default_pkg} + return None + + return None + + +# ============================================================================= +# Public Functions +# ============================================================================= + +def get_packages_info(repo_root: Path) -> list[dict]: + """Get structured package info for monorepo projects. + + Returns list of dicts with keys: name, path, type, default, specLayers, + isSubmodule, isGitRepo. + Returns empty list for single-repo projects. + """ + packages = get_packages(repo_root) + if not packages: + return [] + + default_pkg = get_default_package(repo_root) + spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC + result = [] + + for pkg_name, pkg_config in packages.items(): + pkg_path = pkg_config.get("path", pkg_name) if isinstance(pkg_config, dict) else str(pkg_config) + pkg_type = pkg_config.get("type", "local") if isinstance(pkg_config, dict) else "local" + pkg_git = pkg_config.get("git", False) if isinstance(pkg_config, dict) else False + layers = _scan_spec_layers(spec_dir, pkg_name) + + result.append({ + "name": pkg_name, + "path": pkg_path, + "type": pkg_type, + "default": pkg_name == default_pkg, + "specLayers": layers, + "isSubmodule": pkg_type == "submodule", + "isGitRepo": _is_true_config_value(pkg_git), + }) + + return result + + +def get_packages_section(repo_root: Path) -> str: + """Build the PACKAGES section for text output.""" + spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC + pkg_info = get_packages_info(repo_root) + + lines: list[str] = [] + lines.append("## PACKAGES") + + if not pkg_info: + lines.append("(single-repo mode)") + layers = _scan_spec_layers(spec_dir) + if layers: + lines.append(f"Spec layers: {', '.join(layers)}") + return "\n".join(lines) + + default_pkg = get_default_package(repo_root) + + for pkg in pkg_info: + layers_str = f" [{', '.join(pkg['specLayers'])}]" if pkg["specLayers"] else "" + submodule_tag = " (submodule)" if pkg["isSubmodule"] else "" + git_repo_tag = " (git repo)" if pkg["isGitRepo"] else "" + default_tag = " *" if pkg["default"] else "" + lines.append( + f"- {pkg['name']:<16} {pkg['path']:<20}{layers_str}{submodule_tag}{git_repo_tag}{default_tag}" + ) + + if default_pkg: + lines.append(f"Default package: {default_pkg}") + + return "\n".join(lines) + + +def get_context_packages_text(repo_root: Path | None = None) -> str: + """Get packages context as formatted text (for --mode packages).""" + if repo_root is None: + repo_root = get_repo_root() + + pkg_info = get_packages_info(repo_root) + lines: list[str] = [] + + if not pkg_info: + spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC + lines.append("Single-repo project (no packages configured)") + lines.append("") + layers = _scan_spec_layers(spec_dir) + if layers: + lines.append(f"Spec layers: {', '.join(layers)}") + return "\n".join(lines) + + # Resolve scope for annotations + packages_dict = get_packages(repo_root) or {} + default_pkg = get_default_package(repo_root) + spec_scope = get_spec_scope(repo_root) + task_pkg = _get_active_task_package(repo_root) + scope_set = _resolve_scope_set(packages_dict, spec_scope, task_pkg, default_pkg) + + lines.append("## PACKAGES") + lines.append("") + for pkg in pkg_info: + default_tag = " (default)" if pkg["default"] else "" + type_tag = f" [{pkg['type']}]" if pkg["type"] != "local" else "" + git_tag = " [git repo]" if pkg["isGitRepo"] else "" + + # Scope annotation + scope_tag = "" + if scope_set is not None and pkg["name"] not in scope_set: + scope_tag = " (out of scope)" + + lines.append(f"### {pkg['name']}{default_tag}{type_tag}{git_tag}{scope_tag}") + lines.append(f"Path: {pkg['path']}") + if pkg["specLayers"]: + lines.append(f"Spec layers: {', '.join(pkg['specLayers'])}") + for layer in pkg["specLayers"]: + lines.append(f" - .trellis/spec/{pkg['name']}/{layer}/index.md") + else: + lines.append("Spec: not configured") + lines.append("") + + # Also show shared guides + guides_dir = repo_root / DIR_WORKFLOW / DIR_SPEC / "guides" + if guides_dir.is_dir(): + lines.append("### Shared Guides (always included)") + lines.append("Path: .trellis/spec/guides/index.md") + lines.append("") + + return "\n".join(lines) + + +def get_context_packages_json(repo_root: Path | None = None) -> dict: + """Get packages context as a dictionary (for --mode packages --json).""" + if repo_root is None: + repo_root = get_repo_root() + + pkg_info = get_packages_info(repo_root) + + if not pkg_info: + spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC + layers = _scan_spec_layers(spec_dir) + return { + "mode": "single-repo", + "specLayers": layers, + } + + default_pkg = get_default_package(repo_root) + spec_scope = get_spec_scope(repo_root) + task_pkg = _get_active_task_package(repo_root) + + return { + "mode": "monorepo", + "packages": pkg_info, + "defaultPackage": default_pkg, + "specScope": spec_scope, + "activeTaskPackage": task_pkg, + } diff --git a/.trellis/scripts/common/paths.py b/.trellis/scripts/common/paths.py new file mode 100644 index 0000000..1c5a58e --- /dev/null +++ b/.trellis/scripts/common/paths.py @@ -0,0 +1,447 @@ +#!/usr/bin/env python3 +""" +Common path utilities for Trellis workflow. + +Provides: + get_repo_root - Get repository root directory + get_developer - Get developer name + get_workspace_dir - Get developer workspace directory + get_tasks_dir - Get tasks directory + get_active_journal_file - Get current journal file +""" + +from __future__ import annotations + +import re +from datetime import datetime +from pathlib import Path + + +# ============================================================================= +# Path Constants (change here to rename directories) +# ============================================================================= + +# Directory names +DIR_WORKFLOW = ".trellis" +DIR_WORKSPACE = "workspace" +DIR_TASKS = "tasks" +DIR_ARCHIVE = "archive" +DIR_SPEC = "spec" +DIR_SCRIPTS = "scripts" + +# File names +FILE_DEVELOPER = ".developer" +FILE_CURRENT_TASK = ".current-task" +FILE_TASK_JSON = "task.json" +FILE_JOURNAL_PREFIX = "journal-" + + +# ============================================================================= +# Repository Root +# ============================================================================= + +def get_repo_root(start_path: Path | None = None) -> Path: + """Find the nearest directory containing .trellis/ folder. + + This handles nested git repos correctly (e.g., test project inside another repo). + + Args: + start_path: Starting directory to search from. Defaults to current directory. + + Returns: + Path to repository root, or current directory if no .trellis/ found. + """ + current = (start_path or Path.cwd()).resolve() + + while current != current.parent: + if (current / DIR_WORKFLOW).is_dir(): + return current + current = current.parent + + # Fallback to current directory if no .trellis/ found + return Path.cwd().resolve() + + +# ============================================================================= +# Developer +# ============================================================================= + +def get_developer(repo_root: Path | None = None) -> str | None: + """Get developer name from .developer file. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Developer name or None if not initialized. + """ + if repo_root is None: + repo_root = get_repo_root() + + dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER + + if not dev_file.is_file(): + return None + + try: + content = dev_file.read_text(encoding="utf-8") + for line in content.splitlines(): + if line.startswith("name="): + return line.split("=", 1)[1].strip() + except (OSError, IOError): + pass + + return None + + +def check_developer(repo_root: Path | None = None) -> bool: + """Check if developer is initialized. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + True if developer is initialized. + """ + return get_developer(repo_root) is not None + + +# ============================================================================= +# Tasks Directory +# ============================================================================= + +def get_tasks_dir(repo_root: Path | None = None) -> Path: + """Get tasks directory path. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Path to tasks directory. + """ + if repo_root is None: + repo_root = get_repo_root() + return repo_root / DIR_WORKFLOW / DIR_TASKS + + +# ============================================================================= +# Workspace Directory +# ============================================================================= + +def get_workspace_dir(repo_root: Path | None = None) -> Path | None: + """Get developer workspace directory. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Path to workspace directory or None if developer not set. + """ + if repo_root is None: + repo_root = get_repo_root() + + developer = get_developer(repo_root) + if developer: + return repo_root / DIR_WORKFLOW / DIR_WORKSPACE / developer + return None + + +# ============================================================================= +# Journal File +# ============================================================================= + +def get_active_journal_file(repo_root: Path | None = None) -> Path | None: + """Get the current active journal file. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Path to active journal file or None if not found. + """ + if repo_root is None: + repo_root = get_repo_root() + + workspace_dir = get_workspace_dir(repo_root) + if workspace_dir is None or not workspace_dir.is_dir(): + return None + + latest: Path | None = None + highest = 0 + + for f in workspace_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"): + if not f.is_file(): + continue + + # Extract number from filename + name = f.stem # e.g., "journal-1" + match = re.search(r"(\d+)$", name) + if match: + num = int(match.group(1)) + if num > highest: + highest = num + latest = f + + return latest + + +def count_lines(file_path: Path) -> int: + """Count lines in a file. + + Args: + file_path: Path to file. + + Returns: + Number of lines, or 0 if file doesn't exist. + """ + if not file_path.is_file(): + return 0 + + try: + return len(file_path.read_text(encoding="utf-8").splitlines()) + except (OSError, IOError): + return 0 + + +# ============================================================================= +# Current Task Management +# ============================================================================= + +def normalize_task_ref(task_ref: str) -> str: + """Normalize a task ref for stable runtime storage. + + Stored refs should prefer repo-relative POSIX paths like + `.trellis/tasks/03-27-my-task`, even on Windows. Absolute paths are preserved + unless they can later be converted back to repo-relative form by callers. + """ + normalized = task_ref.strip() + if not normalized: + return "" + + path_obj = Path(normalized) + if path_obj.is_absolute(): + return str(path_obj) + + normalized = normalized.replace("\\", "/") + while normalized.startswith("./"): + normalized = normalized[2:] + + if normalized.startswith(f"{DIR_TASKS}/"): + return f"{DIR_WORKFLOW}/{normalized}" + + return normalized + + +def resolve_task_ref(task_ref: str, repo_root: Path | None = None) -> Path | None: + """Resolve a task ref to an absolute task directory path.""" + if repo_root is None: + repo_root = get_repo_root() + + normalized = normalize_task_ref(task_ref) + if not normalized: + return None + + path_obj = Path(normalized) + if path_obj.is_absolute(): + return path_obj + + if normalized.startswith(f"{DIR_WORKFLOW}/"): + return repo_root / path_obj + + return repo_root / DIR_WORKFLOW / DIR_TASKS / path_obj + + +def get_current_task( + repo_root: Path | None = None, + platform_input: dict | None = None, + platform: str | None = None, +) -> str | None: + """Get current task directory path (relative to repo_root). + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Relative path to current task directory or None. + """ + if repo_root is None: + repo_root = get_repo_root() + + from .active_task import resolve_active_task + + return resolve_active_task(repo_root, platform_input, platform).task_path + + +def get_current_task_abs( + repo_root: Path | None = None, + platform_input: dict | None = None, + platform: str | None = None, +) -> Path | None: + """Get current task directory absolute path. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Absolute path to current task directory or None. + """ + if repo_root is None: + repo_root = get_repo_root() + + relative = get_current_task(repo_root, platform_input, platform) + if relative: + return resolve_task_ref(relative, repo_root) + return None + + +def get_current_task_source( + repo_root: Path | None = None, + platform_input: dict | None = None, + platform: str | None = None, +) -> tuple[str, str | None, str | None]: + """Get active task source as (`source`, `context_key`, `task_path`).""" + if repo_root is None: + repo_root = get_repo_root() + + from .active_task import get_current_task_source as _get_source + + return _get_source(repo_root, platform_input, platform) + + +def set_current_task( + task_path: str, + repo_root: Path | None = None, + platform_input: dict | None = None, + platform: str | None = None, +) -> bool: + """Set current task in session scope. + + Args: + task_path: Task directory path (relative to repo_root). + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + True on success, False on error. + """ + if repo_root is None: + repo_root = get_repo_root() + + from .active_task import set_active_task + + return set_active_task( + task_path, + repo_root, + platform_input=platform_input, + platform=platform, + ) is not None + + +def clear_current_task( + repo_root: Path | None = None, + platform_input: dict | None = None, + platform: str | None = None, +) -> bool: + """Clear current task in session scope. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + True on success. + """ + if repo_root is None: + repo_root = get_repo_root() + + from .active_task import clear_active_task + + clear_active_task( + repo_root, + platform_input=platform_input, + platform=platform, + ) + return True + + +def has_current_task(repo_root: Path | None = None) -> bool: + """Check if has current task. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + True if current task is set. + """ + return get_current_task(repo_root) is not None + + +# ============================================================================= +# Task ID Generation +# ============================================================================= + +def generate_task_date_prefix() -> str: + """Generate task ID based on date (MM-DD format). + + Returns: + Date prefix string (e.g., "01-21"). + """ + return datetime.now().strftime("%m-%d") + + +# ============================================================================= +# Monorepo / Package Paths +# ============================================================================= + + +def get_spec_dir(package: str | None = None, repo_root: Path | None = None) -> Path: + """Get the spec directory path. + + Single-repo: .trellis/spec + Monorepo with package: .trellis/spec/<package> + + Uses lazy import to avoid circular dependency with config.py. + """ + if repo_root is None: + repo_root = get_repo_root() + + from .config import get_spec_base + + base = get_spec_base(package, repo_root) + return repo_root / DIR_WORKFLOW / base + + +def get_package_path(package: str, repo_root: Path | None = None) -> Path | None: + """Get a package's source directory absolute path from config. + + Returns: + Absolute path to the package directory, or None if not found. + """ + if repo_root is None: + repo_root = get_repo_root() + + from .config import get_packages + + packages = get_packages(repo_root) + if not packages or package not in packages: + return None + + info = packages[package] + if isinstance(info, dict): + rel_path = info.get("path", package) + else: + rel_path = str(info) + + return repo_root / rel_path + + +# ============================================================================= +# Main Entry (for testing) +# ============================================================================= + +if __name__ == "__main__": + repo = get_repo_root() + print(f"Repository root: {repo}") + print(f"Developer: {get_developer(repo)}") + print(f"Tasks dir: {get_tasks_dir(repo)}") + print(f"Workspace dir: {get_workspace_dir(repo)}") + print(f"Journal file: {get_active_journal_file(repo)}") + print(f"Current task: {get_current_task(repo)}") diff --git a/.trellis/scripts/common/safe_commit.py b/.trellis/scripts/common/safe_commit.py new file mode 100644 index 0000000..4174191 --- /dev/null +++ b/.trellis/scripts/common/safe_commit.py @@ -0,0 +1,285 @@ +""" +Safe git-add helpers for Trellis-owned paths. + +Why this module exists +---------------------- +A real user incident: a project's `.gitignore` listed `.trellis/` (company-wide +template / personal habit). When `add_session.py` and `task.py archive` ran +their auto-commit and `git add` failed with `ignored by .gitignore`, the AI +agent driving the workflow "fixed" it by retrying with +`git add -f .trellis/` — which fan-out-included every ignored subtree +(`.trellis/.backup-*/`, `.trellis/worktrees/`, `.trellis/.template-hashes.json`, +`.trellis/.runtime/`), committing 548 files / 83474 lines of caches/backups. + +Design +------ +- Scripts only stage SPECIFIC product paths (journal files, index.md, the + current task dir, the archive dir). Never the whole `.trellis/` tree. +- If plain `git add <specific>` fails with "ignored by", DO NOT retry with + ``-f``. The presence of `.trellis/` in `.gitignore` is treated as user + intent ("keep .trellis/ local-only"). The script warns and skips the + auto-commit; users who want auto-staging can either fix their `.gitignore` + or set ``session_auto_commit: false`` and manage git themselves. +- The warning includes a negative example: ``Do NOT use `git add -f .trellis/` ...`` + so any AI rereading the log doesn't reinvent the bug. + +History note: 0.5.10 introduced an automatic ``git add -f`` retry on the +specific paths. That was reverted in 0.5.11 — auto-forcing into a tree the +user had gitignored violates user intent even when the path list is narrow. +The wider-grain forbidden command stays forbidden, and the narrow-grain auto +``-f`` is gone too. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from .git import run_git +from .paths import ( + DIR_ARCHIVE, + DIR_TASKS, + DIR_WORKFLOW, + DIR_WORKSPACE, + FILE_JOURNAL_PREFIX, + get_developer, +) + + +# Paths under .trellis/ that must NEVER be auto-staged. Listed here so the +# warning to the user can show concrete subpaths to ignore individually +# instead of ignoring the whole `.trellis/` tree. +TRELLIS_IGNORED_SUBPATHS = ( + ".trellis/.backup-*", + ".trellis/worktrees/", + ".trellis/.template-hashes.json", + ".trellis/.runtime/", + ".trellis/.cache/", +) + + +def safe_trellis_paths_to_add(repo_root: Path) -> list[str]: + """Return the list of repo-relative paths the auto-commit should stage. + + Only includes paths that exist on disk so callers don't pass non-existent + arguments to git. The caller is responsible for `git diff --cached` + checking afterwards. + + Included: + - .trellis/workspace/<developer>/journal-*.md + - .trellis/workspace/<developer>/index.md + - .trellis/tasks/<task-dir>/ (every active task directory) + - .trellis/tasks/archive/ (whole archive subtree, if present) + + Excluded (intentionally — these must not be staged): + - .trellis/.backup-*, .trellis/worktrees/, + .trellis/.template-hashes.json, .trellis/.runtime/, .trellis/.cache/ + """ + paths: list[str] = [] + + # Workspace journal files + index.md + developer = get_developer(repo_root) + if developer: + ws = repo_root / DIR_WORKFLOW / DIR_WORKSPACE / developer + if ws.is_dir(): + for f in sorted(ws.glob(f"{FILE_JOURNAL_PREFIX}*.md")): + if f.is_file(): + paths.append( + f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{f.name}" + ) + index_md = ws / "index.md" + if index_md.is_file(): + paths.append( + f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/index.md" + ) + + # Active tasks: each direct child of tasks/ that is a directory and not + # the archive root. The archive subtree is added as a single path below. + tasks_dir = repo_root / DIR_WORKFLOW / DIR_TASKS + if tasks_dir.is_dir(): + for child in sorted(tasks_dir.iterdir()): + if not child.is_dir(): + continue + if child.name == DIR_ARCHIVE: + continue + paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child.name}") + + archive_dir = tasks_dir / DIR_ARCHIVE + if archive_dir.is_dir(): + paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}") + + return paths + + +def safe_archive_paths_to_add( + repo_root: Path, + task_name: str | None = None, + modified_children: list[str] | None = None, +) -> list[str]: + """Return paths to stage after `task.py archive`. + + Scoped to ONLY the paths the archive operation actually touched: + + - the archive subtree (where the freshly-moved task lives) + - the source task directory (for source-side deletes; caller pairs + this with `git rm --cached` since `git add` won't stage deletes + for a path that no longer exists in the working tree) + - any child task directories whose `task.json` was edited to drop + the archived parent (parent-children relationship update) + + This narrow scope avoids "scope creep" — dirty changes in OTHER + active task dirs (parallel-window edits) are NOT bundled into the + archive commit. Callers handle each kind of change in its own + commit boundary. + + Backwards-compat: with no arguments, the function walks the whole + `.trellis/tasks/` subtree the old way (active tasks + archive). New + callers should always pass `task_name`. + """ + paths: list[str] = [] + tasks_dir = repo_root / DIR_WORKFLOW / DIR_TASKS + if not tasks_dir.is_dir(): + return paths + + archive_dir = tasks_dir / DIR_ARCHIVE + + if task_name is not None: + # Narrow scope — only paths that still exist on disk (so + # `git add` doesn't choke on the moved-away source). The caller + # handles the source-side deletes via `git rm --cached` + # explicitly. + if archive_dir.is_dir(): + paths.append( + f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}" + ) + for child_name in modified_children or []: + paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child_name}") + return paths + + # Legacy wide scope (no task_name): preserve old behavior so callers + # that have not been updated keep working. + if archive_dir.is_dir(): + paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}") + for child in sorted(tasks_dir.iterdir()): + if not child.is_dir(): + continue + if child.name == DIR_ARCHIVE: + continue + paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child.name}") + return paths + + +def _stderr_indicates_ignored(stderr: str) -> bool: + """git add error indicates the path is excluded by .gitignore.""" + if not stderr: + return False + lowered = stderr.lower() + return "ignored by" in lowered + + +def safe_git_add( + paths: list[str], repo_root: Path +) -> tuple[bool, bool, str]: + """Run `git add` on specific paths; never retry with -f. + + Returns ``(success, used_force, stderr)``. The ``used_force`` field is + kept for signature compatibility with the 0.5.10 implementation but is + always ``False`` — we never auto-force. + + Behavior: + - No paths passed → success, no force, empty stderr. + - Plain ``git add -- <paths>`` succeeds → return success. + - Plain fails (any reason — ignored or otherwise) → return failure with + the stderr. Callers should inspect the stderr (see + :func:`print_gitignore_warning`) and skip the auto-commit. + """ + if not paths: + return True, False, "" + + rc, _, err = run_git(["add", "--", *paths], cwd=repo_root) + if rc == 0: + return True, False, "" + return False, False, err + + +def print_gitignore_warning(paths: list[str]) -> None: + """Explain to the user (and any AI reading the log) what to do. + + CRITICAL: includes the negative example + ``Do NOT use `git add -f .trellis/``` — agents reading the warning are + known to invent that command, which fans out to ignored caches/backups. + """ + print( + "[WARN] git add failed because .trellis/ paths are ignored by your .gitignore.", + file=sys.stderr, + ) + print( + "[WARN] Skipping auto-commit. The journal/task files were still written to disk;", + file=sys.stderr, + ) + print( + "[WARN] git was not touched.", + file=sys.stderr, + ) + print("[WARN]", file=sys.stderr) + print( + "[WARN] Trellis manages these specific paths and they should be tracked:", + file=sys.stderr, + ) + if paths: + for p in paths: + print(f"[WARN] {p}", file=sys.stderr) + else: + print( + "[WARN] .trellis/workspace/<developer>/{journal-*.md,index.md}", + file=sys.stderr, + ) + print( + "[WARN] .trellis/tasks/<task-dir>/", + file=sys.stderr, + ) + print( + "[WARN] .trellis/tasks/archive/", + file=sys.stderr, + ) + print("[WARN]", file=sys.stderr) + print( + "[WARN] Recommended: change your .gitignore from `.trellis/` to specific", + file=sys.stderr, + ) + print( + "[WARN] subpaths that should remain ignored, e.g.:", + file=sys.stderr, + ) + for sub in TRELLIS_IGNORED_SUBPATHS: + print(f"[WARN] {sub}", file=sys.stderr) + print("[WARN]", file=sys.stderr) + print( + "[WARN] Or, if you intentionally keep .trellis/ local-only, set in", + file=sys.stderr, + ) + print( + "[WARN] .trellis/config.yaml:", + file=sys.stderr, + ) + print( + "[WARN] session_auto_commit: false", + file=sys.stderr, + ) + print( + "[WARN] so the scripts skip git entirely and you can review / commit", + file=sys.stderr, + ) + print( + "[WARN] manually with `git status` / `git add` / `git commit`.", + file=sys.stderr, + ) + print("[WARN]", file=sys.stderr) + print( + "[WARN] Do NOT use `git add -f .trellis/` — it pulls in backups, worktrees,", + file=sys.stderr, + ) + print( + "[WARN] and runtime caches that should never be committed.", + file=sys.stderr, + ) diff --git a/.trellis/scripts/common/session_context.py b/.trellis/scripts/common/session_context.py new file mode 100644 index 0000000..b4de49a --- /dev/null +++ b/.trellis/scripts/common/session_context.py @@ -0,0 +1,821 @@ +#!/usr/bin/env python3 +""" +Session context generation (default + record modes). + +Provides: + get_context_json - JSON output for default mode + get_context_text - Text output for default mode + get_context_record_json - JSON for record mode + get_context_text_record - Text for record mode + output_json - Print JSON + output_text - Print text +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +from pathlib import Path + +from .active_task import resolve_context_key +from .config import get_git_packages +from .git import run_git +from .packages_context import get_packages_section +from .tasks import iter_active_tasks, load_task, get_all_statuses, children_progress +from .paths import ( + DIR_SCRIPTS, + DIR_SPEC, + DIR_TASKS, + DIR_WORKFLOW, + DIR_WORKSPACE, + count_lines, + get_active_journal_file, + get_current_task, + get_current_task_source, + get_developer, + get_repo_root, + get_tasks_dir, +) + + +# ============================================================================= +# Helpers +# ============================================================================= + +_PACKAGE_NAME = "@mindfoldhq/trellis" +_UPDATE_CHECK_TIMEOUT_SECONDS = 1.0 +_VERSION_RE = re.compile( + r"^\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?\s*$" +) +_VERSION_TOKEN_RE = re.compile(r"\b\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?\b") +_POLYREPO_IGNORED_DIRS = { + "node_modules", + "target", + "dist", + "build", + "out", + "bin", + "obj", + "vendor", + "coverage", + "tmp", + "__pycache__", +} +_POLYREPO_SCAN_MAX_DEPTH = 2 + + +def _is_git_worktree(path: Path) -> bool: + """Return True when path is inside a Git worktree.""" + rc, out, _ = run_git(["rev-parse", "--is-inside-work-tree"], cwd=path) + return rc == 0 and out.strip().lower() == "true" + + +def _parse_recent_commits(log_output: str) -> list[dict]: + """Parse `git log --oneline` output into structured commit entries.""" + commits = [] + for line in log_output.splitlines(): + if not line.strip(): + continue + parts = line.split(" ", 1) + if len(parts) >= 2: + commits.append({"hash": parts[0], "message": parts[1]}) + elif len(parts) == 1: + commits.append({"hash": parts[0], "message": ""}) + return commits + + +def _collect_git_repo_info(name: str, rel_path: str, repo_dir: Path) -> dict | None: + """Collect Git status for one known repository directory.""" + if not (repo_dir / ".git").exists(): + return None + + _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_dir) + branch = branch_out.strip() or "unknown" + + _, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_dir) + changes = len([l for l in status_out.splitlines() if l.strip()]) + + _, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_dir) + + return { + "name": name, + "path": rel_path, + "branch": branch, + "isClean": changes == 0, + "uncommittedChanges": changes, + "recentCommits": _parse_recent_commits(log_out), + } + + +def _collect_root_git_info(repo_root: Path) -> dict: + """Collect root Git info without pretending a non-Git root is clean.""" + if not _is_git_worktree(repo_root): + return { + "isRepo": False, + "branch": "", + "isClean": False, + "uncommittedChanges": 0, + "recentCommits": [], + } + + _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) + branch = branch_out.strip() or "unknown" + + _, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root) + status_lines = [line for line in status_out.splitlines() if line.strip()] + + _, short_out, _ = run_git(["status", "--short"], cwd=repo_root) + + _, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root) + + return { + "isRepo": True, + "branch": branch, + "isClean": len(status_lines) == 0, + "uncommittedChanges": len(status_lines), + "statusShort": short_out.splitlines(), + "recentCommits": _parse_recent_commits(log_out), + } + + +def _discover_child_git_repos(repo_root: Path) -> list[tuple[str, str]]: + """Discover child Git repositories using the init-time polyrepo heuristic.""" + found: list[str] = [] + + def is_candidate_dir(path: Path) -> bool: + name = path.name + return not name.startswith(".") and name not in _POLYREPO_IGNORED_DIRS + + def scan(rel_dir: Path, depth: int) -> None: + if depth >= _POLYREPO_SCAN_MAX_DEPTH: + return + abs_dir = repo_root / rel_dir + try: + children = sorted(abs_dir.iterdir(), key=lambda p: p.name) + except OSError: + return + + for child in children: + if not child.is_dir() or not is_candidate_dir(child): + continue + + child_rel = ( + rel_dir / child.name if rel_dir != Path(".") else Path(child.name) + ) + if (child / ".git").exists(): + found.append(child_rel.as_posix()) + continue + scan(child_rel, depth + 1) + + scan(Path("."), 0) + if len(found) < 2: + return [] + return [(path.replace("/", "_"), path) for path in sorted(found)] + + +def _collect_package_git_info( + repo_root: Path, + discover_unconfigured: bool = False, +) -> list[dict]: + """Collect Git status for independent package repositories. + + Packages marked with ``git: true`` in config.yaml are authoritative. + When the Trellis root is not a Git repo and no configured package repos are + available, optionally fall back to the bounded polyrepo child scan. + + Returns: + List of dicts with keys: name, path, branch, isClean, + uncommittedChanges, recentCommits. + Empty list if no git-repo packages are configured. + """ + git_pkgs = get_git_packages(repo_root) + result = [] + for pkg_name, pkg_path in git_pkgs.items(): + pkg_dir = repo_root / pkg_path + info = _collect_git_repo_info(pkg_name, pkg_path, pkg_dir) + if info is not None: + result.append(info) + + if result or not discover_unconfigured: + return result + + discovered = [] + for pkg_name, pkg_path in _discover_child_git_repos(repo_root): + info = _collect_git_repo_info(pkg_name, pkg_path, repo_root / pkg_path) + if info is not None: + discovered.append(info) + return discovered + + +def _append_root_git_context(lines: list[str], root_git_info: dict) -> None: + """Append root Git status without misleading non-Git roots.""" + lines.append("## GIT STATUS") + if not root_git_info["isRepo"]: + lines.append("Root is not a Git repository.") + lines.append("Run Git commands from the package repository paths listed below.") + else: + lines.append(f"Branch: {root_git_info['branch']}") + if root_git_info["isClean"]: + lines.append("Working directory: Clean") + else: + lines.append( + f"Working directory: {root_git_info['uncommittedChanges']} " + "uncommitted change(s)" + ) + lines.append("") + lines.append("Changes:") + for line in root_git_info.get("statusShort", [])[:10]: + lines.append(line) + lines.append("") + + lines.append("## RECENT COMMITS") + if not root_git_info["isRepo"]: + lines.append( + "Root has no Git commit history because it is not a Git repository." + ) + elif root_git_info["recentCommits"]: + for commit in root_git_info["recentCommits"]: + lines.append(f"{commit['hash']} {commit['message']}") + else: + lines.append("(no commits)") + lines.append("") + + +def _append_package_git_context(lines: list[str], package_git_info: list[dict]) -> None: + """Append Git status and recent commits for package repositories.""" + for pkg in package_git_info: + lines.append(f"## GIT STATUS ({pkg['name']}: {pkg['path']})") + lines.append(f"Branch: {pkg['branch']}") + if pkg["isClean"]: + lines.append("Working directory: Clean") + else: + lines.append( + f"Working directory: {pkg['uncommittedChanges']} uncommitted change(s)" + ) + lines.append("") + lines.append(f"## RECENT COMMITS ({pkg['name']}: {pkg['path']})") + if pkg["recentCommits"]: + for commit in pkg["recentCommits"]: + lines.append(f"{commit['hash']} {commit['message']}") + else: + lines.append("(no commits)") + lines.append("") + + +def _read_project_version(repo_root: Path) -> str | None: + try: + version = (repo_root / DIR_WORKFLOW / ".version").read_text( + encoding="utf-8" + ).strip() + except OSError: + return None + return version or None + + +def _fetch_trellis_version_output() -> str | None: + try: + result = subprocess.run( + ["trellis", "--version"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=_UPDATE_CHECK_TIMEOUT_SECONDS, + ) + except (OSError, subprocess.SubprocessError, TimeoutError): + return None + + if result.returncode != 0: + return None + output = f"{result.stdout}\n{result.stderr}".strip() + return output or None + + +def _extract_available_update_version(output: str) -> str | None: + update_match = re.search( + r"Trellis update available:\s*" + r"(?P<current>\S+)\s*(?:→|->)\s*(?P<latest>\S+)", + output, + ) + if update_match: + return update_match.group("latest").strip() + candidates = _VERSION_TOKEN_RE.findall(output) + return candidates[-1] if candidates else None + + +def _resolve_available_update_version() -> str | None: + output = _fetch_trellis_version_output() + if not output: + return None + return _extract_available_update_version(output) + + +def _parse_version(version: str) -> tuple[tuple[int, int, int], tuple[str, ...] | None] | None: + match = _VERSION_RE.match(version) + if not match: + return None + major, minor, patch, prerelease = match.groups() + numbers = (int(major), int(minor or "0"), int(patch or "0")) + prerelease_parts = tuple(prerelease.split(".")) if prerelease else None + return numbers, prerelease_parts + + +def _compare_prerelease( + left: tuple[str, ...] | None, + right: tuple[str, ...] | None, +) -> int: + if left is None and right is None: + return 0 + if left is None: + return 1 + if right is None: + return -1 + + for left_part, right_part in zip(left, right): + if left_part == right_part: + continue + left_numeric = left_part.isdigit() + right_numeric = right_part.isdigit() + if left_numeric and right_numeric: + left_int = int(left_part) + right_int = int(right_part) + return (left_int > right_int) - (left_int < right_int) + if left_numeric: + return -1 + if right_numeric: + return 1 + return (left_part > right_part) - (left_part < right_part) + + return (len(left) > len(right)) - (len(left) < len(right)) + + +def _compare_versions(left: str, right: str) -> int | None: + parsed_left = _parse_version(left) + parsed_right = _parse_version(right) + if parsed_left is None or parsed_right is None: + return None + + left_numbers, left_prerelease = parsed_left + right_numbers, right_prerelease = parsed_right + if left_numbers != right_numbers: + return (left_numbers > right_numbers) - (left_numbers < right_numbers) + return _compare_prerelease(left_prerelease, right_prerelease) + + +def _update_marker_path(repo_root: Path) -> Path: + context_key = resolve_context_key() + if not context_key: + terminal_key = os.environ.get("TERM_SESSION_ID", "").strip() + context_key = terminal_key or f"ppid-{os.getppid()}" + safe_key = re.sub(r"[^A-Za-z0-9._-]+", "_", context_key).strip("._-") + if not safe_key: + safe_key = "session" + return ( + repo_root + / DIR_WORKFLOW + / ".runtime" + / f"update-check-{safe_key[:160]}.marker" + ) + + +def _mark_update_check_attempted(repo_root: Path) -> bool: + marker_path = _update_marker_path(repo_root) + if marker_path.exists(): + return False + try: + marker_path.parent.mkdir(parents=True, exist_ok=True) + marker_path.write_text("checked\n", encoding="utf-8") + except OSError: + pass + return True + + +def _get_update_hint(repo_root: Path) -> str | None: + marker_path = _update_marker_path(repo_root) + if marker_path.exists(): + return None + + current_version = _read_project_version(repo_root) + if not current_version: + return None + + latest_version = _resolve_available_update_version() + if not latest_version: + return None + + _mark_update_check_attempted(repo_root) + comparison = _compare_versions(current_version, latest_version) + if comparison is None or comparison >= 0: + return None + + return ( + f"Trellis update available: {current_version} -> {latest_version}, " + "run trellis upgrade" + ) + + +# ============================================================================= +# JSON Output +# ============================================================================= + +def get_context_json(repo_root: Path | None = None) -> dict: + """Get context as a dictionary. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Context dictionary. + """ + if repo_root is None: + repo_root = get_repo_root() + + developer = get_developer(repo_root) + tasks_dir = get_tasks_dir(repo_root) + journal_file = get_active_journal_file(repo_root) + + journal_lines = 0 + journal_relative = "" + if journal_file and developer: + journal_lines = count_lines(journal_file) + journal_relative = ( + f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}" + ) + + root_git_info = _collect_root_git_info(repo_root) + + # Tasks + tasks = [ + { + "dir": t.dir_name, + "name": t.name, + "status": t.status, + "children": list(t.children), + "parent": t.parent, + } + for t in iter_active_tasks(tasks_dir) + ] + + # Package git repos (independent sub-repositories) + pkg_git_info = _collect_package_git_info( + repo_root, + discover_unconfigured=not root_git_info["isRepo"], + ) + + result = { + "developer": developer or "", + "git": { + "isRepo": root_git_info["isRepo"], + "branch": root_git_info["branch"], + "isClean": root_git_info["isClean"], + "uncommittedChanges": root_git_info["uncommittedChanges"], + "recentCommits": root_git_info["recentCommits"], + }, + "tasks": { + "active": tasks, + "directory": f"{DIR_WORKFLOW}/{DIR_TASKS}", + }, + "journal": { + "file": journal_relative, + "lines": journal_lines, + "nearLimit": journal_lines > 1800, + }, + } + + if pkg_git_info: + result["packageGit"] = pkg_git_info + + return result + + +def output_json(repo_root: Path | None = None) -> None: + """Output context in JSON format. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + """ + context = get_context_json(repo_root) + print(json.dumps(context, indent=2, ensure_ascii=False)) + + +# ============================================================================= +# Text Output +# ============================================================================= + +def get_context_text(repo_root: Path | None = None) -> str: + """Get context as formatted text. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Formatted text output. + """ + if repo_root is None: + repo_root = get_repo_root() + + lines = [] + lines.append("========================================") + lines.append("SESSION CONTEXT") + lines.append("========================================") + lines.append("") + + developer = get_developer(repo_root) + + # Developer section + lines.append("## DEVELOPER") + if not developer: + lines.append( + f"ERROR: Not initialized. Run: python ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>" + ) + return "\n".join(lines) + + lines.append(f"Name: {developer}") + lines.append("") + + root_git_info = _collect_root_git_info(repo_root) + _append_root_git_context(lines, root_git_info) + + # Package git repos — independent sub-repositories + _append_package_git_context( + lines, + _collect_package_git_info( + repo_root, + discover_unconfigured=not root_git_info["isRepo"], + ), + ) + + # Current task + lines.append("## CURRENT TASK") + current_task = get_current_task(repo_root) + if current_task: + current_task_dir = repo_root / current_task + source_type, context_key, _ = get_current_task_source(repo_root) + lines.append(f"Path: {current_task}") + lines.append( + f"Source: {source_type}" + (f":{context_key}" if context_key else "") + ) + + ct = load_task(current_task_dir) + if ct: + lines.append(f"Name: {ct.name}") + lines.append(f"Status: {ct.status}") + lines.append(f"Created: {ct.raw.get('createdAt', 'unknown')}") + if ct.description: + lines.append(f"Description: {ct.description}") + + # Check for prd.md + prd_file = current_task_dir / "prd.md" + if prd_file.is_file(): + lines.append("") + lines.append("[!] This task has prd.md - read it for task details") + else: + lines.append("(none)") + lines.append("") + + # Active tasks + lines.append("## ACTIVE TASKS") + tasks_dir = get_tasks_dir(repo_root) + task_count = 0 + + # Collect all task data for hierarchy display + all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)} + all_statuses = {name: t.status for name, t in all_tasks.items()} + + def _print_task_tree(name: str, indent: int = 0) -> None: + nonlocal task_count + t = all_tasks[name] + progress = children_progress(t.children, all_statuses) + prefix = " " * indent + lines.append(f"{prefix}- {name}/ ({t.status}){progress} @{t.assignee or '-'}") + task_count += 1 + for child in t.children: + if child in all_tasks: + _print_task_tree(child, indent + 1) + + for dir_name in sorted(all_tasks.keys()): + if not all_tasks[dir_name].parent: + _print_task_tree(dir_name) + + if task_count == 0: + lines.append("(no active tasks)") + lines.append(f"Total: {task_count} active task(s)") + lines.append("") + + # My tasks + lines.append("## MY TASKS (Assigned to me)") + my_task_count = 0 + + for t in all_tasks.values(): + if t.assignee == developer and t.status != "done": + progress = children_progress(t.children, all_statuses) + lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress}") + my_task_count += 1 + + if my_task_count == 0: + lines.append("(no tasks assigned to you)") + lines.append("") + + # Journal file + lines.append("## JOURNAL FILE") + journal_file = get_active_journal_file(repo_root) + if journal_file: + journal_lines = count_lines(journal_file) + relative = f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}" + lines.append(f"Active file: {relative}") + lines.append(f"Line count: {journal_lines} / 2000") + if journal_lines > 1800: + lines.append("[!] WARNING: Approaching 2000 line limit!") + else: + lines.append("No journal file found") + lines.append("") + + # Packages + packages_text = get_packages_section(repo_root) + if packages_text: + lines.append(packages_text) + lines.append("") + + # Paths + lines.append("## PATHS") + lines.append(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/") + lines.append(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/") + lines.append(f"Spec: {DIR_WORKFLOW}/{DIR_SPEC}/") + lines.append("") + + lines.append("========================================") + + return "\n".join(lines) + + +# ============================================================================= +# Record Mode +# ============================================================================= + +def get_context_record_json(repo_root: Path | None = None) -> dict: + """Get record-mode context as a dictionary. + + Focused on: my active tasks, git status, current task. + """ + if repo_root is None: + repo_root = get_repo_root() + + developer = get_developer(repo_root) + tasks_dir = get_tasks_dir(repo_root) + + root_git_info = _collect_root_git_info(repo_root) + + # My tasks (single pass — collect statuses and filter by assignee) + all_tasks_list = list(iter_active_tasks(tasks_dir)) + all_statuses = {t.dir_name: t.status for t in all_tasks_list} + + my_tasks = [] + for t in all_tasks_list: + if t.assignee == developer: + done = sum( + 1 for c in t.children + if all_statuses.get(c) in ("completed", "done") + ) + my_tasks.append({ + "dir": t.dir_name, + "title": t.title, + "status": t.status, + "priority": t.priority, + "children": list(t.children), + "childrenDone": done, + "parent": t.parent, + "meta": t.meta, + }) + + # Current task + current_task_info = None + current_task = get_current_task(repo_root) + if current_task: + source_type, context_key, _ = get_current_task_source(repo_root) + ct = load_task(repo_root / current_task) + if ct: + current_task_info = { + "path": current_task, + "name": ct.name, + "status": ct.status, + "source": source_type, + "contextKey": context_key, + } + + # Package git repos + pkg_git_info = _collect_package_git_info( + repo_root, + discover_unconfigured=not root_git_info["isRepo"], + ) + + result = { + "developer": developer or "", + "git": { + "isRepo": root_git_info["isRepo"], + "branch": root_git_info["branch"], + "isClean": root_git_info["isClean"], + "uncommittedChanges": root_git_info["uncommittedChanges"], + "recentCommits": root_git_info["recentCommits"], + }, + "myTasks": my_tasks, + "currentTask": current_task_info, + } + + if pkg_git_info: + result["packageGit"] = pkg_git_info + + return result + + +def get_context_text_record(repo_root: Path | None = None) -> str: + """Get context as formatted text for record-session mode. + + Focused output: MY ACTIVE TASKS first (with [!!!] emphasis), + then GIT STATUS, RECENT COMMITS, CURRENT TASK. + """ + if repo_root is None: + repo_root = get_repo_root() + + lines: list[str] = [] + lines.append("========================================") + lines.append("SESSION CONTEXT (RECORD MODE)") + lines.append("========================================") + lines.append("") + + developer = get_developer(repo_root) + if not developer: + lines.append( + f"ERROR: Not initialized. Run: python ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>" + ) + return "\n".join(lines) + + # MY ACTIVE TASKS — first and prominent + lines.append(f"## [!!!] MY ACTIVE TASKS (Assigned to {developer})") + lines.append("[!] Review whether any should be archived before recording this session.") + lines.append("") + + tasks_dir = get_tasks_dir(repo_root) + my_task_count = 0 + + # Single pass — collect all tasks and filter by assignee + all_statuses = get_all_statuses(tasks_dir) + + for t in iter_active_tasks(tasks_dir): + if t.assignee == developer: + progress = children_progress(t.children, all_statuses) + lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress} — {t.dir_name}") + my_task_count += 1 + + if my_task_count == 0: + lines.append("(no active tasks assigned to you)") + lines.append("") + + root_git_info = _collect_root_git_info(repo_root) + _append_root_git_context(lines, root_git_info) + + # Package git repos — independent sub-repositories + _append_package_git_context( + lines, + _collect_package_git_info( + repo_root, + discover_unconfigured=not root_git_info["isRepo"], + ), + ) + + # CURRENT TASK + lines.append("## CURRENT TASK") + current_task = get_current_task(repo_root) + if current_task: + source_type, context_key, _ = get_current_task_source(repo_root) + lines.append(f"Path: {current_task}") + lines.append( + f"Source: {source_type}" + (f":{context_key}" if context_key else "") + ) + ct = load_task(repo_root / current_task) + if ct: + lines.append(f"Name: {ct.name}") + lines.append(f"Status: {ct.status}") + else: + lines.append("(none)") + lines.append("") + + lines.append("========================================") + + return "\n".join(lines) + + +def output_text(repo_root: Path | None = None) -> None: + """Output context in text format. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + """ + if repo_root is None: + repo_root = get_repo_root() + update_hint = _get_update_hint(repo_root) + if update_hint: + print(update_hint) + print("") + print(get_context_text(repo_root)) diff --git a/.trellis/scripts/common/task_context.py b/.trellis/scripts/common/task_context.py new file mode 100644 index 0000000..7ffc9f5 --- /dev/null +++ b/.trellis/scripts/common/task_context.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +""" +Task JSONL context management. + +Provides: + cmd_add_context - Add entry to JSONL context file + cmd_validate - Validate JSONL context files + cmd_list_context - List JSONL context entries + +Note: + ``cmd_init_context`` was removed in v0.5.0-beta.12. JSONL context files + are now seeded at ``task.py create`` time with a self-describing + ``_example`` line; the AI agent curates real entries during planning when + the task needs sub-agent/spec context. See ``.trellis/workflow.md`` for the + current planning artifact contract. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from .log import Colors, colored +from .paths import get_repo_root +from .task_utils import resolve_task_dir + + +# ============================================================================= +# Command: add-context +# ============================================================================= + +def cmd_add_context(args: argparse.Namespace) -> int: + """Add entry to JSONL context file.""" + repo_root = get_repo_root() + target_dir = resolve_task_dir(args.dir, repo_root) + + jsonl_name = args.file + path = args.path + reason = args.reason or "Added manually" + + if not target_dir.is_dir(): + print(colored(f"Error: Directory not found: {target_dir}", Colors.RED)) + return 1 + + # Support shorthand + if not jsonl_name.endswith(".jsonl"): + jsonl_name = f"{jsonl_name}.jsonl" + + jsonl_file = target_dir / jsonl_name + full_path = repo_root / path + + entry_type = "file" + if full_path.is_dir(): + entry_type = "directory" + if not path.endswith("/"): + path = f"{path}/" + elif not full_path.is_file(): + print(colored(f"Error: Path not found: {path}", Colors.RED)) + return 1 + + # Check if already exists + if jsonl_file.is_file(): + content = jsonl_file.read_text(encoding="utf-8") + if f'"{path}"' in content: + print(colored(f"Warning: Entry already exists for {path}", Colors.YELLOW)) + return 0 + + # Add entry + entry: dict + if entry_type == "directory": + entry = {"file": path, "type": "directory", "reason": reason} + else: + entry = {"file": path, "reason": reason} + + with jsonl_file.open("a", encoding="utf-8") as f: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + + print(colored(f"Added {entry_type}: {path}", Colors.GREEN)) + return 0 + + +# ============================================================================= +# Command: validate +# ============================================================================= + +def cmd_validate(args: argparse.Namespace) -> int: + """Validate JSONL context files.""" + repo_root = get_repo_root() + target_dir = resolve_task_dir(args.dir, repo_root) + + if not target_dir.is_dir(): + print(colored("Error: task directory required", Colors.RED)) + return 1 + + print(colored("=== Validating Context Files ===", Colors.BLUE)) + print(f"Target dir: {target_dir}") + print() + + total_errors = 0 + for jsonl_name in ["implement.jsonl", "check.jsonl"]: + jsonl_file = target_dir / jsonl_name + errors = _validate_jsonl(jsonl_file, repo_root) + total_errors += errors + + print() + if total_errors == 0: + print(colored("✓ All validations passed", Colors.GREEN)) + return 0 + else: + print(colored(f"✗ Validation failed ({total_errors} errors)", Colors.RED)) + return 1 + + +def _validate_jsonl(jsonl_file: Path, repo_root: Path) -> int: + """Validate a single JSONL file. + + Seed rows (no ``file`` field — typically ``{"_example": "..."}``) are + skipped silently; they are self-describing comments, not real entries. + """ + file_name = jsonl_file.name + errors = 0 + + if not jsonl_file.is_file(): + print(f" {colored(f'{file_name}: not found (skipped)', Colors.YELLOW)}") + return 0 + + line_num = 0 + real_entries = 0 + for line in jsonl_file.read_text(encoding="utf-8").splitlines(): + line_num += 1 + if not line.strip(): + continue + + try: + data = json.loads(line) + except json.JSONDecodeError: + print(f" {colored(f'{file_name}:{line_num}: Invalid JSON', Colors.RED)}") + errors += 1 + continue + + file_path = data.get("file") + entry_type = data.get("type", "file") + + if not file_path: + # Seed / comment row — skip silently + continue + + real_entries += 1 + full_path = repo_root / file_path + if entry_type == "directory": + if not full_path.is_dir(): + print(f" {colored(f'{file_name}:{line_num}: Directory not found: {file_path}', Colors.RED)}") + errors += 1 + else: + if not full_path.is_file(): + print(f" {colored(f'{file_name}:{line_num}: File not found: {file_path}', Colors.RED)}") + errors += 1 + + if errors == 0: + print(f" {colored(f'{file_name}: ✓ ({real_entries} entries)', Colors.GREEN)}") + else: + print(f" {colored(f'{file_name}: ✗ ({errors} errors)', Colors.RED)}") + + return errors + + +# ============================================================================= +# Command: list-context +# ============================================================================= + +def cmd_list_context(args: argparse.Namespace) -> int: + """List JSONL context entries.""" + repo_root = get_repo_root() + target_dir = resolve_task_dir(args.dir, repo_root) + + if not target_dir.is_dir(): + print(colored("Error: task directory required", Colors.RED)) + return 1 + + print(colored("=== Context Files ===", Colors.BLUE)) + print() + + for jsonl_name in ["implement.jsonl", "check.jsonl"]: + jsonl_file = target_dir / jsonl_name + if not jsonl_file.is_file(): + continue + + print(colored(f"[{jsonl_name}]", Colors.CYAN)) + + count = 0 + seed_only = True + for line in jsonl_file.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + + try: + data = json.loads(line) + except json.JSONDecodeError: + continue + + file_path = data.get("file") + if not file_path: + # Seed / comment row — don't count as a real entry + continue + seed_only = False + + count += 1 + entry_type = data.get("type", "file") + reason = data.get("reason", "-") + + if entry_type == "directory": + print(f" {colored(f'{count}.', Colors.GREEN)} [DIR] {file_path}") + else: + print(f" {colored(f'{count}.', Colors.GREEN)} {file_path}") + print(f" {colored('→', Colors.YELLOW)} {reason}") + + if seed_only: + print(f" {colored('(no curated entries yet — only seed row)', Colors.YELLOW)}") + + print() + + return 0 diff --git a/.trellis/scripts/common/task_queue.py b/.trellis/scripts/common/task_queue.py new file mode 100644 index 0000000..f7485e2 --- /dev/null +++ b/.trellis/scripts/common/task_queue.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +""" +Task queue utility functions. + +Provides: + list_tasks_by_status - List tasks by status + list_pending_tasks - List tasks with pending status + list_tasks_by_assignee - List tasks by assignee + list_my_tasks - List tasks assigned to current developer + get_task_stats - Get P0/P1/P2/P3 counts +""" + +from __future__ import annotations + +from pathlib import Path + +from .paths import ( + get_repo_root, + get_developer, + get_tasks_dir, +) +from .tasks import iter_active_tasks + + +# ============================================================================= +# Internal helper +# ============================================================================= + +def _task_to_dict(t) -> dict: + """Convert TaskInfo to the dict format callers expect.""" + return { + "priority": t.priority, + "id": t.raw.get("id", ""), + "title": t.title, + "status": t.status, + "assignee": t.assignee or "-", + "dir": t.dir_name, + "children": list(t.children), + "parent": t.parent, + } + + +# ============================================================================= +# Public Functions +# ============================================================================= + +def list_tasks_by_status( + filter_status: str | None = None, + repo_root: Path | None = None +) -> list[dict]: + """List tasks by status. + + Args: + filter_status: Optional status filter. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + List of task info dicts with keys: priority, id, title, status, assignee. + """ + if repo_root is None: + repo_root = get_repo_root() + + tasks_dir = get_tasks_dir(repo_root) + results = [] + + for t in iter_active_tasks(tasks_dir): + if filter_status and t.status != filter_status: + continue + results.append(_task_to_dict(t)) + + return results + + +def list_pending_tasks(repo_root: Path | None = None) -> list[dict]: + """List pending tasks. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + List of task info dicts. + """ + return list_tasks_by_status("planning", repo_root) + + +def list_tasks_by_assignee( + assignee: str, + filter_status: str | None = None, + repo_root: Path | None = None +) -> list[dict]: + """List tasks assigned to a specific developer. + + Args: + assignee: Developer name. + filter_status: Optional status filter. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + List of task info dicts. + """ + if repo_root is None: + repo_root = get_repo_root() + + tasks_dir = get_tasks_dir(repo_root) + results = [] + + for t in iter_active_tasks(tasks_dir): + if (t.assignee or "-") != assignee: + continue + if filter_status and t.status != filter_status: + continue + results.append(_task_to_dict(t)) + + return results + + +def list_my_tasks( + filter_status: str | None = None, + repo_root: Path | None = None +) -> list[dict]: + """List tasks assigned to current developer. + + Args: + filter_status: Optional status filter. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + List of task info dicts. + + Raises: + ValueError: If developer not set. + """ + if repo_root is None: + repo_root = get_repo_root() + + developer = get_developer(repo_root) + if not developer: + raise ValueError("Developer not set") + + return list_tasks_by_assignee(developer, filter_status, repo_root) + + +def get_task_stats(repo_root: Path | None = None) -> dict[str, int]: + """Get task statistics. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Dict with keys: P0, P1, P2, P3, Total. + """ + if repo_root is None: + repo_root = get_repo_root() + + tasks_dir = get_tasks_dir(repo_root) + stats = {"P0": 0, "P1": 0, "P2": 0, "P3": 0, "Total": 0} + + for t in iter_active_tasks(tasks_dir): + if t.priority in stats: + stats[t.priority] += 1 + stats["Total"] += 1 + + return stats + + +def format_task_stats(stats: dict[str, int]) -> str: + """Format task stats as string. + + Args: + stats: Stats dict from get_task_stats. + + Returns: + Formatted string like "P0:0 P1:1 P2:2 P3:0 Total:3". + """ + return f"P0:{stats['P0']} P1:{stats['P1']} P2:{stats['P2']} P3:{stats['P3']} Total:{stats['Total']}" + + +# ============================================================================= +# Main Entry (for testing) +# ============================================================================= + +if __name__ == "__main__": + stats = get_task_stats() + print(format_task_stats(stats)) + print() + print("Pending tasks:") + for task in list_pending_tasks(): + print(f" {task['priority']}|{task['id']}|{task['title']}|{task['status']}|{task['assignee']}") diff --git a/.trellis/scripts/common/task_store.py b/.trellis/scripts/common/task_store.py new file mode 100644 index 0000000..d4b527a --- /dev/null +++ b/.trellis/scripts/common/task_store.py @@ -0,0 +1,746 @@ +#!/usr/bin/env python3 +""" +Task CRUD operations. + +Provides: + ensure_tasks_dir - Ensure tasks directory exists + cmd_create - Create a new task + cmd_archive - Archive completed task + cmd_set_branch - Set git branch for task + cmd_set_base_branch - Set PR target branch + cmd_set_scope - Set scope for PR title + cmd_add_subtask - Link child task to parent + cmd_remove_subtask - Unlink child task from parent +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from datetime import datetime +from pathlib import Path + +from .config import ( + get_packages, + get_session_auto_commit, + is_monorepo, + resolve_package, + validate_package, +) +from .git import run_git +from .io import read_json, write_json +from .log import Colors, colored +from .paths import ( + DIR_ARCHIVE, + DIR_TASKS, + DIR_WORKFLOW, + FILE_TASK_JSON, + generate_task_date_prefix, + get_developer, + get_repo_root, + get_tasks_dir, +) +from .safe_commit import ( + print_gitignore_warning, + safe_archive_paths_to_add, + safe_git_add, +) +from .task_utils import ( + archive_task_complete, + find_task_by_name, + resolve_task_dir, + run_task_hooks, +) + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +def _slugify(title: str) -> str: + """Convert title to slug (only works with ASCII).""" + result = title.lower() + result = re.sub(r"[^a-z0-9]", "-", result) + result = re.sub(r"-+", "-", result) + result = result.strip("-") + return result + + +def ensure_tasks_dir(repo_root: Path) -> Path: + """Ensure tasks directory exists.""" + tasks_dir = get_tasks_dir(repo_root) + archive_dir = tasks_dir / "archive" + + if not tasks_dir.exists(): + tasks_dir.mkdir(parents=True) + print(colored(f"Created tasks directory: {tasks_dir}", Colors.GREEN), file=sys.stderr) + + if not archive_dir.exists(): + archive_dir.mkdir(parents=True) + + return tasks_dir + + +def _find_archived_task_by_dir_name(tasks_dir: Path, dir_name: str) -> Path | None: + """Find an archived task directory with the exact active-task dir name.""" + archive_dir = tasks_dir / DIR_ARCHIVE + if not archive_dir.is_dir(): + return None + + for month_dir in sorted(archive_dir.iterdir()): + if not month_dir.is_dir(): + continue + candidate = month_dir / dir_name + if candidate.is_dir(): + return candidate + + return None + + +def _repo_relative_path(path: Path, repo_root: Path) -> str: + """Format a path relative to the repo root when possible.""" + try: + return path.relative_to(repo_root).as_posix() + except ValueError: + return str(path) + + +# ============================================================================= +# Sub-agent platform detection + JSONL seeding +# ============================================================================= + +# Config directories of platforms that consume implement.jsonl / check.jsonl. +# Keep in sync with src/types/ai-tools.ts AI_TOOLS entries — these are the +# platforms listed in workflow.md's "agent-capable" Skill Routing block +# (Class-1 hook-inject + Class-2 pull-based preludes). Kilo / Antigravity / +# Windsurf are NOT in this list: they do not consume JSONL. +_SUBAGENT_CONFIG_DIRS: tuple[str, ...] = ( + ".claude", + ".cursor", + ".codex", + ".kiro", + ".gemini", + ".opencode", + ".qoder", + ".codebuddy", + ".factory", # Factory Droid + ".github/copilot", + ".pi", # Pi Agent +) + +_SEED_EXAMPLE = ( + "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. " + "Put spec/research files only — no code paths. " + "Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. " + "Delete this line once real entries are added." +) + + +def _has_subagent_platform(repo_root: Path) -> bool: + """Return True if any sub-agent-capable platform is configured. + + Detected by probing well-known config directories at the repo root. Used + only to decide whether ``task.py create`` should seed empty + ``implement.jsonl`` / ``check.jsonl`` files. + """ + for config_dir in _SUBAGENT_CONFIG_DIRS: + if (repo_root / config_dir).is_dir(): + return True + return False + + +def _write_seed_jsonl(path: Path) -> None: + """Write a one-line seed JSONL file with a self-describing ``_example``. + + The seed row has no ``file`` field, so downstream consumers (hooks + + preludes) that iterate entries via ``item.get("file")`` naturally skip + it. The row exists purely as an in-file prompt for the AI curator. + """ + seed = {"_example": _SEED_EXAMPLE} + path.write_text(json.dumps(seed, ensure_ascii=False) + "\n", encoding="utf-8") + + +def _default_prd_content(title: str, description: str | None = None) -> str: + """Return the default PRD skeleton created with every task.""" + goal = (description or "").strip() or "TBD." + heading = title.strip() or "Untitled task" + return f"""# {heading} + +## Goal + +{goal} + +## Requirements + +- TBD + +## Acceptance Criteria + +- [ ] TBD + +## Notes + +- Keep `prd.md` focused on requirements, constraints, and acceptance criteria. +- Lightweight tasks can remain PRD-only. +- For complex tasks, add `design.md` for technical design and `implement.md` for execution planning before `task.py start`. +""" + + +# ============================================================================= +# Command: create +# ============================================================================= + +def cmd_create(args: argparse.Namespace) -> int: + """Create a new task.""" + repo_root = get_repo_root() + + if not args.title: + print(colored("Error: title is required", Colors.RED), file=sys.stderr) + return 1 + + # Validate --package (CLI source: fail-fast) + package: str | None = getattr(args, "package", None) + if not is_monorepo(repo_root): + # Single-repo: ignore --package, no package prefix + if package: + print(colored(f"Warning: --package ignored in single-repo project", Colors.YELLOW), file=sys.stderr) + package = None + elif package: + if not validate_package(package, repo_root): + packages = get_packages(repo_root) + available = ", ".join(sorted(packages.keys())) if packages else "(none)" + print(colored(f"Error: unknown package '{package}'. Available: {available}", Colors.RED), file=sys.stderr) + return 1 + else: + # Inferred: default_package → None (no task.json yet for create) + package = resolve_package(repo_root=repo_root) + + # Default assignee to current developer + assignee = args.assignee + if not assignee: + assignee = get_developer(repo_root) + if not assignee: + print(colored("Error: No developer set. Run init_developer.py first or use --assignee", Colors.RED), file=sys.stderr) + return 1 + + ensure_tasks_dir(repo_root) + + # Get current developer as creator + creator = get_developer(repo_root) or assignee + + # Generate slug if not provided + slug = args.slug or _slugify(args.title) + if not slug: + print(colored("Error: could not generate slug from title", Colors.RED), file=sys.stderr) + return 1 + + # Create task directory with MM-DD-slug format + tasks_dir = get_tasks_dir(repo_root) + date_prefix = generate_task_date_prefix() + dir_name = f"{date_prefix}-{slug}" + task_dir = tasks_dir / dir_name + task_json_path = task_dir / FILE_TASK_JSON + + archived_task_dir = _find_archived_task_by_dir_name(tasks_dir, dir_name) + if archived_task_dir: + print(colored(f"Error: Task already archived: {dir_name}", Colors.RED), file=sys.stderr) + print(f"Archived at: {_repo_relative_path(archived_task_dir, repo_root)}", file=sys.stderr) + print("Use a new slug if you intend to create a new task.", file=sys.stderr) + return 1 + + if task_dir.exists(): + print(colored(f"Warning: Task directory already exists: {dir_name}", Colors.YELLOW), file=sys.stderr) + else: + task_dir.mkdir(parents=True) + + today = datetime.now().strftime("%Y-%m-%d") + + # Record current branch as base_branch (PR target) + _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) + current_branch = branch_out.strip() or "main" + + task_data = { + "id": slug, + "name": slug, + "title": args.title, + "description": args.description or "", + "status": "planning", + "dev_type": None, + "scope": None, + "package": package, + "priority": args.priority, + "creator": creator, + "assignee": assignee, + "createdAt": today, + "completedAt": None, + "branch": None, + "base_branch": current_branch, + "worktree_path": None, + "commit": None, + "pr_url": None, + "subtasks": [], + "children": [], + "parent": None, + "relatedFiles": [], + "notes": "", + "meta": {}, + } + + write_json(task_json_path, task_data) + + prd_path = task_dir / "prd.md" + if not prd_path.exists(): + prd_path.write_text( + _default_prd_content(args.title, args.description), + encoding="utf-8", + ) + + # Seed implement.jsonl / check.jsonl for sub-agent-capable platforms. + # Agent curates real entries during planning when the task needs them. + # Agent-less platforms (Kilo / Antigravity / Windsurf) skip this — they + # load specs via the trellis-before-dev skill instead of JSONL. + seeded_jsonl = False + if _has_subagent_platform(repo_root): + for jsonl_name in ("implement.jsonl", "check.jsonl"): + jsonl_path = task_dir / jsonl_name + if not jsonl_path.exists(): + _write_seed_jsonl(jsonl_path) + seeded_jsonl = True + + # Handle --parent: establish bidirectional link + if args.parent: + parent_dir = resolve_task_dir(args.parent, repo_root) + parent_json_path = parent_dir / FILE_TASK_JSON + if not parent_json_path.is_file(): + print(colored(f"Warning: Parent task.json not found: {args.parent}", Colors.YELLOW), file=sys.stderr) + else: + parent_data = read_json(parent_json_path) + if parent_data: + # Add child to parent's children list + parent_children = parent_data.get("children", []) + if dir_name not in parent_children: + parent_children.append(dir_name) + parent_data["children"] = parent_children + write_json(parent_json_path, parent_data) + + # Set parent in child's task.json + task_data["parent"] = parent_dir.name + write_json(task_json_path, task_data) + + print(colored(f"Linked as child of: {parent_dir.name}", Colors.GREEN), file=sys.stderr) + + # Auto-activate the new task so the per-turn breadcrumb fires planning + # state. Best-effort: gracefully degrade if no session identity (CLI run + # outside an AI session) — the task is still created, the user can run + # task.py start later. Pointer is session-scoped so this never affects + # other AI sessions. + try: + from .active_task import resolve_context_key, set_active_task + if resolve_context_key(): + try: + rel_dir = task_dir.relative_to(repo_root).as_posix() + except ValueError: + rel_dir = str(task_dir) + set_active_task(rel_dir, repo_root) + except Exception: + pass + + print(colored(f"Created task: {dir_name}", Colors.GREEN), file=sys.stderr) + print("", file=sys.stderr) + print(colored("Next steps:", Colors.BLUE), file=sys.stderr) + print(" - Fill prd.md with requirements and acceptance criteria", file=sys.stderr) + print(" - Lightweight task: PRD-only is valid", file=sys.stderr) + print(" - Complex task: add design.md and implement.md before task.py start", file=sys.stderr) + if seeded_jsonl: + print( + " - Curate implement.jsonl / check.jsonl as spec/research manifests when sub-agents need context", + file=sys.stderr, + ) + print(" - Use /trellis:continue or phase context to decide the next step", file=sys.stderr) + print("", file=sys.stderr) + + # Output relative path for script chaining + print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}") + + run_task_hooks("after_create", task_json_path, repo_root) + return 0 + + +# ============================================================================= +# Command: archive +# ============================================================================= + +def cmd_archive(args: argparse.Namespace) -> int: + """Archive completed task.""" + repo_root = get_repo_root() + task_name = args.name + + if not task_name: + print(colored("Error: Task name is required", Colors.RED), file=sys.stderr) + return 1 + + tasks_dir = get_tasks_dir(repo_root) + + # Resolve task directory (supports task name, relative path, or absolute path) + task_dir = resolve_task_dir(task_name, repo_root) + + if not task_dir or not task_dir.is_dir(): + print(colored(f"Error: Task not found: {task_name}", Colors.RED), file=sys.stderr) + print("Active tasks:", file=sys.stderr) + # Import lazily to avoid circular dependency + from .tasks import iter_active_tasks + for t in iter_active_tasks(tasks_dir): + print(f" - {t.dir_name}/", file=sys.stderr) + return 1 + + dir_name = task_dir.name + task_json_path = task_dir / FILE_TASK_JSON + + # Update status before archiving + today = datetime.now().strftime("%Y-%m-%d") + # Names of child task dirs whose task.json gets modified below; passed + # into safe_archive_paths_to_add so they're staged in this commit. + modified_children: list[str] = [] + if task_json_path.is_file(): + data = read_json(task_json_path) + if data: + data["status"] = "completed" + data["completedAt"] = today + write_json(task_json_path, data) + + # Handle subtask relationships on archive. + # Keep this task in its parent's children list so progress + # counters (children_progress) stay consistent — children + # missing from the active set are treated as completed. + task_children = data.get("children", []) + + # If this is a parent, clear parent field in all children + if task_children: + for child_name in task_children: + child_dir_path = find_task_by_name(child_name, tasks_dir) + if child_dir_path: + child_json = child_dir_path / FILE_TASK_JSON + if child_json.is_file(): + child_data = read_json(child_json) + if child_data: + child_data["parent"] = None + write_json(child_json, child_data) + modified_children.append(child_dir_path.name) + + # Clear any session that still points at this task before the path moves. + from .active_task import clear_task_from_sessions + clear_task_from_sessions(str(task_dir), repo_root) + + # Archive + result = archive_task_complete(task_dir, repo_root) + if "archived_to" in result: + archive_dest = Path(result["archived_to"]) + year_month = archive_dest.parent.name + print(colored(f"Archived: {dir_name} -> archive/{year_month}/", Colors.GREEN), file=sys.stderr) + + # Auto-commit unless --no-commit + if not getattr(args, "no_commit", False): + if not _auto_commit_archive(dir_name, repo_root, modified_children): + print( + colored( + "Archive moved on disk, but git auto-commit did not complete. " + "Resolve `git status` before continuing.", + Colors.RED, + ), + file=sys.stderr, + ) + return 1 + + # Return the archive path + print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{year_month}/{dir_name}") + + # Run hooks with the archived path + archived_json = archive_dest / FILE_TASK_JSON + run_task_hooks("after_archive", archived_json, repo_root) + return 0 + + return 1 + + +def _auto_commit_archive( + task_name: str, + repo_root: Path, + modified_children: list[str] | None = None, +) -> bool: + """Stage Trellis-owned task paths and commit after archive. + + Scoped narrowly to the archived task's source + destination paths + plus any child task dirs whose ``task.json`` was edited (parent → + children relationship update). Dirty changes in OTHER active task + dirs are NOT bundled into the archive commit. + + If ``.gitignore`` blocks the paths, we warn + skip — we do NOT + retry with ``git add -f``. The warning explicitly forbids + ``git add -f .trellis/`` (which would fan out to caches/backups) + and points users at ``session_auto_commit: false``. + + Honors ``session_auto_commit`` in ``.trellis/config.yaml``: when + set to ``false``, this function returns immediately without + touching git (the archive directory move on disk is unaffected). + """ + if not get_session_auto_commit(repo_root): + print( + "[OK] session_auto_commit: false — skipping git stage/commit.", + file=sys.stderr, + ) + return True + + source_rel = f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_name}" + rc, tracked_out, _ = run_git( + ["ls-files", "--", source_rel], + cwd=repo_root, + ) + source_was_tracked = rc == 0 and bool(tracked_out.strip()) + + paths = safe_archive_paths_to_add( + repo_root, task_name=task_name, modified_children=modified_children + ) + if not paths: + print("[OK] No task changes to commit.", file=sys.stderr) + return True + + success, _, err = safe_git_add(paths, repo_root) + if not success: + if err and "ignored by" in err.lower(): + print_gitignore_warning(paths) + else: + print( + f"[WARN] git add failed: {err.strip() if err else 'unknown error'}", + file=sys.stderr, + ) + return not source_was_tracked + + # Belt-and-suspenders for the phantom-delete bug: `safe_git_add` uses + # `git add` (no -A) which only stages additions/modifications. The + # source task directory was moved away by `shutil.move`, so its files + # need an explicit `git rm --cached` to stage the deletions in this + # same commit — otherwise they sit as uncommitted "phantom deletes" + # against HEAD until something later picks them up. + # + # `--ignore-unmatch` makes this a no-op when the task was never tracked + # (e.g. archiving a task that lived only in working tree). + run_git( + ["rm", "-r", "--cached", "--ignore-unmatch", "--", source_rel], + cwd=repo_root, + ) + + rc, _, _ = run_git( + ["diff", "--cached", "--quiet", "--", *paths, source_rel], + cwd=repo_root, + ) + if rc == 0: + print("[OK] No task changes to commit.", file=sys.stderr) + return True + + commit_msg = f"chore(task): archive {task_name}" + rc, _, err = run_git(["commit", "-m", commit_msg], cwd=repo_root) + if rc == 0: + print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr) + return True + else: + print(f"[WARN] Auto-commit failed: {err.strip()}", file=sys.stderr) + return not source_was_tracked + + +# ============================================================================= +# Command: add-subtask +# ============================================================================= + +def cmd_add_subtask(args: argparse.Namespace) -> int: + """Link a child task to a parent task.""" + repo_root = get_repo_root() + + parent_dir = resolve_task_dir(args.parent_dir, repo_root) + child_dir = resolve_task_dir(args.child_dir, repo_root) + + parent_json_path = parent_dir / FILE_TASK_JSON + child_json_path = child_dir / FILE_TASK_JSON + + if not parent_json_path.is_file(): + print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr) + return 1 + + if not child_json_path.is_file(): + print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr) + return 1 + + parent_data = read_json(parent_json_path) + child_data = read_json(child_json_path) + + if not parent_data or not child_data: + print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr) + return 1 + + # Check if child already has a parent + existing_parent = child_data.get("parent") + if existing_parent: + print(colored(f"Error: Child task already has a parent: {existing_parent}", Colors.RED), file=sys.stderr) + return 1 + + # Add child to parent's children list + parent_children = parent_data.get("children", []) + child_dir_name = child_dir.name + if child_dir_name not in parent_children: + parent_children.append(child_dir_name) + parent_data["children"] = parent_children + + # Set parent in child's task.json + child_data["parent"] = parent_dir.name + + # Write both + write_json(parent_json_path, parent_data) + write_json(child_json_path, child_data) + + print(colored(f"Linked: {child_dir.name} -> {parent_dir.name}", Colors.GREEN), file=sys.stderr) + return 0 + + +# ============================================================================= +# Command: remove-subtask +# ============================================================================= + +def cmd_remove_subtask(args: argparse.Namespace) -> int: + """Unlink a child task from a parent task.""" + repo_root = get_repo_root() + + parent_dir = resolve_task_dir(args.parent_dir, repo_root) + child_dir = resolve_task_dir(args.child_dir, repo_root) + + parent_json_path = parent_dir / FILE_TASK_JSON + child_json_path = child_dir / FILE_TASK_JSON + + if not parent_json_path.is_file(): + print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr) + return 1 + + if not child_json_path.is_file(): + print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr) + return 1 + + parent_data = read_json(parent_json_path) + child_data = read_json(child_json_path) + + if not parent_data or not child_data: + print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr) + return 1 + + # Remove child from parent's children list + parent_children = parent_data.get("children", []) + child_dir_name = child_dir.name + if child_dir_name in parent_children: + parent_children.remove(child_dir_name) + parent_data["children"] = parent_children + + # Clear parent in child's task.json + child_data["parent"] = None + + # Write both + write_json(parent_json_path, parent_data) + write_json(child_json_path, child_data) + + print(colored(f"Unlinked: {child_dir.name} from {parent_dir.name}", Colors.GREEN), file=sys.stderr) + return 0 + + +# ============================================================================= +# Command: set-branch +# ============================================================================= + +def cmd_set_branch(args: argparse.Namespace) -> int: + """Set git branch for task.""" + repo_root = get_repo_root() + target_dir = resolve_task_dir(args.dir, repo_root) + branch = args.branch + + if not branch: + print(colored("Error: Missing arguments", Colors.RED)) + print("Usage: python task.py set-branch <task-dir> <branch-name>") + return 1 + + task_json = target_dir / FILE_TASK_JSON + if not task_json.is_file(): + print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) + return 1 + + data = read_json(task_json) + if not data: + return 1 + + data["branch"] = branch + write_json(task_json, data) + + print(colored(f"✓ Branch set to: {branch}", Colors.GREEN)) + return 0 + + +# ============================================================================= +# Command: set-base-branch +# ============================================================================= + +def cmd_set_base_branch(args: argparse.Namespace) -> int: + """Set the base branch (PR target) for task.""" + repo_root = get_repo_root() + target_dir = resolve_task_dir(args.dir, repo_root) + base_branch = args.base_branch + + if not base_branch: + print(colored("Error: Missing arguments", Colors.RED)) + print("Usage: python task.py set-base-branch <task-dir> <base-branch>") + print("Example: python task.py set-base-branch <dir> develop") + print() + print("This sets the target branch for PR (the branch your feature will merge into).") + return 1 + + task_json = target_dir / FILE_TASK_JSON + if not task_json.is_file(): + print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) + return 1 + + data = read_json(task_json) + if not data: + return 1 + + data["base_branch"] = base_branch + write_json(task_json, data) + + print(colored(f"✓ Base branch set to: {base_branch}", Colors.GREEN)) + print(f" PR will target: {base_branch}") + return 0 + + +# ============================================================================= +# Command: set-scope +# ============================================================================= + +def cmd_set_scope(args: argparse.Namespace) -> int: + """Set scope for PR title.""" + repo_root = get_repo_root() + target_dir = resolve_task_dir(args.dir, repo_root) + scope = args.scope + + if not scope: + print(colored("Error: Missing arguments", Colors.RED)) + print("Usage: python task.py set-scope <task-dir> <scope>") + return 1 + + task_json = target_dir / FILE_TASK_JSON + if not task_json.is_file(): + print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) + return 1 + + data = read_json(task_json) + if not data: + return 1 + + data["scope"] = scope + write_json(task_json, data) + + print(colored(f"✓ Scope set to: {scope}", Colors.GREEN)) + return 0 diff --git a/.trellis/scripts/common/task_utils.py b/.trellis/scripts/common/task_utils.py new file mode 100644 index 0000000..62c215e --- /dev/null +++ b/.trellis/scripts/common/task_utils.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +""" +Task utility functions. + +Provides: + is_safe_task_path - Validate task path is safe to operate on + find_task_by_name - Find task directory by name + resolve_task_dir - Resolve task directory from name, relative, or absolute path + archive_task_dir - Archive task to monthly directory + run_task_hooks - Run lifecycle hooks for task events +""" + +from __future__ import annotations + +import shutil +import sys +from datetime import datetime +from pathlib import Path + +from .paths import get_repo_root, get_tasks_dir + + +# ============================================================================= +# Path Safety +# ============================================================================= + +def is_safe_task_path(task_path: str, repo_root: Path | None = None) -> bool: + """Check if a relative task path is safe to operate on. + + Args: + task_path: Task path (relative to repo_root). + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + True if safe, False if dangerous. + """ + if repo_root is None: + repo_root = get_repo_root() + + normalized = task_path.replace("\\", "/") + + # Check empty or null + if not normalized or normalized == "null": + print("Error: empty or null task path", file=sys.stderr) + return False + + # Reject absolute paths + if Path(task_path).is_absolute(): + print(f"Error: absolute path not allowed: {task_path}", file=sys.stderr) + return False + + # Reject ".", "..", paths starting with "./" or "../", or containing ".." + if normalized in (".", "..") or normalized.startswith("./") or normalized.startswith("../") or ".." in normalized: + print(f"Error: path traversal not allowed: {task_path}", file=sys.stderr) + return False + + # Final check: ensure resolved path is not the repo root + abs_path = repo_root / Path(normalized) + if abs_path.exists(): + try: + resolved = abs_path.resolve() + root_resolved = repo_root.resolve() + if resolved == root_resolved: + print(f"Error: path resolves to repo root: {task_path}", file=sys.stderr) + return False + except (OSError, IOError): + pass + + return True + + +# ============================================================================= +# Task Lookup +# ============================================================================= + +def find_task_by_name(task_name: str, tasks_dir: Path) -> Path | None: + """Find task directory by name (exact or suffix match). + + Args: + task_name: Task name to find. + tasks_dir: Tasks directory path. + + Returns: + Absolute path to task directory, or None if not found. + """ + if not task_name or not tasks_dir or not tasks_dir.is_dir(): + return None + + # Try exact match first + exact_match = tasks_dir / task_name + if exact_match.is_dir(): + return exact_match + + # Try suffix match (e.g., "my-task" matches "01-21-my-task") + for d in tasks_dir.iterdir(): + if d.is_dir() and d.name.endswith(f"-{task_name}"): + return d + + return None + + +# ============================================================================= +# Archive Operations +# ============================================================================= + +def archive_task_dir(task_dir_abs: Path, repo_root: Path | None = None) -> Path | None: + """Archive a task directory to archive/{YYYY-MM}/. + + Args: + task_dir_abs: Absolute path to task directory. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Path to archived directory, or None on error. + """ + if not task_dir_abs.is_dir(): + print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr) + return None + + # Get tasks directory (parent of the task) + tasks_dir = task_dir_abs.parent + archive_dir = tasks_dir / "archive" + year_month = datetime.now().strftime("%Y-%m") + month_dir = archive_dir / year_month + + # Create archive directory + try: + month_dir.mkdir(parents=True, exist_ok=True) + except (OSError, IOError) as e: + print(f"Error: Failed to create archive directory: {e}", file=sys.stderr) + return None + + # Move task to archive + task_name = task_dir_abs.name + dest = month_dir / task_name + + try: + shutil.move(str(task_dir_abs), str(dest)) + except (OSError, IOError, shutil.Error) as e: + print(f"Error: Failed to move task to archive: {e}", file=sys.stderr) + return None + + return dest + + +def archive_task_complete( + task_dir_abs: Path, + repo_root: Path | None = None +) -> dict[str, str]: + """Complete archive workflow: archive directory. + + Args: + task_dir_abs: Absolute path to task directory. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Dict with archive result info. + """ + if not task_dir_abs.is_dir(): + print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr) + return {} + + archive_dest = archive_task_dir(task_dir_abs, repo_root) + if archive_dest: + return {"archived_to": str(archive_dest)} + + return {} + + +# ============================================================================= +# Task Directory Resolution +# ============================================================================= + +def resolve_task_dir(target_dir: str, repo_root: Path) -> Path: + """Resolve task directory to absolute path. + + Supports: + - Absolute path: /path/to/task + - Relative path: .trellis/tasks/01-31-my-task + - Task name: my-task (uses find_task_by_name for lookup) + + Args: + target_dir: Task directory specification. + repo_root: Repository root path. + + Returns: + Resolved absolute path. + """ + if not target_dir: + return Path() + + normalized = target_dir.replace("\\", "/") + while normalized.startswith("./"): + normalized = normalized[2:] + + # Absolute path + if Path(target_dir).is_absolute(): + return Path(target_dir) + + # Relative path (contains path separator or starts with .trellis) + if "/" in normalized or normalized.startswith(".trellis"): + return repo_root / Path(normalized) + + # Task name - try to find in tasks directory + tasks_dir = get_tasks_dir(repo_root) + found = find_task_by_name(target_dir, tasks_dir) + if found: + return found + + # Fallback to treating as relative path + return repo_root / Path(normalized) + + +# ============================================================================= +# Lifecycle Hooks +# ============================================================================= + +def run_task_hooks(event: str, task_json_path: Path, repo_root: Path) -> None: + """Run lifecycle hooks for a task event. + + Args: + event: Event name (e.g. "after_create"). + task_json_path: Absolute path to the task's task.json. + repo_root: Repository root for cwd and config lookup. + """ + import os + import subprocess + + from .config import get_hooks + from .log import Colors, colored + + commands = get_hooks(event, repo_root) + if not commands: + return + + env = {**os.environ, "TASK_JSON_PATH": str(task_json_path)} + + for cmd in commands: + try: + result = subprocess.run( + cmd, + shell=True, + cwd=repo_root, + env=env, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if result.returncode != 0: + print( + colored(f"[WARN] Hook failed ({event}): {cmd}", Colors.YELLOW), + file=sys.stderr, + ) + if result.stderr.strip(): + print(f" {result.stderr.strip()}", file=sys.stderr) + except Exception as e: + print( + colored(f"[WARN] Hook error ({event}): {cmd} — {e}", Colors.YELLOW), + file=sys.stderr, + ) + + +# ============================================================================= +# Main Entry (for testing) +# ============================================================================= + +if __name__ == "__main__": + repo = get_repo_root() + tasks = get_tasks_dir(repo) + + print(f"Tasks dir: {tasks}") + print(f"is_safe_task_path('.trellis/tasks/test'): {is_safe_task_path('.trellis/tasks/test', repo)}") + print(f"is_safe_task_path('../test'): {is_safe_task_path('../test', repo)}") diff --git a/.trellis/scripts/common/tasks.py b/.trellis/scripts/common/tasks.py new file mode 100644 index 0000000..7b44094 --- /dev/null +++ b/.trellis/scripts/common/tasks.py @@ -0,0 +1,112 @@ +""" +Task data access layer. + +Single source of truth for loading and iterating task directories. +Replaces scattered task.json parsing across 9+ files. + +Provides: + load_task — Load a single task by directory path + iter_active_tasks — Iterate all non-archived tasks (sorted) + get_all_statuses — Get {dir_name: status} map for children progress +""" + +from __future__ import annotations + +from collections.abc import Iterator +from pathlib import Path + +from .io import read_json +from .paths import FILE_TASK_JSON +from .types import TaskInfo + + +def load_task(task_dir: Path) -> TaskInfo | None: + """Load task from a directory containing task.json. + + Args: + task_dir: Absolute path to the task directory. + + Returns: + TaskInfo if task.json exists and is valid, None otherwise. + """ + task_json = task_dir / FILE_TASK_JSON + if not task_json.is_file(): + return None + + data = read_json(task_json) + if not data: + return None + + return TaskInfo( + dir_name=task_dir.name, + directory=task_dir, + title=data.get("title") or data.get("name") or "unknown", + status=data.get("status", "unknown"), + assignee=data.get("assignee", ""), + priority=data.get("priority", "P2"), + children=tuple(data.get("children", [])), + parent=data.get("parent"), + package=data.get("package"), + raw=data, + ) + + +def iter_active_tasks(tasks_dir: Path) -> Iterator[TaskInfo]: + """Iterate all active (non-archived) tasks, sorted by directory name. + + Skips the "archive" directory and directories without valid task.json. + + Args: + tasks_dir: Path to the tasks directory. + + Yields: + TaskInfo for each valid task. + """ + if not tasks_dir.is_dir(): + return + + for d in sorted(tasks_dir.iterdir()): + if not d.is_dir() or d.name == "archive": + continue + info = load_task(d) + if info is not None: + yield info + + +def get_all_statuses(tasks_dir: Path) -> dict[str, str]: + """Get a {dir_name: status} mapping for all active tasks. + + Useful for computing children progress without loading full TaskInfo. + + Args: + tasks_dir: Path to the tasks directory. + + Returns: + Dict mapping directory names to status strings. + """ + return {t.dir_name: t.status for t in iter_active_tasks(tasks_dir)} + + +def children_progress( + children: tuple[str, ...] | list[str], + all_statuses: dict[str, str], +) -> str: + """Format children progress string like " [2/3 done]". + + Args: + children: List of child directory names. + all_statuses: Status map from get_all_statuses(). + + Returns: + Formatted string, or "" if no children. + """ + if not children: + return "" + # A child missing from active statuses has been archived (cmd_archive + # sets status=completed before moving the dir). Count it as done so + # parent progress doesn't regress when children are archived. + done = sum( + 1 for c in children + if c not in all_statuses or all_statuses.get(c) in ("completed", "done") + ) + return f" [{done}/{len(children)} done]" diff --git a/.trellis/scripts/common/trellis_config.py b/.trellis/scripts/common/trellis_config.py new file mode 100644 index 0000000..5dbec7a --- /dev/null +++ b/.trellis/scripts/common/trellis_config.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +""" +Standalone reader for .trellis/config.yaml. + +Mirrors a minimal subset of common.config so callers (hooks, workflow_phase) +can read configuration without importing the full task/repo helpers. Returns +an empty dict on missing/malformed files so callers stay simple. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Optional + + +CONFIG_REL_PATH = ".trellis/config.yaml" + + +def _unquote(value: str) -> str: + if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"): + return value[1:-1] + return value + + +def _strip_inline_comment(value: str) -> str: + """Strip ` # …` inline comments while preserving `#` inside quoted strings. + + YAML treats ` #` (space-hash) as a comment opener; bare `#` inside a token + is part of the value. Quoted strings are immune. + """ + in_quote: str | None = None + for idx, ch in enumerate(value): + if in_quote: + if ch == in_quote: + in_quote = None + continue + if ch in ('"', "'"): + in_quote = ch + continue + if ch == "#" and (idx == 0 or value[idx - 1].isspace()): + return value[:idx] + return value + + +def _next_content_line(lines: list[str], start: int) -> tuple[int, str]: + i = start + while i < len(lines): + stripped = lines[i].strip() + if stripped and not stripped.startswith("#"): + return i, lines[i] + i += 1 + return i, "" + + +def _parse_yaml_block( + lines: list[str], start: int, min_indent: int, target: dict +) -> int: + i = start + current_list: list | None = None + + while i < len(lines): + line = lines[i] + stripped = line.strip() + + if not stripped or stripped.startswith("#"): + i += 1 + continue + + indent = len(line) - len(line.lstrip()) + if indent < min_indent: + break + + if stripped.startswith("- "): + if current_list is not None: + current_list.append(_unquote(stripped[2:].strip())) + i += 1 + elif ":" in stripped: + key, _, value = stripped.partition(":") + key = key.strip() + value = _strip_inline_comment(value).strip() + value = _unquote(value) + current_list = None + + if value: + target[key] = value + i += 1 + else: + next_i, next_line = _next_content_line(lines, i + 1) + if next_i >= len(lines): + target[key] = {} + i = next_i + elif next_line.strip().startswith("- "): + current_list = [] + target[key] = current_list + i += 1 + else: + next_indent = len(next_line) - len(next_line.lstrip()) + if next_indent > indent: + nested: dict = {} + target[key] = nested + i = _parse_yaml_block(lines, i + 1, next_indent, nested) + else: + target[key] = {} + i += 1 + else: + i += 1 + + return i + + +def parse_simple_yaml(content: str) -> dict: + """Parse a small subset of YAML. See common.config for full doc.""" + lines = content.splitlines() + result: dict = {} + _parse_yaml_block(lines, 0, 0, result) + return result + + +def read_trellis_config(repo_root: Optional[Path] = None) -> dict: + """Read .trellis/config.yaml. Returns {} on missing or malformed file.""" + root = repo_root or Path.cwd() + config_file = root / CONFIG_REL_PATH + try: + content = config_file.read_text(encoding="utf-8") + except (FileNotFoundError, OSError): + return {} + try: + parsed = parse_simple_yaml(content) + except Exception: + return {} + return parsed if isinstance(parsed, dict) else {} diff --git a/.trellis/scripts/common/types.py b/.trellis/scripts/common/types.py new file mode 100644 index 0000000..5802e10 --- /dev/null +++ b/.trellis/scripts/common/types.py @@ -0,0 +1,110 @@ +""" +Core type definitions for Trellis task data. + +Provides: + TaskData — TypedDict for task.json shape (read-path type hints only) + TaskInfo — Frozen dataclass for loaded task (the public API type) + AgentRecord — TypedDict for registry.json agent entries +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TypedDict + + +# ============================================================================= +# task.json shape (TypedDict — used only for read-path type hints) +# ============================================================================= + +class TaskData(TypedDict, total=False): + """Shape of task.json on disk. + + Used only for type annotations when reading task.json. + Writes must use the original dict to avoid losing unknown fields. + """ + + id: str + name: str + title: str + description: str + status: str + dev_type: str + scope: str | None + package: str | None + priority: str + creator: str + assignee: str + createdAt: str + completedAt: str | None + branch: str | None + base_branch: str | None + worktree_path: str | None + commit: str | None + pr_url: str | None + subtasks: list[str] + children: list[str] + parent: str | None + relatedFiles: list[str] + notes: str + meta: dict + + +# ============================================================================= +# Loaded task object (frozen dataclass — the public API type) +# ============================================================================= + +@dataclass(frozen=True) +class TaskInfo: + """Immutable view of a loaded task. + + Created by load_task() / iter_active_tasks(). + Contains the commonly accessed fields; the original dict + is preserved in `raw` for write-back and uncommon field access. + """ + + dir_name: str + directory: Path + title: str + status: str + assignee: str + priority: str + children: tuple[str, ...] + parent: str | None + package: str | None + raw: dict # original dict — use for writes and uncommon fields + + @property + def name(self) -> str: + """Task name (id or name field).""" + return self.raw.get("name") or self.raw.get("id") or self.dir_name + + @property + def description(self) -> str: + return self.raw.get("description", "") + + @property + def branch(self) -> str | None: + return self.raw.get("branch") + + @property + def meta(self) -> dict: + return self.raw.get("meta", {}) + + +# ============================================================================= +# registry.json agent entry +# ============================================================================= + +class AgentRecord(TypedDict, total=False): + """Shape of an agent entry in registry.json.""" + + id: str + pid: int + task_dir: str + worktree_path: str + branch: str + platform: str + started_at: str + status: str diff --git a/.trellis/scripts/common/workflow_phase.py b/.trellis/scripts/common/workflow_phase.py new file mode 100644 index 0000000..2d32931 --- /dev/null +++ b/.trellis/scripts/common/workflow_phase.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Workflow Phase Extraction. + +Extracts step-level content from .trellis/workflow.md and optionally filters +platform-specific blocks. + +Platform marker syntax in workflow.md: + + [Claude Code, Cursor, ...] + agent-capable content + [/Claude Code, Cursor, ...] + +Provides: + get_phase_index - Extract the Phase Index section (no --step) + get_step - Extract a single step (#### X.X) section + filter_platform - Strip platform blocks that don't include the given name +""" + +from __future__ import annotations + +import re + +from .paths import DIR_WORKFLOW, get_repo_root + + +def _workflow_md_path(): + return get_repo_root() / DIR_WORKFLOW / "workflow.md" + +# Match a line that *is* a platform marker: "[A, B, C]" or "[/A, B, C]" +_MARKER_RE = re.compile(r"^\[(/?)([A-Za-z][^\[\]]*)\]\s*$") + +# Step heading: "#### 1.0 Title" or "#### 1.0 ..." +_STEP_HEADING_RE = re.compile(r"^####\s+(\d+\.\d+)\b.*$") + +# Phase Index starts here; Phase 1/2/3 step bodies follow; ends at Breadcrumbs. +_PHASE_INDEX_HEADING = "## Phase Index" + + +def _read_workflow() -> str: + path = _workflow_md_path() + if not path.exists(): + raise FileNotFoundError(f"workflow.md not found: {path}") + return path.read_text(encoding="utf-8") + + +def _parse_marker(line: str) -> tuple[bool, list[str]] | None: + """Parse a platform marker line. + + Returns: + (is_closing, [platform_names]) if line is a marker, else None. + """ + m = _MARKER_RE.match(line) + if not m: + return None + is_closing = m.group(1) == "/" + names = [p.strip() for p in m.group(2).split(",") if p.strip()] + return is_closing, names + + +def get_phase_index() -> str: + """Return the compact Phase Index summary from workflow.md. + + SessionStart and no-step phase context use this small summary as their + orientation payload. Detailed Phase 1/2/3 instructions are loaded with + ``get_step`` on demand. ``[workflow-state:STATUS]`` tag blocks are + consumed by the per-turn hook, so they're stripped from this output. + """ + text = _read_workflow() + lines = text.splitlines() + + start: int | None = None + end: int | None = None + for i, line in enumerate(lines): + stripped = line.strip() + if start is None and stripped == _PHASE_INDEX_HEADING: + start = i + continue + if start is not None and stripped == "## Phase 1: Plan": + end = i + break + + if start is None: + return "" + if end is None: + end = len(lines) + + section = "\n".join(lines[start:end]).rstrip() + # Strip [workflow-state:STATUS]...[/workflow-state:STATUS] blocks since + # they're injected separately by inject-workflow-state.py per-turn. + import re as _re + tag_re = _re.compile( + r"\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n.*?\n\s*\[/workflow-state:\1\]\n?", + _re.DOTALL, + ) + return tag_re.sub("", section).rstrip() + "\n" + + +def get_step(step_id: str) -> str: + """Return the `#### X.X` section matching step_id (header + body). + + Body ends at the next `####` or `---` or `##` heading (whichever comes first). + """ + text = _read_workflow() + lines = text.splitlines() + + start: int | None = None + for i, line in enumerate(lines): + m = _STEP_HEADING_RE.match(line) + if m and m.group(1) == step_id: + start = i + break + if start is None: + return "" + + end: int = len(lines) + for j in range(start + 1, len(lines)): + line = lines[j] + if line.startswith("#### "): + end = j + break + if line.startswith("## "): + end = j + break + # Horizontal rule at column 0 + if line.strip() == "---": + end = j + break + + return "\n".join(lines[start:end]).rstrip() + "\n" + + +def _platform_matches(platform: str, block_names: list[str]) -> bool: + """Case-insensitive fuzzy match: accept 'cursor', 'Cursor', 'claude-code', 'Claude Code'.""" + needle = platform.lower().replace("-", "").replace("_", "").replace(" ", "") + for name in block_names: + hay = name.lower().replace("-", "").replace("_", "").replace(" ", "") + if needle == hay: + return True + return False + + +def resolve_effective_platform(platform: str, config: dict) -> str: + """Map ``codex`` to a dispatch-mode-namespaced virtual platform name. + + When ``--platform codex`` is passed, return ``"codex-inline"`` (default) + or ``"codex-sub-agent"`` based on ``.trellis/config.yaml`` ``codex.dispatch_mode``. + ``filter_platform`` then surfaces blocks whose marker lists include the + namespaced name (e.g. ``[codex-sub-agent, ...]`` or ``[codex-inline, Kilo, + Antigravity, Windsurf]``). + + Default is ``inline`` because Codex sub-agents run with ``fork_turns="none"`` + isolation and can't inherit the parent session's task context — inline + keeps the main agent in charge so context isn't lost. Invalid / missing + values also fall back to inline. + + Other platforms are returned unchanged. + """ + if platform == "codex": + mode = "inline" + codex_cfg = config.get("codex") if isinstance(config, dict) else None + if isinstance(codex_cfg, dict): + cfg_mode = codex_cfg.get("dispatch_mode") + if cfg_mode in ("inline", "sub-agent"): + mode = cfg_mode + return f"codex-{mode}" + return platform + + +def filter_platform(content: str, platform: str) -> str: + """Keep lines outside any `[...]` block + lines inside blocks that include platform. + + Marker lines themselves are dropped from the output. + """ + lines = content.splitlines() + out: list[str] = [] + + in_block = False + keep_block = False + + for line in lines: + marker = _parse_marker(line) + if marker is not None: + is_closing, names = marker + if not is_closing: + in_block = True + keep_block = _platform_matches(platform, names) + else: + in_block = False + keep_block = False + continue # drop the marker line itself + + if in_block: + if keep_block: + out.append(line) + continue + out.append(line) + + # Collapse runs of 3+ blank lines that may arise from dropped markers + collapsed: list[str] = [] + blank_run = 0 + for line in out: + if line.strip() == "": + blank_run += 1 + if blank_run <= 2: + collapsed.append(line) + else: + blank_run = 0 + collapsed.append(line) + + return "\n".join(collapsed).rstrip() + "\n" diff --git a/.trellis/scripts/get_context.py b/.trellis/scripts/get_context.py new file mode 100644 index 0000000..0bde5bf --- /dev/null +++ b/.trellis/scripts/get_context.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +""" +Get Session Context for AI Agent. + +Usage: + python get_context.py Output context in text format + python get_context.py --json Output context in JSON format +""" + +from __future__ import annotations + +from common.git_context import main + + +if __name__ == "__main__": + main() diff --git a/.trellis/scripts/get_developer.py b/.trellis/scripts/get_developer.py new file mode 100644 index 0000000..f8a89eb --- /dev/null +++ b/.trellis/scripts/get_developer.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +""" +Get current developer name. + +This is a wrapper that uses common/paths.py +""" + +from __future__ import annotations + +import sys + +from common.paths import get_developer + + +def main() -> None: + """CLI entry point.""" + developer = get_developer() + if developer: + print(developer) + else: + print("Developer not initialized", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.trellis/scripts/hooks/linear_sync.py b/.trellis/scripts/hooks/linear_sync.py new file mode 100644 index 0000000..1fdce68 --- /dev/null +++ b/.trellis/scripts/hooks/linear_sync.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""Linear sync hook for Trellis task lifecycle. + +Syncs task events to Linear via the `linearis` CLI. + +Usage (called automatically by task.py hooks): + python .trellis/scripts/hooks/linear_sync.py create + python .trellis/scripts/hooks/linear_sync.py start + python .trellis/scripts/hooks/linear_sync.py archive + +Manual usage: + TASK_JSON_PATH=.trellis/tasks/<name>/task.json python .trellis/scripts/hooks/linear_sync.py sync + +Environment: + TASK_JSON_PATH - Absolute path to task.json (set by task.py) + +Configuration: + .trellis/hooks.local.json - Local config (gitignored), example: + { + "linear": { + "team": "TEAM_KEY", + "project": "Project Name", + "assignees": { + "dev-name": "linear-user-id" + } + } + } +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +# ─── Configuration ──────────────────────────────────────────────────────────── + +# Trellis priority → Linear priority (1=Urgent, 2=High, 3=Medium, 4=Low) +PRIORITY_MAP = {"P0": 1, "P1": 2, "P2": 3, "P3": 4} + +# Linear status names (must match your team's workflow) +STATUS_IN_PROGRESS = "In Progress" +STATUS_DONE = "Done" + + +def _load_config() -> dict: + """Load local hook config from .trellis/hooks.local.json.""" + task_json_path = os.environ.get("TASK_JSON_PATH", "") + if task_json_path: + # Walk up from task.json to find .trellis/ + trellis_dir = Path(task_json_path).parent.parent.parent + else: + trellis_dir = Path(".trellis") + + config_path = trellis_dir / "hooks.local.json" + try: + with open(config_path, encoding="utf-8") as f: + return json.load(f) + except (OSError, json.JSONDecodeError): + return {} + + +CONFIG = _load_config() +LINEAR_CFG = CONFIG.get("linear", {}) + +TEAM = LINEAR_CFG.get("team", "") +PROJECT = LINEAR_CFG.get("project", "") +ASSIGNEE_MAP = LINEAR_CFG.get("assignees", {}) + +# ─── Helpers ────────────────────────────────────────────────────────────────── + + +def _read_task() -> tuple[dict, str]: + path = os.environ.get("TASK_JSON_PATH", "") + if not path: + print("TASK_JSON_PATH not set", file=sys.stderr) + sys.exit(1) + with open(path, encoding="utf-8") as f: + return json.load(f), path + + +def _write_task(data: dict, path: str) -> None: + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + f.write("\n") + + +def _linearis(*args: str) -> dict | None: + result = subprocess.run( + ["linearis", *args], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if result.returncode != 0: + print(f"linearis error: {result.stderr.strip()}", file=sys.stderr) + sys.exit(1) + stdout = result.stdout.strip() + if stdout: + return json.loads(stdout) + return None + + +def _get_linear_issue(task: dict) -> str | None: + meta = task.get("meta") + if isinstance(meta, dict): + return meta.get("linear_issue") + return None + + +# ─── Actions ────────────────────────────────────────────────────────────────── + + +def cmd_create() -> None: + if not TEAM: + print("No linear.team configured in hooks.local.json", file=sys.stderr) + sys.exit(1) + + task, path = _read_task() + + # Skip if already linked + if _get_linear_issue(task): + print(f"Already linked: {_get_linear_issue(task)}") + return + + title = task.get("title") or task.get("name") or "Untitled" + args = ["issues", "create", title, "--team", TEAM] + + # Map priority + priority = PRIORITY_MAP.get(task.get("priority", ""), 0) + if priority: + args.extend(["-p", str(priority)]) + + # Set project + if PROJECT: + args.extend(["--project", PROJECT]) + + # Assign to Linear user + assignee = task.get("assignee", "") + linear_user_id = ASSIGNEE_MAP.get(assignee) + if linear_user_id: + args.extend(["--assignee", linear_user_id]) + + # Link to parent's Linear issue if available + parent_issue = _resolve_parent_linear_issue(task) + if parent_issue: + args.extend(["--parent-ticket", parent_issue]) + + result = _linearis(*args) + if result and "identifier" in result: + if not isinstance(task.get("meta"), dict): + task["meta"] = {} + task["meta"]["linear_issue"] = result["identifier"] + _write_task(task, path) + print(f"Created Linear issue: {result['identifier']}") + + +def cmd_start() -> None: + task, _ = _read_task() + issue = _get_linear_issue(task) + if not issue: + return + _linearis("issues", "update", issue, "-s", STATUS_IN_PROGRESS) + print(f"Updated {issue} -> {STATUS_IN_PROGRESS}") + cmd_sync() + + +def cmd_archive() -> None: + task, _ = _read_task() + issue = _get_linear_issue(task) + if not issue: + return + _linearis("issues", "update", issue, "-s", STATUS_DONE) + print(f"Updated {issue} -> {STATUS_DONE}") + + +def cmd_sync() -> None: + """Sync prd.md content to Linear issue description.""" + task, _ = _read_task() + issue = _get_linear_issue(task) + if not issue: + print("No linear_issue in meta, run create first", file=sys.stderr) + sys.exit(1) + + # Find prd.md next to task.json + task_json_path = os.environ.get("TASK_JSON_PATH", "") + prd_path = Path(task_json_path).parent / "prd.md" + if not prd_path.is_file(): + print(f"No prd.md found at {prd_path}", file=sys.stderr) + sys.exit(1) + + description = prd_path.read_text(encoding="utf-8").strip() + _linearis("issues", "update", issue, "-d", description) + print(f"Synced prd.md to {issue} description") + + +# ─── Parent Issue Resolution ───────────────────────────────────────────────── + + +def _resolve_parent_linear_issue(task: dict) -> str | None: + """Find parent task's Linear issue identifier.""" + parent_name = task.get("parent") + if not parent_name: + return None + + task_json_path = os.environ.get("TASK_JSON_PATH", "") + if not task_json_path: + return None + + current_task_dir = Path(task_json_path).parent + tasks_dir = current_task_dir.parent + parent_json = tasks_dir / parent_name / "task.json" + + if parent_json.exists(): + try: + with open(parent_json, encoding="utf-8") as f: + parent_task = json.load(f) + return _get_linear_issue(parent_task) + except (json.JSONDecodeError, OSError): + pass + return None + + +# ─── Main ───────────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + action = sys.argv[1] if len(sys.argv) > 1 else "" + actions = { + "create": cmd_create, + "start": cmd_start, + "archive": cmd_archive, + "sync": cmd_sync, + } + fn = actions.get(action) + if fn: + fn() + else: + print(f"Unknown action: {action}", file=sys.stderr) + print(f"Valid actions: {', '.join(actions)}", file=sys.stderr) + sys.exit(1) diff --git a/.trellis/scripts/init_developer.py b/.trellis/scripts/init_developer.py new file mode 100644 index 0000000..557b289 --- /dev/null +++ b/.trellis/scripts/init_developer.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +""" +Initialize developer for workflow. + +Usage: + python init_developer.py <developer-name> + +This creates: + - .trellis/.developer file with developer info + - .trellis/workspace/<name>/ directory structure +""" + +from __future__ import annotations + +import sys + +from common.paths import ( + DIR_WORKFLOW, + FILE_DEVELOPER, + get_developer, +) +from common.developer import init_developer + + +def main() -> None: + """CLI entry point.""" + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} <developer-name>") + print() + print("Example:") + print(f" {sys.argv[0]} john") + sys.exit(1) + + name = sys.argv[1] + + # Check if already initialized + existing = get_developer() + if existing: + print(f"Developer already initialized: {existing}") + print() + print(f"To reinitialize, remove {DIR_WORKFLOW}/{FILE_DEVELOPER} first") + sys.exit(0) + + if init_developer(name): + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.trellis/scripts/task.py b/.trellis/scripts/task.py new file mode 100644 index 0000000..92ba674 --- /dev/null +++ b/.trellis/scripts/task.py @@ -0,0 +1,500 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Task Management Script. + +Usage: + python task.py create "<title>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>] [--package <pkg>] + python task.py add-context <dir> <file> <path> [reason] # Add jsonl entry + python task.py validate <dir> # Validate jsonl files + python task.py list-context <dir> # List jsonl entries + python task.py start <dir> # Set active task + python task.py current [--source] # Show active task + python task.py finish # Clear active task + python task.py set-branch <dir> <branch> # Set git branch + python task.py set-base-branch <dir> <branch> # Set PR target branch + python task.py set-scope <dir> <scope> # Set scope for PR title + python task.py archive <task-dir> # Archive completed task + python task.py list # List active tasks + python task.py list-archive [month] # List archived tasks + python task.py add-subtask <parent-dir> <child-dir> # Link child to parent + python task.py remove-subtask <parent-dir> <child-dir> # Unlink child from parent +""" + +from __future__ import annotations + +import argparse +import sys + +from common.log import Colors, colored +from common.paths import ( + DIR_WORKFLOW, + DIR_TASKS, + FILE_TASK_JSON, + get_repo_root, + get_developer, + get_tasks_dir, + get_current_task, +) +from common.active_task import ( + clear_active_task, + resolve_active_task, + resolve_context_key, + set_active_task, +) +from common.io import read_json, write_json +from common.task_utils import resolve_task_dir, run_task_hooks +from common.tasks import iter_active_tasks, children_progress + +# Import command handlers from split modules (also re-exports for plan.py compatibility) +from common.task_store import ( + cmd_create, + cmd_archive, + cmd_set_branch, + cmd_set_base_branch, + cmd_set_scope, + cmd_add_subtask, + cmd_remove_subtask, +) +from common.task_context import ( + cmd_add_context, + cmd_validate, + cmd_list_context, +) + + +# ============================================================================= +# Command: start / finish +# ============================================================================= + +def cmd_start(args: argparse.Namespace) -> int: + """Set active task.""" + repo_root = get_repo_root() + task_input = args.dir + + if not task_input: + print(colored("Error: task directory or name required", Colors.RED)) + return 1 + + # Resolve task directory (supports task name, relative path, or absolute path) + full_path = resolve_task_dir(task_input, repo_root) + + if not full_path.is_dir(): + print(colored(f"Error: Task not found: {task_input}", Colors.RED)) + print("Hint: Use task name (e.g., 'my-task') or full path (e.g., '.trellis/tasks/01-31-my-task')") + return 1 + + # Convert to relative path for storage + try: + task_dir = full_path.relative_to(repo_root).as_posix() + except ValueError: + task_dir = str(full_path) + + task_json_path = full_path / FILE_TASK_JSON + + if not resolve_context_key(): + # Degraded mode: no session identity available. + # Hook didn't inject TRELLIS_CONTEXT_ID (common on Windows + Claude Code, + # --continue resume path, fork distribution, hooks disabled, etc.). Skip + # per-session pointer write; AI continues based on conversation context. + print(colored( + "ℹ Session identity not available; active-task pointer not persisted " + "this session (degraded mode). AI continues based on conversation context.", + Colors.YELLOW, + )) + print(colored( + "Hint: run inside an AI IDE/session that exposes session identity, " + "or set TRELLIS_CONTEXT_ID before running task.py start.", + Colors.YELLOW, + )) + + # Still flip task.json status: planning → in_progress so downstream phases proceed. + if task_json_path.is_file(): + data = read_json(task_json_path) + if data and data.get("status") == "planning": + data["status"] = "in_progress" + if write_json(task_json_path, data): + print(colored("✓ Status: planning → in_progress (degraded)", Colors.GREEN)) + run_task_hooks("after_start", task_json_path, repo_root) + return 0 + + active = set_active_task(task_dir, repo_root) + if active: + print(colored(f"✓ Current task set to: {task_dir}", Colors.GREEN)) + print(f"Source: {active.source}") + + if task_json_path.is_file(): + data = read_json(task_json_path) + if data and data.get("status") == "planning": + data["status"] = "in_progress" + if write_json(task_json_path, data): + print(colored("✓ Status: planning → in_progress", Colors.GREEN)) + + print() + print(colored("The hook will now inject context from this task's jsonl files.", Colors.BLUE)) + + run_task_hooks("after_start", task_json_path, repo_root) + return 0 + else: + print(colored("Error: Failed to set current task", Colors.RED)) + return 1 + + +def cmd_finish(args: argparse.Namespace) -> int: + """Clear active task.""" + repo_root = get_repo_root() + active = clear_active_task(repo_root) + current = active.task_path + + if not current: + print(colored("No current task set", Colors.YELLOW)) + return 0 + + # Resolve task.json path before clearing + task_json_path = repo_root / current / FILE_TASK_JSON + + print(colored(f"✓ Cleared current task (was: {current})", Colors.GREEN)) + print(f"Source: {active.source}") + + if task_json_path.is_file(): + run_task_hooks("after_finish", task_json_path, repo_root) + return 0 + + +def cmd_current(args: argparse.Namespace) -> int: + """Show active task.""" + repo_root = get_repo_root() + active = resolve_active_task(repo_root) + + if args.source: + print(f"Current task: {active.task_path or '(none)'}") + print(f"Source: {active.source}") + if active.stale: + print("State: stale") + return 0 if active.task_path else 1 + + if active.task_path: + print(active.task_path) + return 0 + + return 1 + + +# ============================================================================= +# Command: list +# ============================================================================= + +def cmd_list(args: argparse.Namespace) -> int: + """List active tasks.""" + repo_root = get_repo_root() + tasks_dir = get_tasks_dir(repo_root) + current_task = get_current_task(repo_root) + developer = get_developer(repo_root) + filter_mine = args.mine + filter_status = args.status + + if filter_mine: + if not developer: + print(colored("Error: No developer set. Run init_developer.py first", Colors.RED), file=sys.stderr) + return 1 + print(colored(f"My tasks (assignee: {developer}):", Colors.BLUE)) + else: + print(colored("All active tasks:", Colors.BLUE)) + print() + + # Single pass: collect all tasks via shared iterator + all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)} + all_statuses = {name: t.status for name, t in all_tasks.items()} + + # Display tasks hierarchically + count = 0 + + def _print_task(dir_name: str, indent: int = 0) -> None: + nonlocal count + t = all_tasks[dir_name] + + # Apply --mine filter + if filter_mine and (t.assignee or "-") != developer: + return + + # Apply --status filter + if filter_status and t.status != filter_status: + return + + relative_path = f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}" + marker = "" + if relative_path == current_task: + marker = f" {colored('<- current', Colors.GREEN)}" + + # Children progress + progress = children_progress(t.children, all_statuses) + + # Package tag + pkg_tag = f" @{t.package}" if t.package else "" + + prefix = " " * indent + " - " + + if filter_mine: + print(f"{prefix}{dir_name}/ ({t.status}){pkg_tag}{progress}{marker}") + else: + print(f"{prefix}{dir_name}/ ({t.status}){pkg_tag}{progress} [{colored(t.assignee or '-', Colors.CYAN)}]{marker}") + count += 1 + + # Print children indented + for child_name in t.children: + if child_name in all_tasks: + _print_task(child_name, indent + 1) + + # Display only top-level tasks (those without a parent) + for dir_name in sorted(all_tasks.keys()): + if not all_tasks[dir_name].parent: + _print_task(dir_name) + + if count == 0: + if filter_mine: + print(" (no tasks assigned to you)") + else: + print(" (no active tasks)") + + print() + print(f"Total: {count} task(s)") + return 0 + + +# ============================================================================= +# Command: list-archive +# ============================================================================= + +def cmd_list_archive(args: argparse.Namespace) -> int: + """List archived tasks.""" + repo_root = get_repo_root() + tasks_dir = get_tasks_dir(repo_root) + archive_dir = tasks_dir / "archive" + month = args.month + + print(colored("Archived tasks:", Colors.BLUE)) + print() + + if month: + month_dir = archive_dir / month + if month_dir.is_dir(): + print(f"[{month}]") + for d in sorted(month_dir.iterdir()): + if d.is_dir(): + print(f" - {d.name}/") + else: + print(f" No archives for {month}") + else: + if archive_dir.is_dir(): + for month_dir in sorted(archive_dir.iterdir()): + if month_dir.is_dir(): + month_name = month_dir.name + count = sum(1 for d in month_dir.iterdir() if d.is_dir()) + print(f"[{month_name}] - {count} task(s)") + + return 0 + + +# ============================================================================= +# Help +# ============================================================================= + +def show_usage() -> None: + """Show usage help.""" + print("""Task Management Script + +Usage: + python task.py create <title> Create new task directory + python task.py create <title> --package <pkg> Create task for a specific package + python task.py create <title> --parent <dir> Create task as child of parent + python task.py add-context <dir> <jsonl> <path> [reason] Add entry to jsonl + python task.py validate <dir> Validate jsonl files + python task.py list-context <dir> List jsonl entries + python task.py start <dir> Set active task + python task.py current [--source] Show active task + python task.py finish Clear active task + python task.py set-branch <dir> <branch> Set git branch + python task.py set-base-branch <dir> <branch> Set PR target branch + python task.py set-scope <dir> <scope> Set scope for PR title + python task.py archive <task-dir> Archive completed task + python task.py add-subtask <parent> <child> Link child task to parent + python task.py remove-subtask <parent> <child> Unlink child from parent + python task.py list [--mine] [--status <status>] List tasks + python task.py list-archive [YYYY-MM] List archived tasks + +Monorepo options: + --package <pkg> Package name (validated against config.yaml packages) + +List options: + --mine, -m Show only tasks assigned to current developer + --status, -s <s> Filter by status (planning, in_progress, review, completed) + +Examples: + python task.py create "Add login feature" --slug add-login + python task.py create "Add login feature" --slug add-login --package cli + python task.py create "Child task" --slug child --parent .trellis/tasks/01-21-parent + python task.py add-context <dir> implement .trellis/spec/cli/backend/auth.md "Auth guidelines" + python task.py set-branch <dir> task/add-login + python task.py start .trellis/tasks/01-21-add-login + python task.py current --source + python task.py finish + python task.py archive add-login + python task.py add-subtask parent-task child-task # Link existing tasks + python task.py remove-subtask parent-task child-task + python task.py list # List all active tasks + python task.py list --mine # List my tasks only + python task.py list --mine --status in_progress # List my in-progress tasks +""") + + +# ============================================================================= +# Main Entry +# ============================================================================= + +def main() -> int: + """CLI entry point.""" + # Deprecation guard: `init-context` was removed in v0.5.0-beta.12. + # Detect early so argparse doesn't mask the real reason with a generic + # "invalid choice" error. + if len(sys.argv) >= 2 and sys.argv[1] == "init-context": + print( + colored( + "Error: `task.py init-context` was removed in v0.5.0-beta.12.", + Colors.RED, + ), + file=sys.stderr, + ) + print( + "implement.jsonl / check.jsonl are now seeded on `task.py create` for", + file=sys.stderr, + ) + print( + "sub-agent-capable platforms and curated by the AI during planning when needed.", + file=sys.stderr, + ) + print("See .trellis/workflow.md planning artifact guidance or run:", file=sys.stderr) + print( + " python ./.trellis/scripts/get_context.py --mode phase --step 1", + file=sys.stderr, + ) + print( + "Use `task.py add-context <dir> implement|check <path> <reason>` to append entries.", + file=sys.stderr, + ) + return 2 + + parser = argparse.ArgumentParser( + description="Task Management Script", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + subparsers = parser.add_subparsers(dest="command", help="Commands") + + # create + p_create = subparsers.add_parser("create", help="Create new task") + p_create.add_argument("title", help="Task title") + p_create.add_argument("--slug", "-s", help="Task slug") + p_create.add_argument("--assignee", "-a", help="Assignee developer") + p_create.add_argument("--priority", "-p", default="P2", help="Priority (P0-P3)") + p_create.add_argument("--description", "-d", help="Task description") + p_create.add_argument("--parent", help="Parent task directory (establishes subtask link)") + p_create.add_argument("--package", help="Package name for monorepo projects") + + # add-context + p_add = subparsers.add_parser("add-context", help="Add context entry") + p_add.add_argument("dir", help="Task directory") + p_add.add_argument("file", help="JSONL file (implement|check)") + p_add.add_argument("path", help="File path to add") + p_add.add_argument("reason", nargs="?", help="Reason for adding") + + # validate + p_validate = subparsers.add_parser("validate", help="Validate context files") + p_validate.add_argument("dir", help="Task directory") + + # list-context + p_listctx = subparsers.add_parser("list-context", help="List context entries") + p_listctx.add_argument("dir", help="Task directory") + + # start + p_start = subparsers.add_parser("start", help="Set active task") + p_start.add_argument("dir", help="Task directory") + + # current + p_current = subparsers.add_parser("current", help="Show active task") + p_current.add_argument("--source", action="store_true", + help="Show active task source") + + # finish + subparsers.add_parser("finish", help="Clear active task") + + # set-branch + p_branch = subparsers.add_parser("set-branch", help="Set git branch") + p_branch.add_argument("dir", help="Task directory") + p_branch.add_argument("branch", help="Branch name") + + # set-base-branch + p_base = subparsers.add_parser("set-base-branch", help="Set PR target branch") + p_base.add_argument("dir", help="Task directory") + p_base.add_argument("base_branch", help="Base branch name (PR target)") + + # set-scope + p_scope = subparsers.add_parser("set-scope", help="Set scope") + p_scope.add_argument("dir", help="Task directory") + p_scope.add_argument("scope", help="Scope name") + + # archive + p_archive = subparsers.add_parser("archive", help="Archive task") + p_archive.add_argument("name", help="Task directory or name") + p_archive.add_argument("--no-commit", action="store_true", help="Skip auto git commit after archive") + + # list + p_list = subparsers.add_parser("list", help="List tasks") + p_list.add_argument("--mine", "-m", action="store_true", help="My tasks only") + p_list.add_argument("--status", "-s", help="Filter by status") + + # add-subtask + p_addsub = subparsers.add_parser("add-subtask", help="Link child task to parent") + p_addsub.add_argument("parent_dir", help="Parent task directory") + p_addsub.add_argument("child_dir", help="Child task directory") + + # remove-subtask + p_rmsub = subparsers.add_parser("remove-subtask", help="Unlink child task from parent") + p_rmsub.add_argument("parent_dir", help="Parent task directory") + p_rmsub.add_argument("child_dir", help="Child task directory") + + # list-archive + p_listarch = subparsers.add_parser("list-archive", help="List archived tasks") + p_listarch.add_argument("month", nargs="?", help="Month (YYYY-MM)") + + args = parser.parse_args() + + if not args.command: + show_usage() + return 1 + + commands = { + "create": cmd_create, + "add-context": cmd_add_context, + "validate": cmd_validate, + "list-context": cmd_list_context, + "start": cmd_start, + "current": cmd_current, + "finish": cmd_finish, + "set-branch": cmd_set_branch, + "set-base-branch": cmd_set_base_branch, + "set-scope": cmd_set_scope, + "archive": cmd_archive, + "add-subtask": cmd_add_subtask, + "remove-subtask": cmd_remove_subtask, + "list": cmd_list, + "list-archive": cmd_list_archive, + } + + if args.command in commands: + return commands[args.command](args) + else: + show_usage() + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.trellis/spec/backend/codex-continuation-contracts.md b/.trellis/spec/backend/codex-continuation-contracts.md new file mode 100644 index 0000000..ffc99fc --- /dev/null +++ b/.trellis/spec/backend/codex-continuation-contracts.md @@ -0,0 +1,67 @@ +# Codex Continuation and CPA Integration Contracts + +## Scenario: Responses continuation middleware and CPA integration + +### 1. Scope / Trigger +- Trigger this spec whenever work touches `/v1/responses` request handling, Codex encrypted reasoning replay, 516-style truncation detection, CPA integration, or proxy/egress deployment for Codex traffic. +- This is cross-layer work: request decoding, upstream SSE control flow, auth routing, Docker/Caddy networking, and downstream response compatibility all affect correctness. +- The SJC migration proved that replacing sub2api with CPA removes the old non-passthrough production path, but does not by itself solve the 516 continuation problem. + +### 2. Signatures +- HTTP endpoint: `POST /v1/responses` +- Request headers: + - `Content-Encoding: zstd` is accepted by the middleware and decoded before JSON parsing. + - `Content-Encoding` must not be forwarded after decode because the forwarded body is plain JSON. + - `Responses-API-Base` may override the upstream Responses base according to config mode. +- Continuation detector: + - `reasoning_tokens == 518 * n - 2` + - examples: `516`, `1034`, `1552`, `2070`, `2588` +- Production CPA route: + - Public base URL: `https://cpa.konbakuyomu.us/` + - CPA local bind: `127.0.0.1:8317` + - Required production Codex egress: `socks5://172.19.0.1:1082` + +### 3. Contracts +- A continuation round is allowed only when the terminal upstream response has the truncation-token fingerprint and the completed output includes replayable encrypted reasoning. +- Hidden continuation rounds must preserve the same selected OAuth account and proxy route. Do not rotate to another account inside one folded response; encrypted reasoning may be account-bound. +- Downstream clients must see one logical Responses stream, even if the middleware or CPA opens multiple upstream rounds. +- Reasoning items may be forwarded as reasoning, but tentative message/function-call output from a truncated round must stay buffered and must be discarded if a continuation round is opened. +- Final usage must distinguish agent-facing logical usage from billed upstream usage. Keep per-round metadata redacted; never log OAuth tokens, API keys, or encrypted reasoning payloads. +- CPA's ordinary stream chunk plugin surface is not enough for this feature because it can mutate/drop chunks after executor output, but cannot own upstream retry/continuation with the same auth context. A durable CPA-native implementation belongs in the Codex executor or in a new executor-level supervisor API. + +### 4. Validation & Error Matrix +- Unsupported request `Content-Encoding` -> return `400` with an explicit decode error. +- Invalid zstd body -> return `400`; log content type, encoding, and byte length, but not body content. +- Truncation fingerprint without encrypted reasoning -> do not continue; flush the natural terminal response. +- Upstream EOF before terminal event -> emit an incomplete terminal response and do not leak buffered tentative text/tool calls. +- Continuation cap reached -> stop and report the cap reason in metadata. +- CPA egress route not reachable from the CPA container -> fail deployment validation; attach CPA to the required Docker network rather than falling back to direct egress. + +### 5. Good/Base/Bad Cases +- Good: Round 1 ends with `reasoning_tokens=516`, has encrypted reasoning, and tentative text. The middleware replays reasoning plus a hidden marker into Round 2, discards Round 1 text, and downstream receives only the folded final answer. +- Base: A normal upstream response has no truncation fingerprint. The middleware acts as a transparent stream proxy. +- Bad: A downstream plugin sees `response.completed` and tries to start another upstream request after chunks have already been emitted. This leaks partial output and cannot guarantee same-auth replay. + +### 6. Tests Required +- Unit: detector accepts `518 * n - 2` values and rejects adjacent values. +- Unit: zstd request bodies decode before JSON parsing, and `Content-Encoding` is dropped from forwarded headers. +- Stream fixture: truncated round followed by clean round produces one created event, one terminal event, monotonic sequence numbers, two reasoning items, and only the final clean answer. +- Stream fixture: truncated function call is discarded; clean function call is flushed. +- Stream fixture: EOF without terminal event emits `response.incomplete` and does not leak buffered text. +- Integration: CPA production smoke must verify `/healthz`, authenticated `/v1/models`, authenticated `/v1/responses`, and egress evidence through `172.19.0.1:1082`. + +### 7. Wrong vs Correct + +#### Wrong +```text +client -> CPA ordinary StreamChunkInterceptor plugin -> open ad hoc second request +``` + +This is too late in the pipeline: the plugin only sees downstream-bound chunks and cannot safely reuse the executor-selected Codex auth/proxy context. + +#### Correct +```text +client -> continuation supervisor -> Codex executor same-auth upstream rounds -> folded downstream stream +``` + +The continuation owner must sit at executor level, where it can inspect raw upstream SSE, preserve auth/proxy identity, replay encrypted reasoning, and reconstruct the final logical response. diff --git a/.trellis/spec/backend/database-guidelines.md b/.trellis/spec/backend/database-guidelines.md new file mode 100644 index 0000000..b61aa78 --- /dev/null +++ b/.trellis/spec/backend/database-guidelines.md @@ -0,0 +1,51 @@ +# Database Guidelines + +> Database patterns and conventions for this project. + +--- + +## Overview + +<!-- +Document your project's database conventions here. + +Questions to answer: +- What ORM/query library do you use? +- How are migrations managed? +- What are the naming conventions for tables/columns? +- How do you handle transactions? +--> + +(To be filled by the team) + +--- + +## Query Patterns + +<!-- How should queries be written? Batch operations? --> + +(To be filled by the team) + +--- + +## Migrations + +<!-- How to create and run migrations --> + +(To be filled by the team) + +--- + +## Naming Conventions + +<!-- Table names, column names, index names --> + +(To be filled by the team) + +--- + +## Common Mistakes + +<!-- Database-related mistakes your team has made --> + +(To be filled by the team) diff --git a/.trellis/spec/backend/directory-structure.md b/.trellis/spec/backend/directory-structure.md new file mode 100644 index 0000000..9bb253d --- /dev/null +++ b/.trellis/spec/backend/directory-structure.md @@ -0,0 +1,54 @@ +# Directory Structure + +> How backend code is organized in this project. + +--- + +## Overview + +<!-- +Document your project's backend directory structure here. + +Questions to answer: +- How are modules/packages organized? +- Where does business logic live? +- Where are API endpoints defined? +- How are utilities and helpers organized? +--> + +(To be filled by the team) + +--- + +## Directory Layout + +``` +<!-- Replace with your actual structure --> +src/ +├── ... +└── ... +``` + +--- + +## Module Organization + +<!-- How should new features/modules be organized? --> + +(To be filled by the team) + +--- + +## Naming Conventions + +<!-- File and folder naming rules --> + +(To be filled by the team) + +--- + +## Examples + +<!-- Link to well-organized modules as examples --> + +(To be filled by the team) diff --git a/.trellis/spec/backend/error-handling.md b/.trellis/spec/backend/error-handling.md new file mode 100644 index 0000000..bcd5533 --- /dev/null +++ b/.trellis/spec/backend/error-handling.md @@ -0,0 +1,51 @@ +# Error Handling + +> How errors are handled in this project. + +--- + +## Overview + +<!-- +Document your project's error handling conventions here. + +Questions to answer: +- What error types do you define? +- How are errors propagated? +- How are errors logged? +- How are errors returned to clients? +--> + +(To be filled by the team) + +--- + +## Error Types + +<!-- Custom error classes/types --> + +(To be filled by the team) + +--- + +## Error Handling Patterns + +<!-- Try-catch patterns, error propagation --> + +(To be filled by the team) + +--- + +## API Error Responses + +<!-- Standard error response format --> + +(To be filled by the team) + +--- + +## Common Mistakes + +<!-- Error handling mistakes your team has made --> + +(To be filled by the team) diff --git a/.trellis/spec/backend/index.md b/.trellis/spec/backend/index.md new file mode 100644 index 0000000..bfc2cd2 --- /dev/null +++ b/.trellis/spec/backend/index.md @@ -0,0 +1,39 @@ +# Backend Development Guidelines + +> Best practices for backend development in this project. + +--- + +## Overview + +This directory contains guidelines for backend development. Fill in each file with your project's specific conventions. + +--- + +## Guidelines Index + +| Guide | Description | Status | +|-------|-------------|--------| +| [Directory Structure](./directory-structure.md) | Module organization and file layout | To fill | +| [Database Guidelines](./database-guidelines.md) | ORM patterns, queries, migrations | To fill | +| [Error Handling](./error-handling.md) | Error types, handling strategies | To fill | +| [Quality Guidelines](./quality-guidelines.md) | Code standards, forbidden patterns | To fill | +| [Logging Guidelines](./logging-guidelines.md) | Structured logging, log levels | To fill | +| [Codex Continuation Contracts](./codex-continuation-contracts.md) | Responses continuation, CPA integration, and egress contracts | Active | + +--- + +## How to Fill These Guidelines + +For each guideline file: + +1. Document your project's **actual conventions** (not ideals) +2. Include **code examples** from your codebase +3. List **forbidden patterns** and why +4. Add **common mistakes** your team has made + +The goal is to help AI assistants and new team members understand how YOUR project works. + +--- + +**Language**: All documentation should be written in **English**. diff --git a/.trellis/spec/backend/logging-guidelines.md b/.trellis/spec/backend/logging-guidelines.md new file mode 100644 index 0000000..bb930df --- /dev/null +++ b/.trellis/spec/backend/logging-guidelines.md @@ -0,0 +1,51 @@ +# Logging Guidelines + +> How logging is done in this project. + +--- + +## Overview + +<!-- +Document your project's logging conventions here. + +Questions to answer: +- What logging library do you use? +- What are the log levels and when to use each? +- What should be logged? +- What should NOT be logged (PII, secrets)? +--> + +(To be filled by the team) + +--- + +## Log Levels + +<!-- When to use each level: debug, info, warn, error --> + +(To be filled by the team) + +--- + +## Structured Logging + +<!-- Log format, required fields --> + +(To be filled by the team) + +--- + +## What to Log + +<!-- Important events to log --> + +(To be filled by the team) + +--- + +## What NOT to Log + +<!-- Sensitive data, PII, secrets --> + +(To be filled by the team) diff --git a/.trellis/spec/backend/quality-guidelines.md b/.trellis/spec/backend/quality-guidelines.md new file mode 100644 index 0000000..c1e1065 --- /dev/null +++ b/.trellis/spec/backend/quality-guidelines.md @@ -0,0 +1,51 @@ +# Quality Guidelines + +> Code quality standards for backend development. + +--- + +## Overview + +<!-- +Document your project's quality standards here. + +Questions to answer: +- What patterns are forbidden? +- What linting rules do you enforce? +- What are your testing requirements? +- What code review standards apply? +--> + +(To be filled by the team) + +--- + +## Forbidden Patterns + +<!-- Patterns that should never be used and why --> + +(To be filled by the team) + +--- + +## Required Patterns + +<!-- Patterns that must always be used --> + +(To be filled by the team) + +--- + +## Testing Requirements + +<!-- What level of testing is expected --> + +(To be filled by the team) + +--- + +## Code Review Checklist + +<!-- What reviewers should check --> + +(To be filled by the team) diff --git a/.trellis/spec/guides/code-reuse-thinking-guide.md b/.trellis/spec/guides/code-reuse-thinking-guide.md new file mode 100644 index 0000000..bb789e9 --- /dev/null +++ b/.trellis/spec/guides/code-reuse-thinking-guide.md @@ -0,0 +1,223 @@ +# Code Reuse Thinking Guide + +> **Purpose**: Stop and think before creating new code - does it already exist? + +--- + +## The Problem + +**Duplicated code is the #1 source of inconsistency bugs.** + +When you copy-paste or rewrite existing logic: +- Bug fixes don't propagate +- Behavior diverges over time +- Codebase becomes harder to understand + +--- + +## Before Writing New Code + +### Step 1: Search First + +```bash +# Search for similar function names +grep -r "functionName" . + +# Search for similar logic +grep -r "keyword" . +``` + +### Step 2: Ask These Questions + +| Question | If Yes... | +|----------|-----------| +| Does a similar function exist? | Use or extend it | +| Is this pattern used elsewhere? | Follow the existing pattern | +| Could this be a shared utility? | Create it in the right place | +| Am I copying code from another file? | **STOP** - extract to shared | + +--- + +## Common Duplication Patterns + +### Pattern 1: Copy-Paste Functions + +**Bad**: Copying a validation function to another file + +**Good**: Extract to shared utilities, import where needed + +### Pattern 2: Similar Components + +**Bad**: Creating a new component that's 80% similar to existing + +**Good**: Extend existing component with props/variants + +### Pattern 3: Repeated Constants + +**Bad**: Defining the same constant in multiple files + +**Good**: Single source of truth, import everywhere + +### Pattern 4: Repeated Payload Field Extraction + +**Bad**: Multiple consumers cast the same JSON/event fields locally: + +```typescript +const description = (ev as { description?: string }).description; +const context = (ev as { context?: ContextEntry[] }).context; +``` + +This is duplicated contract logic even when the code is only two lines. Each +consumer now has its own definition of what a valid payload means. + +**Good**: Put the decoder, type guard, or projection next to the data owner: + +```typescript +if (isThreadEvent(ev)) { + renderThreadEvent(ev); +} +``` + +**Rule**: If the same untyped payload field is read in 2+ places, create a +shared type guard / normalizer / projection before adding a third reader. + +--- + +## When to Abstract + +**Abstract when**: +- Same code appears 3+ times +- Logic is complex enough to have bugs +- Multiple people might need this + +**Don't abstract when**: +- Only used once +- Trivial one-liner +- Abstraction would be more complex than duplication + +--- + +## After Batch Modifications + +When you've made similar changes to multiple files: + +1. **Review**: Did you catch all instances? +2. **Search**: Run grep to find any missed +3. **Consider**: Should this be abstracted? + +### Reducers Should Use Exhaustive Structure + +When state is derived from action-like values (`action`, `kind`, `status`, +`phase`), prefer a reducer with one `switch` over scattered `if/else` updates. + +```typescript +// BAD - action-specific state transitions are hard to audit +if (action === "opened") { ... } +else if (action === "comment") { ... } +else if (action === "status") { ... } + +// GOOD - one reducer owns the transition table +switch (event.action) { + case "opened": + ... + return; + case "comment": + ... + return; +} +``` + +This matters when the event log is the source of truth. A reducer is the +documented replay model; display code and commands should not duplicate pieces +of that replay model. + +--- + +## Checklist Before Commit + +- [ ] Searched for existing similar code +- [ ] No copy-pasted logic that should be shared +- [ ] No repeated untyped payload field extraction outside a shared decoder +- [ ] Constants defined in one place +- [ ] Similar patterns follow same structure +- [ ] Reducer/action transitions live in one reducer or command dispatcher + +--- + +## Gotcha: Python if/elif/else Exhaustive Check + +**Problem**: Python's if/elif/else chains have no compile-time exhaustive check. When you add a new value to a `Literal` type (e.g., `Platform`), existing if/elif/else chains silently fall through to `else` with wrong defaults. + +**Symptom**: New platform works partially — some methods return Claude defaults instead of platform-specific values. No error is raised. + +**Example** (`cli_adapter.py`): +```python +# BAD: "gemini" falls through to else, returns "claude" +@property +def cli_name(self) -> str: + if self.platform == "opencode": + return "opencode" + else: + return "claude" # gemini silently gets "claude"! + +# GOOD: explicit branch for every platform +@property +def cli_name(self) -> str: + if self.platform == "opencode": + return "opencode" + elif self.platform == "gemini": + return "gemini" + else: + return "claude" +``` + +**Prevention**: When adding a new value to a Python `Literal` type, search for ALL if/elif/else chains that switch on that type and add explicit branches. Don't rely on `else` being correct for new values. + +--- + +## Gotcha: Asymmetric Mechanisms Producing Same Output + +**Problem**: When two different mechanisms must produce the same file set (e.g., recursive directory copy for init vs. manual `files.set()` for update), structural changes (renaming, moving, adding subdirectories) only propagate through the automatic mechanism. The manual one silently drifts. + +**Symptom**: Init works perfectly, but update creates files at wrong paths or misses files entirely. + +**Prevention**: +- **Best**: Eliminate the asymmetry — have the manual path call the automatic one (e.g., `collectTemplateFiles()` calls `getAllScripts()` instead of maintaining its own list) +- **If asymmetry is unavoidable**: Add a regression test that compares outputs from both mechanisms +- When migrating directory structures, search for ALL code paths that reference the old structure + +**Real example**: `trellis update` had a manual `files.set()` list for 11 scripts that `getAllScripts()` already tracked. Fix: replaced the manual list with a `for..of getAllScripts()` loop. See `update.ts` refactor in v0.4.0-beta.3. + +--- + +## Template File Registration (Trellis-specific) + +When adding new files to `src/templates/trellis/scripts/`: + +**Single registration point**: `src/templates/trellis/index.ts` + +1. Add `export const xxxScript = readTemplate("scripts/path/file.py");` +2. Add to `getAllScripts()` Map + +That's it. `commands/update.ts` uses `getAllScripts()` directly — no manual sync needed. + +**Why this matters**: Without registration in `getAllScripts()`, `trellis update` won't sync the file to user projects. Bug fixes and features won't propagate. + +**History**: Before v0.4.0-beta.3, `update.ts` had its own hand-maintained file list that frequently fell out of sync with `getAllScripts()`. This caused 11 Python files to be silently skipped during `trellis update`. The fix was to eliminate the duplicate list and use `getAllScripts()` as the single source of truth. + +### Quick Checklist for New Scripts + +```bash +# After adding a new .py file, verify it's in getAllScripts(): +grep -l "newFileName" src/templates/trellis/index.ts # Should match +``` + +### Template Sync Convention + +`.trellis/scripts/` (dogfooded) and `packages/cli/src/templates/trellis/scripts/` (template) must stay identical. After editing `.trellis/scripts/`, always sync: + +```bash +rsync -av --delete --exclude='__pycache__' .trellis/scripts/ packages/cli/src/templates/trellis/scripts/ +``` + +**Gotcha**: Running rsync with wrong source/destination paths can create nested garbage directories (e.g., `.trellis/scripts/packages/cli/...`). Always double-check paths before running. diff --git a/.trellis/spec/guides/cross-layer-thinking-guide.md b/.trellis/spec/guides/cross-layer-thinking-guide.md new file mode 100644 index 0000000..9686546 --- /dev/null +++ b/.trellis/spec/guides/cross-layer-thinking-guide.md @@ -0,0 +1,327 @@ +# Cross-Layer Thinking Guide + +> **Purpose**: Think through data flow across layers before implementing. + +--- + +## The Problem + +**Most bugs happen at layer boundaries**, not within layers. + +Common cross-layer bugs: + +- API returns format A, frontend expects format B +- Database stores X, service transforms to Y, but loses data +- Multiple layers implement the same logic differently + +--- + +## Before Implementing Cross-Layer Features + +### Step 1: Map the Data Flow + +Draw out how data moves: + +``` +Source → Transform → Store → Retrieve → Transform → Display +``` + +For each arrow, ask: + +- What format is the data in? +- What could go wrong? +- Who is responsible for validation? + +### Step 2: Identify Boundaries + +| Boundary | Common Issues | +| --------------------- | --------------------------------- | +| API ↔ Service | Type mismatches, missing fields | +| Service ↔ Database | Format conversions, null handling | +| Backend ↔ Frontend | Serialization, date formats | +| Component ↔ Component | Props shape changes | + +### Step 3: Define Contracts + +For each boundary: + +- What is the exact input format? +- What is the exact output format? +- What errors can occur? + +--- + +## Common Cross-Layer Mistakes + +### Mistake 1: Implicit Format Assumptions + +**Bad**: Assuming date format without checking + +**Good**: Explicit format conversion at boundaries + +### Mistake 2: Scattered Validation + +**Bad**: Validating the same thing in multiple layers + +**Good**: Validate once at the entry point + +### Mistake 3: Leaky Abstractions + +**Bad**: Component knows about database schema + +**Good**: Each layer only knows its neighbors + +### Mistake 4: Every Consumer Parses The Same Payload + +**Bad**: A command reads JSONL events and casts fields inline: + +```typescript +const thread = (ev as { thread?: string }).thread; +const labels = (ev as { labels?: string[] }).labels; +``` + +This looks local, but it means every consumer owns a private version of the +event contract. The next field change will update one command and miss another. + +**Good**: Decode once at the event boundary, then export typed projections: + +```typescript +if (!isThreadEvent(ev)) return false; +return ev.thread === filter.thread; +``` + +**Rule**: For append-only logs, JSON streams, RPC payloads, or config files, +create one owner for: + +- event / payload type definitions +- type guards and normalization from `unknown` +- metadata projections used by UI commands +- reducers that replay state from the source of truth + +Rendering code may format fields, but it must not redefine the payload contract. + +--- + +## Checklist for Cross-Layer Features + +Before implementation: + +- [ ] Mapped the complete data flow +- [ ] Identified all layer boundaries +- [ ] Defined format at each boundary +- [ ] Decided where validation happens + +After implementation: + +- [ ] Tested with edge cases (null, empty, invalid) +- [ ] Verified error handling at each boundary +- [ ] Checked data survives round-trip +- [ ] Checked that consumers import shared decoders / projections instead of + casting payload fields locally +- [ ] Checked that derived state points back to the source event identifier + (`seq`, `id`, `version`) instead of inventing a second cursor + +--- + +## Cross-Platform Template Consistency + +In Trellis, command templates (e.g., `record-session.md`) exist in **multiple platforms** with identical or near-identical content. This is a cross-layer boundary. + +### Checklist: After Modifying Any Command Template + +- [ ] Find all platforms with the same command: `find src/templates/*/commands/trellis/ -name "<command>.*"` +- [ ] Update all platform copies (Markdown `.md` and TOML `.toml`) +- [ ] For Gemini TOML: adapt line continuations (`\\` vs `\`) and triple-quoted strings +- [ ] Run `/trellis:check-cross-layer` to verify nothing was missed + +**Real-world example**: Updated `record-session.md` in Claude to use `--mode record`, but forgot iFlow, Kilo, OpenCode, and Gemini — caught by cross-layer check. + +--- + +## Generated Runtime Template Upgrade Consistency + +Some generated files are both documentation and runtime input. In Trellis, +`.trellis/workflow.md` is parsed by `get_context.py`, `workflow_phase.py`, +SessionStart filters, and per-turn hooks. Template changes must be validated +against both fresh init and upgrade paths. + +### Checklist: After Modifying A Runtime-Parsed Template + +- [ ] Identify every runtime parser that reads the template, not just the file + writer that installs it +- [ ] Check whether relevant syntax lives outside obvious managed regions + such as tag blocks +- [ ] Verify fresh `init` output and a versioned `update` scenario that writes + the older `.trellis/.version` +- [ ] Add an upgrade regression using an older pristine template fixture, then + assert the installed file reaches the current packaged shape +- [ ] Update the backend spec that owns the runtime contract + +--- + +## Versioned Documentation Boundary + +Versioned documentation is a cross-layer boundary: source paths, `docs.json` +version routing, and the rendered version selector must all describe the same +release line. + +### Checklist: Before Editing Versioned Docs + +- [ ] Identify the target release line: stable, beta, or RC +- [ ] Verify the edited MDX path matches that line: + - stable: `docs-site/{start,advanced,...}` and `docs-site/zh/{start,advanced,...}` + - beta: `docs-site/beta/**` and `docs-site/zh/beta/**` + - RC: `docs-site/rc/**` and `docs-site/zh/rc/**` +- [ ] Verify `docs.json` navigation points the version label to the same paths +- [ ] Grep the opposite tree for release-line-specific terms before committing +- [ ] Treat beta content appearing under root release paths as a source-path bug, + not a rendering bug + +**Real-world example**: A beta-only task workflow change documented +`prd.md` + `design.md` + `implement.md`, task-creation consent, and Codex +mode banners under root `start/` and `advanced/` paths. The docs site then +served 0.6 beta behavior under the Release selector. The fix was to restore root +release docs, move the 0.6 content to `beta/` and `zh/beta/`, and add a grep +audit for beta markers against the root release tree. + +**Real-world example**: Codex inline mode changed workflow platform markers from +`[Codex]` / `[Kilo, Antigravity, Windsurf]` to `[codex-sub-agent]` / +`[codex-inline, Kilo, Antigravity, Windsurf]`. Fresh init was correct, but +`trellis update` only merged `[workflow-state:*]` blocks and preserved stale +markers outside those blocks. Result: upgraded projects got new hook scripts +but old workflow routing, so `get_context.py --mode phase --platform codex` +could return empty Phase 2.1 detail. + +--- + +## Mode-Detection Probe Checklist + +When a CLI auto-detects a mode by probing a remote resource (e.g., checking if `index.json` exists to decide marketplace vs direct download): + +### Before implementing: + +- [ ] Probe runs in **ALL** code paths that use the result (interactive, `-y`, `--flag` combos) +- [ ] 404 vs transient error are distinguished — don't treat both as "not found" +- [ ] Transient errors **abort or retry**, never silently switch modes +- [ ] Shared state (caches, prefetched data) is **reset** when context changes (e.g., user switches source) +- [ ] **Shortcut paths** (e.g., `--template` skipping picker) must have the same error-handling quality as the probed path — check that downstream functions don't call catch-all wrappers + +### After implementing: + +- [ ] Trace every path from probe result to the mode-decision branch — no fallthrough +- [ ] External format contracts (giget URI, raw URLs) are tested or at least documented as comments +- [ ] Metadata reads consume a complete response or use a streaming parser — never parse a fixed-size prefix as full JSON +- [ ] When reconstructing a composite identifier from parsed parts, verify **all** fields are included and in the **correct position** (e.g., `provider:repo/path#ref` not `provider:repo#ref/path`) +- [ ] Verify that **action functions** called after a shortcut don't internally use the old catch-all fetch — they must use the probe-quality variant when error distinction matters + +**Real-world example**: Custom registry flow had 8 bugs across 3 review rounds: (1) probe only ran in interactive mode, (2) transient errors fell through to wrong mode, (3) giget URI had `#ref` in wrong position, (4) prefetched templates leaked across source switches, (5) `--template` shortcut bypassed probe but `downloadTemplateById` internally used catch-all `fetchTemplateIndex`, turning timeouts into "Template not found". + +**Real-world example**: Agent-session update hints fetched npm `latest` metadata with `response.read(4096)` and then parsed it as complete JSON. The `@mindfoldhq/trellis` package metadata exceeded 4 KB, so the JSON was truncated, parse failed silently, and the first session injection showed no update hint. Fix: read the complete response before parsing, and add a regression where `version` is followed by an 8 KB metadata tail. + +--- + +## Cross-Platform Template Consistency + +In Trellis, command templates (e.g., `record-session.md`) exist in **multiple platforms** with identical or near-identical content. This is a cross-layer boundary. + +### Checklist: After Modifying Any Command Template + +- [ ] Find all platforms with the same command: `find src/templates/*/commands/trellis/ -name "<command>.*"` +- [ ] Update all platform copies (Markdown `.md` and TOML `.toml`) +- [ ] For Gemini TOML: adapt line continuations (`\\` vs `\`) and triple-quoted strings +- [ ] Run `/trellis:check-cross-layer` to verify nothing was missed + +**Real-world example**: Updated `record-session.md` in Claude to use `--mode record`, but forgot iFlow, Kilo, OpenCode, and Gemini — caught by cross-layer check. + +--- + +## Generated Runtime Template Upgrade Consistency + +Some generated files are both documentation and runtime input. In Trellis, +`.trellis/workflow.md` is parsed by `get_context.py`, `workflow_phase.py`, +SessionStart filters, and per-turn hooks. Template changes must be validated +against both fresh init and upgrade paths. + +### Checklist: After Modifying A Runtime-Parsed Template + +- [ ] Identify every runtime parser that reads the template, not just the file + writer that installs it +- [ ] Check whether relevant syntax lives outside obvious managed regions + such as tag blocks +- [ ] Verify fresh `init` output and a versioned `update` scenario that writes + the older `.trellis/.version` +- [ ] Add an upgrade regression using an older pristine template fixture, then + assert the installed file reaches the current packaged shape +- [ ] Update the backend spec that owns the runtime contract + +**Real-world example**: Codex inline mode changed workflow platform markers from +`[Codex]` / `[Kilo, Antigravity, Windsurf]` to `[codex-sub-agent]` / +`[codex-inline, Kilo, Antigravity, Windsurf]`. Fresh init was correct, but +`trellis update` only merged `[workflow-state:*]` blocks and preserved stale +markers outside those blocks. Result: upgraded projects got new hook scripts +but old workflow routing, so `get_context.py --mode phase --platform codex` +could return empty Phase 2.1 detail. + +--- + +## Mode-Detection Probe Checklist + +When a CLI auto-detects a mode by probing a remote resource (e.g., checking if `index.json` exists to decide marketplace vs direct download): + +### Before implementing: +- [ ] Probe runs in **ALL** code paths that use the result (interactive, `-y`, `--flag` combos) +- [ ] 404 vs transient error are distinguished — don't treat both as "not found" +- [ ] Transient errors **abort or retry**, never silently switch modes +- [ ] Shared state (caches, prefetched data) is **reset** when context changes (e.g., user switches source) +- [ ] **Shortcut paths** (e.g., `--template` skipping picker) must have the same error-handling quality as the probed path — check that downstream functions don't call catch-all wrappers + +### After implementing: +- [ ] Trace every path from probe result to the mode-decision branch — no fallthrough +- [ ] External format contracts (giget URI, raw URLs) are tested or at least documented as comments +- [ ] Metadata reads consume a complete response or use a streaming parser — never parse a fixed-size prefix as full JSON +- [ ] When reconstructing a composite identifier from parsed parts, verify **all** fields are included and in the **correct position** (e.g., `provider:repo/path#ref` not `provider:repo#ref/path`) +- [ ] Verify that **action functions** called after a shortcut don't internally use the old catch-all fetch — they must use the probe-quality variant when error distinction matters + +**Real-world example**: Custom registry flow had 8 bugs across 3 review rounds: (1) probe only ran in interactive mode, (2) transient errors fell through to wrong mode, (3) giget URI had `#ref` in wrong position, (4) prefetched templates leaked across source switches, (5) `--template` shortcut bypassed probe but `downloadTemplateById` internally used catch-all `fetchTemplateIndex`, turning timeouts into "Template not found". + +**Real-world example**: Agent-session update hints fetched npm `latest` metadata with `response.read(4096)` and then parsed it as complete JSON. The `@mindfoldhq/trellis` package metadata exceeded 4 KB, so the JSON was truncated, parse failed silently, and the first session injection showed no update hint. Fix: read the complete response before parsing, and add a regression where `version` is followed by an 8 KB metadata tail. + +--- + +## When to Create Flow Documentation + +Create detailed flow docs when: + +- Feature spans 3+ layers +- Multiple teams are involved +- Data format is complex +- Feature has caused bugs before + +--- + +## Event Log / Projection Boundary + +Append-only logs are cross-layer contracts. A single event travels through: + +``` +CLI input → event writer → events.jsonl → reader → filter → reducer → display +``` + +### Checklist: After Adding A New Event Kind Or Field + +- [ ] Add the event kind to the central event taxonomy +- [ ] Add a typed event variant or type guard at the event layer +- [ ] Add normalization helpers for array/object fields that come from + user input or JSON +- [ ] Keep `seq` / `id` assignment in the event writer only +- [ ] Make filters and reducers consume the typed event guard, not local casts +- [ ] Make display code consume reducer output or typed events, not raw JSON +- [ ] Add at least one regression that proves history replay and live filtering + use the same filter model + +**Real-world example**: Thread channels added `kind: "thread"`, `description`, +`context`, labels, and `lastSeq`. The first implementation replayed thread +state correctly, but several commands still re-parsed event payload fields with +local casts. The fix was to make the core event layer own `ThreadChannelEvent` +and `isThreadEvent`, make `reduceChannelMetadata` the only channel metadata +projection, and make `reduceThreads` the only thread replay reducer. diff --git a/.trellis/spec/guides/index.md b/.trellis/spec/guides/index.md new file mode 100644 index 0000000..56c6d77 --- /dev/null +++ b/.trellis/spec/guides/index.md @@ -0,0 +1,97 @@ +# Thinking Guides + +> **Purpose**: Expand your thinking to catch things you might not have considered. + +--- + +## Why Thinking Guides? + +**Most bugs and tech debt come from "didn't think of that"**, not from lack of skill: + +- Didn't think about what happens at layer boundaries → cross-layer bugs +- Didn't think about code patterns repeating → duplicated code everywhere +- Didn't think about edge cases → runtime errors +- Didn't think about future maintainers → unreadable code + +These guides help you **ask the right questions before coding**. + +--- + +## Available Guides + +| Guide | Purpose | When to Use | +|-------|---------|-------------| +| [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md) | Identify patterns and reduce duplication | When you notice repeated patterns | +| [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md) | Think through data flow across layers | Features spanning multiple layers | + +--- + +## Quick Reference: Thinking Triggers + +### When to Think About Cross-Layer Issues + +- [ ] Feature touches 3+ layers (API, Service, Component, Database) +- [ ] Data format changes between layers +- [ ] Multiple consumers need the same data +- [ ] You're not sure where to put some logic +- [ ] You are adding an event kind, JSONL record, RPC payload, or config field +- [ ] UI / command code starts casting raw payload fields directly + +→ Read [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md) + +### When to Think About Code Reuse + +- [ ] You're writing similar code to something that exists +- [ ] You see the same pattern repeated 3+ times +- [ ] You're adding a new field to multiple places +- [ ] **You're modifying any constant or config** +- [ ] **You're creating a new utility/helper function** ← Search first! +- [ ] Two files read the same untyped payload field with local casts +- [ ] Multiple branches update the same derived state from `kind` / `action` + +→ Read [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md) + +### When Verifying AI Cross-Review Results + +- [ ] Reviewer claims "user input can be malicious" → Check the actual data source (internal manifest? user config? external API?) +- [ ] Reviewer flags "missing validation" → Is the data from a trusted internal source? +- [ ] Reviewer says "behavior change" → Read the code comments — is it intentional design? +- [ ] Reviewer identifies a "bug" in test → Mentally delete the feature being tested — does the test still pass? If yes → tautological test + +**Common AI reviewer false-positive patterns**: +1. **Trust boundary confusion**: Treating internal data (bundled JSON manifests) as untrusted external input +2. **Ignoring design comments**: Flagging intentional behavior documented in code comments as bugs +3. **Variable misreading**: Not tracing a variable to its actual definition (e.g., Map keyed by path vs name) + +**Verification rule**: Every CRITICAL/WARNING finding must be verified against the actual code before prioritizing. Budget ~35% false-positive rate for AI reviews. + +--- + +## Pre-Modification Rule (CRITICAL) + +> **Before changing ANY value, ALWAYS search first!** + +```bash +# Search for the value you're about to change +grep -r "value_to_change" . +``` + +This single habit prevents most "forgot to update X" bugs. + +--- + +## How to Use This Directory + +1. **Before coding**: Skim the relevant thinking guide +2. **During coding**: If something feels repetitive or complex, check the guides +3. **After bugs**: Add new insights to the relevant guide (learn from mistakes) + +--- + +## Contributing + +Found a new "didn't think of that" moment? Add it to the relevant guide. + +--- + +**Core Principle**: 30 minutes of thinking saves 3 hours of debugging. diff --git a/.trellis/tasks/00-bootstrap-guidelines/prd.md b/.trellis/tasks/00-bootstrap-guidelines/prd.md new file mode 100644 index 0000000..9bee139 --- /dev/null +++ b/.trellis/tasks/00-bootstrap-guidelines/prd.md @@ -0,0 +1,126 @@ +# Bootstrap Task: Fill Project Development Guidelines + +**You (the AI) are running this task. The developer does not read this file.** + +The developer just ran `trellis init` on this project for the first time. +`.trellis/` now exists with empty spec scaffolding, and this bootstrap task +exists under `.trellis/tasks/`. When they want to work on it, they should start +this task from a session that provides Trellis session identity. + +**Your job**: help them populate `.trellis/spec/` with the team's real +coding conventions. Every future AI session — this project's +`trellis-implement` and `trellis-check` sub-agents — auto-loads spec files +listed in per-task jsonl manifests. Empty spec = sub-agents write generic +code. Real spec = sub-agents match the team's actual patterns. + +Don't dump instructions. Open with a short greeting, figure out if the repo +has any existing convention docs (CLAUDE.md, .cursorrules, etc.), and drive +the rest conversationally. + +--- + +## Status (update the checkboxes as you complete each item) + +- [ ] Fill backend guidelines +- [ ] Add code examples + +--- + +## Spec files to populate + + +### Backend guidelines + +| File | What to document | +|------|------------------| +| `.trellis/spec/backend/directory-structure.md` | Where different file types go (routes, services, utils) | +| `.trellis/spec/backend/database-guidelines.md` | ORM, migrations, query patterns, naming conventions | +| `.trellis/spec/backend/error-handling.md` | How errors are caught, logged, and returned | +| `.trellis/spec/backend/logging-guidelines.md` | Log levels, format, what to log | +| `.trellis/spec/backend/quality-guidelines.md` | Code review standards, testing requirements | + + +### Thinking guides (already populated) + +`.trellis/spec/guides/` contains general thinking guides pre-filled with +best practices. Customize only if something clearly doesn't fit this project. + +--- + +## How to fill the spec + +### Step 1: Import from existing convention files first (preferred) + +Search the repo for existing convention docs. If any exist, read them and +extract the relevant rules into the matching `.trellis/spec/` files — +usually much faster than documenting from scratch. + +| File / Directory | Tool | +|------|------| +| `CLAUDE.md` / `CLAUDE.local.md` | Claude Code | +| `AGENTS.md` | Codex / Claude Code / agent-compatible tools | +| `.cursorrules` | Cursor | +| `.cursor/rules/*.mdc` | Cursor (rules directory) | +| `.windsurfrules` | Windsurf | +| `.clinerules` | Cline | +| `.roomodes` | Roo Code | +| `.github/copilot-instructions.md` | GitHub Copilot | +| `.vscode/settings.json` → `github.copilot.chat.codeGeneration.instructions` | VS Code Copilot | +| `CONVENTIONS.md` / `.aider.conf.yml` | aider | +| `CONTRIBUTING.md` | General project conventions | +| `.editorconfig` | Editor formatting rules | + +### Step 2: Analyze the codebase for anything not covered by existing docs + +Scan real code to discover patterns. Before writing each spec file: +- Find 2-3 real examples of each pattern in the codebase. +- Reference real file paths (not hypothetical ones). +- Document anti-patterns the team clearly avoids. + +### Step 3: Document reality, not ideals + +**Critical**: write what the code *actually does*, not what it should do. +Sub-agents match the spec, so aspirational patterns that don't exist in the +codebase will cause sub-agents to write code that looks out of place. + +If the team has known tech debt, document the current state — improvement +is a separate conversation, not a bootstrap concern. + +--- + +## Quick explainer of the runtime (share when they ask "why do we need spec at all") + +- Every AI coding task spawns two sub-agents: `trellis-implement` (writes + code) and `trellis-check` (verifies quality). +- Each task has `implement.jsonl` / `check.jsonl` manifests listing which + spec files to load. +- The platform hook auto-injects those spec files + the task's `prd.md` + into every sub-agent prompt, so the sub-agent codes/reviews per team + conventions without anyone pasting them manually. +- Source of truth: `.trellis/spec/`. That's why filling it well now pays + off forever. + +--- + +## Completion + +When the developer confirms the checklist items above are done with real +examples (not placeholders), guide them to run: + +```bash +python ./.trellis/scripts/task.py finish +python ./.trellis/scripts/task.py archive 00-bootstrap-guidelines +``` + +After archive, every new developer who joins this project will get a +`00-join-<slug>` onboarding task instead of this bootstrap task. + +--- + +## Suggested opening line + +"Welcome to Trellis! Your init just set me up to help you fill the project +spec — a one-time setup so every future AI session follows the team's +conventions instead of writing generic code. Before we start, do you have +any existing convention docs (CLAUDE.md, .cursorrules, CONTRIBUTING.md, +etc.) I can pull from, or should I scan the codebase from scratch?" diff --git a/.trellis/tasks/00-bootstrap-guidelines/task.json b/.trellis/tasks/00-bootstrap-guidelines/task.json new file mode 100644 index 0000000..9eb6e63 --- /dev/null +++ b/.trellis/tasks/00-bootstrap-guidelines/task.json @@ -0,0 +1,28 @@ +{ + "id": "00-bootstrap-guidelines", + "name": "00-bootstrap-guidelines", + "title": "Bootstrap Guidelines", + "description": "Fill in project development guidelines for AI agents", + "status": "in_progress", + "dev_type": "docs", + "scope": null, + "package": null, + "priority": "P1", + "creator": "dxt98", + "assignee": "dxt98", + "createdAt": "2026-07-01", + "completedAt": null, + "branch": null, + "base_branch": null, + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [ + ".trellis/spec/backend/" + ], + "notes": "First-time setup task created by trellis init (backend project)", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/check.jsonl b/.trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/check.jsonl new file mode 100644 index 0000000..9cd59d4 --- /dev/null +++ b/.trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/design.md b/.trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/design.md new file mode 100644 index 0000000..9723c10 --- /dev/null +++ b/.trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/design.md @@ -0,0 +1,53 @@ +# Design + +## Architecture + +The migration replaces the public application relay while keeping the existing SJC egress router layer: + +`client -> cpa.konbakuyomu.us -> caddy-edge -> cpa:8317 -> CPA Codex executor -> socks5://172.19.0.1:1082 -> AT&T residential upstream -> OpenAI` + +The old sub2api stack remains available until CPA passes data-plane verification: + +`client -> sub2api.konbakuyomu.us -> caddy-edge -> sub2api-canary:8080 -> sub2api -> 1082` + +## Server Layout + +- CPA runtime directory: `/opt/codex-stacks/cpa` +- CPA config: `/opt/codex-stacks/cpa/config.yaml` +- CPA auth files: `/opt/codex-stacks/cpa/auths/*.json` +- CPA logs: `/opt/codex-stacks/cpa/logs` +- Compose service: `cpa` +- Docker network: `cpa_net` +- Published port: `127.0.0.1:8317:8317` +- Egress reachability: CPA also joins the existing `sub2api_canary_net` because the retained `sub2api-egress-att` host-network proxy listens specifically on `172.19.0.1:1082`; this keeps the migrated `proxy_url` literal and avoids reconfiguring the egress router. + +## Auth and Access Contracts + +- sub2api API keys are read from the existing database and copied into CPA `api-keys`. +- The active sub2api OAuth account is converted into CPA auth JSON with: + - `type: "codex"` + - token fields copied from sub2api credentials + - `proxy_url: "socks5://172.19.0.1:1082"` + - `disabled: false` +- Non-production or risky accounts are either skipped or written with `disabled: true`. +- Secrets stay on the server in root-only paths. Task artifacts record only counts, IDs, status, paths, and redacted key prefixes/suffixes. + +## Caddy and DNS + +The user has already created DNS for `cpa.konbakuyomu.us`. Caddy will add a new site block using the existing wildcard certificate: + +`cpa.konbakuyomu.us -> cpa:8317` + +The old `sub2api.konbakuyomu.us` route is not removed until CPA passes health, auth, model, and real response checks. + +After CPA passes verification, `sub2api.konbakuyomu.us` returns an explicit `410` response pointing clients at `https://cpa.konbakuyomu.us/`; it must not reverse proxy to the removed sub2api containers. + +## Disk Safety + +The SJC host has limited free space, so the deployment intentionally avoids extra databases and large management components. Docker prune and recursive deletion are forbidden. If space drops below the configured SJC clean threshold, only the existing capacity guard allowlist may be used. + +## Rollback + +- Before Caddy cutover: stop CPA only; leave sub2api untouched. +- After Caddy cutover: restore the previous Caddyfile from the root-only backup and reload Caddy. +- If CPA auth refresh fails: re-login the affected CPA Codex auth; do not rely on the old sub2api non-passthrough reasoning path as the long-term fallback. diff --git a/.trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/implement.jsonl b/.trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/implement.jsonl new file mode 100644 index 0000000..9cd59d4 --- /dev/null +++ b/.trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/implement.md b/.trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/implement.md new file mode 100644 index 0000000..85c3f75 --- /dev/null +++ b/.trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/implement.md @@ -0,0 +1,75 @@ +# Implementation Plan + +## Preflight + +- Verify SJC disk, Docker state, Caddy route, DNS for `cpa.konbakuyomu.us`, `1081`/`1082` egress ASN, and sub2api DB counts. +- Abort before pulling CPA if free disk space is below the SJC clean threshold unless the existing capacity guard allowlist recovers space. + +## Backup + +- Create a root-only backup directory under `/root/cpa-migration-backups/<timestamp>/`. +- Back up: + - sub2api compose and data config files + - Caddyfile + - egress router configs + - SQL dump of sub2api database + - redacted migration manifest with account/API key counts and routing decisions + +## CPA Deployment + +- Create `/opt/codex-stacks/cpa` with `config.yaml`, `docker-compose.yaml`, `auths/`, and `logs/`. +- Generate CPA auth JSON from sub2api DB: + - account `3` enabled with `proxy_url` set to `socks5://172.19.0.1:1082` + - account `2` disabled if exported + - error accounts skipped or disabled +- Copy existing active sub2api API keys into CPA `api-keys`. +- Start CPA and validate `http://127.0.0.1:8317/healthz`. + +## Cutover + +- Add or update Caddy `cpa.konbakuyomu.us` site block to reverse proxy CPA. +- Connect Caddy to `cpa_net` if needed. +- Reload Caddy and verify `https://cpa.konbakuyomu.us/healthz`. +- Run authenticated `/v1/models` and real `/v1/responses` smoke tests. +- Confirm response smoke coincides with `sub2api-egress-att` logs or an equivalent `1082` egress probe. + +## sub2api Retirement + +- Stop and remove only these explicit containers after CPA validation: + - `sub2api-canary` + - `sub2api-canary-postgres` + - `sub2api-canary-redis` +- Do not delete old data directories in this task. +- Verify the egress router containers remain running. + +## Final Verification + +- Check public CPA health and authenticated data-plane. +- Check `docker ps` confirms CPA and egress routers running, sub2api app/Postgres/Redis not running. +- Check disk free space after migration. +- Record verification evidence in the task and report remaining manual client base URL change. + +## Execution Evidence - 2026-07-01 + +- Root-only backup created at `/root/cpa-migration-backups/20260701T034218Z`. +- Backups include sub2api DB dump, sub2api compose/config, Caddyfile, egress router configs, and redacted migration manifests. +- CPA deployed at `/opt/codex-stacks/cpa` with container `cpa`, image `eceasy/cli-proxy-api:latest`; startup log reported `CLIProxyAPI Version: v7.2.47`, commit `00114be`, built `2026-06-29T11:00:58Z`. +- CPA auth migration: one enabled Codex auth from sub2api account `3`, `proxy_url=socks5://172.19.0.1:1082`; one disabled backup auth from account `2`; four active production API keys copied from sub2api group `openai-cpa-poc`. +- CPA initially could not reach `172.19.0.1:1082` from isolated `cpa_net`; confirmed `sub2api-egress-att` listens only on `172.19.0.1:1082`, then attached CPA to `sub2api_canary_net` as well as `cpa_net`. Final `sub2api_canary_net` members: `caddy-edge cpa`. +- Private checks passed: `http://127.0.0.1:8317/healthz` returned `200`; authenticated `/v1/models` returned seven models; authenticated `/v1/responses` returned `200` on `gpt-5.5`. +- Caddy now routes `cpa.konbakuyomu.us -> cpa:8317`; `https://cpa.konbakuyomu.us/healthz` returned `200`. +- Public authenticated data-plane passed: `/v1/models` returned seven models; `/v1/responses` returned `200`, model `gpt-5.5`, response text `cpa-public-ok`. +- Egress evidence after public response: `sub2api-egress-att` logged `172.19.0.6 -> chatgpt.com:443` matching `sub2api-att-residential[sub2api-att-whitelisted-ss]`. +- Old route retired: `https://sub2api.konbakuyomu.us/health` returns `410` with `migrated to https://cpa.konbakuyomu.us/`; Caddyfile no longer contains `reverse_proxy sub2api-canary:8080`. +- Removed only explicit containers: `sub2api-canary`, `sub2api-canary-postgres`, `sub2api-canary-redis`. Preserved `sub2api-egress-att` and `sub2api-egress-direct`. +- Final running relevant containers: `cpa`, `sub2api-egress-att`, `sub2api-egress-direct`. +- Final root disk snapshot: `/dev/sda1 9.6G`, used `8.5G`, available `1.1G`, `89%`. +- DNS note: SJC resolvers `1.1.1.1` and `8.8.8.8` resolve `cpa.konbakuyomu.us -> 38.59.246.182`; this Windows client still returned NXDOMAIN during verification, but `curl --resolve cpa.konbakuyomu.us:443:38.59.246.182` returned `{"status":"ok"}`. + +## Closeout Lessons + +- The production migration is complete for the gateway stack: CPA is the public endpoint, sub2api app/Postgres/Redis are stopped/removed, and the AT&T egress router remains in service. +- This migration does not by itself prove the 516 continuation problem is solved. It removes the old sub2api non-passthrough path and gives a cleaner base for the fix. +- The current CodexCont middleware is the executable behavior reference for 516 continuation: detect the `518 * n - 2` reasoning-token fingerprint, require replayable encrypted reasoning, discard tentative output from truncated rounds, and fold hidden upstream rounds into one downstream Responses stream. +- CPA's ordinary stream chunk plugin surface is insufficient for the full fix because it cannot own same-auth upstream continuation. A durable CPA implementation should live in the Codex executor or a new executor-level supervisor extension point. +- If avoiding a CPA fork is more important than a single-container production shape, keep CPA close to upstream and run CodexCont as a sidecar in front of CPA. If minimizing moving parts is more important, patch CPA natively and keep the patch small, tested, and regularly rebased. diff --git a/.trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/prd.md b/.trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/prd.md new file mode 100644 index 0000000..dbf327b --- /dev/null +++ b/.trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/prd.md @@ -0,0 +1,45 @@ +# SJC sub2api to CPA production migration + +## Goal + +Replace the SJC production sub2api endpoint with lightweight CPA while preserving Codex OAuth auth, AT&T residential egress, and rollback evidence. + +## Confirmed Facts + +- SJC is reachable with `ssh sjc-guard`; the root disk is small (`9.6G`) and had about `1.3-1.4G` free during planning. +- Existing public production endpoint `https://sub2api.konbakuyomu.us/health` returned `200`. +- The user has created the DNS record for `cpa.konbakuyomu.us`. +- Caddy currently reverse proxies `sub2api.konbakuyomu.us` to `sub2api-canary:8080`; `cli-proxy.konbakuyomu.us` is currently `410 gone`. +- Current sub2api image was created on `2026-06-06`, before the later encrypted reasoning preservation fix, and account-level `openai_passthrough` is not enabled. +- The active production OpenAI OAuth account in sub2api is account `3`, assigned to group `openai-cpa-poc`, bound to proxy `SJC-ATT-RESIDENTIAL -> socks5://172.19.0.1:1082`. +- The `1082` egress route currently exits as `AS7018 AT&T Enterprises, LLC`; `1081` remains the direct SJC route. + +## Requirements + +- R1: Deploy a lightweight CPA stack on SJC at `/opt/codex-stacks/cpa` without adding Postgres, Redis, CPA Manager, Usage Keeper, or other persistent services. +- R2: Preserve current usable client API keys so callers can migrate by changing only the base URL from `https://sub2api.konbakuyomu.us/` to `https://cpa.konbakuyomu.us/`. +- R3: Convert usable sub2api OpenAI OAuth credentials into CPA `type: codex` auth JSON files locally on the server without printing or committing tokens. +- R4: Ensure the default CPA production account uses `proxy_url: socks5://172.19.0.1:1082`; do not silently fall back to `1081` or direct SJC egress. +- R5: Add `cpa.konbakuyomu.us` to Caddy using the existing wildcard TLS material and reverse proxy it to the CPA container. +- R6: Keep rollback available until CPA is verified with health, model listing, and a real `/v1/responses` request. +- R7: After CPA verification, stop and remove only the explicitly named sub2api app/Postgres/Redis containers; keep the egress router containers because CPA depends on `1082`. +- R8: Respect the small-disk constraint: do not run `docker system prune`, `docker image prune`, recursive deletes, or bulk directory deletion. + +## Acceptance Criteria + +- [ ] Trellis artifacts exist and record the migration requirements, design, implementation order, evidence, and rollback points. +- [ ] `/opt/codex-stacks/cpa` contains root-only CPA config/auth material and a Docker Compose deployment. +- [ ] `http://127.0.0.1:8317/healthz` and `https://cpa.konbakuyomu.us/healthz` return healthy responses. +- [ ] An authenticated `/v1/models` request succeeds through CPA using a migrated API key. +- [ ] A real authenticated `/v1/responses` request succeeds through CPA and produces evidence that `1082`/AT&T egress was used. +- [ ] `sub2api-canary`, `sub2api-canary-postgres`, and `sub2api-canary-redis` are not running after cutover. +- [ ] `sub2api-egress-att` and `sub2api-egress-direct` remain running. +- [ ] No secret-bearing `.env`, auth JSON, API key, OAuth token, or database dump is written into Git or Obsidian. +- [ ] No Docker prune, recursive deletion, or bulk deletion is performed. + +## Out of Scope + +- Migrating sub2api usage history or dashboard state into CPA. +- Installing a long-term CPA management dashboard. +- Deleting old sub2api data directories without a separate explicit user confirmation. +- Retiring the egress routers or changing the upstream residential subscription architecture. diff --git a/.trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/task.json b/.trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/task.json new file mode 100644 index 0000000..1b75626 --- /dev/null +++ b/.trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/task.json @@ -0,0 +1,26 @@ +{ + "id": "sjc-sub2api-cpa-production-migration", + "name": "sjc-sub2api-cpa-production-migration", + "title": "SJC sub2api to CPA production migration", + "description": "Replace the SJC production sub2api endpoint with lightweight CPA while preserving Codex OAuth auth, AT&T residential egress, and rollback evidence.", + "status": "completed", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P0", + "creator": "dxt98", + "assignee": "dxt98", + "createdAt": "2026-07-01", + "completedAt": "2026-07-01", + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} diff --git a/.trellis/workflow.md b/.trellis/workflow.md new file mode 100644 index 0000000..06f8e6a --- /dev/null +++ b/.trellis/workflow.md @@ -0,0 +1,710 @@ +# Development Workflow + +--- + +## Core Principles + +1. **Plan before code** — figure out what to do before you start +2. **Specs injected, not remembered** — guidelines are injected via hook/skill, not recalled from memory +3. **Persist everything** — research, decisions, and lessons all go to files; conversations get compacted, files don't +4. **Incremental development** — one task at a time +5. **Capture learnings** — after each task, review and write new knowledge back to spec + +--- + +## Trellis System + +### Developer Identity + +On first use, initialize your identity: + +```bash +python ./.trellis/scripts/init_developer.py <your-name> +``` + +Creates `.trellis/.developer` (gitignored) + `.trellis/workspace/<your-name>/`. + +### Spec System + +`.trellis/spec/` holds coding guidelines organized by package and layer. + +- `.trellis/spec/<package>/<layer>/index.md` — entry point with **Pre-Development Checklist** + **Quality Check**. Actual guidelines live in the `.md` files it points to. +- `.trellis/spec/guides/index.md` — cross-package thinking guides. + +```bash +python ./.trellis/scripts/get_context.py --mode packages # list packages / layers +``` + +**When to update spec**: new pattern/convention found · bug-fix prevention to codify · new technical decision. + +### Task System + +Every task has its own directory under `.trellis/tasks/{MM-DD-name}/` holding `task.json`, `prd.md`, optional `design.md`, optional `implement.md`, optional `research/`, and context manifests (`implement.jsonl`, `check.jsonl`) for sub-agent-capable platforms. + +```bash +# Task lifecycle +python ./.trellis/scripts/task.py create "<title>" [--slug <name>] [--parent <dir>] +python ./.trellis/scripts/task.py start <name> # set active task (session-scoped when available) +python ./.trellis/scripts/task.py current --source # show active task and source +python ./.trellis/scripts/task.py finish # clear active task (triggers after_finish hooks) +python ./.trellis/scripts/task.py archive <name> # move to archive/{year-month}/ +python ./.trellis/scripts/task.py list [--mine] [--status <s>] +python ./.trellis/scripts/task.py list-archive + +# Code-spec context (injected into implement/check agents via JSONL). +# `implement.jsonl` / `check.jsonl` are seeded on `task create` for sub-agent-capable +# platforms; the AI curates real spec + research entries during planning when needed. +python ./.trellis/scripts/task.py add-context <name> <action> <file> <reason> +python ./.trellis/scripts/task.py list-context <name> [action] +python ./.trellis/scripts/task.py validate <name> + +# Task metadata +python ./.trellis/scripts/task.py set-branch <name> <branch> +python ./.trellis/scripts/task.py set-base-branch <name> <branch> # PR target +python ./.trellis/scripts/task.py set-scope <name> <scope> + +# Hierarchy (parent/child) +python ./.trellis/scripts/task.py add-subtask <parent> <child> +python ./.trellis/scripts/task.py remove-subtask <parent> <child> + +# PR creation +python ./.trellis/scripts/task.py create-pr [name] [--dry-run] +``` + +> Run `python ./.trellis/scripts/task.py --help` to see the authoritative, up-to-date list. + +**Current-task mechanism**: `task.py create` creates the task directory and (when session identity is available) auto-sets the per-session active-task pointer so the planning breadcrumb fires immediately. `task.py start` writes the same pointer (idempotent if already set) and flips `task.json.status` from `planning` to `in_progress`. State is stored under `.trellis/.runtime/sessions/`. If no context key is available from hook input, `TRELLIS_CONTEXT_ID`, or a platform-native session environment variable, there is no active task and `task.py start` fails with a session identity hint. `task.py finish` deletes the current session file (status unchanged). `task.py archive <task>` writes `status=completed`, moves the directory to `archive/`, and deletes any runtime session files that still point at the archived task. + +### Workspace System + +Records every AI session for cross-session tracking under `.trellis/workspace/<developer>/`. + +- `journal-N.md` — session log. **Max 2000 lines per file**; a new `journal-(N+1).md` is auto-created when exceeded. +- `index.md` — personal index (total sessions, last active). + +```bash +python ./.trellis/scripts/add_session.py --title "Title" --commit "hash" --summary "Summary" +``` + +### Context Script + +```bash +python ./.trellis/scripts/get_context.py # full session runtime +python ./.trellis/scripts/get_context.py --mode packages # available packages + spec layers +python ./.trellis/scripts/get_context.py --mode phase --step <X.Y> # detailed guide for a workflow step +``` + +--- + +<!-- + WORKFLOW-STATE BREADCRUMB CONTRACT (read this before editing the tag blocks below) + + The [workflow-state:STATUS] blocks embedded in the ## Phase Index section + below are the SINGLE source of truth for the per-turn `<workflow-state>` + breadcrumb that every supported AI platform's UserPromptSubmit hook + reads. inject-workflow-state.py (Python platforms) and + inject-workflow-state.js (OpenCode plugin) only parse them — there is no + fallback dict baked into the scripts after v0.5.0-rc.0. + + STATUS charset: [A-Za-z0-9_-]+. When the hook can't find a tag, it + degrades to a generic "Refer to workflow.md for current step." line — + intentionally visible so users notice and fix a broken workflow.md. + + INVARIANT (test/regression.test.ts): + Every workflow-walkthrough step marked `[required · once]` must have a + matching enforcement line in its phase's [workflow-state:*] block. The + breadcrumb is the only per-turn channel; if a mandatory step isn't + mentioned there, the AI silently skips it (Phase 1 planning gate + skip and Phase 3.4 commit skip both manifested via this gap). + + TAG ↔ PHASE scoping: + [workflow-state:no_task] → no active task; before Phase 1 + [workflow-state:planning] → all of Phase 1 (status='planning') + [workflow-state:planning-inline] → Codex inline variant of Phase 1 + [workflow-state:in_progress] → Phase 2 + Phase 3.1-3.4 + (status stays 'in_progress' from + task.py start until task.py archive) + [workflow-state:in_progress-inline] → Codex inline variant of Phase 2/3 + [workflow-state:completed] → currently DEAD: cmd_archive flips + status and moves the dir in the same + call, so the resolver loses the + pointer (block kept for a future + explicit in_progress→completed + transition) + + Editing checklist: + - When you change a [workflow-state:STATUS] block, also check the + matching phase's `[required · once]` walkthrough steps for sync + - Run `trellis update` after editing to push the new bodies to + downstream user projects (block-level managed replacement) + - Full runtime contract: + .trellis/spec/cli/backend/workflow-state-contract.md +--> + +## Phase Index + +``` +Phase 1: Plan → classify, get task-creation consent, then write planning artifacts +Phase 2: Execute → implement only after task status is in_progress +Phase 3: Finish → verify, update spec, commit, and wrap up +``` + +### Request Triage + +- Simple conversation or small task: ask only whether this turn should create a Trellis task. If the user says no, skip Trellis for this session. +- Complex task: ask whether you may create a Trellis task and enter planning. If the user says no, do not do broad inline implementation; explain, clarify scope, or suggest a smaller split. +- User approval to create a task is not approval to start implementation. Planning still happens first. + +### Planning Artifacts + +- `prd.md` — requirements, constraints, and acceptance criteria. Do not put technical design or execution checklists here. +- `design.md` — technical design for complex tasks: boundaries, contracts, data flow, tradeoffs, compatibility, rollout / rollback shape. +- `implement.md` — execution plan for complex tasks: ordered checklist, validation commands, review gates, and rollback points. +- `implement.jsonl` / `check.jsonl` — spec and research manifests for sub-agent context. They do not replace `implement.md`. +- Lightweight tasks may be PRD-only. Complex tasks must have `prd.md`, `design.md`, and `implement.md` before `task.py start`. + +### Parent / Child Task Trees + +Use a parent task when one user request contains several independently verifiable deliverables. The parent task owns the source requirement set, the task map, cross-child acceptance criteria, and final integration review; it normally should not be the implementation target unless it also has direct work. + +Use child tasks for deliverables that can be planned, implemented, checked, and archived independently. Parent/child structure is not a dependency system: if one child must wait for another, write that ordering in the child `prd.md` / `implement.md` and keep each child's acceptance criteria testable. + +Create new children with `task.py create "<title>" --slug <name> --parent <parent-dir>`. Link existing tasks with `task.py add-subtask <parent> <child>`, and unlink mistakes with `task.py remove-subtask <parent> <child>`. + +<!-- Per-turn breadcrumb: shown when there is no active task (before Phase 1) --> + +[workflow-state:no_task] +No active task. First classify the current turn and ask for task-creation consent before creating any Trellis task. +Simple conversation / small task: ask only whether this turn should create a Trellis task. If the user says no, skip Trellis for this session. +Complex task: ask the user if you can create a Trellis task and enter the planning phase. If the user says no, explain, clarify scope, or suggest a smaller split. +[/workflow-state:no_task] + +### Phase 1: Plan +- 1.0 Create task `[required · once]` (only after task-creation consent) +- 1.1 Requirement exploration `[required · repeatable]` (`prd.md`; complex tasks also need `design.md` + `implement.md`) +- 1.2 Research `[optional · repeatable]` +- 1.3 Configure context `[conditional · once]` — Claude Code, Cursor, OpenCode, Codex, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi +- 1.4 Activate task `[required · once]` (review gate, then `task.py start`; status → in_progress) +- 1.5 Completion criteria + +<!-- Per-turn breadcrumb: shown throughout Phase 1 (status='planning') --> + +[workflow-state:planning] +Load `trellis-brainstorm`; stay in planning. +Lightweight: `prd.md` can be enough. Complex: finish `prd.md`, `design.md`, and `implement.md`; ask for review before `task.py start`. +Multi-deliverable scope: consider a parent task plus independently verifiable child tasks; dependencies must be written in child artifacts, not implied by tree position. +Sub-agent mode: curate `implement.jsonl` and `check.jsonl` as spec/research manifests before start. +[/workflow-state:planning] + +<!-- Per-turn breadcrumb: shown throughout Phase 1 when codex.dispatch_mode=inline. + Codex-only opt-in alternate to [workflow-state:planning]. The main agent + edits code directly in Phase 2, so jsonl curation is skipped — + the inline workflow loads `trellis-before-dev` instead of injecting JSONL + into a sub-agent. --> + +[workflow-state:planning-inline] +Load `trellis-brainstorm`; stay in planning. +Lightweight: `prd.md` can be enough. Complex: finish `prd.md`, `design.md`, and `implement.md`; ask for review before `task.py start`. +Multi-deliverable scope: consider a parent task plus independently verifiable child tasks; dependencies must be written in child artifacts, not implied by tree position. +Inline mode: skip jsonl curation; Phase 2 reads artifacts/specs via `trellis-before-dev`. +[/workflow-state:planning-inline] + +### Phase 2: Execute +- 2.1 Implement `[required · repeatable]` +- 2.2 Quality check `[required · repeatable]` +- 2.3 Rollback `[on demand]` + +<!-- Per-turn breadcrumb: shown while status='in_progress'. + Scope: all of Phase 2 + Phase 3.1-3.4 (status stays 'in_progress' from + task.py start until task.py archive; only archive flips it). The body + therefore must cover every required step from implementation through + commit, including Phase 3.3 spec update and Phase 3.4 commit. --> + +Sub-agent dispatch protocol applies to all platforms and all sub-agents, including class-2 Codex/Copilot/Gemini/Qoder and `trellis-research`: every dispatch prompt starts with `Active task: <task path from task.py current>` before role-specific instructions. + +[workflow-state:in_progress] +Tools: `trellis-implement` / `trellis-research` are sub-agent types only (Task/Agent tool, NOT Skill; there is no skill by these names). `trellis-update-spec` is a skill. `trellis-check` exists as both; prefer the Agent form when verifying after code changes. +Flow: `trellis-implement` -> `trellis-check` -> `trellis-update-spec` -> commit (Phase 3.4) -> `/trellis:finish-work`. +Main-session default: dispatch implement/check sub-agents. Sub-agent self-exemption: if already running as `trellis-implement`, do NOT spawn another `trellis-implement` or `trellis-check`; if already running as `trellis-check`, do NOT spawn another `trellis-check` or `trellis-implement`. Dispatch is main session only. +Dispatch prompt starts with `Active task: <task path from task.py current>`. Read context: jsonl entries -> `prd.md` -> `design.md if present` -> `implement.md if present`. +[/workflow-state:in_progress] + +<!-- Per-turn breadcrumb: shown while status='in_progress' when + codex.dispatch_mode=inline. Codex-only opt-in alternate to + [workflow-state:in_progress]. The main session edits code directly + instead of dispatching sub-agents. --> + +[workflow-state:in_progress-inline] +Flow: `trellis-before-dev` -> edit -> `trellis-check` -> validation -> `trellis-update-spec` -> commit (Phase 3.4) -> `/trellis:finish-work`. +Do not dispatch implement/check sub-agents in inline mode. +Read context: `prd.md` -> `design.md if present` -> `implement.md if present`, plus relevant spec/research loaded by skills. +[/workflow-state:in_progress-inline] + +### Phase 3: Finish +- 3.1 Quality verification `[required · repeatable]` +- 3.2 Debug retrospective `[on demand]` +- 3.3 Spec update `[required · once]` +- 3.4 Commit changes `[required · once]` +- 3.5 Wrap-up reminder + +<!-- Per-turn breadcrumb: shown while status='completed'. + Currently DEAD in normal flow: cmd_archive writes status='completed' in + the same call that moves the task dir to archive/, so the active-task + resolver loses the pointer and the hook never fires on archived tasks. + Block preserved for a future status-transition redesign (e.g. an + explicit in_progress→completed command). Edit through the same spec + channel as the live blocks. --> + +[workflow-state:completed] +Code committed. Run `/trellis:finish-work`; if dirty, return to Phase 3.4 first. +[/workflow-state:completed] + +### Rules + +1. Identify which Phase you're in, then continue from the next step there +2. Run steps in order inside each Phase; `[required]` steps can't be skipped +3. Phases can roll back (e.g., Execute reveals a prd defect → return to Plan to fix, then re-enter Execute) +4. Steps tagged `[once]` are skipped if the output already exists; don't re-run +5. Artifact presence informs the next step; missing `design.md` / `implement.md` is valid for lightweight tasks and incomplete planning for complex tasks. + +### Active Task Routing + +When a user request matches one of these intents inside an active task, route first, then load the detailed phase step if needed. + +[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] + +- Planning or unclear requirements -> `trellis-brainstorm`. +- `in_progress` implementation/check -> dispatch `trellis-implement` / `trellis-check`. +- Repeated debugging -> `trellis-break-loop`; spec updates -> `trellis-update-spec`. + +[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] + +[codex-inline, Kilo, Antigravity, Windsurf] + +- Planning or unclear requirements -> `trellis-brainstorm`. +- Before editing -> `trellis-before-dev`; after editing -> `trellis-check`. +- Repeated debugging -> `trellis-break-loop`; spec updates -> `trellis-update-spec`. + +[/codex-inline, Kilo, Antigravity, Windsurf] + +### Guardrails + +- Task creation approval is not implementation approval; implementation waits for `task.py start` after artifact review. +- PRD-only is valid for lightweight tasks; complex tasks need `design.md` + `implement.md`. +- Planning must be persisted to task artifacts; checks must run before reporting completion. + +### Loading Step Detail + +At each step, run this to fetch detailed guidance: + +```bash +python ./.trellis/scripts/get_context.py --mode phase --step <step> +# e.g. python ./.trellis/scripts/get_context.py --mode phase --step 1.1 +``` + +--- + +## Phase 1: Plan + +Goal: classify the request, get task-creation consent when a task is needed, and produce the planning artifacts required before implementation. + +#### 1.0 Create task `[required · once]` + +Create the task directory only after task-creation consent. The command sets status to `planning`, writes `task.json`, creates a default `prd.md`, and auto-targets the new task when session identity is available: + +```bash +python ./.trellis/scripts/task.py create "<task title>" --slug <name> +``` + +`--slug` is the human-readable name only. Do **not** include the `MM-DD-` date prefix; `task.py create` adds that prefix automatically. + +For task trees, create the parent task first and then create each child with `--parent <parent-dir>`. Do not start the parent just because children exist; start the child that owns the next independently verifiable deliverable. + +After this command succeeds, the per-turn breadcrumb auto-switches to `[workflow-state:planning]`, telling the AI to stay in planning. + +Run only `create` here — do not also run `start`. `start` flips status to `in_progress`, which switches the breadcrumb to the implementation phase before planning artifacts are reviewed. Save `start` for step 1.4. + +Skip when `python ./.trellis/scripts/task.py current --source` already points to a task. + +#### 1.1 Requirement exploration `[required · repeatable]` + +Load the `trellis-brainstorm` skill and explore requirements interactively with the user per the skill's guidance. + +The brainstorm skill will guide you to: +- Ask one question at a time +- Prefer researching over asking the user +- Prefer offering options over open-ended questions +- Update `prd.md` immediately after each user answer +- Split large scopes into a parent task plus child tasks when the deliverables can be verified independently +- Keep `prd.md` focused on requirements and acceptance criteria +- For complex tasks, produce `design.md` and `implement.md` before implementation starts + +When considering a parent/child split: +- Use a parent task when one request contains several independently verifiable deliverables. +- Parent tasks own source requirements, child-task mapping, cross-child acceptance criteria, and final integration review. +- Child tasks own actual deliverables that can be planned, implemented, checked, and archived independently. +- Parent/child structure is not a dependency system. If child B depends on child A, write that ordering in child B's `prd.md` / `implement.md`. +- Start the child task that owns the next deliverable. Do not start the parent unless the parent itself has direct implementation work. + +Return to this step whenever requirements change and revise the relevant artifact. + +#### 1.2 Research `[optional · repeatable]` + +Research can happen at any time during requirement exploration. It isn't limited to local code — you can use any available tool (MCP servers, skills, web search, etc.) to look up external information, including third-party library docs, industry practices, API references, etc. + +[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] + +Spawn the research sub-agent: + +- **Agent type**: `trellis-research` +- **Task description**: Research <specific question> +- **Key requirement**: Research output MUST be persisted to `{TASK_DIR}/research/` + +[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] + +[codex-inline, Kilo, Antigravity, Windsurf] + +Do the research in the main session directly and write findings into `{TASK_DIR}/research/`. (For `codex-inline` this avoids the `fork_turns="none"` isolation that prevents `trellis-research` sub-agents from resolving the active task path.) + +[/codex-inline, Kilo, Antigravity, Windsurf] + +**Research artifact conventions**: +- One file per research topic (e.g. `research/auth-library-comparison.md`) +- Record third-party library usage examples, API references, version constraints in files +- Note relevant spec file paths you discovered for later reference + +Brainstorm and research can interleave freely — pause to research a technical question, then return to talk with the user. + +**Key principle**: Research output must be written to files, not left only in the chat. Conversations get compacted; files don't. + +#### 1.3 Configure context `[required · once]` + +[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] + +Curate `implement.jsonl` and `check.jsonl` so the Phase 2 sub-agents get the right spec/research context. These files were seeded on `task create` with a single self-describing `_example` line; your job here is to fill in real entries. + +**Location**: `{TASK_DIR}/implement.jsonl` and `{TASK_DIR}/check.jsonl` (already exist). + +**Format**: one JSON object per line — `{"file": "<path>", "reason": "<why>"}`. Paths are repo-root relative. + +**What to put in**: +- **Spec files** — `.trellis/spec/<package>/<layer>/index.md` and any specific guideline files (`error-handling.md`, `conventions.md`, etc.) relevant to this task +- **Research files** — `{TASK_DIR}/research/*.md` that the sub-agent will need to consult + +**What NOT to put in**: +- Code files (`src/**`, `packages/**/*.ts`, etc.) — those are read by the sub-agent during implementation, not pre-registered here +- Files you're about to modify — same reason + +**Split between the two files**: +- `implement.jsonl` → specs + research the implement sub-agent needs to write code correctly +- `check.jsonl` → specs for the check sub-agent (quality guidelines, check conventions, same research if needed) + +These manifests do not replace `implement.md`. `implement.md` is the human-readable execution plan for a complex task; jsonl files only list context files to inject or load. + +**How to discover relevant specs**: + +```bash +python ./.trellis/scripts/get_context.py --mode packages +``` + +Lists every package + its spec layers with paths. Pick the entries that match this task's domain. + +**How to append entries**: + +Either edit the jsonl file directly in your editor, or use: + +```bash +python ./.trellis/scripts/task.py add-context "$TASK_DIR" implement "<path>" "<reason>" +python ./.trellis/scripts/task.py add-context "$TASK_DIR" check "<path>" "<reason>" +``` + +Delete the seed `_example` line once real entries exist (optional — it's skipped automatically by consumers). + +Skip when: `implement.jsonl` and `check.jsonl` have agent-curated entries (the seed row alone doesn't count). + +[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] + +[codex-inline, Kilo, Antigravity, Windsurf] + +Skip this step. Context is loaded directly by the `trellis-before-dev` skill in Phase 2. + +[/codex-inline, Kilo, Antigravity, Windsurf] + +#### 1.4 Activate task `[required · once]` + +After artifact review, flip the task status to `in_progress`: + +```bash +python ./.trellis/scripts/task.py start <task-dir> +``` + +For lightweight tasks, `prd.md` can be enough. For complex tasks, `prd.md`, `design.md`, and `implement.md` must exist and be reviewed before start. On sub-agent-capable platforms, curate jsonl manifests when extra spec or research context is needed; seed-only manifests are tolerated by consumers. + +After this command succeeds, the breadcrumb auto-switches to `[workflow-state:in_progress]`, and the rest of Phase 2 / 3 follows. + +If `task.py start` errors with a session-identity message (no context key from hook input, `TRELLIS_CONTEXT_ID`, or platform-native session env), follow the hint in the error to set up session identity, then retry. + +#### 1.5 Completion criteria + +| Condition | Required | +|------|:---:| +| `prd.md` exists | ✅ | +| User confirms task should enter implementation | ✅ | +| `task.py start` has been run (status = in_progress) | ✅ | +| `research/` has artifacts (complex tasks) | recommended | +| `design.md` exists (complex tasks) | ✅ | +| `implement.md` exists (complex tasks) | ✅ | + +[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] + +| `implement.jsonl` / `check.jsonl` curated when extra spec or research context is needed | recommended | + +[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] + +--- + +## Phase 2: Execute + +Goal: turn reviewed planning artifacts into code that passes quality checks. + +#### 2.1 Implement `[required · repeatable]` + +[Claude Code, Cursor, OpenCode, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] + +Spawn the implement sub-agent: + +- **Agent type**: `trellis-implement` +- **Task description**: Implement the reviewed task artifacts, consulting materials under `{TASK_DIR}/research/`; finish by running project lint and type-check +- **Dispatch prompt guard**: Tell the spawned agent it is already the `trellis-implement` sub-agent and must implement directly, not spawn another `trellis-implement` / `trellis-check`. + +The platform hook/plugin auto-handles: +- Reads `implement.jsonl` and injects referenced spec/research files into the agent prompt +- Injects `prd.md`, `design.md` if present, and `implement.md` if present + +[/Claude Code, Cursor, OpenCode, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] + +[codex-sub-agent] + +Spawn the implement sub-agent: + +- **Agent type**: `trellis-implement` +- **Task description**: Implement the reviewed task artifacts, consulting materials under `{TASK_DIR}/research/`; finish by running project lint and type-check +- **Dispatch prompt guard**: The prompt MUST start with `Active task: <task path>`, then explicitly say the spawned agent is already `trellis-implement` and must implement directly without spawning another `trellis-implement` / `trellis-check`. + +The Codex sub-agent definition auto-handles the context load requirement: +- Resolves the active task with `task.py current --source`, then reads `prd.md`, `design.md` if present, and `implement.md` if present +- Reads `implement.jsonl` and requires the agent to load each referenced spec/research file before coding + +[/codex-sub-agent] + +[Kiro] + +Spawn the implement sub-agent: + +- **Agent type**: `trellis-implement` +- **Task description**: Implement the reviewed task artifacts, consulting materials under `{TASK_DIR}/research/`; finish by running project lint and type-check +- **Dispatch prompt guard**: Tell the spawned agent it is already the `trellis-implement` sub-agent and must implement directly, not spawn another `trellis-implement` / `trellis-check`. + +The platform prelude auto-handles the context load requirement: +- Reads `implement.jsonl` and injects referenced spec/research files into the agent prompt +- Injects `prd.md`, `design.md` if present, and `implement.md` if present + +[/Kiro] + +[codex-inline, Kilo, Antigravity, Windsurf] + +1. Load the `trellis-before-dev` skill to read project guidelines +2. Read `{TASK_DIR}/prd.md`, then `design.md` if present, then `implement.md` if present +3. Consult materials under `{TASK_DIR}/research/` +4. Implement the code per reviewed artifacts +5. Run project lint and type-check + +[/codex-inline, Kilo, Antigravity, Windsurf] + +#### 2.2 Quality check `[required · repeatable]` + +[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] + +Spawn the check sub-agent: + +- **Agent type**: `trellis-check` +- **Task description**: Review all code changes against specs and task artifacts; fix any findings directly; ensure lint and type-check pass +- **Dispatch prompt guard**: Tell the spawned agent it is already the `trellis-check` sub-agent and must review/fix directly, not spawn another `trellis-check` / `trellis-implement`. + +The check agent's job: +- Review code changes against specs +- Review code changes against `prd.md`, `design.md` if present, and `implement.md` if present +- Auto-fix issues it finds +- Run lint and typecheck to verify + +[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] + +[codex-inline, Kilo, Antigravity, Windsurf] + +Load the `trellis-check` skill and verify the code per its guidance: +- Spec compliance +- lint / type-check / tests +- Cross-layer consistency (when changes span layers) + +If issues are found → fix → re-check, until green. + +[/codex-inline, Kilo, Antigravity, Windsurf] + +#### 2.3 Rollback `[on demand]` + +- `check` reveals a prd defect → return to Phase 1, fix `prd.md`, then redo 2.1 +- Implementation went wrong → revert code, redo 2.1 +- Need more research → research (same as Phase 1.2), write findings into `research/` + +--- + +## Phase 3: Finish + +Goal: ensure code quality, capture lessons, record the work. + +#### 3.1 Quality verification `[required · repeatable]` + +Load the `trellis-check` skill and do a final verification: +- Spec compliance +- lint / type-check / tests +- Cross-layer consistency (when changes span layers) + +If issues are found → fix → re-check, until green. + +#### 3.2 Debug retrospective `[on demand]` + +If this task involved repeated debugging (the same issue was fixed multiple times), load the `trellis-break-loop` skill to: +- Classify the root cause +- Explain why earlier fixes failed +- Propose prevention + +The goal is to capture debugging lessons so the same class of issue doesn't recur. + +#### 3.3 Spec update `[required · once]` + +Load the `trellis-update-spec` skill and review whether this task produced new knowledge worth recording: +- Newly discovered patterns or conventions +- Pitfalls you hit +- New technical decisions + +Update the docs under `.trellis/spec/` accordingly. Even if the conclusion is "nothing to update", walk through the judgment. + +#### 3.4 Commit changes `[required · once]` + +The AI drives a batched commit of this task's code changes so `/finish-work` can run cleanly afterwards. Goal: produce work commits FIRST, then bookkeeping (archive + journal) commits land after — never interleaved. + +**Step-by-step**: + +1. **Inspect dirty state**: + ```bash + git status --porcelain + ``` + Snapshot every dirty path. If the working tree is clean, skip to 3.5. + +2. **Learn commit style** from recent history (so drafted messages blend in): + ```bash + git log --oneline -5 + ``` + Note the prefix convention (`feat:` / `fix:` / `chore:` / `docs:` ...), language (中文/English), and length style. + +3. **Classify dirty files into two groups**: + - **AI-edited this session** — files you wrote/edited via Edit/Write/Bash tool calls in this session. You know what changed and why. + - **Unrecognized** — dirty files you did NOT touch this session (could be the user's manual edits, leftover WIP from a previous session, or unrelated work). Do NOT silently include these. + +4. **Draft a commit plan**. Group AI-edited files into logical commits (1 commit per coherent change unit, not 1 commit per file). Each entry: `<commit message>` + file list. List unrecognized files separately at the bottom. + +5. **Present the plan once, ask for one-shot confirmation**. Format: + ``` + Proposed commits (in order): + 1. <message> + - <file> + - <file> + 2. <message> + - <file> + + Unrecognized dirty files (NOT in any commit — confirm include/exclude): + - <file> + - <file> + + Reply 'ok' / '行' to execute. Reply with edits, or '我自己来' / 'manual' to abort. + ``` + +6. **On confirmation**: run `git add <files>` + `git commit -m "<msg>"` for each batch in order. Do not amend. Do not push. + +7. **On rejection** (user replies "不行" / "我自己来" / "manual" / any pushback on the plan): stop. Do not attempt a second plan. The user will commit by hand; you skip ahead to 3.5 once they confirm. + +**Rules**: +- No `git commit --amend` anywhere — three-stage three-commit flow (work commits → archive commit → journal commit). +- Never push to remote in this step. +- If the user wants different message wording but accepts the file grouping, edit the message and re-confirm once — but if they reject the grouping, exit to manual mode. +- The batched plan is one prompt; do not prompt per commit. + +#### 3.5 Wrap-up reminder + +After the above, remind the user they can run `/finish-work` to wrap up (archive the task, record the session). + +--- + +## Customizing Trellis (for forks) + +This section is for developers who want to modify the Trellis workflow itself. All customization is done by editing this file; the scripts are parsers only. + +### Changing what a step means + +Edit the corresponding step's walkthrough body in the Phase 1 / 2 / 3 sections above. Critical invariants: +- No active task must triage first and ask for task-creation consent before creating a Trellis task. +- Planning must distinguish lightweight PRD-only tasks from complex tasks that require `prd.md`, `design.md`, and `implement.md` before start. +- Every required execution path must keep the Phase 3.4 commit reminder reachable before `/trellis:finish-work`. + +All tag blocks live in the `## Phase Index` section above, immediately after each phase summary: + +| Scope | Corresponding tag | +|---|---| +| No active task (before Phase 1) | `[workflow-state:no_task]` (after the Phase Index ASCII art) | +| All of Phase 1 (task created → ready for implementation) | `[workflow-state:planning]` (after Phase 1 summary) | +| Codex inline Phase 1 | `[workflow-state:planning-inline]` | +| Phase 2 + Phase 3.1–3.4 (implementation + check + wrap-up) | `[workflow-state:in_progress]` (after Phase 2 summary) | +| Codex inline Phase 2 + Phase 3.1–3.4 | `[workflow-state:in_progress-inline]` | +| After Phase 3.5 (archived) | `[workflow-state:completed]` (after Phase 3 summary; **currently DEAD**) | + +### Changing the per-turn prompt text + +Directly edit the body of the corresponding `[workflow-state:STATUS]` block. After editing, run `trellis update` (if you're a template maintainer) or restart your AI session (if you're customizing your own project) — no script changes required. + +### Adding a custom status + +Add a new block: + +``` +[workflow-state:my-status] +your per-turn prompt text +[/workflow-state:my-status] +``` + +Constraints: +- STATUS charset: `[A-Za-z0-9_-]+` (underscores and hyphens allowed, e.g. `in-review`, `blocked-by-team`) +- A lifecycle hook must write `task.json.status` to your custom value, otherwise the tag is never read +- Lifecycle hooks live in `task.json.hooks.after_*` and bind to one of `after_create / after_start / after_finish / after_archive` + +### Adding a lifecycle hook + +Add a `hooks` field to your `task.json`: + +```json +{ + "hooks": { + "after_finish": [ + "your-script-or-command-here" + ] + } +} +``` + +Supported events: `after_create / after_start / after_finish / after_archive`. Note that `after_finish` ≠ a status change (it only clears the active-task pointer); use `after_archive` for "task is done" notifications. + +### Full contract + +For the workflow state machine's runtime contract, the locations of all status writers, pseudo-statuses (`no_task` / `stale_<source_type>`), the hook reachability matrix, and other deep details, see: + +- `.trellis/spec/cli/backend/workflow-state-contract.md` — runtime contract + writer table + test invariants +- `.trellis/scripts/inject-workflow-state.py` — actual parser (reads workflow.md only, no embedded text) diff --git a/.trellis/workspace/dxt98/index.md b/.trellis/workspace/dxt98/index.md new file mode 100644 index 0000000..6b1a567 --- /dev/null +++ b/.trellis/workspace/dxt98/index.md @@ -0,0 +1,40 @@ +# Workspace Index - dxt98 + +> Journal tracking for AI development sessions. + +--- + +## Current Status + +<!-- @@@auto:current-status --> +- **Active File**: `journal-1.md` +- **Total Sessions**: 0 +- **Last Active**: - +<!-- @@@/auto:current-status --> + +--- + +## Active Documents + +<!-- @@@auto:active-documents --> +| File | Lines | Status | +|------|-------|--------| +| `journal-1.md` | ~0 | Active | +<!-- @@@/auto:active-documents --> + +--- + +## Session History + +<!-- @@@auto:session-history --> +| # | Date | Title | Commits | Branch | +|---|------|-------|---------|--------| +<!-- @@@/auto:session-history --> + +--- + +## Notes + +- Sessions are appended to journal files +- New journal file created when current exceeds 2000 lines +- Use `add_session.py` to record sessions diff --git a/.trellis/workspace/dxt98/journal-1.md b/.trellis/workspace/dxt98/journal-1.md new file mode 100644 index 0000000..2814c8f --- /dev/null +++ b/.trellis/workspace/dxt98/journal-1.md @@ -0,0 +1,7 @@ +# Journal - dxt98 (Part 1) + +> AI development session journal +> Started: 2026-07-01 + +--- + diff --git a/.trellis/workspace/index.md b/.trellis/workspace/index.md new file mode 100644 index 0000000..cb8e1f3 --- /dev/null +++ b/.trellis/workspace/index.md @@ -0,0 +1,125 @@ +# Workspace Index + +> Records of all AI Agent work records across all developers + +--- + +## Overview + +This directory tracks records for all developers working with AI Agents on this project. + +### File Structure + +``` +workspace/ +|-- index.md # This file - main index ++-- {developer}/ # Per-developer directory + |-- index.md # Personal index with session history + |-- tasks/ # Task files + | |-- *.json # Active tasks + | +-- archive/ # Archived tasks by month + +-- journal-N.md # Journal files (sequential: 1, 2, 3...) +``` + +--- + +## Active Developers + +| Developer | Last Active | Sessions | Active File | +|-----------|-------------|----------|-------------| +| (none yet) | - | - | - | + +--- + +## Getting Started + +### For New Developers + +Run the initialization script: + +```bash +python ./.trellis/scripts/init_developer.py <your-name> +``` + +This will: +1. Create your identity file (gitignored) +2. Create your progress directory +3. Create your personal index +4. Create initial journal file + +### For Returning Developers + +1. Get your developer name: + ```bash + python ./.trellis/scripts/get_developer.py + ``` + +2. Read your personal index: + ```bash + cat .trellis/workspace/$(python ./.trellis/scripts/get_developer.py)/index.md + ``` + +--- + +## Guidelines + +### Journal File Rules + +- **Max 2000 lines** per journal file +- When limit is reached, create `journal-{N+1}.md` +- Update your personal `index.md` when creating new files + +### Session Record Format + +Each session should include: +- Summary: One-line description +- Branch: Which branch the work was done on +- Main Changes: What was modified +- Git Commits: Commit hashes and messages +- Next Steps: What to do next + +--- + +## Session Template + +Use this template when recording sessions: + +```markdown +## Session {N}: {Title} + +**Date**: YYYY-MM-DD +**Task**: {task-name} +**Branch**: `{branch-name}` + +### Summary + +{One-line summary} + +### Main Changes + +- {Change 1} +- {Change 2} + +### Git Commits + +| Hash | Message | +|------|---------| +| `abc1234` | {commit message} | + +### Testing + +- [OK] {Test result} + +### Status + +[OK] **Completed** / # **In Progress** / [P] **Blocked** + +### Next Steps + +- {Next step 1} +- {Next step 2} +``` + +--- + +**Language**: All documentation must be written in **English**. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c9c4c66 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,21 @@ +<!-- TRELLIS:START --> +# Trellis Instructions + +These instructions are for AI assistants working in this project. + +This project is managed by Trellis. The working knowledge you need lives under `.trellis/`: + +- `.trellis/workflow.md` — development phases, when to create tasks, skill routing +- `.trellis/spec/` — package- and layer-scoped coding guidelines (read before writing code in a given layer) +- `.trellis/workspace/` — per-developer journals and session traces +- `.trellis/tasks/` — active and archived tasks (PRDs, research, jsonl context) + +If a Trellis command is available on your platform (e.g. `/trellis:finish-work`, `/trellis:continue`), prefer it over manual steps. Not every platform exposes every command. + +If you're using Codex or another agent-capable tool, additional project-scoped helpers may live in: +- `.agents/skills/` — reusable Trellis skills +- `.codex/agents/` — optional custom subagents + +Managed by Trellis. Edits outside this block are preserved; edits inside may be overwritten by a future `trellis update`. + +<!-- TRELLIS:END --> From e7546ff8d219f0409e12a07a87b3d1a9c1fa767b Mon Sep 17 00:00:00 2001 From: konbakuyomu <konbakuyomu@gmail.com> Date: Wed, 1 Jul 2026 12:20:15 +0800 Subject: [PATCH 03/68] chore(task): archive 07-01-sjc-sub2api-cpa-production-migration --- .../07-01-sjc-sub2api-cpa-production-migration/check.jsonl | 0 .../07-01-sjc-sub2api-cpa-production-migration/design.md | 0 .../07-01-sjc-sub2api-cpa-production-migration/implement.jsonl | 0 .../07-01-sjc-sub2api-cpa-production-migration/implement.md | 0 .../2026-07}/07-01-sjc-sub2api-cpa-production-migration/prd.md | 0 .../07-01-sjc-sub2api-cpa-production-migration/task.json | 2 +- 6 files changed, 1 insertion(+), 1 deletion(-) rename .trellis/tasks/{ => archive/2026-07}/07-01-sjc-sub2api-cpa-production-migration/check.jsonl (100%) rename .trellis/tasks/{ => archive/2026-07}/07-01-sjc-sub2api-cpa-production-migration/design.md (100%) rename .trellis/tasks/{ => archive/2026-07}/07-01-sjc-sub2api-cpa-production-migration/implement.jsonl (100%) rename .trellis/tasks/{ => archive/2026-07}/07-01-sjc-sub2api-cpa-production-migration/implement.md (100%) rename .trellis/tasks/{ => archive/2026-07}/07-01-sjc-sub2api-cpa-production-migration/prd.md (100%) rename .trellis/tasks/{ => archive/2026-07}/07-01-sjc-sub2api-cpa-production-migration/task.json (99%) diff --git a/.trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/check.jsonl b/.trellis/tasks/archive/2026-07/07-01-sjc-sub2api-cpa-production-migration/check.jsonl similarity index 100% rename from .trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/check.jsonl rename to .trellis/tasks/archive/2026-07/07-01-sjc-sub2api-cpa-production-migration/check.jsonl diff --git a/.trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/design.md b/.trellis/tasks/archive/2026-07/07-01-sjc-sub2api-cpa-production-migration/design.md similarity index 100% rename from .trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/design.md rename to .trellis/tasks/archive/2026-07/07-01-sjc-sub2api-cpa-production-migration/design.md diff --git a/.trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/implement.jsonl b/.trellis/tasks/archive/2026-07/07-01-sjc-sub2api-cpa-production-migration/implement.jsonl similarity index 100% rename from .trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/implement.jsonl rename to .trellis/tasks/archive/2026-07/07-01-sjc-sub2api-cpa-production-migration/implement.jsonl diff --git a/.trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/implement.md b/.trellis/tasks/archive/2026-07/07-01-sjc-sub2api-cpa-production-migration/implement.md similarity index 100% rename from .trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/implement.md rename to .trellis/tasks/archive/2026-07/07-01-sjc-sub2api-cpa-production-migration/implement.md diff --git a/.trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/prd.md b/.trellis/tasks/archive/2026-07/07-01-sjc-sub2api-cpa-production-migration/prd.md similarity index 100% rename from .trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/prd.md rename to .trellis/tasks/archive/2026-07/07-01-sjc-sub2api-cpa-production-migration/prd.md diff --git a/.trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/task.json b/.trellis/tasks/archive/2026-07/07-01-sjc-sub2api-cpa-production-migration/task.json similarity index 99% rename from .trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/task.json rename to .trellis/tasks/archive/2026-07/07-01-sjc-sub2api-cpa-production-migration/task.json index 1b75626..1cc45c4 100644 --- a/.trellis/tasks/07-01-sjc-sub2api-cpa-production-migration/task.json +++ b/.trellis/tasks/archive/2026-07/07-01-sjc-sub2api-cpa-production-migration/task.json @@ -23,4 +23,4 @@ "relatedFiles": [], "notes": "", "meta": {} -} +} \ No newline at end of file From 130b5f0b740823e48964aa8c9ec10cd999ec2968 Mon Sep 17 00:00:00 2001 From: konbakuyomu <konbakuyomu@gmail.com> Date: Wed, 1 Jul 2026 12:20:30 +0800 Subject: [PATCH 04/68] chore: record journal --- .trellis/workspace/dxt98/index.md | 9 +++---- .trellis/workspace/dxt98/journal-1.md | 34 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/.trellis/workspace/dxt98/index.md b/.trellis/workspace/dxt98/index.md index 6b1a567..0d84144 100644 --- a/.trellis/workspace/dxt98/index.md +++ b/.trellis/workspace/dxt98/index.md @@ -8,8 +8,8 @@ <!-- @@@auto:current-status --> - **Active File**: `journal-1.md` -- **Total Sessions**: 0 -- **Last Active**: - +- **Total Sessions**: 1 +- **Last Active**: 2026-07-01 <!-- @@@/auto:current-status --> --- @@ -19,7 +19,7 @@ <!-- @@@auto:active-documents --> | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~0 | Active | +| `journal-1.md` | ~41 | Active | <!-- @@@/auto:active-documents --> --- @@ -29,6 +29,7 @@ <!-- @@@auto:session-history --> | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 1 | 2026-07-01 | SJC CPA migration closeout | `98bf0df`, `aa113c2` | `main` | <!-- @@@/auto:session-history --> --- @@ -37,4 +38,4 @@ - Sessions are appended to journal files - New journal file created when current exceeds 2000 lines -- Use `add_session.py` to record sessions +- Use `add_session.py` to record sessions \ No newline at end of file diff --git a/.trellis/workspace/dxt98/journal-1.md b/.trellis/workspace/dxt98/journal-1.md index 2814c8f..6a0b2fa 100644 --- a/.trellis/workspace/dxt98/journal-1.md +++ b/.trellis/workspace/dxt98/journal-1.md @@ -5,3 +5,37 @@ --- + + +## Session 1: SJC CPA migration closeout + +**Date**: 2026-07-01 +**Task**: SJC CPA migration closeout +**Branch**: `main` + +### Summary + +Recorded the completed SJC sub2api to CPA migration, captured Codex continuation/CPA integration contracts, and committed zstd request-body decoding tests. + +### Main Changes + +(Add details) + +### Git Commits + +| Hash | Message | +|------|---------| +| `98bf0df` | (see git log) | +| `aa113c2` | (see git log) | + +### Testing + +- [OK] (Add test results) + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete From e9c2dee1b53a837cf4a0a33b9ca1ddf56e61c24a Mon Sep 17 00:00:00 2001 From: konbakuyomu <konbakuyomu@gmail.com> Date: Wed, 1 Jul 2026 13:16:54 +0800 Subject: [PATCH 05/68] docs: record CPA CodexCont rollout --- .../check.jsonl | 1 + .../design.md | 56 +++++++ .../implement.jsonl | 1 + .../implement.md | 149 ++++++++++++++++++ .../prd.md | 48 ++++++ .../task.json | 26 +++ 6 files changed, 281 insertions(+) create mode 100644 .trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/check.jsonl create mode 100644 .trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/design.md create mode 100644 .trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/implement.jsonl create mode 100644 .trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/implement.md create mode 100644 .trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/prd.md create mode 100644 .trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/task.json diff --git a/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/check.jsonl b/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/check.jsonl new file mode 100644 index 0000000..9cd59d4 --- /dev/null +++ b/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/design.md b/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/design.md new file mode 100644 index 0000000..52d030b --- /dev/null +++ b/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/design.md @@ -0,0 +1,56 @@ +# Design + +## Architecture + +Production API data path: + +`Codex client -> cpa.konbakuyomu.us -> caddy-edge -> codexcont:8787 -> cpa:8317 -> CPA Codex executor -> socks5://172.19.0.1:1082 -> OpenAI` + +Direct CPA paths remain: + +`client -> cpa.konbakuyomu.us -> caddy-edge -> cpa:8317` + +Management path: + +`browser -> Cloudflare Access -> cpa-admin.konbakuyomu.us -> Cloudflare Tunnel -> 127.0.0.1:8327 -> cpa-admin-proxy -> cpa:8317 -> CPA management panel` + +## Runtime Layout + +- CodexCont stack: `/opt/codex-stacks/codexcont` +- CodexCont config: `/opt/codex-stacks/codexcont/config.toml` +- CodexCont app source: copied from this repository into `/opt/codex-stacks/codexcont/app` +- CodexCont container: `codexcont` +- CodexCont Docker network: `cpa_net` +- CPA admin tunnel stack: `/opt/codex-stacks/cpa-admin-tunnel` +- CPA admin loopback proxy: `cpa-admin-proxy`, host bind `127.0.0.1:8327` +- CF token file: `/opt/codex-stacks/cpa-admin-tunnel/.env`, root-only, never committed +- Backup root: `/root/cpa-codexcont-admin-backups/<timestamp>/` + +## Caddy Contract + +- `cpa.konbakuyomu.us /v1/responses` reverse proxies to `codexcont:8787`. +- `cpa.konbakuyomu.us /management.html`, `/v0/management*`, and `/v0/resource/plugins/*` return a blocking status. +- All other CPA traffic reverse proxies to `cpa:8317`. +- Rollback is restoring the backed-up Caddyfile and reloading Caddy. + +## CPA Management Contract + +- `remote-management.secret-key` is set to a generated high-entropy secret. +- `remote-management.allow-remote` remains `false`. +- The `config.yaml` bind mount becomes writable because CPA hashes and persists plaintext management keys at startup. +- The plaintext management key is stored in a root-only credential file for the user to retrieve over SSH; task artifacts only record the path. +- Docker-published `127.0.0.1:8317` reaches CPA from a bridge address, so CPA does not treat it as a local client under `allow-remote: false`. The admin proxy keeps `allow-remote: false` by setting local-only forwarding headers before proxying to CPA. + +## Cloudflare Tunnel Contract + +- The user supplies a Cloudflare Dashboard Tunnel token out of band. +- The cloudflared container runs with host networking and targets `http://127.0.0.1:8327`. +- Cloudflare Access must protect `cpa-admin.konbakuyomu.us`; CPA management key remains the second layer. +- If the token is not available during implementation, leave the tunnel stack prepared but not running, and record the exact start command. + +## Local Evaluation Contract + +- Do not modify `C:\Users\dxt98\.codex`. +- Create a temporary local test `CODEX_HOME` with only the minimal `config.toml` and auth material needed for the eval. +- Point the test provider at `https://cpa.konbakuyomu.us/v1` with `wire_api = "responses"`. +- Run `D:\Dev\50_Scripts\52_Python\codex-candy-eval\codex_candy_eval.py` from its own directory. diff --git a/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/implement.jsonl b/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/implement.jsonl new file mode 100644 index 0000000..9cd59d4 --- /dev/null +++ b/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/implement.md b/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/implement.md new file mode 100644 index 0000000..1549b74 --- /dev/null +++ b/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/implement.md @@ -0,0 +1,149 @@ +# Implementation Plan + +## 1. Preflight + +- Confirm Git status and active task. +- Confirm SJC disk, Docker state, CPA/Caddy containers, networks, and current management endpoint status. +- Confirm no secrets are printed in command output. + +## 2. Backups + +- Create `/root/cpa-codexcont-admin-backups/<timestamp>/`. +- Back up `/opt/codex-stacks/cpa/docker-compose.yaml`, `/opt/codex-stacks/cpa/config.yaml`, Caddyfile, and any new stack manifests. +- Record only backup path and redacted evidence. + +## 3. CodexCont Sidecar + +- Create `/opt/codex-stacks/codexcont/app` and copy only runtime files needed by CodexCont. +- Create `config.toml` with: + - `server.host = "0.0.0.0"` + - `server.port = 8787` + - `server.listen_paths = ["/v1/responses"]` + - `upstream.url = "http://cpa:8317/v1/responses"` + - `upstream.mode = "fixed"` + - `auth.mode = "passthrough"` + - continuation enabled with existing defaults +- Create Dockerfile using Python 3.12 and install project dependencies. +- Start `codexcont` on `cpa_net`. +- Validate from container/network that CodexCont can reach CPA. + +## 4. Caddy Cutover + +- Update Caddy for `cpa.konbakuyomu.us`: + - Block management paths. + - Route `/v1/responses` to `codexcont:8787`. + - Route all other traffic to `cpa:8317`. +- Reload Caddy. +- Verify health, models, real responses, and CodexCont logs. +- Roll back Caddy if responses fail. + +## 5. CPA Management API + +- Generate a strong CPA management key on the server. +- Store plaintext key in a root-only credentials file and write only the path in task evidence. +- Update CPA `config.yaml` with `remote-management.secret-key` and `allow-remote: false`. +- Change compose mount for config to writable. +- Restart CPA and verify local `/management.html` and authenticated `/v0/management/config`. +- Verify public API domain management paths remain blocked. + +## 6. Cloudflare Tunnel + +- Create `/opt/codex-stacks/cpa-admin-tunnel/docker-compose.yaml`. +- Run `cpa-admin-proxy` on `127.0.0.1:8327` to preserve CPA `allow-remote: false`. +- Create root-only `.env` placeholder or use the user-provided `TUNNEL_TOKEN`. +- Start `cloudflared` only after token is available. +- Verify `cpa-admin.konbakuyomu.us/management.html` reaches Cloudflare Access / management panel. + +## 7. Eval + +- Create an isolated local test `CODEX_HOME` under a temp directory. +- Configure a test provider for `https://cpa.konbakuyomu.us/v1`. +- Run `python codex_candy_eval.py -m gpt-5.5 -r high -n 5`. +- Keep or delete the temp test home only after reporting the path; do not touch the user's normal Codex config. + +## 8. Closeout + +- Update task evidence with validation results. +- Run local CodexCont tests if repository code changed. +- Commit Trellis task artifacts and any repo changes. +- Record remaining manual Cloudflare Access/token action if blocked. + +## Execution Evidence + +### Preflight And Backups + +- SJC access: `ssh sjc-guard`. +- Backup path: `/root/cpa-codexcont-admin-backups/20260701T044651Z`. +- Disk before CodexCont build: `/dev/sda1` around `8.5G used / 845-846M free` after build, `92%`. +- No Docker prune or bulk filesystem deletion was used. + +### CodexCont Sidecar + +- Stack path: `/opt/codex-stacks/codexcont`. +- Container: `codexcont`. +- Network: `cpa_net`. +- Runtime image build used `python:3.12-slim`. +- Fixed a UTF-8 BOM in server `config.toml`; `tomllib` rejected the BOM at line 1 before the fix. +- Container health evidence: + - `codexcont` can reach `http://cpa:8317/healthz` with HTTP `200`. + - `caddy-edge` resolves `codexcont` on `cpa_net`. + +### Caddy Cutover + +- `cpa.konbakuyomu.us` Caddy block now: + - blocks `/management.html`, `/v0/management*`, `/v0/resource/plugins/*` with `404`. + - sends `/v1/responses` and `/v1/responses/*` to `codexcont:8787`. + - sends other paths to `cpa:8317`. +- `caddy validate --config /etc/caddy/Caddyfile`: valid. +- `caddy reload --config /etc/caddy/Caddyfile`: succeeded. + +### Public API Validation + +- `https://cpa.konbakuyomu.us/healthz`: HTTP `200`. +- `https://cpa.konbakuyomu.us/v1/models` with existing API key: HTTP `200`. +- `https://cpa.konbakuyomu.us/v1/responses` streaming smoke with `gpt-5.5`: HTTP `200`, expected marker text returned. +- Public management blocking: + - `https://cpa.konbakuyomu.us/management.html`: HTTP `404`. + - `https://cpa.konbakuyomu.us/v0/management/config`: HTTP `404`. +- CodexCont log evidence for live response: + - `fold start: model=gpt-5.5 path=/v1/responses url=http://cpa:8317/v1/responses`. + - continuation decisions were logged for eval rounds. +- Egress evidence: + - `sub2api-egress-att` logs show CPA traffic to `chatgpt.com:443` through `sub2api-att-residential`. + +### CPA Management + +- CPA management key generated on server only. +- Plaintext management key path: `/root/cpa-codexcont-admin-backups/20260701T044651Z/cpa-management-key.txt`. +- CPA `config.yaml` bind mount changed from read-only to writable. +- CPA restarted with local image and `--pull never`. +- CPA hashed and persisted `remote-management.secret-key`; plaintext was not left in `config.yaml`. +- `allow-remote` remains `false`. +- Direct host request to `127.0.0.1:8317/v0/management/config` returned `403 remote management disabled` because Docker publish reaches CPA as a bridge peer, not loopback. +- Admin proxy added to preserve `allow-remote: false`: + - stack path: `/opt/codex-stacks/cpa-admin-tunnel`. + - container: `cpa-admin-proxy`. + - host bind: `127.0.0.1:8327`. + - proxy sets `X-Forwarded-For`, `X-Real-IP`, and `CF-Connecting-IP` to `127.0.0.1`. +- Admin proxy validation: + - `http://127.0.0.1:8327/management.html`: HTTP `200`. + - `http://127.0.0.1:8327/v0/management/config` without key: HTTP `401`. + - `http://127.0.0.1:8327/v0/management/config` with management key: HTTP `200`. + +### Cloudflare Tunnel Status + +- `/opt/codex-stacks/cpa-admin-tunnel/docker-compose.yaml` prepared with: + - `cpa-admin-proxy` running now. + - `cpa-admin-tunnel` using `cloudflare/cloudflared:latest`, host networking, and `.env`. +- `/opt/codex-stacks/cpa-admin-tunnel/.env` exists as root-only placeholder. +- `cpa-admin-tunnel` is not started because no Cloudflare Tunnel token has been provided yet. +- Cloudflare Dashboard public hostname should target `http://127.0.0.1:8327`, not `8317`, because of the CPA local-client behavior above. + +### Isolated Codex Candy Eval + +- Normal `C:\Users\dxt98\.codex` was not modified. +- Temporary `CODEX_HOME`: `C:\Users\dxt98\AppData\Local\Temp\codex-cpa-eval-home-20260701130319`. +- Test provider: `https://cpa.konbakuyomu.us/v1`, `wire_api = "responses"`, API key supplied via process environment only. +- Command: `python D:\Dev\50_Scripts\52_Python\codex-candy-eval\codex_candy_eval.py -m gpt-5.5 -r high -n 5`. +- Result: `4/5` correct, `80.0%`. +- Important caveat: CodexCont did catch and continue multiple `518n-2` rounds, but one eval run still ended wrong after a first-round `reasoning_tokens=516`. Current sidecar is a mitigation and traffic-path fix, not yet a proof of complete 516-class elimination. diff --git a/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/prd.md b/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/prd.md new file mode 100644 index 0000000..aeb3023 --- /dev/null +++ b/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/prd.md @@ -0,0 +1,48 @@ +# CPA CodexCont Sidecar and Admin Tunnel + +## Goal + +Run CodexCont as a server-side continuation middleware in front of CPA for `/v1/responses`, keep CPA on the upstream official image, and expose CPA's management panel only through a Cloudflare Tunnel protected by Cloudflare Access and a CPA management key. + +## Confirmed Facts + +- Production CPA currently runs on SJC at `/opt/codex-stacks/cpa`, container `cpa`, image `eceasy/cli-proxy-api:latest`, bound as `127.0.0.1:8317:8317`. +- `cpa` is on `cpa_net` and `sub2api_canary_net`; the `sub2api-egress-att` route remains required for `socks5://172.19.0.1:1082`. +- CPA management endpoints and `/management.html` currently return `404`, because `remote-management.secret-key` is not enabled. +- SJC root disk is small: about `9.6G` total, `8.5G` used, `1.1G` available during planning. +- The host Python is `3.10`, while CodexCont requires Python `>=3.12`; the production sidecar should therefore run in a Python 3.12 container. +- No existing `cloudflared` binary or tunnel container was found on SJC. +- Local `codex-candy-eval` uses the `codex` CLI. To avoid touching the user's daily local Codex config, evaluation must use an isolated `CODEX_HOME` or command-line config overrides. + +## Requirements + +- R1: Create `/opt/codex-stacks/codexcont` and run CodexCont as a restartable service/container on SJC. +- R2: Route only `cpa.konbakuyomu.us/v1/responses` through CodexCont; keep other CPA API routes direct to CPA. +- R3: Configure CodexCont upstream as `http://cpa:8317/v1/responses`, auth mode `passthrough`, continuation enabled. +- R4: Explicitly block public access on `cpa.konbakuyomu.us` to `/management.html`, `/v0/management*`, and `/v0/resource/plugins/*`. +- R5: Enable CPA management with a strong generated management key while keeping `remote-management.allow-remote: false`. +- R6: Prepare a Cloudflare Tunnel stack for `cpa-admin.konbakuyomu.us` using a Dashboard Token, stored only in a root-only server path. +- R7: The Cloudflare Access policy for `cpa-admin.konbakuyomu.us` must restrict access to the user's Cloudflare identity before the endpoint is considered production-ready. +- R8: Do not print OAuth tokens, API keys, Cloudflare Tunnel tokens, or management keys into terminal output, Git, Obsidian, or task artifacts. +- R9: Do not run Docker prune or recursive/bulk deletion; if disk space becomes insufficient, pause before cleanup. +- R10: Record candy-eval results honestly; do not claim the 516 class is completely eliminated unless live evidence supports it. + +## Acceptance Criteria + +- [ ] Trellis artifacts record requirements, design, implementation steps, backups, evidence, and residual risks. +- [ ] CodexCont is running on SJC and can reach CPA through `cpa_net`. +- [ ] `https://cpa.konbakuyomu.us/healthz` succeeds. +- [ ] Authenticated `https://cpa.konbakuyomu.us/v1/models` succeeds through CPA. +- [ ] Authenticated `https://cpa.konbakuyomu.us/v1/responses` succeeds and CodexCont logs show it handled the request. +- [ ] Public `https://cpa.konbakuyomu.us/management.html` and `/v0/management/config` are blocked. +- [ ] `https://cpa-admin.konbakuyomu.us/management.html` is reachable only through Cloudflare Access and then CPA management key authentication. +- [ ] A `codex-candy-eval` run using isolated local test config points at `https://cpa.konbakuyomu.us/v1` without changing `C:\Users\dxt98\.codex`. +- [ ] Candy-eval result and CodexCont fold logs are recorded, including any residual 516-class failure. + +## Out of Scope + +- Forking or patching CPA itself for 516 continuation. +- Enabling a heavy CPA management stack, Postgres, Redis, or usage keeper. +- Deleting old sub2api data directories or running Docker prune. +- Storing Cloudflare or CPA management secrets in the repository. +- Claiming benchmark-level 516 correctness from route smoke tests alone. diff --git a/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/task.json b/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/task.json new file mode 100644 index 0000000..7abd82a --- /dev/null +++ b/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/task.json @@ -0,0 +1,26 @@ +{ + "id": "cpa-codexcont-sidecar-admin-tunnel", + "name": "cpa-codexcont-sidecar-admin-tunnel", + "title": "CPA CodexCont sidecar and admin tunnel", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "dxt98", + "assignee": "dxt98", + "createdAt": "2026-07-01", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "Server-side CodexCont and CPA admin proxy are deployed. Cloudflare Tunnel start is blocked on the Dashboard Tunnel token. Isolated candy eval ran 5 tests via CPA and scored 4/5; sidecar caught 518n-2 rounds but does not yet prove complete 516-class elimination.", + "meta": {} +} From a08fce131382050bf6bd945d9d49ce04ebd85659 Mon Sep 17 00:00:00 2001 From: konbakuyomu <konbakuyomu@gmail.com> Date: Wed, 1 Jul 2026 15:37:11 +0800 Subject: [PATCH 06/68] docs: record CPA admin tunnel validation --- .../implement.md | 7 ++++--- .../prd.md | 18 +++++++++--------- .../task.json | 2 +- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/implement.md b/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/implement.md index 1549b74..b27f6cd 100644 --- a/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/implement.md +++ b/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/implement.md @@ -135,9 +135,10 @@ - `/opt/codex-stacks/cpa-admin-tunnel/docker-compose.yaml` prepared with: - `cpa-admin-proxy` running now. - `cpa-admin-tunnel` using `cloudflare/cloudflared:latest`, host networking, and `.env`. -- `/opt/codex-stacks/cpa-admin-tunnel/.env` exists as root-only placeholder. -- `cpa-admin-tunnel` is not started because no Cloudflare Tunnel token has been provided yet. -- Cloudflare Dashboard public hostname should target `http://127.0.0.1:8327`, not `8317`, because of the CPA local-client behavior above. +- `/opt/codex-stacks/cpa-admin-tunnel/.env` is root-only and secrets were not recorded in Git, Obsidian, or Trellis artifacts. +- User provided/configured the Cloudflare Tunnel token out of band and reported the Docker Cloudflare connector is connected. +- Cloudflare Dashboard public hostname targets `http://127.0.0.1:8327`, not `8317`, because of the CPA local-client behavior above. +- Final user acceptance: `https://cpa-admin.konbakuyomu.us/management.html` reaches the CPA management panel through Cloudflare Tunnel + Cloudflare Access + CPA management key. ### Isolated Codex Candy Eval diff --git a/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/prd.md b/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/prd.md index aeb3023..d65d209 100644 --- a/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/prd.md +++ b/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/prd.md @@ -29,15 +29,15 @@ Run CodexCont as a server-side continuation middleware in front of CPA for `/v1/ ## Acceptance Criteria -- [ ] Trellis artifacts record requirements, design, implementation steps, backups, evidence, and residual risks. -- [ ] CodexCont is running on SJC and can reach CPA through `cpa_net`. -- [ ] `https://cpa.konbakuyomu.us/healthz` succeeds. -- [ ] Authenticated `https://cpa.konbakuyomu.us/v1/models` succeeds through CPA. -- [ ] Authenticated `https://cpa.konbakuyomu.us/v1/responses` succeeds and CodexCont logs show it handled the request. -- [ ] Public `https://cpa.konbakuyomu.us/management.html` and `/v0/management/config` are blocked. -- [ ] `https://cpa-admin.konbakuyomu.us/management.html` is reachable only through Cloudflare Access and then CPA management key authentication. -- [ ] A `codex-candy-eval` run using isolated local test config points at `https://cpa.konbakuyomu.us/v1` without changing `C:\Users\dxt98\.codex`. -- [ ] Candy-eval result and CodexCont fold logs are recorded, including any residual 516-class failure. +- [x] Trellis artifacts record requirements, design, implementation steps, backups, evidence, and residual risks. +- [x] CodexCont is running on SJC and can reach CPA through `cpa_net`. +- [x] `https://cpa.konbakuyomu.us/healthz` succeeds. +- [x] Authenticated `https://cpa.konbakuyomu.us/v1/models` succeeds through CPA. +- [x] Authenticated `https://cpa.konbakuyomu.us/v1/responses` succeeds and CodexCont logs show it handled the request. +- [x] Public `https://cpa.konbakuyomu.us/management.html` and `/v0/management/config` are blocked. +- [x] `https://cpa-admin.konbakuyomu.us/management.html` is reachable only through Cloudflare Access and then CPA management key authentication. +- [x] A `codex-candy-eval` run using isolated local test config points at `https://cpa.konbakuyomu.us/v1` without changing `C:\Users\dxt98\.codex`. +- [x] Candy-eval result and CodexCont fold logs are recorded, including any residual 516-class failure. ## Out of Scope diff --git a/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/task.json b/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/task.json index 7abd82a..f3eee5d 100644 --- a/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/task.json +++ b/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/task.json @@ -21,6 +21,6 @@ "children": [], "parent": null, "relatedFiles": [], - "notes": "Server-side CodexCont and CPA admin proxy are deployed. Cloudflare Tunnel start is blocked on the Dashboard Tunnel token. Isolated candy eval ran 5 tests via CPA and scored 4/5; sidecar caught 518n-2 rounds but does not yet prove complete 516-class elimination.", + "notes": "Server-side CodexCont, CPA admin proxy, and Cloudflare Tunnel/Access admin path are deployed. User validated that cpa-admin.konbakuyomu.us/management.html reaches the CPA management panel through the intended chain. Isolated candy eval ran 5 tests via CPA and scored 4/5; sidecar caught 518n-2 rounds but does not yet prove complete 516-class elimination.", "meta": {} } From a3d2177b258d2b7a3888269fd95892892b4dbfb5 Mon Sep 17 00:00:00 2001 From: konbakuyomu <konbakuyomu@gmail.com> Date: Wed, 1 Jul 2026 15:37:35 +0800 Subject: [PATCH 07/68] chore(task): archive 07-01-cpa-codexcont-sidecar-admin-tunnel --- .../07-01-cpa-codexcont-sidecar-admin-tunnel/check.jsonl | 0 .../07-01-cpa-codexcont-sidecar-admin-tunnel/design.md | 0 .../implement.jsonl | 0 .../07-01-cpa-codexcont-sidecar-admin-tunnel/implement.md | 0 .../07-01-cpa-codexcont-sidecar-admin-tunnel/prd.md | 0 .../07-01-cpa-codexcont-sidecar-admin-tunnel/task.json | 6 +++--- 6 files changed, 3 insertions(+), 3 deletions(-) rename .trellis/tasks/{ => archive/2026-07}/07-01-cpa-codexcont-sidecar-admin-tunnel/check.jsonl (100%) rename .trellis/tasks/{ => archive/2026-07}/07-01-cpa-codexcont-sidecar-admin-tunnel/design.md (100%) rename .trellis/tasks/{ => archive/2026-07}/07-01-cpa-codexcont-sidecar-admin-tunnel/implement.jsonl (100%) rename .trellis/tasks/{ => archive/2026-07}/07-01-cpa-codexcont-sidecar-admin-tunnel/implement.md (100%) rename .trellis/tasks/{ => archive/2026-07}/07-01-cpa-codexcont-sidecar-admin-tunnel/prd.md (100%) rename .trellis/tasks/{ => archive/2026-07}/07-01-cpa-codexcont-sidecar-admin-tunnel/task.json (93%) diff --git a/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/check.jsonl b/.trellis/tasks/archive/2026-07/07-01-cpa-codexcont-sidecar-admin-tunnel/check.jsonl similarity index 100% rename from .trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/check.jsonl rename to .trellis/tasks/archive/2026-07/07-01-cpa-codexcont-sidecar-admin-tunnel/check.jsonl diff --git a/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/design.md b/.trellis/tasks/archive/2026-07/07-01-cpa-codexcont-sidecar-admin-tunnel/design.md similarity index 100% rename from .trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/design.md rename to .trellis/tasks/archive/2026-07/07-01-cpa-codexcont-sidecar-admin-tunnel/design.md diff --git a/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/implement.jsonl b/.trellis/tasks/archive/2026-07/07-01-cpa-codexcont-sidecar-admin-tunnel/implement.jsonl similarity index 100% rename from .trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/implement.jsonl rename to .trellis/tasks/archive/2026-07/07-01-cpa-codexcont-sidecar-admin-tunnel/implement.jsonl diff --git a/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/implement.md b/.trellis/tasks/archive/2026-07/07-01-cpa-codexcont-sidecar-admin-tunnel/implement.md similarity index 100% rename from .trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/implement.md rename to .trellis/tasks/archive/2026-07/07-01-cpa-codexcont-sidecar-admin-tunnel/implement.md diff --git a/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/prd.md b/.trellis/tasks/archive/2026-07/07-01-cpa-codexcont-sidecar-admin-tunnel/prd.md similarity index 100% rename from .trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/prd.md rename to .trellis/tasks/archive/2026-07/07-01-cpa-codexcont-sidecar-admin-tunnel/prd.md diff --git a/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/task.json b/.trellis/tasks/archive/2026-07/07-01-cpa-codexcont-sidecar-admin-tunnel/task.json similarity index 93% rename from .trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/task.json rename to .trellis/tasks/archive/2026-07/07-01-cpa-codexcont-sidecar-admin-tunnel/task.json index f3eee5d..bb3840c 100644 --- a/.trellis/tasks/07-01-cpa-codexcont-sidecar-admin-tunnel/task.json +++ b/.trellis/tasks/archive/2026-07/07-01-cpa-codexcont-sidecar-admin-tunnel/task.json @@ -3,7 +3,7 @@ "name": "cpa-codexcont-sidecar-admin-tunnel", "title": "CPA CodexCont sidecar and admin tunnel", "description": "", - "status": "in_progress", + "status": "completed", "dev_type": null, "scope": null, "package": null, @@ -11,7 +11,7 @@ "creator": "dxt98", "assignee": "dxt98", "createdAt": "2026-07-01", - "completedAt": null, + "completedAt": "2026-07-01", "branch": null, "base_branch": "main", "worktree_path": null, @@ -23,4 +23,4 @@ "relatedFiles": [], "notes": "Server-side CodexCont, CPA admin proxy, and Cloudflare Tunnel/Access admin path are deployed. User validated that cpa-admin.konbakuyomu.us/management.html reaches the CPA management panel through the intended chain. Isolated candy eval ran 5 tests via CPA and scored 4/5; sidecar caught 518n-2 rounds but does not yet prove complete 516-class elimination.", "meta": {} -} +} \ No newline at end of file From 82e54b821699c3dfe3ebfe4ebcc529e7f94bf1e8 Mon Sep 17 00:00:00 2001 From: konbakuyomu <konbakuyomu@gmail.com> Date: Wed, 1 Jul 2026 16:13:24 +0800 Subject: [PATCH 08/68] feat: add CodexCont status dashboard --- .../check.jsonl | 2 + .../design.md | 54 +++ .../implement.jsonl | 3 + .../implement.md | 97 +++++ .../07-01-codexcont-status-dashboard/prd.md | 49 +++ .../task.json | 26 ++ README.md | 21 ++ README_zh.md | 21 ++ config.example.toml | 3 + middleware/admin.py | 131 +++++++ middleware/app.py | 70 +++- middleware/config.py | 8 + middleware/dashboard.html | 353 ++++++++++++++++++ middleware/diagnostics.py | 254 +++++++++++++ middleware/proxy.py | 43 +++ tests/test_middleware.py | 74 ++++ 16 files changed, 1205 insertions(+), 4 deletions(-) create mode 100644 .trellis/tasks/07-01-codexcont-status-dashboard/check.jsonl create mode 100644 .trellis/tasks/07-01-codexcont-status-dashboard/design.md create mode 100644 .trellis/tasks/07-01-codexcont-status-dashboard/implement.jsonl create mode 100644 .trellis/tasks/07-01-codexcont-status-dashboard/implement.md create mode 100644 .trellis/tasks/07-01-codexcont-status-dashboard/prd.md create mode 100644 .trellis/tasks/07-01-codexcont-status-dashboard/task.json create mode 100644 middleware/admin.py create mode 100644 middleware/dashboard.html create mode 100644 middleware/diagnostics.py diff --git a/.trellis/tasks/07-01-codexcont-status-dashboard/check.jsonl b/.trellis/tasks/07-01-codexcont-status-dashboard/check.jsonl new file mode 100644 index 0000000..d3b4a2a --- /dev/null +++ b/.trellis/tasks/07-01-codexcont-status-dashboard/check.jsonl @@ -0,0 +1,2 @@ +{"file": ".trellis/spec/backend/codex-continuation-contracts.md", "reason": "Validate continuation metadata, secret logging, and production CPA route invariants."} +{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "Validate status/log event contracts from backend to frontend."} diff --git a/.trellis/tasks/07-01-codexcont-status-dashboard/design.md b/.trellis/tasks/07-01-codexcont-status-dashboard/design.md new file mode 100644 index 0000000..1dbe142 --- /dev/null +++ b/.trellis/tasks/07-01-codexcont-status-dashboard/design.md @@ -0,0 +1,54 @@ +# Design + +## Architecture + +Dashboard functionality lives inside the existing CodexCont Starlette app. CPA remains an official upstream service and is not forked or extended for this first version. + +Production data path remains unchanged: + +`Codex -> cpa.konbakuyomu.us/v1/responses -> Caddy -> CodexCont -> CPA -> OpenAI` + +Admin view path: + +`Browser -> Cloudflare Access -> cpa-admin.konbakuyomu.us/codexcont/ -> cpa-admin-proxy -> codexcont:8787/admin/` + +## Backend Contracts + +- `GET /admin/healthz` returns `{ "ok": true }` plus process uptime. +- `GET /admin/status` returns process metrics, recent counters, upstream health, and a safe config summary. +- `GET /admin/logs?limit=N` returns the newest redacted in-memory log events. +- `GET /admin/logs/stream` uses `text/event-stream`; new events are emitted as JSON `data:` frames. +- `GET /admin/` serves the static dashboard. Static assets can be embedded or served under `/admin/static/...`; no Node build is required. + +## Metrics And Logs + +- Add a small diagnostics module responsible for: + - monotonic service start time and uptime + - bounded ring buffer + - subscriber queues for SSE + - request counters and active request tracking + - continuation/truncation/failure counters + - log redaction for sensitive-looking strings and headers +- Instrument these points: + - request accepted / passthrough / fold start + - round decision with reasoning token count and continuation decision + - continuation opened + - request finished cleanly or failed + - upstream health probe result +- Use generated request IDs for dashboard correlation only; do not expose upstream tokens or reasoning payloads. + +## Frontend + +- Single static operational dashboard, not a landing page. +- Compact top band: CodexCont health, CPA health, active requests, SSE connection status. +- Metrics grid: total requests, continuations, truncation hits, failures, last continuation. +- Live log table: timestamp, level, event, request id, model, round, reason, message. +- Controls: level/event filter, pause, autoscroll, clear local view, reconnect state. +- Styling is plain CSS with restrained colors, max 8px card radius, stable dimensions, responsive grid, and no decorative gradient/orb background. + +## Deployment Contract + +- Public API host `cpa.konbakuyomu.us` must not expose `/admin` or `/codexcont`. +- Existing `cpa-admin-proxy` should route `/codexcont/` to `codexcont:8787/admin/` with path stripping; all other paths continue to CPA management. +- Cloudflare Access remains the outer auth layer. CodexCont admin routes do not implement their own login for v1. +- Server rollout must back up current CodexCont stack and `cpa-admin-proxy` config before changes. diff --git a/.trellis/tasks/07-01-codexcont-status-dashboard/implement.jsonl b/.trellis/tasks/07-01-codexcont-status-dashboard/implement.jsonl new file mode 100644 index 0000000..98b4e35 --- /dev/null +++ b/.trellis/tasks/07-01-codexcont-status-dashboard/implement.jsonl @@ -0,0 +1,3 @@ +{"file": ".trellis/spec/backend/codex-continuation-contracts.md", "reason": "Continuation and CPA integration constraints for instrumenting /v1/responses without changing fold semantics."} +{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "Dashboard events cross backend, SSE, and frontend rendering boundaries."} +{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "Diagnostics owns one event/metrics projection instead of duplicated payload parsing."} diff --git a/.trellis/tasks/07-01-codexcont-status-dashboard/implement.md b/.trellis/tasks/07-01-codexcont-status-dashboard/implement.md new file mode 100644 index 0000000..5a313f4 --- /dev/null +++ b/.trellis/tasks/07-01-codexcont-status-dashboard/implement.md @@ -0,0 +1,97 @@ +# Implementation Plan + +## 1. Task And Spec Prep + +- Confirm clean Git state and current Trellis task. +- Read applicable Trellis specs before code edits. +- Keep sensitive deployment values out of Trellis artifacts. + +## 2. Backend Diagnostics + +- Add a diagnostics module with a bounded in-memory ring buffer, metrics snapshot, subscriber queues, and redaction helpers. +- Add admin routes to the Starlette app. +- Add lightweight instrumentation in `handle_responses` and `fold_stream` for lifecycle and continuation events. +- Add upstream health probe from `/admin/status` using the configured CPA upstream base. + +## 3. Frontend + +- Add static dashboard HTML/CSS/JS served by CodexCont. +- Use EventSource for `/admin/logs/stream` and fetch `/admin/status` periodically. +- Include filters, pause/autoscroll controls, local clear, connection state, and responsive layout. + +## 4. Local Validation + +- Add/extend tests for diagnostics ring buffer, redaction, SSE stream, admin routes, and current middleware behavior. +- Run the existing test suite. +- Use Playwright or a local browser smoke to capture desktop and mobile dashboard states. + +## 5. Server Rollout + +- Back up current `/opt/codex-stacks/codexcont` and `/opt/codex-stacks/cpa-admin-tunnel` config files to a root-only timestamped path. +- Rebuild/restart only the CodexCont stack; do not prune Docker. +- Update `cpa-admin-proxy` so `/codexcont/` proxies to `codexcont:8787/admin/`. +- Verify public API host still blocks admin paths. +- Verify `cpa-admin.konbakuyomu.us/codexcont/` loads through Cloudflare Access. + +## 6. Acceptance Evidence + +- Record local test results, server route checks, and dashboard live-log proof in `implement.md`. +- Specifically record whether this active Codex conversation or another real `/v1/responses` request appears in live logs. +- Commit code and Trellis artifacts with narrow staging. + +## Rollback + +- Restore backed-up CodexCont stack files and restart `codexcont`. +- Restore backed-up `cpa-admin-proxy` config if `/codexcont/` routing breaks CPA management access. +- Caddy public API routing should not need rollback if it remains unchanged. + +## Execution Evidence + +### Local Implementation + +- Added `middleware.diagnostics` as the single in-memory owner for metrics, ring buffer events, subscribers, and redaction. +- Added read-only admin routes and static dashboard under `/admin/`. +- Instrumented `/v1/responses` request start, passthrough/fold start, round decision, continuation open, finish, and failure events. +- Added `[admin].max_log_events = 800` default config. +- Updated README and README_zh with dashboard usage and project layout. + +### Local Validation + +- `.venv\Scripts\python.exe -m py_compile middleware\app.py middleware\admin.py middleware\diagnostics.py middleware\proxy.py middleware\config.py tests\test_middleware.py`: passed. +- `.venv\Scripts\python.exe tests\test_middleware.py`: `123/123 checks passed`. +- `git diff --check`: passed. +- Playwright via Node REPL + system Edge validated `http://127.0.0.1:8797/admin/`: + - desktop 1440px: title `CodexCont Dashboard`, SSE `Live`, status cards `3`, metric cards `4`, horizontal overflow `false`. + - mobile 390px: horizontal overflow `false`, log table rendered, local invalid `/v1/responses` event appeared in the log table. + +### Server Rollout + +- Backup path: `/root/codexcont-dashboard-backups/20260701T080223Z`. +- Disk before rollout: `/dev/sda1` around `8.7G used / 832-833M available / 92%`. +- Uploaded updated CodexCont middleware files to `/opt/codex-stacks/codexcont/app/middleware/`. +- Added production `[admin] max_log_events = 800` to `/opt/codex-stacks/codexcont/config.toml`. +- Updated `/opt/codex-stacks/cpa-admin-tunnel/Caddyfile` so: + - `/codexcont/` routes to `codexcont:8787/admin/`. + - all other paths continue to `cpa:8317`. +- `docker exec cpa-admin-proxy caddy validate --config /etc/caddy/Caddyfile`: valid. +- Rebuilt/restarted only `codexcont`; restarted only `cpa-admin-proxy`. +- No Docker prune or bulk filesystem deletion was used. + +### Server Validation + +- Local admin proxy: + - `http://127.0.0.1:8327/codexcont/`: HTTP `200`. + - `http://127.0.0.1:8327/codexcont/status`: HTTP `200`; CPA upstream health `200`, `log_retention=800`. + - `http://127.0.0.1:8327/management.html`: HTTP `200`, preserving CPA management access. +- Cloudflare Access: + - `https://cpa-admin.konbakuyomu.us/codexcont/`: HTTP `302` to Cloudflare Access login when unauthenticated. + - `https://cpa-admin.konbakuyomu.us/management.html`: HTTP `302` to Cloudflare Access login when unauthenticated. +- Public API domain: + - `https://cpa.konbakuyomu.us/healthz`: HTTP `200`. + - `https://cpa.konbakuyomu.us/admin/`: HTTP `404`. + - `https://cpa.konbakuyomu.us/codexcont/`: HTTP `404`. + - `https://cpa.konbakuyomu.us/management.html`: HTTP `404`. +- Live-log proof: + - Current real Codex conversation traffic appeared as `fold_start`, `round_decision`, and `request_finished` for `model=gpt-5.5`. + - After the redaction fix, `round_decision` preserved numeric `reasoning_tokens` such as `281`. + - A controlled public invalid JSON request to `/v1/responses` returned HTTP `400` and appeared as `request_failed reason=invalid_json_body`. diff --git a/.trellis/tasks/07-01-codexcont-status-dashboard/prd.md b/.trellis/tasks/07-01-codexcont-status-dashboard/prd.md new file mode 100644 index 0000000..fb2fb64 --- /dev/null +++ b/.trellis/tasks/07-01-codexcont-status-dashboard/prd.md @@ -0,0 +1,49 @@ +# CodexCont Status Dashboard + +## Goal + +Add a lightweight, server-side CodexCont dashboard that shows current service health, request/continuation metrics, and real-time logs for the production CodexCont sidecar. The page must help verify the live CPA chain used by this Codex conversation without modifying CPA or storing persistent logs on the small SJC disk. + +## Confirmed Facts + +- CodexCont is a Python Starlette proxy currently serving `/v1/responses`. +- Production traffic is routed as `cpa.konbakuyomu.us/v1/responses -> CodexCont -> CPA -> OpenAI`. +- CPA remains the official image; CodexCont owns the 516/518n-2 continuation mitigation. +- Admin access already works through `cpa-admin.konbakuyomu.us` via Cloudflare Tunnel + Cloudflare Access + CPA management key. +- SJC disk is small, so first version must avoid persistent logs, databases, Docker prune, and broad cleanup. +- This Codex conversation is expected to use the same production path, so the dashboard should be able to show live logs from real ongoing chat traffic. + +## Requirements + +- R1: Add read-only admin routes inside CodexCont: + - `GET /admin/healthz` + - `GET /admin/status` + - `GET /admin/logs` + - `GET /admin/logs/stream` + - `GET /admin/` +- R2: Track in-process metrics: uptime, active requests, total requests, continuation count, 516/518n-2 truncation hits, failure count, last request/continuation/error timestamps, and upstream CPA health. +- R3: Add an in-memory ring buffer for structured, redacted operational events. Do not record request bodies, authorization headers, API keys, OAuth tokens, or encrypted reasoning content. +- R4: Stream logs to the browser with SSE so the page updates without manual refresh. +- R5: Provide a compact operational frontend with status cards, metrics, live log table, filters, pause/autoscroll controls, and mobile-safe layout. +- R6: Expose the page only through `https://cpa-admin.konbakuyomu.us/codexcont/`; keep public `https://cpa.konbakuyomu.us` from exposing `/admin` or `/codexcont`. +- R7: Keep logs memory-only by default, with a bounded retention size around 500-1000 entries. +- R8: Preserve existing `/v1/responses` behavior and current continuation semantics. + +## Acceptance Criteria + +- [x] Trellis artifacts record PRD, design, implementation steps, deployment evidence, and residual risks. +- [x] Local tests pass for ring buffer retention, subscriber broadcast, redaction, admin route smoke, and existing middleware behavior. +- [x] Local frontend check confirms the dashboard renders without overlapping text on desktop and mobile viewports. +- [x] `GET /admin/status` reports live metrics and redacted config summary. +- [x] `GET /admin/logs/stream` emits live SSE events when requests flow through CodexCont. +- [x] On SJC, `https://cpa-admin.konbakuyomu.us/codexcont/` opens through the existing Cloudflare Access path. +- [x] This active Codex conversation or a real `/v1/responses` request appears in the dashboard live logs. +- [x] `https://cpa.konbakuyomu.us/admin/` and `https://cpa.konbakuyomu.us/codexcont/` are not publicly exposed. +- [x] No secrets are printed or committed, no persistent log store is added, and no Docker prune or bulk deletion is used. + +## Out Of Scope + +- CPA plugin implementation for v1. +- Long-term historical analytics, log database, login system, or multi-user RBAC. +- Changing CPA auth/account scheduling, OpenAI OAuth tokens, or 516 continuation logic beyond instrumentation hooks. +- Deleting old stack data or cleaning server disk outside explicitly named files. diff --git a/.trellis/tasks/07-01-codexcont-status-dashboard/task.json b/.trellis/tasks/07-01-codexcont-status-dashboard/task.json new file mode 100644 index 0000000..0f01c85 --- /dev/null +++ b/.trellis/tasks/07-01-codexcont-status-dashboard/task.json @@ -0,0 +1,26 @@ +{ + "id": "codexcont-status-dashboard", + "name": "codexcont-status-dashboard", + "title": "CodexCont status dashboard", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "dxt98", + "assignee": "dxt98", + "createdAt": "2026-07-01", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "CodexCont dashboard is implemented and deployed. Local tests passed 123/123; Edge smoke verified desktop/mobile layout and live SSE logs. SJC cpa-admin path /codexcont/ routes through Cloudflare Access to CodexCont; public cpa.konbakuyomu.us admin/codexcont paths return 404. Live logs showed current gpt-5.5 Codex conversation traffic and a controlled invalid_json_body event.", + "meta": {} +} diff --git a/README.md b/README.md index 3545c25..d30c966 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,23 @@ Security guard: if a request supplies `Responses-API-Base`, the middleware will Do not commit secrets. `rt.json` and `free_rt.json` are ignored by `.gitignore`, and tokens in `config.toml` should be handled carefully. +## Dashboard + +CodexCont serves a lightweight read-only dashboard at: + +```text +http://127.0.0.1:8787/admin/ +``` + +It exposes service status, upstream health, in-memory request metrics, and live redacted logs through SSE. Log history is memory-only and bounded by: + +```toml +[admin] +max_log_events = 800 +``` + +Do not expose `/admin/` on a public API hostname unless it is protected by an external access layer such as Cloudflare Access. + ## When continuation is applied The middleware folds only when all of the following are true: @@ -163,16 +180,20 @@ Current offline coverage includes: - header transparency - upstream URL resolution - auth safety guard +- dashboard diagnostics and admin route smoke - EOF/upstream-error behavior ## Project layout ```text middleware/ + admin.py # read-only dashboard/admin routes app.py # Starlette app and route handler codex.py # truncation math and continuation payload builders config.py # config.toml loader and dataclasses creds.py # upstream header/auth construction + dashboard.html # static dashboard page + diagnostics.py # in-memory metrics, ring buffer, and SSE subscribers proxy.py # fold_stream state machine sse.py # incremental SSE parser/serializer store.py # in-memory ID store for optional stateful repair diff --git a/README_zh.md b/README_zh.md index 37376ae..20aa1d3 100644 --- a/README_zh.md +++ b/README_zh.md @@ -105,6 +105,23 @@ chatgpt_account_id = "" # 非空时作为 chatgpt-account-id 发送 不要提交密钥。`.gitignore` 已忽略 `rt.json` 和 `free_rt.json`;如果把 token 写入 `config.toml`,也请谨慎管理。 +## 状态面板 + +CodexCont 内置一个只读状态面板: + +```text +http://127.0.0.1:8787/admin/ +``` + +它可以查看服务状态、上游健康、内存中的请求指标,以及通过 SSE 实时推送的脱敏日志。日志只保存在进程内存中,默认上限为: + +```toml +[admin] +max_log_events = 800 +``` + +不要把 `/admin/` 直接暴露在公网 API 域名上;生产环境应放在 Cloudflare Access 这类外层访问控制之后。 + ## 什么时候会执行续写折叠 只有同时满足以下条件时,中间件才会执行折叠逻辑: @@ -161,16 +178,20 @@ uv run python tests/test_middleware.py - header 透明转发 - 上游 URL 解析 - 鉴权安全保护 +- dashboard diagnostics 和 admin route smoke - EOF / 上游错误处理 ## 项目结构 ```text middleware/ + admin.py # 只读状态面板 / admin 路由 app.py # Starlette 应用和路由处理 codex.py # 截断数学和续写 payload 构造 config.py # config.toml 加载和 dataclass 配置 creds.py # 上游 header / auth 构造 + dashboard.html # 静态状态面板页面 + diagnostics.py # 内存指标、环形日志和 SSE 订阅 proxy.py # fold_stream 状态机 sse.py # 增量 SSE 解析和序列化 store.py # 可选 stateful repair 使用的内存 ID 存储 diff --git a/config.example.toml b/config.example.toml index 39b9a03..ba2ef80 100644 --- a/config.example.toml +++ b/config.example.toml @@ -50,3 +50,6 @@ rechunk_size = 16 [log] level = "info" dump_rounds_dir = "" # if set, dump per-round upstream SSE (codex_mw_r{n}.sse.txt) + +[admin] +max_log_events = 800 # memory-only dashboard log retention; no log files are written diff --git a/middleware/admin.py b/middleware/admin.py new file mode 100644 index 0000000..e9b9d91 --- /dev/null +++ b/middleware/admin.py @@ -0,0 +1,131 @@ +"""Read-only admin routes for the CodexCont dashboard.""" +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + +import httpx +from starlette.requests import Request +from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse + +from .config import Config + +_DASHBOARD_HTML = Path(__file__).with_name("dashboard.html") + + +def _admin_diag(request: Request): + return request.app.state.diagnostics + + +def _safe_config(cfg: Config, diagnostics_max_events: int) -> dict[str, Any]: + parsed = urlsplit(cfg.upstream.url) + return { + "listen_paths": list(cfg.server.listen_paths), + "upstream_mode": cfg.upstream.mode, + "upstream_host": parsed.netloc, + "upstream_path": parsed.path, + "auth_mode": cfg.auth.mode, + "continuation_enabled": cfg.cont.enabled, + "continuation_method": cfg.cont.method, + "max_continue": cfg.cont.max_continue, + "truncation_step": cfg.cont.truncation_step, + "log_retention": diagnostics_max_events, + } + + +def _upstream_health_url(cfg: Config) -> str | None: + parsed = urlsplit(cfg.upstream.url) + if not parsed.scheme or not parsed.netloc: + return None + return f"{parsed.scheme}://{parsed.netloc}/healthz" + + +async def _probe_upstream(client: httpx.AsyncClient, cfg: Config) -> dict[str, Any]: + url = _upstream_health_url(cfg) + if not url: + return {"ok": False, "status": "invalid_upstream_url"} + try: + resp = await client.get(url, timeout=3.0) + return { + "ok": 200 <= resp.status_code < 400, + "status": "http", + "status_code": resp.status_code, + "url": url, + } + except Exception as exc: + return { + "ok": False, + "status": "error", + "error": type(exc).__name__, + "url": url, + } + + +async def admin_healthz(request: Request) -> JSONResponse: + return JSONResponse(_admin_diag(request).health()) + + +async def admin_status(request: Request) -> JSONResponse: + cfg: Config = request.app.state.cfg + upstream = await _probe_upstream(request.app.state.client, cfg) + diag = _admin_diag(request) + return JSONResponse( + diag.snapshot(upstream=upstream, config=_safe_config(cfg, diag.max_events)) + ) + + +async def admin_logs(request: Request) -> JSONResponse: + raw_limit = request.query_params.get("limit", "200") + try: + limit = int(raw_limit) + except ValueError: + limit = 200 + diag = _admin_diag(request) + return JSONResponse({"events": diag.recent(limit=limit), "max_events": diag.max_events}) + + +def _sse_event(name: str, data: Any) -> bytes: + body = json.dumps(data, ensure_ascii=False, separators=(",", ":")) + return f"event: {name}\ndata: {body}\n\n".encode("utf-8") + + +async def admin_logs_stream(request: Request) -> StreamingResponse: + diag = _admin_diag(request) + queue = diag.subscribe() + once = request.query_params.get("once") == "1" + + async def events(): + try: + yield _sse_event("ready", {"ok": True}) + for item in diag.recent(limit=50): + yield _sse_event("log", item) + if once: + return + while True: + if await request.is_disconnected(): + break + try: + item = await asyncio.wait_for(queue.get(), timeout=15.0) + except TimeoutError: + yield b": keepalive\n\n" + continue + yield _sse_event("log", item) + finally: + diag.unsubscribe(queue) + + return StreamingResponse( + events(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +async def admin_dashboard(_request: Request) -> HTMLResponse: + return HTMLResponse(_DASHBOARD_HTML.read_text(encoding="utf-8")) + + +async def admin_redirect(_request: Request) -> RedirectResponse: + return RedirectResponse(url="./admin/", status_code=307) diff --git a/middleware/app.py b/middleware/app.py index 45f890f..f13ac0e 100644 --- a/middleware/app.py +++ b/middleware/app.py @@ -19,6 +19,14 @@ from starlette.responses import JSONResponse, Response, StreamingResponse from starlette.routing import Route +from .admin import ( + admin_dashboard, + admin_healthz, + admin_logs, + admin_logs_stream, + admin_redirect, + admin_status, +) from .codex import ( build_round_payload, declares_continue_tool, @@ -27,6 +35,7 @@ ) from .config import Config from .creds import build_upstream_headers, would_inject_authorization +from .diagnostics import Diagnostics from .proxy import fold_stream, open_passthrough, open_round from .store import IdStore @@ -92,17 +101,36 @@ def _url_is_from_header(cfg: Config, request: Request) -> bool: async def _passthrough( - client: httpx.AsyncClient, cfg: Config, request: Request, raw: bytes, url: str + client: httpx.AsyncClient, + cfg: Config, + request: Request, + raw: bytes, + url: str, + diagnostics: Diagnostics | None = None, + request_id: str | None = None, ): """Pure proxy: forward the raw request and stream the raw response back.""" headers = build_upstream_headers(request.headers.items(), cfg) - resp = await open_passthrough(client, url, raw, headers) + try: + resp = await open_passthrough(client, url, raw, headers) + except Exception as exc: + if diagnostics and request_id: + diagnostics.request_failed(request_id, reason="passthrough_open_error", detail=repr(exc)) + raise async def body_iter(): + failed = False try: async for chunk in resp.aiter_bytes(): yield chunk + except Exception as exc: + failed = True + if diagnostics and request_id: + diagnostics.request_failed(request_id, reason="passthrough_stream_error", detail=repr(exc)) + raise finally: + if diagnostics and request_id and not failed: + diagnostics.request_finished(request_id, status=f"passthrough:{resp.status_code}") await resp.aclose() return StreamingResponse( @@ -115,6 +143,8 @@ async def body_iter(): async def handle_responses(request: Request) -> Response: cfg: Config = request.app.state.cfg client: httpx.AsyncClient = request.app.state.client + diagnostics: Diagnostics = request.app.state.diagnostics + request_id = diagnostics.request_started(path=request.url.path) wire_raw = await request.body() try: @@ -127,6 +157,7 @@ async def handle_responses(request: Request) -> Response: len(wire_raw), exc, ) + diagnostics.request_failed(request_id, reason="body_decode_error", detail=str(exc)) return JSONResponse({"error": str(exc)}, status_code=400) try: @@ -138,16 +169,21 @@ async def handle_responses(request: Request) -> Response: request.headers.get("content-encoding"), len(raw), ) + diagnostics.request_failed(request_id, reason="invalid_json_body") return JSONResponse({"error": "invalid JSON body"}, status_code=400) if not isinstance(body, dict): + diagnostics.request_failed(request_id, reason="non_object_body") return JSONResponse({"error": "body must be a JSON object"}, status_code=400) url = _resolve_upstream_url(cfg, request) if url is None: + diagnostics.request_update(request_id, model=body.get("model")) + diagnostics.request_failed(request_id, reason="missing_responses_api_base") return JSONResponse( {"error": "Responses-API-Base header is required (upstream mode=header_required)"}, status_code=400, ) + diagnostics.request_update(request_id, model=body.get("model"), upstream_url=url) # Safety: never send the proxy's configured credentials to a URL the request # itself supplied. If the base came from the header, the request must carry @@ -157,6 +193,7 @@ async def handle_responses(request: Request) -> Response: ): log.warning("blocked: Responses-API-Base override without own auth (model=%s)", body.get("model")) + diagnostics.request_failed(request_id, reason="blocked_header_override_without_own_auth") return JSONResponse( {"error": "When overriding the upstream base (Responses-API-Base), the request must " "provide its own Authorization; the proxy will not send its configured " @@ -186,10 +223,14 @@ async def handle_responses(request: Request) -> Response: else "declares-continue_thinking") log.info("passthrough (%s): model=%s path=%s url=%s", why, body.get("model"), request.url.path, url) - return await _passthrough(client, cfg, request, raw, url) + diagnostics.mark_passthrough(request_id, reason=why, model=body.get("model")) + return await _passthrough(client, cfg, request, raw, url, diagnostics, request_id) log.info("fold start: model=%s path=%s url=%s input_items=%d", body.get("model"), request.url.path, url, len(body.get("input") or [])) + diagnostics.mark_fold_start( + request_id, model=body.get("model"), path=request.url.path, upstream_url=url + ) # repair_followup="stateful": re-insert tool_pair continue pairs after recorded # ids (tool_pair only — commentary preserves cross-turn structure via forward_marker). @@ -218,12 +259,25 @@ async def handle_responses(request: Request) -> Response: if resp.status_code >= 400: err = await resp.aread() await resp.aclose() + diagnostics.request_failed( + request_id, reason="upstream_http_error", detail={"status_code": resp.status_code} + ) return Response( err, status_code=resp.status_code, media_type=resp.headers.get("content-type") ) return StreamingResponse( - fold_stream(client, cfg, body, headers, resp, request.app.state.id_store, url=url), + fold_stream( + client, + cfg, + body, + headers, + resp, + request.app.state.id_store, + url=url, + diagnostics=diagnostics, + request_id=request_id, + ), media_type="text/event-stream", ) @@ -243,6 +297,7 @@ def create_app(cfg: Config) -> Starlette: @contextlib.asynccontextmanager async def lifespan(app: Starlette): app.state.cfg = cfg + app.state.diagnostics = Diagnostics(max_events=cfg.admin.max_log_events) app.state.client = _make_client() app.state.id_store = IdStore() try: @@ -251,6 +306,13 @@ async def lifespan(app: Starlette): await app.state.client.aclose() routes = [ + Route("/admin", admin_redirect, methods=["GET"]), + Route("/admin/", admin_dashboard, methods=["GET"]), + Route("/admin/healthz", admin_healthz, methods=["GET"]), + Route("/admin/status", admin_status, methods=["GET"]), + Route("/admin/logs", admin_logs, methods=["GET"]), + Route("/admin/logs/stream", admin_logs_stream, methods=["GET"]), + ] + [ Route(path, handle_responses, methods=["POST"]) for path in cfg.server.listen_paths ] return Starlette(routes=routes, lifespan=lifespan) diff --git a/middleware/config.py b/middleware/config.py index 57bb15b..1f305e3 100644 --- a/middleware/config.py +++ b/middleware/config.py @@ -71,6 +71,11 @@ class LogCfg: dump_rounds_dir: str = "" +@dataclass(frozen=True) +class AdminCfg: + max_log_events: int = 800 + + @dataclass(frozen=True) class Config: server: ServerCfg = field(default_factory=ServerCfg) @@ -79,6 +84,7 @@ class Config: cont: ContinueCfg = field(default_factory=ContinueCfg) stream: StreamCfg = field(default_factory=StreamCfg) log: LogCfg = field(default_factory=LogCfg) + admin: AdminCfg = field(default_factory=AdminCfg) # Directory config.toml lived in (for resolving relative paths if needed). root: Path = field(default_factory=lambda: Path.cwd()) @@ -108,6 +114,7 @@ def load_config(path: str | Path) -> Config: cont = _section(data, "continue") stream = _section(data, "stream") log = _section(data, "log") + admin = _section(data, "admin") # listen_paths is a list in TOML; store as tuple. if "listen_paths" in server and isinstance(server["listen_paths"], list): @@ -125,6 +132,7 @@ def load_config(path: str | Path) -> Config: cont=ContinueCfg(**_only_known(ContinueCfg, cont)), stream=StreamCfg(**_only_known(StreamCfg, stream)), log=LogCfg(**_only_known(LogCfg, log)), + admin=AdminCfg(**_only_known(AdminCfg, admin)), root=path.resolve().parent if path.exists() else Path.cwd(), ) diff --git a/middleware/dashboard.html b/middleware/dashboard.html new file mode 100644 index 0000000..1cdfbd2 --- /dev/null +++ b/middleware/dashboard.html @@ -0,0 +1,353 @@ +<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>CodexCont Dashboard + + + +
+
+
+

CodexCont Dashboard

+
Waiting for live diagnostics...
+
+
+ Connecting + + + +
+
+ +
+
+

Service

+
+ CodexContunknown + Uptime- + Started- +
+
+
+

CPA Upstream

+
+ Healthunknown + Status- + Host- +
+
+
+

Config

+
+ Mode- + Continuation- + Retention- +
+
+
+ +
+
Total Requests
0
-
+
Active
0
in-flight streams
+
Continuations
0
-
+
516 / 518n-2 Hits
0
0 failures
+
+ +
+
+

Live Logs

+
+ + +
+
+
+ + + + + + + + + + + +
TimeLevelEventRequestMessage / Fields
No log events yet
+
+
+
+ + + + diff --git a/middleware/diagnostics.py b/middleware/diagnostics.py new file mode 100644 index 0000000..1a842c9 --- /dev/null +++ b/middleware/diagnostics.py @@ -0,0 +1,254 @@ +"""In-process diagnostics for the CodexCont admin dashboard.""" +from __future__ import annotations + +import asyncio +import re +import threading +import time +import uuid +from collections import deque +from datetime import UTC, datetime +from typing import Any + +_SECRET_KEYWORDS = ( + "authorization", + "api-key", + "api_key", + "apikey", + "access_token", + "refresh_token", + "id_token", + "tunnel_token", + "bearer_token", + "oauth", + "secret", + "encrypted_content", +) +_BEARER_RE = re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+") +_KEY_VALUE_RE = re.compile( + r"(?i)\b(api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|secret)" + r"\s*[:=]\s*['\"]?[^'\"\s,;]+" +) + + +def utc_now_iso() -> str: + return datetime.now(UTC).isoformat().replace("+00:00", "Z") + + +def redact_value(value: Any, *, key: str = "") -> Any: + """Return a JSON-safe value with obvious secrets removed.""" + key_l = key.lower() + if key_l == "token" or key_l.endswith("_token") or any(part in key_l for part in _SECRET_KEYWORDS): + return "[REDACTED]" + if isinstance(value, str): + text = _BEARER_RE.sub("Bearer [REDACTED]", value) + return _KEY_VALUE_RE.sub(lambda m: f"{m.group(1)}=[REDACTED]", text) + if isinstance(value, dict): + return {str(k): redact_value(v, key=str(k)) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [redact_value(v) for v in value] + if isinstance(value, (int, float, bool)) or value is None: + return value + return str(value) + + +class Diagnostics: + """Small memory-only metrics and event hub. + + This intentionally does not write to disk. It is safe for the small SJC VPS + and simple enough to keep CodexCont independent from CPA internals. + """ + + def __init__(self, *, max_events: int = 800) -> None: + self.max_events = max(1, int(max_events)) + self._events: deque[dict[str, Any]] = deque(maxlen=self.max_events) + self._subscribers: set[asyncio.Queue[dict[str, Any]]] = set() + self._lock = threading.RLock() + self._seq = 0 + self._started_wall = utc_now_iso() + self._started_perf = time.monotonic() + self._active_ids: set[str] = set() + self._counters: dict[str, int] = { + "total_requests": 0, + "active_requests": 0, + "folded_requests": 0, + "passthrough_requests": 0, + "continuations": 0, + "truncation_hits": 0, + "failures": 0, + } + self._last_request_at: str | None = None + self._last_continuation_at: str | None = None + self._last_error_at: str | None = None + self._last_error: dict[str, Any] | None = None + self._request_meta: dict[str, dict[str, Any]] = {} + + def recent(self, *, limit: int | None = None) -> list[dict[str, Any]]: + with self._lock: + events = list(self._events) + if limit is None: + return events + limit = max(0, min(int(limit), self.max_events)) + return events[-limit:] + + def subscribe(self) -> asyncio.Queue[dict[str, Any]]: + queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=200) + with self._lock: + self._subscribers.add(queue) + return queue + + def unsubscribe(self, queue: asyncio.Queue[dict[str, Any]]) -> None: + with self._lock: + self._subscribers.discard(queue) + + def record(self, level: str, event: str, message: str = "", **fields: Any) -> dict[str, Any]: + item = { + "seq": 0, + "ts": utc_now_iso(), + "level": (level or "info").lower(), + "event": event, + "message": redact_value(message), + "fields": redact_value(fields), + } + with self._lock: + self._seq += 1 + item["seq"] = self._seq + self._events.append(item) + subscribers = list(self._subscribers) + + for queue in subscribers: + try: + queue.put_nowait(item) + except asyncio.QueueFull: + try: + queue.get_nowait() + except asyncio.QueueEmpty: + pass + try: + queue.put_nowait(item) + except asyncio.QueueFull: + pass + return item + + def request_started(self, *, path: str, model: str | None = None) -> str: + request_id = uuid.uuid4().hex[:12] + now = utc_now_iso() + with self._lock: + self._active_ids.add(request_id) + self._counters["total_requests"] += 1 + self._counters["active_requests"] = len(self._active_ids) + self._last_request_at = now + self._request_meta[request_id] = { + "request_id": request_id, + "path": path, + "model": model, + "started_at": now, + } + self.record("info", "request_started", "Responses request received", + request_id=request_id, path=path, model=model) + return request_id + + def request_update(self, request_id: str, **fields: Any) -> None: + with self._lock: + meta = self._request_meta.get(request_id) + if meta is not None: + meta.update({k: v for k, v in fields.items() if v is not None}) + + def mark_fold_start(self, request_id: str, *, model: Any, path: str, upstream_url: str) -> None: + with self._lock: + self._counters["folded_requests"] += 1 + self.record("info", "fold_start", "Folded Responses stream started", + request_id=request_id, model=model, path=path, upstream_url=upstream_url) + + def mark_passthrough(self, request_id: str, *, reason: str, model: Any) -> None: + with self._lock: + self._counters["passthrough_requests"] += 1 + self.record("info", "passthrough", "Request passed through without folding", + request_id=request_id, reason=reason, model=model) + + def round_decision( + self, + request_id: str, + *, + round_no: int, + reasoning_tokens: int | None, + n: int | None, + decision: str, + buffered: list[str], + truncation_match: bool, + ) -> None: + if truncation_match: + with self._lock: + self._counters["truncation_hits"] += 1 + self.record( + "info", + "round_decision", + "Round finished and continuation decision was made", + request_id=request_id, + round=round_no, + reasoning_tokens=reasoning_tokens, + n=n, + decision=decision, + buffered=buffered, + truncation_match=truncation_match, + ) + + def continuation_opened(self, request_id: str, *, from_round: int, next_round: int, method: str) -> None: + now = utc_now_iso() + with self._lock: + self._counters["continuations"] += 1 + self._last_continuation_at = now + self.record("info", "continuation_opened", "Opened hidden continuation round", + request_id=request_id, from_round=from_round, next_round=next_round, method=method) + + def request_finished(self, request_id: str, *, status: str, stopped_reason: str | None = None) -> None: + with self._lock: + self._active_ids.discard(request_id) + self._counters["active_requests"] = len(self._active_ids) + self._request_meta.pop(request_id, None) + self.record("info", "request_finished", "Responses request finished", + request_id=request_id, status=status, stopped_reason=stopped_reason) + + def request_failed(self, request_id: str, *, reason: str, detail: Any = None) -> None: + now = utc_now_iso() + error = {"request_id": request_id, "reason": reason, "detail": redact_value(detail)} + with self._lock: + self._active_ids.discard(request_id) + self._counters["active_requests"] = len(self._active_ids) + self._counters["failures"] += 1 + self._last_error_at = now + self._last_error = error + self._request_meta.pop(request_id, None) + self.record("warning", "request_failed", "Responses request failed", **error) + + def health(self) -> dict[str, Any]: + return { + "ok": True, + "started_at": self._started_wall, + "uptime_seconds": round(time.monotonic() - self._started_perf, 3), + } + + def snapshot( + self, + *, + upstream: dict[str, Any] | None = None, + config: dict[str, Any] | None = None, + ) -> dict[str, Any]: + with self._lock: + counters = dict(self._counters) + active = list(self._request_meta.values()) + last_error = dict(self._last_error) if self._last_error else None + last_request_at = self._last_request_at + last_continuation_at = self._last_continuation_at + last_error_at = self._last_error_at + return { + **self.health(), + "counters": counters, + "active_requests": active, + "last_request_at": last_request_at, + "last_continuation_at": last_continuation_at, + "last_error_at": last_error_at, + "last_error": last_error, + "upstream": upstream or {"ok": None, "status": "not_checked"}, + "config": config or {}, + } diff --git a/middleware/proxy.py b/middleware/proxy.py index da4b406..dcd3e83 100644 --- a/middleware/proxy.py +++ b/middleware/proxy.py @@ -25,6 +25,7 @@ tier_n, ) from .config import Config +from .diagnostics import Diagnostics from .sse import DONE, incremental_sse, serialize_done, serialize_event log = logging.getLogger("middleware.proxy") @@ -294,6 +295,8 @@ async def fold_stream( first_response: httpx.Response, id_store: Any | None = None, url: str | None = None, + diagnostics: Diagnostics | None = None, + request_id: str | None = None, ) -> AsyncIterator[bytes]: """Yield the folded downstream SSE byte stream. `first_response` is the already-opened (2xx) round-1 upstream response; later rounds are opened here @@ -317,6 +320,7 @@ async def fold_stream( response = first_response round_no = 0 + closed = False try: while True: @@ -434,12 +438,29 @@ async def fold_stream( else "upstream_eof" if not saw_terminal else stopped_reason or "clean" ) + if diagnostics and request_id: + diagnostics.round_decision( + request_id, + round_no=round_no, + reasoning_tokens=rt, + n=n, + decision=decision, + buffered=buffered, + truncation_match=is_truncation_pattern(rt, cont.truncation_step), + ) log.info("round %d: %s | n=%s buffered=%s -> %s", round_no, _fmt_usage(usage), n, buffered or "[]", decision) await response.aclose() if do_continue: + if diagnostics and request_id: + diagnostics.continuation_opened( + request_id, + from_round=round_no, + next_round=round_no + 1, + method=cont.method, + ) last_id = round_reasoning[-1].get("id") or "" if cont.method == "commentary": marker_items = [commentary_message(cont.marker_text)] @@ -485,6 +506,13 @@ async def fold_stream( response.status_code, body) log.info("done: %d round(s) | %s | status=incomplete stop=upstream_error", round_no, _fmt_usage(total_usage)) + if diagnostics and request_id: + closed = True + diagnostics.request_failed( + request_id, + reason="continuation_upstream_error", + detail={"round": round_no + 1, "status_code": response.status_code}, + ) yield serialize_event( _synthetic_incomplete( base_response, final_output, @@ -503,6 +531,11 @@ async def fold_stream( log.warning("round %d: upstream EOF with no terminal event", round_no) log.info("done: %d round(s) | %s | status=incomplete stop=upstream_eof", round_no, _fmt_usage(total_usage)) + if diagnostics and request_id: + closed = True + diagnostics.request_finished( + request_id, status="incomplete", stopped_reason="upstream_eof" + ) yield serialize_event( _synthetic_incomplete( base_response, final_output, @@ -521,6 +554,11 @@ async def fold_stream( status = ((terminal or {}).get("response") or {}).get("status", "completed") log.info("done: %d round(s) | %s | status=%s stop=%s", round_no, _fmt_usage(total_usage), status, stopped_reason or "natural") + if diagnostics and request_id: + closed = True + diagnostics.request_finished( + request_id, status=status, stopped_reason=stopped_reason or "natural" + ) yield serialize_event( _reconstruct_terminal( terminal, base_response, final_output, @@ -535,6 +573,9 @@ async def fold_stream( log.warning("upstream error mid-stream (round %d): %r", round_no, exc) log.info("done: %d round(s) | %s | status=incomplete stop=upstream_error", round_no, _fmt_usage(total_usage)) + if diagnostics and request_id: + closed = True + diagnostics.request_failed(request_id, reason="upstream_stream_error", detail=repr(exc)) yield serialize_event( _synthetic_incomplete( base_response, final_output, @@ -543,6 +584,8 @@ async def fold_stream( ) return finally: + if diagnostics and request_id and not closed: + diagnostics.request_finished(request_id, status="closed") try: await response.aclose() except Exception: diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 5e18892..b2346d0 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -18,8 +18,10 @@ import zstandard as zstd from starlette.datastructures import Headers +from starlette.testclient import TestClient from middleware.app import ( + create_app, _decode_request_body, _make_client, _resolve_upstream_url, @@ -35,6 +37,7 @@ ) from middleware.config import load_config from middleware.creds import build_upstream_headers, would_inject_authorization +from middleware.diagnostics import Diagnostics, redact_value from middleware.proxy import fold_stream from middleware.sse import DONE, incremental_sse from middleware.store import IdStore @@ -425,6 +428,74 @@ def test_zstd_request_body_decode(): check("identity body unchanged", _decode_request_body(raw, None) == raw) +# --- dashboard diagnostics -------------------------------------------------- + + +def test_diagnostics_ring_and_redaction(): + diag = Diagnostics(max_events=2) + diag.record("info", "first", "Authorization: Bearer abc123", + authorization="Bearer abc123", nested={"api_key": "secret"}) + diag.record("info", "second", "ok") + diag.record("warning", "third", "access_token=abc") + recent = diag.recent() + check("diagnostics ring keeps max events", [e["event"] for e in recent] == ["second", "third"], + str([e["event"] for e in recent])) + bearer_redacted = redact_value("Authorization: Bearer abc123") + check("redact bearer text", + "abc123" not in bearer_redacted and "[REDACTED]" in bearer_redacted, + bearer_redacted) + redacted = redact_value({"api_key": "secret", "safe": "value"}) + check("redact sensitive dict key", redacted.get("api_key") == "[REDACTED]") + check("keep safe dict key", redacted.get("safe") == "value") + token_counts = redact_value({"reasoning_tokens": 516, "total_tokens": 1024}) + check("keep token counters", token_counts.get("reasoning_tokens") == 516) + + +async def test_diagnostics_subscriber_broadcast(): + diag = Diagnostics(max_events=5) + queue = diag.subscribe() + diag.record("info", "broadcast", "hello", request_id="req1") + item = await asyncio.wait_for(queue.get(), timeout=1.0) + diag.unsubscribe(queue) + check("diagnostics subscriber receives event", item.get("event") == "broadcast", str(item)) + check("diagnostics subscriber receives fields", + (item.get("fields") or {}).get("request_id") == "req1", str(item)) + + +def test_admin_routes_smoke(): + base = load_config(ROOT / "config.toml") + cfg = replace( + base, + upstream=replace(base.upstream, url="http://127.0.0.1:9/v1/responses"), + ) + with TestClient(create_app(cfg)) as client: + health = client.get("/admin/healthz") + check("admin healthz 200", health.status_code == 200, str(health.status_code)) + check("admin healthz ok", health.json().get("ok") is True, str(health.text)) + + client.app.state.diagnostics.record("info", "manual_event", "hello") + logs = client.get("/admin/logs?limit=1") + body = logs.json() + check("admin logs 200", logs.status_code == 200, str(logs.status_code)) + check("admin logs returns recent event", + (body.get("events") or [{}])[-1].get("event") == "manual_event", str(body)) + + status = client.get("/admin/status") + status_body = status.json() + check("admin status 200", status.status_code == 200, str(status.status_code)) + check("admin status has counters", "counters" in status_body, str(status_body)) + check("admin status redacted config host", + (status_body.get("config") or {}).get("upstream_host") == "127.0.0.1:9", + str(status_body.get("config"))) + + html = client.get("/admin/") + check("admin dashboard html 200", html.status_code == 200, str(html.status_code)) + check("admin dashboard contains EventSource", "new EventSource" in html.text) + + stream = client.get("/admin/logs/stream?once=1") + check("admin logs stream ready", "event: ready" in stream.text, stream.text[:80]) + + # --- upstream URL resolution via Responses-API-Base header ------------------ @@ -644,6 +715,9 @@ async def _main(): await test_forward_marker_emits_downstream() test_header_transparency() test_zstd_request_body_decode() + test_diagnostics_ring_and_redaction() + await test_diagnostics_subscriber_broadcast() + test_admin_routes_smoke() test_upstream_url_resolution() test_auth_safety_guard() test_auth_injection() From 5b578e1145f9111902ef1c86f5a3933fbfa51068 Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Wed, 1 Jul 2026 17:14:52 +0800 Subject: [PATCH 09/68] feat: add Chinese protection dashboard --- .../design.md | 21 +- .../implement.md | 70 ++ .../07-01-codexcont-status-dashboard/prd.md | 24 + .../task.json | 2 +- README.md | 2 +- README_zh.md | 9 +- middleware/admin.py | 40 +- middleware/app.py | 2 + middleware/dashboard.html | 600 +++++++++++++----- middleware/diagnostics.py | 228 ++++++- tests/test_middleware.py | 83 ++- 11 files changed, 907 insertions(+), 174 deletions(-) diff --git a/.trellis/tasks/07-01-codexcont-status-dashboard/design.md b/.trellis/tasks/07-01-codexcont-status-dashboard/design.md index 1dbe142..0d79f99 100644 --- a/.trellis/tasks/07-01-codexcont-status-dashboard/design.md +++ b/.trellis/tasks/07-01-codexcont-status-dashboard/design.md @@ -16,8 +16,11 @@ Admin view path: - `GET /admin/healthz` returns `{ "ok": true }` plus process uptime. - `GET /admin/status` returns process metrics, recent counters, upstream health, and a safe config summary. +- `GET /admin/requests?limit=N` returns recent request-level protection summaries. Each summary is a redacted, + memory-only projection of internal diagnostics events keyed by the generated request id. - `GET /admin/logs?limit=N` returns the newest redacted in-memory log events. -- `GET /admin/logs/stream` uses `text/event-stream`; new events are emitted as JSON `data:` frames. +- `GET /admin/logs/stream` uses `text/event-stream`; new log events are emitted as `event: log`, and request + summary updates are emitted as `event: request`. - `GET /admin/` serves the static dashboard. Static assets can be embedded or served under `/admin/static/...`; no Node build is required. ## Metrics And Logs @@ -36,14 +39,22 @@ Admin view path: - request finished cleanly or failed - upstream health probe result - Use generated request IDs for dashboard correlation only; do not expose upstream tokens or reasoning payloads. +- Add a request summary projection inside `Diagnostics` so frontend code does not re-derive protection meaning from raw log fields. +- Request protection result values: + - `protected_clean`: folded request completed without a truncation fingerprint. + - `auto_continued`: a truncation fingerprint was detected and a hidden continuation round opened. + - `risk_uncontinued`: a truncation fingerprint was detected but continuation was blocked by a guard. + - `passthrough`: request did not enter folding protection. + - `failed`, `incomplete`, `processing`: failure, incomplete upstream ending, or still active. ## Frontend - Single static operational dashboard, not a landing page. -- Compact top band: CodexCont health, CPA health, active requests, SSE connection status. -- Metrics grid: total requests, continuations, truncation hits, failures, last continuation. -- Live log table: timestamp, level, event, request id, model, round, reason, message. -- Controls: level/event filter, pause, autoscroll, clear local view, reconnect state. +- Chinese-first operational copy. +- Compact top band: CodexCont health, CPA health, continuation config, active requests, SSE connection status. +- Metrics grid: total requests, folded/protected requests, continuations, truncation hits, failures. +- Primary table: recent requests with protection result chips, model, rounds, reasoning token count, continuation count, final result, and expandable round details. +- Advanced log table remains available below the primary request view, with filters, pause/autoscroll, clear local view, and Chinese event labels. - Styling is plain CSS with restrained colors, max 8px card radius, stable dimensions, responsive grid, and no decorative gradient/orb background. ## Deployment Contract diff --git a/.trellis/tasks/07-01-codexcont-status-dashboard/implement.md b/.trellis/tasks/07-01-codexcont-status-dashboard/implement.md index 5a313f4..24d1a4a 100644 --- a/.trellis/tasks/07-01-codexcont-status-dashboard/implement.md +++ b/.trellis/tasks/07-01-codexcont-status-dashboard/implement.md @@ -39,6 +39,76 @@ - Specifically record whether this active Codex conversation or another real `/v1/responses` request appears in live logs. - Commit code and Trellis artifacts with narrow staging. +## 7. V2 Chinese Protection Dashboard + +- Add request-level summary projection in `middleware.diagnostics.Diagnostics`. +- Add `GET /admin/requests` and `event: request` SSE updates. +- Replace the English log-first dashboard with a Chinese request-first dashboard. +- Keep raw logs as an advanced section and preserve the existing `event: log` SSE stream. +- Validate all protection states locally: protected clean, auto-continued, risk-uncontinued, passthrough, failed, incomplete/processing where possible. + +### V2 Local Implementation + +- Added memory-only recent request summaries with protection results: + - `protected_clean` + - `auto_continued` + - `risk_uncontinued` + - `passthrough` + - `failed` + - `incomplete` + - `processing` +- Added `GET /admin/requests?limit=N`. +- Extended `/admin/logs/stream` so it still emits `event: log` and also emits `event: request`. +- Rebuilt `middleware/dashboard.html` as a Chinese request-first operational page with raw logs moved to an advanced section. +- Updated README and README_zh dashboard documentation. + +### V2 Local Validation + +- `.venv\Scripts\python.exe -m py_compile middleware\app.py middleware\admin.py middleware\diagnostics.py middleware\proxy.py middleware\config.py tests\test_middleware.py`: passed. +- `.venv\Scripts\python.exe tests\test_middleware.py`: `136/136 checks passed`. +- `git diff --check`: passed; only existing Windows LF/CRLF warnings were reported. +- Playwright CLI + Edge validated `http://127.0.0.1:8797/admin/`: + - page title `CodexCont 保护状态面板` + - desktop 1440px horizontal overflow `false` + - mobile 390px horizontal overflow `false` + - simulated request states rendered: protected clean, auto-continued, risk-uncontinued, passthrough, failed + - console errors/warnings: `0` +- Controlled local invalid JSON request returned HTTP `400` and appeared in `/admin/requests` with `protection=failed`, `failure_reason=invalid_json_body`. +- Request summary output strips internal timing field `_started_perf` before reaching admin APIs or SSE. + +### V2 Server Rollout + +- Backup path: `/root/codexcont-dashboard-v2-backups/20260701T085718Z`. +- Disk before rebuild: `/dev/sda1` about `8.7G used / 820M available / 92%`. +- Uploaded updated production files: + - `/opt/codex-stacks/codexcont/app/middleware/admin.py` + - `/opt/codex-stacks/codexcont/app/middleware/app.py` + - `/opt/codex-stacks/codexcont/app/middleware/dashboard.html` + - `/opt/codex-stacks/codexcont/app/middleware/diagnostics.py` +- Rebuilt/restarted only `codexcont` with `docker compose up -d --build --no-deps codexcont`. +- No Docker prune, image prune, recursive delete, or bulk directory deletion was used. + +### V2 Server Validation + +- `codexcont`: running after rebuild. +- Disk after rollout: `/dev/sda1` about `8.7G used / 833M available / 92%`. +- Local admin proxy: + - `http://127.0.0.1:8327/codexcont/status`: HTTP `200`, `ok=True`, CPA upstream health `200`, `log_retention=800`. + - `http://127.0.0.1:8327/codexcont/requests?limit=5`: HTTP `200`. + - `http://127.0.0.1:8327/codexcont/`: contains `CodexCont 保护状态面板`, `最近请求`, and `高级日志`. + - `http://127.0.0.1:8327/codexcont/logs/stream?once=1`: emitted `ready`, `request`, and `log` events. +- Public API domain: + - `https://cpa.konbakuyomu.us/healthz`: HTTP `200`. + - `https://cpa.konbakuyomu.us/admin/requests`: HTTP `404`. + - `https://cpa.konbakuyomu.us/codexcont/requests`: HTTP `404`. + - `https://cpa.konbakuyomu.us/management.html`: HTTP `404`. +- Cloudflare Access: + - `https://cpa-admin.konbakuyomu.us/codexcont/`: unauthenticated request returned HTTP `302` to Cloudflare Access. +- Live proof: + - Real current `gpt-5.5` Codex traffic appeared in recent requests with `protection=protected_clean`. + - A controlled public invalid JSON request returned HTTP `400` and appeared in recent requests with `protection=failed`, `failure_reason=invalid_json_body`. + - Final `/codexcont/requests?limit=5` check reported `has_internal_perf=False`. + ## Rollback - Restore backed-up CodexCont stack files and restart `codexcont`. diff --git a/.trellis/tasks/07-01-codexcont-status-dashboard/prd.md b/.trellis/tasks/07-01-codexcont-status-dashboard/prd.md index fb2fb64..7feb858 100644 --- a/.trellis/tasks/07-01-codexcont-status-dashboard/prd.md +++ b/.trellis/tasks/07-01-codexcont-status-dashboard/prd.md @@ -12,6 +12,23 @@ Add a lightweight, server-side CodexCont dashboard that shows current service he - Admin access already works through `cpa-admin.konbakuyomu.us` via Cloudflare Tunnel + Cloudflare Access + CPA management key. - SJC disk is small, so first version must avoid persistent logs, databases, Docker prune, and broad cleanup. - This Codex conversation is expected to use the same production path, so the dashboard should be able to show live logs from real ongoing chat traffic. +- Current dashboard v1 is English-first and log-first. It exposes raw events such as `fold_start`, + `round_decision`, `continuation_opened`, and `request_finished`, but a beginner cannot quickly tell + whether a request was protected, clean, automatically continued, risky, or failed. + +## UX Refinement Request + +- Convert the dashboard to Chinese-first copy. +- Make the first screen explain operational state in beginner-readable terms, without requiring the user + to understand internal event names or raw fields. +- Promote request-level protection status above raw logs: + - protected and clean: the request passed through CodexCont folding/protection and did not hit the + 516/518n-2 truncation fingerprint. + - auto-continued: CodexCont detected the 516/518n-2 fingerprint and opened a hidden continuation round. + - risky/unhandled: the truncation fingerprint was seen but continuation could not be opened because a + guard stopped it. + - failed: request or upstream handling failed. +- Keep raw logs available as an advanced detail view for debugging. ## Requirements @@ -28,6 +45,10 @@ Add a lightweight, server-side CodexCont dashboard that shows current service he - R6: Expose the page only through `https://cpa-admin.konbakuyomu.us/codexcont/`; keep public `https://cpa.konbakuyomu.us` from exposing `/admin` or `/codexcont`. - R7: Keep logs memory-only by default, with a bounded retention size around 500-1000 entries. - R8: Preserve existing `/v1/responses` behavior and current continuation semantics. +- R9: Add Chinese dashboard labels and beginner-readable status explanations. +- R10: Add a request-centric view that groups events by request id and surfaces protection state, model, + rounds, reasoning token counts, continuation count, and final status. +- R11: Clearly distinguish "经过 CodexCont 保护但无需续写" from "检测到 516/518n-2 并已自动续写". ## Acceptance Criteria @@ -40,6 +61,9 @@ Add a lightweight, server-side CodexCont dashboard that shows current service he - [x] This active Codex conversation or a real `/v1/responses` request appears in the dashboard live logs. - [x] `https://cpa.konbakuyomu.us/admin/` and `https://cpa.konbakuyomu.us/codexcont/` are not publicly exposed. - [x] No secrets are printed or committed, no persistent log store is added, and no Docker prune or bulk deletion is used. +- [x] Dashboard first screen is Chinese-first and readable for non-technical users. +- [x] Recent requests show beginner-readable protection status without opening raw logs. +- [x] Requests that triggered automatic continuation are visually distinct from clean protected requests. ## Out Of Scope diff --git a/.trellis/tasks/07-01-codexcont-status-dashboard/task.json b/.trellis/tasks/07-01-codexcont-status-dashboard/task.json index 0f01c85..f2833df 100644 --- a/.trellis/tasks/07-01-codexcont-status-dashboard/task.json +++ b/.trellis/tasks/07-01-codexcont-status-dashboard/task.json @@ -21,6 +21,6 @@ "children": [], "parent": null, "relatedFiles": [], - "notes": "CodexCont dashboard is implemented and deployed. Local tests passed 123/123; Edge smoke verified desktop/mobile layout and live SSE logs. SJC cpa-admin path /codexcont/ routes through Cloudflare Access to CodexCont; public cpa.konbakuyomu.us admin/codexcont paths return 404. Live logs showed current gpt-5.5 Codex conversation traffic and a controlled invalid_json_body event.", + "notes": "CodexCont dashboard v2 is implemented and deployed. Local tests passed 136/136; Playwright/Edge verified Chinese request-first UI, desktop/mobile no-overflow layout, all protection chips, and SSE request/log behavior. SJC cpa-admin path /codexcont/ routes through Cloudflare Access to CodexCont; public cpa.konbakuyomu.us admin/codexcont/management paths return 404. Live requests showed current gpt-5.5 traffic as protected_clean and a controlled invalid_json_body event as failed.", "meta": {} } diff --git a/README.md b/README.md index d30c966..c981553 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ CodexCont serves a lightweight read-only dashboard at: http://127.0.0.1:8787/admin/ ``` -It exposes service status, upstream health, in-memory request metrics, and live redacted logs through SSE. Log history is memory-only and bounded by: +It exposes service status, upstream health, in-memory request metrics, recent request protection summaries, and live redacted logs through SSE. The request view distinguishes protected-clean, auto-continued, risky-uncontinued, passthrough, failed, and incomplete requests. Log history is memory-only and bounded by: ```toml [admin] diff --git a/README_zh.md b/README_zh.md index 20aa1d3..cdfa752 100644 --- a/README_zh.md +++ b/README_zh.md @@ -113,7 +113,14 @@ CodexCont 内置一个只读状态面板: http://127.0.0.1:8787/admin/ ``` -它可以查看服务状态、上游健康、内存中的请求指标,以及通过 SSE 实时推送的脱敏日志。日志只保存在进程内存中,默认上限为: +它可以查看服务状态、上游健康、内存中的请求指标,以及通过 SSE 实时推送的脱敏日志。当前面板优先展示“最近请求”的中文保护结果,能直接区分: + +- `已保护 / 无需续写`:请求进入 CodexCont 保护链,未命中 516/518n-2 指纹。 +- `已自动续写`:检测到 516/518n-2,并已打开隐藏续写轮。 +- `疑似截断 / 未续写`:检测到截断指纹,但保护条件或上限阻止了续写。 +- `透传` / `失败` / `不完整`:分别表示未进入折叠保护、请求失败、上游未完整结束。 + +日志和最近请求都只保存在进程内存中,默认日志上限为: ```toml [admin] diff --git a/middleware/admin.py b/middleware/admin.py index e9b9d91..1c90280 100644 --- a/middleware/admin.py +++ b/middleware/admin.py @@ -87,6 +87,18 @@ async def admin_logs(request: Request) -> JSONResponse: return JSONResponse({"events": diag.recent(limit=limit), "max_events": diag.max_events}) +async def admin_requests(request: Request) -> JSONResponse: + raw_limit = request.query_params.get("limit", "100") + try: + limit = int(raw_limit) + except ValueError: + limit = 100 + diag = _admin_diag(request) + return JSONResponse( + {"requests": diag.recent_requests(limit=limit), "max_requests": diag.max_requests} + ) + + def _sse_event(name: str, data: Any) -> bytes: body = json.dumps(data, ensure_ascii=False, separators=(",", ":")) return f"event: {name}\ndata: {body}\n\n".encode("utf-8") @@ -94,12 +106,15 @@ def _sse_event(name: str, data: Any) -> bytes: async def admin_logs_stream(request: Request) -> StreamingResponse: diag = _admin_diag(request) - queue = diag.subscribe() + log_queue = diag.subscribe() + request_queue = diag.subscribe_requests() once = request.query_params.get("once") == "1" async def events(): try: yield _sse_event("ready", {"ok": True}) + for item in diag.recent_requests(limit=50): + yield _sse_event("request", item) for item in diag.recent(limit=50): yield _sse_event("log", item) if once: @@ -107,14 +122,27 @@ async def events(): while True: if await request.is_disconnected(): break - try: - item = await asyncio.wait_for(queue.get(), timeout=15.0) - except TimeoutError: + log_task = asyncio.create_task(log_queue.get()) + req_task = asyncio.create_task(request_queue.get()) + done, pending = await asyncio.wait( + {log_task, req_task}, + timeout=15.0, + return_when=asyncio.FIRST_COMPLETED, + ) + for task in pending: + task.cancel() + if pending: + await asyncio.gather(*pending, return_exceptions=True) + if not done: yield b": keepalive\n\n" continue - yield _sse_event("log", item) + for task in done: + item = task.result() + event_name = "log" if task is log_task else "request" + yield _sse_event(event_name, item) finally: - diag.unsubscribe(queue) + diag.unsubscribe(log_queue) + diag.unsubscribe_requests(request_queue) return StreamingResponse( events(), diff --git a/middleware/app.py b/middleware/app.py index f13ac0e..96d0dde 100644 --- a/middleware/app.py +++ b/middleware/app.py @@ -24,6 +24,7 @@ admin_healthz, admin_logs, admin_logs_stream, + admin_requests, admin_redirect, admin_status, ) @@ -310,6 +311,7 @@ async def lifespan(app: Starlette): Route("/admin/", admin_dashboard, methods=["GET"]), Route("/admin/healthz", admin_healthz, methods=["GET"]), Route("/admin/status", admin_status, methods=["GET"]), + Route("/admin/requests", admin_requests, methods=["GET"]), Route("/admin/logs", admin_logs, methods=["GET"]), Route("/admin/logs/stream", admin_logs_stream, methods=["GET"]), ] + [ diff --git a/middleware/dashboard.html b/middleware/dashboard.html index 1cdfbd2..9639c6b 100644 --- a/middleware/dashboard.html +++ b/middleware/dashboard.html @@ -1,92 +1,114 @@ - + - CodexCont Dashboard + + CodexCont 保护状态面板 @@ -147,103 +211,211 @@
-

CodexCont Dashboard

-
Waiting for live diagnostics...
+

CodexCont 保护状态面板

+
正在读取运行状态...
- Connecting - - - + 连接中 +
-

Service

+

服务状态

+
+ CodexCont未知 + 运行时间- + 启动时间- +
+
+
+

CPA 上游

- CodexContunknown - Uptime- - Started- + 健康未知 + 状态码- + 地址-
-

CPA Upstream

+

保护配置

- Healthunknown - Status- - Host- + 模式- + 续写- + 日志保留-
-

Config

+

实时流

- Mode- - Continuation- - Retention- + 连接连接中 + 活跃请求0 + 请求记录0
-
-
Total Requests
0
-
-
Active
0
in-flight streams
-
Continuations
0
-
-
516 / 518n-2 Hits
0
0 failures
+
+
总请求
0
-
+
进入保护链
0
由 CodexCont 折叠处理
+
自动续写
0
-
+
疑似截断
0
516 / 518n-2 指纹
+
失败
0
-
-
-
-

Live Logs

+
+
+

最近请求

- + + + + + + + + - +
-
+
- - - - - + + + + + + + + - +
TimeLevelEventRequestMessage / Fields时间保护结果模型轮次思考量续写最终结果详情
No log events yet
暂无请求
+ +
+ + 高级日志 + 用于排查原始事件 + +
+
+
+ + +
+
+ + + +
+
+
+ + + + + + + + + + + +
时间级别事件请求消息 / 字段
暂无日志
+
+
+
diff --git a/middleware/diagnostics.py b/middleware/diagnostics.py index 1a842c9..bdc4434 100644 --- a/middleware/diagnostics.py +++ b/middleware/diagnostics.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from copy import deepcopy import re import threading import time @@ -52,6 +53,20 @@ def redact_value(value: Any, *, key: str = "") -> Any: return str(value) +_RISK_STOP_REASONS = { + "no_encrypted_content", + "max_continue", + "max_total_output_tokens", + "tier_out_of_window", +} + + +def _public_request_summary(summary: dict[str, Any]) -> dict[str, Any]: + public = deepcopy(summary) + public.pop("_started_perf", None) + return redact_value(public) + + class Diagnostics: """Small memory-only metrics and event hub. @@ -59,10 +74,12 @@ class Diagnostics: and simple enough to keep CodexCont independent from CPA internals. """ - def __init__(self, *, max_events: int = 800) -> None: + def __init__(self, *, max_events: int = 800, max_requests: int = 200) -> None: self.max_events = max(1, int(max_events)) + self.max_requests = max(1, int(max_requests)) self._events: deque[dict[str, Any]] = deque(maxlen=self.max_events) self._subscribers: set[asyncio.Queue[dict[str, Any]]] = set() + self._request_subscribers: set[asyncio.Queue[dict[str, Any]]] = set() self._lock = threading.RLock() self._seq = 0 self._started_wall = utc_now_iso() @@ -82,6 +99,7 @@ def __init__(self, *, max_events: int = 800) -> None: self._last_error_at: str | None = None self._last_error: dict[str, Any] | None = None self._request_meta: dict[str, dict[str, Any]] = {} + self._request_summaries: dict[str, dict[str, Any]] = {} def recent(self, *, limit: int | None = None) -> list[dict[str, Any]]: with self._lock: @@ -101,6 +119,24 @@ def unsubscribe(self, queue: asyncio.Queue[dict[str, Any]]) -> None: with self._lock: self._subscribers.discard(queue) + def subscribe_requests(self) -> asyncio.Queue[dict[str, Any]]: + queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=200) + with self._lock: + self._request_subscribers.add(queue) + return queue + + def unsubscribe_requests(self, queue: asyncio.Queue[dict[str, Any]]) -> None: + with self._lock: + self._request_subscribers.discard(queue) + + def recent_requests(self, *, limit: int | None = None) -> list[dict[str, Any]]: + with self._lock: + summaries = [_public_request_summary(item) for item in self._request_summaries.values()] + if limit is None: + return summaries + limit = max(0, min(int(limit), self.max_requests)) + return summaries[-limit:] + def record(self, level: str, event: str, message: str = "", **fields: Any) -> dict[str, Any]: item = { "seq": 0, @@ -130,9 +166,97 @@ def record(self, level: str, event: str, message: str = "", **fields: Any) -> di pass return item + def _trim_requests_locked(self) -> None: + while len(self._request_summaries) > self.max_requests: + removed = False + for request_id in list(self._request_summaries.keys()): + if request_id not in self._active_ids: + self._request_summaries.pop(request_id, None) + removed = True + break + if not removed: + break + + def _request_copy_locked(self, request_id: str) -> dict[str, Any] | None: + summary = self._request_summaries.get(request_id) + if summary is None: + return None + return _public_request_summary(summary) + + def _publish_request(self, summary: dict[str, Any] | None) -> None: + if summary is None: + return + with self._lock: + subscribers = list(self._request_subscribers) + for queue in subscribers: + try: + queue.put_nowait(summary) + except asyncio.QueueFull: + try: + queue.get_nowait() + except asyncio.QueueEmpty: + pass + try: + queue.put_nowait(summary) + except asyncio.QueueFull: + pass + + def _set_request_result_locked(self, summary: dict[str, Any]) -> None: + if summary.get("status") == "failed": + summary["protection"] = "failed" + return + if summary.get("status") == "incomplete": + summary["protection"] = "incomplete" + return + if summary.get("passthrough"): + summary["protection"] = "passthrough" + return + if summary.get("continuation_count", 0) > 0: + summary["protection"] = "auto_continued" + return + if summary.get("truncation_match") or summary.get("stopped_reason") in _RISK_STOP_REASONS: + summary["protection"] = "risk_uncontinued" + return + if summary.get("folded"): + summary["protection"] = "protected_clean" + return + summary["protection"] = "processing" + + def _finish_request_locked( + self, + request_id: str, + *, + status: str, + stopped_reason: str | None = None, + failure_reason: str | None = None, + failure_detail: Any = None, + ) -> dict[str, Any] | None: + now = utc_now_iso() + summary = self._request_summaries.get(request_id) + if summary is None: + return None + summary["updated_at"] = now + summary["ended_at"] = now + started_perf = summary.pop("_started_perf", None) + if isinstance(started_perf, (int, float)): + summary["duration_ms"] = round((time.monotonic() - started_perf) * 1000) + summary["final_status"] = status + summary["stopped_reason"] = stopped_reason + if failure_reason is not None: + summary["status"] = "failed" + summary["failure_reason"] = failure_reason + summary["failure_detail"] = redact_value(failure_detail) + elif status == "incomplete" or status == "closed": + summary["status"] = "incomplete" + else: + summary["status"] = "completed" + self._set_request_result_locked(summary) + return _public_request_summary(summary) + def request_started(self, *, path: str, model: str | None = None) -> str: request_id = uuid.uuid4().hex[:12] now = utc_now_iso() + perf = time.monotonic() with self._lock: self._active_ids.add(request_id) self._counters["total_requests"] += 1 @@ -144,25 +268,81 @@ def request_started(self, *, path: str, model: str | None = None) -> str: "model": model, "started_at": now, } + self._request_summaries[request_id] = { + "request_id": request_id, + "model": model, + "path": path, + "started_at": now, + "updated_at": now, + "ended_at": None, + "duration_ms": None, + "status": "processing", + "protection": "processing", + "folded": False, + "passthrough": False, + "passthrough_reason": None, + "rounds": [], + "latest_round": None, + "latest_reasoning_tokens": None, + "continuation_count": 0, + "truncation_match": False, + "final_status": None, + "stopped_reason": None, + "failure_reason": None, + "failure_detail": None, + "_started_perf": perf, + } + self._trim_requests_locked() + summary = self._request_copy_locked(request_id) + self._publish_request(summary) self.record("info", "request_started", "Responses request received", request_id=request_id, path=path, model=model) return request_id def request_update(self, request_id: str, **fields: Any) -> None: + summary = None with self._lock: meta = self._request_meta.get(request_id) if meta is not None: meta.update({k: v for k, v in fields.items() if v is not None}) + req = self._request_summaries.get(request_id) + if req is not None: + safe_updates = {k: v for k, v in fields.items() if k in {"model", "path"} and v is not None} + if safe_updates: + req.update(safe_updates) + req["updated_at"] = utc_now_iso() + summary = self._request_copy_locked(request_id) + self._publish_request(summary) def mark_fold_start(self, request_id: str, *, model: Any, path: str, upstream_url: str) -> None: + summary = None with self._lock: self._counters["folded_requests"] += 1 + req = self._request_summaries.get(request_id) + if req is not None: + req["folded"] = True + req["model"] = model + req["path"] = path + req["updated_at"] = utc_now_iso() + req["protection"] = "processing" + summary = self._request_copy_locked(request_id) + self._publish_request(summary) self.record("info", "fold_start", "Folded Responses stream started", request_id=request_id, model=model, path=path, upstream_url=upstream_url) def mark_passthrough(self, request_id: str, *, reason: str, model: Any) -> None: + summary = None with self._lock: self._counters["passthrough_requests"] += 1 + req = self._request_summaries.get(request_id) + if req is not None: + req["passthrough"] = True + req["passthrough_reason"] = reason + req["model"] = model + req["updated_at"] = utc_now_iso() + req["protection"] = "passthrough" + summary = self._request_copy_locked(request_id) + self._publish_request(summary) self.record("info", "passthrough", "Request passed through without folding", request_id=request_id, reason=reason, model=model) @@ -177,9 +357,31 @@ def round_decision( buffered: list[str], truncation_match: bool, ) -> None: + summary = None if truncation_match: with self._lock: self._counters["truncation_hits"] += 1 + with self._lock: + req = self._request_summaries.get(request_id) + if req is not None: + round_summary = { + "round": round_no, + "reasoning_tokens": reasoning_tokens, + "n": n, + "decision": decision, + "buffered": list(buffered), + "truncation_match": truncation_match, + } + req["rounds"].append(round_summary) + req["latest_round"] = round_no + req["latest_reasoning_tokens"] = reasoning_tokens + req["truncation_match"] = bool(req.get("truncation_match") or truncation_match) + req["updated_at"] = utc_now_iso() + if truncation_match and decision != "continue" and req.get("continuation_count", 0) == 0: + req["protection"] = "risk_uncontinued" + req["stopped_reason"] = decision + summary = self._request_copy_locked(request_id) + self._publish_request(summary) self.record( "info", "round_decision", @@ -195,23 +397,38 @@ def round_decision( def continuation_opened(self, request_id: str, *, from_round: int, next_round: int, method: str) -> None: now = utc_now_iso() + summary = None with self._lock: self._counters["continuations"] += 1 self._last_continuation_at = now + req = self._request_summaries.get(request_id) + if req is not None: + req["continuation_count"] = int(req.get("continuation_count") or 0) + 1 + req["updated_at"] = now + req["protection"] = "auto_continued" + summary = self._request_copy_locked(request_id) + self._publish_request(summary) self.record("info", "continuation_opened", "Opened hidden continuation round", request_id=request_id, from_round=from_round, next_round=next_round, method=method) def request_finished(self, request_id: str, *, status: str, stopped_reason: str | None = None) -> None: + summary = None with self._lock: self._active_ids.discard(request_id) self._counters["active_requests"] = len(self._active_ids) self._request_meta.pop(request_id, None) + summary = self._finish_request_locked( + request_id, status=status, stopped_reason=stopped_reason + ) + self._trim_requests_locked() + self._publish_request(summary) self.record("info", "request_finished", "Responses request finished", request_id=request_id, status=status, stopped_reason=stopped_reason) def request_failed(self, request_id: str, *, reason: str, detail: Any = None) -> None: now = utc_now_iso() error = {"request_id": request_id, "reason": reason, "detail": redact_value(detail)} + summary = None with self._lock: self._active_ids.discard(request_id) self._counters["active_requests"] = len(self._active_ids) @@ -219,6 +436,14 @@ def request_failed(self, request_id: str, *, reason: str, detail: Any = None) -> self._last_error_at = now self._last_error = error self._request_meta.pop(request_id, None) + summary = self._finish_request_locked( + request_id, + status="failed", + failure_reason=reason, + failure_detail=detail, + ) + self._trim_requests_locked() + self._publish_request(summary) self.record("warning", "request_failed", "Responses request failed", **error) def health(self) -> dict[str, Any]: @@ -245,6 +470,7 @@ def snapshot( **self.health(), "counters": counters, "active_requests": active, + "recent_requests": self.recent_requests(limit=10), "last_request_at": last_request_at, "last_continuation_at": last_continuation_at, "last_error_at": last_error_at, diff --git a/tests/test_middleware.py b/tests/test_middleware.py index b2346d0..817fac1 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -451,6 +451,71 @@ def test_diagnostics_ring_and_redaction(): check("keep token counters", token_counts.get("reasoning_tokens") == 516) +def test_diagnostics_request_summaries(): + clean = Diagnostics(max_events=10, max_requests=5) + rid = clean.request_started(path="/v1/responses", model="gpt-5.5") + clean.mark_fold_start(rid, model="gpt-5.5", path="/v1/responses", + upstream_url="http://cpa:8317/v1/responses") + clean.round_decision(rid, round_no=1, reasoning_tokens=140, n=None, + decision="clean", buffered=["message"], truncation_match=False) + clean.request_finished(rid, status="completed", stopped_reason="natural") + summary = clean.recent_requests(limit=1)[0] + check("request summary protected clean", summary.get("protection") == "protected_clean", + str(summary)) + check("request summary keeps reasoning tokens", + summary.get("latest_reasoning_tokens") == 140, str(summary)) + + cont = Diagnostics(max_events=10, max_requests=5) + rid = cont.request_started(path="/v1/responses", model="gpt-5.5") + cont.mark_fold_start(rid, model="gpt-5.5", path="/v1/responses", + upstream_url="http://cpa:8317/v1/responses") + cont.round_decision(rid, round_no=1, reasoning_tokens=516, n=1, + decision="continue", buffered=["message"], truncation_match=True) + cont.continuation_opened(rid, from_round=1, next_round=2, method="commentary") + cont.round_decision(rid, round_no=2, reasoning_tokens=181, n=None, + decision="clean", buffered=["message"], truncation_match=False) + cont.request_finished(rid, status="completed", stopped_reason="natural") + summary = cont.recent_requests(limit=1)[0] + check("request summary auto continued", summary.get("protection") == "auto_continued", + str(summary)) + check("request summary continuation count", summary.get("continuation_count") == 1, + str(summary)) + + risk = Diagnostics(max_events=10, max_requests=5) + rid = risk.request_started(path="/v1/responses", model="gpt-5.5") + risk.mark_fold_start(rid, model="gpt-5.5", path="/v1/responses", + upstream_url="http://cpa:8317/v1/responses") + risk.round_decision(rid, round_no=1, reasoning_tokens=516, n=1, + decision="no_encrypted_content", buffered=["message"], truncation_match=True) + risk.request_finished(rid, status="completed", stopped_reason="no_encrypted_content") + summary = risk.recent_requests(limit=1)[0] + check("request summary risk uncontinued", summary.get("protection") == "risk_uncontinued", + str(summary)) + + passthrough = Diagnostics(max_events=10, max_requests=5) + rid = passthrough.request_started(path="/v1/responses", model="gpt-5.5") + passthrough.mark_passthrough(rid, reason="non-stream", model="gpt-5.5") + passthrough.request_finished(rid, status="passthrough:200") + summary = passthrough.recent_requests(limit=1)[0] + check("request summary passthrough", summary.get("protection") == "passthrough", + str(summary)) + + failed = Diagnostics(max_events=10, max_requests=5) + rid = failed.request_started(path="/v1/responses", model="gpt-5.5") + failed.request_failed(rid, reason="invalid_json_body") + summary = failed.recent_requests(limit=1)[0] + check("request summary failed", summary.get("protection") == "failed", str(summary)) + + retained = Diagnostics(max_events=10, max_requests=2) + for idx in range(3): + rid = retained.request_started(path="/v1/responses", model=f"m{idx}") + retained.request_finished(rid, status="completed") + summaries = retained.recent_requests() + check("request summary retention bounded", len(summaries) == 2, str(summaries)) + check("request summary retention newest", [s["model"] for s in summaries] == ["m1", "m2"], + str(summaries)) + + async def test_diagnostics_subscriber_broadcast(): diag = Diagnostics(max_events=5) queue = diag.subscribe() @@ -474,11 +539,24 @@ def test_admin_routes_smoke(): check("admin healthz ok", health.json().get("ok") is True, str(health.text)) client.app.state.diagnostics.record("info", "manual_event", "hello") + rid = client.app.state.diagnostics.request_started(path="/v1/responses", model="gpt-5.5") + client.app.state.diagnostics.mark_fold_start( + rid, model="gpt-5.5", path="/v1/responses", + upstream_url="http://127.0.0.1:9/v1/responses" + ) + client.app.state.diagnostics.request_finished(rid, status="completed") logs = client.get("/admin/logs?limit=1") body = logs.json() check("admin logs 200", logs.status_code == 200, str(logs.status_code)) check("admin logs returns recent event", - (body.get("events") or [{}])[-1].get("event") == "manual_event", str(body)) + (body.get("events") or [{}])[-1].get("event") == "request_finished", str(body)) + + requests = client.get("/admin/requests?limit=1") + requests_body = requests.json() + check("admin requests 200", requests.status_code == 200, str(requests.status_code)) + check("admin requests returns summary", + (requests_body.get("requests") or [{}])[-1].get("protection") == "protected_clean", + str(requests_body)) status = client.get("/admin/status") status_body = status.json() @@ -491,9 +569,11 @@ def test_admin_routes_smoke(): html = client.get("/admin/") check("admin dashboard html 200", html.status_code == 200, str(html.status_code)) check("admin dashboard contains EventSource", "new EventSource" in html.text) + check("admin dashboard Chinese first screen", "最近请求" in html.text) stream = client.get("/admin/logs/stream?once=1") check("admin logs stream ready", "event: ready" in stream.text, stream.text[:80]) + check("admin logs stream request event", "event: request" in stream.text, stream.text[:200]) # --- upstream URL resolution via Responses-API-Base header ------------------ @@ -716,6 +796,7 @@ async def _main(): test_header_transparency() test_zstd_request_body_decode() test_diagnostics_ring_and_redaction() + test_diagnostics_request_summaries() await test_diagnostics_subscriber_broadcast() test_admin_routes_smoke() test_upstream_url_resolution() From 42c1b144f7ac3fabf2a1ff034207cabb6e92cfb2 Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Wed, 1 Jul 2026 17:21:45 +0800 Subject: [PATCH 10/68] docs: capture CodexCont dashboard contracts --- .../backend/codex-continuation-contracts.md | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/.trellis/spec/backend/codex-continuation-contracts.md b/.trellis/spec/backend/codex-continuation-contracts.md index ffc99fc..59416c8 100644 --- a/.trellis/spec/backend/codex-continuation-contracts.md +++ b/.trellis/spec/backend/codex-continuation-contracts.md @@ -65,3 +65,73 @@ client -> continuation supervisor -> Codex executor same-auth upstream rounds -> ``` The continuation owner must sit at executor level, where it can inspect raw upstream SSE, preserve auth/proxy identity, replay encrypted reasoning, and reconstruct the final logical response. + +## Scenario: CodexCont admin diagnostics dashboard and request summaries + +### 1. Scope / Trigger +- Trigger this spec whenever work touches CodexCont `/admin/*` routes, in-process diagnostics, SSE admin streams, dashboard UI, or production deployment of the CodexCont sidecar behind CPA. +- This is a cross-layer contract: `/v1/responses` lifecycle events feed `Diagnostics`, `Diagnostics` projects request summaries, Starlette exposes JSON/SSE admin APIs, and the static dashboard renders beginner-facing protection status. +- The admin dashboard is observability for the 516 continuation mitigation. It must never become a second control plane that mutates CPA, OAuth accounts, proxy routes, or request payloads. + +### 2. Signatures +- Admin routes: + - `GET /admin/healthz` -> service health and uptime. + - `GET /admin/status` -> counters, active request metadata, upstream health, and safe config summary. + - `GET /admin/requests?limit=N` -> recent request-level protection summaries. + - `GET /admin/logs?limit=N` -> recent redacted diagnostic log events. + - `GET /admin/logs/stream` -> SSE stream with `event: ready`, `event: request`, and `event: log`. + - `GET /admin/` -> static dashboard HTML. +- Request summary projection fields: + - `request_id`, `model`, `path`, `started_at`, `updated_at`, `ended_at`, `duration_ms` + - `status`, `protection`, `folded`, `passthrough`, `passthrough_reason` + - `rounds[]`, `latest_round`, `latest_reasoning_tokens`, `continuation_count` + - `truncation_match`, `final_status`, `stopped_reason`, `failure_reason`, `failure_detail` +- Protection values: + - `protected_clean`, `auto_continued`, `risk_uncontinued`, `passthrough`, `failed`, `incomplete`, `processing` + +### 3. Contracts +- `Diagnostics` owns the request-summary projection. The frontend may format labels, but it must not re-derive protection status from raw log event names or ad hoc field parsing. +- Admin data is memory-only. Do not add persistent log files, databases, Redis, or CPA Manager dependencies for dashboard v1/v2 behavior. +- Request summaries and logs must not include request bodies, Authorization headers, API keys, OAuth tokens, encrypted reasoning content, or internal implementation-only fields such as `_started_perf`. +- `event: log` behavior is backward-compatible with the original dashboard stream. Adding request updates must use a separate `event: request` SSE event. +- The beginner-facing dashboard must distinguish "entered CodexCont protection and no continuation was needed" from "516/518n-2 was detected and a hidden continuation round was opened". +- Production admin access must remain behind `cpa-admin.konbakuyomu.us` plus Cloudflare Access. Public `cpa.konbakuyomu.us` must not expose `/admin/*`, `/codexcont/*`, `/management.html`, or CPA management APIs. +- SJC is a small-disk host. Deployment must prefer uploading changed files plus single-service rebuild/restart; do not use Docker prune or broad filesystem cleanup as part of dashboard rollout. + +### 4. Validation & Error Matrix +- Invalid `limit` query on `/admin/requests` or `/admin/logs` -> fall back to safe defaults. +- Request summary retention exceeds configured cap -> discard oldest non-active summaries first. +- Active request summary is returned -> internal monotonic timer fields must be stripped before JSON/SSE output. +- `GET /admin/logs/stream?once=1` -> emits `ready`, recent `request` events, then recent `log` events, then ends. +- Upstream CPA health probe fails -> dashboard reports upstream unhealthy but admin routes still return safely. +- Public API host exposes any admin path -> deployment validation fails; fix Caddy/admin proxy routing before accepting rollout. +- SJC free space is tight before rebuild -> verify `df -h /` and avoid pulls/prune; if rebuild needs new image layers and space is insufficient, pause rather than cleaning broad data. + +### 5. Good/Base/Bad Cases +- Good: A real Codex request appears in `/admin/requests` with `protection=protected_clean` or `auto_continued`, and no raw reasoning content is present. +- Base: An invalid JSON request returns `400` from `/v1/responses` and appears as `protection=failed`, `failure_reason=invalid_json_body`. +- Bad: The dashboard scans log strings like `round_decision` in JavaScript and guesses whether the request was protected. This duplicates backend contract logic and will drift. +- Bad: A production rollout fixes the page but exposes `/admin/requests` on `https://cpa.konbakuyomu.us/`. This leaks operational metadata and violates the public/admin boundary. + +### 6. Tests Required +- Unit: request summary projection covers `protected_clean`, `auto_continued`, `risk_uncontinued`, `passthrough`, `failed`, and retention behavior. +- Unit: redaction preserves numeric counters such as `reasoning_tokens` and `total_tokens`, while redacting bearer/API/OAuth/encrypted-content fields. +- Route smoke: `/admin/requests` returns summaries and `/admin/logs/stream?once=1` includes both `event: request` and `event: log`. +- Frontend smoke: desktop and mobile dashboard render without horizontal overflow, and simulated protection states are visibly distinct. +- Production smoke: `cpa-admin.konbakuyomu.us/codexcont/` reaches the dashboard through Cloudflare Access, while public `cpa.konbakuyomu.us/admin/*` and `/codexcont/*` return `404`. + +### 7. Wrong vs Correct + +#### Wrong +```text +raw log event -> frontend string matching -> protection label +``` + +This spreads the event contract into JavaScript and makes the beginner-facing status depend on incidental log wording. + +#### Correct +```text +/v1/responses lifecycle -> Diagnostics request summary -> /admin/requests + event: request -> dashboard label +``` + +`Diagnostics` is the single projection owner. The UI renders the explicit `protection` value and keeps raw logs as an advanced troubleshooting view only. From 22004b502408ee0eabc143cca15b67b6496a28a2 Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Wed, 1 Jul 2026 17:22:22 +0800 Subject: [PATCH 11/68] chore(task): archive 07-01-codexcont-status-dashboard --- .../2026-07}/07-01-codexcont-status-dashboard/check.jsonl | 0 .../2026-07}/07-01-codexcont-status-dashboard/design.md | 0 .../07-01-codexcont-status-dashboard/implement.jsonl | 0 .../2026-07}/07-01-codexcont-status-dashboard/implement.md | 0 .../2026-07}/07-01-codexcont-status-dashboard/prd.md | 0 .../2026-07}/07-01-codexcont-status-dashboard/task.json | 6 +++--- 6 files changed, 3 insertions(+), 3 deletions(-) rename .trellis/tasks/{ => archive/2026-07}/07-01-codexcont-status-dashboard/check.jsonl (100%) rename .trellis/tasks/{ => archive/2026-07}/07-01-codexcont-status-dashboard/design.md (100%) rename .trellis/tasks/{ => archive/2026-07}/07-01-codexcont-status-dashboard/implement.jsonl (100%) rename .trellis/tasks/{ => archive/2026-07}/07-01-codexcont-status-dashboard/implement.md (100%) rename .trellis/tasks/{ => archive/2026-07}/07-01-codexcont-status-dashboard/prd.md (100%) rename .trellis/tasks/{ => archive/2026-07}/07-01-codexcont-status-dashboard/task.json (94%) diff --git a/.trellis/tasks/07-01-codexcont-status-dashboard/check.jsonl b/.trellis/tasks/archive/2026-07/07-01-codexcont-status-dashboard/check.jsonl similarity index 100% rename from .trellis/tasks/07-01-codexcont-status-dashboard/check.jsonl rename to .trellis/tasks/archive/2026-07/07-01-codexcont-status-dashboard/check.jsonl diff --git a/.trellis/tasks/07-01-codexcont-status-dashboard/design.md b/.trellis/tasks/archive/2026-07/07-01-codexcont-status-dashboard/design.md similarity index 100% rename from .trellis/tasks/07-01-codexcont-status-dashboard/design.md rename to .trellis/tasks/archive/2026-07/07-01-codexcont-status-dashboard/design.md diff --git a/.trellis/tasks/07-01-codexcont-status-dashboard/implement.jsonl b/.trellis/tasks/archive/2026-07/07-01-codexcont-status-dashboard/implement.jsonl similarity index 100% rename from .trellis/tasks/07-01-codexcont-status-dashboard/implement.jsonl rename to .trellis/tasks/archive/2026-07/07-01-codexcont-status-dashboard/implement.jsonl diff --git a/.trellis/tasks/07-01-codexcont-status-dashboard/implement.md b/.trellis/tasks/archive/2026-07/07-01-codexcont-status-dashboard/implement.md similarity index 100% rename from .trellis/tasks/07-01-codexcont-status-dashboard/implement.md rename to .trellis/tasks/archive/2026-07/07-01-codexcont-status-dashboard/implement.md diff --git a/.trellis/tasks/07-01-codexcont-status-dashboard/prd.md b/.trellis/tasks/archive/2026-07/07-01-codexcont-status-dashboard/prd.md similarity index 100% rename from .trellis/tasks/07-01-codexcont-status-dashboard/prd.md rename to .trellis/tasks/archive/2026-07/07-01-codexcont-status-dashboard/prd.md diff --git a/.trellis/tasks/07-01-codexcont-status-dashboard/task.json b/.trellis/tasks/archive/2026-07/07-01-codexcont-status-dashboard/task.json similarity index 94% rename from .trellis/tasks/07-01-codexcont-status-dashboard/task.json rename to .trellis/tasks/archive/2026-07/07-01-codexcont-status-dashboard/task.json index f2833df..72ad0ca 100644 --- a/.trellis/tasks/07-01-codexcont-status-dashboard/task.json +++ b/.trellis/tasks/archive/2026-07/07-01-codexcont-status-dashboard/task.json @@ -3,7 +3,7 @@ "name": "codexcont-status-dashboard", "title": "CodexCont status dashboard", "description": "", - "status": "in_progress", + "status": "completed", "dev_type": null, "scope": null, "package": null, @@ -11,7 +11,7 @@ "creator": "dxt98", "assignee": "dxt98", "createdAt": "2026-07-01", - "completedAt": null, + "completedAt": "2026-07-01", "branch": null, "base_branch": "main", "worktree_path": null, @@ -23,4 +23,4 @@ "relatedFiles": [], "notes": "CodexCont dashboard v2 is implemented and deployed. Local tests passed 136/136; Playwright/Edge verified Chinese request-first UI, desktop/mobile no-overflow layout, all protection chips, and SSE request/log behavior. SJC cpa-admin path /codexcont/ routes through Cloudflare Access to CodexCont; public cpa.konbakuyomu.us admin/codexcont/management paths return 404. Live requests showed current gpt-5.5 traffic as protected_clean and a controlled invalid_json_body event as failed.", "meta": {} -} +} \ No newline at end of file From fede25659ef932ed8b176a66599cbf302efcfb31 Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Wed, 1 Jul 2026 17:24:10 +0800 Subject: [PATCH 12/68] chore: record journal --- .trellis/workspace/dxt98/index.md | 5 ++-- .trellis/workspace/dxt98/journal-1.md | 35 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/.trellis/workspace/dxt98/index.md b/.trellis/workspace/dxt98/index.md index 0d84144..2868cd4 100644 --- a/.trellis/workspace/dxt98/index.md +++ b/.trellis/workspace/dxt98/index.md @@ -8,7 +8,7 @@ - **Active File**: `journal-1.md` -- **Total Sessions**: 1 +- **Total Sessions**: 2 - **Last Active**: 2026-07-01 @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~41 | Active | +| `journal-1.md` | ~76 | Active | --- @@ -29,6 +29,7 @@ | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 2 | 2026-07-01 | CodexCont status dashboard | `82e54b8`, `5b578e1`, `42c1b14` | `main` | | 1 | 2026-07-01 | SJC CPA migration closeout | `98bf0df`, `aa113c2` | `main` | diff --git a/.trellis/workspace/dxt98/journal-1.md b/.trellis/workspace/dxt98/journal-1.md index 6a0b2fa..6da60b7 100644 --- a/.trellis/workspace/dxt98/journal-1.md +++ b/.trellis/workspace/dxt98/journal-1.md @@ -39,3 +39,38 @@ Recorded the completed SJC sub2api to CPA migration, captured Codex continuation ### Next Steps - None - task complete + + +## Session 2: CodexCont status dashboard + +**Date**: 2026-07-01 +**Task**: CodexCont status dashboard +**Branch**: `main` + +### Summary + +Built and deployed the CodexCont admin dashboard, then upgraded it to a Chinese request-first protection status page with request summaries, SSE request updates, production validation, and captured the admin diagnostics contract in backend spec. + +### Main Changes + +(Add details) + +### Git Commits + +| Hash | Message | +|------|---------| +| `82e54b8` | (see git log) | +| `5b578e1` | (see git log) | +| `42c1b14` | (see git log) | + +### Testing + +- [OK] (Add test results) + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete From 1c4feecb8e58f4bf83daad8cdb79b5506d015ad2 Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Wed, 1 Jul 2026 18:48:57 +0800 Subject: [PATCH 13/68] fix: clarify dashboard truncation trigger --- .../backend/codex-continuation-contracts.md | 4 +- README_zh.md | 2 + middleware/dashboard.html | 43 ++++++++++++++----- middleware/diagnostics.py | 9 ++++ tests/test_middleware.py | 12 ++++++ 5 files changed, 58 insertions(+), 12 deletions(-) diff --git a/.trellis/spec/backend/codex-continuation-contracts.md b/.trellis/spec/backend/codex-continuation-contracts.md index 59416c8..c04d873 100644 --- a/.trellis/spec/backend/codex-continuation-contracts.md +++ b/.trellis/spec/backend/codex-continuation-contracts.md @@ -84,7 +84,8 @@ The continuation owner must sit at executor level, where it can inspect raw upst - Request summary projection fields: - `request_id`, `model`, `path`, `started_at`, `updated_at`, `ended_at`, `duration_ms` - `status`, `protection`, `folded`, `passthrough`, `passthrough_reason` - - `rounds[]`, `latest_round`, `latest_reasoning_tokens`, `continuation_count` + - `rounds[]`, `latest_round`, `latest_reasoning_tokens` + - `first_truncation_round`, `first_truncation_reasoning_tokens`, `first_truncation_n`, `first_truncation_decision`, `continuation_count` - `truncation_match`, `final_status`, `stopped_reason`, `failure_reason`, `failure_detail` - Protection values: - `protected_clean`, `auto_continued`, `risk_uncontinued`, `passthrough`, `failed`, `incomplete`, `processing` @@ -95,6 +96,7 @@ The continuation owner must sit at executor level, where it can inspect raw upst - Request summaries and logs must not include request bodies, Authorization headers, API keys, OAuth tokens, encrypted reasoning content, or internal implementation-only fields such as `_started_perf`. - `event: log` behavior is backward-compatible with the original dashboard stream. Adding request updates must use a separate `event: request` SSE event. - The beginner-facing dashboard must distinguish "entered CodexCont protection and no continuation was needed" from "516/518n-2 was detected and a hidden continuation round was opened". +- When a continued request ends with a clean final round, `latest_reasoning_tokens` may be below 516. The dashboard must label it as latest-round reasoning and separately display the first 516/518n-2 trigger round from the request summary. - Production admin access must remain behind `cpa-admin.konbakuyomu.us` plus Cloudflare Access. Public `cpa.konbakuyomu.us` must not expose `/admin/*`, `/codexcont/*`, `/management.html`, or CPA management APIs. - SJC is a small-disk host. Deployment must prefer uploading changed files plus single-service rebuild/restart; do not use Docker prune or broad filesystem cleanup as part of dashboard rollout. diff --git a/README_zh.md b/README_zh.md index cdfa752..9652bd7 100644 --- a/README_zh.md +++ b/README_zh.md @@ -120,6 +120,8 @@ http://127.0.0.1:8787/admin/ - `疑似截断 / 未续写`:检测到截断指纹,但保护条件或上限阻止了续写。 - `透传` / `失败` / `不完整`:分别表示未进入折叠保护、请求失败、上游未完整结束。 +面板里的“末轮思考量”只表示最后一轮上游响应的 reasoning token;如果某个请求被自动续写,真正触发续写的 516/518n-2 会显示在“命中轮”列里。 + 日志和最近请求都只保存在进程内存中,默认日志上限为: ```toml diff --git a/middleware/dashboard.html b/middleware/dashboard.html index 9639c6b..72cc48e 100644 --- a/middleware/dashboard.html +++ b/middleware/dashboard.html @@ -137,7 +137,10 @@ .col-time { width: 168px; } .col-protect { width: 176px; } .col-model { width: 126px; } - .col-small { width: 86px; } + .col-round { width: 72px; } + .col-hit { width: 118px; } + .col-token { width: 112px; } + .col-count { width: 72px; } .col-result { width: 132px; } .col-action { width: 76px; } .mono { font-family: var(--mono); font-size: 12px; } @@ -161,6 +164,7 @@ background: #fff; padding: 8px; } + .round.hit { border-color: #f0d29b; background: #fffaf0; } .round strong { display: block; margin-bottom: 5px; } .log-panel { overflow: hidden; } .log-panel > summary { @@ -203,7 +207,7 @@ h1 { font-size: 21px; } .metric { min-height: 82px; } .metric .value { font-size: 23px; } - .request-wrap table, .log-wrap table { min-width: 920px; } + .request-wrap table, .log-wrap table { min-width: 1000px; } } @@ -287,14 +291,15 @@

最近请求

时间 保护结果 模型 - 轮次 - 思考量 - 续写 + 轮次 + 命中轮 + 末轮思考量 + 续写 最终结果 详情 - 暂无请求 + 暂无请求 @@ -326,7 +331,7 @@

最近请求

时间 - 级别 + 级别 事件 请求 消息 / 字段 @@ -430,6 +435,8 @@

最近请求

model: req.model, protection: req.protection, status: req.status, + truncation_round: req.first_truncation_round, + truncation_tokens: req.first_truncation_reasoning_tokens, stopped: req.stopped_reason, fail: req.failure_reason, passthrough: req.passthrough_reason, @@ -441,14 +448,25 @@

最近请求

if (req.passthrough_reason) return req.passthrough_reason; return "-"; } + function requestTrigger(req) { + if (!req.first_truncation_round) return "-"; + const tokens = req.first_truncation_reasoning_tokens ?? "-"; + return `第 ${req.first_truncation_round} 轮 / ${tokens}`; + } + function requestTriggerHint(req) { + if (!req.first_truncation_round) return "未命中 516/518n-2"; + const decision = DECISIONS[req.first_truncation_decision] || req.first_truncation_decision || "-"; + return `命中后决策:${decision}`; + } function renderRounds(rounds) { if (!Array.isArray(rounds) || !rounds.length) return '暂无轮次数据'; return rounds.map((round) => { const hit = round.truncation_match ? "命中" : "未命中"; const decision = DECISIONS[round.decision] || round.decision || "-"; - return `
+ const cls = round.truncation_match ? "round hit" : "round"; + return `
第 ${esc(round.round)} 轮 -
reasoning: ${esc(round.reasoning_tokens ?? "-")}
+
本轮 reasoning: ${esc(round.reasoning_tokens ?? "-")}
516 指纹: ${esc(hit)}
决策: ${esc(decision)}
`; @@ -464,7 +482,7 @@

最近请求

.slice(0, 100); setText("request-cache", state.requests.size); if (!requests.length) { - tbody.innerHTML = '暂无匹配请求'; + tbody.innerHTML = '暂无匹配请求'; return; } tbody.innerHTML = requests.map((req) => { @@ -478,13 +496,14 @@

最近请求

${chip(req.protection)}
${esc(PROTECTION[req.protection]?.[2] || "")}
${esc(req.model || "-")} ${esc(req.latest_round || rounds.length || 0)} + ${esc(requestTrigger(req))}
${esc(requestTriggerHint(req))}
${esc(req.latest_reasoning_tokens ?? "-")} ${esc(req.continuation_count || 0)} ${esc(result)}
${esc(reason)}
- +

请求摘要

@@ -493,6 +512,8 @@

请求摘要

路径${esc(req.path || "-")} 耗时${esc(fmtMs(req.duration_ms))} 结束时间${esc(fmtTime(req.ended_at))} + 516 命中轮${esc(requestTrigger(req))} + 命中后决策${esc(requestTriggerHint(req))} 透传原因${esc(req.passthrough_reason || "-")} 失败原因${esc(req.failure_reason || "-")}
diff --git a/middleware/diagnostics.py b/middleware/diagnostics.py index bdc4434..04d26c1 100644 --- a/middleware/diagnostics.py +++ b/middleware/diagnostics.py @@ -284,6 +284,10 @@ def request_started(self, *, path: str, model: str | None = None) -> str: "rounds": [], "latest_round": None, "latest_reasoning_tokens": None, + "first_truncation_round": None, + "first_truncation_reasoning_tokens": None, + "first_truncation_n": None, + "first_truncation_decision": None, "continuation_count": 0, "truncation_match": False, "final_status": None, @@ -375,6 +379,11 @@ def round_decision( req["rounds"].append(round_summary) req["latest_round"] = round_no req["latest_reasoning_tokens"] = reasoning_tokens + if truncation_match and req.get("first_truncation_round") is None: + req["first_truncation_round"] = round_no + req["first_truncation_reasoning_tokens"] = reasoning_tokens + req["first_truncation_n"] = n + req["first_truncation_decision"] = decision req["truncation_match"] = bool(req.get("truncation_match") or truncation_match) req["updated_at"] = utc_now_iso() if truncation_match and decision != "continue" and req.get("continuation_count", 0) == 0: diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 817fac1..fb0c886 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -464,6 +464,8 @@ def test_diagnostics_request_summaries(): str(summary)) check("request summary keeps reasoning tokens", summary.get("latest_reasoning_tokens") == 140, str(summary)) + check("request summary clean has no truncation round", + summary.get("first_truncation_round") is None, str(summary)) cont = Diagnostics(max_events=10, max_requests=5) rid = cont.request_started(path="/v1/responses", model="gpt-5.5") @@ -480,6 +482,12 @@ def test_diagnostics_request_summaries(): str(summary)) check("request summary continuation count", summary.get("continuation_count") == 1, str(summary)) + check("request summary records first truncation round", + summary.get("first_truncation_round") == 1, str(summary)) + check("request summary records first truncation tokens", + summary.get("first_truncation_reasoning_tokens") == 516, str(summary)) + check("request summary latest reasoning can be clean round", + summary.get("latest_reasoning_tokens") == 181, str(summary)) risk = Diagnostics(max_events=10, max_requests=5) rid = risk.request_started(path="/v1/responses", model="gpt-5.5") @@ -491,6 +499,8 @@ def test_diagnostics_request_summaries(): summary = risk.recent_requests(limit=1)[0] check("request summary risk uncontinued", summary.get("protection") == "risk_uncontinued", str(summary)) + check("request summary risk decision captured", + summary.get("first_truncation_decision") == "no_encrypted_content", str(summary)) passthrough = Diagnostics(max_events=10, max_requests=5) rid = passthrough.request_started(path="/v1/responses", model="gpt-5.5") @@ -570,6 +580,8 @@ def test_admin_routes_smoke(): check("admin dashboard html 200", html.status_code == 200, str(html.status_code)) check("admin dashboard contains EventSource", "new EventSource" in html.text) check("admin dashboard Chinese first screen", "最近请求" in html.text) + check("admin dashboard has trigger round column", "命中轮" in html.text) + check("admin dashboard has latest reasoning column", "末轮思考量" in html.text) stream = client.get("/admin/logs/stream?once=1") check("admin logs stream ready", "event: ready" in stream.text, stream.text[:80]) From b20a983eed5c39f9a0edd9f4f2f0b1ecb88c38e3 Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Wed, 1 Jul 2026 20:07:03 +0800 Subject: [PATCH 14/68] feat: add CPA usage portal --- .../backend/codex-continuation-contracts.md | 91 ++++ .../check.jsonl | 2 + .../07-01-cpa-key-management-portal/design.md | 171 ++++++++ .../implement.jsonl | 2 + .../implement.md | 123 ++++++ .../07-01-cpa-key-management-portal/prd.md | 85 ++++ .../07-01-cpa-key-management-portal/task.json | 26 ++ README.md | 21 + README_zh.md | 59 +++ cpa_usage_portal/__init__.py | 6 + cpa_usage_portal/app.py | 266 ++++++++++++ cpa_usage_portal/budget.py | 42 ++ cpa_usage_portal/config.py | 59 +++ cpa_usage_portal/cpamp.py | 89 ++++ cpa_usage_portal/key_policy.py | 185 ++++++++ cpa_usage_portal/redaction.py | 146 +++++++ cpa_usage_portal/retention.py | 61 +++ cpa_usage_portal/security.py | 81 ++++ cpa_usage_portal/static/dashboard.html | 411 ++++++++++++++++++ deploy/cpa-usage-portal/Dockerfile | 9 + .../docker-compose.example.yaml | 23 + run_usage_portal.py | 16 + tests/test_cpa_usage_portal.py | 276 ++++++++++++ 23 files changed, 2250 insertions(+) create mode 100644 .trellis/tasks/07-01-cpa-key-management-portal/check.jsonl create mode 100644 .trellis/tasks/07-01-cpa-key-management-portal/design.md create mode 100644 .trellis/tasks/07-01-cpa-key-management-portal/implement.jsonl create mode 100644 .trellis/tasks/07-01-cpa-key-management-portal/implement.md create mode 100644 .trellis/tasks/07-01-cpa-key-management-portal/prd.md create mode 100644 .trellis/tasks/07-01-cpa-key-management-portal/task.json create mode 100644 cpa_usage_portal/__init__.py create mode 100644 cpa_usage_portal/app.py create mode 100644 cpa_usage_portal/budget.py create mode 100644 cpa_usage_portal/config.py create mode 100644 cpa_usage_portal/cpamp.py create mode 100644 cpa_usage_portal/key_policy.py create mode 100644 cpa_usage_portal/redaction.py create mode 100644 cpa_usage_portal/retention.py create mode 100644 cpa_usage_portal/security.py create mode 100644 cpa_usage_portal/static/dashboard.html create mode 100644 deploy/cpa-usage-portal/Dockerfile create mode 100644 deploy/cpa-usage-portal/docker-compose.example.yaml create mode 100644 run_usage_portal.py create mode 100644 tests/test_cpa_usage_portal.py diff --git a/.trellis/spec/backend/codex-continuation-contracts.md b/.trellis/spec/backend/codex-continuation-contracts.md index c04d873..1200b8f 100644 --- a/.trellis/spec/backend/codex-continuation-contracts.md +++ b/.trellis/spec/backend/codex-continuation-contracts.md @@ -137,3 +137,94 @@ This spreads the event contract into JavaScript and makes the beginner-facing st ``` `Diagnostics` is the single projection owner. The UI renders the explicit `protection` value and keeps raw logs as an advanced troubleshooting view only. + +## Scenario: CPA Key Policy, CPAMP, and user usage portal + +### 1. Scope / Trigger +- Trigger this spec whenever work touches `cpa_usage_portal/`, CPAMP monitoring + queries, CPA Key Policy state parsing, per-key quota display, user usage + events, or production routes for `cpa-usage.konbakuyomu.us`. +- This is a cross-layer contract: Key Policy state authenticates a raw + `cpa_...` key, CPA records the plugin principal into usage events, CPAMP + hashes that principal, the portal filters CPAMP analytics, and the frontend + renders only safe per-user summaries. + +### 2. Signatures +- Portal routes: + - `GET /healthz` + - `POST /api/session` + - `DELETE /api/session` + - `GET /api/me` + - `GET /api/usage?range=24h|7d` + - `GET /api/events?limit=N&before=...` + - `GET /api/events/stream` +- Production user route: `https://cpa-usage.konbakuyomu.us/` +- Production admin route for CPAMP: `https://cpa-admin.konbakuyomu.us/` +- Key Policy state path on SJC: + `/opt/codex-stacks/cpa/plugin-state/cpa-key-policy-state.json` + +### 3. Contracts +- CPA stays on the official image. Do not fork CPA to implement per-key usage + views. +- The user portal is a read-only sidecar. It may read Key Policy state and + query CPAMP monitoring, but it must not mutate CPA, OAuth accounts, proxy + routing, or Key Policy records in v1. +- Login validation uses the raw user key only once: + `sha256(trimmed_raw_cpa_key)` must match Key Policy `key_hash` + (`sha256:`). The raw key must not be stored, logged, or returned. +- CPAMP filtering for Key Policy keys must use `sha256(Key Policy id)`, not + `sha256(raw cpa_... key)`. CPA Key Policy authenticates requests with + `Principal = key.ID`, and CPAMP hashes CPA's usage-record principal. +- Keep raw-key hash and CPAMP usage hash as separate concepts in code and + tests. Session cookies may contain safe hash identifiers, but every request + must re-load Key Policy state and validate the raw-key hash still maps to an + enabled record. +- User APIs must never return raw API keys, full raw-key hashes, full CPAMP + usage hashes, OAuth tokens, CPA management keys, CPAMP admin keys, cookies, + Authorization headers, request bodies, response bodies, or encrypted + reasoning content. +- CPAMP `api_key_stats` must be projected before returning it to the browser. + Do not pass CPAMP rows through directly because they may include full + `api_key_hash` values. +- Public `cpa.konbakuyomu.us` must continue to block management, plugin, + admin, CodexCont dashboard, CPAMP, and usage-portal internals. +- SJC is disk-constrained. Portal rollouts should upload changed files and + rebuild only `cpa-usage-portal`; do not use Docker prune or broad deletion. + +### 4. Validation & Error Matrix +- Unknown raw API key -> `401 invalid_api_key`. +- Disabled Key Policy record -> `403 api_key_disabled` at login or + `401 key_not_available` for an existing session. +- Missing or invalid session -> `401 not_authenticated`. +- CPAMP rows whose `api_key_hash` does not match the current key's CPAMP usage + hash -> drop them server-side. +- `GET /api/usage` must not include the full raw-key hash or full policy-id + hash anywhere in the JSON response. +- DNS for `cpa-usage.konbakuyomu.us` may be absent while the sidecar and Caddy + route are ready; verify with explicit host resolution before declaring the + route broken. + +### 5. Good/Base/Bad Cases +- Good: A user logs in with a `cpa_...` key; the portal validates + `sha256(raw key)` against Key Policy state, then queries CPAMP with + `sha256(key.id)` and shows only that key's events. +- Base: A Key Policy key has no events yet. The portal still shows safe key + metadata and empty usage tables. +- Bad: The portal filters CPAMP with `sha256(raw key)` and shows zero events + even though CPAMP has usage records for the key id. +- Bad: `/api/usage` returns CPAMP `api_key_stats` unchanged and leaks a full + `api_key_hash` to the browser. + +### 6. Tests Required +- Unit: raw key hash validates login while `record.cpamp_hash` equals + `sha256(policy id)` when `id` is present. +- Unit: CPAMP analytics calls use the policy-id hash, not the raw-key hash. +- Unit: usage/event projections reject another key's hash and do not return + full hash values. +- Unit: redaction covers Authorization, cookies, API keys, tokens, management + keys, and encrypted reasoning fields while preserving numeric token counters. +- Unit: retention deletes old CPAMP `usage_events` in batches and does not run + `VACUUM`. +- Production smoke: allowed Key Policy model succeeds, disallowed model is + rejected, CPAMP records real usage, the portal login succeeds, and + `/api/events` returns only own events. diff --git a/.trellis/tasks/07-01-cpa-key-management-portal/check.jsonl b/.trellis/tasks/07-01-cpa-key-management-portal/check.jsonl new file mode 100644 index 0000000..f1f8092 --- /dev/null +++ b/.trellis/tasks/07-01-cpa-key-management-portal/check.jsonl @@ -0,0 +1,2 @@ +{"file": ".trellis/spec/backend/codex-continuation-contracts.md", "reason": "Check hash separation, no-secret projections, route blocking, and small-disk rollout constraints"} +{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "General backend quality gate placeholder for local tests and deployment verification"} diff --git a/.trellis/tasks/07-01-cpa-key-management-portal/design.md b/.trellis/tasks/07-01-cpa-key-management-portal/design.md new file mode 100644 index 0000000..3af528c --- /dev/null +++ b/.trellis/tasks/07-01-cpa-key-management-portal/design.md @@ -0,0 +1,171 @@ +# CPA Key Management And Usage Portal Design + +## Architecture + +```text +Codex / clients + -> https://cpa.konbakuyomu.us/v1/responses + -> public Caddy + -> CodexCont sidecar + -> CPA official container + -> CPA Key Policy plugin + -> OpenAI OAuth account + +CPA official container + -> usage event queue + -> CPAMP collector + -> /data/usage.sqlite + +User browser + -> https://cpa-usage.konbakuyomu.us/ + -> cpa-usage-portal sidecar + -> Key Policy state file (read-only) + -> CPAMP monitoring API (filtered by api_key_hash) +``` + +CPA remains the source of API behavior. CodexCont remains the source of 516 +continuation protection. CPAMP and the user portal observe and project safe +usage information; they do not own request forwarding. + +## Server Components + +### CPAMP + +- Stack path: `/opt/codex-stacks/cpamp`. +- Network: `cpa_net`. +- Persistent data: `/opt/codex-stacks/cpamp/data/usage.sqlite` and + `/opt/codex-stacks/cpamp/data/data.key`. +- Collector mode: `auto`. +- CPA management endpoint: `http://cpa-admin-proxy:8327`, so CPA can keep + `remote-management.allow-remote: false`. +- Admin route: `https://cpa-admin.konbakuyomu.us/cpamp/` via the existing admin + proxy and Cloudflare Access. + +### CPA Key Policy + +- Install the official Linux amd64 release for `cpa-key-policy v0.2.1`. +- Mount a persistent CPA plugin directory and a persistent plugin state + directory into the CPA container. +- CPA config shape: + +```yaml +usage-statistics-enabled: true +plugins: + enabled: true + dir: "/CLIProxyAPI/plugins" + configs: + cpa-key-policy: + enabled: true + priority: 10 + state_file: "/CLIProxyAPI/plugin-state/cpa-key-policy-state.json" + keys: [] +``` + +When the state file exists, it is the source of truth. New user keys are created +through Key Policy management APIs or UI. Existing native CPA keys remain for +compatibility/admin access and should be migrated later. + +### User Usage Portal + +- Stack path: `/opt/codex-stacks/cpa-usage-portal`. +- Domain: `cpa-usage.konbakuyomu.us`. +- Network: `cpa_net`. +- Runtime: Python 3.12 with Starlette/httpx, matching CodexCont's lightweight + stack. +- Static frontend: one Chinese HTML/CSS/JS page; no npm build. +- Portal reads Key Policy state read-only and talks to CPAMP through an internal + admin URL with a CPAMP admin key from a root-only secret file or environment. + +## Portal Data Contracts + +### Hash Normalization + +- Raw key: accepted only in `POST /api/session`. +- Raw-key hex: `sha256(trimmed_raw_key).hexdigest()`. +- Key Policy hash: `sha256:`; this validates login. +- CPAMP usage hash for Key Policy keys: `sha256(Key Policy id)`. +- Fallback CPAMP hash for non-id records: raw-key hex. + +The portal stores only safe hash identifiers and key metadata in a signed +HttpOnly cookie. It must not store the raw `cpa_...` key. + +### Safe Key Metadata + +The portal may return: + +- key id/name/preview if present. +- enabled/disabled status. +- model allowlist or aliases. +- RPM. +- daily/weekly USD limits. +- usage counters exposed by Key Policy state. + +It must not return Key Policy internal `key_hash`, raw key material, or plugin +management credentials. + +### Safe Usage Event + +The portal projects CPAMP events into safe fields: + +- timestamp, model, provider/account labels if already non-secret. +- status, HTTP status, latency. +- input/output/reasoning/cache tokens when available. +- cost estimate when available. +- redacted error summary. + +It must not return prompt, response text, headers, cookies, raw request payload, +raw response payload, OAuth token data, encrypted reasoning content, or any +unrecognized secret-like field. + +## Portal API + +- `POST /api/session`: body `{ "api_key": "..." }`; validates against Key + Policy state and returns safe key metadata. +- `DELETE /api/session`: clears the session cookie. +- `GET /api/me`: returns safe key metadata for the current session. +- `GET /api/usage?range=24h|7d`: returns aggregated usage for the current key. +- `GET /api/events?limit=100&before=...`: returns recent safe request events. +- `GET /api/events/stream`: SSE stream for new safe request events. + +All read APIs require a valid session. Every CPAMP query includes the current +session's CPAMP-format `api_key_hash`. + +## Budget Suggestion + +The portal package includes an admin utility module/script for budget +suggestions: + +- Inputs: total daily/weekly budget, enabled Key Policy keys, model price table. +- Disabled keys are excluded. +- Missing model price data blocks USD limit suggestions. +- Output: per-key suggested daily/weekly limits and a safe patch payload that an + admin can apply through Key Policy management APIs. + +Applying limits remains an administrator action in v1. + +## Retention + +The retention job deletes CPAMP rows older than 7 days in small batches. It must +only target known CPAMP usage tables after introspection and run +`PRAGMA wal_checkpoint(TRUNCATE)` after deletion. It must not run `VACUUM`. + +## Security Boundaries + +- Public API host must continue to block `/management.html`, + `/v0/management*`, `/v0/resource/plugins/*`, `/admin/*`, `/codexcont/*`, + `/cpamp/*`, and portal-internal admin routes. +- CPAMP remains behind Cloudflare Access and CPA management key. +- User portal does not use Cloudflare Access in v1; the API key itself is the + self-service credential. This makes strict redaction and per-key filtering the + primary boundary. + +## Rollback + +- If CPAMP fails before CPA config changes, stop only CPAMP and leave production + traffic unchanged. +- If CPA plugin startup fails, restore the backed-up CPA config/compose and + restart only `cpa`. +- If the user portal fails, remove only the portal route/container; CPA, + CodexCont, and CPAMP can continue running. +- If any public admin exposure is detected, revert Caddy/admin proxy routing + before continuing functional tests. diff --git a/.trellis/tasks/07-01-cpa-key-management-portal/implement.jsonl b/.trellis/tasks/07-01-cpa-key-management-portal/implement.jsonl new file mode 100644 index 0000000..77f6345 --- /dev/null +++ b/.trellis/tasks/07-01-cpa-key-management-portal/implement.jsonl @@ -0,0 +1,2 @@ +{"file": ".trellis/spec/backend/codex-continuation-contracts.md", "reason": "CPA Key Policy, CPAMP, usage portal, public route, and 516 sidecar integration contracts"} +{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "Key Policy state, CPAMP analytics, portal API, and frontend projections cross several data boundaries"} diff --git a/.trellis/tasks/07-01-cpa-key-management-portal/implement.md b/.trellis/tasks/07-01-cpa-key-management-portal/implement.md new file mode 100644 index 0000000..f37d6a0 --- /dev/null +++ b/.trellis/tasks/07-01-cpa-key-management-portal/implement.md @@ -0,0 +1,123 @@ +# CPA Key Management And Usage Portal Implementation Plan + +## Phase 1: Local Implementation + +1. Add a `cpa_usage_portal` Python package with: + - Key Policy state loader and hash normalization. + - Signed session cookie helpers. + - CPAMP client abstraction. + - Redaction and safe event projection. + - Budget suggestion helper. + - Retention helper for CPAMP SQLite. + - Starlette app and static Chinese dashboard. +2. Add focused tests for: + - `sha256:` vs bare-hex normalization. + - API key validation and unknown key rejection. + - HttpOnly session cookie behavior. + - CPAMP requests always include the current key hash. + - Secret redaction and safe event projection. + - Budget suggestion edge cases. + - Retention deleting old rows in batches without `VACUUM`. +3. Update README files with short operational notes. + +## Phase 2: Server Preflight + +1. Record `df -h /`, `docker system df`, and current container list. +2. Back up root-only copies of: + - `/opt/codex-stacks/cpa/docker-compose.yaml` + - `/opt/codex-stacks/cpa/config.yaml` + - `/opt/codex-stacks/cpa-admin-tunnel/Caddyfile` + - `/opt/codex-stacks/caddy/Caddyfile` + - Key Policy state and CPAMP data paths if already present. +3. Do not use Docker prune or broad deletion. If free space is insufficient for + image pulls, pause for an explicit per-image cleanup decision. + +## Phase 3: CPAMP And Key Policy + +1. Create `/opt/codex-stacks/cpamp` and start CPAMP on `cpa_net`. +2. Enable CPA usage statistics and persistent plugin mounts. +3. Install `cpa-key-policy v0.2.1` from the official release and verify checksum. +4. Restart only `cpa`; verify `/healthz`, `/v1/models`, and CodexCont + `/v1/responses`. +5. Route `cpa-admin.konbakuyomu.us/cpamp/` through the admin proxy. + +## Phase 4: User Portal + +1. Create `/opt/codex-stacks/cpa-usage-portal`. +2. Deploy the portal container on `cpa_net`. +3. Add `cpa-usage.konbakuyomu.us` routing. +4. Verify login with a test Key Policy key and confirm only that key's events + are visible. + +## Phase 5: Acceptance And Closeout + +1. Verify Key Policy: + - Allowed model succeeds. + - Disallowed model fails. + - Low-limit/RPM test key is constrained. +2. Verify CPAMP receives real requests. +3. Verify CodexCont dashboard and 516 protection still show current requests. +4. Verify public API domain still blocks management/plugin/admin paths. +5. Record disk/database/log sizes. +6. Update this task with deployment evidence, then commit. + +## Current Evidence + +- Existing production CPA public base: `https://cpa.konbakuyomu.us/`. +- Existing admin base: `https://cpa-admin.konbakuyomu.us/`. +- Existing CodexCont admin path: `/codexcont/`. +- Existing SJC disk is known to be tight; live preflight is required before any + server-side image pull. + +## Deployment Evidence 2026-07-01 + +- Root-only backup path: `/root/cpa-key-management-backups/20260701-193204`. +- Server disk after rollout: `/dev/sda1` size `9.6G`, used `8.8G`, available + `771M`, use `93%`. +- `docker system df` before the final portal rebuild showed images `2.227GB`, + local volumes `53.64MB`, build cache `234.8MB`; no Docker prune was used. +- CPAMP is deployed at `/opt/codex-stacks/cpamp`, container `cpamp`, and + `http://127.0.0.1:8327/health` returned `200`. +- CPA config has `usage-statistics-enabled: true`, `plugins.enabled: true`, and + `cpa-key-policy.enabled: true` with state file + `/CLIProxyAPI/plugin-state/cpa-key-policy-state.json`. +- Key Policy state path on the host: + `/opt/codex-stacks/cpa/plugin-state/cpa-key-policy-state.json`; current safe + status is `key_count=1`. +- A Key Policy smoke key successfully called + `https://cpa.konbakuyomu.us/v1/responses`: HTTP `200`, status `completed`. +- A disallowed model using the smoke key was rejected with HTTP `401`. +- Public `https://cpa.konbakuyomu.us` returned `404` for `/management.html`, + `/v0/management/config`, `/v0/resource/plugins/cpa-key-policy`, + `/admin/requests`, `/codexcont/`, and `/cpamp/`. +- CodexCont admin proxy route remains healthy: + `http://127.0.0.1:8327/codexcont/healthz` and `/codexcont/requests?limit=1` + returned `200`. +- CPAMP SQLite sizes after rollout: `usage.sqlite` `236K`, WAL `3.3M`, SHM + `32K`. +- User portal is deployed at `/opt/codex-stacks/cpa-usage-portal`, container + `cpa-usage-portal`, image `cpa-usage-portal:latest`. +- Portal route was verified through local host resolution for + `https://cpa-usage.konbakuyomu.us`: `/healthz` `200`, login `200`, + `/api/events` returned `2` own events, `/api/usage` returned `2` calls and + `0` failures. +- `/api/usage` no longer exposes either the raw-key hash or the Key Policy id + hash; it returns only safe stat fields plus a preview. +- Live CPAMP comparison confirmed the important hash contract: + `sha256("sjc-smoke-20260701")` returned `calls=1 events=1`, while + `sha256(raw smoke key)` returned `calls=0 events=0`. The portal now validates + login with raw-key hash but filters CPAMP with the Key Policy id hash. +- Local validation after the hash correction: + `.venv\Scripts\python.exe tests\test_cpa_usage_portal.py` -> `32/32`; + `.venv\Scripts\python.exe tests\test_middleware.py` -> `143/143`; + `.venv\Scripts\python.exe -m compileall cpa_usage_portal run_usage_portal.py` + completed. + +## Remaining Operational Note + +- `getent hosts cpa-usage.konbakuyomu.us` returned no records during final + verification. The service and Caddy route are ready; public access needs the + Cloudflare DNS record `cpa-usage.konbakuyomu.us -> 38.59.246.182`. +- RPM/daily-limit enforcement was not stress-tested with many live requests to + avoid unnecessary spend. Model allowlist rejection and normal Key Policy + authentication were verified. diff --git a/.trellis/tasks/07-01-cpa-key-management-portal/prd.md b/.trellis/tasks/07-01-cpa-key-management-portal/prd.md new file mode 100644 index 0000000..3b66c7a --- /dev/null +++ b/.trellis/tasks/07-01-cpa-key-management-portal/prd.md @@ -0,0 +1,85 @@ +# CPA Key Management And Usage Portal + +## Goal + +Give the CPA production stack real per-key governance without forking CPA: +administrators can monitor usage and set limits, while ordinary users can log +in with their own API key and see only their own usage and recent request +details. + +The first version combines three layers: + +- CPA Manager Plus (CPAMP) for administrator monitoring and request-level + usage collection. +- CPA Key Policy for per-key model allowlists, RPM, and daily/weekly USD + limits. +- A small independent usage portal for user self-service views. + +## Requirements + +- Keep CPA on the official image. Do not fork CPA or patch its runtime code. +- Keep CodexCont in the `/v1/responses` production path. The 516 continuation + protection must not regress. +- Deploy CPAMP as an admin-only service behind `cpa-admin.konbakuyomu.us` and + Cloudflare Access. CPAMP must consume CPA usage events and persist them in a + bounded SQLite database. +- Enable CPA usage statistics and CPA plugins. Install the official + `cpa-key-policy` plugin release and configure a persistent state file. +- New shared keys should be CPA Key Policy `cpa_...` keys. Existing native CPA + `api-keys` remain as compatibility/admin escape hatch but are not the target + mechanism for quota-managed users. +- Provide a Chinese user portal at `cpa-usage.konbakuyomu.us`. +- The user portal authenticates by accepting an API key once, validating it + against Key Policy state, and storing only a signed HttpOnly session cookie + containing safe hash identifiers and key metadata. +- The user portal must never store, log, render, or return raw API keys, OAuth + tokens, CPA management keys, CPAMP admin keys, cookies, Authorization headers, + request bodies, response bodies, or encrypted reasoning content. +- Users can only see their own key metadata, aggregate usage, and recent + request summaries. Server-side filtering by `api_key_hash` is mandatory. +- Request details may include safe operational fields: time, model, status, + latency, token usage, cost estimate, cache/reasoning counters if available, + and redacted failure summaries. +- Budget allocation is semi-automatic in v1: the administrator supplies a + trusted total budget, enabled keys are averaged, disabled keys are excluded, + and missing model prices block automatic USD limit writes. +- CPAMP request history retention is 7 days. Retention must be batched and avoid + `VACUUM`; WAL checkpoint is acceptable. +- SJC disk is small. Before server deployment, record `df -h /` and + `docker system df`; do not use Docker prune or broad directory deletion. + +## Acceptance Criteria + +- [ ] CPAMP `/health` or equivalent status endpoint is reachable through the + admin path and real CPA requests appear in monitoring data. +- [ ] CPA has `usage-statistics-enabled: true` and `plugins.enabled: true` with + Key Policy loaded from a persistent plugin directory/state file. +- [ ] A Key Policy test key can call `/v1/responses`; a disallowed model is + rejected; a low-limit/RPM test key is constrained. +- [ ] The CodexCont dashboard and `/v1/responses` folding path still work after + CPA plugin/usage changes. +- [ ] `cpa-usage.konbakuyomu.us` lets a user log in with a Key Policy key and + shows only that key's status, limits, aggregate usage, and recent events. +- [ ] User portal tests cover hash normalization, session safety, per-key + filtering, redaction, budget suggestions, and retention SQL behavior. +- [ ] Public `cpa.konbakuyomu.us` still blocks CPA management/plugin/admin + paths and does not expose CPAMP or the user portal internals. +- [ ] Deployment notes record final disk space, CPAMP SQLite/WAL size, and + backup paths without leaking secrets. + +## Non-Goals + +- No CPA fork. +- No public CPAMP access for ordinary users. +- No attempt to infer OpenAI account balance as exact dollars in v1. +- No long-term billing ledger beyond 7-day request history. +- No request/response body inspection in the user portal. + +## Notes + +- Key Policy stores the raw `cpa_...` key hash as `sha256:` for login + validation. For plugin keys, CPAMP records `api_key_hash` as + `sha256(Key Policy id)` because CPA receives the plugin key id as the + authenticated principal. The portal owns both normalizations. +- Cloudflare Access protects admin surfaces. The user portal is protected by + API-key self-check plus strict server-side filtering. diff --git a/.trellis/tasks/07-01-cpa-key-management-portal/task.json b/.trellis/tasks/07-01-cpa-key-management-portal/task.json new file mode 100644 index 0000000..3f8c0e0 --- /dev/null +++ b/.trellis/tasks/07-01-cpa-key-management-portal/task.json @@ -0,0 +1,26 @@ +{ + "id": "cpa-key-management-portal", + "name": "cpa-key-management-portal", + "title": "CPA key management and usage portal", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "dxt98", + "assignee": "dxt98", + "createdAt": "2026-07-01", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/README.md b/README.md index c981553..4e2fd2f 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,24 @@ max_log_events = 800 Do not expose `/admin/` on a public API hostname unless it is protected by an external access layer such as Cloudflare Access. +## CPA Usage Portal + +This repository also includes a small self-service sidecar in `cpa_usage_portal/`. +It is designed to sit next to CPA Manager Plus and CPA Key Policy: users log in +with their own `cpa_...` key and can only see usage filtered to that key. The +portal validates login with the raw key's Key Policy hash, but filters CPAMP +usage with `sha256(Key Policy id)` because the plugin authenticates to CPA as +the key id. Raw API keys, OAuth tokens, management keys, request bodies, +response bodies, and encrypted reasoning content are never returned. + +Run locally: + +```bash +.venv/Scripts/python.exe run_usage_portal.py +``` + +Docker deployment templates live in `deploy/cpa-usage-portal/`. + ## When continuation is applied The middleware folds only when all of the following are true: @@ -181,6 +199,7 @@ Current offline coverage includes: - upstream URL resolution - auth safety guard - dashboard diagnostics and admin route smoke +- CPA usage portal hash/session/filtering/redaction/retention behavior - EOF/upstream-error behavior ## Project layout @@ -200,9 +219,11 @@ middleware/ tests/ test_middleware.py + test_cpa_usage_portal.py fixtures/ run.py # uvicorn entrypoint +run_usage_portal.py # CPA usage portal entrypoint config.example.toml # example runtime configuration; copy to config.toml for local use ``` diff --git a/README_zh.md b/README_zh.md index 9652bd7..b9a2fd4 100644 --- a/README_zh.md +++ b/README_zh.md @@ -131,6 +131,54 @@ max_log_events = 800 不要把 `/admin/` 直接暴露在公网 API 域名上;生产环境应放在 Cloudflare Access 这类外层访问控制之后。 +## CPA 用量自助页 + +本仓库还包含一个独立的 CPA 用量自助页 sidecar: + +```bash +.venv/Scripts/python.exe run_usage_portal.py +``` + +它不是 CPA fork,也不是 CPAMP 的替代品。推荐生产链路是: + +```text +CPA Key Policy 负责 key 和限额 +CPAMP 负责请求级用量采集 +cpa_usage_portal 只给普通用户看自己的用量 +``` + +关键环境变量: + +```text +CPA_USAGE_PORTAL_KEY_POLICY_STATE=/data/cpa-key-policy-state.json +CPA_USAGE_PORTAL_CPAMP_URL=http://cpamp:18317 +CPA_USAGE_PORTAL_CPAMP_ADMIN_KEY_FILE=/run/secrets/cpamp_admin_key +CPA_USAGE_PORTAL_SESSION_SECRET_FILE=/run/secrets/session_secret +``` + +用户只在登录时提交自己的 `cpa_...` API Key。服务端会用 `sha256` 校验 Key Policy state,并在 HttpOnly cookie 中只保存用于会话校验的哈希和安全元数据;页面和接口不会返回原始 key、完整 key hash、OAuth token、CPA/CPAMP 管理密钥、请求正文、响应正文或 encrypted reasoning 内容。 + +Docker 部署模板在: + +```text +deploy/cpa-usage-portal/ +``` + +## CPAMP 与 Key Policy 配合 + +计划中的生产组合是: + +- CPAMP:管理员面板和请求级监控,放在 `cpa-admin.konbakuyomu.us` + Cloudflare Access 后面。 +- CPA Key Policy:生成 `cpa_...` key,配置每个 key 的模型权限、RPM、每日/每周 USD 限额。 +- CPA 用量自助页:普通用户用自己的 key 登录,只能看自己的用量和最近请求。 + +Key Policy 有两个容易混淆的标识: + +- `key_hash`:原始 `cpa_...` key 的 `sha256:`,只用于登录校验。 +- `id`:Key Policy 交给 CPA 的 Principal;CPAMP monitoring 里的 `api_key_hash` 实际是 `sha256(id)`。 + +因此门户登录时用原始 key hash 找到 Key Policy 记录,查询 CPAMP 时改用该记录 `id` 的 hash。所有 CPAMP 查询都会强制带当前登录 key 对应的 `api_key_hash` 过滤。 + ## 什么时候会执行续写折叠 只有同时满足以下条件时,中间件才会执行折叠逻辑: @@ -188,6 +236,7 @@ uv run python tests/test_middleware.py - 上游 URL 解析 - 鉴权安全保护 - dashboard diagnostics 和 admin route smoke +- CPA 用量自助页的 key hash/session/过滤/脱敏/retention 行为 - EOF / 上游错误处理 ## 项目结构 @@ -205,11 +254,21 @@ middleware/ sse.py # 增量 SSE 解析和序列化 store.py # 可选 stateful repair 使用的内存 ID 存储 +cpa_usage_portal/ + app.py # 用户用量自助页 Starlette 应用 + key_policy.py # CPA Key Policy state 只读解析 + cpamp.py # CPAMP monitoring API client + redaction.py # 用户可见事件脱敏投影 + retention.py # CPAMP SQLite 7 天保留辅助 + static/dashboard.html # 中文自助页 + tests/ test_middleware.py + test_cpa_usage_portal.py fixtures/ run.py # uvicorn 入口 +run_usage_portal.py # CPA 用量自助页入口 config.example.toml # 示例运行配置;复制为 config.toml 后本地使用 ``` diff --git a/cpa_usage_portal/__init__.py b/cpa_usage_portal/__init__.py new file mode 100644 index 0000000..647bff3 --- /dev/null +++ b/cpa_usage_portal/__init__.py @@ -0,0 +1,6 @@ +"""Self-service CPA usage portal.""" + +from .app import create_app +from .config import PortalConfig, load_config_from_env + +__all__ = ["PortalConfig", "create_app", "load_config_from_env"] diff --git a/cpa_usage_portal/app.py b/cpa_usage_portal/app.py new file mode 100644 index 0000000..2775f04 --- /dev/null +++ b/cpa_usage_portal/app.py @@ -0,0 +1,266 @@ +"""Starlette app for the self-service CPA usage portal.""" +from __future__ import annotations + +import asyncio +import contextlib +import json +from pathlib import Path +from typing import Any + +import httpx +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import HTMLResponse, JSONResponse, Response, StreamingResponse +from starlette.routing import Route + +from .config import PortalConfig +from .cpamp import CPAMPClient, range_window +from .key_policy import KeyPolicyState +from .redaction import safe_api_key_stats, safe_events +from .security import sha256_hex, sign_session, verify_session + +_STATIC = Path(__file__).with_name("static") +_DASHBOARD = _STATIC / "dashboard.html" + + +class AuthError(Exception): + pass + + +def _json_error(message: str, status_code: int) -> JSONResponse: + return JSONResponse({"error": message}, status_code=status_code) + + +def _load_key_state(request: Request) -> KeyPolicyState: + cfg: PortalConfig = request.app.state.cfg + return KeyPolicyState.load(cfg.key_policy_state_path) + + +def _session_payload(request: Request) -> dict[str, Any]: + cfg: PortalConfig = request.app.state.cfg + token = request.cookies.get(cfg.session_cookie_name, "") + payload = verify_session(token, cfg.session_secret) + if payload is None: + raise AuthError("not_authenticated") + return payload + + +def _current_key(request: Request): + payload = _session_payload(request) + state = _load_key_state(request) + record = state.get_by_raw_hash(str(payload.get("key_hash") or "")) + if record is None or not record.enabled: + raise AuthError("key_not_available") + return record + + +def _parse_before(request: Request) -> tuple[int | None, int | None]: + before_ms = request.query_params.get("before_ms") + before_id = request.query_params.get("before_id") + if request.query_params.get("before") and (not before_ms and not before_id): + parts = request.query_params["before"].split(":", 1) + before_ms = parts[0] if parts else None + before_id = parts[1] if len(parts) > 1 else None + try: + parsed_ms = int(before_ms) if before_ms else None + except ValueError: + parsed_ms = None + try: + parsed_id = int(before_id) if before_id else None + except ValueError: + parsed_id = None + return parsed_ms, parsed_id + + +async def dashboard(_request: Request) -> HTMLResponse: + return HTMLResponse(_DASHBOARD.read_text(encoding="utf-8")) + + +async def healthz(request: Request) -> JSONResponse: + cfg: PortalConfig = request.app.state.cfg + state_ok = Path(cfg.key_policy_state_path).exists() + cpamp_ok: bool | None = None + try: + await request.app.state.cpamp.health() + cpamp_ok = True + except Exception: + cpamp_ok = False + return JSONResponse({"ok": state_ok and bool(cpamp_ok), "key_policy_state": state_ok, "cpamp": cpamp_ok}) + + +async def create_session(request: Request) -> JSONResponse: + try: + body = await request.json() + except json.JSONDecodeError: + return _json_error("invalid_json", 400) + api_key = str((body or {}).get("api_key") or "").strip() + if not api_key: + return _json_error("api_key_required", 400) + + key_hash = sha256_hex(api_key) + state = _load_key_state(request) + record = state.get_by_raw_hash(key_hash) + if record is None: + return _json_error("invalid_api_key", 401) + if not record.enabled: + return _json_error("api_key_disabled", 403) + + cfg: PortalConfig = request.app.state.cfg + token = sign_session( + {"key_hash": record.raw_key_hash, "cpamp_hash": record.cpamp_hash, "key_name": record.name}, + cfg.session_secret, + ttl_seconds=cfg.session_ttl_seconds, + ) + response = JSONResponse({"me": record.safe_dict()}) + response.set_cookie( + cfg.session_cookie_name, + token, + max_age=cfg.session_ttl_seconds, + httponly=True, + secure=cfg.cookie_secure, + samesite="lax", + path="/", + ) + return response + + +async def delete_session(request: Request) -> JSONResponse: + cfg: PortalConfig = request.app.state.cfg + response = JSONResponse({"ok": True}) + response.delete_cookie(cfg.session_cookie_name, path="/") + return response + + +async def me(request: Request) -> JSONResponse: + try: + record = _current_key(request) + except AuthError as exc: + return _json_error(str(exc), 401) + return JSONResponse({"me": record.safe_dict()}) + + +async def usage(request: Request) -> JSONResponse: + try: + record = _current_key(request) + except AuthError as exc: + return _json_error(str(exc), 401) + window = range_window(request.query_params.get("range", "24h")) + data = await request.app.state.cpamp.analytics( + api_key_hash=record.cpamp_hash, + window=window, + include_events=False, + ) + return JSONResponse({ + "range": request.query_params.get("range", "24h") if request.query_params.get("range") in {"24h", "7d"} else "24h", + "from_ms": window.from_ms, + "to_ms": window.to_ms, + "summary": data.get("summary") or {}, + "timeline": data.get("timeline") or [], + "model_share": data.get("model_share") or [], + "api_key_stats": safe_api_key_stats(data.get("api_key_stats") or [], expected_hash=record.cpamp_hash), + }) + + +async def events(request: Request) -> JSONResponse: + try: + record = _current_key(request) + except AuthError as exc: + return _json_error(str(exc), 401) + limit_raw = request.query_params.get("limit", "100") + try: + limit = max(1, min(int(limit_raw), 200)) + except ValueError: + limit = 100 + before_ms, before_id = _parse_before(request) + window = range_window("7d") + data = await request.app.state.cpamp.analytics( + api_key_hash=record.cpamp_hash, + window=window, + include_events=True, + event_limit=limit, + before_ms=before_ms, + before_id=before_id, + ) + page = data.get("events") or {} + items = safe_events(page.get("items") or [], expected_hash=record.cpamp_hash) + return JSONResponse({ + "events": items, + "next_before_ms": page.get("next_before_ms") or 0, + "next_before_id": page.get("next_before_id") or 0, + "has_more": bool(page.get("has_more")), + "total_count": page.get("total_count") or len(items), + }) + + +def _sse(name: str, payload: Any) -> bytes: + data = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + return f"event: {name}\ndata: {data}\n\n".encode("utf-8") + + +async def events_stream(request: Request) -> StreamingResponse: + try: + record = _current_key(request) + except AuthError as exc: + return StreamingResponse(iter([_sse("error", {"error": str(exc)})]), media_type="text/event-stream") + + cfg: PortalConfig = request.app.state.cfg + + async def stream(): + seen: set[str] = set() + yield _sse("ready", {"ok": True}) + while True: + if await request.is_disconnected(): + break + try: + data = await request.app.state.cpamp.analytics( + api_key_hash=record.cpamp_hash, + window=range_window("24h"), + include_events=True, + event_limit=20, + ) + page = data.get("events") or {} + items = safe_events(page.get("items") or [], expected_hash=record.cpamp_hash) + for item in reversed(items): + event_hash = str(item.get("event_hash") or "") + if event_hash and event_hash not in seen: + seen.add(event_hash) + yield _sse("event", item) + except Exception as exc: + yield _sse("error", {"error": type(exc).__name__}) + await asyncio.sleep(max(1.0, cfg.poll_seconds)) + + return StreamingResponse( + stream(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +def create_app(cfg: PortalConfig) -> Starlette: + if not cfg.session_secret: + raise ValueError("CPA_USAGE_PORTAL_SESSION_SECRET or file is required") + if not cfg.cpamp_admin_key: + raise ValueError("CPA_USAGE_PORTAL_CPAMP_ADMIN_KEY or file is required") + + @contextlib.asynccontextmanager + async def lifespan(app: Starlette): + client = httpx.AsyncClient() + app.state.cfg = cfg + app.state.client = client + app.state.cpamp = CPAMPClient(cfg.cpamp_base_url, cfg.cpamp_admin_key, client) + try: + yield + finally: + await client.aclose() + + routes = [ + Route("/", dashboard, methods=["GET"]), + Route("/healthz", healthz, methods=["GET"]), + Route("/api/session", create_session, methods=["POST"]), + Route("/api/session", delete_session, methods=["DELETE"]), + Route("/api/me", me, methods=["GET"]), + Route("/api/usage", usage, methods=["GET"]), + Route("/api/events", events, methods=["GET"]), + Route("/api/events/stream", events_stream, methods=["GET"]), + ] + return Starlette(routes=routes, lifespan=lifespan) diff --git a/cpa_usage_portal/budget.py b/cpa_usage_portal/budget.py new file mode 100644 index 0000000..834a698 --- /dev/null +++ b/cpa_usage_portal/budget.py @@ -0,0 +1,42 @@ +"""Semi-automatic budget suggestion helpers.""" +from __future__ import annotations + +from dataclasses import dataclass + +from .key_policy import KeyRecord, has_model_prices + + +@dataclass(frozen=True) +class BudgetSuggestion: + enabled_key_count: int + per_key_daily_usd: float | None + per_key_weekly_usd: float | None + patches: list[dict] + + +def suggest_equal_budget( + keys: list[KeyRecord], + *, + total_daily_usd: float | None = None, + total_weekly_usd: float | None = None, + require_model_prices: bool = True, +) -> BudgetSuggestion: + enabled = [key for key in keys if key.enabled] + if not enabled: + return BudgetSuggestion(0, None, None, []) + if require_model_prices: + missing = [key.name for key in enabled if not has_model_prices(key)] + if missing: + raise ValueError("missing model prices for: " + ", ".join(missing)) + + daily = None if total_daily_usd is None else round(float(total_daily_usd) / len(enabled), 4) + weekly = None if total_weekly_usd is None else round(float(total_weekly_usd) / len(enabled), 4) + patches = [] + for key in enabled: + patch = {"key_hash": key.key_hash, "name": key.name} + if daily is not None: + patch["daily_limit_usd"] = daily + if weekly is not None: + patch["weekly_limit_usd"] = weekly + patches.append(patch) + return BudgetSuggestion(len(enabled), daily, weekly, patches) diff --git a/cpa_usage_portal/config.py b/cpa_usage_portal/config.py new file mode 100644 index 0000000..c4e9994 --- /dev/null +++ b/cpa_usage_portal/config.py @@ -0,0 +1,59 @@ +"""Environment configuration for the CPA usage portal.""" +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class PortalConfig: + host: str = "0.0.0.0" + port: int = 8797 + key_policy_state_path: str = "/data/cpa-key-policy-state.json" + cpamp_base_url: str = "http://cpamp:18317" + cpamp_admin_key: str = "" + session_secret: str = "" + session_cookie_name: str = "cpa_usage_session" + session_ttl_seconds: int = 24 * 60 * 60 + cookie_secure: bool = True + poll_seconds: float = 3.0 + + +def _read_secret(value: str, file_path: str) -> str: + if value.strip(): + return value.strip() + if file_path.strip(): + return Path(file_path).read_text(encoding="utf-8").strip() + return "" + + +def _bool_env(name: str, default: bool) -> bool: + value = os.environ.get(name) + if value is None or value == "": + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def load_config_from_env() -> PortalConfig: + return PortalConfig( + host=os.environ.get("CPA_USAGE_PORTAL_HOST", "0.0.0.0"), + port=int(os.environ.get("CPA_USAGE_PORTAL_PORT", "8797")), + key_policy_state_path=os.environ.get( + "CPA_USAGE_PORTAL_KEY_POLICY_STATE", + "/data/cpa-key-policy-state.json", + ), + cpamp_base_url=os.environ.get("CPA_USAGE_PORTAL_CPAMP_URL", "http://cpamp:18317"), + cpamp_admin_key=_read_secret( + os.environ.get("CPA_USAGE_PORTAL_CPAMP_ADMIN_KEY", ""), + os.environ.get("CPA_USAGE_PORTAL_CPAMP_ADMIN_KEY_FILE", ""), + ), + session_secret=_read_secret( + os.environ.get("CPA_USAGE_PORTAL_SESSION_SECRET", ""), + os.environ.get("CPA_USAGE_PORTAL_SESSION_SECRET_FILE", ""), + ), + session_cookie_name=os.environ.get("CPA_USAGE_PORTAL_SESSION_COOKIE", "cpa_usage_session"), + session_ttl_seconds=int(os.environ.get("CPA_USAGE_PORTAL_SESSION_TTL_SECONDS", str(24 * 60 * 60))), + cookie_secure=_bool_env("CPA_USAGE_PORTAL_COOKIE_SECURE", True), + poll_seconds=float(os.environ.get("CPA_USAGE_PORTAL_POLL_SECONDS", "3")), + ) diff --git a/cpa_usage_portal/cpamp.py b/cpa_usage_portal/cpamp.py new file mode 100644 index 0000000..a334e8b --- /dev/null +++ b/cpa_usage_portal/cpamp.py @@ -0,0 +1,89 @@ +"""CPAMP monitoring API client.""" +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import Any + +import httpx + + +@dataclass(frozen=True) +class AnalyticsWindow: + from_ms: int + to_ms: int + + +def now_ms() -> int: + return int(time.time() * 1000) + + +def range_window(range_name: str) -> AnalyticsWindow: + current = now_ms() + if range_name == "7d": + delta = 7 * 24 * 60 * 60 * 1000 + else: + delta = 24 * 60 * 60 * 1000 + return AnalyticsWindow(from_ms=current - delta, to_ms=current) + + +class CPAMPClient: + def __init__(self, base_url: str, admin_key: str, client: httpx.AsyncClient) -> None: + self.base_url = base_url.rstrip("/") + self.admin_key = admin_key.strip() + self.client = client + + def _headers(self) -> dict[str, str]: + return {"Authorization": f"Bearer {self.admin_key}"} + + async def health(self) -> dict[str, Any]: + resp = await self.client.get(f"{self.base_url}/health", headers=self._headers(), timeout=5.0) + resp.raise_for_status() + data = resp.json() + return data if isinstance(data, dict) else {"ok": True} + + async def analytics( + self, + *, + api_key_hash: str, + window: AnalyticsWindow, + include_events: bool = False, + event_limit: int = 100, + before_ms: int | None = None, + before_id: int | None = None, + ) -> dict[str, Any]: + include: dict[str, Any] = { + "summary": True, + "timeline": True, + "model_share": True, + "api_key_stats": True, + "granularity": "hour", + } + if include_events: + page: dict[str, Any] = {"limit": max(1, min(int(event_limit), 200))} + if before_ms is not None: + page["before_ms"] = int(before_ms) + if before_id is not None: + page["before_id"] = int(before_id) + include["events_page"] = page + + body = { + "from_ms": window.from_ms, + "to_ms": window.to_ms, + "now_ms": now_ms(), + "time_zone": "Asia/Shanghai", + "filters": { + "api_key_hashes": [api_key_hash], + "include_failed": True, + }, + "include": include, + } + resp = await self.client.post( + f"{self.base_url}/v0/management/monitoring/analytics", + headers=self._headers(), + json=body, + timeout=15.0, + ) + resp.raise_for_status() + data = resp.json() + return data if isinstance(data, dict) else {} diff --git a/cpa_usage_portal/key_policy.py b/cpa_usage_portal/key_policy.py new file mode 100644 index 0000000..cdc4b80 --- /dev/null +++ b/cpa_usage_portal/key_policy.py @@ -0,0 +1,185 @@ +"""Read-only projection of CPA Key Policy state.""" +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from .security import hash_preview, key_policy_hash, normalize_key_hash + + +def _as_list(value: Any) -> list[Any]: + if isinstance(value, list): + return value + if isinstance(value, tuple): + return list(value) + if isinstance(value, str) and value.strip(): + return [item.strip() for item in value.split(",") if item.strip()] + return [] + + +def _first(raw: dict[str, Any], *names: str) -> Any: + for name in names: + if name in raw: + return raw[name] + return None + + +def _float_or_none(value: Any) -> float | None: + if value is None or value == "": + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _int_or_none(value: Any) -> int | None: + if value is None or value == "": + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +@dataclass(frozen=True) +class KeyRecord: + key_hash: str + name: str + enabled: bool + policy_id: str | None = None + rpm: int | None = None + models: list[str] = field(default_factory=list) + daily_limit_usd: float | None = None + weekly_limit_usd: float | None = None + daily_usage_usd: float | None = None + weekly_usage_usd: float | None = None + preview: str | None = None + raw: dict[str, Any] = field(default_factory=dict) + + @property + def raw_key_hash(self) -> str: + return normalize_key_hash(self.key_hash) + + @property + def cpamp_hash(self) -> str: + if self.policy_id: + # CPA Key Policy authenticates requests as key.ID. CPA Manager Plus + # then hashes that principal, not the original cpa_... key. + return hashlib.sha256(self.policy_id.strip().encode("utf-8")).hexdigest() + return self.raw_key_hash + + def safe_dict(self) -> dict[str, Any]: + return { + "id": self.policy_id or self.name or hash_preview(self.raw_key_hash), + "name": self.name, + "enabled": self.enabled, + "preview": self.preview or hash_preview(self.raw_key_hash), + "rpm": self.rpm, + "models": list(self.models), + "limits": { + "daily_usd": self.daily_limit_usd, + "weekly_usd": self.weekly_limit_usd, + }, + "usage": { + "daily_usd": self.daily_usage_usd, + "weekly_usd": self.weekly_usage_usd, + }, + } + + +class KeyPolicyState: + def __init__(self, keys: list[KeyRecord], *, source: Path | None = None) -> None: + self.keys = keys + self.source = source + self._by_raw_hash = {item.raw_key_hash: item for item in keys} + self._by_cpamp_hash = {item.cpamp_hash: item for item in keys} + + @classmethod + def load(cls, path: str | Path) -> "KeyPolicyState": + source = Path(path) + data = json.loads(source.read_text(encoding="utf-8")) + keys = [_parse_key(item) for item in _extract_keys(data)] + return cls([key for key in keys if key is not None], source=source) + + def get(self, key_hash: str) -> KeyRecord | None: + normalized = normalize_key_hash(key_hash) + return self._by_raw_hash.get(normalized) or self._by_cpamp_hash.get(normalized) + + def get_by_raw_hash(self, key_hash: str) -> KeyRecord | None: + return self._by_raw_hash.get(normalize_key_hash(key_hash)) + + def get_by_cpamp_hash(self, key_hash: str) -> KeyRecord | None: + return self._by_cpamp_hash.get(normalize_key_hash(key_hash)) + + def enabled_keys(self) -> list[KeyRecord]: + return [key for key in self.keys if key.enabled] + + +def _extract_keys(data: Any) -> list[dict[str, Any]]: + if isinstance(data, list): + return [item for item in data if isinstance(item, dict)] + if not isinstance(data, dict): + return [] + for path in ( + ("keys",), + ("state", "keys"), + ("data", "keys"), + ("config", "keys"), + ): + current: Any = data + for part in path: + if not isinstance(current, dict): + current = None + break + current = current.get(part) + if isinstance(current, list): + return [item for item in current if isinstance(item, dict)] + return [] + + +def _parse_key(raw: dict[str, Any]) -> KeyRecord | None: + raw_hash = _first(raw, "key_hash", "keyHash", "hash", "api_key_hash", "apiKeyHash") + if not isinstance(raw_hash, str) or not raw_hash.strip(): + return None + try: + normalized = normalize_key_hash(raw_hash) + except ValueError: + return None + + disabled = bool(_first(raw, "disabled", "is_disabled", "isDisabled") or False) + enabled_raw = _first(raw, "enabled", "is_enabled", "isEnabled") + enabled = bool(enabled_raw) if enabled_raw is not None else not disabled + models = _as_list(_first(raw, "models", "allowed_models", "allowedModels", "model_allowlist", "modelAllowlist", "aliases")) + name = str(_first(raw, "name", "label", "alias", "description") or "").strip() + if not name: + name = hash_preview(normalized) + raw_id = _first(raw, "id", "key_id", "keyId") + policy_id = str(raw_id).strip() if raw_id is not None else None + + return KeyRecord( + key_hash=key_policy_hash(normalized), + name=name, + enabled=enabled and not disabled, + policy_id=policy_id or None, + rpm=_int_or_none(_first(raw, "rpm", "rpm_limit", "rpmLimit", "rpm_per_minute", "rpmPerMinute")), + models=[str(item) for item in models], + daily_limit_usd=_float_or_none(_first(raw, "daily_limit_usd", "dailyLimitUsd")), + weekly_limit_usd=_float_or_none(_first(raw, "weekly_limit_usd", "weeklyLimitUsd")), + daily_usage_usd=_float_or_none(_first(raw, "daily_usage_usd", "dailyUsageUsd")), + weekly_usage_usd=_float_or_none(_first(raw, "weekly_usage_usd", "weeklyUsageUsd")), + preview=str(_first(raw, "preview", "key_preview", "keyPreview") or "").strip() or None, + raw=dict(raw), + ) + + +def has_model_prices(record: KeyRecord) -> bool: + prices = _first(record.raw, "model_prices", "modelPrices", "prices") + if isinstance(prices, dict) and prices: + return True + if isinstance(prices, list) and prices: + return True + return False diff --git a/cpa_usage_portal/redaction.py b/cpa_usage_portal/redaction.py new file mode 100644 index 0000000..169124e --- /dev/null +++ b/cpa_usage_portal/redaction.py @@ -0,0 +1,146 @@ +"""Safe projections for user-facing usage data.""" +from __future__ import annotations + +import re +from typing import Any + +from .security import hash_preview, normalize_key_hash + +SECRET_KEYWORDS = ( + "authorization", + "api_key", + "apikey", + "access_token", + "refresh_token", + "id_token", + "cookie", + "set-cookie", + "oauth", + "secret", + "encrypted_content", + "management_key", + "cpamp", +) + +_BEARER_RE = re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+") +_KEY_RE = re.compile( + r"(?i)\b(api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|cookie|secret)" + r"\s*[:=]\s*['\"]?[^'\"\s,;]+" +) + + +def redact(value: Any, *, key: str = "") -> Any: + key_l = key.lower() + if any(part in key_l for part in SECRET_KEYWORDS): + return "[REDACTED]" + if isinstance(value, str): + text = _BEARER_RE.sub("Bearer [REDACTED]", value) + return _KEY_RE.sub(lambda m: f"{m.group(1)}=[REDACTED]", text) + if isinstance(value, dict): + return {str(k): redact(v, key=str(k)) for k, v in value.items()} + if isinstance(value, list): + return [redact(item) for item in value] + if isinstance(value, (int, float, bool)) or value is None: + return value + return str(value) + + +def safe_event(event: dict[str, Any], *, expected_hash: str) -> dict[str, Any] | None: + api_key_hash = str(event.get("api_key_hash") or "").strip() + try: + normalized = normalize_key_hash(api_key_hash) + expected = normalize_key_hash(expected_hash) + except ValueError: + return None + if normalized != expected: + return None + + failed = bool(event.get("failed")) + status_code = event.get("fail_status_code") + return { + "request_id": event.get("request_id") or "", + "event_hash": event.get("event_hash") or "", + "timestamp_ms": event.get("timestamp_ms") or 0, + "model": event.get("resolved_model") or event.get("model") or "", + "requested_model": event.get("model") or "", + "endpoint": event.get("endpoint") or event.get("path") or "", + "status": "failed" if failed else "success", + "failed": failed, + "status_code": status_code, + "latency_ms": event.get("latency_ms"), + "ttft_ms": event.get("ttft_ms"), + "input_tokens": event.get("input_tokens") or 0, + "output_tokens": event.get("output_tokens") or 0, + "cached_tokens": event.get("cached_tokens") or 0, + "cache_read_tokens": event.get("cache_read_tokens") or 0, + "cache_creation_tokens": event.get("cache_creation_tokens") or 0, + "reasoning_tokens": event.get("reasoning_tokens") or 0, + "total_tokens": event.get("total_tokens") or 0, + "cost": event.get("cost") or 0, + "service_tier": event.get("service_tier") or "", + "reasoning_effort": event.get("reasoning_effort") or "", + "api_key_preview": hash_preview(expected), + "failure": redact(event.get("fail_summary") or ""), + "quota": { + "used_percent": event.get("header_quota_used_percent"), + "recover_at_ms": event.get("header_quota_recover_at_ms"), + "plan": event.get("header_quota_plan_type") or "", + "error_kind": event.get("header_error_kind") or "", + "error_code": event.get("header_error_code") or "", + }, + } + + +def safe_events(events: list[dict[str, Any]], *, expected_hash: str) -> list[dict[str, Any]]: + projected: list[dict[str, Any]] = [] + for event in events: + if isinstance(event, dict): + safe = safe_event(event, expected_hash=expected_hash) + if safe is not None: + projected.append(safe) + return projected + + +_SAFE_STAT_FIELDS = { + "calls", + "requests", + "total_calls", + "success_calls", + "failure_calls", + "success_rate", + "input_tokens", + "output_tokens", + "cached_tokens", + "cache_read_tokens", + "cache_creation_tokens", + "reasoning_tokens", + "total_tokens", + "cost", + "total_cost", + "latency_ms", + "avg_latency_ms", + "ttft_ms", + "avg_ttft_ms", +} + + +def safe_api_key_stats(stats: list[dict[str, Any]], *, expected_hash: str) -> list[dict[str, Any]]: + try: + expected = normalize_key_hash(expected_hash) + except ValueError: + return [] + projected: list[dict[str, Any]] = [] + for stat in stats: + if not isinstance(stat, dict): + continue + row_hash = str(stat.get("api_key_hash") or "").strip() + if row_hash: + try: + if normalize_key_hash(row_hash) != expected: + continue + except ValueError: + continue + safe = {key: stat.get(key) for key in _SAFE_STAT_FIELDS if key in stat} + safe["api_key_preview"] = hash_preview(expected) + projected.append(safe) + return projected diff --git a/cpa_usage_portal/retention.py b/cpa_usage_portal/retention.py new file mode 100644 index 0000000..07357de --- /dev/null +++ b/cpa_usage_portal/retention.py @@ -0,0 +1,61 @@ +"""Small, explicit retention helper for CPAMP SQLite history.""" +from __future__ import annotations + +import sqlite3 +import time +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class RetentionResult: + deleted: int + cutoff_ms: int + batches: int + + +def _has_required_usage_table(conn: sqlite3.Connection) -> bool: + row = conn.execute( + "select name from sqlite_schema where type = 'table' and name = 'usage_events'" + ).fetchone() + if row is None: + return False + columns = {item[1] for item in conn.execute("pragma table_info(usage_events)").fetchall()} + return {"id", "timestamp_ms"}.issubset(columns) + + +def enforce_usage_retention( + db_path: str | Path, + *, + keep_days: int = 7, + batch_size: int = 500, + now_ms_value: int | None = None, +) -> RetentionResult: + keep_ms = max(1, int(keep_days)) * 24 * 60 * 60 * 1000 + cutoff_ms = (now_ms_value if now_ms_value is not None else int(time.time() * 1000)) - keep_ms + deleted = 0 + batches = 0 + conn = sqlite3.connect(str(db_path)) + try: + conn.execute("pragma busy_timeout = 5000") + if not _has_required_usage_table(conn): + return RetentionResult(0, cutoff_ms, 0) + while True: + rows = conn.execute( + "select id from usage_events where timestamp_ms < ? order by timestamp_ms limit ?", + (cutoff_ms, max(1, int(batch_size))), + ).fetchall() + if not rows: + break + ids = [row[0] for row in rows] + placeholders = ",".join("?" for _ in ids) + conn.execute(f"delete from usage_events where id in ({placeholders})", ids) + conn.commit() + deleted += len(ids) + batches += 1 + if len(ids) < batch_size: + break + conn.execute("pragma wal_checkpoint(TRUNCATE)").fetchall() + finally: + conn.close() + return RetentionResult(deleted, cutoff_ms, batches) diff --git a/cpa_usage_portal/security.py b/cpa_usage_portal/security.py new file mode 100644 index 0000000..656ba03 --- /dev/null +++ b/cpa_usage_portal/security.py @@ -0,0 +1,81 @@ +"""Hashing and signed-session helpers for the CPA usage portal.""" +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import time +from typing import Any + +HASH_PREFIX = "sha256:" + + +def sha256_hex(value: str) -> str: + return hashlib.sha256(value.strip().encode("utf-8")).hexdigest() + + +def normalize_key_hash(value: str) -> str: + text = (value or "").strip().lower() + if text.startswith(HASH_PREFIX): + text = text[len(HASH_PREFIX):] + if len(text) != 64 or any(ch not in "0123456789abcdef" for ch in text): + raise ValueError("invalid sha256 key hash") + return text + + +def key_policy_hash(hex_hash: str) -> str: + return f"{HASH_PREFIX}{normalize_key_hash(hex_hash)}" + + +def hash_preview(hex_hash: str) -> str: + normalized = normalize_key_hash(hex_hash) + return f"{normalized[:8]}...{normalized[-4:]}" + + +def _b64(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _unb64(data: str) -> bytes: + pad = "=" * (-len(data) % 4) + return base64.urlsafe_b64decode(data + pad) + + +def sign_session(payload: dict[str, Any], secret: str, *, ttl_seconds: int) -> str: + now = int(time.time()) + safe_payload = { + **payload, + "iat": now, + "exp": now + max(1, int(ttl_seconds)), + } + encoded = _b64(json.dumps(safe_payload, sort_keys=True, separators=(",", ":")).encode("utf-8")) + signature = hmac.new(secret.encode("utf-8"), encoded.encode("ascii"), hashlib.sha256).digest() + return f"{encoded}.{_b64(signature)}" + + +def verify_session(token: str, secret: str) -> dict[str, Any] | None: + try: + encoded, signature = token.split(".", 1) + except ValueError: + return None + want = hmac.new(secret.encode("utf-8"), encoded.encode("ascii"), hashlib.sha256).digest() + try: + got = _unb64(signature) + except Exception: + return None + if not hmac.compare_digest(got, want): + return None + try: + payload = json.loads(_unb64(encoded)) + except Exception: + return None + if not isinstance(payload, dict): + return None + if int(payload.get("exp") or 0) < int(time.time()): + return None + try: + payload["key_hash"] = normalize_key_hash(str(payload.get("key_hash") or "")) + except ValueError: + return None + return payload diff --git a/cpa_usage_portal/static/dashboard.html b/cpa_usage_portal/static/dashboard.html new file mode 100644 index 0000000..8582eb6 --- /dev/null +++ b/cpa_usage_portal/static/dashboard.html @@ -0,0 +1,411 @@ + + + + + + CPA 用量自助页 + + + +
+
+
+

CPA 用量自助页

+

用自己的 API Key 登录,只查看自己的限额、用量和最近请求。

+
+
+ 实时未连接 + +
+
+ + + + +
+ + + + diff --git a/deploy/cpa-usage-portal/Dockerfile b/deploy/cpa-usage-portal/Dockerfile new file mode 100644 index 0000000..b5b58bd --- /dev/null +++ b/deploy/cpa-usage-portal/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim + +WORKDIR /app +RUN pip install --no-cache-dir "httpx>=0.27" "starlette>=0.37" "uvicorn>=0.30" + +COPY cpa_usage_portal ./cpa_usage_portal +COPY run_usage_portal.py ./run_usage_portal.py + +CMD ["python", "run_usage_portal.py"] diff --git a/deploy/cpa-usage-portal/docker-compose.example.yaml b/deploy/cpa-usage-portal/docker-compose.example.yaml new file mode 100644 index 0000000..8a2db79 --- /dev/null +++ b/deploy/cpa-usage-portal/docker-compose.example.yaml @@ -0,0 +1,23 @@ +services: + cpa-usage-portal: + build: + context: ../.. + dockerfile: deploy/cpa-usage-portal/Dockerfile + container_name: cpa-usage-portal + restart: unless-stopped + networks: + - cpa_net + environment: + CPA_USAGE_PORTAL_KEY_POLICY_STATE: /data/plugin-state/cpa-key-policy-state.json + CPA_USAGE_PORTAL_CPAMP_URL: http://cpamp:18317 + CPA_USAGE_PORTAL_CPAMP_ADMIN_KEY_FILE: /run/secrets/cpamp_admin_key + CPA_USAGE_PORTAL_SESSION_SECRET_FILE: /run/secrets/session_secret + CPA_USAGE_PORTAL_COOKIE_SECURE: "true" + volumes: + - /opt/codex-stacks/cpa/plugin-state:/data/plugin-state:ro + - ./secrets/cpamp_admin_key:/run/secrets/cpamp_admin_key:ro + - ./secrets/session_secret:/run/secrets/session_secret:ro + +networks: + cpa_net: + external: true diff --git a/run_usage_portal.py b/run_usage_portal.py new file mode 100644 index 0000000..c8745ba --- /dev/null +++ b/run_usage_portal.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +"""Run the CPA usage self-service portal.""" +from __future__ import annotations + +import uvicorn + +from cpa_usage_portal import create_app, load_config_from_env + + +def main() -> None: + cfg = load_config_from_env() + uvicorn.run(create_app(cfg), host=cfg.host, port=cfg.port) + + +if __name__ == "__main__": + main() diff --git a/tests/test_cpa_usage_portal.py b/tests/test_cpa_usage_portal.py new file mode 100644 index 0000000..967edcf --- /dev/null +++ b/tests/test_cpa_usage_portal.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +"""Offline tests for the CPA usage self-service portal.""" +from __future__ import annotations + +import json +import sqlite3 +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +from starlette.testclient import TestClient + +from cpa_usage_portal.app import create_app +from cpa_usage_portal.budget import suggest_equal_budget +from cpa_usage_portal.config import PortalConfig +from cpa_usage_portal.key_policy import KeyPolicyState +from cpa_usage_portal.redaction import redact, safe_event +from cpa_usage_portal.retention import enforce_usage_retention +from cpa_usage_portal.security import ( + hash_preview, + key_policy_hash, + normalize_key_hash, + sha256_hex, + sign_session, + verify_session, +) + + +_RESULTS: list[tuple[str, bool, str]] = [] + + +def check(name: str, cond: bool, detail: str = "") -> None: + _RESULTS.append((name, bool(cond), detail)) + + +class FakeCPAMP: + def __init__(self, expected_hash: str, other_hash: str) -> None: + self.expected_hash = expected_hash + self.other_hash = other_hash + self.seen_hashes: list[str] = [] + + async def health(self): + return {"ok": True} + + async def analytics(self, *, api_key_hash, window, include_events=False, + event_limit=100, before_ms=None, before_id=None): + self.seen_hashes.append(api_key_hash) + data = { + "summary": { + "total_calls": 2, + "success_calls": 1, + "failure_calls": 1, + "success_rate": 0.5, + "total_tokens": 300, + "reasoning_tokens": 100, + "total_cost": 0.0123, + }, + "timeline": [], + "model_share": [{"model": "gpt-5.5", "calls": 2, "tokens": 300, "cost": 0.0123}], + "api_key_stats": [ + {"api_key_hash": api_key_hash, "calls": 2, "total_tokens": 300}, + {"api_key_hash": self.other_hash, "calls": 99, "total_tokens": 999}, + ], + } + if include_events: + data["events"] = { + "items": [ + { + "event_hash": "evt-a", + "request_id": "req-a", + "timestamp_ms": 1800000000000, + "api_key_hash": api_key_hash, + "model": "gpt-5.5", + "failed": False, + "total_tokens": 200, + "reasoning_tokens": 80, + "latency_ms": 1234, + "cost": 0.01, + }, + { + "event_hash": "evt-b", + "request_id": "req-b", + "timestamp_ms": 1800000001000, + "api_key_hash": self.other_hash, + "model": "gpt-5.5", + "failed": True, + "fail_summary": "Authorization: Bearer should-not-leak", + }, + ], + "has_more": False, + "total_count": 2, + } + return data + + +def write_state(path: Path, raw_key: str, disabled_key: str) -> tuple[str, str, str]: + key_hash = sha256_hex(raw_key) + cpamp_hash = sha256_hex("alice-key") + disabled_hash = sha256_hex(disabled_key) + path.write_text( + json.dumps( + { + "keys": [ + { + "id": "alice-key", + "key_hash": key_policy_hash(key_hash), + "name": "Alice", + "enabled": True, + "rpm": 12, + "models": ["gpt-5.5"], + "daily_limit_usd": 5, + "weekly_limit_usd": 30, + "daily_usage_usd": 0.5, + "model_prices": {"gpt-5.5": {"input": 1}}, + }, + { + "id": "disabled-key", + "key_hash": key_policy_hash(disabled_hash), + "name": "Disabled", + "enabled": False, + "model_prices": {"gpt-5.5": {"input": 1}}, + }, + ] + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + return key_hash, cpamp_hash, disabled_hash + + +def test_hash_and_session() -> None: + raw = " cpa_test_key " + hex_hash = sha256_hex(raw) + check("sha256 trims raw key", hex_hash == sha256_hex(raw.strip())) + check("normalize strips sha256 prefix", normalize_key_hash(key_policy_hash(hex_hash)) == hex_hash) + check("hash preview hides middle", hash_preview(hex_hash).startswith(hex_hash[:8])) + + token = sign_session({"key_hash": hex_hash, "key_name": "Alice"}, "secret", ttl_seconds=60) + payload = verify_session(token, "secret") + check("session verifies", payload is not None and payload.get("key_hash") == hex_hash) + check("session rejects wrong secret", verify_session(token, "other") is None) + + +def test_key_policy_and_budget(tmp: Path) -> None: + key_hash, cpamp_hash, _ = write_state(tmp / "state.json", "cpa_live", "cpa_disabled") + state = KeyPolicyState.load(tmp / "state.json") + record = state.get(key_hash) + check("key policy finds key", record is not None and record.name == "Alice") + check("key policy validates raw key hash", state.get_by_raw_hash(key_hash) is record) + check("key policy cpamp hash uses policy id", + record is not None and record.cpamp_hash == cpamp_hash) + check("key policy finds cpamp hash", state.get_by_cpamp_hash(cpamp_hash) is record) + check("key policy safe dict hides full hash", + record is not None and key_hash not in json.dumps(record.safe_dict())) + + suggestion = suggest_equal_budget(state.enabled_keys(), total_daily_usd=10, total_weekly_usd=70) + check("budget enabled count", suggestion.enabled_key_count == 1) + check("budget daily assigned", suggestion.per_key_daily_usd == 10) + check("budget patch uses policy hash", suggestion.patches[0]["key_hash"].startswith("sha256:")) + + bad_state = KeyPolicyState([record.__class__(**{**record.__dict__, "raw": {}})]) + try: + suggest_equal_budget(bad_state.enabled_keys(), total_daily_usd=1) + blocked = False + except ValueError: + blocked = True + check("budget blocks missing prices", blocked) + + +def test_redaction_and_safe_event() -> None: + expected = sha256_hex("cpa_live") + other = sha256_hex("cpa_other") + redacted = redact("Authorization: Bearer abc123 and api_key=secret") + check("redact bearer", "abc123" not in redacted and "[REDACTED]" in redacted, redacted) + safe = safe_event( + { + "api_key_hash": expected, + "event_hash": "evt", + "fail_summary": "access_token=secret", + "failed": True, + }, + expected_hash=expected, + ) + check("safe event accepts own hash", safe is not None) + check("safe event redacts failure", safe is not None and "secret" not in safe.get("failure", "")) + check("safe event rejects other hash", + safe_event({"api_key_hash": other}, expected_hash=expected) is None) + + +def test_retention(tmp: Path) -> None: + db = tmp / "usage.sqlite" + conn = sqlite3.connect(db) + conn.execute("create table usage_events (id integer primary key autoincrement, timestamp_ms integer not null)") + conn.execute("insert into usage_events(timestamp_ms) values (?)", (1_000,)) + conn.execute("insert into usage_events(timestamp_ms) values (?)", (10_000_000_000,)) + conn.commit() + conn.close() + result = enforce_usage_retention(db, keep_days=7, batch_size=1, now_ms_value=10_000_000_000) + verify = sqlite3.connect(db) + try: + count = verify.execute("select count(*) from usage_events").fetchone()[0] + finally: + verify.close() + check("retention deletes old rows", result.deleted == 1 and count == 1, str(result)) + + +def test_app_routes(tmp: Path) -> None: + key_hash, cpamp_hash, other_raw_hash = write_state(tmp / "state.json", "cpa_live", "cpa_disabled") + other_hash = sha256_hex("other-policy-id") + cfg = PortalConfig( + key_policy_state_path=str(tmp / "state.json"), + cpamp_admin_key="cpamp_test", + session_secret="session_secret", + cookie_secure=False, + ) + with TestClient(create_app(cfg)) as client: + fake = FakeCPAMP(cpamp_hash, other_hash) + client.app.state.cpamp = fake + + health = client.get("/healthz") + check("portal healthz ok", health.status_code == 200 and health.json().get("ok") is True) + + bad = client.post("/api/session", json={"api_key": "nope"}) + check("portal rejects unknown key", bad.status_code == 401) + + login = client.post("/api/session", json={"api_key": "cpa_live"}) + check("portal login ok", login.status_code == 200, login.text) + cookie = login.headers.get("set-cookie", "") + check("portal cookie httponly", "HttpOnly" in cookie, cookie) + check("portal cookie does not contain raw key", "cpa_live" not in cookie, cookie) + + me = client.get("/api/me") + check("portal me ok", me.status_code == 200 and me.json()["me"]["name"] == "Alice", me.text) + usage = client.get("/api/usage?range=24h") + usage_body = usage.json() + check("portal usage ok", usage.status_code == 200 and usage_body["summary"]["total_calls"] == 2) + check("portal usage stat hides cpamp hash", cpamp_hash not in json.dumps(usage_body), str(usage_body)) + check("portal usage stats reject other hash", + len(usage_body["api_key_stats"]) == 1 and usage_body["api_key_stats"][0]["calls"] == 2, + str(usage_body)) + events = client.get("/api/events?limit=100") + body = events.json() + check("portal filters events to own key", len(body["events"]) == 1, str(body)) + check("portal never returns full raw api hash", key_hash not in json.dumps(body), str(body)) + check("portal does not use disabled raw hash", other_raw_hash not in json.dumps(body), str(body)) + check("portal cpamp filter used policy-id hash", + fake.seen_hashes and all(h == cpamp_hash for h in fake.seen_hashes)) + + +def main() -> None: + test_hash_and_session() + with tempfile.TemporaryDirectory() as d: + test_key_policy_and_budget(Path(d)) + test_redaction_and_safe_event() + with tempfile.TemporaryDirectory() as d: + test_retention(Path(d)) + with tempfile.TemporaryDirectory() as d: + test_app_routes(Path(d)) + + passed = sum(1 for _, ok, _ in _RESULTS if ok) + for name, ok, detail in _RESULTS: + mark = "PASS" if ok else "FAIL" + line = f"[{mark}] {name}" + if not ok and detail: + line += f" -- {detail}" + print(line) + print(f"\n{passed}/{len(_RESULTS)} checks passed") + sys.exit(0 if passed == len(_RESULTS) else 1) + + +if __name__ == "__main__": + main() From 61911b03e80a2b470d378f00daff741347df2a63 Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Wed, 1 Jul 2026 20:54:21 +0800 Subject: [PATCH 15/68] docs: capture CPA key management closeout --- .../backend/codex-continuation-contracts.md | 45 +++++++++++++++++++ .../implement.md | 24 ++++++++-- .../07-01-cpa-key-management-portal/task.json | 4 +- README_zh.md | 34 ++++++++++++++ 4 files changed, 102 insertions(+), 5 deletions(-) diff --git a/.trellis/spec/backend/codex-continuation-contracts.md b/.trellis/spec/backend/codex-continuation-contracts.md index 1200b8f..3ebb507 100644 --- a/.trellis/spec/backend/codex-continuation-contracts.md +++ b/.trellis/spec/backend/codex-continuation-contracts.md @@ -166,9 +166,28 @@ This spreads the event contract into JavaScript and makes the beginner-facing st ### 3. Contracts - CPA stays on the official image. Do not fork CPA to implement per-key usage views. +- CPAMP stays on the official `seakee/cpa-manager-plus:latest` image. Do not + patch CPAMP source for user self-service behavior; add sidecars or routes + around it instead. +- CPA Key Policy stays as the official release plugin binary mounted into CPA. + Updating Key Policy should mean replacing the plugin binary and preserving + `plugin-state`, not editing CPA source. +- Keep CPA, CPAMP, CodexCont, and `cpa-usage-portal` as separate containers / + stacks on the shared `cpa_net`. Do not bundle them into one image because + independent updates are part of the maintenance contract. - The user portal is a read-only sidecar. It may read Key Policy state and query CPAMP monitoring, but it must not mutate CPA, OAuth accounts, proxy routing, or Key Policy records in v1. +- Ordinary users should receive Key Policy `cpa_...` keys. CPA native `sk...` + keys are compatibility/admin escape hatches and should not be treated as + self-service user credentials. +- There is no automatic one-to-one binding between native CPA `sk...` keys and + Key Policy `cpa_...` keys. If a future migration needs such a bridge, design + an explicit mapping layer and prove it cannot bypass Key Policy limits. +- The CPAMP login key and the CPA management key are different secrets. + `cpa-admin.konbakuyomu.us/management.html` currently points to CPAMP, so it + requires the CPAMP admin key. CPA-native management calls use the CPA + management key through the internal admin proxy path. - Login validation uses the raw user key only once: `sha256(trimmed_raw_cpa_key)` must match Key Policy `key_hash` (`sha256:`). The raw key must not be stored, logged, or returned. @@ -193,9 +212,13 @@ This spreads the event contract into JavaScript and makes the beginner-facing st ### 4. Validation & Error Matrix - Unknown raw API key -> `401 invalid_api_key`. +- Native CPA `sk...` key submitted to the user portal -> `401 invalid_api_key` + unless it has been explicitly migrated into Key Policy; this is expected. - Disabled Key Policy record -> `403 api_key_disabled` at login or `401 key_not_available` for an existing session. - Missing or invalid session -> `401 not_authenticated`. +- CPA management key submitted to CPAMP UI -> reject as an invalid admin key; + use the CPAMP admin key for CPAMP. - CPAMP rows whose `api_key_hash` does not match the current key's CPAMP usage hash -> drop them server-side. - `GET /api/usage` must not include the full raw-key hash or full policy-id @@ -208,12 +231,17 @@ This spreads the event contract into JavaScript and makes the beginner-facing st - Good: A user logs in with a `cpa_...` key; the portal validates `sha256(raw key)` against Key Policy state, then queries CPAMP with `sha256(key.id)` and shows only that key's events. +- Good: CPA and CPAMP are updated by pulling their official images while the + custom user portal is rebuilt separately from this repository. - Base: A Key Policy key has no events yet. The portal still shows safe key metadata and empty usage tables. - Bad: The portal filters CPAMP with `sha256(raw key)` and shows zero events even though CPAMP has usage records for the key id. - Bad: `/api/usage` returns CPAMP `api_key_stats` unchanged and leaks a full `api_key_hash` to the browser. +- Bad: Ordinary users log into CPAMP or create native `sk...` keys for + themselves. That expands the admin trust boundary and bypasses the intended + Key Policy user model. ### 6. Tests Required - Unit: raw key hash validates login while `record.cpamp_hash` equals @@ -228,3 +256,20 @@ This spreads the event contract into JavaScript and makes the beginner-facing st - Production smoke: allowed Key Policy model succeeds, disallowed model is rejected, CPAMP records real usage, the portal login succeeds, and `/api/events` returns only own events. + +### 7. Wrong vs Correct + +#### Wrong +```text +user -> native sk... key -> user portal / CPAMP admin +``` + +Native CPA keys are not the quota-managed user identity in this deployment. + +#### Correct +```text +admin -> Key Policy creates cpa_... key -> user uses cpa_... for Codex and usage portal +``` + +The `cpa_...` key is both the request credential and the self-service usage +credential, while CPAMP remains admin-only. diff --git a/.trellis/tasks/07-01-cpa-key-management-portal/implement.md b/.trellis/tasks/07-01-cpa-key-management-portal/implement.md index f37d6a0..add0703 100644 --- a/.trellis/tasks/07-01-cpa-key-management-portal/implement.md +++ b/.trellis/tasks/07-01-cpa-key-management-portal/implement.md @@ -115,9 +115,27 @@ ## Remaining Operational Note -- `getent hosts cpa-usage.konbakuyomu.us` returned no records during final - verification. The service and Caddy route are ready; public access needs the - Cloudflare DNS record `cpa-usage.konbakuyomu.us -> 38.59.246.182`. +- `cpa-usage.konbakuyomu.us` DNS was later added by the user. Server-side + public health verification returned `200`. - RPM/daily-limit enforcement was not stress-tested with many live requests to avoid unnecessary spend. Model allowlist rejection and normal Key Policy authentication were verified. + +## Closeout Decisions + +- Ordinary users should use Key Policy `cpa_...` keys for both Codex requests + and the user usage portal. Native CPA `sk...` keys are compatibility/admin + escape hatches, not the self-service user identity. +- The user portal intentionally rejects native `sk...` keys with + `invalid_api_key` unless they are explicitly migrated into Key Policy. +- `https://cpa-admin.konbakuyomu.us/management.html` points to CPAMP, so its + login key is the CPAMP admin key. CPA management key is a separate secret for + CPA-native management calls. +- There is no one-to-one binding between native `sk...` keys and Key Policy + `cpa_...` keys in the current design. Adding such a bridge would need a + separate mapping layer and bypass-risk review. +- CPA, CPAMP, and CPA Key Policy were not source-modified. They are deployed as + official image/plugin artifacts plus config, volumes, and Caddy routing. +- `cpa-usage-portal` and CodexCont are the custom-maintained sidecars. All + production components are separate containers/stacks so CPA/CPAMP/Key Policy + can be updated independently from custom code. diff --git a/.trellis/tasks/07-01-cpa-key-management-portal/task.json b/.trellis/tasks/07-01-cpa-key-management-portal/task.json index 3f8c0e0..bec53b8 100644 --- a/.trellis/tasks/07-01-cpa-key-management-portal/task.json +++ b/.trellis/tasks/07-01-cpa-key-management-portal/task.json @@ -21,6 +21,6 @@ "children": [], "parent": null, "relatedFiles": [], - "notes": "", + "notes": "Completed: CPA/CPAMP/Key Policy remain official artifacts; custom code is split into CodexCont and cpa-usage-portal sidecars. Ordinary users use Key Policy cpa_... keys for Codex and self-service usage; native sk... keys are admin/compatibility only.", "meta": {} -} \ No newline at end of file +} diff --git a/README_zh.md b/README_zh.md index b9a2fd4..4433161 100644 --- a/README_zh.md +++ b/README_zh.md @@ -179,6 +179,40 @@ Key Policy 有两个容易混淆的标识: 因此门户登录时用原始 key hash 找到 Key Policy 记录,查询 CPAMP 时改用该记录 `id` 的 hash。所有 CPAMP 查询都会强制带当前登录 key 对应的 `api_key_hash` 过滤。 +### Key 体系 + +生产上推荐把普通用户统一迁移到 Key Policy 生成的 `cpa_...` key: + +```text +Codex Base URL: https://cpa.konbakuyomu.us/v1 +Codex API Key: cpa_... +用量自助页: https://cpa-usage.konbakuyomu.us/ +``` + +CPA 原生 `api-keys` 里的 `sk...` key 只建议保留为管理员兼容/救急入口,不作为普通用户分发体系。它不天然带 Key Policy 的用户身份、模型 allowlist、RPM、每日/每周限额,也不作为用量自助页的登录凭据。 + +管理面板也有两类密钥: + +- CPAMP 管理面板登录使用 CPAMP admin key。 +- CPA 原生 management API 使用 CPA management key。 + +当前 `https://cpa-admin.konbakuyomu.us/management.html` 指向 CPAMP,所以登录它需要 CPAMP admin key,而不是 CPA management key。 + +### 维护边界 + +CPA、CPAMP、CPA Key Policy 都保持官方来源,不在本仓库二开: + +- CPA:官方镜像 `eceasy/cli-proxy-api:latest`。 +- CPAMP:官方镜像 `seakee/cpa-manager-plus:latest`。 +- CPA Key Policy:官方 release 的 `cpa-key-policy.so`,挂载到 CPA 插件目录。 + +本仓库自定义维护的只有: + +- `CodexCont`:负责 516/518n-2 续写保护。 +- `cpa_usage_portal`:普通用户用量自助页。 + +服务器上它们是独立 stack / 独立容器,后续可以分别更新 CPA、CPAMP、Key Policy、CodexCont 和用户自助页。 + ## 什么时候会执行续写折叠 只有同时满足以下条件时,中间件才会执行折叠逻辑: From a92963f46b03e895b5f25f69cffaa63f44839034 Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Wed, 1 Jul 2026 20:54:38 +0800 Subject: [PATCH 16/68] chore(task): archive 07-01-cpa-key-management-portal --- .../2026-07}/07-01-cpa-key-management-portal/check.jsonl | 0 .../2026-07}/07-01-cpa-key-management-portal/design.md | 0 .../07-01-cpa-key-management-portal/implement.jsonl | 0 .../2026-07}/07-01-cpa-key-management-portal/implement.md | 0 .../2026-07}/07-01-cpa-key-management-portal/prd.md | 0 .../2026-07}/07-01-cpa-key-management-portal/task.json | 6 +++--- 6 files changed, 3 insertions(+), 3 deletions(-) rename .trellis/tasks/{ => archive/2026-07}/07-01-cpa-key-management-portal/check.jsonl (100%) rename .trellis/tasks/{ => archive/2026-07}/07-01-cpa-key-management-portal/design.md (100%) rename .trellis/tasks/{ => archive/2026-07}/07-01-cpa-key-management-portal/implement.jsonl (100%) rename .trellis/tasks/{ => archive/2026-07}/07-01-cpa-key-management-portal/implement.md (100%) rename .trellis/tasks/{ => archive/2026-07}/07-01-cpa-key-management-portal/prd.md (100%) rename .trellis/tasks/{ => archive/2026-07}/07-01-cpa-key-management-portal/task.json (92%) diff --git a/.trellis/tasks/07-01-cpa-key-management-portal/check.jsonl b/.trellis/tasks/archive/2026-07/07-01-cpa-key-management-portal/check.jsonl similarity index 100% rename from .trellis/tasks/07-01-cpa-key-management-portal/check.jsonl rename to .trellis/tasks/archive/2026-07/07-01-cpa-key-management-portal/check.jsonl diff --git a/.trellis/tasks/07-01-cpa-key-management-portal/design.md b/.trellis/tasks/archive/2026-07/07-01-cpa-key-management-portal/design.md similarity index 100% rename from .trellis/tasks/07-01-cpa-key-management-portal/design.md rename to .trellis/tasks/archive/2026-07/07-01-cpa-key-management-portal/design.md diff --git a/.trellis/tasks/07-01-cpa-key-management-portal/implement.jsonl b/.trellis/tasks/archive/2026-07/07-01-cpa-key-management-portal/implement.jsonl similarity index 100% rename from .trellis/tasks/07-01-cpa-key-management-portal/implement.jsonl rename to .trellis/tasks/archive/2026-07/07-01-cpa-key-management-portal/implement.jsonl diff --git a/.trellis/tasks/07-01-cpa-key-management-portal/implement.md b/.trellis/tasks/archive/2026-07/07-01-cpa-key-management-portal/implement.md similarity index 100% rename from .trellis/tasks/07-01-cpa-key-management-portal/implement.md rename to .trellis/tasks/archive/2026-07/07-01-cpa-key-management-portal/implement.md diff --git a/.trellis/tasks/07-01-cpa-key-management-portal/prd.md b/.trellis/tasks/archive/2026-07/07-01-cpa-key-management-portal/prd.md similarity index 100% rename from .trellis/tasks/07-01-cpa-key-management-portal/prd.md rename to .trellis/tasks/archive/2026-07/07-01-cpa-key-management-portal/prd.md diff --git a/.trellis/tasks/07-01-cpa-key-management-portal/task.json b/.trellis/tasks/archive/2026-07/07-01-cpa-key-management-portal/task.json similarity index 92% rename from .trellis/tasks/07-01-cpa-key-management-portal/task.json rename to .trellis/tasks/archive/2026-07/07-01-cpa-key-management-portal/task.json index bec53b8..6111b00 100644 --- a/.trellis/tasks/07-01-cpa-key-management-portal/task.json +++ b/.trellis/tasks/archive/2026-07/07-01-cpa-key-management-portal/task.json @@ -3,7 +3,7 @@ "name": "cpa-key-management-portal", "title": "CPA key management and usage portal", "description": "", - "status": "in_progress", + "status": "completed", "dev_type": null, "scope": null, "package": null, @@ -11,7 +11,7 @@ "creator": "dxt98", "assignee": "dxt98", "createdAt": "2026-07-01", - "completedAt": null, + "completedAt": "2026-07-01", "branch": null, "base_branch": "main", "worktree_path": null, @@ -23,4 +23,4 @@ "relatedFiles": [], "notes": "Completed: CPA/CPAMP/Key Policy remain official artifacts; custom code is split into CodexCont and cpa-usage-portal sidecars. Ordinary users use Key Policy cpa_... keys for Codex and self-service usage; native sk... keys are admin/compatibility only.", "meta": {} -} +} \ No newline at end of file From 254bf50b94770a1309f86fd773bc5ea447c1770e Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Wed, 1 Jul 2026 20:55:06 +0800 Subject: [PATCH 17/68] chore: record journal --- .trellis/workspace/dxt98/index.md | 5 ++-- .trellis/workspace/dxt98/journal-1.md | 34 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/.trellis/workspace/dxt98/index.md b/.trellis/workspace/dxt98/index.md index 2868cd4..c72fc4c 100644 --- a/.trellis/workspace/dxt98/index.md +++ b/.trellis/workspace/dxt98/index.md @@ -8,7 +8,7 @@ - **Active File**: `journal-1.md` -- **Total Sessions**: 2 +- **Total Sessions**: 3 - **Last Active**: 2026-07-01 @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~76 | Active | +| `journal-1.md` | ~110 | Active | --- @@ -29,6 +29,7 @@ | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 3 | 2026-07-01 | CPA key management and usage portal | `b20a983`, `61911b0` | `main` | | 2 | 2026-07-01 | CodexCont status dashboard | `82e54b8`, `5b578e1`, `42c1b14` | `main` | | 1 | 2026-07-01 | SJC CPA migration closeout | `98bf0df`, `aa113c2` | `main` | diff --git a/.trellis/workspace/dxt98/journal-1.md b/.trellis/workspace/dxt98/journal-1.md index 6da60b7..5125820 100644 --- a/.trellis/workspace/dxt98/journal-1.md +++ b/.trellis/workspace/dxt98/journal-1.md @@ -74,3 +74,37 @@ Built and deployed the CodexCont admin dashboard, then upgraded it to a Chinese ### Next Steps - None - task complete + + +## Session 3: CPA key management and usage portal + +**Date**: 2026-07-01 +**Task**: CPA key management and usage portal +**Branch**: `main` + +### Summary + +Deployed CPAMP, CPA Key Policy, and a separate user usage portal; clarified cpa_ versus sk key model, official component maintenance boundaries, hash contracts, and archived the completed task. + +### Main Changes + +(Add details) + +### Git Commits + +| Hash | Message | +|------|---------| +| `b20a983` | (see git log) | +| `61911b0` | (see git log) | + +### Testing + +- [OK] (Add test results) + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete From 5737e3b3a07270d4412046d896e5b93982bf1c25 Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Wed, 1 Jul 2026 21:39:29 +0800 Subject: [PATCH 18/68] feat: refresh custom CPA dashboards --- .../artifacts/codexcont-desktop.png | Bin 0 -> 141661 bytes .../artifacts/codexcont-mobile.png | Bin 0 -> 107410 bytes .../artifacts/usage-desktop.png | Bin 0 -> 143609 bytes .../artifacts/usage-mobile.png | Bin 0 -> 110875 bytes .../check.jsonl | 2 + .../07-01-dashboard-visual-refresh/design.md | 74 ++ .../implement.jsonl | 3 + .../implement.md | 122 +++ .../07-01-dashboard-visual-refresh/prd.md | 58 ++ .../07-01-dashboard-visual-refresh/task.json | 26 + cpa_usage_portal/redaction.py | 71 +- cpa_usage_portal/static/dashboard.html | 791 ++++++++++++------ middleware/dashboard.html | 640 ++++++++------ tests/test_cpa_usage_portal.py | 43 + 14 files changed, 1325 insertions(+), 505 deletions(-) create mode 100644 .trellis/tasks/07-01-dashboard-visual-refresh/artifacts/codexcont-desktop.png create mode 100644 .trellis/tasks/07-01-dashboard-visual-refresh/artifacts/codexcont-mobile.png create mode 100644 .trellis/tasks/07-01-dashboard-visual-refresh/artifacts/usage-desktop.png create mode 100644 .trellis/tasks/07-01-dashboard-visual-refresh/artifacts/usage-mobile.png create mode 100644 .trellis/tasks/07-01-dashboard-visual-refresh/check.jsonl create mode 100644 .trellis/tasks/07-01-dashboard-visual-refresh/design.md create mode 100644 .trellis/tasks/07-01-dashboard-visual-refresh/implement.jsonl create mode 100644 .trellis/tasks/07-01-dashboard-visual-refresh/implement.md create mode 100644 .trellis/tasks/07-01-dashboard-visual-refresh/prd.md create mode 100644 .trellis/tasks/07-01-dashboard-visual-refresh/task.json diff --git a/.trellis/tasks/07-01-dashboard-visual-refresh/artifacts/codexcont-desktop.png b/.trellis/tasks/07-01-dashboard-visual-refresh/artifacts/codexcont-desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..70d8975dc43a093728c6fae3536f2903476e63aa GIT binary patch literal 141661 zcmXtk9-GNE9kT9k=!0zTI4 z0Mn3U02*c({m?qSr8@N&S?r1Sa`NLVC?Dm|`DMi?#V2BiZB`%GHrz++_R7kR%65wi zAMXY~j};{JV0I(hmvo2!9=lNwE5*8DJY;_yfxxe7&Oc(kSjGAA&C%hQ2gv;~OtF`S z%>npdDTIIi!qG^5*sT;NOCG3&>01$_?@wmZpcDBj_L75EWUx}3%cMS!{0cI@gdm{Y zc7XgtA~~K|S?GPZ_VJZWfzUarg4Vu2F%&@nbxd+B17}QaI+h)ERJr8i%etJbAk;9$ zCBYdh5J+x>7L1*C%+r66dCqhy!EFqR4LK5h)yt|fX`bjMy9wgix;#A>_?xYp0%S74NxE2Mv74>bzdli+`#9aN>$AvCJxDd z(f(1^@C2=3r0+8ztr36G8pAu zth|cDqI0fUzQ1dfDEfSrNYOaR4=IFev_pGKEKFcaIsrun%!odF3zj&uhSFZTB$V@^ z_p-vJ;S?ou5=`2fLO7x81iL5H-KZ6L*71=IjyYYsmO^r>+9%|JfW9?&io=K;_?Un$c+M)h>QN%k z3Qm~;w^Ab4`K`pL2re!A5K@syY%mqD;9UJc;9A_z5ISM~c8U4tvv)WC9 z4!q$}l_HC8h`Iv3$$89w3JFZ!`hMVDf2e;Hs$kABqPrp-DXMzps0O64o;A(%7q-h= zU7R}tO_h)*GR4fkTG`kSqQYQQC%ZU*LLJRA!#G{C)iu0HT+N%k4%1?w*%FvKrzpA+ zr&J@!Snn8$4Y{WT6wp^AC!7~9A!+EwV@Wn@w34cUZ9-f9wcNWy&1g>MOT5o$Uis@t zqzQ=>Ow_8_RIO2H-bV4Q$GKo4Ye>FCL<~roa*d2v5)cWk|MWlz+LRMR%(&_+#)SCqa|OP(Qd!)vi9T2S4lHP$G1qVVSh7qtfh7vd-!(Z_!=A9Q zg{M`W=`NbJ3w#i~(AdnJ>?n*`paVIz;do#gkWjo*Om3G`z(ajoL;R|V$t9keZK|Zo zARjx#=sd5Z{L`J(i8GFJea1)&P>4%^Wa7Jigj&Rge)1~yyM-uS8b`xIx=cTVS3(kg z_*ju9e^|mKrgXYQ`>hdwj>VXB2fzoDcq+#E?D=O^#h7_Y+rH}PH@t%ma{M`ePAe}~ zc_A=%Uph)#Z&043Nvp}$SPNs5$R2AcLB32H3X0g6Qy1lMH#$69tm92OpQP+gY-T;m zdvS#}>S9LxP1L}mRZ{{^A%50?>vu_iB>LvZtM=Mldb4ZCoLPwr@m)&1S7!1BZRkwu zzyXdH{CyGzaeDFx9~T>}{5%Y+YVsy(D<6r!X&^&BQqgs9gR`6t>i0;r?#xqZm+?Ct zRL1-=Y8{fAvN9qZ$?-klXvc8oc{oWM$v~#cIFy<(kc556Uxz%o&VA<#efz942WYJq zgE55%+YSnKK6-J+sa2BM^?p6Sg?`BTEwC)yQnD=pf>g+0JT|EhoTeS{!~hOFe$s zzF~@z$p!$QeJN9<@djCIp`o|ujcADUz+kjp(^DJR@7ga-gw-8LV|a6k0)L@Y-xZip zN4uLf?tkXlBDL@V8O(LmXepi)w5T=H=iiJ(vJ}A!ykXPt0+86k1N};x_uPxDEW}Kw zuUo<2^B*}C{Gy>7&7fCqhlsa-hH{OPNiFJZhLL!?CD|?BG7&V`#jzQ!mPvLU!aZgi zuFyT7dP#Vazf>I~P1Lr<0KUB`xQ`FPYhp3SS;@C;Wrn1Ikk6Pqx|hTA@~Wy^wW3I2 zt(XtHe-mj1#dj6GhzSUDilk+KZw%QMY{>f{hJ?`05;zeImU?^6a^BR|Oo@|or}>45 zz`1*90{n_!GKJfG@CP}X#rSvGHI>1eK=-6)Hw2_%PdC`K4QAG`)5M=_)Kn`cSF|%1 z#_juhZ_>rDX&G?_1qYe6hacAnSe9CTYZmbEa7h1BPh9~-w03BE#zEXFRz@*aXrmna zvjBjdC$|@JXs!#k7XLA^#t0bL`pC6YZDC7}U6@^N=89ExQ_1JT}sjv3Sl zLu`-5EoWI4^^;e|wl}z-4hhJArxX#yhM?Vxk6@SPM7)uMqJcy6{l#A0e`uO7QEb**^ zwiHh8MHLL)8yGl&s#E(j<><96nw*xds9}VA7ZFVciUkx?>4KMgFf?h*41^6W2ggnb zrNNis`)?$4r|#8r5K*B=vHRo~i5Mn{r+-jsu&H8|g+|3MJ7lG>7LXM7(_&lz9wCci zzr=5l<5x7(J%=O_Ctzjr%X&%%a=Lq6m5mSq4RVJ_v zVlQfhMUj%y&2?n#YYDC&QlXQXO439qUu4 z)(0Y(%StBUjsB=etlY5uG%0YKd1#8CzKA)8-DksvIHch9qMQ2&72XP8-n(RyDlI

ypnS~O2qo!NS@9cXuMP{uS($W`ABiD-iM>kPBz2|z3{NbM!ipK30CJ4O`aOVr zi*%3w>vhkhs!B18Rq_nL>(y6W(r3s_fr)JY$MhzGa0~+>?E=g>k|u@kL%6Nxim&(= zPw#Mrg%&t!Jx*SsEbrJ>5{`>gfLeZ5)8q~{2%TQbu5=|3MN^)OCUOodLVJh4bq z5hX+b5bQm~&Dyut#Ujh0WY>iWB2ji;+#s}y>lcp*6I2O>9qeK(Y0Ayvki|bF3mL@K zvvtzyzd~0u43~NUTToj%^NMje#t!N?2aENo(~8GSaHL%u!OynD!;&!Oj+d2EsoARv z7hS7_%Rg~(l^OGrH8S4~A^mQb({8W$lUoKY+aC31a^n~p-ZSP*EMuJlam4R=xu0>k zIJNRos5#e6;Uf+ev00tcV5X=GM=YnpY*l`44&UfK^cZHKLTAJo-E^`p-&oQ6v&7(@ zuLy`Pq$g(NSoirp%w|OJ##>vFE4h+y@(K8``cU6J?iw!)Qf*q5WeE}PJUj|8#*sZ`e zDNK4btU!g7TyZ`1>R-74MG#olNF;;G*5D$1#;8^-3eYwwVOl)O^fJQs(m)j#@zq^@ z;(=~IoGaNyZVue@oEQSg3+u=)K~R-JzNvn8u5?m&A^?aHi{$zl~UG3y6rq~ zV{OA~QP^){+Uql?L=;MnKlFB6G3WEU3vtPSDN@ZTc?qChe!}?GSods{9=O|l$#&_8?hsSdZ^odsMmeyfg=px zy#@8)AIjq8c^Z_^BF1MM{`4eMS7nO=-%0Ny+a1s^DmP3tEpg{3M8ekn!fjHZy$4Ne0E8NI!^ z^?rMUdCRS3`m$IGI(9R|ERpfA8pH68?(VUDNp@q$MS*8#xb&ZR$+?MNJ3gPVRnjwq z4bc64?!0sPJ#qb4v_wzFere}7ft!!pIUjxqZq!2IVv-8F#b`Uk8?)e%KCO{bZk26R z=tGk{gVWl?5189&x5T*@`PK#RqBdVJ7z|17YQ%+W$K-Co{tGolQQhc#!i45m6HCen z7`xL=EddSFP#Z}P35RFrzW!jmAa%{r)BXNqtyr zmZQaMi4EV&FuO0W*BBU(M!LKba-(A`(&O<>awYqkE#9nB+nQ|R@xgA3lVe*&2#EV3 z{^;u282FZ<#jX{+ZoQ)N$9lJ#TMR6e{q&$Ugi;7x2=6uw1h~i$7SmtKjyIvA5@_DM zh!da*2_@}Okt-qF!o#BpkKbp7q`g`Xxllji<%^ZPOcBwq`eNkZg_6u|V!4(@@v_-m z5;K{0Zy=uuBCWm5-p<7kNKX9S*@OmQ#+7=_5YhG4YR!=85Jfvf-xIPaco_Ds$;9@{ zx7~FzcMpX@}<%q-2P4kEngY|nmD5rz*re6CQ3w!pOBqmB7f0--khdn|@A!EJ+BNN2< zwBGh;x`&(uEfx=&m!s%5P2SKLDw|0wy%3Jnf;W!im$pm!b5I&v$rjC`Nvc&(NlT4M zgp)K#vmlttYwYtXH}~kK&83{UP>{$ICnuho2T)caHnLn)%Myu`D%7;3A97^YQ$`qG zRYW%O3j>b>tRZC0;&Az?4*XT0tU^{XZ^EX}Qz#)?-Goiu{c zr~I+nrSR>v;R8(9+Jhe&qOx8V8VJx1kJ$PIry>QC& zbKuH3fziCsx^VQ?irGY3jMLR*BE1n~n<~ZbXtAM2-4A#8(`qa9WBEF7FAGPQt#u2g zp7;ui@|9iuQM&Je3m-dO1gu;5@UZtO0AdGV6tD-8l;^x4Q^m|MLQk&&7uy3ff~yP~ zx6*M-@zS*I`h8B!Q9Ivz7y*5R{jv;g?ye1gWIH}Fu{Q4yMF!3SMM*#_ z>z_3-Yl%-TNCJ3e+(f5mVi;q@z=>mfKp#+)zjQEzSx5I~1I7MV?_bltV_HWgR|E7l zyFxw>XzDlQw}R`_t<34BJ0XSnsk_Y61e{(@0Mu5Y&1H}4F^sfYEiCckcCWx9)W1ON z%+OclbQdzk#2p8bF2^4ul>8NkTJydS`^mHW7kSg5|5yfI{BJV9UKGKkK(#a@lF5Z8 zfC3c+TWe~`3OcffU+KnlVh!i_s072Fm7kO!d*FkS7Jis~1znppK5<4~E1O84s;tWr z*A%--uWzR5ePy`e~ly|W0>c?z%jWL zSv7KwnS~^jkm(w{)uXz0RCH`JL1YcYvJyRZbAF&K@wN=2kNgw?g-lm`5(8rVjZBjr zhw@S*1HZ6xKo_SdU0~gm?gT_>7q?<6|AxoMUu`HN^mb#Vt~$Bnfkg!RLM5m~jOAGG zifO9)QgL7XWOc~O{Qzy=Sjwz1Eg?G~{6CY5cGkZ~g9Udu4X2VWUXwDc>-3W1cbsZ0 zi?L}_rab|%mQ+=>)?WAgKSzuwVYYct1ec%qKp3fX>M&MP1arZfK`4c-cS&0yDxsvEJg8GrDN_wc7SV(ot$5^%>3e(C?5#iLn$fiB0TeKUisMHi%VzrT2{=Ef34mVh(Bx}85GcG9(U{qdGe zYr^q#y#fiMjcDhvuX)X_eQu7vrZE0vU0BU7$lYd@6#Q6KSc+_8MDecy%&-!jhjTC%S_idfML>W}{#kU3|$x9!urmp5}Bxu5)_uDN)K z(MPb0kR(C!WF%AkHd}*U`IBk9t|4qQd^b1Tduqn`a6l6<)!)gtBW>L<&!*F>mnfp^R>qo_LOoV1&m+?M z=5~Ah`jp|Ni7JjS+Hl0Q>R>Ql!Z7hD3?M{eb$~cA-c9nl4~NQ? zwtS+lBJ&Hr>m{kA?EVk0!yPI1eXGEFaw>}}FjS;c9z1en6Y+?UKiuV|NzX_)axT3U zdM8L~^|^9Qh5m8wsX1&rM7G`e(f6$w{$fDkphd*;a^P6!zog8H{_Zn38)1jN20 z>DvwzYUm_n48Ftbdtr^IXb!xdLR1aZn6_VuO% zTU>Hp*f^LgGQexg2S_r{`2_aZ>FSJRA@Oop9J>>vGi1g4Q#;Vm!BZ#_aGmiX=a3n~ z>rof(*l8-=cPC~~c5P?yxfR_)1l;Y-0_{3{np|H%4;bpBb$(8-i!FU`6iG`Ed|BD} zAb-a1yvIA>daqg9)p(lThxn)w5a9?tADuG-0yE!@4Bx!xkUuIX4Sd8~g*5mgyVep0 z>``ZFT~}kz!_!=_>elR?Kvefh+aBFhJtRHnnEt&!a5o*qh+rl6K`ZZ6l=O$;z(r8% zCCt09xGX=L=n7vY(hUfsapStse|9Bm!7$pdf%j(c7sgK7xFPz!k8|Msm!F{d#ph0v z_rIiq`A`xaRzoNUZ>G-&u>7DXHH8KZ>H{X89fZ$EX(EUB2g~aNgr5!rPv|Dc_Ku&F zT)ZxPT^7>~xzHZSnzaAs(CN(PB>kZA&z~XJT?DI%<;Z0Ch{9e4Muy2{_d^nko6DNP zBmwVSpbScx0p3oRNYU7cmE{2tO!>TiDX}qM~`+0(>3ZM%F60`ifAKo zDAzkZi7<{+c=xIO|7oRmIP!sf2C>+8u~@+@n!c}0gR3yfGvvIP16q?}y{9bna8emz zrVkQ94?$4Kc15&^^$$|`kBUy3kv|(`3WevyJtswIL&Q|LGv*8{nw|khoKf;hH9DA+ zk07kNdrQQ`n8{*UzCv9S>}W_sWLVV3+^tF#G7ap{>|N9}1%F-nHr?nL_;C+oC+ zvZI-ws;qQXDDkQLc!T10(z*3}HeSIn!PH=AjU@IZEe`FV)C$B&Qt@BO zi@frjU3rvD$|gP#vv{$Am^S#9{#L0&NwiT|miUMqJXju@3em~Tws^9$$ioD+l90!xbS+gI+8#Xy7aHOB7fB$9hD@PXQC#y+fHmj|!vHScvNqMCL8`-`5X! zDYP)O$%n;)?;atmCvk(atX){=)g#7oyv$pP8e6&!Wb;lJ9+ zKQpTlUMv(HQNr?imu?cUcD1eH#bR2ndVTcu`L9L2>zA2gYM7mbbh2eLc@Z5XYQZ=Y zM14sL8LyEh{%{cinDE2?dd8=AzD*?T0*eEM4fO8#N4d}|#SF^3EVqBzvk{bkx1|v3 zIYAf-3%T+qBtmn^@7xf--8j=KAmv-CPK+6J4zg~HbqcvDZMY9)s7%^T8&O`_bNaO& zxg%22JI#*FO19E7&^zFvi~C>}O2Obda<&IC9@7jNgDQ%v8AgUQK=Lj-Uws5an{=Li zDb@#{*pPRz=9zwMSOfX)1y`K31>V8;Cx76wy!O0@f!^R_#*lpo2ysktax(G@1nj-= zAoB1kehX{j15Dodt~=QsH;;_zmCOgCm;sZqltfi*Pc+yFXzohLxSvL4I()8Fnh?yx zChy@`u0;HYq;8M|IV9Bl{_mhDhg$TpEx*e>E{6#hZ34`+m^4x$|WX^q65pTw8~Z{{kQlE&Nd-&Czh&47El~C z4g1o-+V8O-0kkPt=P_Jnl6wrqGIgB`B!miwDlKQbdS#V323(?lFZHGP=Pz?qkpwb4RP*=(Ua zePam!=nJ1f^kFySY==4G8D0Hn@56{z%O?R^JmBD$I<@v^|M4jJoU=Q~dcORhx*Gb| zifAnDz`116WzX7PstYMP?Fw%lqxDB7XWI?EqXdod1Ze&0;*14*RMgXxK>K)P03XN3 zF_U}g4QC;vOz(mY2g$D>2KgC8%tL^5isxa^p^)#3M%{M!K2PO^{0$AAE?-0!u`2D# zk6>#;`nNLRZsD4OX;4Bgsk|W-Y%pfIlHIw;op1y%KCT^=AmBN?G=81RZKZo?p9d|6 zJL{lVv9%EVVKcFXJHLoqL#u>|bidyog|GF4QBvt}9I%2a3P|%u23eg5qG|*{%tDeH zQOp)YyTg9lmg8styjx3L7ok6;*jVmFk#bC1!$<+8oNBk9e_D|t{24t+)6>kD{_$lIw<{_GR9oQGfd+W`OrT>x;Z;ezy z=S1i36#c6t%-X7fYmJnx|K|m;wci`-hLqNtMiR?YKAK4K1vXi!k;~PgGv^KzVqksu z4rYM-^{{Y`H3R8{(lmA&gOHJSL5*6PXp}wh2V!Qe*5MgerUh-TQNx}G>*QG7j;Ty^ zt~zQeT|IVBm)xrs_G|XR31g6&>m35Pi~lQbS=-73c*`A~r{KVK7(z+Fhs#e=e#_$v z461A7bmDLQ_l~Jh#|QGm8+1 z*HFNmK`vqW$*f#qM&p_LE}zn(qCL{mu9#?rFJHc>L1i@juOt3HX8q4y`8P=HMd560 z~&{iLic%UOJk~Sf#E@0oZe$@WOEI_vcc+TnlUe&h~qQr)QRRhaljSdP`sfwrt_>|7kSPGL$We5 zva+6PbR?w?T+IJhzE}1xLk3$Q-~Y}`8WP<6{dEF?D&e~2*@O7;E%NV7ci{ZHn7W9y zk}?yyA(!@k!%TYMBmfA?S;#EzV(PPFaiy1COkfBLnkvVvD3}9CW?xCLBOv4I5htAU z)VTDc+FywIdPPUjk>B7@%M>vfZuVk)y+RMM-FD2%ZdtJZ|82Iqu7dA1{_Ezi{~~rK z*5T0~9F_x_y$xeo{R1hNkY5GuXxp2`Rp&%wi7sW*9<>Wl)aH?>t#gr!YTeBKBCqC@ zpyXy##r>}h-rENXb*$10Ug0$rB6uZ$%HAG~lO_8c$?`p`o9-sTnp6G=_m6Gd;(>Xy zjtvv+bN7{vxg&pDSV<#S0cQ?qCh=)OCDGd*RD*{DkaDrk&Ram$E079?oW{Q^>Pf7W zr{|_Sq7!BnwCW@kwkslngT>3*Mz=8kX{vuK!`~MZMh~DDasQgXQ!^u^`NJ?lIq#l# zT8F;wVf9*{+mFIvyBHiMYpJ3qkD-XjgneEHV))xHe&;`MYcjWHZRVYd55Fk6w8vZm z!F#a-N0x|}LXIA<7C2o7{7Ox}=jl)#=z0ZLMk9BN>bZXSS0$m(4nO6pfeU@)caCN6^OR z+4!l>h=Mng)LrRRuYZ`Lws+Q=1`-*UpOyMzvQ^o3vejZNrjZ)B9oa_LyLVLzzRqI4 zD#-*o_W`1BcOAW%^Lq=ScsMV#ErWV@#{J#)<16$0tgo;fYL(M4Sf3Z)_ah({x}PE# zmIaH)i2aWE%3_K6T@gBc?o;))I1mB5ukZnl0XnKscu4tz9%nR9JvS*TXP*xn%j91| ziStUUcM>^k-==Q1AQoeOZrr-so*74w!Pa(l5;JQ@!t#yTL725NN&h|`Tag5a8Q;p& z1xfBP+oFx^N|P^S@1|veV5o7jMmU&VwIcCFTVlSVgR(Bm6T>J1-Y7+ zSgM+uk+3fxM7lr(h7IGq4P746znEz|a1fK3==#AR*2+duA~nwfo?Z^zLJamWXm zu$fHZ2%3L0$3-ACv4bonEe$J%;RUYs&~L!~@xq??#J+pKu=2giJ~@ivq)YW%ZsM`Z zKIGmB15n#b#R2;W>v>oPJhqMNz)r2+_z_8-(^c&-CxtxbZ zpj3P+pZ>8BF~Ou9b^42svW^n`LpFrms_-b}t(*6rPyEtp1(7b^8Jd_W+hXGs4!|fA zw+<{qfFrq?+|;MV)<7m%1HweVqMaEgv#>Hkt+XFZseNB#-7+bx3&Tf&a)6;i%oAA?Wwactaq69i+o1O~J4rDyoZdvfwI*g)QG^Gp^H+|-%`9iw%q^+3r7ee%C+ek# zH7pR|>8b1K^{1X`Lc^gfQPQYeClt z6X|5Q)u$P(@N{t%gNS6v;e1cZ1!Vr0p%@9Y_f-lkhdl12*{16W|BqbwsVr`XF*S*C z;cl0OR5*shn%wPHkW9kG?Lixr4=$R}%a|eXeW;Wz9^Yf{R-zYXPy%hfe%F< zIrbTLF)JOrXOB!3Q#t;bj#IW}(7A32a>K1D?UXX5 z6CLbfarr6A4{&`Yv&eHXk_yyY_*?b4BlhCVpQG|=hZe@C9-rkoHcCg#XZ>8mjf5Jx z7V6x~`}A8aI()EpBitC(R1@fR_AHu5l>fv34qX~k1L*Nmy+Ag%4k=TkPhfP8J_L79c_R1%AsWg=j-JcuA3;f5)g}D5*JLr&npg+Cg z>p0&8qQYtr0A7xTaf^W`7^K7I@xT8okw^Jf{~d%PY_ED&_4Ga|{yv0ll!E0NDkf&eZ#?ZHIM15a2})r*K1n8Eo(P5}F!L!In(4Maeh)KFiYOXm3- zsX&+62>x9#Vv!hrw?_!>=zv@Y&q?Xd7Rv4X^mt^E@pey{e*5LE0M@zlN!;|7_ZfO~ zS%LduOv3i($OhfEfsp6JES)0Yd_m~moi60g{riIQ7Z<=?#Qd~rHiI=~rQ=mnf4RJ# zh=C_w!QXczjMssRwbrvZSh6L#*ty+dV*bXmk&lP>(hL%B&j5V?6{%W39Gd3tbat)n z&zSF@_3iD^G_8jVYAy6p@$L3oc63SlM3YV2$^@%;`&B-NpRBD9Y?a*BhBuX756|Dw zysyVvO1W^a{Wdq~Bp4IFE3kFJ%X>evp6Oh;#Ef7N(FCjgbsj=_`U9);k4H1fCf>2e zldkl2h+oJfD968+${cmx%17WdOwIOEEDFVE&~(Ydz3jV9LbuIf{0VlD`bc!d$v-TNe8xCnr+#2xOh`Dji+wEI@=apZOK30x!?&sHX!4xK|W zV6M!X7~mnX5o>dg>*wOzFMy=0$)Zp+(YtKuUxa|m{0`*&y)m8tQzYQ6bh|Aek+;Vy zOL|Rdmt9ib_$Jm=x}jANBbHpmy_^zH8Cdq8Qy) z<@?&x)1ADF_x`%|CjwNq&RAjC={*)9;(10_?2U&)=y6$mFZ}_Et|UdW|9unBh9WkL zDiMBF&~ZaSlGL)mWzPcT?plr%hY~lb%4SeE%Y_%Ap%Tl#k7v{?PMG)`{R7VSIafQV zsqH<`vrne?1|sPO@@vI2LfF4NC+m}tQlO%wLs9Fiy2xRSw29)_KsVO>cludiB=k!u zmUSVtXn}>^a<=-)O~V}6I|GNZF&qxz<9kafZ&=H zI$JhR{W(WNuiWE=OTIZj#A`Q)Md9JJx`<UTBkiUW5h!-ruh1fG1JC(vIi7wGxbKUTsmD{%WP84i%ILnZ#NPP$KQ92Y*xuKD z>W_rw*kOVqz|H&vTlhXGY##M@nRJ!!&!Ijs5k6x{scYs{pelIF&;jt)yj%i<9j^F( z(iLT|ewa=*?7;F zuzv~WG;v!v0#LW$Sk`rQd3bpIzO|GFINb*Md0($q9_tbD_>p+O2_l;LXgsXAxr#Z} zhS!Q%t+qpoyRGI0>N)OfBI^;g*xCL7HWX+9^8`LsoCas?KK(kp#$TzSqF<4RTK8W& zDh*KMn5T9kTeZ{yI(*cWp#zdA^UXTDHSi`P#Q|60TIH@+;q4*NT*vk>_uq0>Z9XM+ zNt{n+K4Pu4UzaxXiRMxGkGtW^$cl2j)YLUO-UpaI1z)p06x$9y82k9%hv))a?$AG9 z8f|BKho4V5=C8}okG9?CR8J|I%Y8>$V;pjMpUd`Zz4oKXL|prb=22PcXh>i7twIdC zt6zg-l!?XX?ev{L4_o~FM{4iN>)=BYvc05}RVQW^q@V$wmid@pu>H_Tz?>$UTDu~SLp~k-xbUr++|Jxbr4_EUsOw%1k zT-AMqz>{8hQjuhaW~Z0G4!%d`RsMJ&zLz4GZkLDNH{?^cT--J7rh}9QiRnD2lRw*j z7kFAZulsRmQ~WlP0lX*kE;A!hc$2?8)%J%L6}CMO9x6$(_^dNNUKSX7fNA?=fWB#h zm%X$U<*uR6xC8yV{Gr`CI(<-RL(!YX< z70Iu9)TbYw@ib0!fu2m+-R*tu>{PrUDnCulnVx+*^3j;mzkVLL0)U?Ff!$;R+#I7W zu|>ym@hlSJAQ#?+&d^_{Kq~p7X;Sk&3g68&k6!f*F}!V80yNwOR{!S=k*>qGEjmN5 zOBLEU=7=H^R3VF3Ikb0A)|wpmRT$FE@|T??qMCNQ+fT)gM+=L1=40{lMm1p8R51~^ z!{_eYFOOwnuJfhycrsme!G~5d1J6mKryi>BS3dw*0v7p ziL8|IcM|x@FNRLzE`H))LSUp&DzAV?Wi9?ZXG@0q9#G(g^PQxWp*|c7vUdbcpsP=H zdCZS1*x^_NTLDoAVdwsAgRJwTdI*{E_AsqUPth@^17!ZpYdv~_=ew44!`0TC(?dn& z>)HAA04|h?z@4N&bcr6?Q~sj97MAx^z}A=+P%cvZ@x<5X*9w9bS^k8!j}xcjQcLWG z8sDQ9-cI3@xl=0Hwm0S1MME_Gn@TJoujv{}#>aJUh-YeHd8bH~KlFl5PjQ5@rwyV{ zFZFo6(xgZ1>P4G!;ItNtzv{awZbQ}k^dd%VbK8~B?J%nP9el}nT^uI4G zIjs=RD@zY!u{+h~cl?oTWgRs@D2j<{*jETinZx@fad(Mc6dnPIga8x7pgjB6Ni&z1 zxG)e`@8lwo(g}a97d55goZ&z}x|GG$@#Yn;5~k$*JL6ECJ+bcW^<`W(4>vcwk~C9~ z(I@>2SRyi=Bi$e}DPle6G)=GB-qM!8(@^B8jA&mrGeeR$oVfGy{z;F}`mRzXdrpZ+ z#Adbm&v0TrYgy6|fO5_>LJ}H^jn;!Xr<>%*%m9sVRyg~+k5OuG>L~j&*V6m~McuQ4 ztOPBU{}+yfl%~)jlWe%(9Xy>PMwtskDnCaQwhcyqemkKG;+Z6niND$U!Us-27KVR5 zIn}H2G{+gA`y|>HUI!ohY~xR1+Ueh&5VQz=hs~evxO^B+=aNs}9UvqA!7!KO_oSy$ zlS5EjRg#k@F_a@QFZdqg^Blv118>Naav5v+zLUgsrAwl@qF#Ib=Zn@kcDj@X@NRzf zUUNU;$l#Vo1-fe-B)1`~0Mw-Hl?b?>fWPiM{9q}34)N%oRGFkFa{JX6a2~d;*nV}} zlJ55~0gW|)ir$b^^7|(WHB3cM0phZ-ckf99`e$A5mJa@^*jo;4!xWOm)<-lzu<=M_ zdQ$i;_Et*>l)i2*^TYYm$81<@a&P$OXO!z+suNai>v3t?MQnoGwV>0owW{7wSxj_{ z_Z*N?Cr)%MrAA2ED;K`OTxxI+oV5jPzu!{>$ArfTrud>9FCf0vf%z9d{a;yBA6gnq z_pM(0iKc~+o$$%2xyXeG`@ATe3~0il!l{FqUGaOOP6wpJ*OB&R6_hR3arvRCdBa0# zXT5V|^>ru^%;WJDSX|8zR>ZFS_zPN3Mm}(^9V%8ARwi6vYyp&^@ZlJ`gBVXZ07)OB zYp0P&JqZU}Yj2P5lNey8*Leq=q2JS>ij%fC-WLB`2XB9)k4LNT?WO+T!5Ioaz)0R2 zl4Th_4OH`^=Tk0H!}N8@;l5H*IRanC?hJRB7{9f$J+_CfbYlp6^!M+Ya`)xzkc}!d znso4evtI0YVi$F=W>X?$%}c4&+PaL=?=sOxL8j0n8hgD)y#WgiP`zfQd3cvP*q3{d z`7p@2z7LUq_{&XW;Q2mm!OAn3Lq|0Hs3gmLd2XefcEfv#d7anCZ@S)aTHLG*;kK^s zYiJz$>6AWPMj1Dw17~KrC=yVA)js6L1SI{M+6?){mtHa$0_aiOcgA9mAZx<2RHFyJ zZD28n|7$pFsOI!po8x+38{pcPXAUWZ_(RYH4K%5BHz%_iG@KVh`Vo?P7N_@l_w72V zGB4NVDTVB%4KDvab{gtX{Qfs>7Jlcyb*L7$ldN2JrZ-E;rZHShAFP4hjPq&Mcjblsm&H`OgFI8GB z%pwV3yRXiMnati&yNfVCbmbGNlzU`tinRF!Lcr83A|BA9uRrDQTzKoQRs660*q*;VHMHVaJ=En4w zRpL%%>j~X0+m;P2*G>nxE0MXI-<<5rFy>?b(SNxU$%syrnv71^WzT5wKV16e&1iAi zx1NxB$IjQk_btolGNR>&Q`&pllH3z3K6mB;by9J^|JUPoP8k%|xk|s)m9EpY$C!+s z5oo4pkibz{T0kZXiSXSEc)TJ&AF2y(7i$_k-uBjEoSSt>hDX9%5rFZK?>L(;bM?7C z&ej$&AgtVem2j*~%u#MRsc@X|1|4r(dV=e6+7BWSFLyh)dDTk9)KVk}ASCu(=MM3z}h#X8#_6G-N$bVW{;$l7`EFKQfax58A4zbznj$df$JX|-^OEELv24cqH?5tYw zi`3Q2zk5&$F&vT59RNcEhavLqc}?Fdx}D0BN#7n=<34KSZ&7_ZTK`ZQ(QoU!y0D*#&o)mu}<3v_4)uQ3; z5^?8w8)-mB*Xw;Q>bWKxly;qTPTp$344-&pe)yqob3ENz`$3)`+J?n8bcCE|AGhLN z!MMF}O784TYuYJ~<;H_f#Jw|_UI}AWlE>;OmV55XKv^@5M7OFfj%obK&}DwUvLy z@Hl~}TXb~$`7RCi9)kp9Dm z{@;93yiS+rm&Vl?hu1BgV3(y-8@ze5JCaE1^)x=8+eT9;`J^A}kusiI=0UnjT1$4T zwN;9G&X2pJE0661uQpHwJ3oCa zP+kw3Ncyb`TvtQzpT6L>h)V@l6jHyr zQeKhg1escwQ=cu}sbp*t&!rH&1VYJf7Vpj6VHD%lifz9Q{eaq!?V?z7cki?&(N(VKZ4@v|WAV*-w00g!+LDf9umD+^m;tUCh%^1ooTpj()Hs6YaU<$BTH< z`y86NJ)Wz%yM6jpM%p)xljU+8&MrEedC#c)Kc?O~s;#Gc-^MAf1&V8ND_Y!&7I!P! z;_eU#4#lmwdyBghT#LKAy9NvV($DjL*Lv5=f5|#IXJ*dK-uK-58ldy6v3!O8{WP93 zs6Od_Y}2f={fC-FE^jM&s@Kz{AXsI5U1?ZQ-jMh2yHXyR4LG?|r_v z@?z~Ua*wH^U*cK5tu@`za`7PPHBEW;m&-`E(YMz~>ol(^*i-Z5{W*m5ay9ktLg=x4j#k zXa#;Rv+wJ2oF(6T%=m{yz8w@ei3*HCp7T5d3eU4PqMlbQf{A%DXmc6E-ZBb5c$`QQ zKMp3EgG5Eta*~m*XT_||C%MSD^TJ+@?loRh@+O!c4(NGAz1nx^9>?QZm0ulh65CX- zzGLRSHSHbveD=WYfw;VuU_+jRP6sz>S-9+;Zwz+BJlFXKm_3=?k&x>iCR<3>k4Vu=ufIV}!wW!Jk1b+9A1XaiD!oOj9VSO#9)OK2(@ zoX*={z)I~ypjK--YVnYqb30igJHR=vJMfA2_}Vt>#SOnR?@yppOYX_*<3k0-nDE`5 z!ktr)~8z--CEmw0BkbsrGLXiay<6u?u@|oOz7vlO?%aKWy2; z%bXELZba2rwk5ZF{B0#MDh( z&q=?sLHc`(&ev;$O}zH);JQEG8{E8&Jh#r_H}k=d7b1_TXZrJFq>oCvVvWYtrD0f-G5UZ7 zr_Irp06<;!KBQ@Zg5!xYWzj5$s$n#Hsd^&L@4V=@f^O{|j@j8lbo4J_`OZbHst&zb z3zgA%w<$@tt1jW^=qJAk!_{q*lYRE0mnqf~SuC^ai(kr+LrR?{`I+^o zoAFyGtO#@24OYC8msi032L*h-vU=O4&G}NVo0R3UQyimI^oaa`Fzjj%0(wH&&LWeS zg_KW0gH5FBq2B;PF5qwD6f)b^##QbPd@?Yp`f7Nx5u#n>ei{-hn&vV2wDaoa|x6SMz#RWk(7_) zr?Po-2f&tXeFt*j()*Ge$IbPi(f)vxF$}0uoXqx3Vz0dO?UZZ&o7Y}!%nH!)i!P4v zRz6f127dzp?*9%pr^IuspEz_L)$WAe{-6jngpA{{_w?1JI$a9@{oZEP=fAd{_gs*- zqowSyJuj4hyFR^PHzVSP+`rw4^k4U#w%B_UDw9fLl7{3{(Z8B6U#z!R2sof33-tG8 z`n*nri9FO@kPDTvh?^2VDFa;RRqmZu2kxrY+=l1aa|AaOT;FDyLq&$&-EY@#4u5X= z?fe8vy1mbwT=Ds3F^iEXQ}Zy;+7AR#kKrxKePxF zcdJ~LFLemaKKmFOTNIkQtq(gF!k0a11__#1GgRGKcABcWs#LL2N9{gOeTS8jsR;t;*eeB%DxwAo-Al5nKaOD8x_pMeq-M^86cy^ak zsDEGY05dszi~SzW1O3u7HjIwR@qI1D&@A)T#^j`p%WJ7=thSXgBerLe6%x*{B8<4) zok^#cM2NecTft7+>X6C1xDWhh|9tDwOe$-TEOo#k5zpf68i)*E16#nNC<(U8l`

b1Wk)-5!4C*YMsY4Z5NPcGan5wtgyW`!hOJGS607V^)!V&4(KM< zaDM%`t7|g&bU`qt`5Q4=)O+qXuv5N8;5pfN(`|sIOm^eQus7PI==IpR$ctZnKBsM5P10j& z(6O0}#s*Pv3>r9DfC|zTv9H$C)$34o;cchIaVK$fFW$DRFbI6hXGGh?2K%KKi>woEk$f(fUnho|>~1laPXE@2e?GB9_$M>oxUms` zonaC~T9zP$FT@>w5=a|6kQn6GmyLdBo5xBi#6j!u7XEsN(8x%pNk?mCm3Jb(D5gI( zCGj=Kc8h*oD+gYpy87(KqwF@H1bsD!?dBR7wjdcc))Id{Ug{J?XrnArimoLr>c)tn zTrEtTZ~1MuIg%i!nYn0)4NDsR^Hpa5voLTaG`H9!y@&i@jVELMV@eia;nbnxi5;&Y z)zIMX1-7Zf(s{{_RVk-Og2RB7fl=~A6uzzvOmgST-rDUpO3?O~{Qx4A=y)S|xX=h8J6Xo+= zz2P{E7-Sar;0ezlmW@W&9?l~S9{BXb9eWecQVL5XWFPKz?5G*QDsV$rJL}fVD24r_ zI4Fz4cjb}5_u+$(RItRzB}Hstbg*M~I&FGX#FI^+vsuY}csIXEJ2e{Rd-CEMVB;4K zMby8%1=Y$${x4(r-seb71+W0Bl}B&9{g9h1;-_}CaD*L$`r77()b53a)z>ER63w>= z-$2@T_9F{FvYbYc2^Co~6nHIJ^g7PXaS#`qUmD)pOuGj@D?`jRhLI=Mu6|$^@v(Kh zT_vDRkS*1@Z*_p0K_j52M?>w68gNhwnI$) zdV)Udm$58IZY+bLLw_C zj%7ku&!t(`RfVi`IFiegYo4|o7~{l?VtO&lQqQy5>|<_6zyY?^pCPtOV6e*@75nx~ zEGZ{myl$d@5tKqy&8H40mP$OMFLHN$#8omTGTdQA&S(2B4lKV8iu|@TMi*mNmHgFE zJ`pTpyo>{#K(E%Yu-?{B2+3hC)`5#buPgHwil1o7qU;0RICsEu&Mu0 z0MUL3Z(PA5rBrz6>1;~-%tdSQkKGx&d))t!hcPH-gWyqgB2jeS_rdQIw|I*3?W$f4 zZ1ArDA$^7h!dF*_^sR4x(r^@0@UbX-d)|SS#ZgCEIVXd=c?!CnB9RfQ6%T7aRdA@|S%dPsf76Sa!bQS!-LozZ~&pv4$$k z7>=7=CFZ_*_H(rR*YmQbh2_9X#&4!qyWM3#;9mh?Ya>3kg8;M` zUz6LZFbd~GSixj}WS*SPr}KpV36MIOeC!|X3fhcW>y78$B%7R{vuAxLuU|pp&vFf} z%56?}BX74QfB7mq_LCD#VrUORqwDoKFwa+)GX zDdPJg+*TZznfl#U{#f<>8Fadb_!)ign!oT@GbK&UoRM@1AMI-|$j>s*E_4Rfl^oKx z+2E^^+_p4oon5?s(BHjZ>dVWeR`gen9D7PPc{imqk@AgJ8u9OcAINt8wQq1b z2wx1Yskm~hyzfOlc%{);Qp>|?@{O6+Sh8p!Z3;>dGK7HQ$6h*mi1?rd2l$w4AE~la z;3OI9sJgqY<0=t4sk1Q1QwBYy>DzM+Kfau$yzagR;3VCG4LD!3o4HSOEc~3FxbnDB zUC!SF9>VA=TXuiuX6^x7j}sUkp&dtCQB7TQg^n2zl-r@Wkc{T!<$_!e^?Q^BF03KJ z_pG3lge(pkRtd~!(!ex*bi`)l6>DmG-%txjAi%i$dr_dHRBq2=XP- z=8&cD>G&q6k|kr=yi(iOWTdp7PW+-lK?vf~JzpY~PY1`3;wDb{ZBU2tfk-kJ@X~d5 zm^=*)9a#Jq;d=fUWhp(Ba(I(1cIrC3JBh5gU8YY>xK1Q&nh7oknTek$;X~|Vpt*p+3rA$piM4~ z9%nar6KcJY0W8EKCrmripsenA*ZH9{bWEr~f4YV(igU@Q$ScxWJQzhbbRQ)gdg^*3 zXap5nawiG~?>rwvs?J9roM%PZeXJ{38llB@ZM2whZMi$o2lhk!)fqIDOi(NBoqVcG z5t4Z>tE*->uOQ=ldv-tSLA39&UD)}o!LW1#CUMUUrZ95^%f$dcR{v-@R#&Pa_1Log zx|WvT&6IUxI25Po(W+oSEuO}91Ch{zynd&&hzr>kqUY$9V&%Ps7%wJAq_ACm(YG8! zLzbL$&R^NGPlLz~o)6-XR1b*V3{X5$z^0z$)5FD|I}!qi#hqPY(UyyC(SKSB?SuLI zOBiIS^+$XQRH$n!ZMuGTAwQ7Xa`{I>^;VYX-Rd6YBM~NF;O3{hBe_yI>vi8}8h&z( zHA)ScSeI248lMH0*RvCdANST|9To^mEszw9&OH?D2&7h{>D81GXn|GmHo@|8h=rM* zqhYuPqsUsd1K+Jee2EO;Z7l|yJ_@MAke5mq+27kOzTKh@@XNLMn^{{BrK;~r7z6a> z-a^rl%)GgfT5Wot;ia)fehj)yy7z;CRwwlBs3D0;D{garhN2ldezGWIXga@OHX(&6 zFlwL)v;U0ubWWRC)l#EGHnlQ8)p0*`l}ZJ+%jGEs>m7C(e{whcbH7FJBvb*B(e6)o z;ojwi+n%J8wN^jtUN|&4%gqoYx#bUSz=-y(3xOH%a@Mt5Te<*Vj;!5qYVl7vGOE=vjy+-{J zn50d{!T-)kxSL<{Bg@0t4%)dNbQ6zE12y2|#@WM$$m{dY$G2C(wz-a*%Udi8@AItm zX;q7{^@UtHTg8CvwBvaOYoZ-cyg2F}7*TuSmysk<Si=jX06BXZKp2p&3{DQ7v%4uh%aKSr$`CWz`@tTMSY2nEj@I1u_XdOgi?@y z_8bt~b2UmBa6AuX;jPeu5~phV4Uv$=J2Jz#_=RewCez!GVB{7hQjp8OzLt*@kAOr+$x z^L!|3*@KW}#bxzyo$X5GYs)<(=rHfYc5qBu%0#yN0xW$SE`h(e(cI@%*%3ozl@Yq| zho+5hF~+I9zAzU$8ZQH(7{%O*8QLl50>w^dZovqAnK%zWBE*;bRDD|G1O5O-5iCE+ z^?p+|(OzO^5>mX_0ZC5mJUnmJIlNxU@+}2bwG8KYUWWaGP>8EHq<&&#o%(GG%lsTxnmNK%77glE`K4O@dOCQE~%Ym5S7BWOVGas^6QvzzL zv&`uM2sy6%a&A`gg8kC!>TX@!=M<%QrGpq>v^t-ZWRcFN(Qs~G>CD;7J78w0-r=r_ z%5+KT+j&OHUmm)}n?GPm*i0czCHRNzaAC+f@}vjG+tD|Huq-A$c>32lWey_QhoB-S zU{8tTR>rm(&FUMG5XR3p(flARA1s2$2wlh<|RU!pZ9iOO!ubCB@;J`p3n zwvQ18I?BDFG2SLJPK+Ias=c*@FJmzo)G-b6xu$x5Aqbxxp%(s}Ea|5=FCvw>n7TJqO2m z0Ccq594@At_-tc{y{Ltmo7=de1xK(?VjlCuQj&dD^zpKaO~z`s1D{SZm}k<2 zSc?U(%s_Z0ehg7m8V;t=!;<WT8no4`g{f2Mc$h^IW`!%9p-$M~jM7pC)>xthYUt zm!;}x?>{1U`?%P@?x+pP2m!G|5b4{}T4muv<&! zt#bak-QHY0Om-u2F<*C}RpMo(Xz2R%#6v--iOzZ~*zBb4w(e%h*IEjwp3%6Nr(^1P z`66@}?z-G6DMjoQDDCg?5D@{u4I2p~-KQbW4&O-Hk_g?6b9bqZ8gSv;1wo;2Y$6&3 zz*P}4wjAH1<|7Z0mjh*cp&jI2hsT|1!_FFQ&>CjC?=wO|>;77sg~*FhwqLhz3VYo5 zn2y_Vh)C=0I&kRInk*_-;3?cK10jJ5X}*cKEQd=>Opzg_=e46N4={A#jJ8I3Ex+j4bkh8U;hff ztWTql-br-f%7OUjEB^WqE)ZbjoA@sp;CRi5XZPzK3i(R}f2M*)1QIgQDm+x7*lQnP z_ks(k@*y1(D`VvOFh%lqc{bwT47Cqv9PCza*RtHpt*n=4S2KCpVTV2PI~$AJfE>E6 zBx`R;7Ii+!cTX#XM&g0D`ftzfIa?d{>smo1)&f0Q`g&0jK7?SBRxhYyK;8667@fsU zb}(;d-jJ6KF=OYwE_Mq3*I=v1cT#eU)idabAL2kiPw?t#Lt9cf^o+av>VLefV#o=l zEKMiqmA(6GO3IQ6tM0H=;NBa$P;5jlL^ViD1(;hI%!B0va469q=ns-@6ST4vmveir zKH@R0w}Yo!Mpq|?XKz3aUC}-0uL%n$aS9jEzIw~|kqZTapMHmRS>()yw~vx{K3<3U z+9`(}yvCQf{|l;a!lF4>9y{n2d){tR)?wcdAKGC2B!hseMA#x~D$_dhx(?FREyS3h;72!brc0 zQXC%!`xpOUTxN+iV=l(MAGFLeS{2L?&UK(@Dz+KhP>y%H zNxpN->6~&I>R##82_!kbu2jx2)iBViy?JYXYHRQxN+KV+U(P$Mce5}zH=rH@k8g_2 znm?YN&wMg+L!4Q~FV^Sv)#WgD{M$nE;SY@{fcvH7bWIHZ213cQu*@@jcV%g4IMn;r zwZPtyGQeNCeB=dA+wgr5ZQt0UUWX-F&u|%~NEvb7H6&TSr`kNrR@tm;0aq>@^SW0XikOl9$XOI*G<<6fm(e?i{(e7!G|lU09Vj9}l9Jf7usWY7qt zr9FsqLbmgSO#f*=G_z9B7f^OZh(W^758}T^$Hd2_HBwAyYViMyn&wz9@u)f~;UnJu zzG#dUN(eIHsWcvo?%dc8Dnmyw>%-Ll&*AS%B%f{hyo=u%Ju zv$Bv**U=*#q_HmmSkKmE83vbYm{SKaV$Kdjc*{3-Htxo$sGCQQ0zWT`?pon`T1 ztM=%X+DRrzVwC09v&V|cO0GYHnX)?MB!%^xj|2vDL}dR=*ctF3#HVnEvy9#HUmah& zObg$)_PX24{Bb|=>iu~3hJHOvfFu=zSaImFIlF}XM6VUq;VJQYJA5R)wM@y<8ZbJuSY zbPcp8C^!7z3llfD&K%CgO=4slLWdHiR19WMH-QWDY-PVkb$^T|-|;BqFfW;0KnpE~ z@$q>VeAn9DXzcANfz5Ol!Z$%&hz$Bss1bD?BXu1}{H%%?Hpz2Uk2>U1`A#?c$F>{t zhozJCEdq8K9J0Td6^t~fM33g*NvrD;i?G9$^eMg>>VYoPwrl9c5?uJiJkCR9t9JM1 z`p4LXVzmL88TNXBcfNbOpBq`XM|XZuxIbt%DQ}k8p7XlhlfQ^zd?qBaqEmsSNoSaiEwkogVph4$7KU8faarja1% za(L|X1phD!YsqXk-qyPOleHKViwK4m`uw;U%ggSL?RAsES- zI1^Ey?I>lm@`?(Gb6N!jd(g55*77!9Mkv5JR9Xbh1 z`l_vwdq3tgJdVbAM_pS0iG7psg18sIWT34J zzuY=#^r%&_Ijnzn0Ih(95eV9?aN8&NLhT+b+5S-hgCv+Krc@9J=$-m92Tk$|eu;VI z#mm!nf7HyM(K&p;=8d1=KTn>z4jdIE{byokEsl9 zxw)c(43=u~Qu3>Tq0z-_0Lt~QP0jhrdL=2cJwi7cwJS!czCe{LDDe?Xea1nw^}N6< z4xaLe0-qN`DQbqABo(IW?BDuXvy&xqt#}yJye+EBCVh!d$Wg(a> zdl~o!;RR?S?qF^$+ej!IAv=2frYD??{UI(*NVCKOI3laF{BS@S4A7 zsGs(?dII&$@8pm(Sbln+p)tK{WPU07zQxnsipSA5G26+{@9nX=9tX}!T0`0;aGzI{ z+(~&kGn<1K+m8v=@>y4Qw~Z?s@4^~GL)l*qpD#-OYcBd$_u&}~qgN8=u)=S}&k?9t z4VDj|cCkB`?jNd1HoWpI|JH=~TwuKy%WU??Of?!iWZ-$%AjY-5mXshs9Pt48w?w*+?>YiZ`Gb1#JYP~4 zR-dtCVRnYJHHJLY{!x^u9Iyoy=|;LJ?`vpHOynLIgQe#_>eYuRVS0hApx}0@3%)CY zR`kLwMqHgQ%U-O36tE0R4M{v#g?i8D1}c8HzrMe@pbEDKg6RvsuiC$~0w>lUrpMy& zPWe5D2y-g1dv=?HWso0B!Ck_Erq#p#g1-I*HUSup|G*Yuyg+=LqUIL{91Oc}{vqQi zhEp6p%H&x%VJFk?a}_iPBwlk_F$(VLT(av=+|So*@}QNo&t$G@OXtXhDOlt9<{dOU z@vZ>C%2ac|)uN6j_-N&1!`FKhNGMq;21oj4i1U3lufU{ke`WJ!gIxdLF(@ItaG5Zp zamSAg@&*Zv)OtVL71?#(N8ru>r5qXeHAG6#7Z^{3)C+GyQyQmcwlfT`dSo<1^n?e` zwiW5%heaq+wEHTAwn}r*4SbGluan0U9})`YY*KDy|#A&<)7Z>Kw}ZUq`GEs)V1P5=prFNLn-^quHS` zIHkMSV^lnJl@n)ZKhx96>Xa>t%I-WtDNjF>mxe$@UkLHREcBv5Xt;~`1dM0uH4XN7 z|C%!pT7oRAXDsgIj3^M{Ikhz-~oe-ur)?o= z&S9ayOKoCsqq$u%jac92^i$_RBe0E{_G(q5F_=fU!F2k%w?L^pGrXOj=`&F|g$yR3 z*cQQ)H;R><5KUEzBl%f@0Mes&A6$f05_M6}@|_-cIN!CDx?+nBcc@s2nA z+oIUdkalAIcF9P?FP4orFE+)P&P>}I>|J8Bi|lpig5rY6qR9S@ zT|_7p2Ny|d`=S81QmWyjgTU}sb?ncW4oTaR(x{LagyA!Kl&pC=7Adh)GY$Pof>sXN zE1As5v3kF6GsMm46~h^7if9DO_^Yy#jn&l_Rkb1zcS|eV#cF$=>m97tW`x7XRx;8! zOT4gWWv3dFy+d`4>4Mzc$?Or6{|^B5zq^1wtSp*6LVi)v?;5|A`PMNEjFrN}z|WJp zk>ci@0|8xO3}S=eAe!AAXiu!mk*ZB1oJ@0lUQC&QW7CO`4T4o+nW9elM)4nj7cdHb z2#;yHl}DM@97{1~QD7oU0cA(|>WyB`0dta1xR zSaIS6cN5Q-!%;CjJM|7-c*tYvxOCj)D=R9n%dGfRO>~~!m_J#u|=-2kC`o{RQ zP|Tw3II;d2m==fQK^JG-0^%Bm$_y`uON}sZDf*ao6fs_a(e7$#L7zPol2_7(tfy~D-g*o z60IgILPWY>fBDkxvKt0tE~qYG%Ik+Lb5&8^`7A04={e9VRhscDeH8t!E1vd~%J=~% z$czK0Dyp)orW06n#cI`&6j-prq+ufmG{o-K`C_D8Cp@{~b!0>yFLm zsPk(0KHDza>4d(Is3YMK~UHHsQ%g_2?n;Vs0%aD1LJ+-@VhSStS1u zQ=`*xsZELV>CRBPpE5n;Z^kLP!Rk-sYD;F{iDP1&$#g@i_L&zSTykQ(7MfQvCR{m0 zZ^Q!bWYU4ZjWwIbou{%sq+UceoCIK21e(U|ublaC+nm&+S#XJaJCr}HwmG25F-J7Q z08)C9%;rIK!O#6+OT9t-7mh3qz7{r|JDz&YQsKmH;|)1s5(|{v$<8mTQHZ>Yc|=*w zYjCn=JohrT9|nC;rzbl%w&Lie`Pma*42h9%LloLryI|H+hdJ-fP`pohIJF)EYE(;9 zJfrq*QVOE}V%wTz>xv2t*Zm~2;P$0ea5)p4HDA2)$vH>3+Gi>9g7I?6oYmszX0l%X zYzyD2`*lG${fZ)JHUR*R?tbTp?ZaAJ&+)M1-Y%@aYEp9#-Sint^nr10(TL*q@|}@V z)eHDmyoHWA!GN)l1jJ{0=uP4i$rF(a6oSrH)su$edTWShQF!4+8GU_5eD8BVPSJwx zvq=j@Wa%fRjO+m+Lt`mPXjErplo7wRZt=4b>Jz;I&PltNQoFLH@baoDi$D2h8;#pf zCRfAy>g%K@s1W5T{!a^7)iWB4+_vNYCTqWULMG|Myd>arB9)7&X1#_dMBLDLn(21q zXp_{{T`R#~KMF(t0S_>z)<7aZNgS{)r#^-tKyNeF7348)Y>Ag;|Bu0ZJsc5r7Rm7W zb-kmMQls^0z>*{pZjj${<+QFAiiL|3y^ezqkxQbj+QO{OEw%8PU5#*xyAZ@p9=((P z?B)4MfE-mW87UGbj5!vgBJW@)>40zPwSsv+QT037Q z0^*@Oep~nS+NwVY6`@NzAg%#$8t92E6YR&Ue;7!@23sX6x}H;4d#2@q?Vpv;zS z3fv10pHF+d0mg!*fZrEf`2+#HQh7<`d`CVDd5uLGdVna>duic((ul|Hh%N(SS>e%? z11j-1@1rl3j=qYElOnRD75+i<5}hz%97WK>ZXkZ~&=u!3-=-QJ4@YN7u8+g00^=#f zIfrI}ppTkQ<Ug-Ro|Gx?iEUXCC&)!^{XJz zD?KS#O*p>EcVOKL!Ld|ZutEu}{YH+c&ry8=t@4u1r-I^-*4_w!nbhMFDg9vK*nOIk zNl^|zR1t#fZVF*In!1wrB*4I(uRTtG>lir6okzu$N>NljqbH`DqXTF_ zTAd;WmVqb3B77KH6mh5Iql@6wR= ztM%+n(kym1Zn>%agqhL%4${$$b3hW4a6*Et`}bbM5IyP@#M?}?I10XpD(PEt(62Hb<_(QXzTaunHqU+kLbX;5S$&$m3iJfW&GyjC zWF+E=aMCc}P9O!-MZl#dN+H&1?FOH1j=cSJ_n>dC<*D!sfhX%U(?}q#sQ^ z;1yS|s2Mo9fcU|lPUZSf2@@f`6~#Cq7B9>?vsu6mn&V|iEk_O?NkQ61!w1oyBE+2q znGHDh$iIE{X2t`nf)2~mN&e0s?&Qc&#?o_Rn~?$wWz=)<7DPfvq{Vx@`wO+*lGTjA zpPEw2MX@YQpywULIC_UC!UKe`8eN(pWT32|_hR{|N+a%(7l>Zu8p6x*cic%QmM0mx zYZ}j-v)UGkc}^^oV*~=+L}#!?WE+9cpJ=ATMl{zIu4+j}crMy_a=HWPvg)-NW7?P_ zM@Y)8h|rIgRzyB2X+->YHWObrCF?v*E?9yIvlz^2Vk~b`gBnqI^Y)&FJsNRuvX*GT zX*rZO_tJ{kmI@}sHLeC6N6D%?{gxP5^**_}vBFWzWy&6A{SS>P^8jTvF=8Ohz*8&o zMOu-*ra!|e`w$akoH${6Xf*+MeCUczok#&$p5zZ}+LX!sYdPb4bnnw96r?j<)&Uq5 zqJq^Ro9=bh=b^zZ@a}ipeRzb%L08o%d;za#OaA41EH5Y1cT3XstX7brI%SqV*$-X8 z@SXmjH+IFvVt+L0p%CL2@?|P&^_ze zzfrr4Vrr4RIa`=hiA@XJs}c4<6iL|)NH0$nf@K6*Y0GB#c8ehfmz+nAj!#vJ|UU6w42u zImHCdYZ$C%))O!OH41vV5)r4hM|7y=4AuiY1~3UkE$sAD@IZd%k~>a}%yNnvr5wjl zeh9kP9Iv*k`qwU!4Iu%C;;;hEA7>O(&*&<_McccT355-u^;vB?mSnqb=koeGJw2+a zQp;uO<*M3f5#IM`t(Eq3jwR;MjIN*LdT(y8|KAMT+xpt` zGf+9K9(ul_imPuMa`K9}9dqHN+JrPXqy<^hH`)z)c%#^2ED~}%lW}74Q>ZgzC#4=d zbqC7xdPS8WrdMScoiZ{f+h+hN_D*tn(W;EWmp++dOAPooFV=z>w`p01oHXj_v}*fZqcf&YU$8rjA_8}+tCV!1eYMM+0^*0JuLDy zT=IoLb@Rz4tJ&3k4?jB*opP-6*WYX|s(!oE?;;HO#n&-0T|SX|X~Nvk*v!zD*V0bx z?6{Jd{4*Wda%R!x&k%LC9LT^0XYLyREnJ{-#SOc)2fhA+#V_INYeZM#y`xY61F!gg z&Nx0HsYyKH*5Tu1Xy$yq$-rv%51&Z~ocL)oSqrTFdlOqj=YHJVMbV62;Gv=^{8cBO zi5b86Gwp4IdtXFWJ!eM;XJ6sZLm6MTk~zJp0uIT>W3z%R<_6S)m zKaeIrB8+)?amrhqC)Sd3 zp9LR~{AxbsLAd(mJ95AmZ{2rPakmPgUBB^k3aXra=tF|zO1U@vVQ4uLu5-owgfZ<7 zez0PmzK7Au*GVu|rhbLvxTG^qj%JlKQ{nKr%b>pk5r}7O=&#*jYFsV&!8h&1&mS%& z+|CgdVTCfZa?G4y*lG-r2(bnF+NWuLVB#_+P{021z5ru#vsGBlodx&1>!VPSM;;Z2d2*X-+f^{uHVyo*i0o`P>rc{)O0dM548!(|F`-J3?IGJLzy{ z;YOBsbEBm$oyXZadI^oD!pz9{u1pB>iW+Qc9`Sv%UyP&F+z1oXEImSTx#Lf@R&(iX z-h%DO*KCIScHvOk`LE7VKG-~5D+M$b)7;4^(f1xvX%Xe=n{lCsotM?DoBI0P>;IKI zr^#QFU>Z!%mUHRI9$t}J_-kTfiXbN9 zh4X!)mrZV@@w~-IdTd+S0^g_AODy~nBY;AiHSbWNx1 z6s4%t^Uz$IJZ_BXUzNL>4nzS9C_!87)zg*7{=!kdQ8g{MU6mioPXyT)X&9~2$9M?0 z{Wrc);&2+HG029a(t&xx8(JNWB{FcsSh#xb%@qcy8{XJ4<+e@+M3Q4a!8l37D2~>s z!I)YwAD*rDhA+27bnrO7-;outq=FSJIhVIGFSM$9Y|~?i7FzCYCOd*jzEgFLDNl)& zJS*5fbf4e+m%^b@E!QDKePMp)k!ma$VW%9%Wye5qu3*{N%Rv@SQ_2a`267AUvwk5D zOL1bl-KpM8aX3kofxsH=wIT1C_|F`m;IlrG99>ztUjv9yWasfyEKKOuWIy6pQ z#+;1ZrICz&n7{69XC;?`V&V`t!Bn`;&50?UQ;9(Afp~&a9WvPgWa&|aNI}+O4=ose zQRP(z2CDk(U-kG&Qg{giE8PKZG|@s)snRqbZptD~Ow>notgLFFOFWCtyFd?o3R(lI zh0Q+tcYWoh$a{xcsa)631M8WA`FB$(B=Gza!0%shOczvn1a>DAB7%2IcxgoiY3Agx z?NCGY36EaI7sk^L?+3Rg!aTuL3`Ql!v~?Yx3PZyZtvsPpj6o3luFs>&`6YP>hM>Teh^DhKsK4!SJIn5F52fnFnNvD=}|*N|lAMy{wPU-iGT8<^C1{ zFuwt)C(A-n4QCWTy^J=oleI0WJmEqP6t628GA(~~LaL?t%B#@kD~Dyu|Auh{TgJ?Z zzMpZXCAee zrK(SBHh?ILL51Ai?l%XnQBc2rj4H*uhKw%T%G=H%f&)PU5349zma7gcQaHb)styv7 zWDm#O2ajP6TI`e98U9lP)g$qp#{as2pb5@KdD~cFzc zX3WEV_w;YPW%icTdrX9)BK?}MOY|xQ(?fNR)9xwXI?6v)ii-|%vZ_^w@htPU|EC4K zA4_ukI`#Zneh6>Z3t7g-#!m0?;+-qp>ahbGLe6G^)}~kSi^^pRn(y9_d!>6Cesnz}5JgCuU{xt4%)oIjLv4S;4GT=^2Wn|n;C-_HL z!?v_%y$!9nYG0OfZyj=b3GKNM+`?UX$`5q1G>0q=nx`K=)fv`Q;}*lBg+42r$jME7 z^NmpO#y5||wMA|;&KnIp!DAhZ#<7Nc z(_YHR9^dB+oz((umz~D_{ek3U0_Zlkk7R&PRSN3}0oZa;t;q?Jl0F$yeK^j(EBn0UYq-&+S zyPKs!>F$Fx&U+NIv%^L>AR?(EFHbL*Tt_dMr$yecW`Nvigy3k~({qB2LYh6~Bp zc=ZsD{^bQ6BV~g4`S_-$WF~J_HjsiQL|JYS&pVR0LLhigiJUX^SmUwW1#R_*WnJUb*U2ZtCBx2A0;2 zzw1D&%L6#yce|{kyRPcX+e;J@il0;xym^KTo1|E^mTK)hJFl2Ec4qZFvKMV4_d-eR zy?glFmu!MEsINq)W%z+68v!hepZ$5rW^8ad@m|@4<_!(q8Q3mSivy=$tsQBr;T^lb z`1M|zz9lfaLQpN5GcfD#yHNY^573KTwmpt(;(K7VMgPpFd4h*W{y2dXR@$Yq=#|4ZLXp845H35d}&G*Z5c$i_NA62f?3&}qjW<6mmTC;FwlMRTP^%)y?^#(Wxu6%=f~W&{B;Pc3KF`z}YA-Vc z3+Wd)&BYx7Qu7&c*5(#RstbiNmO_>@@R$v7+1>TO_FC&WD?vqw?mMJScl`r!E7{@U zH|X0FZZJ^}C!f(7I)hke#%JTq1F*_2a~A&lemlBwphHA96AKD5TL)7~(^AuZ309Y@ z=;!5E2Yk%W&tJ4yJY5+x1f1CpFGgBn>cL^!oao2afL5}Q`6KD5b$thQgP1$fFPW>m z%#6}MDqC$T9ar`Ry8aXn!uh6UML{%CFGE11YY1Db$O?#f4>!5+#OC#0B>QkfvVY$z z=QcQe!ou7dRvOHgQ|ID6Se^S#t7i(?>V!y}#iu7WEEi5|ABT+a3qdYVzP{lH;3Dl> z0FBDnSq2i0{`Koui1K`M3FIX?FgY#l_&7K?%Dq_dMc@W7i>-hr#hj?YgdjuI^sZn`^wrBv;3>O8t*xWD1PP96pKth)@J)ga5S zMhYisElm8AdJSb^qdOeHmfq~@>cVzAF$1u0HVbKbTxY*>D6zL)h>e(09!7~~#=x+= z4BQ9+Mj)5^FpIa#e6tdDfkezr`ay$t3k~CMt}yNcCdN~)nUgK{E^iaH_S8nr`09Oo zq?8$5WIlVVhk0QS4fGBjMs)t5c=jtYt2&=OgR_af7e=@N4oJZ~qmNWYm}F+(gsWvI z7e=!%O6@DHH~}+Mg!Ug?t$;d=XNvKa3`0{E^`ZeV7lDQ=-#MT|3vt&gVf{|j?*tn) z5`+~~3m2s8m4aoS6R}1f54|tq*Im$E#=u8~_vr~&v{V~ZG8p3Mr+81!ydJzV##I1x zy)-*yasNSaM;i+KiksL9bK~gI2}4qUG@!%Zm7@u#7oMxj&S%GZ2mRoWI%RWNlTirN zp2vs3JNpW|Z-eS3Fbkf4pPQfIc>Vd{>(gV>J9gfWdd=PJCM_=eaLsl)j!yq#=vnC~ zv*2k{PAS@o6~KGaM6^U@-09#Fc?{Mi0DTcWI{t(!#)%*-r#Q)0R|=FV?HKsPT4vjk z<$J{pJix8|WQKXARy*Ek`QlDQXiF|6h|0y4NJ-#jTL(J zl!II}w`(-IZt#XO+?e?pjhXiS9Ms}K5>AH z+LcAD_mU1fbpE3+E*HdbK$;_(&RdelF>0oIZiDOPqZ7@@$N!c!ao;HI1_pz>EX_Gb ztm9{U^USV|^^&3b%pSsBZ<h znn*oryW`OX@koQ(2x7$@#va+bMdfl5xGO7^9DR|`D>UUnK1hNH$S)!YgxWke2`1~t z4WzGKF^93M1j^54${hQh7$7kLqYqfD?#nQot~BHHnD2nA1dbMlM1pXHq zP|7_Q@3p6+ob-1Th_UnijEN%6SYP8UBE`@50Sp!dSZqY9LBjXp2q@Ls zT+qo-m){KtH!MD}Y@?=eI~LBi9<&P+dwhJ^9CJH_5M{O<(haa<&)h}U+Bd4SYH!Q9 z9QldFFaA9F^Ge(-{e;y1n>P1LbR)!qH8oShDn=N`iLFLwUJ1)6)mQKcl`?%PF~qC_}{xvR*#>5J5VJ=}1tIVCdbvN*GG9KxK12}>D>zU#)! zRt@BkeC-)%m`Rw88_AkqLll1_NiVjvCxgDc^hqv?vdD&t>2R8VVSeM-&*z=&%c2uG zzGar}3zvn(`cKM~e>{a-W@^2JUgJ+k!7S1f=jZWZ2|Ci0HeCpn5qhG%4# zGnLariy&a0A}nkWK+2HB%R8U!J`&KVrH&k{sZEtLEpC5Z+2Dv|mKFzPQ%iLFMkJR% z##X%AIg?Gmp@iDozS9~1fzXhRm9D58sH$q-OE7_s2IOIkCL_GHdw0D%>HS80>uq}j zs31$Wmz5DU{a+08LE8QlyQLkZ`%V7@vCJIJoe(){+|RZ!AH(au$#Wf{U-wnwb68e2 zGTj@L4G^fhe8t?xb zqGy-(ouEO=O93y$z^OX1ii!#%Ek@JrEnh{9D&$b~df7W*PZyc&%gjMzNW=p5{D|^b z?*%O-gXHDVE~5C3TVZ zF=!9xlEtGo{+-XkybSF9+IO9lFA@WaDK8T8M(47o(Nn`)F+OLGm-95|Wa1e1%RG)S z(&8*yG`t&{$OvH2rSlAZ^5uJP0V8PbAJQ$Ioh10IicVHH_gLkKTKLe&;5Ub|F2GUX z5O;e{)Sg;b653@M<%{G!vvPOU|3>Z2I+pTC-3i;W*7p}}RA(4MdwM*p?k`b!P7$Sp z@qI48R)@&Ue_&$Swy|fBqmm-J^6h0GUmE`^447!QasA73^;@+9Ty$4s+ssS0J)dy_ z|1|PNy~N`pM?iD)lmiU*VZ(RJ`q2xixf8@BrK1MUsKS7e;| zM^Dw4S1kQIhb!e8l9E={lY2GKO)flTez=HV8G1E3aZ#;>ena&XjfMGku;_e1BG@5z zO-a@K_hn^6R*{B-qETBOu!~wWSm(&PDcA8}S)`5A6T@(85RA;o5@SF1Bt}b}7nMM# z?k;%EteT%2)r_YrY6AY|ST+3o9VnafV?)_5qx#Yw!q-X}0Tepj&L|2se?W)e zR)r;g%XC>WMa56ugnK$xKkRP)Oljfb3wlQin)xIbE(wQGX8Ht!&gPYa2yj^P8Kt&W z$Eqw%J>r1%`@9hO$G}YR`Op_anvT+uZ+No!aw>7yE(TCycqnr%?SLi_SJ8i<0#3HZ z=Wb{_T%VBew3Rk#Q$9H7d|$bg4mei$rf6pVC9L_isdL%U8UoxkbeI3=qp8wt3I$t+ zLY&3Q0O3Z)a1HD$;=L}A}~AD*^=T0|j;)JB^;sU4}p3 zxkSxk6t&J0H3Qq@HVO^g&)^vN+|lu>?tjL*2K3kpv%FEP?c*zE_meVe??Qvj9z0NX zG2i_=xR8l2)8X1&pFO8?sA>=~8uPksmwED46W@L*w5JhwUSK9kQ!@x`n;Wnyt8#kW zUseXNSNUa5q{kVM*q<_|kL+k&SRw*nFLq`f9itd)7kD!FSSASm`7n&a74zhg>dg5P zVE=VYPTXZ?8+<2rWZb{6Y6%W9MMXvbD?UnSeC~Flp{egNxH0OJunXwWih}UOaoDQr z8#M`@{ILJ;Ea1h9zk=-M*pH@ReMz8gg#GljpGW!1$>MmXq{NftPBY4zf$HqZ!n-Cn zeOdB!o|Se3eh|@XZaq)$32sJco#!;=rfj^gclM^~gF89ReZ0=X%4(a%AhD+V>PQq; zDtzed{hys`4noK`B{WHk74i#UeXxu^P8yNZOkNd8wR!Fpz*xQSx@0zx{6e&?lD)1@ z2nY6~8cyMM9m(&^WtE*Ns_U$cw9JKHWQFd&#fP)!^u*}6DK|Nu_uH=zDUE)g#TxqN zr6c-*>Gd!QA%s5MPduPUfoS>b5XWQ$`MC@E;$k8Gpy-A#G^*JDH<2+$l-&Ny4Jy|! z8&r0kSHAMXZhz+wkRf%y6SvbO2Y=WJ_E0;^sH*p66cXpRE0~5ZYl!mw5LarlJbxr3 zYC+hHq(B!RziT2Oz9!`q4rVmCoYLAHqA#bZUa8#X3zKc1h7A79@>Fmi%-A{Bl5UB@ z|1A+(b*=>o6M=#ZwwJJAc?jYT!o!7oQ~4ztWc0Z$cW(maB$nM;?r*_b1TD=X8y{Re zvlH8^@9(E$?)o=WcEQ93QAk{);7cM2KV+C>^f;NGFQ<^G!iH6|f;S?)a*AL|Iop%_ z()gfVT-49)Y)(dH$6VrCD$%oB^voYUn-I1=$itjx#m|}HzE?J<=Dc&*kwLn={iwad z3m%w$gLX;E@Quhmjjcu*AbdC6O1~v5Ua*ts2m092nV&nx%o-yMwQ>I!=o*aZ2l^M1 zRp&U&T*wIo$t_liqB&-DH~V6LS>JytEO$P6(>-y$(i!o?Qs-*F7w;~~>#=LtP1p3E zn`oG{J?uMT`^fh)f-sG#NOG{SC8nzhUXsHU*XREJ3+sdW@A4Gk!~&iZG)CX^?1#7X zBJl*Pz72-5CARtzztmgKDKD{=7&Pv%>u(;36cWh7+WGZ5LnP{w5{0+?p6UZns$U8lD!p?f4J%X8HW*)jGKR#)kZmQ0#sP_D+JL8kC>**fWbuPE)x0)RK0c z{u6DE&_b=<(w90ie9=MEZ-QrVi?7a}e zCxRn;w#MQ-N|DC`5&}hm2DP3%lG&T(imIyE9eH zOiCK|5<80wPphn>iEn0J*jIC}y{}8`uHnh^8VUjFp`vwI7BA@$Y9N#PJJKRtsp4-+ zaqZk|86my|z4I6Pu-$NIlf&9u%V$0J#ch7W`TE!GE;F6p)#zGB_N$zIW#W@}blpPY zBjOMZ&uaI{)zP`8-CU8A>a)XDy&872lpwvm5s*~hNK>Y&$sH83`N&FiA71LwS=^om zV?l%IIZw*j(5KKbJZkQiA(u-GVtw&NFMFYq&^)QDN1Ca$fJ6w}ji zI{!;l>T!3xldQ8)rQUKm4o2ok6FpC!w1vGo;56ye^`dnnW~K0&EHM}^m4ip^uHIME z8=Lh#zGiIo>NBE3&5IDWf3k;gxLb}?;FYxb;GZS`S~}@X=l}8k%k;Ry7#eHes?ZNn z7nQsDFp)@#ug%Hj?G$$F$#@TI4~&FZDOIz-QC&i^iUWEw$P`y3U)krrqHdoK-`5_w zTsHk+=Gz#1tLB($b6>GNyq<~v7@%X!^EDNyA3h%|2r!M^)noyauz{uv1 zQLyE1k7~DbHt2`O3ZLDyA8)xXVm6A!?OcMx)K;2xljj^pr?4VNj?`sU&mtN)6o%c~ zuOwCMu7H>n0LmD5d=%F-XgZ60{Xt1i{cWR58ow8clMii_PL=shHmx(&)1x->USepF zE`7+$sHN5=xKy^` zNrV0Cgw61)B)$6C=!pcIR+NOtV-_vDu;y}5h=U-L>8_M8eh`$7{x~4opO(&TVx+=J zk^Pd{&p1SjM+#7im(GhqXn&piY1?WopSw*^o~!lb?`u{?AFEaUI}0dWk>7Dyx2_?> zgXKUzG(#^pZ(T%4lMS1d?>p>rRkS?ZKfu5F67Ukjn0wf1wL4Y7oMFu(4AGLVPZk-VkBIF}>j9h+_vt4)O#2$I z#_{8Z)N)SW{Iujg3y9zxZn-fz;3xaOr90(UQ0{qDnm`*#@or;}JE~kWuRmLa(?YOJ z<~y+*qG!8%&G%8vrv3WWoFKE2)id8i*3O$j+6D}q18sB@+OcMHy6A*@H`6AKn{>yN z=Kk@Uy+fOKsiky|CSeCNnn!1nBu~<=urQWn9cmk)+?Fvg>O02Dz23TE+F9erZF`kJ zjI4=J83vx8t#*7xp7%+-?Wqc@-SF-EjN8$$&bVG|rxTv`1-JX)-w7UtI$m+Rik7IT z+G1`kt|vZQ3}060-gbPms%yq$H%$ypMhR^tGkDhuLQ%9c>+S&&=)#!WU0KdKgN2K> ze-bX=^0+NQ(fRC^#6Moc|A8R32qBK54+>X(YNy``&Mj^#^Y$_gb z?0uF8np%R-!M9PiKV~*38O)O#$|6;#~ z{u7hT`lJu9kkU#0t-%|Rzc1e1R!k&SQX32!Wkc%jTz-Jsc0VR5;$bGeDbe2Ssa4|6 zm86)ZgWWphtv=iilcbL6uf4GGG(cDlxaE%H4W!7~Wwh)FuA zM`qOA#SUML)IDO`Ir4trsn7YJU}fHaZ?Ha{bvjMlWQP9=2U3 z?)0Sk9%5JD*bBec%WosyI~;lZ@>gNHyiN2wu~F;!xUa$SGVb(iRsf2fS%2C^ld;p zqt{222eZc!-b5usZH)4Q=EDI`tWUDx9~~%5vZ8cxT6fAsPxOw()y1}dLpn?ZM;7*W z?5Uz7;!um4G1>f{G^HoL)FGE-8Koj+6<_P~`di59=z;)=|gc54etNVb%RyF|14 zwB%H|6N{BxCiWFiMoJ{EPT#)A4m=-SrWO&Z*afo%Y=LY-EYA2^B7J{JXehU&%l?~^3t zh{MJXU*qWv!X37s8a9IZV*4M(%`}dsGy`a@=UR>ycG9=2FZf@LkC2iP-P%DU@`{d%l0>yW*sC?b=4MS6GunyQzJKo?Wor=LXZ^MiMUl#OpjC{$UHNvU zF?|F7W$OU_CM`Tb%yle4G@Jlac;9?SW0p&==K%-dCnwcEcMXDxTzqf@`w{yw+B~WGk~A%9(FJy(ub6%HrRTnMLX=3Sk3kTSX-fU-l#$3(kilfV|Z< z|1GNdb`*(>_(#Xpg39|IdDq~VY(ISpT%NLvL+&s$FFSLTo1;k9_vOEJM97Mw9E+_C zZXfxlw#r~;ljSnR84mT~ooH|MiVxmoiYAipH5;Q8V80?5zUw#71+QmEZ1a71%VO|v z@Z86r-!{-_tDiSs&@ANgH&e;$;|o=!qpmv6I?_vj>8tZm={WB;iX+Ksq{{N~A$sgU zD(={&{?>AFRxOdr`qT7v%kZ zEGMNyE}8I<=)@g1J)73)Oz%7W)z2Ftdt zoR+bfJ9wc76s_DvHMY8&v?;4Z{`6P^V+~F}=%dsYciQ@t_;`vjN`@Ggs1`w@+F145 zVy^+i_f|#x|IPyXTHwte9qaqwx^gARh`Y$;!du|gS4X^+TZ*rV!WjiqHS{5XO4 zgwrce>Ry4BIMU z8h{`<#L1&l7Y>iN`fTkTR(|Gok%GqreV2^0HQEE&LeoNj5($Gz>(B0zhYD zE+%_@-yRx6^aoNK11?->BCnTekZeRBN+BL6VSTR9w>L(`5^}-xt((!J8xOmV?;d$x)IPIEzyCI>)k%63` zGmsshuTb~4 z&TY0Pv(AdN-iQ?U&Zc57K$hHWY?32)@%>I`z!s$)>}02#vrmuT{Awi1gM+_!&wx(7 z$~XYX?;H)DRPif+RT+7_)*-8N8)nBf#vUeUAzKr4tSsUZBh`V|H%4m{gURKA^U{y+ z+ZOn#-9(&x^Jo@1E68ETYBWXKbthrT#1-3OYqHw1|Mz)ug}u)9xSe3kn*Q|Hd0EYs zzhE=vqLNg|Rd@yF}KKfMF_n%R((k9}zVPVbRcstrJyz=X-7Rpdk_pROO%t*C*T>XGRiGx{hZV5;W&|Z?j57o+?^OWSeVJ6(8ZlCN= zvSGw(*d2YE^V;P(Pnq*l{>pgH&>3hC5w`06X}@tdMtN^Pu^kuHYB^y6`7#Hpo;5cz z*S7=CQLw338(*h6u=Yi+9@0ZRio364@-#f^1-*uFIgBbru^+;8Uf<=o3}A~-3h z$ZE5H>ul_ygcNNg7|i*P!U^tIB844Zx&8$%u_RulrV2x!1ppIc-TTGz9F2qkkhx1)IJupuD7tB94{ge$H*%R+LH>P9 zO)ZAlP9Wve3u{~Bqr6=8hM2I0LHqN+XOfrnMD`^wFFGUA)PYaVuh@RLSS6fLhp`-5 zj;u$;Rf}J)LlLD>A+wVk$G$KUa=z5{eu%ew-8sZIFD4v6DKqISIe);?!r4tQo~U!5 zl*`j__GNR$7NW%x=YHMIZy4}Jqf(UOzV2#w5(FGaPYYSKye&SC zJmuoKfPvyM>rs*izsl9A;lWFP$82twlGKKsq-~brNC*Ig{1)#?-0(##s#qclmey8I z4P2v93EQwN=_B%W&gTAUNCZyQc=s$5^4@iI4R;*rMfceg&9t^waOh3Q;LtX+Q@T5y zplK+hdCnzBPW!s7$qK$fW1Vvpn!t)=+v*I%;#|3J9!t2U=@3n#8dh!oIiS)#>aZ(U zr+_?)tICOs z%_UBVlX?~2esSjuIj48rJ>|Dis@S&`X;#8-f~b16ZvKpq-q)h2-LxHlOV@0Wano0t{T z1=T(SEe3kV>D)Mh0u;7RFwP?|$>ZneVKhRRyci$a=OlqK#TG9(^OyNEsXlX{>aubx9*%%Oki4NvCD6y0w<8kMwnw7*vd z>$IGgCfOkYVIE+wR7Z*~F|mYdINTj5L`QQ2i&-nxBfpy=DlJE8r^@ZbfP$dj*v=Yx z)};F!IPLS0rB8N!ACEVdP}aa}Xmw;so?|}CcTQz()eXpZYE3^;fGpfGwVorj#=9M? zAJp8&cgIMuz#V0%l(>P-NyeKiOjQiRuP7eyf2TcO5+OIgzTL=egT)~IAKDBKQ%wP| zml%R1YZj>8M^C-nuq}>8WKVK@1w!>|#o*LBO|V3nrryh|U!+7Em5|Nxr>%#@$U-?X z<(ppQm)Jl#2?WIFSYHmc`GJJ~aq;)NzsC0Payu%wlvmOdoHKtPsp7kc5k(a*sN+08 zO+5zw0xbyDFy1@#6+$Tx)~fMuqu8AMD*m*^TWEJ^{wFMySrF@K6D9~Sd6H;+gItYS zKMnPjMjOErskY+0@FKhQ7e~&Uv7kKtcLQ^|k~y2VN-tpB(GTs|47)Fcu(kf=%sm9b zhJyCsR6(zi@%sSC|Sjm^5o&2s@vDLZDg);yt%Zr{`4MG);c`}V+Agpcib07 z7Gid@z+)$_?r_?VEpHDggcJ-ZS!T|C*gGnf_&8(txS*bTY_#@pbNhnI$=Ey{poYi} z(1sp0qY^b$GiCHhVWvv;Kh7Z=khnzzx#!EE6WJ|)gbx_vPyGrkut;PS6j+H(7!U|* zpFHi6poS84e2>EU+(mb;O&-tgt-f+H;OGzrJb%LHeiP(XhP5kq@uoBR0)NP+vj{h3 zVw3#R(!S#Pe>Qi!l(A1t9J(>tK6uQDy9TeJz5*LL`5C=d@$_at2qMxB;^Kb*Ad?rV zkA6OP*aEjid9m{oBU`=_QzR4n&*hCwD|VP8a10zxxQ1KVai_q ziwk`Z#W-PQ4p#vo_PTbqWpnq?uObh!Juf{217&fs!i(nq6nbOgC2>Wh)qhS*Fa1(8 zSwk=12n2RzvuI+xBw13}vGynWCPuF2o?kIT2FYw<9MIyBBneez*fj9!(vE%jt<9rm_cUH zt9{Ye^XJ#+5%BobtHnA=d3kL3_uH#0Y5l`(&9?1&RHzWa-Ha5SiXdoOQ1YJu6Kki- z-|9NbX~B7*w9)PxQ+gtixooqDDiU-z_f(d~`#^vgf`Mto^diUXn}dUtqT>Gw!l0Vy zWvy~t$bph51pA<>IlePT6uwIPREKM#h+iOhX6GP=N5CdlOAbub>&@2=%G=w9df?xZD!_M*eBAq+!XLZHq>_D|6PIbC`C z%J;ntZwE)giK~sb!I@~dK9gf$vic7OgbL`{39e0*u@Txa&r_TCY}>6pJe&dw`fT!f zV0G^3jj64?q|e&giwnzJJG|-o8cR#-$}ymvlv_)5Ir8%U&zv3172=^(Qi8K(?0bJX z!Aq?YgS&G*F>>N}E_Rin1h7t?lpwU|}L(btk#cj`z-YFKJzJH>YM~QC&@+L(UvJR|ykl?pwPL>Dn??peY$g&{G98hg@PB>7ORT(6m!O~-J!b(1uZ(r4z^l6gm{i?{!&$!z}bWm!VmCZa7R>Y^zF9JSpHM5{y z)YU{oJ|B=}H=+UC(x%D3m#?sgR~~XEkzT^FUdb43JMGn+3t9)1MgXeoEX;p^X9ms% z>Z8FQ>-p9KOdBUSi(8Bq-rIwz7ctYb)UKA~7+yqss zNB6yVcGZ4l>4{B*H!sI4Z#)x^-5m#iery|ElY7zdOHxd-8(_x4-UohSu}3rnv>dG> z0oGqg?#Fa+z+P_t-=svm3yN?ThHX)76I5AOTPcD;G*aLWr#0E>Fj(;8${UB5+m zvjHqJZE>23TeT!B)S;7=`|wcJ;BMPqYUM=QWKU3gBILJHQDZ)Mc!-X0bXY7t-oco(psTc$C+6P0}w@{FK=%Yg9e{2p(Blv3o zF|E!V{jldqnb z51$|btYX_X;|>^_^F%xP9{=BkRrB+O&DZ*}M#enuAR6>haCQrmk9%I4VC{u@z4d>N za03WrS3sG>btNIT{)i-Ry`5|4eFOe<&_)`3dMGX_2U_rraw~(h0FW`CW*niDcDJ}- zfN$`KF(DZJX}h528pm)(4MM>{^C=OqnID7>R%|%UiC|GKQjh~3g3{*arv$!tBs&2O ztNSbO{#p<+5E^MQhuCWFr9QWR$Ltne69Jj$oIVB75|(HKlT^WfsshGoIp9P~VmF&w zX3YhqFO+=rasDcgyVc#L5u3NlIHq`FOq;mfMUt_*M6{5gI-7?$idE6PA&>S?1xRw2MeB^yfzvJ=JWKUvtkq3V!aA^f%0l)`_dNu<*E;?|GZ3 z&bGJqFOoRCXezOL?YWLJR8|?_+CaPb#q*=RmZc%H*Ol%=pdm)wM^p6fV1um@W4m+J z4VUWfT0fv6y1UH#u1J;u1sUd6i?`Lam=Z8WJ8wsm(Vr(MZ$}7B4t*nzISf9<&`V!v zgCOHb6qL1(f(c+b59_TEfn2FSh{Bv%))+Z`NYeWD&!9^TLVN@i+D^eegWUt~|-%4pk_om?ReI%ZqNU8F_3EX{nXak1T%~MCwRM_lAll9Lp;dbf+ zk3Zy3HhhhRY5CtWUSsw2Z*3f~(|k7^CvSgC9wZ!r=r*bS=YH?Z>Q38JW5>ZC{D<|^ ziI3ZR(z!%{o6@=Up@T1#0}xU^<4HLGGsl_G ze|!E~cmVH_$SOASOCUijlTB|fj&m;0EJ{$7}Q$^VfS z^a7wJ!!Yfj`LBdrO7)K3HGk-1^;72_E+R4Z+SpAidziT-v9Agc5^u?y>{tPK9O)5N zcW0f}xP+^!KDKD<>#pVMWk4^(fAV1)PC)SZwdOh7AVLzM>KW!7Gv_umCq3DX(52_T zwqF54s!19JAe{T$l5*Fyl14{oK9cp80BTJoO7duJ4e)B7BR8B-SzDQ|%=9_o%Hlcz z;~d2O>b?91f!2{nYp{%*w6jVO>?p@^8pz_DG1`&yHM-<{1{5oCN9Ji^LR*HEY>q2L zwuO_^mu0xME(R+uCx(Wt6(F_Y8yu^y0Pq;&rK>tZ*B>yCV{6JCQ7+2r=rbG{mlrzt zX;m_LL^;@Sc!$m2tiOxW5fYSK*fn8`v;c^Kh^yR+(qB{u2a4hrb>gkv_!)>}8U6-*VBlG`for@(lXbxp?uud2c zVI4@3zMBLV0IL7pdt|3X%_qZwCWAX5bw~L2jhLSwEap(s7)MN)7J(+HEW}{=1_1-w z6-#bIz|OY%rygJk6vEnSK?pjY#iiT;CJN$_sLB4ZjM4ZQ#$Wd?BQx8Ixa@usLC1C) zF>dssJM?`-in_v*-vnuA;#Vk%{f;CX1Txc zCX7jH#kN4G`+F9n9knwhsxsr&CjmoVijAiqTeOR#iL1)6OH6F(u~)AQpg4fbXkR>2 zRdN60jvftBc(Y#JC_k(t=gx`ZRCrzEJ~Wnq{wOm2pIrzd6@YMI1?LKrTYPxY~DtCrtgnD;(Lf~@Fig2r(Fi! zs8ZcB{~N17w@bFt<*JW5XOl1XCMG7}rB46(uMzi0&Xht60T*|1K?9k|Y17bs(Jw!M zJqEi(A{1EV1+c~8BOIC#fm1Z?&=PN&4a4VA9u6{m!1$j<+N!n+A~{DTIX6YuV$Qwe zX5|S=P!D8i9pa(p~jpiR;t>#W8h##BUAS- z?|-cbx$O|T|F5p8+9e*>Jc9nI|6LrKoBLF~U%dU~zEMX8)gjd4C~Uv* z;v6Mkq%yuenMCzC*$ue@dUPzn=9)GI6jzueA*5|*Se_w!NjfK?tr=^eM~&QPUi4$3 z!Tq8T$8uSRf)zDEcie0ER=2CXj!pdRkn_WS7~|!_Rq~1m4#$C(uO^=MtQBk{VgxEW zepI9SY_!vEEm4MVnC+asy(ohqFM}9SF;cL5Ka zb%B?X51wkyr}b_)6rYxIgXt~Ji>!32e_v-5{*cHB)7H}i98ulQUGd=7F>crek`VhX zpNsW&6U5GJyt(1A)$L6ses*Q0>15K-6D+SG3}a}X)uc>i@iB4L4z}*n`R9fU%0(Uo z#oUHAcY<(jzrsZG*Kdk+&PSGC`eKq%0us5CpJ!*!+1qo^5ud+1*%p|NaW~Czty9q3 zoK|{vJ#zW_*_k+4SSimVUKe^8E#-NMT@C$$v}Wr{xa{~5QM7A3{R`m~!~J=YaFTDtTc$ictzi&yG26bs z>}0FDfW|s$D%L#j>H+`x(WA(v%9q0c$N1B z;hUtZBJ%Y|tfI8bX=vG8lM6p^x>botL#IC6^3QCePN?uSE}Myy7M74)XgexS5SY@8Ls_)BXhx3l4ehxHUli|} zzJ8UN_wNE_kn;@=y!Xa*L<<6+k{A;7qm|h+6(SkDPUX8On~vl3ELuq45s@Hihg4oG zkf{MgODKS~*)k5b(?TDVJ>k1vm@Z!)r*q(SGah2RBoU~q0deLf?1z;e*{wy09BCS| zQBO7>b)=;9@U}f}7y)N~%N-Gx)J+84J>d+%Ms(NC`2J2N&hMw?pB8XipsO;=!c*QO zD%Rm&DJVD==68Nk_~p{))%*Nz=XbkhoX27&htG+zw8+b;ogP7MxY*}3g)S`f-V>#e+Ib`y*|+3{j`gcy|$}3`5j%IV_V>K<0-kveH%|vr9VFG8^D2V z4#-O2RWo|$-HB0Rpq)vQy^($i`oJt&awDknaMtOSS&2I#x#~BX>bcRKBx=NTla&(J zcP(iAG4)da`D73pmce+&c{*pl#TYV@6nU(n)wpxPa?RR&=jV6xJ6M6UTotFm_`S5F zpOk8OjKr@e`2B)u^|KLzZJW(KP5JS}QkiIOQNmIj?H!A4{Uv^l!8EQ;Fz~$TBt|?P z>>Ysz*Q0&$JLc)ENS><|8>$fgrqGwt)bmu9~=7+Mi(0l zd)km-dm!6DW+$x{9OLqO3eOK;WAw`iBG2iUfAtVV0ke{76ah8=?6O`^l6JR$cAO4t z$ixqFJ9pvbUbP9Vxw{EGQ) z%7`q@lc57)hWgjSCys|3q1yIqbu7PsdP654+@s5UZ>SO~NXP0|v<)t*pFdIl zhB47Rj8EC&fI+6&BsF7mRGD{i7f}ns6=;=tnC}ONvUVoxA0Kbb9?!a-%ubXMii<`c zpUkYgMnB>c##H3`>leRpr*BbXMM{wP{CDB=dhKVtp+!R?#e_C1o%ahG(iTMj?s8e$ zzG8&dhUT}_cx!4~RJ>JWrHSWfd#i}lXfWWs>y@ZAI-Jo6v9^D09eX0{ZhxG@Yc|Se zHk!UGx+{Eg|6qIK)oiukakK1M`y;*7=x6k<-QDox2`A=Rr9-?~&h(}Zu8fd=@-SY- z?Nza-)9>@j+^52sU)I?ikZWT8Hor+V?x}+6t8{IP5us$*lNZHO0p+yW)7fTR5G0^U zV(D--@KJ&&iOO%cJH*3ah7Jc}k-rIJDs-5Y0HP6~oKOB2E68Y1*~ zdUnMlZ`EN zp9@t!j@_3W1-sRGU6C}akl={zb_IwX((W=5($=epE%#FH)@6v-(Eo;(ikuCnfJc?H zA1RsyTJEEx5%X@LQ3;iQImNMMn!pFW0x4?YEh(R)LPA0hh-&Qxir(2DlsEroB4mZ? zX=H>h|8BmBNf%aK?w@^uUzP2oFhd?R^^IF*!uUeM!**m-ugUsAXPebpextkr{tpFB z3q%qTgShE5_7iU+qApai$Jx*L$H%5+mFeoOCl{9$e(?jW4^*gCCh})q_VCc?jm0Fj zlX=gx#P8n2B>bMeUgh^V_1EiD5|1ZeWB~)7^5{1k*<>&DRa=VVE#pleIsI-KRncKx zy6BhvCE47-OcXj}BzbAc1ETya6>}1e+JARb`41v7ew#70X(fDl)6&I*WB(qOV`}xp zuFEMh4~vUj;Fe+p5luOZ^=Ocsmx)yPJo*{vA(h+$@ce*Vxny>J<^fU?UX5@pW?}#I z&((FYy5AgJpMKR!%->xM6ym5wqdJT4iZ5&zNwk(7x{O%S4E}xja4=zjUEF!m^Q5up zeG%Yq>H99<+!>3fJI}w7FP0(2>!EFf^!y(xT1pFMPrvtBs+p)(aS<_(as4Mc`srpB z`FT#!+sDVmw3@xMeiZvGRS$h#b60^UrcRf|;J*iAL#(5*32jYV#i)voZ>uf2D;%4Kh3(Tl6yAjky~A$L zI|+^pQeZmtDHB@~F|BJ?FRa(@5esP9b5aQR32|Zmi(5YvY#){TRjm|!ZcaE<{5|#O z?@h%-c+a_Bt6CXGuH167YXzR5TX_bEs@-1xL~`a-Yw|?o<7n&^_Ch??rI&f9HwN^ps zw<_a*eIfRg4|=RSQ78Wvn$I63?tQn|C}_9$WWBBfMBUz$THKLI2=m-~Ww@DdC66Mb zrMQ|;L8Y&X@ebm3wOn0iCwTiH-v^G0=dbpjMl(~F{$}G_F2W`+ME)OBUmeg^)AU<; z+EQAec=1x)i#xP90fL0$QrxA5;L;YSxI0Bdu;5O3iaP}Y!6^=b;KAjl?{~lV-u?HS z-JPACnKNhRx3htB*RC1mDqPpjGQ~k8vCu;WmtlJ$G*8x@h-Ff9D%txMkdNf%V3dQ? z4d*jF!|;LA|R@*@f?2JD#$kR^nD@qcO4M%fzy zMkAzGB5_>GT2*&Rl(|@0GTNdSq{H7K9qU+d6a?@k_?1+aS5D2>qYJm?+AWuKbMFRp zrHR+nA*(@Fv3I?iIb#x1=M4`%YETKww@9AYsQY9GXD&qt~RgDfU6G;A)Aoyd; zQt0XwTkNg7xWC=TK8=0>Y_RbCHilE0(b6A9()j03!6%u)`hN?bgeB2iSaLQ!Q5+7> zI=7$&7rM-gpCoW+1Qz`XL&~uk9Nm6*WClmx|KX!#K=az9mp|+q%v?DkJ;wsrxu5uc zkYSFCHgC-{aD7MGYFpMM0Kdv-+g6Vrq7Z6ty>-$7JDv*sovOsataAnWl>gm z_uN0EIucMWttG9@M(5-_-+aAsf48Bi5MVt#X_|R{Wd`#*KPz`MF7JW6ZK~E zU*nhKIknE{32tDrEmHKp68Jcb?s6omw6Cs|zFH>QD%NiT5ZYU21>agMH^yXE=( z!}F<%Y6or(2mBkGoq<-Kf!3o0XZ^e;pBNN>pgfihxXKga?VA4Udg&!<3&e`rG5q4Y zNmrJ~NfDhg2G2n89Lk3R{5AXT_QIvH)Pb1s$O6UsrRDb1p*6A0k>qJi>5$a@;WyHg z2-bS!Ln6M9TK~*=)s{TTr@ai`T?AIcnsToLdn(-gdIa5hl=?-qgqP1g%zaPupm+7* z^KPL!qH~yy~2pe{ie%FV2|IH154nKlzXGu-5LsYEU0w1wb`I4 zvOUn}Dqhi)YkHB1;(F&>Od+}wevUz(Cy+mOzCLW9_xkgPis8U32?bV&i+j%aq|6jk z0iqgft39eC+)w;G5X{}h>^bs9`&&bh^L~}@n)g^u&zl#!N}$UQ$o*Z1P1bb-#c^iV z&_~Z-ePx5Yx&cc4g-pWytMsyJJh>8VMbh}S&Z0R3aTGeat{qj*a!tEo){Y~Y2n29lM?S20c4EUp(Jr7BE@}%zEw10&X?epeE>~ zo5-P<%SgkmTT6*|`*llq#r=Mrb9!@LFS$CF%xeik~`;G|a zN-E^IXG*QqPI~`zyu1qZJ6|>RR_cEW0O**~bBc72QnH=s1)oNdlWGg2?EE+4J6!zE z4yf+u&M>;p9xc%*Ny|_mPX-%|%V>4lxd~6${6w8>3x|%;lAY-Z)by=!P%OA)f4Js& z1U(PTTG!KILX;WX-1-&i&YI)skoS6`&_*wJ7Vchr#N)x*pf_xxZ2}{HdL!~AN=JQp zeba0Xc59!W9hbkg4t4U`vJ?@LE5&zeAkfv(96F^uo8G5+x=m{Mn2wUD2Ff(h!XSBy zDy~B%p__#-&)Y5{ht96B{J1C89mDd2cz{{_pVpD&?K3t?e7ZKgRrIZdpuXv0mpLSro%b>ly}z7 zsD{?|uK9zEkcL*=RdR;d6dPinp9Xg0IGkUtdF!N~FpHElVB%%7oT>F9(StUU*!7BwB}&tLG} zGTHT()Kp8w%NiWP!R#@Xrk`&u;H%bsw~=M%CRo8_5`;7AL%bXceHP-L3rzVY<+x{0 zuzLUQ`{qcIkT9idImiA%QsJtHrK~5={qoH4sm3d*+1pa*eL>H?DH@|panoxwvN2HH zqjB&&Rki6{SZwOq-!F%{)}O0n%hB2v^_F`6CvSXiWQxv-X@Asm52FazUyb`i2%L*k zz{od~`d?n)V??JmZ1Rsl0>PmyF*E)JA7WQE<=2y};dz_g&x;WnjUm#lr#ID#95Ha1 z@mZn@%S-T_r}k7zLGm|Mn*o2v8Ef@v5^D+%?P5l^9N!?M?t+Uyd{xTEuV=~yrsLl6 zCNbeCu-J&wm4G^Q8LwHHzbw%?^*at3bzIypMy4`Ye|aq6on5`k`_hJvTQ`n-isp7U zwi%OHV5<+@lfwGOe$f-A>Fe-p>9r_YvpLxyCE*31e__Svmxrg!>2B}r3EKUCiQfk^ zJ(mG*#F*xk<%JqEDfL_C_>HXeEzOmr$5y120PViTY-sWh2RYsErc*sN&d29%9ND={ zw(<3ujwcS3c6$@x{f3^Wl<^BEbz!`x$fZq_l<8;8;tQe)#a`}#-%5QGQ`kNK^G60l z%0!k>FNK#VHdq~acGc~>i-f(#^nX1AREX63-AZ?qWqxe>4133Dxu$ZbTVRP3Va8^h zK9==H)smX+IYm??LCPJn_RQpQ>odxq4ayBK#TYFahJF}B*o6a+$LSJ==`S=FZxsHl?~+6of6fXXho3+9Naz20dW<^C?HhWdZrT8#>PHcINV62y1;ySXlpDtD)aFr@_G6u59I!{ ze%*N-Fk3uiu6}Cxri>wsLuI*=^VYvYp<);7qpD<^Keov4NtFH6VvsD*+R0Q3dfwop zjlV;_%s%i-R52i9Ug#jx_|C~|YtJ>)L!qU`EgogjW7ALxT{scvLxt<5%MjG3wLX|# zMI{||@3z$1)nveY>Oo1=HJe2j^8_^?2TLxe+eNJ2EEyc0eV5MF95LDZVJXgJc?-Y2 ziQU%Z=bpoa1*=vU{<&r2F9+{@7G;t>xJymV6#+c>Drbq=_CNiT6Fi^+d4LmM0`e_V zFa^O$v$a~wmw$c&0~j6{2t(XP z2de^@F))*|l5l!HC8hqad^k-2tgBQ=HsJ;83+xu_*HvcT(qKq!t3JWX^jE~iiQUh5 zBAq}D6%kNB8MkbtV_ShnrvN=J1+@Dg(vgmOK!*d)^K>|$NF+hbzx~DnRxSJlL!#E< ziV~~IVx(08vtV2#-ck5@EK7ovh}>623aY5rBM3jnMWB2ps!hgxL0#o!H|k=Vcq|;H zS?d!aI%Z-fBOY=g5RDu;w{J;GEF{3M5jawqw^ZV5cy$E|SM;m$!d=L%=}*$1Y0WfS z%rmQ7n>x*)gnBc+msmCQmUvQX3drsR%Sx;gcN2Tcz)n8!?mKHcP7I4#O_gDHUqQzb zsmPR7bD{C)_zmrm8W9gtg%B^Pm}-neahhk*C!=0~OM2B1+~(I_dhn4Up`1n7>d5Et zi+ypCf;MN4`JD5%0+ZW*%yqa|cVDdMooYD@C*H!~2TcsII^a<-p@IldMCn#0TjuqO zWhY~lNWP|K5u>>dzvz|_lHtCDg4n6<9UE^G1P3CY_Dn9DNGrHUS`ZEg#@bVL0lm*(sTESCUZ?W5$|sCS)5T@T;?ortkh z;-_5;H4|~B`KA!E6&OO@JD)@74KvptPqy6tG1PMF{+azjQfoMopETqO%N+8`1%w|v4s z&&IECqtwTb&6Bh#C>%9Hn3!~u~Vg@`v?jHgs@ zLwYUNf2XH>x2Vp_k7nM`REp=?1AY+7#6~X^{3BREAjlhfDpocH1*%J98Z~&<7Yb4? z%2l+ccI}O91W8*x-@>^)*9k3lP@~9>q+s~lL@&unJjs0a+trbZ8!zlCL4Xmz&ZL@# z!&Ane0Y%jx?K*KI3N0_AE&V3j$}olS+x)k~R@wmv=^s^vbn(krmHMaIsS2c- zEQ?W0Gj-@VANfV`oR>7XA7mPIpjMXKV4tr^B;^4|h>De#4>DH+j3^Q|Frb&1f{c3F zg%GGY%jxjLmLa%^Q63c+#}@x`d17&2GnS!RFB^%UVp#pxLa<-5+JSkBpwQS2{K1673gng`%Ha&-9 zz0q3=6p5unDjQmzZN=yEPgSZ)qdi9_PGf!*nK>y$%*+S)^(sUxP{BzB*zY_ zi!<5RD?Za~(d8|~a@KXbXOx2&b&&5DqleGSfcspC2R|-uZLu3uYOMsXlbZ*Xf z1kYb}2eX60fJsUiaKM6sAt0JKrSGi-6^^ieb^;moDx}=_KZfnF2iNeROTqsf#a0fQ z_%7Q(sabIIpS3+jKp8Qlo9yfg?f9>lhZJK(O{{~H>n9eE)@-;lM8eG@NVp8ETI3)L zJIgfp0|_=lO~*Up=#|;7E*-x68^YL&;vdL4PbfCdb44R8$z%#i;8xGIi&YhA_%z#_ z;(hyKQv%^HS#~#iQGL#pHIoZKk{x63pUy?TKpS2^XyxR~g-_BF=??UVwHp>s3^H+e z3|O4;nh1WM2)e$)cv|Vf(alU8fQ&zWhBj?l5TasrBS;eXV*kKh>6r;D>+jsJ=+C<4)&B*r8wVk64tS@)L?Y-y)ZOUqL0m zwlB;`pgg8zjy?UBM6rB z5-yNY{z)=5|NUmFNh_HLpb zoJ|^KVVtZ+yr(B2YwDbqTjI`}Yuyf$aXW53VKN;%1c4EUH^K%IyArct_J2>}+_M4R z;zc`GA}|ktWM%R4I~GlGOv9MC(PZ*moeMf>)Wg0+SG-=v4idV5bH{Hj4koT%6z**R z@sH?Or`Kj1NTlCwUk0$Io%}OzSts5Wv%shxXzF-Z1L+$ZSC!UjCQ+C@yn$$n_b7~e zy<%dcgx#&2msd$s1_4zGydJ?Gl)kRv6lS?E#%b?fGj4#Hr zZ3A6prnE|`PBMBww|2SCtYuj&UFWxP)OoGoxmi6sYWAk+=p5yv-{9f4G^b31x(3#%~e`|kw;If@XO+$rILuZT)TNv81 z=UvSF7Jkmp{)>HyCYUx?rZ-$x;dk)?sk7ydH?MWfhG2>u*PO1;JE2=vlTK6DX!u8& zoUX?qjy&c;k87i-Eq&IgJ9Oo|H$@Y+{znMK;A*M+_3?=hClg7t<+5?LXypy*n|+Qf zh)1e4{gX}|S?#O?E)6b?s=pQAr)8;>seDhC-o~s{0H=j?Y9lXISUs=SsY=`}4o}P_ zE1{B;4h$KOpYCXThp#dLWO4pRZUP1Od-8)S))$hocr25H3=nLgloI(L zJ*li?HmwCOt!G3`vDYE>S(zI&g`3VEJNX ze}q^|X}%I|iamc`^d#(CNi64Wbl0{^+xKwlq~4Z$$D>eQ?iV?Ro^5`AE?Q&O#b>ik z{P8gg;pbd7$LJ)YZ+F^~S0MAk(y#dYKlF|`75r=}S&OTejV_9I<~N`b72E3d7aoI+ zf8&#_k&(0<^&&?py_$>m4%FVml~XS~?K1qCKOgfLR2vl|q_{cA*{)-nvKnBVZ{x>` zgIETzpOF?3cH7r4#IJ0yA;0)M-Y)o zp8RXgJAp;BWwoZlxZ`&`vpFF-Uv_&TbUuo<%3;luKZxvOh>oe(r-{U$#lvGi#~6Hf zQ{9BWk%C?f1C0wlZWVpDh&x>k&`1EY>v;!P`q$!|XWkuN#FFDUNd`K1T+0*HcLAmC zC6LZ(bD!0GZ7%I4T`$=*ysxTJHE3_439CXL{ZWD4f$Y?htC$PV9BVG(K$Y?>pDWGf z*9CUf8=_w%h(wPJD;Kvs$gT!+sj2Q4O?G?IsHj3ROJU?|Uc&2EPJ!^a-Gg*ys~csV z2Eru4o1Bo@(9I?GpyB1;%i>%jln2fBBS-nbY!FKO`zfXr^kE2uHJ%_c1VWCIfpd;6 zzJ45Nm!IvURtI8QrIE@Js$9Mq#5LYrbA|x2ka8#XkmAcm5J98|>N(8AK13HQf3}d# z?gr4D_*hj&KbkL8U)1rPLL;DIKgJL0%l(6Q;+%JMd}wZGnP{)EF0Wf2>@vb8i{N2Xur^-}sX z!Hbx}147HSDQKNDLl@2`7OYnP?Kv^nVL&A!Mv* z?Yp>RW2gfpO~E47m`+)eXCu7MhADS^#QrFCyS~qS4w}P@MsU}Gc66{;_9~#}+0NWU zUSJK~`{Rw9hMJF9SK@S-oon~5i{(Om!OO1IjY^QS)v~?J-^@7s?n$iMhRkJym5L6D zuT)o9OtMw`J9qB>qle>moPnzCYj!nFE)r7(3`*nGB_Q#6q+}gz@B3j3|A3Yd_ zo=MGX7|;)jaxP1V9_-{-FK3NBo!sPNC8~E3N=7HK>0k36*b&8pARAoz)iouqsPzRr zmcEnp#4?rMk(a4x&ImRe?n!5((bCMSx3t1;<6VidgUYP8EChx_{Fb7zm$7iFRG7(y z1gQZ?8{v1{&2pUBJfE{{>32O?9_;o?|5$!=k1Az8zb)Ts_DL5soX(w9*YEdq5lqwh z^4IY%hO3E6+2+)VC4$RV`Snq0b!maoc2@=!>AQ8A6m(O1SC1vasO!6|7h`+B`p={i zke0389MI0>5>kqVOIUAzh?inq{|DxQ`mi<5HmZM~EmZ&W#^u{Q8xr1Wl|3E;ntUeK zCn8z^6!gzFm$mdu;w+{;g2`1Zp|uB4Aq17152e&B_Uj5%UM7N>_=)@@0&aaPNkKBb zqlDI5)5J`>lvHg~TfH_ZXFj%P`91lMnVFT3yrv)A zR=eiZRi#aPu0_g9*^GP6rOG{tSR6Dc>S>-&aT?4K)yMFCvNe>O?FDA~X>S_U_#bFb zJCjvU(uu==`stdv`Bo+*D^Ei;q8M!`xtN*jj}WiHk-u~K+?+oJ4lWrSn<=m7m{~w= z8ITF6u=M`S3kcnL*%3A57-ZCi>ZRm9`Rg=K<5fffU)6Di)d3+$Nkwkw^JgXQ{ zu|n4ESaiWzrNp|eR{Jsawc5C(p~iZ=)^n=%rb~Hi{CeZfi;ddJ89M%)QtK(?WN7CzqSxU(p9W?4B99htGHAJ2(ra_c|0r}$`e{qhS0q`n z5d3_yH8q=)cZukX)WQY=H_@9HHB!vi4dB1L7WJfX&rEb+*o3!!IfK(f1NmSA?u%Kk?WaoULm=&&xBFU4s+M9~I zOWOC--7~nI(>dE1P?xT$FA9mHKq6Rr5tbP zG_Q5cCJJ$JD7zZ%Bu+^PT0}}m(iv)P2+Y_ygA)=9BjrOB~zKZzQk zoEE=v;&@h@{l$_yBM&+35gF^&TL-%5CervSCZes%;{DE6oK!KTT(23O2L&v~+CkPu zM+s9Op|vfPH6b+)qtq3q*+gX2CKy&LG|6Re+T^0YRt#}h2S$9l$?O*2nNI@a{KqGb zP*yk93tfLikq2$;2eF4M0``Nt!g;s;q$Rnm9hF<8TV2y_D4I5SY>4!U>)?tqxgW>#N_LFVsVfbZ--U_df`<<={M ztDgxLtcmW%1Z117B*Xf{Ex19Qm$f$5sY%i4;!an?>9d(lw2Qz=&pE_GI}8`xG=sd> zYfkKo)A)D(NY!7dt1ot9WBTseqY?=gPj5fyU2VVZRUO?}`F?a+=Q%__xUuhg@eh#W z{NLL{Mv)wJ_6V^JVrHAG)#ZE4pPvx~Lwz||@K%ong(Py;`)jl@k)kn(TeC(Qj-U6fNSt-&l>XE12Y6_U`T&AX9 z;dLK3$IvxWOI>BMR9t;LR@U#h)3*wXzG;Vf-w*sC8>6YV8fZHp9lc&6NOj_}@xi0; zm;xc7WCjq*X`sA(MhOl9AlitXmWgwJF+EU zGs?Q<46^sGr6FKx6Q$In~nr5acqiJ0meA4K(g-4Be+M%p|_)OcE zUTu(AB5M{7vfj!WZ^_6wmMuh1iRZq-sN7O<1_IWDWZen_x5z{2?!%5|hQh+4&66eC z>yTZw(W)1?xCLTcV|{n4N~5*tnpkJysn)eg*2$lZWUVHOtqs9q zqvIaXo|U7C(=Mg1XucZZk!#u!?=NO~X$dXncSLZQCalLj?H$jVjObEjvojt_yQ^!* z2$JC>Zl$VEjz&~Yt=bH?cGhOM^vw>op;ci$!QhjGV?OG935U^)*GbdE1-28#7H7mi zq#2n@=P%#+m z!LD`%8m)?l=;@@~lUesLz0KJp{t(E{)upBnK~$0LOU+cvCz~9J-ojYL17>ClsfG2t zZid5-^_Cr9IT>HgxZvZFa&RV98dt>bz|G*UxFRb0?~J}lrmFI%E>u4Xd9m+XcztzQ zrs6kA;8L79Pxg+=;+LCRc~_^g z2WWYxH-qO+E>en>qo2#r-Z*jLUc=@mPN6KZw~>dFehG*K1QDnM+3Rs! z^mZ)@bH4L?Sa^F97rHWS4EYj6%ggbjmKKUB^E{BU>Wf7%++T$iWo|))3&%0S%i-p(2c(Y@3lW=2GpiuYvM2~M{G5%tJsR)o z=Zd zrG%|uP)Be9P$?VKSZU%L>BI|28%tJRU!i8g4q@9rE0(?(S=qHm7vx4pP7pKroK7?wTQu` zS_tTwqmrQUV5kRf%`Cw0`pHcnmq=8g+_%ovMkwf8@JsgHQXWK(Xu>!Y`)MDl8^$MZ zl*ns9>nqQB(51~@YE5rXYx`*#H^9yuMLDbivGOILxM&tlhz2A}E0%(0%5Vc*(xKWR zHdWws{!mQ=(w+F$KGrmidk*~7u^~ZP5apab^^x6XsM+YtKcw3J!p$8QcE@2lWp5K(q9azoe1+FrZ#?IWm$(VtVdJZBQ+ zFJ*>c>t8QABAX}x+d|B{u#3Q?T%4)A9wo^jyaUB+ov)C?P+BkTRG+-qhYrC# z<3Z#(WX4GHi}9&hGJVV~D-(rKP5ObnjkuDaRt+z#8Ybt!FY9;S6HQO+BrBG&V^D5l z`h|CXJmlX)5HT!uATIGG zuh%FHt!q!;CbnL0hzIVGEd)i*bB9vi;ouv{nKM_8jL=*gk<9Yyp{G59A6?(7(EfnU7Kb{FrPKzt-PNHiru27~e&%hLW}{nRRm9Kpe+CE0w4DRO{w<6E|)H z-(S>Bwk>WXkPjcQNDGmVSlM78MI{kV%j*$)X7;g}8RcREm5;aM1sTlHY5s~b+)K?>uUyf`); z?`q$*t*(ER{-uF3UKYj63ur@#_&8$EhWteAqVl&m!beMv3=|BDH4t?|-9@g}U0ig# z;T#MO-W8J$m&2US-;H+l z8Sz>LHToI>O?g`ot+7jWrCsnBgjC^Jamywyl^7-f;>~}CL#&)Eg+-J`M@?^o1zSAm zM-zEy&Weo(A|6fgV0Do>r36%AU_Y9s0kGm|%R%hzCRrnBX!Cr-G$96&F9l-|AX5XZ zy@-P*bHRaZX8S+77ZSgRs6`i4{s&ruRbcF+5)fM)Dw(hV=FHY8#Q1X7udtjee}5Yw z8Tq^bSpRbNP|SHi#}|)S`F}?Gu$x3PdU||H#WQSgp~R25ii?Yr(wY=B0~Y-Us3MmS z6pT5o?X*>TU86t6wi6^v@$U)N*4MwnjMA)e-`2d=bW6*1tb2AEwT675m??P!!S z=bm+nsE{FVcv#43K08{@OD(~JIueoW8V00sDCLnW(0nxVxczhCE|;0h3C9PAABy53 zUfmEODX&04a=MJLOwJY2*hT4!t@-+0*UEe@Y6pnDr%fr=bF7ZZy>$CPqH~{J|f*GOldy9i7itmc~waD3S+w&%mWe z6g&5 z#*eWvcVV~aw1k~(-KUjN4M5zc<{~3>fhF+-&p^}6@*|@{&|%=UTM+rx?;TiKB>y( zrg>258lOhobV6;iX(gX*D*B&7MupcaI>gpsCWczIYy506z<8qRn^jGS7UA@;Yd?!x%@K}iU6mH3WojCc#Vu~dJpQNwv^AFFfXr*Z*cLz~ z#W-Y|tZ1NC!A4`jW?1KhA#2XH>l&mWvfv-ruMG=xoQE6t0aNQ7mgyO4D=aW%4-6<& z*YtD^jlnB7&9$Uc6CKzsd4Tr-jQbVQxvOog2L4D|rd@77)d8$`81k`nny(+~w+#|| zV!&?;d7#7vEd>=5;0rE^Ji=YE$_>0o>cmY0-3?rgehfTUYmK(=rz&}ERh?ljDR)P{7=RW z&Q2EC*Dzi?Ytk$!Njrl4d!9xURC)n@*K*SgyuZ3j98wU&iGkNF=cj$da&nD@#EQ@;b2o+By%TMdJK&%6b&&{YCV_;x7+qL z!Snvw>9@Hj)rIPb=tCrzp0@-HvO9-Dfg4Uzkt4@SWc4seU%kDnUbCW! zSq@-HT+q?pbKSq(RIi;m;0TKQOw_rm%4dIncg+(o+136a`+8QRvwE!b@+Rz&kuZkh ztdhL)CJ4!3qc^RX$EMl&aOjUQt#|gNRk+*Y`gNZd`M`O?QA}ie&coQXS%ENc6YWL@+i2ez))8w-qj^AO*RwRpst)0uAl)xN5gF+hRA^ zXc~x6+tmm9eKAV&TKwW+DS;2Y{goX4_t7dRZ0du$|pF6elOMSQ*iZ_z-!?sSL% z7z!M{r}o?D6>HglO2}JWDxM$aL_jmY4W%Dm2^<1@&g)k``@4^LR}l5v4uc*(f9_3K zr~n;cDRF#6N8REzR#Fo&_J7ISVf4OAes{+pn_vcT0%VsrZ=^&m4#~DyuTkCGeOVH} zyjFVyy1L%{9bI7=(q32<|E`EL-a0FOgYLcPq}P@V@7Ag}9 z-^y*kx@PA}W%8b{XM^WGVWqg=4{z27dWU4ocdzqRq-r~tK zF2Rl1gV^^!YGGuI_j-X3X_3EPXHPr+Aeg$6J!uc!ci1={Sz(VAuywkq$MstL9=su_ z4RibMu(aAqyH&Zpj`DY6s)jS7$nkdq>{g}2XvduPYMq9ekNkCC>$lvfG2-k-z`V0| zQ@&r9^+~5ln8F;2u8p4^IG7s-Zdc^P-$c3A5;(ApO=8iG+j7%{?$@>t*9umBgYF&l zr2fbLdUZE`^%`f6B-Bvr9k?$)ZCTvVpafoi%7|Z%ZL(W61A^dA$>mBg3XfbbrA%XW+?^U6)W05lno)fLdC()4uKW zVO}wBWo$SL76(H+>_9z5#UZ8S?Keob%_5LxD6$rbsk%8YTxdLP%MyuV~{bPZLnb%-}_zWWfqjLg7C8R;E7|(M1W~Lt>N;( zo7SF%{_`c51)H~CyM*A`b;x-w_`eOH+dmVM!%t0j%jO0Cu0d839JBgix-{kt-3 z+WIHo;jl0;7*m|bUE4*wap&|UC?{1}nBwMiNGNtxeWr;Tva8T9XzfxMPjX$@bxipe zUwp6_gj;EN=&(!fVSGYH5fey5xpU=k7R=SLMK82>#MvViM!QmS{F23M^Kq^ULR<>E zYZSY?NjSYmjSMjLHkFkOkgSKrUK_E3Hx#kNfe5Fyk|8O3@Bsr?gf2g>FRv1|^-ptG z&?_4Y^z?531D!DYWd?xvyzY08Io5grRq62kVmi$b=eD`w=uN-|pke^)8_b7&*SdR; zb#}K~crBzA#3n%tRUp(Il4Yj;xNVcx^V^=L|9SO7-&9h(6O*Hny5LI+qbnn9Y1O@^ zJ7?%_%yo%@{XgDB2%BC=@1+VxU+pH#!I{lIt95_7I7*Gqbgo|SXmKs@R&wce?{)CP z0Y#r=B!>koHai6JBy6+9s)a34(i2Pf55`^oT)rJKT0c@!$@i2tdg^?Voj45Gvj{ki zp;g7RWh`;Cmn_Rw3%bFAo;k|pUipG`HyBf6CLzb16l(%Fa~r;dV`amqu*Cpe0S-qa zVZm~vG3_Ogq&#w(hk7fcvETK^VdCd*IRpjoL#XlZ9jT&d$L#k`sLwBjYjiu!Yjc{0 zm5ooH8WT%ye{MfhGdee3DZ$nwg|6=oD>dr96@y$vh8N&Ak!rQS0E5b}SEyA*w zAMaOH>KDwmI2+c#w$kVt1RqNA=+1y+K3BRq&PWG-iDB$pBA<|y@a$gVd8;V$!P|T& zr6g-aklHuq4%y9wkwY3KSD$mA-7#}RY-Zi>ZFpwDRYRMad23& zvMX4@F~p=Uz=h0r;eY!;b9n!qtMDJSAJbFzc~NIHV%gHVJGrBVR)iD5-@-uHxI}{^5-!_% zmW`c<_`f~g1I%!r6?=Rdd?wu<%*;~Q-5nOZBnvj6j*ieQtn}P-9|(CtIIQ|#vfdB= z=lyNO4v_J^0xX5ps!`l;Quks_8+Wg>~tMd%H3%QGJ{B=g_l9z;h$VQ+>wN@PvVdNSd1{DKxd zeEzO+=-95`)*`FV|NG+4cukU5#U`CudaE&8d`Sa>Z$eQrjfqvRN#<*2zy7=Uugvd} zlBYx&b!&&eE#zeV41TNzA@KXUsapR#(gOjZ&S#3R{6pMG{|{AH84yR(w22Uc2X_w; z+yjK*!QCB#ySr|J1`qBKJh;0BcXxML+*y`g_%?T!d+(bc^JBWFr>3U6y6357c21xu~f8UGZ+32jsQ1gFaMzM9ZW#wGsL2d-*vPAruj=}zTC|F_bYK9K_k z8;M*(-WwbvF`TB#Fo>bHgNOGz>lO)z{#1m7dOP3g497eGsz!Y}EW0D$6= zQvKD&MAyK8&2+sjAB`x5Ya?17SE&L<#wx$(+G^ z{#f_tMULy9mP-o#s6WoX5Xcm^d0p4L+mmca@}FOw*+E+|qHG-Lgn49nyWC>ZK*AE4 z)kA;3<5V2%U)|iKp5MT;;INBs4q<{Vs5#{qHkPdDWVvrYS_Vr|NtpnkoP8q-CToJ? z*@vmvDp;Q?Cx}zy%&uUiEq`a2Bdctd-`Ld`TU`!%DIuU2kk*(UFov;=*vR&MUaW4$yTMp_ccYOl)9DuB?L zyhVoBe~Fk!0Rt1d#JLr+7{yo`7CY^y_<@|Zo1_+#&lH&$M`&F@|V01wSz@zTt3{lhI1#b zb;6vBnfp>!p$Ho}dnDsbJg|R73lOy$CqDBPs#;Ab=R zcQ*EYsE)v~fYT=)!?BMIc4ujEih2H`T(?U7Ll^elTlnPF1${xdncb z&PQtbmIU(Zr(^%KZw3-u-xu>WVBQt>4{qpn=+*?bMM}O`ht2vLByDes_}r`Ox3-tU zjN>VPtHR=`JbCu>syJ~mi;@BC2WmJq_Ys$PrAwso{H32bfZtJxG+X6N0&L*@)Y8$S%M1RZz%RnNK14rdyTM7>xcS#t zk)&NTVlU!1)9EwIps?PM2J?U&7@;|`>M3HvEu zPQ63EDJxuEBU2D!sA9!!RK%w-HuOUy%XHT;KPPyzFzF42`9}?FMVin$lU-Mi{9!-{ zRr@m@*uIn7tm3?9qJ6TtNfs{p$p^m#_gmak+;8A$7!;1Pe_eOnK;7*l)4Ckm3^5lk zHwR;=Ua^n9uKineFlkb8WTPjt*E{rhnyIvLvaf%@h@{QP=1X@ii9yywL>u%AItJM~ zadN~Lgh6?2!Bu8qGG2Z2WQFhulQIT?3#jD2wK>!UXzNox^ayCg^Y=B$Pj^j zh&bggyjT106CXD?RKR$;JoOyD zwM4Pa$T(_QK0QNy(vmlQ1_r|V7ZjLYXmT~ss8|B^Z7%liqMg~?!f#zvVe%R+MW)`QDt+2rtrAE4x;qJ?bWMG^;>-N1`i=nBLfwse`o$Y zu9Js|Qr;#$?SyM-205W?5!2B@KozEP+r_kuMW3L=$E=(jnIklBqcF>#o8L6Lq>M}D zMe^j82tfB-XKNEFbTo}t#&_R!B%&GtyRAtD%sG~Vz8Y54uT~(ZPsELnM+Qx6W1%bq zTO$v{#=+<5cTav($2eDh{T${);S~!idf0&ctHQ_4!#hFmF8!~&!(CsCb~lVC@yhjj z!om0M4Sw2Mzu^DY)AzZ+iWGLY^UlZ$yKqoHtCL;ol<8IR*7R2YMpc8Z${6MwnMK=v z9l*-j5XXK&#ZCqo3Pv|MEcY~cWQ4|?4LgyF0o1ny0b&bv*1a8m59ZClD?I_w&0gCJ zSb|OmP*bGDyHIBW^>(YPwPdVo`n-Vh?tTDU39JA|e}B#6yc$)Za-zS<_E_31GW8}! zX1kE@dj949Yu#F)BYa$40H1uQFVINs+o$2)%06Mgc zy#1Ev-fV3L{_<{>1#;hMeX@4c&Mm&ec&7{Ux&Xx~Kw=-A*E)J{kLlN*27k5~;C6;Q zoliDyhYNV$$wY}>>)P*af4r_VvX;0y;D4_f#P&I)-In|wbN^VI`9;VD+HGO~7`PS! z9qj9f*@5P1me^O5;b%pjH)(ii@Pf|GHx_Z!GCrjBA40 z7udg+JQIxuxv0-#LT{EC1&WrY->5oR?BpkrPd^kdV}+P@QfNeR*>IX@-l1@(_qWHg zxFSWUm8?d+eW-|3EUOCuzf7knp z&$F0yp+L$=Fa8;K+r^BWn~%xSlK^B|&5uC5ggzzJKcT*U3!gfitFuQ&!f-@QX^RON z4WnW)ipRy=r?~B(HTJ|ONzn*j`-p+x7TxaF@#KE+pSYpT3nupR-PEaQMFFL`%_QF5xr46_i^(4PY6} zS`r8;xnw3@bTc~|I*hw;m3F4talc$4x8>%^KRN>(5U{IJVBhyMJT9S1tv0c%FM;3j za9+;H`~)K@Z`qO&hegEl^`%b#Jt+uX2@`($^!V755SAgfdEy!5-mchl{romCs%ubi zkPduGhTHCQb()_R};|I?s={@?a&at2paQGFn>m!m!j$5&de^2{2C7fAkN`FNd-K6~a(mxj3CM<)UaTI+ zIHyfSD>cC>$`^Cl@=EuYg>=teJY4hpfTG8E61z3^{R}lJA|-eP1b7-JKaJ_n3gkqW zWmY1`y_C2mCiNWsa*m+ytiyb!;Qd6*-N3A|@PRzdxft-xPQE*?-Jr>|^!2C5D)<88 z!Pvip~?WsY*IrgQYEE2HZFegb?>7y_)Nq=B}ANqM%QausEqsQ{t$AUfl zrp9W&RB?(#8BqfGGDYmZPsjPr}MGy1(bR(Px{A0gw8%fX|)3 z0N&@h=wuO6N*9lVkZ1wMHGsqVug3tB8Cx>k@D57oW6qtY$L9H0>{CCe-u3f_4G&QG zeDL6*c`(0IIghZ^`P8uzrA{`I;Sw(JUC)vYf%T^_Mg!8(PJiPi5`)yc4XFFdMQ_E5 z>v$*@t(I&w=_&v^bp?I6;dg(8Sa(24@=-)P6q)^wS3!yVn_VYsMrR0=Q?>-cq^C@; z&^Hi+0e&At(;w&&QP9ZVM{#_tINRJ3Y?dW$q4=CVgb@}Shmm$8&H0N7Gc@u+@Ja<=_$#3hxEW~aaM?wPDD@z-nh5#rcA^1p-ziLe0$brKzIGj`FSVX?ah{b zx=$O=+ShnGR?=R#X<|m6xiY{*W(r60hX6XRQ^=4kE>#^7f(*K?&;Iy~2M>vP>MQuL zLPYjetA#aAqhqS(J-z8&;+9cwfWgYiRc9*W!%4aTy~e9s*XObe&DL+h&hl#Crk;ia+>}KbO3^rqR}Ju=4O*4MMCCBKzkoJ zVmpc-eLMYOL3s~*D`*_Ll{9vh>s|)aF>YDX>((gPAfr_|6ClY^;sbv?h+KdmB?~9TOYh`z?R<^whmzl2Ts!# z)?QC&oHk2j)K0yV;k$*H*hTIfJS?KEX-L)4;qI1CEsqFhp4aX0c_?8iK zY%S7$azV$AaG;K&o+%V>C9Ajd6ZpA&|7Ies>cd3UVv1EpHx60plB7x>S_gf_(c_jE zkH|^lESs`E2G;0m(D8*EOR=u_w7;m9WN$wLf(nXnYCSZw;+EZ78t*AzO=y~Jv#KHr zg-qXhor4pKSEU2nxjY!pmxUXeQoU`DE(mGpNm<5{juqE-P*lbx?#tXWWP*l(vil0n z2yp%$_^B^X_q&>6OPobQV9c7*s;z}M1w>Dcd%Z^6r<3mCvrHdBCLd^LGLDJV2doTp zQ^w-(q}3y(HjvhcoIRTdsM&L=oOv~8s;C3_j+b~*Hb)a@OAC?}wa&p8O|-r=ZJ@rl z7v8_*?0wSWgH#*y`wBBsHTc-59=ocv#rf2=ixGm`ML4){wxUDO(WnHb9b~MItpmBD zE2DZ<|I)Y+N4KR3hI?4p){clQucPgrOn0V7H~7A5HH)h)Q2-K>K;!Wj3a*Z?*0}05(2j4*3L&&gUx*-QwGEkq=bf zcX%8mfmPu$fNXk}9;DL3jFJ=&df^p^yBszLR>PhFD-9}02X^lGS)QgX!LjEjz1fAfzCoa}9`mpu(~~BKuF+uL zG_jN;qn>VmShz3PQOjKZ&@HjP+ed=oO@RY{dTVr)Ky)cc=nhF*z7q8E-Arik1}cDt zs{1wic*873Fz>y6>`6=rqK^R_6`!A}EM9+P_nzs?PZI>63tJP)eCUiPlya<%6Kt@)hVxP?S= zijnfw>G;-{j?U)|p^Yxb`EIrT`8 z7&l4p;4HMg zIXWB1C*1OO*NI1Nd%r;ryrQLrCvXCl9d1XHOq4Y&=260c6g!?~k;fCQr-m0nzzX#Y zah01?_~B=2AaW|V6~&#g+Be7x7kc-#mZJvnyy$c zq!${Eh8JhhSoU(>8k<8d#IVPZiT@}^p|l&VH=--~l}Nio+Upd7eJ4Kkks=B$%=w#$ zn!#IAP}@CLtk($U22)bVj*y@*uOH%R<(O+@OZGuw5H^z%uH zP8U)6dmx|3Lds>Hdi&pN;UC$$v9sH8-BM9$&hS0I0*eCsk-Q18zg}FeRskLryynUn zKQE%KkLi8gJ7H&{A#QP2Ah_N;a$&ej>MWy=@`=&$Tp^ucdZpsfh)!>o>!EG_wuM=? z+0ioVt6xSqc;#a6%hs0~tzYw{t1#lGiv6poR9Cg24#COfI-4)#@+5{Xq;9bMg1(c* z($dE4OCif9=%p+7A#d-tS-y&TyDzCIwK^snvcmTZS-avGXJb3phk4H?yhFbuiPuno z@Bd!3-o$X!))kwLOp6k>>SeJ?C9@s_q!~?{+-@Ul{|Ry-DLMyy$)|}&cAJvsW zmx8>^-=6U;j^D@>Ll~b}rjGoAWKR#6f<2-`&gvq)sYzftBBUR@%!gS(Y8iCQ!ld*t zO91mrD7UnfrK|tkfFq~dVt^$+ZOqZTqrekb6SkBMN#DeaIxFVAV3Eem-gx)kJZ&ME zSiZnL6w%A%Dsu}_ zNe|1w+kQ0g7nzaW4C@RA9uIl8nRuResoeE|eddRp6K9KWcF9!^wKv^U2ItzW4i!h6 z7~{XS@%!-jRr?Bt$JRFORHt%~sc26XSf5#p26s9Z3}v)a^#9JeOb&h*vrd8lfFA;OTs%j%VSW z;}H~e-{w?^%wRj@91kP2zC}$l%{*iF-O=l&6dzQXwMgc7*1-`G*dH@oJ=6328lCyx zMjzZg1Jnh}X@hd5NG}{SBi-L0<3oElMdyy)taz|pm-B+2W+U~)onLg&*@G-Hf@PtU zfJ+hux)v6qzg-n@a6U~A9x{a#SclB2;w4jdYbrnW8s+AnnY&lyf{hQUK@nK?zGBDp z^tWqH30hPl7lYYEPqRr!{ZTn5<20#z4j#Jz!D?l;vkt%8yl=zgB2j+ptfJQxV)n2V zViHY#vSbl_NV?0fviJIgj~OlLOGv!B3N@Z@HJo8N4dB|n311(fhr14Yx@Q2XE~T+Y z2cl^*-eXfA50$1sbMI^Dcmv>zkd)3dG%xI^h}(E$7W})d3lN*?#2`@>5X=HKtuHHb`=19FjkZ!tjm?AUDWCjAji$ElB$-S z#K5G}vT}Dff<#5px>oYdS{A0Mw^zmt)!WRn@30+WQHx}dh$tQsahemNSfZw5UpH>4 z-f$kOxowgbnJPwBg{ubZ2t;<%g4Jhy$Y;83E1G?TB&~5HlX@7-=J zT#4k?))yptH3QlWMOQyt%s|O;zpX_XiCz-T&`WKU3;TC4=Tv72+%(mv68hykf|$kK z>LrelBW4&XO;2#rosx8IxGV5baf4d3`rQM(ah{-$OI$n|e4#yQS`8*}*fB{c^UU?R zw!KW^YS8&qNXQlUKxd1QTp{=x-Jy{4~1=6vs4L8qs zF9t3uneZ=&?R4Aad#Aju9~CL*C=h6;c(@)asI>H^)ccN|xR4gfWl`#PhPwSq8J72% z)BZ4~Q9!P@l#DB`gig8nAu^vCUN}}lqiCh|f`rP$_w>g%E{s7pr7F*&#X&%W$@;#y zJ5g8pho^*Dnc%g>5xp=JGE8QeLPic(Bhw_t@xAzM6Prl1&GDtbfk@jTh;1mB0SvQJ zEgJ3a2ZxiC`%0cG_a#bus| zg)_!D0J*J9%sO0hqRh;xHrU%uMQw%8Do^ls|LFuc~*HHEzT8R8TP| zzu!DQz46gYAQfE-UivhiQOvJVKCgD$h^=w;yiXi2`mZ&E2J^5emnp)9obEBAfB|$!|D$?PzmNtSM)3#O?9v z>%$RwqLiRYHMA3tCoXDpd(eQh3|Q!5vE_FZPNe-&LzAi^4bb_wg$=`bQKuoIGL*bC zX(g1z!z-_$*z*i+l2>LU1{P~u`dV5smsGczEeaV06m7vh7VQ2Uc{&>OEKwcarOl(p z2}aK0^H$YSda_K1 z6{-?dMs^S}Elj~bOjR^=reTbLrBsfW+DQM;mx)!N_4$dt<$CDYiQoaxiVOz1Io$|t zXCKK%V*c$SKJ4kRVu(_rcLZ(x(1f{#mk|)OG9~2rD)BprP;WX7jUHNea#uvZ0ZDY{ zUNQu6yxvAQVogdSb2T%(m2^kY6Q=45q_Dn=I;N;?fE1VN$#G$je~VMX$$n$lwtJUn z|N3;)#c~APxa{S(Pyc~>1rXKZw;b9C&#JDf*E27Q;Y_KY#d#*NzIawPE#=?>KqF(w z)uhD>z6ohHI7BLHxzFLqv016>@4LKoP)8g7v{(nx6jhVg3JZHnTT)}dt{yzOdpnDd zT~fb5!WY*&j?h1Z=YEAKF=x+K7s7uIJx~M+oQn5!e+w-|m~BJ)i$8)=TvGDErF}AdUCt(@5s-QS*3W=Lm-Nb=+2GZ4Cz(Su z?l(>Pdq*wy#-h7K#$|}5CK{;6x{qtHGzyYg@jk+fFD17z-hb3@j0gP$)#D+1hlj$U zp1V)Xv-iAPHSN(Tt& zeIOWpJ7^#;v$%PVPcKtvd=;l`Z5gc^zN5l4vXR z^pp&Z+Q(lvGR#M8(kHAFE|;aS>!w^vQh4GjFGX_}7h%9U+4r$Z!;$8tLkIQfuxhNA zse;Gq)kK8r{x4ws2QwJ|K|Nqb?6qrfR(n^3eX5!&_9wxzbXRhBbPWAhZVy^mujljp z7>0+?c#lvY&oP>?&d^(utWCWTsyWeYBv)MSEXrjm5T@h+2u4yG*`A2UwiI(PaM8VR z4Sx@%4;yj(t*Doo(qj2)t!IOIM+eN7`*99E4kkTq?5`yNUHlG!dEL74cM?}ubNrZ~ z14qyO-XvImV;+VGF9^J@An0pN5Y2yxh?h`s$uL}D5un>9xt&k1H9CkOJhh0cuuHq4 zGWLugxaH!br#0=(Oj3j+p&tdOH=@ZzD~DBOs@>|cWyGL1C;+Jg66Fv8g!{uhg&6%m zt3+h#Y(eg)>}v0S8@i)y*bbYh3>IMQDwVR|^IY&K-usy$FS``mrOXmpR_IHZuH^ma zW7LJ5VB1tPY7hpXBizC3Y2C@EQ|{-L9J_-@ioKsA)}1td=lxL=*>)d9}2 zPgw$7?YP}!q~{%=$M>GpRRTO8p++AxlpN!$$8WkjK)h>lrTyeh-ltOhaYaeJhMJv9 zd~H!!#9OMPC>PT+pe*lL3H#v2y=(^%7jf${r;H=>`%>j5&+avf?eZ~c4-{*Ga3At; z=Y2qVmXUL|zH)Se`$sl6n3oIoW}m0BjrSu)>h~GORrPrK``%BU*)mt!Ns61Lxt;L2 z9rz#;CBe2Kf8pDLjUSP=BT1Rv*izDv+tVG2NJtqI@iL6qf^WGXGoi`AZYjNRJ~8R8 zsPBtSJQ@n;%qCP5Fs?MTmTuO-71K`V_B762mD`rDd?}VQZQ|{BPW0FT~4fjh~33 zVN<`CWi}uG*CHo-l{leEzlX=x7uf6kIr3<4t~!MUt~6f@@cg+3H*sxoTG`Pqa?vRa zTL&9$|5-%C6dj|a_Z19ekPJn?|0&=@{qHS8##p)%6x|ep2t*F8|336fxEPH1C;hIk zGx#tezB258yvEc_O>QD~d?G4SNp}{S5HBM z3O_<8YLguz+Wn_B;9ZlzB8+TufvU# z!~ctx^ZPoSg$;^cAYs(3(z{LBQB~j9WW@D{-6Up%hF$-P`h`YVCEWo8WQZVx@d%4y zG2|p+J6;Z(=_!-x3QE42e&&h{iClaq zl6q0^^(i3nfAu1xvMVM6uO~Ge#DWRz!5iPbds1yR!$(9~{wG6$h%;0BOeDIh}|!Qzqxn6jqh;uWk$|$Q!EHVc zZ+!lz(G4R^1&QoHCTFx^1cZjk*O>p+k}Mgqqbs)b!e!^CzF03w@LvYzcY`oO5z?&x z@3B>$6I--kYVl#w&?#hB{NO(w`2Zh`R37(jRtev5RR2N^*^*|(IivvM6cqUyW;Z6V zBOY$IW!m{u;V(~p90vXAJpUd{+KiB81?Wzi!&z)0X=_a#;5q#VL<3*=qKk}bFhj}D zPgvzSQ}*YT+Y;!?hl6RC1M}k3i3VeUrkM+883AM$$G593dynSaZ{P$f-8=I-abl{O zH*M|;UX<)T9`j>XL`$u%d7o~o^-`v~lAA}42sG|J2pX6d;>6V6XehMWymb=DN%wPS z28sJE)Vwjhje>g_ESOSjbvmX{WzOSf-J59eShvhggX$pGt|8#WS$6*@(M_ztW%24f zNiFfR{Cfp!6e@99xZpykDe@LImsF;1nsJ1n-aIRzm~mR?vxylMpT zDqz7v+q@1bmo{R=?4)P7cZ@{B&v;PsvBAGVfl|Dl+A5b?U+tw(o7Ua&ifcjjw)vT( z-tu?ME24u36CX^Htn-<8SX!W6AM3YY_r7^(B>-%EOJy6Su2HM61i8VxMn~2>?iClP zZ|g~U){tt6dRV&VN%M9(b}$W;Cu{}=(Nua*srJVG-1TV%?htg{&DXpS0}MH@t;}^! z9(h~*^=SCV!1%3MmH8h5a7qnyo<|XZ{KB9Y(BPU&8fjz}j^H=9DYcdJTPSf1?U7?Ft_l0*m3u#S98&V4V zV_CW_M<&;g3sqLY|DTi%x66Zl$px@uL?qXhU3=|5M2 zb`p|4C+97tbsma^&oW{%c&dC&q<{y|HEf;puk?DoxK)$jTl`3!=DYi8r*SmiUd|&L z0KBX)T$}|2MXY69c3m9Ca_Nj98(l|vxDNadV?FmJq5oU{DjWG4H^_9+HI#);^2NtK zq6qow&NVJ;i`drHYBm1K!zPva-H_3i5pQ@mJnhx_l;~fFwC`|Q9^ zonI{SJ~6De?9jbOg8B;dU>W;fU3Rapca@*0p!Z$(OD`5A742?cyj;2_;tlluk*Hh0 zaY0?EUY0$nCu{oMgM3&JvzfPG=Ci_HVhp-Gi85(!m*OCN7lIs>LZiQY$M*QRwQWrOzB!8a#XYSp+YftUd7jXV^DYj%$jxu!+P$*q#lXXHdxF z9b?U}wROMNj>~pdxj@jb7z@@{&LsC?joW^e8*oKm`BUDuKP*z+Nu=Jw80WLZlB%=L zMlT*H&*~f4%uxNsgMCjchThvaNSog2uYlEhd#_jW(Z^D+H!?!Bs6ag{3a5JP$DqU* z?olL_Qb)ggS|;I^&B{>}1~Rvp>aa@CH*?zdI0@BvF#2!;cRz1{o-#^`u8#+U&C_K{ zj?=aB+OEn-)BQy6_dsK)cj+%P6U z({VE$4#KTp-=RDMnfB$@-DzJWT6}whSjj95egCKqsf>N01ZNY`hbK>Jc?GnhxPT&1VLk^g}q-FbgnOOxird!fr3k% z*xscHnvHOiXRYc^5uv73BTaRwF&wpY>{Hn;#U8d>cJ22q*lBWw9G6{SJbQyM9hh(| zOhh*7?gQFSf>@LlvzdNJSboio5G2`5oE&yO?>OTw}x{tdr$t<8} z+Si%GPAGhZW#M+dtXH34XVjgo(_m61OmtfxW1p~iu+` zd0$3_@`3WK%o>9Rk;v~B+HTP4ena8XttBTRF zt>u7SQC-NZGlZ5D_ncvE*+1ZJwuWCfu$KMO%j_cRE*>)AJr0dPM+<#yvidAWrj_*5 z^kM0|Yii0%$KiYqq5@ct?Onc;$_4)N2IT9m`2d`zW2GQP#zS29U#hrgSCUNcB=9i= zZyFcT*=;$G3-Mt3MY`h08b6Kgr~uXU*ntP^1qH0XwDmU6GY!~q@aF0JSAY&nDkkDR z3enLnQ>I0Y<#dXs5y{%B#D3Er? zMRU97q+M>3BwWE??Ft}P|D@DF{kl&czVxWBfVP8>K^;e-G0C-Ufm++W(co;HzukmT z1r|&$XWeu)-AxmTsnNc#w$`M1H0)}jwOZ_BK0v!JJ2|j3X!5|>IENxpYRMIPA2(>= zMRa(a-uj4mIU*H1YciWkbaeu^s3EucdecJc7 z%Gbf?y2c=p2_*hFz;_DC>)|N+KwX^<_ia3;nhBP{b%XJOf5+v(whcPU0{oVq!~Vg* zvrscjc)Oix>Rh~;mJE>?sQq0G4E%P1drJJo`;Cq4`^(%4V)92)kLaV-;E1t1*mhk+ zo0?hXF8dgK_^ov*932?BxwVNLrKyHCRDLxNwm!0nTzq}0kd+CjYgcbl6LAA{UkjmF ztK24FS%`*vH>hg5NnJL0aKt%BeVLZ=Z^6R3j`a_Z#4p%;rZ&B4!FF|t>isMh>q5PZ zt)bEk%uzVvSA8Hw`cVjq5MOaW>o}>_cxUXE%j8ZJA|~O_FLOsUu1stj6ixm5n2kEO zgIb%wcxVCo1esHbR?xf}W-l1y$@dbty{Ma%?xQx0{)Y4!q&J~BMBhTkOj=P z`MIMxJ_I`uV1q!HUnH19w|Il}aCuprVp$VViQ2qS4)NWRwyNm()W%3<)_md=8+?_m znA1eaR#~9v)%_0_)Q0!OsY&i#Z9~d*xgA{WW}#ZjIzGdbek zIt2!Y4$_UmQjLeY<~?O=XvBijP2$qdUr)5Mbn5o7chNzxNJ-;1;TfaN z(-IZQ;Qe#KVr}Qok%mOh?@w1AWD+p;+Ui*}?L!nzb>`z0t&N9kteg1-fY#_}Q6}!8 ztqrk9!(Nv%<{b^Y9D@eBGMVh_s!}K#l_X7M%#Vw_KOhwh3mQKw4|=$Tn&X@cyWu|a zMFCJ7WRhOj=?6xMaMWSJ{?OzQ`P7w^P&rph3i4cVaF!7yy7J1?BBl1o->llD4&m&# z_L7)?l4>xy^|6V%mTc%PWMdVm96}={FXW&l@;=Nh$~G4L<^!Hpi8ct$M)^*hPc)ZO z!E+Hx1+}X@>*|af8Qdgp82{+uXPKG2%2e3&RX8hC?;T$Y`mWv(EM|Z^;Sr~!j5k|+ z!=`yjxleJv?{IVBPsMRWWYYa`Y6Jxqz>%;!qVok@uXG3~#+B=;% zUd&uZqLZPd&KBrCz`I4Wx48cCF~iwSZE@5JqeQ@y^3|-Q*5WE*%D^e+dxWVue!i@d z+ja_Fo6;Pq;EN!3B{8EzEA(&FQ@Sz%ME%Yz{B*_Rj)_gX!!Pn^gA3Ol@dbDWHX)X0g#8VvMreg2pNbbQB_bjNUZ#V$Az8ik zxtF2r+GulY*QB?o0>pHT#F#Je*w!sD`oWj4AZdZI zD)m9R+nD*uM6msI)12r!<`m|CazOG8xhlV~f2*C_c(%L#k#v}q zuXXTJ&95jQGnZ<3%NbE(h@c=he{D0m#o2y~fo%AmuGF-FCi9!6OV~UGj02X+5`)Vp z)|=b{h9-||7$e=5!oMVu_|SE8Y%0ae6bv5{G^61GOAAIh6RBNT`V3}K>nl3Y(Jb*r zd=b$3{)_tjaIEC2DRX^UVt)3R@9jpj8xTOpb3FNryyWL~3q<DH70&zz$`Ja94Rd@%w|C7WgaCbJ)+7+Q=Lr8$3t+f}9n5tM+pJrR z9`#1+0|o_`pp+Z_Ux3B$(~qjjEo=JpSOy*!Qz@-gg;)CS4w^^fRS8raLPAWnRwELb&x5IH-i2L?8t>MqcF!?(%Sh3 z74&rfNAxL-|3XMLv~*;|iSQu>nhqA)Hp$1V|4J!jyj_xej?@B!aiDgMJ`Tx-{}z4e z>jMUKiyCCdm<5U6`USg+M^vD8c>JfFCb?Zi$pL?lfUW&KWb0pK2#zrAGZ?8f=O4E- z9NZU|w?%5Is$|4P24eYg%fF#vyXAVlce$u8RDYTivJRlb%yH#{0l9c%YOuYw)J+xF zotq^UiarhpDML}In-=kbTp?w{4t)p(GV^w}ORhgZ7-(rJ;g*&4?%@7u=0{!U_3OXm zo4F4jDyg`(Z(Y+#r}BW27=FUSZ!UN;Vs?N$Tslfwf@;H5`jdkT(Ehiv`iEK<_H$XL zU-2(w#eQMK>*^_HeJuS^(VnB^4mAaEhM~pBHd`E)F0Mz4`s^QIv{-6IIT$_tFb?|F-uF zUImXe|CtJjVzE@4e?XC;RWZwdKu;7|%Qvuy1av;w#D4AXpMy4Do7RTT&0+p~zYwg( zFI5CpLC6hG${G>G$M)rUXQze@#lJP+(65E|u>ANQ4jGgM{{!v)2>tpGo$zdRoMZp{N$o@yf^QY=Ro|6owU9*J@8k2Ie5-pKO zrEL;RI7w$OgT73)w&$5#w%1`uQzBbKf8@OSrNEL+2SX)5-#heP9dij?<+-Cl zUv{wnQ)$9RQr;j2XrJ`V8+8H&nA)3lQ*0%Ty54FJ1w9HZQ!eXkp0{z6D6M;&UGxaJ zSU_L6KlyJ_DU<5*?E-1HKdpU$j<#G`_E+nW(W_tG^=D)c-P0pkLcr<&`@XN<7FG<* zxaw)Td`mEn+%>)@WB@=>n{}$Ri zz1^mYgC-WBDC(kqMb_Z~^`O$Fo56~@Hg9+wzuwhHAaXY)OZ4lGD~D91Hu)tqCpF+w z#heCYi&1*L$Nnb(!IwSZ2>mV`x57b`;{aGBg$d%v!3Tr+H&oWG5xPIdbzK_$|lPf z5N-`$%W-7%2*7l=e}JaIZ><&gYV_fU@}O^y-XpsVQvd0xqhC#Y9ZbO1%rhRKZMe}u zG?JS*+HLA&&dDh|eAt!?CtywHV1HYsmC?H9u2{gk$F*yO#!fL@?jd{&P5m8y-e$q+ zSYo0Nj-bte)l>b}M>~$D+6~r7VY{;wIe97!tY|t8`RgTZH{U;muT%u)wom>JLS)2* zF2p}Pzo$r*5}ie-r(hB-e!j{Rz?!c}#V68}B6Ze8D+6p$xp%Fte!y@|-CwgsOS1>x7@m!coPj=6{Slbz{5#?t< zg+o_IfgxlB$AX`&O^`Y627xA@y>t0QBg!+W2D~U#_YMa-8H4smAnjt5Mi7HL+&eQG0Q`O7_#{~08C=mJZK z2B^Wi0aR|U!euZa5&mYs?w!5TP>PE#lvwMit*tgwZ&Z5OBbNDr%V1P#4?Ix4yBu!Buc;9PUF$|KRg zANeI**etF7WXauxJ$_Q$`m(*QT&1?WpuV8_u#1gj3F{`jH1d$PY-lK&-!-z=Wf}tQEl|)=4WJ*p zUA2TXT?mXvz|KXP_Wqd<`o>>$Bh1OE9fIcdR^%T^XbM#tt<9EifBSjWt^xB#N|~ugu)qCEkF7=IP^v(p`#t}2yy%<)$K51O-SgK>7F5w z$wCFaBja~X1e@u3@l76$p?2Yk@5JY?e&ZH1T}klCh<`-uCopd)-#cpEyBUh@IN_y9 z&@zrF9lJDD*MHjcJv!5e>|LiDBcJreM2kx{;N%~BE;NmKx>QzQ za63~qRWNU@St&X{H#0Mr&a2@`vr>n^pu7WH64|jf)S%i>?yK|UlRDkElD%|Lo z>0OA@$wcOu!4FkMNLzx=dh2rX+pQd@e(SDE5y)>b|BtC_49}}syJ^}qNn<-{qsF#v z+qP}Ajcwbu-PpEm+sU{4zF*Hd`%kVbPcr+N+4t<3d(FDn>Ry#Uk!%P-k%MjPM+?+k z7v8J6btLmzm5RCFxk^enXZ)V~vUPabp>q_GMC~D8TA1BcFnGhec@$-%DZR}uNG7Rz!phpe^S|L#l>v!)?O<&mi9d?+xENnTatSMl8FSHUklx- zkl-Vv7@SJO50rdyB{-!@hKB8ntCr1Ez>DKNc6L1ZXEwWsWzWOLMl*5Q`mQoAw;PQV zq}$z@w$ls52b-z`PmJ3$NpLa9LTGFrVc6-(2w+K>g3h<9?2(aGEZiU~d; zlChk5>{ZyeSPpb-BYgRSj4jCn#9>;qfwsmnjC3Mjrh0{!G5HWxJa_m>IxD0jRG$0W zAS0rLGVeETJnZ~ppMGD^=Z#DSHJi$zraF-~_1i+@7xHT)Vq)2VFv;VL7WzGS>ldBA zn&UFhFz<6NdII7k0)FLRUwgmmfj=GBOb_DSo!OkA6zmxJSAi)>^}AF5*7bZyR-nx01OV*o2rauj){BBAoxpxt5)eZ3#JQbsu$4XeE4_;|C zH~q=nqDl3vHFO?M#QP`^(V0YlWKyo>9%5&7zvhJ4Q zXU;o=kS{(PyxA}3suS?2)2K5LZy?>92YHrAntFXD;_w;-U$v%ZyW8vUvYTi z-n?~vT>oJVy<|sR%zrHGk(D7B=EMMf)n@CC1upOY_A;uQ88H~Ua#r@+m3TyJ+{GH z*L9DK^%XPd!Z>RW72=W?mK61c*I4yDlRh*owvZUg(8&tR*+Vd}0W=mRO_Aw5Xw^=- z*;}G%IJh=nL?s4vArFxekDs|}C~uyo$$ms8_z~6hCf-~#N{sFBRT3(Qt-;>Q2X$mkq$SWUT|yC>1tuEbvfD%vaI2U%h+Wvi}YdjbK%d> z-myQJ;}FP5C!g=K?#i_KC$n&FkGso1cMtRP@hENuTHc=j& z{+DIpbT{@UjPr;U)RL};PPByyzuEAQAZmz58-{P{EiTB@6duP*^46{qlCX`24^Fg^ zu0O~L61fy|StoXr)n^!lIIwa~oy0>I6DGlNgG1)voNx zkxYa7;>wzc`hK4-ua@KRWL7~M)&epqgMY?>Qv`5Q&#BWSh5nsA}P2md-Vy@K%NCZ;xNX+Q&DD4y{#$of&B@QFC zX?A7zoyt2IXKSg)A0^r&Q2Pwl2ZyLu!9`$7TwGsu9)1jIdNXl{YOU1{^C!?|3O8PL zWu{2|#R5K|_5^khA}yV9&U9qV6(Q9r)k@(MX?v3`0^|7sgr_-sK@GK#n-rx`EXX7TlYl#mgKWP|AJS#_DHr_8O^4ASC<1T(Ozw{Bq zwGy?!MObkE_Ogh4nhtd-Bk}5=R>@ zWE#+29O0pRQk6*G9N@Hf*M4;zxaFKes=9#4qxW)y)NIQa^Z4)h6)b@V^yV~Z05!0y z8*vX;R+>^c7&k&u37e=`gx}P8bxx0!0$qs2>(Otwdbtx6pc^M&8g+XQQY~QV)7xxy za(AeykpjVwXRX(>oZEb#c4YctXNq<9mkT1+>d0F_CxFCm3Oiv?=<9VD;q~_da(PBB zuF}p^qa}r>-vMkd!m5%B&f+cx5vO}9XY(G|ULQb`@s1V>AWN>I&68&VVR zyVK=S!VS184aNd7SXW6o?8+bOQ{5$vX*I;o=v0oB@2rPFlzw5L6M>Dw09t1GWJa+M z5>-Z}Gb=1gQHtAwDy3P`YB>El6(<`~%zfZ?*H|eozbU(wQ8XN}M4wAKxZHos*wcmW z{cI>;VSm4_z^!~gWyfa1(HcCG4^`1hzfO8stp1eHQLS$^8Qyf#|MQ-^5PKo5QSa96 z(ddPDCCv}eMx2Nm0?SsTh(lOv*DNiG=EuNLK$sj$&s$P!tB=o?H4`=PB_WU>9+_cx z&bKixF3HIe-cUpanb4@DAa%-h^F&HGhB+od9`V?QnonJc#IvC=c+?g%g1ucPX{PPt zfx;?Cvzz@23Q`N5c(tX@-*m<2s$5CNotILQ+!FV-bF(;D-mE7?SjsMg>vD9w*L+{~ zUZMSPUXbm+api5Wmfj&6L_}Th1~HuGFuACcW{Z9lgF?@`0S-_-N0}2K2iRWm ztjxrx&nAxrh_(*?etJC7o~IZZo2qG+`;+u0o%N{n6;e0w>?zXQgYw21HFdFUG&K>zdRssfML1lvm7)S8g28Ypx0nD{Qag_ z9<_w?+^|-CG>O+@JXF#;GHi2RGp+5=_-X4X(cId@s4oZLe^E}8wwD-zP*LH!K@v&O zUMB6;VwtvnlSlW*7HhTYF92;?yHaduB2USF-ti)lYCTI(DI~PM8G0Hs?t@{e?7V#n zeC4^6h<8Ym|8>!L*_?d(6RB7)PCA3V>1W4kqf<`*GFZIPCqnS!>zrjEeK-s#cD`V! zyYP3vrJv(!u{58w1Yf(t9;vmv(8wSvuhxIEXLj;_R}K<}ef;)0W(pOw4&5g}RCrU5FWR_A-HN3;zWz{DSH);2LK<|QA!tCEz!J1?Kn-e` zRJX=PrwIoU(O+?cR0b067@#yF(!E@Ks1_^VuCt8PW#^W1dk?c&`cQw%nfjU7shF_o z<79AjgI25;Kex)Bo9Bg~s8oAip9^t`aMCLxl<*LwM+l~&`*!?K@U>*UBWnNnmlyi` zFFvuP6(ioU9pL-xT-C!LXrGlE5mc{8{-6hxp-&?cqAcGObWEj5R8?x+k4{Sval1;; zsn^)wdUXIEA?2rDw&k8=ue0BmUN)_$UL3u;Ii3b{xAt2&z58)pH)vvb{c#1($vX_x znX;;YMvWz)Nf-F9SjtMB!*#3oG2dknGUe0BDz$7)|2VS=2p)Fs!v>8Wa#4X)H}BMX zqa`=OV)f1wD|K-?sewdl&r965*=!A>!Q15fp*$6n`3jAS=tI_34PwOf!6QYZVmuGs z@7sg7*tTvr;Y6&*`$OGDkiPU;O-ghblVl(~;sI^^;m^$*<$j;hH$S&6 z@>CO=L8*azozZ}{42p#R5bw5oh@=4d4T;tFjL8`i5Bj4||2@Su2F=A@GIbTi^|7kB*T z(N9qThWBS->uQ^X`9ao7y1|bO=^6NIkKbuLRT}mjE|wt^EZ~+N-Q5%7X*cd0E_f!){W4VCKJ6CL(o$3QQdnJvN8thA~^lFb3fsFv!Q{xJg5Y` zE!tdMTGZ!3-H84K6QbpiWYpP2zLA30VdHu4pJL6L1V#@byk6VS(Iz^jQD`Pgtu7U_ zh^!ihA1AI3Uo|>f$}NST!@0bURf(EM_nBbO5yD>%i}mr;Y$NWT-q5;?*SIgZYs?@t zBwUX9tu^b8igB0`O)Of0Gaw~Cc&P9>fj@XmJtd2QNB)uLOzco|b#cH{Z9og4%1Sq3 zH;~2^XUbe3hvH%SvJVnwjbkgvVEsuNUNm(i=2bfjsdJNXB*5wisQXFFJalmATA?{9eh z;;d`TK)>uBlY#1!z`F(&WhAjEr~cuab&`BPp%ppps?y!}fQ3zg^cXbCbPA?qG(sV$ zX=~bP9}y$!A!l69Me;}rZk1l;EGixTr>>Gqy0gfG8itExE9)IJ4*buLO&tzg)=ZIL z;5oyLcDNab?jsQ&8bqi~;zI-`dPW8gr3#!j>``mK2(`NQ7RG>UxxH7nG_!BHb2~2v zB1KJAV$PdBp)JLql{EYQ7VN)Rx;P;J1Y&}C=CwKgE;=6GoZ|hPQsY(ktsa3HZ7qHN z*IxRk9)4;BW{)a>bU8s%iSRJ^Dj?z@@0YBzGLrPTYmTSV79};N%Fz;8s%1M#ZyrF2 zN#9f&`VRkkDtR6JYInwNDS>*7XpP+Wbus1FjUzx6ygNj}N;|r;6vL(lp*GG+XwKE~ z;5zkuld*H9dmR=~+T+W?@*W@2e``h`zmgtiZ$J5INJLlSz9+>V-5N{?f2Wz_jtYxb z?CUC>f`w&*LvIDzu#%NTPfe|Q_aiEZW_Q6LHH05(yMDtZEI<6+U~sI}uR9n^yJb_n z7rbkfhVor}aK!yIFMmZP9w+{?lJo8QfSi4=o$lQb&o6MdDwJJB>wP7i2R52lY;!o| zOdCubdxGuZV$R!hU>0L+0Yg>3yzFgSF)EAQ0;WHZ*PDX12awFim}|bt z0Id$DNf=!c9wvgp zO7FE;>rW}`&07~4P9y^r2`h8Di_-+4N_Qt~`P0_uiX?kqZ$BBc!bBZ*U z{CSydLI&gjB@W9a;S;sCL!Hh<1W!|CIe8{fGM^qzC?-K7QM>#w4293b9S}ZH5SvEXK9DI=VMY4 z2U55|0k#;3b&!W+u?DClj5FiXs6HeQh#G<=*L~fYu~$DbUYh?u6I>%AhI&rr#S*f11pkGGLG4zHD?Xc+u=kwLepj>MHJxAWbY z0H|K;$|STEyWG~sA{;q}#IwcCt7czWSJebk8E!aUPPAk|!|CR`u4UxxFC zlR@)6O1J+mFDe5c>eq0ZalEg^j~k|P7oXhS8j7`=xn%O4P7)q*Ga)__K@1w~3+Q z&eZ|D#;YR;wl~tpr%KkNSuEb|DO```)$qpoVc;vK!J-LG*{IZ;^E17A=t7o z2Zy`MLIuc6bnqhQkO9V6yQO;XdcGb0Fbqk%AvGAKQF7#|un+qHjUpoV+bTHoSX@2s zo0O3L_cE94QeyXwFc2?Jo}ox_x83eTMw8RpM+GhRFZXY6u-ncZIGb7E_^*YU9Zkxi zG;4t$+dB?-#Px?F0GPK{=kq-T)GHW!5kJq;Kc{yJ zyJ*7e-ZCn8sa|m`G%)c1%9LF@H{E=D=}F7Ey5!gbrQS|hGtm7}nh5MnkB7o;2c}t$ zyNnH7E?3(2f`seKV}C~Prifi5Nov|kT#I6ILm?D=@Kf3IC;fVcQFHpp$1RRJtKGNo zoa#lZml_&A8q;zD%-vGv3>AgOt^$xfeVPWeq~uIndMIkm{x}Mb3FiALjis7u$oPRj z5QlV|$SDjhwqlWAbOB zWMQS~_|Dv-^1FEys^1!Fx%KX+`-M#~PJjlODZ)2hU+X|ksQ`DvzLtgA3$87&b{QOLh(Z-~n3+;UIGq6W0)h=3NB4wp}Y6qIoXb3epV;D^p=u zIfbqtrh@SD221w3+7Zt!2JFyL*OcJb-kS|GX;X`sKHCXrg-E^aw|eKiS})Pdi%?*= zFaHMQ_8Omw%obgMUGE5H`+VVq zH;VIkgjzhGeSmCLq1Ki^s`3P$i#RvzH%kBx_<8W~rTu)v;l!Xm35(VkZyCRboK!le zQ#*vnV72D$AiXGu1@?|y>2esG=~G88OgTll4baQJi&sG7Qq!_x|@r|jHHg%q@60)@zd@n zmbYeTIA3`}!$D&*&c=!idZ8z{{Efzh5T)wq02+!(1wotak1%a+G$?JvYfSBGc12=N z)DNe8EIL-I*lCJSjpuze!xd~;DR15oxlnUm>L5Q9YHV1PRUlsbGShW29qioK;`)wA zs9OmcF!3O->tC(+EL)l%nA~265Xy8XRZ6qu^zU(;sSZA&32mfh-cT39yz%3e)s^4aIluX>6!c#2#DOUzoGdxDphkt zPqC2DGda2e872ji|1Z{+jS`!fIv4te9|Nm*9-CE{8sCmj+rLA!OE$SEqwB&6R>FA} zNRFG2sX*nke5zwK z2a_PExjuZ_8=PTau#ajwARVTEl-}n>I%SlfCAh-C&r1O{Wv-?=9d-j-TYgxg>YtaF zQ@JNEwDL3MZ=9ckVYTl`0$u`gdgae8^wyi$YHIPCT28q-3q4n9EhOr;!Z{Y-t(Zl&CA z9EBal3_3d$LY7LNm^vI|a?ER0Rjv9x_3lXFbi0E~QieapWomfA zr~Ux1lIgdNs?C6)fh82I`6M#AmOrT@@<`Kn=Zcz~!Uf5m-ZKZ=w!Ye|bhj^)#`e;( zvamypIQ0E)-l7*xuNGgGZz@cd=dLjDvLJwQDJ(#lM!(vY*DELrXgad7b<7 zje9DSu(L6O!fsiZ(FB)MuA(!Su6L%5e-b^+(+%nzK3aAcey5U=cuG{?)>j|XB7MP; ztCh*Ul)Ha8{s96)nsZ+JT>f@p8lb2~IA=wcpHJ?OtM$nZY|sZRF?-8twsVt!g!Vb` zUb-_iprwNOOW!*82~xJr)0E_5O#lj7d4~g8gFwwt9~V7-e-+3dz8O3}kS-@X<2xV+ z!@**lZ~mjxkqf)VzO+-@0KJw_IqLsdc{-klrHvx;PI`cP8!CV~8@bvURvzrj<(Tm#q@7Kg|R$idDT3V0y; zK^XY;0G|nfJZ<;9K~u9~1`nLDmf?$j#KWFy$~2d=5@YQ;UFb(AVAbE9$`TG2CYHk0 zXQLQ_eWv{W1^D+Lxq~=S>RPSM2K-T`PTxP%zp&i}*ZBdOgr%_x)S%L`80_tt9`adc z$CK>r!-f`JVRFyLgBT1Nn^z3?aMcG~PHe(%hVCpkq?ZlM$U_J&AfR7IWfN4o9O&Ug z3_T3E1}aurpbR93-4TNkUv0 z?R@-|?ux{wiK*S@4FME)tD2fqYKCZ|(j-a> z>F?BP6!;Xz+2G8U!?^Su5?-<8lq;ejKW@H1Ac|)?B9@g54p_*m%kv+ClcSU4=F#S# z3WYlH0qPBeJG8}cu$pP3Ti<$x#6GkrQEM#QM@|05dgICickCXTS;w^Be#)l37wOj& zT*jY@RAi&u@#+<8Koz87jvyLr9 zT(r9S4n67F_@4HSpG82)SXx^HiW}$k>|H*}j!r`DQQ*qoy+p$a)8N3c&Y8=L<2&X4 z@hd3?%qQs@fYy?K0j{g;j)Po|r3|aYJ|>us$Ft`af{VRVwg&ejqJ`=ttJer{rpU%j zLHQO_Y)+>IV@6p5Tr5wTZ$13bz({vA1bKPeaim12*5s#}~winv(3^-;cUR*39fV-jLZH?Dad~kQ(O5`P9?*}N@ zKLhjU`pU9dzG`M}Mo=J~>w^O>|H-%IZonc2gVi&b3b&Jj<;|3gnNKVEOL)=Y2JJUn zPe)QqPJfK;NIil--~XTMD~&`P{1?+zx7%ancETD-tW!M-nISX`(`kQ{A1FR3ds}AA zpj#zr7n3>$WXD5auweVeYNSL#7@!_*_Ey@Cc8vA&IQxB#m$O-5>#M2);;IiJ;X;1? zfBReZOm=KF|I$qlkYNoqJ^|nIgTS%bA2*}y^)Lh3f6$38HPE@>!5K*;jN>b{k^!~L z_*4JSA7LZkcEShp%E&Ff9pj~+-}GK_stXvtt33OYish?qmVgJ`TP|%ZJmqE9t zhs|uqCE-r6`{*eYusx7DuR%stftk+R|M%e+b=|aQ9T0_*eh&s*P_hW7FSy7gn0JhN z!2=NB0Agj+ZwDAb1}me@yklt1?($hEJQh~t%N5gc>!EgI04SfkO;+PkPf)YsbvHH^ z970sV4#UyragUFmm_7Q28n#|kC@4Vq|8e#s_M1`CK^J&Hb^^^U3SbAzj?2ZuBc7u( zmbhI3V9>s35v1;3K@(iaBb&4VN)#jti1(*)lDfl>CeV7sAp|ha-L^Kjt$OS*+Tuj+ z(;N>N|6ddTLm|2pE?i=D#?OS0nh!PtOQ;ngN5vH+GW{m)?qbJFH@T_tN1+FnvSr{P zfrPYeuDjCCgRHY#`MJtwTAWNcw(L}36J<)Sa=2~x2t&*>QR?Pj#_(5Di&KG8h8GK# zDL`N9Ywx-L*%u@b(@=R`TC2Tmci)ebK~QKx5Lpi!v0gHSZ?_($BXlY065q#j0Se@9(}Q43Hz!$<)t%y{eJVEbQC70uWvSa_ZYlvp7{Ac?PW zBV|{x3CN=b-DBhQv^8;<6MpF`cv&Q*rTo@VHe8ViFqg|0#jy8KZLKr<|3+)t+ME9J z0ze4;c73|D{tPqVS_u?K1yyALeqVtN&OhN`BiH_n%E_ie?a5C*@p&2m>q2^_@9l{W z>(cJa?yZ&7ROpSc?S4YAC5JuBS19D`K}H#`^&pL}PWy9|{?Em8M8g1Izb`|+RNp2O ziwuwtr*HrVAA2T35r!Wqigv&Vu%N`{@hqK!sn*{Z#gfl=V}DV*ki)DJQ* z0a)JUhy?NBpIU9q|NRJ3*3-u11HeFPJmUY}9nKGVT&0Wu>wok+7`U2uDZ2q>|K};1 zZF)v^G<@UwL{w}$M^!nc64`R?qDsG5Ba<;TTDy3KY!}sJ%_j;NhcB z`QGur#fXMxh_wpUOAI{P$DOh{z()R;cV7mTPo*RFF`o@0T8=;)$8V*yFTf!SdD9JH z8m9=l&VMkDAf$1?dV2yau3^OAvq+t= zle_Z?Hps|O3Z&~S*R%8!$}6}3+a<^_(7#m$VioA-Jx3%Y#9Z`qB|;qJVZTFiQePfq z63SPb9gYkBk5F?`1HOE-04M^`0%TY39Xa^p3p0aZvEE*A}Dg_4CaV zzBw~_J(jf~qUT4O0!HD_;c4v7gRq9RlhKQ4|7n8~B;PD>ZWeGnQ;f}kprh~QlYa-k z#*Pr6mEt;Ad4oyCoEL5rZG^spCrp?3CqMi5`f6CDEREx5*{R!S+=r=T60D#fUAOV~ zQxK3syyWckiwW5tUwX$?%l#~JU81$i|K2w9a^vYs{!S${Yicvp^snyl@tNFt7=)bT ztpr-(-f%SCzY^(xS~S6ASe_Ot4ex?57hAaN)D<=DMZl6CeE#3tfj5&I91&RnRXp9s zg+PhV3eg;RKpV#Y3$6dtiU$MU?Wg~pi;KVs_@7VyxlHb6*1-ezfY?Hj3T6V##m^U- zESH_KLYXjQ3I6^O4>|ulshYC9CSX#oVF);qBeExdkj4=N#98{%{~dyZsXFx(C!FFj zWhJFpL34B3mjBEs6H6LFJ|Lyshbu*!XCxz9wNdBYLZ{=0F2ySHE<*2fqiO@OEYx-up#^3?>j>8pXn=-C{Z>2M$CIZixgaEXxNX@a^6BTzjL0Y`pdIS#1!u|AYtj zE8`nbxmQi3nnbn3)d2cxg=3cx+4rouX-17lRW0%K6N94Xea`}T83u2cSMB&Uu0GR>9eHS>x#J2m$* zbIp6lm^s)wa70~?Kk4tEt1{ zny2jnI{si;pP4zlHwvd)Je-|`~JUS2oDo&ii*;3;LG_dYi0wU zdX|pSKz#1=H=mE6@tyM0=p1d15AViGAS`bJO3>KZmYWyuTa)UzkN@3~{#cvLMnoLk z36&MewhT9Yh^3{heh@C{1b-gW zZgoS^SNvyaEj%E7KEq-3#oJy&zH)Q$#TT`dV?Vq98jb!^cfr)d(auS*`qw?`b##}YuUBg{jUA#$+P!mPRYOBOWu5oYUkxI zulJmsTbi0({C5A``y~Xl=NVDO*U&{y%=o!(_@_7nNFgIjY@lzGRH1|!46Hr3na%G} zVp`6huUZY>KcRnN<-CSik!tC2gZ;YzcXHF!wWwP)PuQ?t2padp1X@KxKvp&wvHy1R zIfY&(3U+NP>dd0T0m7SzjztFq2#`&?nS6@2XY2JQ?_d4)KlQEO99F^f>+0ut^rF|> z=bD^xJ)XZlH(YFHyvDsB%mFne`3GJ%ZyBXc%ieddcoi>5f~MZJ_n6lY>JdgnZlzOq z1@9g^ub7VBQ^)V;@!GLu(t*lRcct~9uJmnlXG6WPck;h`s{V}cq{55aZEOA%t9gU` zjKzv3YsaNuMa7!vM1udbu#=NReZ7ZN)lWgTg)u^+2t9VxZCC7kOzi3_6^cO9ISJ3Y1Jl9BieIO0asbmZFEAd|V6T1cR&NMJ~ z*tn(k_?Gv*w3Z+>*RMhxP7!*(!JZD9y8&=_HH~wj9GF07e!NwC3|m9V57*$W^T6{1 zk>lax&!B0NiH_8G<9d{MX>qUDf*ZgT>gz{nPz^-%EVF0%NmO_#qj^k+qg59R zCtKv(20I1FCa!Q|GX2`P9!$rb$R)fi3L4d9z_a(=*z42BD3|DX)^Nt;>NU0JgCb_{ zmkqE4Xjr%B{yYl*>O13UoXaS=J7lnVgaadPs89b?3U8n?y9`v8SZc3C(vo&oY3i}} zIZ@Yt#0J9MZf+w|)6C|9u|9q~>m` zwmXltbhKiJd%-SQd%?X_Lac{ZbA6X`&oGts;o)v;zafR4+r@0Avu+uugcME%T2J&d z9Em{X_zJyyF{-42a~n6Iv)b3~5n6fU%C!A))V1YCcK6QDRmy%X8H;25G~*o6PBub! z*GI!p+Ut!UCskWIMQ!J)!CDH9Cc{*{z13H~-YTVtw7*SjNF7E=K$-oX$pwX`b3RQ+g+%j+D#GX`b)> z{gT@zYzItpMxd3?N=N%Pcb?0 z)Bj8=E3m#aaVOc#(1(o#+?v@*ppakGD=EgwhM96}9%n24%=8pB|BlZQXIsj0RcqU+ zl(+3OtybY`xTc(9lYnQCDV_ut?Yb!EionD?5X4Jr>LkRP9ZrS^Hr%~51fMq%b7T_s zApDhK0D;c>nJ0Ji{bC}u{O<6&-3X%jS#Z3fp=&@2q(wMz2`QM&2&FG72}>cCKsS7w^rPB{Up2wwz`}`R3_2HT5U|W3#9;q47`4kCmkhOxAjtca$?52j4rX_Qsjti_*{1BPE2D zucp%RtN37C51QcAKm|+a1c=OyN}^mh*#vecpF4)k4bV-InZ%X?)#hfN6e@@EgKN&; zjqRB-jEFAZGTOTD42He;Joc^ICEry$yD-WMjwH<8W|kmcQIT7~Z9xJB^JlD#6qq;TV>xUhBm=)l_=jMzidD%Vw^T<)_{H>ykZ^fL7yQ9SWhz<|}D9 zqmbEFn3`$GXw;65u3B2m-X4^c?u9im_Mt-Ep=?_)11FyMde4;8ClL@ilaoN>juP^OM!wwh_hs*fPl~rQMz>UA%@6*SDiO! zo1ZRHaV_kUBh!Cw>g%fY`DPV$bs!p}f8}eq4nY{6tjA$6iO~g*5~yp%$TE=g8GN-6 z6B`@PF8(P>t$y0a3)x1S^L&I=s3_j5po2P>H(O+O{4+f})RrH|~x4XG3dKvrQn08bXF{1{2<_ zSn`N|O$02Pl>%j*RfIWK_t7Fu$+YKryW`}5*&)mJ2<~#)(h~g$i z1M>OCX5!*?eJP2OnGHqq=vgrW&j}_vBa2F|j@LF6ef+}vf<%=xYP8Aoi)2NdCS9Gv zaKp%WA$bE1mrJ$K?N(%oVCy*NZrycp^Z4jbMJ|u>Dh<+|PUD}l!H_>&j>}ftH*M3D zTiSr^rU4IFqPsGP?>y|u!>%K|Vh2#I;BQGmN8NhZUC$b(4;NBAB!c59Tx@nx-CR%C zTUQK{jc(6IGWDfd8m_Xxc)p0gzAHx4VX7~QNaDIO*xTVh%1_OF<)D7yH4BHVN`z>; z-dMIU#+BHU#?+TsdeK%K4k5`gI(u7o#(iB>=YY6+B2ee}GF7<{z-BH1f7!jgF#Zb| zM-VHw-iYzc`4TAdl3vk`Bu-X~CNtP7`TL_{K|L5QbuksuW?n)5CqV zg#7pTwj4LVoY5(KV*UwZSyjgG`qC)N0R^#fv9v$1;&&#_#2j73SJze@F@Vgnit^6f zrmz#tJ(ouUExV5^3=;{LT%A9<^@j=IwR7<<5F?+3_6L z^H^3K(Kj3paZ)&{7Tayd(s+RngC_ek@zR?EgZ|L^M3eI`$x61pOO5lZ>^Mc?@9P;F zS<>cqn+HX6rAbSvq!c2T=3Fqr%jB=xp5|rTQD9uj3sQ3nM*2xBnR$*EK{|VjK6GEW z2!&Iq{RR2?pmi`(k=Ny>eSUKP8O)FHA-2fN%{QC-sLRUKG}FS!XrZa@9M=oAV|L`URz6)%@O4G4-37 z#>{5n%bJG*oAIgBoidwXh$k5*uhL!sG!exRfAo{X^!h+<@_QwY7_jIj6B8kDT9w?I zWE2^%IIp?1KU_}TeDoVyP7;rNIjwSi5hne7%>k;H{akdyz{PvbKpkY3InY$KW2Rvj#SedTQIKd z(hsjB8MiH@T%D|$<#76XV`UT1QaC&`24NM5D{jO5(Ef9F*FU`H(nn2N`+Fm=e}p6i z-4Xax3@SGa*yI$JU<&8X2rVc`pzg5NlaB0B0QHDY2I_*@Ms|FZFef9_** zxpPehC@)-M3~7(c()x<`212o9oAO8|X%~F!Qk9BfHvJ&fXH-XyZK#{J0wILol!Tj| z!J(>pMpga|T&t=SPr>~!4+xVeCLWB+W!DVHr|fvC2AVKd@LGuF%dTSXnme`StmlTDNu^&Po%p$ z?AL?|tpPwIa9A-LSeU{S9Sip!3zm>4&(ae}<0qRnGW#O~V~d)?Wq9$V%>Be`DQfAX zY}zcT%_b3rFiZsQ=NMY$p%oY$%~1~L^0SI@vx?waYPdIP^r%6zWQ%uM?|N4fD7K?_}Z6HZ-WtvwYZCok2kQ#NwUtqQ>Y!WznlBo8Qyr=#+6fS({VB0 zH@^JBGVXm%5_Oz)tH1KscN9%(dWZtKDLQYzdc)I_mEI7$!+BJMl%33(wwr6^YrqDO znMwXRFWQh`Qc-@m&_YNoA@*iQ7LX?yVRPl_er5T5SgSJY^JdIx*`^fH7nwosZ$CZv z7wTbn^CVaZD_9wyR2HqT5QcDSjo_kwu|OfCYg2`8Kg!9jmhetnG_B48cr zjPi2W+QutdMT$sO$p#dIM^lIJOv!B`ygYVfuRq5JGK!Xnh;;}%Nqg3P zh^hjInN8yJF3H>m$bq{u_pt@BSQ;1M#KhX9|D)LRN@hc+}qqka?78YH> zbi=teu~3;EVx`Zbxt}4Lu-a-koXI>Dl1~|f2I*BX_8_ySrV?r*Kl$;exh%W5WL{{o zoum#m1DTokwpFe*2#>c))JsDIG@G2;W^0l4+%iTD7x#OO-YIQ}6^G61EC%Jxl*=a; zJ;`ZSKX0&Y;>2=**TcnqbS{Sg>AGO-WMP0HV#T_I~#sucu*{R zl~>WwdxDt<24EQ*)iRwCfdtkM=Dgs0@tOfU%)L)A2`A9F@TMG2)5^OtS2Pli%%Pp% zqYZK1yg7aNgIrnY|3%j__2$=3I{w1A+uMI&TnI2jHz{IN!lULD$F{nbQ;X+1BLhnT z(aY>bNq6HB*=&^YlYcVi8?1;Tt?r_VGe(o%JGEFd@E=}G9!Y=?+jpT&B0*t{>jINr zmZgVol)-z+W-l}5tpjGc%eC(7bhS3iMJHOx6Ay8rZy*B0cDNNRLsrJ7y_?#a_UoF~ z=?@RD=io1*Ug<5D=NXA~E*T3?uZs#zhpf$uV51vfccaWd2c(zi4sT586AoQy7< zs@{6VeqmY8E$jE!c;hx*_eWd=Xk(W@_gW*OjpIr%JOmvvREce*VW5SVJ`EQIhvF$; z_ruI-z%_nwDo>87Di9kav9QP3fdFY!t|sjF-jn3yB?$3jBEI6wO2O>tcq4b%v(z{E z5=5$>Ai>e*KIg2Rm!dTR4V%|k2w)=>V#NvP6UEWVPeepbADP=efIJOdht!<gl$4x0l$G^rcUeQ$2xUJf^o2PZKwe@d(_WqIAl_(vGTbgRN2f@ zU&qusIc&I0n=k3q<*(h2&9&vYyZT!30ZSHE?Qi#I7R`MuXP0zYe_?UVx-CDD8e;<< zXNm4R5_BwW?8=s|q|Xa45c<9*!jppq<-}0oRv}!4i{-vD7GEvZ(H7U{wq2J5nYvR& zWSDGZI5{2!liVI88m}wX3v#KM$jPm`w(+M537sYegkzhmdOv8Aza)Qg_I%oTa=m7D z1^N-CecApm7H|-HSj1yiNOT_?P&6aMDt)1&;w0*LwaT|VY{>mt&xC<8p3N<69^@5u zrKSWShLJC1c|I;&)aDtGq1_9c7_Ks2h>81KX!3HwR(-)Gx-3uhy6h`T0dI<1NDs}UIqw$;7yVjF7&!TWI!pkZZ**N(&@BV za{g{;nf*5VK3!tkWLu!yw6ImBv^iyA+h-0vn2i<+cje&YenM%v`$BBs(ah@a%6!q* zM4<2^zrhlRBYdln$l~xuA*vZ>X;=`EBYBH1k<;2S6p9ll>}C6~%=%5n7~!nb`- zh{+WffbsN5n-uW(rXMMl?1zgzy$AP;NRy1W31UbZTa6jc!2@*k8;}{zz8(>v zO`xb`b=?phGH&Jc;)G$c zx{&Bsd$Wxw*Quqq)}io@+dKRV+TINrEXyYN86%{Y9!|5Q_&F+18 z%zptnHESSTEIR=?2tigVLWD_Gqx2gCLn6 zD-&FWS7`-xqy3qe&J^xPHRTn*#V?6p+5xSj(Js33aYI5ZQwJd27+tLJ!wevs*pjSy zlFFCktj=U3+7nz+Uu^9*eqZz2!0QjIQA*U*g+p>IC#57{H;Ilyvyfj{vyp{sq>Ntp z9G(8Iou?(;yYl;NcQlL?ou<#_^yC0iLjC?}(jP3cvGNp85Fv4cH*}r35orKAuK(&7 zJ->B{;gD-Oj8T(Nq<5ZJZ<6zCbD?NSzj;GfCDPGR<>&)WaxHzaxysNADpdzB^7rH6 z(hvI+!B1smS)Gf=(01PypVeSKw~H#Bmxk&aC!4FVhc;AUUE~KIpcy7>kNPr^#~ab^ zGz^Dl?|lu`LDE>M9fGWwlHr}~Xef;dY?+ugFz1f$?-{4c$WITGPG?*8YVnDbgT9^+ z=JfAu@Z9tqVW1fbo{KfUBTiaR$-P1rXQoCe-2GO_vOl5udoP8`$m8yoy+^`=h;Br1 zHLizwCiRTkE%c6-Lgw7SS@7^0*+PRq#2^*s$-32Q_EEWtBk-J~@(rT_o9QliG!-?n z%V@&KlKt*_d8lESD0^5Wo{YlH$*9|Z$b2VEgZ)f@xrN>e+iiQs0eui|N+oO+9{Q|z z5!a0xjWkaQh_lNwflSvcW{4mx-UwY7=d z+_uR+4Sl~%i&d;u^$rtoy1mxl)zhYVM?oX9SDDd1mDXmvZMuIChu4@hO*5Y(JBs6~ zZ?0`EzK=!KLX6~EE_8fU#AFkc{yiZmIQOi0B0}$bL6#T-VTQiz*&68$5bfQ)GH&43 zQ2Ry4OI)mU!PUZ^c(LP;=a=UAQtLDMDj=n4+`_2 z#Z4Di?PjDj3>0;7SHD!{HQhZMWU*L|;H_GvsONCTNv|>f?aq#32|4FRJeCa}-%MFJ znLi>hGM*IMswoX_e%~$^uZBNR*J5My2Ck;4R?v})ik4E_wMeIq2+Xp{ZaJq8LsCgH>9@4ys`Sq5coyN&mD}#gu zh=!G>gP()NTCq;Hn{aSgr-(97;k+f}D8Tg&lV%MixxA2dOq4$~l zz&{Le4jCAKmIM)E%i}=$^eR43bL?wtzG0wXzUdN?z^#FE6yZ6g*KA@Sxu9v^N-$UbC zMPbVWdmYgx$LK0)EhXAWoLZjds}4LvU*!t1ieA{VHMS+QU}Ip-BipyBdLDZgv5{vu zoF^y<)LvshSY|r;X%Kaywr^zA_zg9v8hj-_o7{Ayk(_;W^j?ONd?`&}KtmC?XoMQU zW<^#BLp0bla#<^GB`2OFb&lnm9r6|G2;5EU?GBPf`A~^g(Ybb}v<8gzBQMhRjfN92 zwbNpumgKXioQ+pHQ=q;BF3+hJ>3}`u{QAfy*PZ3tOmPJdEiPj=Z|HRPizj9>CJhto z02Sq0oP71BgUGDHl9+q{t<-Ept<;qqfxfmk3)$?2jIx)_(}lTUCqJ$Op?Y9Zl+%Ds zu?c}Oc2!5)LudPTgu!GRHQUsfK0C=s>uP~6%Dy7uMgi2$)XMnIL3zD;AP+~PHjTOt zQH2vK>KHZrw}?K_sC(iEexekuE7fVz&6$0l_?IAETh{bob`Tn3*hCc+!|y*SK%p zjBB_{`n89b{EP96>}&?&oC}h3L=5={BBZ+2j8YLzDkW#mYy6%;d<8FFtLNFsWnLVm zl}_J^E`nH2b}$i5BeQ~~!|0amGY$qeou;n<7N0KaLYry(j`Ao@FgkefPu}Z=1I@%d zh=*Ta+)W*~KPGN3aH41LNodsIe!2k|W|icSfGSbZs(i1NusDmGd(#$&h0C=+BqmAU z1G6dAuqP8N;g=}{m!&YWe&pU3g30q)Cf|i3SI56H99FEj0Rbi` z*Kbm)ED~kaKPN9#i(VnYys@%tbK7fQQ@r+y77`}O6RJR`+{|DMtojuYBC6Js%zhdP zkYDjZH9klXlq=GdS0oHt^m#mX<(X}-+6+ERki5J|>L>Qy)!Ve0wy-rQq^+00*ERc% z*TpJ&!i^}9Mp(qZFYlx|A+6PZg$obLsJrYMY07t3iF^L#Z{Uhsc&ZjHSF5|b#6|jJ zr~lq(FR)S22EBE3A9A1?e2o`qs);l_a+=$yPV3LMhy@0=rcVP&^CVL6og@-k^EW5z zH7jwj2#fodj;{RgAppertso+5+Fq3Rs#{ZuoL@|#&}%VE{qAy)X50tIHlLzDw>KNk zj$pF`#%?;=$)p}2Xrnge?Ywc_m?vM~2xy^Z>jIko0rOlHg*M9sK4_AvM$wqjs|apl8l4}wge^#=~0d3`8Ad*8wN13uX&3Zx~+2N z8g`cA_V}V>{=2y?k8`!>Vvs8+4XG09b#e?7nAmb-X(5K#twv+b%tfRozx`$hb%4jr zqoWH7-QTSYVONd!mMKBb&7WYloFkj> z457*S3TL)7WasJudltXm(1N9|kzi={lJ2a-se`w7CK8!Gu)sU?sE#ip)~!U4jR{$e#{_H5KQDNyB4k6usYi zsS=Bh9i7OND(2a!n%iE21zJ&Vm|_02oyGV7=lz!dHKf^}9?Dh4|1TeQaUJ&HY3xsct);9@F+gc-w(eNWDJF-r4PaU4<$vgFq}m9m-r-*EaX3wlD?hB z_PO@I>Vdpo*C7(31&4Q@B)-0X{y4V63-KRRdrMl`MTSumy+@@0qf3Yyo z`J65vI4V1oiIjzA4{oJxc{g9huG*SNi=X|S_-a;mFna!fEt#n_yL=$191$i)?#cX_ zg!SBLCgK_qH|SAN1CE;G07+51dDuS-w z2UKibf{&`cV3*Rlgu*v&*=7U7g z|2O;GXliYQ6EX3K(jaU4&y~gFk60NrdqYp0gKssr_3vHpHA^etBcJ`G@o%Wf5 zgy=TL8cb|%+}bGt`0cvh1V(x$z00ZlQY)+S@%8ldArLq6&($=4*-gZ~nZ+j%6C?6d zvvb9@IM@|G%ta{eloj>xF+tSAHcYZLd?#8j){P~lrs`qC|)gbTB z&@x@%V9*D_aGFf}b#EzF4wgm!|)=C9EK1Kaw$_6I_6K?%~O7T%5)w{$K3Z*aI5 z?njUiyoIgb^nc%fb^CgRaH?&q9HJT9D0>C zo|toJxlKI!E%+zDr#J|bOv)~1pLqlZx*obk_wy$8+ z!u3V>7HE-2!g|2s031?Sl+QcG#Zv5EVjZ4x<`+2?)-UuAvhLWh*J8fNdwbh6X~oxf zaO`&4C9g1lCq*!R%(`-A7fIN$X9GkV*)m8-AX zJf=@?Cd(%t(ZLmo6`tKzpz;&^`!sSspwZwTAbZNDGH?C- zZoKL6*SZ8nn9gl&72{ePsLR%aK|nj@zs_^ym$;g@hfE2371_klXysQ*=}9%8JGf8& z)4i;RwH+kwD`+NPfu~4Oi&)FL@mJAp?_UI<+QxQ;w}lX>>-a}3I-1$B6 zr|ULFM3es$P1>s+=SudANM8N$TmsO_JBh`OKK(It%{4qLv-*PVd7}2>pCe^3-bz3p zNnDqIE*=x>mT@eA<9+>&)7xb&yu0JZE@sU0U&g#0IUEx^0(UIuBltjFwf&h+4$AuD zN*O4{<6_5cDIS7_q?zj>6c;OH@2&fm>C+2ZN|5~fng7$n%&?co$&goa1~tWmPMrRE zT1}~?a>;L`dJi(AH$ry$)<Y67H6mZ&hzR5 zRf3@csml7&cg>IknZI*qEr)wk=tAWotR29fSk{1;=!v0V$sgA0LHthAJ|jKN@Uo%w z6nLz!)w@3s5X<92ZnoHd_e3>?U-6zw6B5+B_}%b(jjo8rIn0v`i%uC94|^_OAC=>Q zF5kjSuaO`u+UcY37eO?$molx$%&|jw5n2j2Q{ePy8Pk1nAY7s5K`Iih{&aW^{Q7-F zpl-pVl?_@zPH}GW*nHp1$;X!bz^!s!}YK*Hh3gXCW4I9__RlcH#*HdIYZ z)5?3gsI80bNMowacX^7q(07|nx7N(^dp(#dHo>>H!=uZ>+HWjvYvzY$yOBamx>~{g0J>IX3&?JpFjR}TBcyc zqh%Ee0#?f5IIHhK27}~4kE;GFoEr~4EB-?MlYpjT?a@Fd)den40nhVH8u$2dJA(BA zeemMM0xz?NfmInX(y`6+y3^A+Cd2jUSgE$?Y_GEfT)2R00(0Ucg1OdgSCk2DA5IfCAHQA}YR*0F`t*mIO%kW~1yqeofS+7y8r46=yo8%Ve zIxWv*~$~-CG(^ zI!PR&%DXzenbyYDdrydnI*gq@297;m{w^f-em6%`-!~vp?hv1YD=sctOU|})Wy3Ai zd)N`xDeA}XqPWk;9Y(Utw}3!d$0lyZ=?Up}+LAH^QTPK{VM&F~ecD(5lejPXUXSa% z9gX`Sz0AU?4GO1!-)KEt@GBJp(mz@uZ|$Heh(M~*-y}iD_I)ws+4YDU-l{Bpq*@nW z8+_>L`^#s~NsJzm5uIU^1>O}mgAU6?hHL7MoU zXhIJz>#nGvW*oHv;(HdROtdt3t|j(VG7qwlF+hr40mYKSm5}(7Fm}v?iJcdrU8`pU z+?yUn21tv7Z(X#tLg`ED!_L zZYL103_UzQ*tkCz$Ko+>dfFr$D~;@tS1Oc5Ye%PW2oXz_?bNdYhPEt#N`mChK#;ga zUhySD2>H>~AaexKS526f;`w{~+(^R~8{D2n?ZGuW=3PUPh}abk^|>x4ros5z z)mxuh;Hc`H@nYx3^VDqQZ0B2qKLgDob5g^;oJB8nfPSD@{$CTGNDT6%hwlIuo$|f{ zp=Rh-_NO8}Y<(>j>GnP{C)XK5`?(A=i|29a=^3=>Jf~)!bUf~IYHw=+p?N~sFNEcw zBpz)1RNA`gUWk*{Tj}m=gQkeNE-#}GB?4M8LP37;zd;>p+@$<2R_rDM&lR@tkt7k^ zDi@85o0B&YORZ0@WYp6vUaI{ge$JffYJUs!Ju2F%>BuZCf*D=JyX&^f3P%mT=-VtO z6#c$gbU`*MwwXh!8Z(mb&eP;a5m1;IbfwdRd^LjJr9ZCP$peFt6W`|g{Pgm)=j+P1 zhP+>V%4i8L{7ZIj|Mirm+F{m&Y)D!4{=QM72&Zj4IA)<+~lcqj_7bDEwhF2Pe= zxp)*_CHCC#8JcAlStpq4xA-`&Cn_d?EJWGpq1WJo^LdH|KyJSA2_`T!n~eJXzUSqu z`TEVA8*=DPCQWM1lA^2tv3`~6SjBQd-q*9!8E`x}Sdn{qQ(vUVznmHvFZwy&F&5*V z`6`-KIki?g&THoN7|rRf#}}#|dQS5E+Sir+F7Kr4A1^7{?s~=DdOIoQzb+yAS_Z&K zVDlGBGbr6%e|a_$(E!c#mw$F7*)!RJ#+jJ~les2;W*iOv8y#F8y= z89^YC(#2snruK#Jpz{3;)z2`o)yrn1r=7F7Wm`2ylecX&7sf+KR}#3yi!W|^rx?)2 zeX?)0e+}3GXscJ7TYJpk3yVE6zR^;fn_IgZ3W`=eibUwQqBPA3R*!4?_I8te?gc4O zmY@K8qxgLvs?R3J;~0eA92~0FKA=t@I28Cz(2`LsoO&R6SdCN+0){8Ka@Ah=OF36` zQy_$7bFhAo5j|b3*}0gn!H2vDn#d>7v(i8E{Qa>hdkTFd`_>T6A$SZ+vc?-aa!_lu zexYe1D)!n~*&K(alw@wpiq6C&osi`0GNM!Wf!sYr4V4hk(sb7uVr4^3uQtaV8sLZC zMZZG(eojpPcYon>4iRcy_y4ME4luFLCs%RdvFrlN7Xq;BAO`(7I^D0_xd2#PPW(6h z4xem&Io%`OPZ)~tFuTKJ7~{T2CTzYzeSib8us@ad!+_Y8f#CbpU|_#BK`_rviuhsK zy0l!ao%8<709r?fD=A;*70Fl3j5vD(2Nun0nAbVFUD`(G%_3K9^4wX00n`(oa@1<5 z;d+R`y`phDUvt|ko{9gDAZ9tz2C9R;_K(!<snJp*J5!`K+p=f9zyt0O0?i4)f&` zcMUd5pE*MUr;X*MTiy6w5uzbWkg_F5RmzADHs(?_(P`3?5(u+UXPh>$7=f7QVmYgt zK7MiKbJ%HPw6b(o^1$wfwO>SklJOFD1P3GRB7_qc2zI>QYVvW?p$2ObjhW+(533XsF!J&8 zX}B+E?5D@y@X-o!X!c+8@d~J)bVoz*DPx0JQH&RY?m$QJ759nljI_IB)rXCh+CKyU z)bAJmy&u>erm)*&RTOZm&;{!;X}-8+vZ0m~)dk9pEiEc3H52!?)Wf6Zdp`QCIvQx* z{1*#Yn4>Mf+!$EjIdfuJS7>_1*bm1Q<0$f^cEdCQO-Xc@(3#GWtBIy7^nk)T`mX$u zcx&GuCw5ylhCGf8LSqFtAjmpMX6d9v7cXeu-Cvykvs7H;k3m^}eG@3R5b~V$Ez)m0 z<@IPak)HL1>Ri3kSIA#PNI_vlVvu~KLZ_9VF8H4_3O!EF?J$X?k7x~VtlGW;&oUOV z#nfsm)5*7Xe zoMOH^7PbKYKoPwNYvVbWLMM>Mz}2Zb$w2G&F6PfvWjzJVc}&he7IUo@`Pm44$YQ!`qdFdPg#S)Vke zlf^~f=Sn~;IX!xvKN*+CLd0U~jM{uPVzFRIK&PcU^ED$HNZ?tDk)Ar-*+Fh$=OFuD zjbqR%LMJ)5W!pz0`3pwF3p=@%;g&auTvFdgWVz;6(VTsd<}T5Lh(S)KKo{*s1Sj#e zAfAgK9TSx@djCVgn89xD10&9@CsE?MZXu)N zqm-R|by_<8(uB#F3F3>7q1cJY>PFZd;XN2an5zh=nD2S{widAOtr!GKt;bd}pc>&I zZ%?q}hN(&!>8H2kxuGR5%01Wq;U$k6S1l8TPNbCgaZ=BzK{6mUnG(Y+e8I#7{*i@vc+e6tw3@8tO_0P92KK@mE4_)y(Jusrq`r-a z3Xpo^^`}TEkq$uITzC6xf5T0EQgIf!3;4xMi}=%@WXZnODPOA z%=C5#3@1`XT&5Z?O(wYPyk(bvpV~7Mla*n2tb34}6amCL8?vN1mA$&k@WXScjLX6C zPJYsniNmy7OAo)vMp%k>+M*cGkTg)(tCf;JRr2YGVvHLo99gfqeOE@dX93HCwB!p6 zDlzcu^f(+N-yX~FIVa2WR9%R*_ZTsRT(&S<{Z(wf2tv7@=HXilFZs~ZfW7+>BTcOi53f@3A)t5uc z`_~Smqbn|#*~$U70GcQ^ zQ@_hl4nuc#1`|7e*GVq6S zF{F3WU~WPvjJA4*!Y8s!fSJa6$^17akhXC6+=Q8oE@)>l(Qxi8VsFNm{-C|?1z_as zhH8?wY#-!p1ZXIugo4B2xyq2)f322w8laS< zgSP0Llvfm-Q6?Nlf}n#}KIUR-(v3v1bh*iW@;}MuE2g_zfSZgj_o{C5E@D+d_$h!> zoEHJ*^ZZw6A(T#Vk@}aTiMSFWe18~vY&n3^oWPx9v+$jrDs@ro(BQWsGq9mx2kCNkF;i&27H4VCvYq}xrI0wKh5-9D@+0zajffhqt&i<^N%5+;~N<= ztDi2aBkEVN?#F1sY#H=a#2F$2*p-m(T#@D>SvC8r!doe8BlbVF!Z^bm4G+a#QfMDf zBZr{0R!%wE3J*2fZo;-#BuG4zZZDOALE`}DJhSZPh{f;D>FecXlaxo))K z5Kq%!GO?<1zGAhmdh|cO?Rp(D&?qD|3dp)h1GuSgIGoE=Uk-kpcKJE(A-?A$!alR{ zI+H7Of;p;F{4#w6UW-eSlNtcpUSOmn5R(Y&ovT>biYByR)szpa3X}99?kTJROrh?h zFIdelu>)6TaJV$a4Iue1AWYaKo64{!f+LJ@MyqKbJ3fH9bcOGz7OubWID}cNY488& zp@~II{!tjP|46L{CQIdZ46cpr95BTFX0`n*Q94KaEAM8~<7IFp-n~1s?WmHZT+cW0 zi~u5Yz6nnS*a)Zux)Gz2IPEeBu&K>QPAQBP@QjVS)Mt23{`%sFkWVhr{@tZGp-SJ` zW7~9f$`!x08 zH}4-U2Bv|_m*##Fs@DY&rHP4~j9IOf5FJStx?%pqcE4<$^YyW%MnHJ7DSxtJIA^5h z@P6K0D|r+e&0V=AKka~9t;W{pc>eBrp_A2s<*?TO9c24N$>LTCiDfod6`40sqcO*F0b7%kLVDPSDYW&+acyP2+ZJdZS> zom}L2uw08SOHWpZ?=g33M%U2WwCy%S%nmFsD*fT5`);LRNadoK>Mza+o;6(D&4AC% zw(9wPd zKp_B|lXGAiY>p&6E$P5be-CC(5Apgb7Ydv;E7Q8ovcx8lw+0l9%-W*r!^!q)rs(W* z04?QhCdF&qXDS!q?wJ$rBU#5~?GDU~816GVL+N7=qS5*e9ee~d<0lBj+swc2`5cd9 zpCs?et^X97rhN1E!p*wM#yhuF>qYB}X13vGG%y#mcwHS=q!{msEn`N%QXH6+TPwoC zd3(k+Q)LRVL@*z6HUN`6+tpiZ^}7#Z>J=T+)@e#+2tU5Ks;YN?Rlj~Jzr^4ikc z&0j-NVldOcm{zA21DeU>ZQ5KG>csaGe%3iTh)r7^6p8p9$l&1>amfn69M$b};5+HI zT$!$wkq^Uu1D2**_XXD#s)mu1EV(IKFY|cJj_}(LunIc*zH)f#eR)PqS_W}Rg;M%F z;vH@@AL4?HYTivM$#Xmk3-*l6cog?dD2jM=b`uLL>paOuzJ>xYlvjjNY~(Sf~8p~T3eNoj0OLfcoL-@1=A zA`m#_uP`1LP9MRV$n!hp0fMbrkJG`CUU^P3oc8*`@`kr84isK_I(9$)e^QrKUXb)} z%EYC?Cr1B=r^7J+zPY<6AD`3c+s|h>x!sjpx*Y8K&D{7A@v#FcNOrP?CF2wcdz9mw|QmcLC z6JX#Q^YV^7|FWy(fVLME!;AR!83MK4c%&-n*)YS2ISEum1OZG;kWZ28pr;qoJXcrT zYMI^0QhK;R6Y*{;ptP*<9BFmpk$3;YTl%nCR63CK6^q+!)_5O_@}ssZg7`j@Ac@F3 ztXwtP$u0qiO?faMifsL7BR*qUd7PXvJn};ZDn6kjjjT+uPKjl9d#P!_gP23sLScfI%O-l_=ztk&(#9t|LctDZf+t_)yF29TPu zrlK6Ld1<-vba?N-u`}1U_GK?q7Xy!uo9AMPX4RFjEl2yuMa~P8+W5&2ScQIAE7C-A z0mbjf#h=tDVQAXiqhHt(s-R@1$mkE@3~5HGstT~63LkDn$B8?Uguf_K@cp9u^+Knx z9N%0c?Hcladlf`G>aakQCU`ekujntvdDhb+-1}v>Rd8JW!jF@>OjkXY(6A5DMRjke zhW~0@&n|?}4P6NE0A^!MGm29gx%@@Tw^w5SSIL^sxW9omDvx;Qp{pErPEBskftw75 z0$*tKcf+~qt=Vg{pNtt^r@a7w@rpp_Xt~3zZ(!c4j&GA^M7S8K1&T;obf$i1$7fcB z9f=9d(ZW zUVP>R!(_(3VA3pOaN^InSZ2ZVe+ehGZBm9KS;WLHlesVICIE@lzp}Ey6jYsRf|8Qa zNJ*4GocxRz=O26{mB7_-DkI$ce)jO*?B*RiZmVQa8#op+=Oub}V3{P8V^yx9tP9if6uqU+dZxWf7=kuAZ za-NJYVeRVfn+6#nm1=Avwkh46%lsC4BO_1Xm<0tBYnUW#A||#$!Z&O2;-!nbg9tfh zs<Q_@{zZW>hLNg z7wEKLU;jsDyMA{N9J%@K*_F9>*o^i57EJE@5L~{QDHWfZk(-JcDKu0g0?K{^%2qX! zgi2@bFTzoWiJe2eyaHv%Tb9bh^<-!zaeow&NPBQ_Xk?zxQa@@rs)!>l6xJ+LLJP~7 zxjRqiYqh2b$Ud+U2}q9mgcXUo#M~pS%xM;y$5Ug3Y0pzZG)CJI0?6L2OcD(1vV+D3n?Jm z&t1T2kI~(&@T1OYLS62&0lk&h`_6K+%)wJ__!Y-WqNChsQ1#YT9I{ZvTU!0>y%k%G zGNTY6SN@t9Vn~&RjKX^foCO+Hnu2cr!}hIg5c|QK)II zO(cLqohu%Vnm9VAd%=NrD!ZxT^iL*E8Pz<)4%z9>j)ng8Ez-L&EmPU~^(Jrbbmq7mWtJKKJ-~r<2WKgGB^N$%fHtxO2Q- z+wn}XzDC8w3fe9rC1Xx_W+EXXR>t?5DTK-M#9XO3hGI1`CQ@Wrq-AiaD@tzpW1(J7 z#v2sIbz>f%SmHzP+m;z3gh`1p2dJp3@fh5WU=}`fx=*+0Pxydl^wgmyZ1ks9*HA_1 zhxen;G6!Lmn~nXC-_o0vxgy6nTRNTSVFne$?qfYtPns*)4}83SP1vW3cSFnVGAwu6 z|9<-^q+Sq!ZW65%#UEQ_Zn5Wu6R)AYN0<9!aZEEo!rJ8iK#uxXNk{^4HInjl46A6x@?sAwb@mfKPy=iAGiCdhWSFZ%*i$!abcu~=w_PB#I-rI8{0wQKPZ#* z%DU$i3eV4|^JV%>6{tx;nH(g6xT^kLPTfiI;E^HrBTtv^ za0~LMn+r72DP?UZXQ8ci-}FD&TT|OTD3F9sb64S*C~-)^rKm(n#+kF!j zHn2prcFM2yJNQw%8(hbyB}*^ce`Ne^BQx8+HuZdLAe= zO3rU1rcRC-ydmI;+bO(yc{o8WI{q{T-zW53KpOm7tWK2Y>L?HY;qry{K2d{CiUTER z6-z{irll%t5PZ-@O}}C86wm)f(iu+vG*{1}0uE=%wL{pA2=`phB(kPf$&pY(i}S&z5gmZ2)R2sMt)Z=B3)&ArbvM?ky=*?U7g&f7)i}%%5*!Di1ey^fNO;@3a+SGN_-2&(QzrM z;u#*7E1l47vvjRUX?LtqC8k(V#orfowaMX|1X%@0iLephQzuc;N5C^2Widrx)nS^GwXDY#|P%C z6j8y>8q64Xpe85ugr@#R@aBX)Ek?x2$CWifZ&0Z?qK|#}s7al-en0mR?zNP6MM$Ki zF-qn&dtm5kQKw%ge|5|Hf!7Ad)$TOOWU(w=r&%a8DrMrXACXsg&|LoQa&-Ck3;6G# z6uU0CA)#Bt;gzQd#~;)yr$WizVtX#9gBVVGnz<9K6%pbLIKN#ayKm+6SKg&2l~ahe z?t#_{;{I4BJt_Mxr_8{8kg}&z(JGTevzZr~($`mOsZOzsqtqQ6+iGH4r=Bf+600$CU!Y669 zDls9zwdQ>rOC-{ko>f(lD7nxkNbGB*GRaNl3g@jV8}IzZY335_S{UeeI);f|&vjwN zD;JcT5btFGFXM^@W6#EWGqQcCVxf;f;I(vC+w=*Gx#55{e~QLN6CR;}n9{D78L+^b zlN?)o4QuDx7u)nQoS;jrL!H8AhM=_a`jRqp2TTl%I8|bf7u3vuG(t54=grVksG~Yc zPP3C4yI~lx!OF=gdjYvr;;2xoLhAC~`Imho{3YhY`ke{1@}k-p*y2Zqub~B3Q91ZS zu5bH5g#vuK%%hvWgV;gBVCyE7e%tXrRGaml4xW1Ps30L9r~PkMG@Sd`sVR8o#%g!J z4NH?$o!8LwrODx`adx|d{P`4GwJsg}_b7H-4fCb+TATfZWZ>2cUIJvlyBYIKqjJz( zT^I@p+~4l*MAdMg2q=PCYaP&YWx%Dgwokc(DbfO^n!o5nX$Ye2t?*f{QRT|8EtnCV z4AYDD8E{*6yrX4MkuPzsJevN<^h77R@Tr220o5pH_o$C1X|#{=9NAMsAco?uu=OI} zq~g%Oz-zb7uAY1aFkt9r%_?{tVtR-}mjEP7?Vw~&J2)n^molFKo(;wmr1gR-R)L>Y z7H)4e2R&D;F@s$(+LPU^-@s#K&MEX*d~af(ye+g}MOIA~XK=v~PnBTdsxOOL5kX-{ zwwznu=%<>#I2ObaKS|eSQ;+aRDjnwYsfpUaxM89UCX%aKEGqI5$z-ROaLx>*8HE=@ z_3Quwjqf)d)1Gv;ym54;oR~w3i}N8a)dFAaSq-8zved;~4oIWXECncE zNAfo65TAhxCS75j#&_$5Fqz6>&0aJ=D(>DyTBa1F!CLIn_Mp@mrawt2m%c8)CDca= zP7gQANC8xzLg1v*)%y4YHwt&nBZ8sgP|7ul_;cL(HThtK%czpD_>yH3(ZkbI()v3B zslf7JGw5M?YBXYx0R zh}MV3a#L#Q_3~5e-xo1D$;(sfFSuvr(}WRF07-Uh)kH;`Z!}%%P8^jRo|H~N`nV4W zX(TrKJ`6g&<{&rq<$Kh#oKI%ChjJA;&xL4b-{(I3`jG(Ov_Zd~-}+!Zu8RP?oE|Nr zGw^ZI%}`knnuE;fFiAVV{SyZElmm!r*r}EP;nkh_5Ribmt)P3s>YGB2o`i)R#%}WA zqLojO=jNCiFMtFra!q=f-LKI^CBW<;)|${XBmdZX?z{U(Y4zV_AgXIJ?NMk~{Y0;U z^Y+6TA+xE+_Y%Zy?`y)v#SUXvy(oLTi9(GiFH5aRJ4Gh|=$&{Im^(WwV1}9) z+qe{YGIu6i{LPLd^|yoFLd3Ir9N}963UNVW>iFPnpZphu^l%X0L<3^~Mdp0cUP1by zsZ1QO+ZTB!+^l!I2JCWu=Yx6t{6L5CUv2))Cy+0iM#kX|Xy7n6_VW$}X(*jm4dElZ zcYx-`aDzj!Pd{Xv0zl-hwcnUpGBxS_e1eL(oLRnS)F2Me#R<|v&)c?~iKM#FgjMT3jc73%3`3Eq#?Cd z*GT)KhiHY?2X3cnZkF^zj5;%02zj8AD(_u` zW0}%9zHsC_o=0mU`8Zjk&gm&8jetu0XaG1<`% zFYK|1ZI(0lW2Y4oU&$Ya8QrCLbhjX7T;Kb5N6mqzwiDw}`q)}w7_*$pc`d5O}i68}cxoE~M=G%E@QDWO@e$j?F7$ZB>RFIswONcJL zoH;GwLIAX3Z1ed-ecm9@rg)aZV24P7?!e+YZauqwLtZx{;{q@}y0K|s1B zly2$n?vO@By1To(OS-$eySsbucTl+Q_}|a*&Ib-)&z@OxuAFoIf_ao|O*6^MiVGlD z@b?~5@Aiy6E2uPfnjMnE4g5+GT6(0LvwG6TB)Im`L#tqHV#LI0h789bwj*#bwmegV zAckRV%)RNyN5tnePWv^fM0G6R=>;mt*ZU(Wdi{zB+*<*0=Ko~HY7W?Y5F7u%sB+DK z^uk4xN=4`BSzEwMcngP$xYqtUT*8vkJ6jH4zEqS5<|GDk=)}~Qo5H#tqLR^?BegDn zk4gC+*Ka1FR{WTAdEfJ1xY?tuXg`-)NwL)DvSi;_*M6+ZyS$G82&utUldQ}u18S_) z$bo*x3A*QB`?-+u?)z_hazm%dudu=!$r~$8R737RvH_f`y1s-Cwdx>L$QTka&U*vN z_lnlg`hxkNiwV=x-v%-3u}vQJqDTvjW^>MP(4GcVjOD|wJ8E;IL?4_PtiKRHT$F%7 z`22_Zzc@_pz@t^sgO@Cav_RfgPA<;l>u*IRuH-gX!h^~{vTK0pl8B}aksMo*qElb5 z5?5Plkz?02(z8&3Js)J?nKux4(ah&*Yv;^UBCw~x-u|J;-s2>`3=pm&VBIkj4Aea{ zvIyR7vM{IgpdMbQ^0l*2 z&4%C)B)KINGX5G%*P8+EiS|1I5Uw6inNzy)RU_r~v{SMo;PI;_v^h238(J}e;G@<& z!%)t{i4JK{0JRqH8ISfpU53_A1fMZGMGdoL-rjw&hCF8Aa`a;hc&`1sEtd29>W$Tk z^;>``mq}_pmEm`Ca^CY&?Gy&}H>?zo?WgY_j^0v%4u6F{ugpgy*OM{oP1G(?QmU;< z-(uR8q}~5w*krB844p^+JEDRn?sVOVfM8f$9&#Ql?7-y$qV}FcF}>jUx@`B-N6I{a zS_ih0B1dNr{hS_UeY2lC<~sT0#I|Rcyr|38Te$7nVqD+3vAic~hKk`W;RK|6@$TIt zne}a1U+PMB^YUJlXJB<}fr1y6Q#g8p#iqaC=C1o*^HvFRENx<%n)SdP_Ic2ht33=5 z(ARvyCNQSuf)6Y_WGcn5i;#CcjC@60J^j{R%8pzn12s|lY-joJJsf_co>D7T$sn@p zMj}hq1)uq)_C)T*A4eKP>MtX0cDQfE6DDEf3-cchhdQU6IV(gs7fB1(2zZh2*16s+ zmfA9recPcTb*48o{e(9zk1N)V$fw=2H+nR!-4kj0O2khl5A$=Rt(7r=^_-rku6jTW z5|cZ8Rfz|kG4I2IKU5Cli){KB7uOYBnu8HOb8h-BE=*UT9kAHbATi>YiKS&b7(DBBRezpn_VOd47+^H+g z1PDIMv^p08%D8skE>gEi>rq3x=AAHWJQ6 zy1rp#(P)Yet}?Vvyp=iceDo&JN?=eU*nS~1cjKZZ>Sws;YXT^(?gNQL^an;1u(KID zTUVj8sR)92VwpjDYFNIha;0|XV~Ql6b@C@^f{;|JY~VP_`dcu3PoT8TX{eci6+*Y7 zlc*H(_a9B66+CN$YGvfJXCL)^|2k}V+a6fHIn4GbgY+uJ(T9Z_{sP?ewH2_uV6|P! z2eC-%zGVMv5%jRr8e0e`Eftq>r)J+#@BNqmw{ zf6B;=t@!`yoJ4=G4=6qV3!;knru$tKg1vGm)iE@UI8Kq45(Sv`R(EXi@!apO%eYY= zbSaNqPSkXI;vY_%6Hue~B@Y~@$20La=B_ZY zsqB7A{w0?^m}me5)pRU%_cUPM9oYOQOi-vFHB#Jz^UkXwy-LQuluh00LR0#r0kq7P zWP<E;-#m~`d7?3}*Zf5kz~%=*Vfo_%Vrhzd_sgWZ zE#?10fClv)}eludjKU~$|{~xaE5uk8| zAC&#-Hfb=vjHi@XPRCR+bpJj?iy-C~kNfmwaCA5lS`t1%*JJ0l@Y8(eeNY&9aj{!K zo#e$p|9R72K6*AZZISd;0LkyWAmK_%gPL*N?fQ|gf9$a%cF5inbrm86V67F@ERQUb z{!>K|jzl8>{gZJ)Q$1AqFIf#xS#Ue@mKO|FU`t{I7T%GiY`*OaPiAyLYg|F<7`e`m z{_G)YE9i4Jtz&wR*q{2hYXF22Z6FFFV=a|yjDQ3HaaAr>rK^tRW4JznE&N2LjLoCL zUjOhL5v(QMUp#&eSqJp2t8!NDfDEqK_6b@6+)Y9BkAT#F8;?6=XFI9j>(IwD=d^TU z02b@aY>|5H$i%QK=TEfXZ6s&e9wLnrIC+FvBI}CQL6zy=!~vpW=p1J^Y8wUg8 zV`P7aABgJl;tzcBg8(;PQ2HbrXa>r(j2`#A`YOCZO4Vn8tRp%S7|paE(yKr{P=_$0 ze6~|6er(3($7h4z2v41*nxy1A1Pn~^5(Eq)Kv;%H`*q99NvP=~wKX_((@sUPqGC&3r|W0D$f9@wbsagTeWwGD=NDkT+O;-nmT&zwTy4#ZRd37QKxH zuwB&}XFTVg?o<4SbN$f4jDNS25Zm_oC^v>S6JVvub$xYjox#&Xf`VzaDOdmZ?&wYC z-ufa%*##^rA^iOL81LOM`*ECR+vBR))XBg`R5Bl8p z>7cd0+!zQzSCdeU0K)-GsVE&}?5AxH=+-1Ke1>zPKzRyD;%OnkPS;iu1yWWjfGq7l zt##7>Eu09@>hH*Lm2XTju#sv2w;bZ{kKcnhV+5270g*|43-o)gX-*RYr{A(WG3cn!26Log8ZuV(9DYYj0O8E0KTn zK8|lGXPJ;*9>&})SRabhVTG@ZPQ3Y(iY`vpJ9cr#8WrBILk~w37H(VwO3ECiK;sP&p}R*7*hitn=8=;H6a&6=9%4X=jUwZBx^ z*~xxX-vMuV%B-H-Rg(TFG^><1S$rIz>&Sn+7U=p3q$DqZ3E+DivLF81ds6W62wD^y z8+qi!D=Gh{TtIw-b4Et}>TY*;s!PP*cs7Z(0rKSN6RGCB_mfhIFMuV7h9FkBg`3fd z7j=QUO^2Kf-|)z=)cI%1+180baevuAgIe|HDhg>lr2C?9?Fe;VP7$rI|vg53NSpQ>cC3zQy)WS@wK$y0YKpmTJrOK9N;$e^s(~|3$>97qy~yh6s4cI0j<;Ef1WN7JGel^a+TH=AM?F1K^GhpKP-UcQ%cI6qoWy1Cn~M#<60a_jI@c^u&C0WM4goeK)DD@9B? zrw-b!nx&>MiY6?^mL%ht(;=S-JbfDA2-oo63Vy<+ODzwEGj)w;t>q*}2BX@#NQho7 zeV8R6f0Nm9P-)bI>&(U7#W;)}Sx7^Xo0C_OF#&M=Lm?D(1&26HD;-_1SJuZR}M!zdhWRTc< zBUU%Ut^>*Bw6J}KKhgLULD#c*aV_-RVqFB?1|TQ;3Ym0jak&%yp)kQa8GyS>(N|i) zGBX>l(=KWexKlMoKB&4=C6#qFmOJ;o3f6!0qM;&K+5w-J!7GNwk~wQ#My8TZB0tRs zlb5XmEn|H^*tSx9)QIYL$*+rnDH(x~y)thzs#L(@g;TA{Qt!*Ims7;o@JCV(KS`o` z{-!^Gpuq0xq6los`z~~HU1)0yrKq`nWrI5T;j-8(f7E5)km@;)KIjbBgIaR&sPhK$ zgj8R0V>0uKd`KG2=00zJ-X!VI2m(pS#iBdRFC4F`kmJv(ShD|#5w8$6-@G zrq*tYV@nE=iNa!=cBLK6^vLHr&~|igW3LHW^OQ4)2!|A|V2jjuR|=zYSF!&{RHl_2Iw@^}z)v&mg?PnC8w1+S@Nm1)-O! zH=46;4m>@k+04S9e_kwPI>EH9zo;d&H!|9X5uUJfnBhcz1Ib}WfaAU@^UkC*9i6s} z97C7Igbv!uPyR@`voxpS3(LL%h1$ni^{QHylZdSJ_l&XvO{Djk`U0TA)C+jt?29){4{$TUZ~j$XS= z92(W)6n#p4sva$ZL6UTSHn-XW@q*)SI8<9GG-X;4fQMqSKC%=M_nI~0oQ5Su?SwTi zOEoZ9Juo3D(%H;q?J3!d5d>;w^7n7Z_F>sWp|2FQs=v7z+o!jGTXi>r_=`$4U$XC= zHa1*FSb?a-x-e?@Nl*FP1Mr-Onl=Lqitc!Zo@4iE_#6WLZm!2abeTrC?imCm`smop zJ*NCgCCZV}t|Tc1S;g#cn9F5xGlb~c7<%!*^X)l&@(lvKDyXWbH6iywzU&TQ*!XV*GOt=Q}WD` zgoBLX0p3(hL2l!Cq*bLs`f(Hy%QX35qzPhcly25p?zDt91l)c}Y&ih_FwZKJa&_94| z8YM69cIPU?;AdR?FowRR7o!?x{&s&Vq1g0OfVpRS_gnw0o&O+aEij`twCu#V(v9{c zI>ufNNqn|R(}Yj5_3+@BcqjFN2+8?rnBhu{8nzLyQCpHBd#PAaeIwdI=(IkJvlmLH z-5_US12ElQ&OUD{(0~AJ-6%u;#W4lG?U-)bXCy9;%*@&F`P3#uv9I8y4|ZdF~7d609daGIV67SiwbvFkn68y zNjJV&lE3D46iO*73fz*_u)LT#FCq!bZ*-*YVxx*b26KYmcxYaK`w!jQ9rvKG)VYO9Z8HV7wgt@-F0Ph_H|BtA2^uB*EZk|Y*Cv|&XkuWJ_<2d(Zl4a%3Gt48aIx)T6In-3PwUXp(#eV9E5ge>uAzoQmFdX=qw zd1q{)LzR>J(bNAW7ezvu-7PTAC7n1j-V)J{IsVM=qm)DNdrjtc2T)h=p)IIxzq}v6 z^P+xRLd988upwrh1(=u2YzEU(xYm8V|CDmVYfbVM%sByU6XyKJt%sh*M%^q@%L@6} zvUy90xyogCccRbkx(y1?;qa2ma@SslC$f^eb%*69FegHGp6}s$!4Ir|WElbh>KBJF z<<2>AF8ohynm#^{R*q7haCB}W=gnvM6UXG-;V`sgJ(e0$KAZD1Z1oek+=EF{`L?z2 zLuFukfay&q^`_>=L)Mn>MBNka}rTC_L= z(9L&>7u=)L`_^XyfKVBpWei0IT3$0m`^qaBPgopg8=wQqlY5Gxvz-}zLrcZd(9EeF zkdbw>2LBMqW;$1+dnvD@c|@sM(!;MGiuRVw`D)FJmQ0F*PSCpJ%LG1PMO8NU@!R&l zEatpFVqrqGAc#YDQh#p4=O=E#j5_jGIwDLfH5~ITJMf$>qcds^Ek0RqQGtal1YWB` zVpvRchK$mfQD>{zk-bXpi6L4UoUW+GSQ$#K?Ixvmy>xr}OMEUfBlXJ$(BQI8q;>XQ z|FPW2o$Y7m`-&s}`x4;N_3Y%nprwYt8Nl#sGm5y7)*te+r{0gftmt*eL1FyipRWbj zn()*qWaemq|9WUpzJ|CN`ljvxhA+w{wII0-yvzNSy)|sOaTcKLiJJ17(&)hyA%K@4 z$Rx<%!FGZ#U?1m@k#X}qDPGMO=9X`bkV2SqLq4LA3H-Dx(kHS4JYV|O@>AxDS9#Z8 zWe&deo%O-h$F>4X&9Sc)E0cK-=7g=iX~nZ7z04hrHhT4+KldC%HW_Et1B!!SskB@LCU;vgGwI#`U6 zMdjv=20n{ROpruOSgiAzU*(2EtQVuLWJ@0+2tHORob%;}<^M3m2uP$|0LaDGHhh3{ zKsy5&F&De+`#%d$SnGpYR%1aO3+JTD`Ab#T#hgEEkuSOd@>b<>QW7Tjt?*lm0c0Iw z3OZja^|i7^x`~Vsd0^pe!(o~rnh9SQ;3Tki6Lr92sC+x0$q2-Xr=g=KAkf~DjQM@3 zdWBtswWRg1{>=V3n+GaEAJ!2415WoKCc_56=^d>vJT6Bx^Tbeg`~;k5=614;*JedyZN)q;J;4%bR15HQjTuOYlH*Kg?9nGaVZI|JebhQXRkThqhZ*> zDOu{qidC9@%Z^dRceHOA$R?j5Qfn$I!5${5W>i^VU6uLyL+4?>?K?xsC%`?y><(~x z1CeTaLe}W59vtg^?8C~_O2Y0A#g7>Td&WaXK*HVF@{|i;HMa2fh6+)xdk*MZZFFiu z?=_b2K?Py8fJIKOJ=;1jTZ`y#-8GxTfi;BR{(fg1ZBnCS0>sb%0ibR*wy0Z*-x<#? zTJAR<^4+gkHi5or8jyuTlcjZa(JqO8e=+P7x^qGJ`y~WjuKnS%gG2$qt*niHZ`VMQ zh5(VO%Nit)ty{%jzH$T%l81%N63%y^%NHPG?!o!1O9@eoI-s$DOmgSWu;<5s*YzeG zKU(}Y2)Y7b3LZ}dEVFuuNYu64?_V+(taOTu!oSn{t)A7V>k%|u7@DBZSHNPnU1nT* z2T0puTIp8y{|xs%rgA-H$3UH_zbFBnE}+w!^=o5vZ4zhEOR0MT|E50A1 zsp;r$#pITRW`xqr?CKg2UHJ)6zJM#o~qhLb?8>C zWtAW&9R~I+L~6EUavVNipFz(4oRTf5THor;C>nn`4R`@qJZHAcyevJ0|3q8kEZE%d z+4-|f`J#xDoftP3&L`_!oU>Y#lU?*j*8kIi!uc0B%jrdV8iNE?3UlMf-S@d9J7Nzz zDxprxTk=L02;_>`rEHNN51RymuU5=yW2$zDywwfdQZKAw4jKDNp>E!IT@Nb9H{|yn zPZIe9>-Zstj+)Z$Sh49$flLK=N4O>~hsg+XiG~Y+owL{CYVEU~-((x-5XZDxViANbMRJ>K> z=kc`^$x@|KmY2&rPzGe+3B>Io@a2l#6%N|7Um7_P=s#<0F_OX)>yKNgv%UA@aBYUK z8{A0PtnCac43nVnB41Er$152#Apd^?I5S0xP5@>+L-N$kxZ>9_Daz20*_1IN#>-A| zSh$O-PC~y7FW!Q0TkWZQ7hZdZAGojbrtm%t-YUqG4^W4Py>M;e>A`r*ev|FC&<4&u zwN~1ZxP5pvb=;r|l8nZ6f|Zgo<37p{h|)(bDE~qk?u7KUiQVDjGPa@&`X1lcKkgM9 z)&t_WJ{noiZnGlRVlTZsKIB$9#!qjGCRzMm`Ft+&y4qySHg2076e%MRE-#B)G}v2j zihT=`to|?m3JYr#h?dYAD&`uH&8I=Fyy+?ci#c(SZ!i_lZcI`8JoZ81pO=#>$)#ZB zEOV}|?*t1g>PK8Woo^dvR4}lKD0)9bMPY`*yvCK^oI=cX6d59+NzC&~!uyZqf&Idm zCb7+y@@N&FrCEEmqRiG-q40SC8EZ@ec5$Olt}|bJ^U>`GkPc{5wRM4+JI`V926>cO zjK~{hPoOcmr0s%lvk^XxvM-03#vXw!Bh{dnwbE)MN79gM=lW6~-b9I|n-Z3#3!i*+ zyY`}CeWs9tyua{P&g`H&OEoj$BPjFNO-~v*tKz^EAcEN9{Cm*O_U27gQOj8;2^llT z{5K@P4I}12IUd+DA2f}ir;gFWK7Z*)D_JJu2G9amBn_{ttA2gh16YCTZq9xNfnq{- zv)Q{$4wBl-KjSBjDB3TS#c>d3(W>k&lloN9d2T2MzZkOq*n<4TZ4uvVn3b@1NAZ^p zx{lfTF}al(+U}~Xo?ab+AF(cXV_x?j0@#jA%o11Xw0P~p0(nXg zNJJ0-oY*-xS|9$Dd7>6{%pWRjhs1}#KE;{$iX<*E{Tyn93hj;`hPezciSX(}hPHW~ zXix!_%8eCzO#S+vPOQrse>|j$Q+lLaAvqz4^>W7gwp@jvCq81^ys+3!P!y&4WNF*2 z=dcdZQNt-BjbAMsULN$r=-N-Cq;AdA_d|=@#-NmCtIS|6B*Ye{6TYl2PAczANtg8C zPk2mDa~Fpqs9&V|1ytQ5Ot@53G-g#pr7dDTwaSon{>riJIVq{4>XS(YuB-&RTE+Nq zoQi~@GWmG2pm>#;-vIl^QYfs-&Q;et{^%NB=UU=WV1cD6paXQ5bx5lOCXDG!=y#FE z^Jg+Ua0Up2s^A#FbQgX&Fo{=p*LN)5@-`d6^<`H)qf&k1ikHU&D?WrvS?pO^DlY33NYn_wYwx2M6L{v$`d6p_F5|1w+@f{&k_Ap; ze=0snTzRHd;B2v*v322WZ2b0}ldh03|N5K)hZcBC3?_iue>3S*gurwFS7XSxg2bGx zQ#L-w@9*z^Tdqz=rPb;Ey!dk7Ilg&SQ*`B6kqs~Qp~4jH4F|oI^Zm2o#MmhIA9u)o zlej6nGY}B=bO+L8<%2eZ+6kDpx>3-h1)RcGD(2aWC_S0bOlXov=w7%s2&8XbNiZ2L zYY-iW_4$zAwPx#3zhd>wyZq5!fnXfE*8%ua$Yxw0*6d{0EdTPt zi;2w1r+t^>7+Bg|m}Y~`t2FUrt!86Y4)w6tHrG2ReZCsW9C?_BT@<(tREk-WN^%aP zvFC7xlqOr4IvNIRczuI}y3NzvZ1{p20az1dDRO)@$As}Lp@2Z(>`AG}r?{q@v3>N` zKvP<&93V{NQB$t9o*X7O&K%6*YqtB;xR%TZMPV&3o+J`+r@=)TCVpVW`e6cKpHRp& z)>$_54n7!btWHnb$)c_g_NAApbt+h&_~F7!(|(?aSnhg@U~I%~Okjzrzh?BRCHh&9 zW11g~O5a)bqvv|2*GV=)1L0`lJ$jzql^tW;EEP~~W#L4q&%>19IBlKuqL~R3!|B8?(e#E; z*c_sbP_0)YEk4tCT8NC_-(8jM6%E?~DY<^`XXSE+@edN~@f+f+Le?7)(8dy*of4_yC{~uuWxSXCt|%2{_sZX+@i~(GV6kYf}02X{%E80-KSi<8*~P1sZhu z8Uc=mXo|X$AqN@$@3V8?lDW-wBu5CD#5q>ro?}K7?Xgxy&X=>cU;e6^t_i7b(ETQ5 z8#iMd^M^$>2#`8TR11{(|E&+j=Xaz z^gD&3i;VQ+qZ$%(U`sSgk=1hZ67t%o5Hq4GA~E$|*Q-r~?82B`G3VFdXrl3=B)6WD z_%UIcGskA;y9*kf97!2NJ*P3G3G%Ay!B4%=c*xVkch*YbSyrv zm!i!ha3}_8DRq*%A}uYxEM1i|Z4GcV)<(A9t4I}p)5#(X#JExz(>A9!;!mkA6%?@8NV{dS48c7B&_XcAXY+Go`Iqw^~*~yr*1?WJM}bj=dzO-;r|T4`AJ~7v1!pO)P`g27lzTumMa4 zTHmbEJo{Ai5>J|dYsG=lSFB&DZc5R;m5$CiNNi%YMRP464*DnNvWXpc*)o5OZaujp z|9hQwLOVhIVIxz#DJG%;wUYG`uaJ8Q9Cr8|g1_%$h@AqZmkeUgH;=Fu)QS8x2#dVeE^v@lzRRGH`FSfq zaQ>YLAg12lE(B~yZ$S!SB;e9lN?vZ$nG*Z3(xhzY3I@6ox$|LEXeDY-)X%GnjvGTB zDK`b^H9F{GrDa3bC}3;evq>kdOtS?LikiWRuzO|4b6SN$+vgH2x~^pnu#SHAi0V_^ zAqy(WKje>*V_ih8W4TrO+zXIE>>+6Sx`q2>(!vbAeMRzBsaYH~fAs29-7S!V?FUXJ z5W;4-vYdJSEHz=S#USI`UVIv>flY&{JOQ@oCPeD$4901&(VyY#vFdM_P5s2zRC~$= zEaq7aAmF0%9|ei(KJ#eG7}O>Ra(@MRMN>frX;RWR9W>4kZd`H7iX0-` zd^$#IR!OI1y}A55V13vVtjpCeo5L&Fpu@FU!v^A*nty=^WhjO8-q?=XrP3ltSx)fa zB`)eR(Q=aPsO;1vAe63wB4q#xkqoYRyYJ=N!uzIGF<86rD$i71rO^%m4y1vJaHt9q z4@J;9bf$*d5EJ$%AwSu;Ly7`&BwVf9&yY{9obyOC^&Rvqeo38XsKj;HS_jr>ypp)g zw$~UH=E__Rn1zN(>}LnhmC%t>BaXN0f4jcxN2xf)Bm1ED@@(d(cYE>p#PLod@H)#l zrA_lQva~so`VHmVBAucmgdn@J5L0V3ysZOfT?Ye11dwD6IHwK~J~>{Sy>eUe?a>47 z&H}OES$s)oQocgjZU+&O9Hazjm-y>h zo@xJjR7D4jzW}R^bddh;<})}Tt8FM0h^ze<>;WVUChJy^fYBhNnFl~Y{(Ad_a;!#t z`agkF8?Vm4xpOR`LQta>HFljKUk2*GJXOd`HHZlq6#+#MJ)w8 zkg6AF(naOZR9-$o*3KABv3&p-mSLKR$vqeDcJP(4J-4~hs$_X(4c_q+0&5GNkhYbC zhG2V8`9|s=xSN*5;`q{QYg80!_9u*Eleju@u&||qCtO>q*!0{H5`muO3(4@Si!M?XNDuFFo5)lgWs6O)wiyaEUhBSqULOTCeJ_H2u3epocasmdZ4uI}GEzSK=9M;AF zg}JLF++_dgBWRtECdK5Yf?*lRUUx1V2{K5^5_U^t>t#&i{ zQqkJIB>w@&m`R|8Tgv4JJ$*|QAaw*7eeC~(mUQSODWCdPut@5eo6Q67LOxM`79IH% z0~b-dAQ8cv(G6FjlWh`#&l-HHMg&Ms0C?;1j3K=ACCfKXZt<~2DFfJ)aeCpl+wkT{ zPf`B9aO^9REd>LdfQd9b!XnDh^ZShGpO$gsF0aBHpfq*+?~Nr0$g*agXY6ee{L2Dn z3s3r}`d2--I8JVApb6cKvzns7znR6j1<$XdtRWU1k-v}|`<)gFJZX>x`nezp;4$!6 z(EF};e#IS>#FO?O5;#SKvp|oXG-yWn7yK8rF6Zv9lIJ1pyiH_$$7j-t-Cle~1nfrt zJ??qvwGc}M5+tY9+}+P&v{gIGqyMegH}0&c%W^@jTciM%lFO)_KGWl09nZ`kcYH}Q zOPYW&!Cf@%tB!ReRYyg3D=?S*JyOMynqN<&hDg%Wbs^>;?4Dv`Yr&yZt)zUZkSZ%2 zutUXbHh7BY77W#1(@%KZRzZ%ANWpjdNm5K>Co|)fzkcyNb*>>Og{kG4@m`<>SOv*@ zYm!^`OrbP{?}>z}+`K60Z@ur1rU9&C{~W0jgs64~#->(&UxSU)dC7YblRiupnep1q z=c^^~jgJr0*>?@utMlp$Ks`l*e=u0HwYHW`SsByxKJ;?w-Dm7=I)v_XZYw(`*NaUa zcdz2Af0^9yd$pxuQ7tvKz-dB~_0hos2E;;RLwUYEfzW9eAONkp?9=Td)D1M=c`N3p zTbx1oiMI*4L&AzUlt zHXJim!jRJITB&^a3zq4B{BTMx&nTWC&7k&}$y~cvqT$l?UL2vERnBBCxm511bmJ?K zAgJV9Hi5d|65zfW4stz9!~5sg#cWrfmi4JXoomvrn~5{nQ9CMH6u`J8(-7Zx)l_$S z*xb|)Wo>DE2hQYH7_x8UmSp?9d#CF((R#+klhvO1i#3a@l$nlVTV_>Ui#`P84SEO+lxu>=_7fU<{wNMoNlS6+Mxn7BipQ(VjH_~;Xn^Y!5^@ejo zzUnMp^tdGQwKsk& zJ%W3>Bo5N&N>=hNYjG~dy_8p6EYvnyhgDo3FO5y73)H2ny0xh?H3BMqXYGdmyMD0t z<1(mU)m%i?spZjdR2d$A>q|`y>-Kwhx2+Ku+^YHnLWYDUza!<4XDlFiT3)87c2F7vvPu8t#hhK%Ubv(JL|;O|wqB=+o)F`KLPn^?X?4hb?uX z6^o@f8SnFh4M|-0lhxH~{0;wXwz{qz-pQASC zj4a4Z6-}pag(l)|WKx$)5|&IA7c<`-8~>lyhqbR@;M9&vXtE3Tbqz8VE-cc02FDmm zuv@RA)OzvD6gr}r&x`bZ##@X?^h2z*#KNaRaw)(A=QkUG-!?QhHf?Y{FGUJN7=|Kw znIcONQBFfmujwo4or>=44UJbjLLFJ!8ye0ueIwK9k4OLUc&4JVa6nurQkVZ9UIeeO zAi=^W7CrItOvI6dl{@4w|X3P{7B4z^O5T+Sc6+i@7GRws!Z1v5oPu zev>a1(urJ;g0HHp2L}>Mxp+FRP^u<#z;AIZy$l(9E#6hAS9UPz2;(q_`vx7FG>Sid z4`O%~>!ZTKj?v9tK4O$4dRa;IZ9t^pY4AC?%it7b21?qJ1&6Hz0f57ZowB{hd>V83 zQD9h#i(8in_^6j(Gk!_wq{QZVs~EUiB$tYRk+BEZ9)>&aD`9=fe7w=8v6AmEVAzQR z1sT3%Di8w3MRbvbO(}1AL&O+`9aaFQs*4tY=m%o*6y2>CRC^zEwk(Ei00{qEINdMr zWEz9OtFeIuy)^gq#~7M)`cLE58LXP=7_(sc&o;_EiR-b={9Vi<%Z32{2Vdzw$0s~Z zO{i|$8RUqynZx{M9wp_{voME$mWxsL2(607d_me%lwPs)EMt~bIy-a~<5@cYlm)6Z zo5F~n+QgRmcR#1n8Xx_-nPBGDYx6d83H1BNn?ShqqstH;&%uh+wS;#POw+RnUYPWT zCJyjCopArDsDi*FP}M$Ab(krcnnb4JX=VDoAyfXfZBeIY4oR+#g^gc^wwGdVS;MMz6UJ02SXis=%yE+>~;nIp zp8d$|yBm#al_&NT@u5j#V2bJVr?AwDefO}neT~oQ9=+}#Jj+Aa~#l;yZT+l<|+87H}@oo`zdG^Fn;Nf zUP0hifnu_-OaqaO&eE?8>||PQ^%c(hCXH#gi50S5lS+=y1wgu>#rmEWJaE!jEBFLw zf#q(9e>QK-_NUpju9zRG>+!B^xy!&J=xN?O%VITV;$q=yW7A)pFFxl?H7_LV&L^L_ zff{AFEV$mT5_FL3lylMu9eImATWN^44!+-6TQuICJ1;m78aZ&gIVZj8QNO>Fy(ci- z%|}1KO)>#DSqB|-fp5W|%|XtSq_~5pF3I2elr5&Oaf3-hb*%iPs`)=S_r#Z^&VF>u zr^33Qprd|Q0#;*C|Ahovj*?Xe$=HG3&yH4Fv?4cFbML3^2i0}N#^J`)8@3k0>wyO2 z1wc}&r(QX3B^^{VaNypmiMkDYi3_zw;=l3z9!=DO;3kJ>5B2;2GXdLxRe$?tW6Tn^ zk;ZYb#Ap^zq3`Dj*zAql(WWZ6954f5E+jx*ttJ8w*ACQOTXWLTky;~>h&!%iT`Jmb ztdP0!1(5%4ZY5idXD`=UeQVsQNIllV)LYqFg}^3<#3iHhb8ZzSlq7JuOf(_Yi8 z+R!vcboX4LgHP5A&&m#pYeWgCPrS^JfRNEsE}$VP0ldh;+kDDWDU;hUp}x8B*-L$f zA8|!9#0d#!KJYH&w>qmk?PG&+X~yP{{(3TMzAdpOu&353llEwVcLruH6RHaBsMdf;%OcSSH?tHd`*1A5-hY z9jn$;?$WSbxTPY_<6FPvJ@J-Tm^`jA|590ge)Yv%U&<1O(EBaixF@Ydvi}>x*V5pN zJ#*9a#)aR$BOM?{L>Py)elthzTSnipJh$5ddDR6(K$YarvGj%QK9F;C8IJ4sFVQsd z4s$ApPpYT9Ah0m#e(;;mIoH(9t|9k5pn3-4!SM>UPYMl3WYNPYNj2wL^G_d4n6|21 zR^HHnX1x2IZMWm!7P+&}Vc$%MFF_1e>!&F(^~%SEE$R7>hEN! zmOt9zLX{0Nkq(DAinRuu2rV=)Qd7A;dS>KLU&(?F@hU-UDxgKuDig5F33Q1r7n|pX z&if?O0E5K#w`P*LReoRTr=DqGVE3u!5Jmh(*A}?mKFw!b#AIFw zWlqVz8^2@csuo1NX`R^5*#(1?(b`Zhp_JVQ2yu1=7Mo`j!@_9q)zZz2v!Nvxi@)v` zk1s{@xm|tVZ%w;YWxIfi?Yks3rk8EJ-jyIz>KkXaN0bt%`*|s%1m^|3<}_f8Mx#!@ zL)iWd3m0UROo95WCJuZH??Cn2asw=t=UWQ$x;ORWHj2IPPvSlfa{d|Nb(n$-K3Pm* zHm0UG!pJB++r-OHjS7D8I4d!Se}nXr?n_+<2TL2BeZ4MJZCub*m!dA`S+p_X#VSdK zDG!!9=kt@{Xb$6fACaGuTEG|ua_vz4Hr&=W+UO?1aoJtOe7`oC39G=s!cQVKEG%IL zI-f|(*(IgCJ+=0IPb5v?F!`3Tz%;iRQAgCzxRz8!npJf9EuH^5p4i}h>pXJK_SE75 zyGp|aP!T@rt_E83MYuk7Z@rXkR-F^0JJt`?Vo!JRBEj)v=uop6x7_fk&W|ey??CpB z^et0r_g3_NfN3=s!pJ(k7%$F|_Y)Y2^buvM2hHar>9G82Afb*^NXGjwNhZx$LTZll zsApZ1>XdL&OF1JXFmB&dBO-VD5>SH$rdxbA0z`04kD2e(bi#iDZo+;8&Jp`fat-Ed zAPVE<5mmR$#L#g|3gOwgK_R2rcT2?q+Q&whk@W9AH$$cSmlh>M@;t{gC6`F}WZO6+ z2o|GtId{i%J^J8g3jdz%dUviC32X7zJe6bp_@UGL)N*MchRHj8&>bZLTirr&->rL2 zp4C5A&KB}^w+}V-W;Z%%Dq1f>1(O|k6!JJ&UtdL`tYj@K6#;Vhcdwe_Doy@%u;{7tU@`NQ@7hD%!VhlP}KP;j?ARy^U}NV&wKPuz`ePL6NT> zY#_ohw5=E*Nin-us-&F2*G|7{QY31B^#Mi}eIR-UOJR;6ZU3mTZ`lx2#0}I8uNWur zF)`XY>h;%*Kvm&)hPmw{MdGKEQDq7p+~_V?r<+G^SAvn5ZZWi0Y<2~U-iBI#D=Qg( zK7y@uADM31J6rAe7WPZKuY_y;?bssc@nyOI9Tx!XEGn!XOtN2`GY8#-oi4;-Cy4el z3cuw)@4se1l4v}|4UvOBqajgT1?H4gH974OF#FHhA|x+oyhL=H)a#IK`mIfDDVL(O$1}x_i$joiOiAUcagkkfw#wzL{Z+9 z-cR~$`0`kAWlc+|XLkh`-p^p~=Gn#8mg;td%}(QD7x=4Glv z0s-n&&C2bN>Bvq(UB2=i*K_Y{AGfZ!`Ln#e#(w+VBR)es({4E;%*Ad{C$JP!Q?8&S zQtq#Ot*SGY{oy9ajm=s?^rNvP1OMZscr)WNDh(=j+D$s_0kh-IPu)PFYS{;8NexYZ z8&AQe>Np;V4X_5IQFmv4b4`6qjaUMSDr13{Y6VPxS#E9y9XCmZ7iIg{aV4(ps`{1Y zl7xs_OWix6G5OQdE`m-OuM@l{w!az4hvy3x%1wgHq6O^EW6jN`ZW51aB>F_<BTMN@Kdb5ih+@CU!KbBlvbnz?uIyzuoiabKv=vJ{;@g#8 z7F%3JX@Rn-$hDcLeCbeB9U+QZFUw^KWvBpbAQyVY)5==|nL}-P@3O|cgG_Q~Eb`QAUu2kL&r(un$F6Tq9xE90l@o>f?Az55bp4gSZqX#V1F`^_# zXzEr_EH~o)Eda>E-@}D^1hB3RiPB7DZLfXG(hf}$`pH+1ExjK9htqC6Q2jFG+XJrg z@r(G7H2|FY5RgcC{D-#`Ad>y~`-?;d8iE{f*(K#lOOnh4$HpVs7$W(QAGHXen!Nt@ z6fWRi0rG7zlz}rdj{JNbZz&!xF%$&6+NjLpmSqd#q6m{b1!v2+c@<><#ByqnvdMyK zb)XZvDZBm${1MP5Rv$Is8+pxS9Pds*a}3sz!c6Y6Lt zlC>DZ9{mUBK<+}%t!cnuLvAd|Lej^}!RiIncoKpT={Ua$)$iG>;}Aq2*_zrdVlG zXTiipGr(-{;pCr?QYZxf@W51P`r&~?_SHjjbA-s9XzYYmU!o@=q4lm?m=liwabgFo zUc!T)!_XGvzkp#>VR7og@Q|!-BO^|uhQ*- zcWMnEl1K%u|M2^g7Duj^GTHY|m5) zfYPhlc~kkgljV;;UU^9l+3o=HmC&L^-3)1O=}5akZ`V#T2G`H_=|OkY!ux@9U2S%y zY@)53|B*vW17{w{Gcbj_9tTMav^uee@|R9tkT^907-jRMx~}E-dR>)nPdAEu@4$8B z>hW%kHLiL=w*N>e^mjPNuH*j*NmY;Z$gHSB1Up%N0fq=5Rk07N+Rc*)(~#n=_S6oU ze{>-;m&+0fF5&}%AKK1-+p-%#G1$7=OB+NFW`}PhH0hDZ%?+j-95Afd|5w?$Gj9q+ zGUQd0HjTG-dzVer9cnYg(PG3IcyBZ^I z)^SY)q@ButWBq?^U3*j#X%wg93AZ&%duS$=d(4w=K3c5=%Qef)w5%+jn0iXWGEg&o zKvpNpv<%HtTduoWSzD(QDyHD(>*%UzwvpP3FBHW`Wmu*tVZaW|FgViuF~dB*``!Ed z-Fx}welYV|vVX!bQ|tv+x$ti`G)&&_fT9QWxg)pNaQGwu6t0cUX699zAPcFg>Spzp zoNIoz*xxy?$Ggf$ZUNftTii9~bev zf`ZMvpAHlav}fCuqOKYHu_PPgTVF_M%%bVUu^5xS?>pegd%mTy_foTTQ+JN#mEP({ zq!!^DJ!AJP)IQN}SE?BV!Q!CowS#G&2{#LYB^Uq3UtQzY~Eivn2=G)3k_P9yd{dNM&-+OfH%K$;H z=WJR+a<|I}K5ofVEmh*c8tY)Wp9kKtY+ij@UC>k- zjy<++TyTrI_jLQfGhQG?z=$?n;*?Wo+!vxD*TnB^fCPBbPMD`-NF(Nb+B<(;V}|$F zoK3Z@OJRDrTdo4~cFCTKZ5fnjBwU^3J9SO`eLIP;Y{eN>>A8r#qS2*;{qRQqPlw$O z)%UpTR>JiU=Js}bpWBhX(u2LfO|gh|zG*dPtD_`rGaHJ16wBv9R9eKw0pNXc_CPtE zV{cQr1v+QZF@;@i)a7?=v*I3Km^LMNF)NO)o0rRg-IxIBr;{j@K2`M|fgD08FU{q(Jif=tLJCxpkv9)9jusky9v3jg|)`7mr-1b=O^7|K9kw9sFpZK9_P&ohW~F5 z*))Me`h{CIWGm8t)?}a?-Ojm5ZowkG6iGEe%$FO{hZld;)0q<}5YV>@KBr`*uD>x% zdZdrc^lGrqpzLQ$(fYbdrxPyN3W$e(;aOcrOn?A3D=$os2XRPN*B-nJ&AI5m-nU4p zJm5KWw{27Q(-k8uKDvYzKR8$wu=RT(xpx&KfZCWfzLzi(^{PU;h!3%Iz_P zrE<(Mpg5ozBgPq93Q3P%(@)l@5nm|~Mu1w3BrSD@#siKyh52XSPTx`deO1$jNeQT^ zt$hV96Vv!32x;o5x|o#UjP)Ej!2!&!5Rk!59&1js1o|%6jtMbT_e>)u(vOp2O*@iZ zD}_%a;jnyWJGBxT-WfG-=7A$ci`Db&iPC`QQY{dIK|Zl>^33G|cFifxsgP01pUTiv z<`5k@%Uye+OPeB}o&p=S_L5^W#yjU&K;{rXBJhHwmqSVoCR6DevT)55MfnlZKfPM* zMKTgqS>{QEfh=Q;@%kTpxvgBJwOPsMm|`Fbo#LO&061p$pJdGi68OKtI9f}Ghz6h* z`7I6xT7~zt<)=BC9^kuC#I2HIq%rG~X6E=-MQtk)hG*>AxC7RuOy2&lmOqu)1+<#l zL*lQAAs-RP^#HnI&nCytVSsB6z9$`Zb00}w{c^iAFYVEr}8DWUEBP z7|Ud zH@uKbO`MvPJ%JsL5=w(z^*p^Gm5p>O>G(o$c$mfo?m0ABg$p>OS}%*d0kl5kn(=>C z3DpNt6VDv5xVNuszoea ztWj)Kfj=VS1Za^KX_0>45Qb#Vrb46nT11Y3Y4e3r2xgsz$i}7EQ%xlZSPpc-3`nnm z6ESIAeGMd|&e#tP^W$M@d4&e&iIw1QB8+(90A5NZ`b&Kb@Byv*7S==g;Vit2!}Ehw OQ10FB>ruEf;?n=)y^7@k literal 0 HcmV?d00001 diff --git a/.trellis/tasks/07-01-dashboard-visual-refresh/artifacts/codexcont-mobile.png b/.trellis/tasks/07-01-dashboard-visual-refresh/artifacts/codexcont-mobile.png new file mode 100644 index 0000000000000000000000000000000000000000..aacc784c1685d83d3157cd435e5b8e1d5c974493 GIT binary patch literal 107410 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 efWrq%g9BLQ5wtLhfT@GcDrZb?dxZbMY=e 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�Uw!z?aE(HNj>(ys`9VNP4A9uH3_-!DE^ywt>)t=RQ#Ivg-a&7`>nBXY0>w7Mr(n?UeT0+tyuhH j=#g3A%{uT3c@2bR^kf~65PK8Y12Hu+JC%9j`h))gudfS> literal 0 HcmV?d00001 diff --git a/.trellis/tasks/07-01-dashboard-visual-refresh/artifacts/usage-mobile.png b/.trellis/tasks/07-01-dashboard-visual-refresh/artifacts/usage-mobile.png new file mode 100644 index 0000000000000000000000000000000000000000..83877482caa8a638675a840d0fb70ee9f3cefa0d GIT binary patch literal 110875 zcmYhiWmp}-60QpYLVzGaL$Ck~cXtcAaCdii*Whjo4esvl?(Xic3wJy0eeXWcnIH3G zX1Zs(ru(a^x4T1Sr9~0pf5AgQKp+CegybP0AcG(vVBx=g`MmPXFG~dhfdT;(5>Rwa zJM)73i{3x~QJS&5?4gGYR$$g7^&@i?BJtaXdS%Xp-T6^To&lUA4@_7`TvQ=s; zsfbFzMiTLp%VzsR*h&fLQy`Hpz*fM?Qq~zipuramCOX2>z&YVD+;;e?t(b^tnOJ4Q z-=!sfP?UHP?hn7f!@S|rq~)RfORqRQ(^__OI)%GRGccJGIGo`GU3Zlyx4*Q2K(%$Xnj=WAUYrBvr%AOGt5S z8Ju?(XNMH`tcB}2wum%G^A=#HiKC<*&ELLHxaT%6y%Ikmb~{xry_s79X56#H6dbd99I6*&!wa;l0me6he*|Q}oi( z9hxZ1;Hg53lTUk+tV)RQjsVoN$z3Nx^308fafD=ln2vz*t&Pg3%9MDwA17%A!}j8* zP?o1;gJ;N<3RP_AnAYha^7nVjO`;n*=uU2U3YJ);q)f>R@{5yayC=N=J&1o3DmJs1 z5n%a#!i;<@;HtsKf`M&b)(i48^V3_}mrn_c=-a44mgr9{voT+6*WPv>WX^_{6h~xR zExs7p)0*jn3a3U3%|ungC9;S-`9U&r(tVjN=iCys8i%S#eL6TXKkdDr4Y4u-j;z5i zTD8VoS~1Tj)LpLcRfm;xGWD85h6shV9f5jQF63>u>gTNN6GuEc&Nsr^tgIo^1eaUe z06F58@1Cy~QjM}()}K0lU%gzv;NK~m>PS>r2zIKkEg~POp;qaOgU`}WXPr7Goc1-H zbK1#UCy;B}lTWI2m)cPj4$)LvYgckPzff@X;Yo;3!Sy?M4Ije+mmLo3#LQ+0Y<^ug z4SWAqC#f(b7`SLE9z~J$i8qXq66uV|@<#ccHJgI|;>iG8XnL4ojYsONy|Sf|_y<)r zNH!CV5YR4(W z4VRCx>{iNtZCy28hweF^8?;7|)_ zsyq>zkEZB=LM!9W*IFp_>S$S4LKaGx3WfSH{jgB&w1AoD9BiYh`tk2_aG`q7t{w&2 z$y}1kzbEHA>sO|(ioyr3%{PO^(Onvo`!z(JURD3X89x zIl(TcBhAL0U&{N9kKKFuh76{?<}e%1*>#qpq}3_%7UIF!L>~0%tEtKLhSceiL&3o8L!gS_8*^{W;Z|B$T3WNFKmO>< z7mNTqTufXcFZ(FYgy)AQ!|+ohDNrjVCnaHwmXvO^R+i9itf8-9!zadUK*Ga|n_w=K z5v!>!FE){Ymf@zk6PsOB@cpEAyo@25GLCN_Ra(dhwO)d}j0|V}H zu%V0&+^bufaRlZcD{`_(+`~m$^fRXBmPMt-Vf%)M2YbxStjz__A;@z$686!>98x9p ze9pI%z5DqoLB-$jjJKo0T!ieCS)J&XS79%QO@mIh z8k6rcF$3$PAI+|4Sy@N&ikjjPs!mqvHWEp(IYn7SCmSWt9@LI~d7gv!V@-q$r>o7u zA1e2Mi6d#-fqhx_=4`PK)gf7#5IsAlSoEpmJgX@kY8#hNZ3mH5iXC`qZ4;eW*|lGT zBu9UJaAk%K_$&eACfI`%pH*}|Y&oGvnkF|OuJR7GqhA{lQ>srV=4@6hH>l)2I%yYHK$P?CYq8+4>!3|#DeVQMTkWjJC@g9 z3^VDgy@zq?D|rj-$sjq!z)!2q?;t3?P*v@X3J?UD6ra3T|4HDmRN z89eu_1CTHz0zZ@J#fQfyDx(&UuZXJ{rpQ^IBFQCjzB0_eX{J$0ir3Z(C=zwI6rOu^;TlJB|sHMh11X^Mu64^2r%13Z-iAXK!@nKN4-yT?UOrt8iI6nck{h z_7^VR_BJl=0x>0=8^#f6_1+c7;^yvR-53hB@BIW9yfWNe^7mme@LpyUKAwxkRo|BV z$u!^25KTy1v`l#J-+9Oq8LCbaLJxVHuw{{c!>vv+O(P;YH9rY6{xuworXIh5jbKF5 zH;P$_QT=|66Yx`nd1I}to(WZi>4QAAzp!i~=@h0xrcdJVKDEzIDLCp8jF0E+2zKME zcsaHCQp#A({)^yvW1PoFV`i{DxBjliWFvtmUZC&|T*Fo2op|UlJuG;~JK9BTQWK%z zVSd-Tk;0{t6+YP_*v7Wd!SdkLe6?f&hS3SdcsRj94~74Jz+C3#vmM6sz8coSbFe2^ zlZ|0GG;D&auW%E4NT5K9)b4qlL_GPEPhLi~-5T6l3YmWcZzhOmW4?nH3Q-oi)pBr# z!V>6{M=sg=vgv3|NLt);ie!96yr7f&`wfpJ7Mpr27VT@}68wUS}2am^-pTj%JJtZ}l;tCzFS<_ZX zeWtgvGxbFW;;-(X2N_-FvhwtvBbytKzJEE4YDgY<-fe7N`I_tLr-h%N{hKQsNC)4u8+ zP8lryMZakEJ!&OcZ!FsEf({G4`^jDu%$EJ@5T0h2OzOSZL$*x!R)+6&=lSm*gAW!( zw(I;PFMB#if*+`4LArAvDSY(TFK3do<+Z7$Zvum1!jSuaQD?rYx(h3oY30~dwE^Pp zrshicovA}nn5;Jo6Y2SWtC=t?e28_#qXdK##G+pjTj>nqV|xnCh{Y(4g}=lDHQQfO z(d`IM|3cHg?3yWyUW6Yjp4KZ>x?cohE!)l??ZUfmMsh&TD(tD%o4tYEeeFlS-tGQl z2CLcxRv^51m(HC8(|DdFwd&ANp03uCD}Iy|o8K&~&#!*$Gd@E{gTlW#y#*t7Y28d6 zdx1#GHh31Tiq!A(^aWYgyRk1apE@(sy}o5Rikpo-#x_P4se&j%hhSCTgt{-aqNvH zeDEr~w7n}~suM|9mYdJRPS!NKsAzem&ASjk0LE*L`_mj~GR?^xS|E32Cjai29ZA5VSx>urb+h6o$S_HPTP5Gmr^IfK+`Ew=%V0C7m;q zM;?NGfk$x4w2PKxy3qupu~1>ccWyl)D{C6utrso0+>A06u08_e0@_pUb@knL$6p{*6?_(NND`u^8{R=7zC2j@J;=WWjYYBdN<3(G z9Zg`ifeGpVCpo8lSqp|pv**aa>o$NH4n)5ymS3GK2a{{_X1QPfjI_|Cp_>6iOeYcX za?j&+AXq3UKaB4^u-~hd$eUZJ&CB^D&D$?QvFzW$#Y?EX{-Dtg{Q+Kovrfj{Vw`8% z(70pCa&#v=54n@+Gp}11nI=jU`tI$5bCa-R$5R2#3nHozQW%K-9rqY(q4RgH8ZgEE z>nA%n8cWLL2Nr^F&Y}>AI6FOLA{QLq0d!^!Fp+o{Ilfq-9FhU+Bnw^+2{t3!8Fw* z_uFdFO3;A1h&#mp7m&%I`?Z-!k&B}; z3EHeL8i`KBU9tgxwtS`9q_2l^u_Nkha3=)w2gQu+|a%$7t#}HpMRpmXCeeKbjab$1J)@8Eq zBBbMd`*8eql*xoZ`{WFd=BMMzK9fwV?Sl#dufrw*!3*p}+*#L*TpK{vqAR6mn;x63 zIG-&rNl0mI$##5C+S?csI{Ao1O#>GT=qCA)K7Y7I>)rfI-QD)n2d&$4dQr3kXeYJp zPHK74e9V>GUUDY0ova>K7Q7hN+nt`VPA7Q{e?LaIzBw)te!1AF`Pl};bwl=mx7wJU zoju9mwLXaF{hB#Axdz$Sr=~p2r3QtyKKFNtc$@#|RAB4wc-Z!-!jCiJD2|N1F#rY+^@nx*-u7@e= z2rb+=3GcnV^x|n_U&ZI?`370?g9%k37<~q^?iMI?q)rBptsIs@?VX2t$~;#)R-~zf z1^Of$MR;)HJRe747Z2Cl#0izVPwekMn$oGi9gA{=IM*QtNXyRt343=Czshx27|X*i~@24FG=v zR<|gULTvKIllYQd-h?WvAHZP)3IT>ZPH*Qg-qktSxEoE}XQEXlQ4#jx@QLFlJK)8A zb(Iv@h$|c_FIx1jvb~Z!i~>VZ@w$^4B51!> zhH#xBv}vOPIK{UOfHpKQJEC4MDpuO{^(A_zHTf2$ed0i+I0Dc6g^RoOhh-+@4%Um5 z(MsMwRWc5}V7iazC9gEcjn=1bmkoDGki5bEb3TH$+fjvz@=@^92?kISdEKu6I5=s5 zmB#b?lj#TiahE!)^(Y^awN%&(0gdR1Fm-5jtFNmhX*kK<(T3Gv;vGuE7j{_3P6NW2 zyenemD6|$IuVl^Ro>$l7LEDl{@mxIph4V4(pXX&P+y%j7sW%3ufHoi$bk%uE5T@~TL2%^v^ zY}Mo!@jS;ZhMYO~s{}_XDg;rW^z1ROLH4Cx(Y0e)Kd4BAN+oUM6pIHS$l8951tway zpj@saX#-H+ILDz+mfT-|OtfL=W@VkJ=*8Eyw*9dvsSp`e`GR8g&m7q!n=uDkuV0Vh zm-%}vO>BoCxfYb~O|;|3w_EIutn=-{S&W?XB?ddONc${!sm;^a8AyhZ7v189qs|n? zakgLIYjhi24boYCpKw-DD3kL-w~)WwOBhm!PKEpuWqLeCemR4IjYNw&bLvdKlsbc6 z=x|q_AcdMqR?5hVYs>20jrq0DkFnEDNcV^oBG6zrf7u_t!Y*Eas2%o$tr_*NUcojA zaGE=J@05UM+TA=P1ojcuWasD>gUjwd$)vW zFQeYF^bJATZM5kC&#v_7L|jxmzlklFbok&o@^s-M*upuy^AyRbe$fM>D=eEyMcoaJ zU1q~SiXxsN^Oas-MgF?s!o_E+d6V2a%ARhi&sH-1 z*l`ck`uJ9+@rvdG@Ri{Nszty=OA+(AZ4L79`kh2)WL&LK{?)^h!Wk?Mo1Vg@8h_#Q zc3kQ2^x5i9sIa6`;eJbXnP|HKGc}I8TIDAY+7hLHad6f5t>mhCtxqL*8CqGHzJ=rS zzP+rp_H>5~NVHSo<$k4d6M5Gl!!7WtP$fNyoIi<ps*Nue$|x8K)?9s3M^Qb%!#dAYaGq*PUO=pmj-)+d7WMfAMN0An-asR7t0wO6Z0{4sWd9)z)B`B)ggQ zwqMV-Fy5RaOvQvu68s0gNY+lTC`o^~9d}j=dET4jJ{!AeYgYafUGIc=c{rh$4c_H|no3 z4}JNIL8Yr@{&0^afWZiHtzf6&H6ItP9y7Fpy^7IvzZU; z{R;vy)%OHfdbOt&o8<7@9gB&4Ch!QCN1N3L$fMSTr#19)Wt`4>EH7w9Sp9U7dJFnR z=XM=r*@Tp2)cJFRj$$(=@LJ5}EFoT~!uR_=NAh8T_S{mT=&7kTyaebz6K zH|62UWs)n!%HLneleU?;o-K2NPXFx(3y(YsN_DFdQDPpe>J@P@V;BjGcP(|R2f=u2 zwLP%qU2f`?c-0_R9qB;jL;ReiTH=GP^WuKAw1WHmfy=3MR6-vK{X)m{Eoip-=1RlrM6T05~C27|arE|kWTc&7f?l}%FpF5+~; zcHYb0zt|+O<& zx|r!CD00PkBh*eURVc3OBM{MijAPHD6KmEMhBT&| zkFSl#DN4`9Bv;8o%+Fp4EhU~zO=hW_y1NKW1?^qjv9m63AQ)cuqtS>hVvNxZO{b#( zGc2ovT}MgxghX`2Ht=?CH>bGI^Nz|X8SXpJ0Xx0T7v=UgdA=gDy|G{C&kqC=NBw0! z=M32O{~qpZE~NFwJ^IBbT+m=g#&sUPsBTmIV}mrZUNj&G&GosRINVJ4bo5zK!JBx7 zIo&M1J!MN6Y`hA5=;XC`9ocM0L!x^-!lNqc%N5SQKv*z{xbH%|U_u7xZnn8S_NwsM z0{CL16P0=!5~dK4aHr`Q=&ZBZV&`hvWBRn~ln0`lpa8EV*becru$p`A$QS!X~bOKfXukt{p&{p?~CmXes!?M_tFEaAVJ6|Y;V>6u)^%%N$)?> zO^-R_be`7BD&Ugj87o)WCIhA$gcd%}`Gi&IT9j$I=W%YH)yNyZ4{(!RRr%o5$OD1u zK5e{LihR%=xt`|n8w0Q^;%yj;EMcYlzU@kQ8~=*~@;N`d%d)fg#87)%K=421mBR1oDq`|$sORH1{HTqS zU08F8G8@jWB0LtUn^%*qPWoF??^mJm%)P3h_>|4fh0AHp{=XH#C3E*i#5vKJn_3Q~ z;wwH2o$Th=CsC<~jq^vD6Buvxk)~eQ#0!0`44nyILD-%yxY?+uSI7Z81bhXgsPy~G z%rvz&gIr~oR4Tor>0VdB(M0&O!o#$ypPS6b_6-QmC6nX3u7q3v--`^6Ijw+%%t}gp z?}KT7s&^i`Y*PXr(hx}*I8m8q)8y7iC^bisExS!VRAsLY&=aFMR(KS9%9+03UJFv%@aXiM^HkL;I68@8AkgJ=2?+K)dv^cPt`CYrFkozw6qo z+YT-U*)BJgx9tth%0O7eISSJp`%K59QEnb;4Z%=#7}o=kJbKu*QQ0s1PX7`prnuYo zjJ6{rFz31s1mRNA0bkR?>dd_%ViKpF)^!MMULCzH=az9ws+vfb}n zqng~t_c&jT)_xe8&biF=LChca8ODCN;;+{tL%>g6KLwxSdnu=R~GHJ3}QH zNpf3Fj{%L>8BzjPvt|>U9<)Y&K2N>H|0q8 zr51fJXM;Z{vvMy3;O_Ez+auyfpaQmi%((oJbd2NaQFB=S-AfTGJD|t+s=P&;Ls|Ac zM;*PtV2V{yfhu?iSE195?RrenwZu%TE_Q4_DpSg4R*S;9_nH*Qbg#R5;H}WF7{%A?ab`A4TM+YM~|PNI`-h#>r*?*{OeBigB%ZL_9}wB9@BbNCKkY# zU0&j32I(`!j1b~CNEEVZDb47AP#er_KEckq)W_|AtVc!98i4T#$32^L^W8)(%FGtgkX@1zFF&-j9 ztIKW2JMZ9hxZ*M2+2)x1smb&Z5|Utr!Kz9P_oyhI0pd*g6C7dv^34jW9KkGo152uQ z=&y5KV=j*yv6u|%O1RAzs~uvHUKCaCd)|`&t0|sA4H< z>{@g6%!zRtDzz>$cNwk@3c~!un4oLTM6X8qwl_Sd-)e>rC+T77PRwm(h#vnsMmC-$%i@YMU~gKlG8QL+{(Id{@i60byEx{*VhzvHm=Fx&xiudSm2 zg{NwGWhnPXrzAEAHL~Y!A!o3iX7GijoW-hH_{>0FFtUch9+XGYvl#@akftN_;hm`w zlxWZU+g@8!tL9VgK55Nw#-uktO8HNz{zfO{NaeICyW7u^v$Vo z75m#*AoLNSmA{I~*bPXD<&n*LC5tMa`}=43BozI(TvDP!YHUz}x@gcnQSZwi<}dp! zW#uWS&M`skEW76mk76_CR@ut>LSoM7HMOQdNDiZ)%*?SEse1Db%;MOi6B`P(u(B(v zs=7@UW_nX9BLj=7+&G+}%xr82dzb~|KaYVwCn?{Qdt3C=9Qw)u_uSUr;c(mWc{!T} z>*c%0>icOU@WXb&v3{1%ch2f^xtaVP@Ky%nfee;fxF^4cGJZLnJIl+9MgP?Oegfou zN&zFe1lC@8vvzzXx1ufssVTvF{j+TA6zs2!L!hFhvYkPw-AA2~5U-sdN)<`d5RFgj z`#Hqpkt*7Wh@oO`H)*cRmh1>9F3v4eQ+AB+;4qUo_be+qFs!T0sQIOPhf-p=V8{^9 zicydM)6T~L6YK~z9uUyVtzjDgfoRr%)Y!l-8g(iWRab_F!;Jm|sMZ<&)#+LG-hT>W4V&Z~fr&gRaWG=$60|nD>=(I@ z*Yw+q#5XjM$%d({(|H7CR|kjl7XgXblnik6e4_Gl!|Qf5vdi3m9@qsYe-TvQOg%aS zt-X1#(Wyx06#R6FV}dE8SpNqXPEfuy5|qjLSSj=0Y7{*Ll+v5^ zONUPYx-Ri=g#SFyU`qgY=4s*0^DE(+$}=;hA;+;jmSV>C`apXU2;v)F791S|u8Wcr z>P>nxgYoVP%8-zhWAx4;rbAjS4wtWnLu z<4=FkakUHJ-_ywm0d9_0h4yO~MG_Bkx{N7=w8YPK$zNLrmDl4zWjd9b&!UeB0@fjZ{2*Zvrl4#CuzcS( z+qyqD;4ov98lKngOtNV^`Q?etQuPcVNvb>4I*p1+HK%4iQCUJ$oYj(M_KL(WuZ zJYOQZBmDbY=-1<3tXxw2H=3jq83J6MJGF#!*`vG}5N1hulGjg}^qvh;K9-M_YzuFX z5!UgFtHo+Qt-9O7Gp}AgkvES%P$=toWva)B+@Vy5D|}r^p5va?)!Xpxc$JZJwP(5J zlrp!Pe54a2%>jv-G>GLaaT{556IDLL{MW>m_FgbqoWWPS*JX9rcb*pG)Q@f2j^i<$ zXvyCoKZ8Q;CdZOz+4i$@1?LD=&W^%%w{;E7!Y0FqISWgRe*_8iNSubIkmyl-Rs#|U zVB=uk3Kzmrt6(|;fy@5Xv5wZ1`RKGID~V6#`*)Zy&h=V!{6**fEJvfTo%80Ywven~ zJ54EpJF{70GRWt9s$=8oh$oTj-DTf*EyjcSgjpmY^Sds^>W>9C#xr)*qlt!pYd5{Q zTTl6$PY-g3JmkC%6OZJH#C;Sm>8lBQQ@^C zW89u6_%^>R#IjU!!NS1vHdO-XtT=rW>hEhe8~p(-PzEhv9eb-!y)9WMpc<6ka2uHM z3~A%Zc!+znNjEM0%X1gO-^LNC2RrPofu^O(CG%e)4PKT#yWwk~#NcZ1=CD(+i`xA1yC_S3UwrUEj(#6T0|R>_YseIT1trq5iTA@f3z{V@sFq}T5FdTV+h z*T{%C+56(-p|s|%{@AE2K;>5+>(NaGdkl+O06blF@ zAt5YVn0F5i$$qx@ZG$bxMCe8xS^~S~mx8w?6werwquJtZQUg84SM%wIULJen^qxn7 zQA4ekMq`BTh7S?{uricR>}(y?n)#o;Tib}OPS0jjecTouTRh?uc)Ew}bs)$X%B>P&oG8)pfqx$hS$6VoU_ic<;=PR7# zRwpWt?I%8@qs_{O3UH;*i;wio!Zqj%mo;3ESB?P!i7~Y^x)0y;O1?xz`Mal?#hO_< z65iv*29i2g1`k%KxmiFh0BUoMKdn&Om^YY(O#CD@?wtC?LN7v6+jXx#m)6^gQt@?n zr%}c0^hF|*+e)aoK5}}npAODk3}ryagx3kA;|w#F+iZJ`Kva}xKp0V*Xg_Mk^m8$7 zF78R{!!qUDDFc52enCp*R^BGddw%qCq7rZ$f(a@#rE3-h+P>{Sd3oJ71&a^2oQ4X%vU3C5h|l zpvY9rj@F?B<{^V2&7a5A?srJj0ym*no!m}m6rz!+l{$3g2ZVsTt9XmXD_dv;8k%YC z8v?1D65`^zw-Xfo+DO&o&3weFpvATpgP|>zLhp}(eF}JcRx=lxs$*IUJ8zE|uLPhIpiPeVoVH7`{vhOAStP)u`q+xi zlle4an;opBid?*lnjezH4x#S#j~-O|3nu5Hr$H5#3r3wmr1d>pU}<~y4@9Q^L>zZJ zmO1N(co|9~ZKoQ{uWti5pI3MMJp2KM#$yE9kKC9|Wb0M~q-CPLwV`k}wiGeAb+LX^ zq@%ygGYCxt=J8e*xso=z4AJ!BRGg%mQGdDXexRvwy$|*Gitx2Zb%~XB2Q`an;kO&x zVDE+?dbDpQqDiPXZ%xB51pj)?O}(!W7gNj{^kPn%x;k8Ml!3X1?t!7gz<+wnmxfzo zD9)R3a1L0MISpjM-S)LQIDTCZ-J%~|HnKFOHgbH*F&She@&Ksqi3-Ix<=%SAT1PZ^ zSJ|H_BuOFL*00DppDo!98!70xf{#1AUaF!aqP<;@m1@uqK?{$lv|y!@3j6`$Lo1Z+ z6NQQQ=V@U+nmgb*z8#mva87MshGie3x&X)nIHf(EGnHUE2SG>x19sA3S4NpMLWw>4% zoS8EXe-U2oYfN(lWxFOt)%h&2bGbZ55D_FpAh``(e`}PHZoOmwAqOwsV`kBpJw<5v za(|BXb}33X`J~|BkaqQhm?BfCe4j+gQUlgsFy9c56MHP9hr^<~h@b=kN7cxjT{f`Bl$RCtKoK@rqH_IyFB>#;ptG;5v zoJ3-RS#eR?Q;wHgxm$Jxil(x6NSZ%ryX(4MfN?fVt@V#xX{(w9a z`>Uqv5>m=PtT|02%W zaX>txrN4Hyu#mkAn|aq0Xu`1pkA*LR`uq1%-N9M-qAI(o_DnV3jUcR1o6|B*TH)JaaFB8%p8t z0_(=JLbW7K{8gO|B~#eBG7HXbYVn<(Mvu%1FDQz<#n5`&-5-{#jd=1X^wl(4>Wngue8~p!#oz0vJ)%>Id7i(oRlD&md}h`i zoTDibvG3^?&+Rre`+clQ_+H4%)%~5l^SP6MOKfEfldnc{etdB5Tbfkt8e(iHCu-=_ zTcZ`fzcv<%%~TVwnX@YN(AqNGq>f;!&9({xneb6RX_W>$r3tFlA+B z1CB~biU-B6*`8EUJ$~hbJo(Vn!)W|R4k`QRwIx*eWZgv8;u zR^PVTl@6Y>U|={8Rfl~^6fI(4#})-NhBuURzKI~VaBSN%c%+)nau+*AYm5?~_$8R1 zY&Z2r@b)_za5$bhHEF1+npp8u^!c_mbDs&usneU_lOSFx)_{Ml&HnS@@#+L60{ z{iuYwWm&X{!IkTw>sPkV+?3w&4`I+r+!dK6jy;J+Adfbx)Qxe%RF8VGs%&*+&Bbrf zliU3Kf+NMTwni=1i?tP58R8zMpxSb?$A}9*89L=N z@+a}~RmDX6u3xU6Q>m-ZQ-G8ImRDSzUER@8Wr3Yl_pj>2s;tt&JpLgNtN;UqqAnFWia}#tRV*v)AJw3D=@UQmFd+f;i8ZzDaGGAwv1Tz&v zXCNlL&yKJ%W~rod{+YtQ^|Fz~Wwcn2ltw~p!(eJELYR0>t%KS(Y|e3R$$5)bs8<|yW0o3o^w^YX{N!F~*Aopr{*Bx|CUa7_2KF`Z>oJKMvOAQC9K_1Z zkH(yxnzOXFf}ob?mXoO3XUR{TCt~obeDS<8;o*bwxtRltEHMv13i`e`*0pJM3Vb7% zJe4RE7uI@b_&*{iSRT|5-y7BkKM*zu4@XY^$tu!*ux(SRMLQAe-?Q$^S;Ncw_n~ z{jJk}?nzx$THe~k1yXT7ZJ z@2Ax}h+2&O42sM|W4oB2(8T+0tjqD!1=380ety0?-MXDY^EV=l5no7(3-s?ri&`=p z2bWOso|(EZ+U+wdboBU!A0|qy3c9lcayi0n#p+V>9oZlMsh5<eF{UB&QIYFKbjh`!P5-I<`+d1>t zE7Z`@V!T4J$vMf7j%vRO+`^skV~gUv5a1UOI2Ot|5cr~M@lkpN!jwRjj{WXiYU`Tn z0d)EBGF#3Q4)^9-W}NeUK2?bNmEV3~ZF|veYB62v0)5E7KP@b=;c5>5*@qoX?fRpO z@;M$0gWsK*_b4I?zUqmPFpt zUDxF5`5YtmwjoXyslDRLXPhMkFh>>=@=sfT8}2}0-i!EFy=#S=%J~IYxOqD+!0mW@nyxWAE2 zZ;tf;v54*TavBFi&1=skLJmx7f`qiW&vtUw z{RFSBQkN$}Eg@g`)T0rsd%@>s7qVm1wAfvyh>EsXOG~os+%kVmhsT|dyZb#0k$tvT znpAF^q*xoTKFKxC!tsKj`K~M%VZp1z_-GnR*MFWua1a(|pC9#ypj+k_ALz{zYAOY>o{=y7c#d~{1JOtK~gDk&sC%YUrpdUfUYxZWsN zFAu8sgIk1N(QD7c-lQ-Re0O1DzBgh`ydeD57?On=?%5ebq{2IGM*g2tnA2_|)LrZK zla2olHhyGJv;i0tid8PWxf;rX9rHvpeU&^oXB7;?TN3jfcuo6@c>nuYgQi( zoE`}xWg(4zP+1dd9$j7S54A!ndiW7Yl8M^b_)5b|GqE{Ket?m!cc^i`1H0*eLxL$_ z07e4F&;KaB#LxV1e1FAKwy7k3VJ2Egg{gvyjR}kQZKT|yH3SU|oSm)J2TNB~(i0A7 zU*uHf)O;a7zq&m@4xSjZo;p?YR>u|C*V4Nn>rf9P#gW{J=M+PB`u$b75HC^LK! zf9ta19UiCCpP1gbPXCVx()JwX zRW(un8egd`l;?p2b`~oyYI*qJe76{AsO`zK=-^MH?O$HzaANVm`E{~0E=7nUwzEv_9q`729`B5@aH+mGM zq1Fq2c82;6^|VxIy)$Y(XrRWRnd51>S`~I1>vOcfR>XiRigqqe`jA-ycT=D*sj(y z$?7B@j=i4WUtj=Je7?12)6i!Z9)G*fvJc{@6l!hUFy*`>05=`Cezo^PE?0;TVHwBT zH`&!By)(y8PE^-=az37iw&Wtnp`WInJBjce^JPP*SE`dbAn_6{725Eu>ra@AVojXP zQcPyEWXe7%mYI~XNTmG;-!E7Vy)&BmOM0@X2w|GDF4l%F_GjF!NyupLG07q-5M(DqG6dgv1%6(KMxe# zbDjbx=Z2k957g9EsooYjxc4mD|BnSE55#0?F|{mIBHF2BT;I8xh4#j5ab{g#cWL8^ zo8_p5RCIT*IVb{AY-K(ONbz;_$VY5P0|FS2WI`33-^%uHZ zd7N(M_eYln?$aejr?aczp*&zLRXDeEZ;xiGBD=r2p3*dB{2!jaGOVp&>zYyur4%T^ zy+DFXp}3Z!!QI{6U5hq&30mCU-6=G9f#U8~+}+-A?{~kyc_!z~nVB z;%hx#n>M>cJ0Dnh*#G{byyTLuMqw_Z zfzBAb%1hz8KFM4uVop!BkHGsK_&_j=j874`(09Tbb zs<|F&TQTdzh1_Og(PLNi#GJe^4TpS*HrlY9=wb(BK+O+_ZVQNnDmD7V!(2avs5 zBE6vDZq~4_%r-=9;PL(zycFxgo{uWDSMIWvJECy!N~Hb3#89p)4fEAKE!Aycq4|om zoQ&1V7Iq(=zfL#X5+|IND2=_YP;;Io7F+aghQP`o(>Zch>N2q(9}K7lrm7U@FiuHh z7p|&M6|u8tdx2_{c*#=RE{d@g$3@!0SgF_LWu_f*-C_xv=$M<^NsVP1Lc0Av+z(y! zlo#a(ciDP|8y@_WY}uJ@ag`40?0XM9XX!Sl?XOo=(EgQFUQd+(q!8bZ78po;3xq2F zCH;2XJg+J0etZ0vIYxsuQhCJE;Rz0=`}UqjqiTF(UZ*G|$V25@A&( zfm$r?kIMVV&_~MVWatpeRgC>!{--qxRy&r*f&_!ry~90LW4N4>dN~l7nj*KI@fGp5u$aaMtrn4wal}m~k)o`nB=B zBk0F3*$jEqcp5v*YPw&#&59H3=#m6^J2~SFh28Kt$tg@u`Qx6~SIaz^{J^HZGEj-g zLD=I^ZVu5{23B~C%lkYz|2IvHMUHPdp7Lswk6d(O_2StSHExvaG$Xc>RLNk^zA&u% zt9{D!v{e>YodC8hbfI3;>_^TrbPc#7O?%Kx1|0=T6$GTrmr+Iu9B^9bpRa4qs@S8s zk*L86!f?a0VY?yRmliyFVUBtkLc8-jd`B9nKz6+ndU)lSRLW^*o30_Bt;!GJh{2g0 zU0tp;j952EOYF^>OPw}b|1Xx*s;}ADl1_~jAy<#(|3rB&ej-n)e*e&k7N6&;i54gY zQQ+=;Ky}m*Y8A6kbKs|tv(;tR_3~fvk_1%Wdz4^WCInlZv#G2A%9M)NOU6hHXU@hZ zJgVFZj#8it7Z;R^$K7W@(gV6wdmau(iPncIq96xx5TKev$*jI(1Ai0iA7SOmx=@+O}LJsPj&VP)FdxOYz~WV+>s z6&p?s0Qi)d&eA@u<6Usn%_T|yGIBh*xZ~G2oKM8(40)uw8-0zshJQeFh+QdTwKB?E zWVdjestVZVb#ePkjnO>8&Aw_!wR#z&b0|%0TKk#p;(@9<@W^P*b~+Bbv7U}mBPx&s zoLb^j7M*rsyr@wVH7j9TSG_njeHTf+r5zjWWo4hD6`c%{H8#R z&58!DGmyfa-dnL^m>jQBg*>1(DTQzFYAC((uQNd`Ee>qnkhroX7+9BrF-MDK%P|gg z`!I}ANgdtp2+rqJ{)l>4CtaHWyBo9+*Gdeb_SxOFewVIiv;9xBX#!kywU*b=2w2(w z%xZ?jT;ZTQ<<{=W14Eekq>Xsl98HkmE33%e^}d2T+CXe-6z4mono#86_f*Nw=$XLK zt`=$8gVACMgVG?gxvm10ohIQ~B1sAiJi>u&#NA9y0v&GdrvzlRB$_u2dfPEu#aQGr ziyFfrC%?nnA3uGuGIM^1M5-c510xk~TVauHBV$Z^&~nmU2z4?jBZG#xwi?P_SASYj zg@whJ8udVLUIpt8O&C$q56P0Y(!S0oJ4kQ0^%&7E#oP@qj>}2mLYj;Un^JhgS!669 z$x!SO7ldu@p`EXI@6|#6)*`|-ILeJ*O^aY_HGXnN+<>3ka!LdqdEL-y$>go%V zlmDWv6l^8u!pOw7jHyGmby-*C-~J$Cw86o59T*v~wJNeMdXeqG5m+EVj`B}LSZ{cQ zG!fBWX~`9W2HJvDCs?MW?q2M@m{EuTgfub-^_;GGLsd(wm!P!5%f7AoX9^A+xGH=_ zqe2%ajY4-dLOp70Y2n4y80X;Tr6(@{(x8F$!!)S=$hb4@4JvU?P8;+i!Z9nT+XBc} znI)Z6kyt58I=r!LrPkQ>NESDbnSsP}|3#xFN3%L@@y?fHdJ%E5NHKRrhwGqpWMm{- zytc?Akl2;XZ77}2j#btdCOeas9o~W?@s^!^5@aVDMZ~ce$sQrq7oei93dY_#r4p?F zmEme3`3!kiZg|AdV>CA`yWSkXj%gE2z-j~|T-z?_)lmC$71MNXqO|{NVNse*g)!{J z@_XoCQH@z-Ji3^^TSo9L3g_2I0@n3NhsDTgy^q`B1NcGzC5f+(J@&Og`>VhRLsE2_tY0 zg`v|+z~C)MNIn~mU?p6qoD^bwqa+K+_3_1~SI>Wqrd`?#K1SWdP%#U8=`T}Pn#QDZ zL~tSlsz$OW_<*Svu$l1O}Ra!iu{68|_w?E6|os47+ps*dIQtwKzef+PGCh z!!@9NlhB9MEnUNw-7wA@Y9YZlKbFz3gmWDr**QQ-7(lm`Z9igYbc*=;puY*ifC_lM zaQO9QK-K>GI7DfcZURD@$zcw_)~eP71%cD4c5R7a4o1qw@2u6R@nQyrdk&fXZA=;BeYF}T3zBeuL)eS6)vCU3`Y zKT=Fg3CDZ@LlnTT>5^($+l{xq8+FagHj5G)QG_H|99EO*BHGo!u_wCv>|Z`toX+u0 zcr29bQbuQ-v19Up~*PTN%LOSIR*=S@2G8SgoFu z(OJ}z3(G9w`G;OadWeR#il_Ag8w&Ae7sU~|CcC1ckJiZm~o}9M`@`6?E9nUfu+j(Fj!F1q{hE_)6Kr-u>|~wcZMa z&ro#Mn@R)?5nG~j2a@`k15L9kt zCc{A>Si%MPHC_D_@%vOkm5e$LXOSo(SCqd6Rx@YvA6fOooLIu{#rD9^5AH&FsS(Q) z(#7C^@#P6S#YulixGW~x`24S#YcB+j@zu!`6>hhjJN-Wov)|A!$00Ob$-(BUHRhpX9;RXBF^Yo}dQ@hsl zW)}MN+Sd&;6E^#YjqS~2Ql9s=^8T6=gjNU~dvb-%IfJrFEX^UjdVsA5(V~dbd6+cO zlgBf5_;s6~;-MkS6`5M|J|@?sY1+k&W^-gpVk0xPN=w!ia-@=jgi|GVLimQyEivPssz)G%V%|=aI$HPRWzTWGGG^hGi zx`u_-unA^Uftvc`#fC37nwOKdqo?H{>5SHE+2n@_Dn_#g$e+QS=U~lRYFeGwuRtpu*%{ zlF&_pg67*Y&+{kGC9|knr9_hFb8i|*GS)d1*?zI;kb&3ky0eY2zo@vD`of&FafRvC zxI0_Km)prNW|wf~YwnU=bA$Zml;RQ1Tb8h7BfoFSs>M}+d99Y)-|ANv?Tr5CwF^(q zE|t%MIj6sC2h|%s$w7zkFtZTG@EdR6%s+nkPNfS#554sAR=5>d^ z=?hw+32%$^!lQMm>_qP*j4(i`Cs3`dN|whljcux~s-Zi~jaxHP$I;~kcXso%ot%}W zX32F5Y(Kw&qCro0!TP`H;*z7R5-wZE`EmBGLqzV{rAAnU>q0HK zKFya~gu?^le6Q#`H!{2i${d4Atm)CvR);*vRj0o^J0_*Ioitznb5uS~pY`7$ZhSn# zcBJq5{+8bJ{Z+b8>Xc)y<t)Ph3)DH%x=xjCTE3=3H2{@U{QprnnJHewof6%4v+Z}t&(YWJTT(#3qxM6)VgtnC>g zXDlpopFu3$NYoSfC?3g;iaaFkL`1CN^Lu7FaZ&6Li>oB2DVAOH@A|K4SVy1M*`zI- z+T&?27It*gg&kk_aEpO_b#-mh3Qne9JMtYyC};{)Wi!0ZalJfnP5Rs0$7iEAX2n=r zeW<+RLB3sU*kk_YG}Rh$=o=g^y#Is}imf*8AuU?ndT|?_IBv*#wKr9IoN-3#lVB(2 z$j&oDe?-If5f9X$lPc@on8(iypFBRSBc*ZQ`zzaVOaR%3>e=tk0QlMJEg~~@wbA_F zTJ__0Tt13A(Fm^qtPnMv+U8~}VmMDq@}-;BvTx*Y@R`+CNGj_7oJKMwFa!?YEDfuS zrR`*2ADAh(|0DcrZCP8*{Ks+ZCB{(lJzKm(>vRvMUyFF{z|;mD`s`GfTs%o7%MvDP z92-dX*9%ZNC!9(Q=NaK%U`a_Z4p)9^!FBN-dT4yfa*B*;Op|6Ap16hb>*7kz8ni49 zm`xFDDiH#_`8VucY)uBR)jUv<6&1`JGz5`E?tYUv=72?8sPg8IvQe!cW|WVAet zpY%^At7c~(uixGVrFdjht-*&_>m0QuYUcMJ2IvmNLB|?=^G#J%MI8;u<2bdB=j>qNTppVsU*iP9 z%KpKN?f2!gTyGKSGl`Y3WgI*`sCxk@2K4hEC>x$2Y;j;I zZV3(4qC)*O?&FJ4ZGft%2d%e&;;Nr*iy=k_z;{g2!?nXjruAs7{z7Yp;Z`drAK4|J zZd&QZ#a{9QC7 ziRSVI{-+mARi#`<*Z~l7MO$_`LE@R%Q5gQq?M|H)j_LF^p?YSHmLAd3#$<+Xcd%0LK9^KcdtRZOw<0|ZE$(t zlrU?DYX#rY8#P`-=)mDAI5pK2dZI2zU$h%V3w~tcME$>^qalh8t49t)J5NCPRvS}n z*<E-Vs@RYgAInNKZ2T40D?064G@bVBf+`E8-=Ck#oZE;bq*vCOr zVL0xNzWVDnK)2VtFdhI+PNjhA{*40RGny`?4C%{^1?Dij?h0ii!B-L}Y;zk|MO~dP zC$@2F5CZ+BIM=Rh8P<^bk>ygp3k=_6Ud{xRiNHl_g^sv`pQt3Riym znenAa@Hyd||6G$As442wpEnN?nph{}`W~LwC#s@@%FZ${9!#x;cqlU3aa~bLO;VE4 z*M4+~?SCT8^!OJ%qwm;j`5p5>S?fat1n0DO@647#$mcRvv$EoBpz}J|x+0Z-E!oiY zO?9%=iblKlmO6RoZL5Ty-dLbh4tzD(%eQJEeUsYG4&4Bu%CubYFKUiY&kL_-_}-}= zEg#iVygBk+#H5xLypN3gp72k;P4~K!Cc`yJ>hoHL&yx6mM_nP#~(_yO12PTM8XBO+Cb z@%FvahO}Sj>UeAj>(=EBL~5nn+Y-9t+}A?x{}{x@*tB{&O#gU#J<_q3x7@`i`Yu%V zX=_5e!+%Sf;=h*;74CsHnVW>ai8Ij|P&o(olq_UzE3{NSG#(f_9NX!}oy5io=EFkM ze3ob#9yjS|E@hh~y80c}LzrpctL~a=TbSxy0fhEvfd(~LJV}NXD@-Fs5fK4D@p)ZL zk~>Z3;NmNhdu7@+td3ls{|*+5zjS@p2{0qRAalt{5PVmWM$FabdEZPm`ml9=q-*4v zzxLmbg$R!jA>_eXnjguw)5%I2 z{Z2Mj3w2#~Ce8(oDX3c3FTFD#7%6<+vJ?Eqrk7IM_lBMyd!B8xQ&&81(x+5Ye24s( zQT65EFU~Q``*CT2%*K>r4&E=^gRSZA>b1p!Y4+II0+XAk+USnVw=0pqSe~A+F=;e+ zGoR;7M^V>Zt^`WTQW-3br{owOx9^`jO#aB`PWJ1LVLkh<{FJuW=3VWb_>MC-FeK!6 zdo@a7!vjAvx2;rd62w7@#jQ%aWh9F0Tgs1}z+a}Us1pVF3lRM5p!RniJI_yP_N}j% z7p6Oo^8AnHhxU%2rY<*7aH_wk+WZRU%S_L@mtFbeyOZx`ZbH&9XnPVXe2AA4_WFEMwnHOmslW5eIz!-)JO(S)UV?Kd<( zku#HkW|n9e*fD8HTQM5dz-+o%L8+EmtaZ5w>$BILw{x@0s4kODw@doE?)O8f=^Izg zu2vt(=ajrIFzp34n#nseJYU9fQIMN9(F6h~35px|@t8AnMc=s74ZGuZIgQxI7erx; z$xrM<=j4|Xe@P5;Fg$zhXg>Qc45jMa>Udrl2;gp%d9&~u!W5I9&Z5wxQrZtrCTPsn zUY0^5jaoI(N=uFSyl@F{TL*S{pxVQh&VjCt_t-hM<#li^c|ZPB!XLAFyt!_CI>2Pu za=i2@xY*66Dbsu!JY=afNqs)QCVb{w+a!)=sB3UxB2H{#c>z8op>Sg^VLhpcCg|iZ zI+?bB%?6ih?V|d}Nc~9ivU`cBef`c;|D*da^1BD{`Y)ni+oZGvyiWz>jJ`L=3>}w2 zyDQ|HMO#YR?WtxO|Ly9wU~FXL;COS}uC{>_BRBu|+-hCi46#lVJ;pRTb|0$16wWb%{|9R=R zc9to;G>yo$)neHgkVloF`qh+C+vwH>LHhl^;VZ--oE+=*YGA!Ls2`(imutBB?5NX6 zku0siasT1F{nM7W+THuXFnA1x*BW+{1Fj?PKF)kyR0 z>o({#a(7~#%^r*SojzAf34B&WspZ4N=&F32K4;Hloo9>Ac6a;JkwgEvoWLPG8(uK} zM|+%ocix(N=M(*G#5|Qf$r)(T%@&*d%YS3%d;Fx!`3_qK)gepiJ**)hstqIz(djFo zz8WVXl$rv2$DP9l>a(r^^gX%bfa%z6WHHU@1fX-Y2Wj=>Y$Y)Id zzYPW-9arAQj{f2(a-RaA1(Vh)&Izp4l@D;3 z&;-(G!`{gT*)bY{4J2Y0=l5Rcyud4KOJ)_Z=%%iy9!P2)Yx5xV5l2eO`)YSoV)2Ei z)RKn_mFBB$(Nz7g>KAF(2(bXT_Pk+ql*(|Zg$t*cinJwya7~o(&?6EM-pYgSol@|w zTVI|2jHa7FfCp{2I&-5Pa#Zt=LWdsUI{-pO2m&N&#{%K`1#}5P4ntr0n9i&0zNvM1 zFF1b%6_vV$eV{CKQ0_8Cn)?qnBH&|9j+jX16@cg^-@rU2qwlaoawm;An)Sjogk_t= zM-(|NIs7OD}dZC2inO@k5g68m4xeyUl| zqcHUnW=wH�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 Y1- z%nVIkd~G9p@*TmKX4%yABZ2J3x~^j3$PaL|OU4JnB8jX`g4jQDQc!#sA)qhpMu7I0 zvtgAK-4F6wgTO5qaI(HVLZ)E+p%of;@N|AdV*JQH5CbI9A|NE%mw{{7*lYl1!km1z z0dO*X1$J3acV!LIr~Ce4FkqaZ1cYR(r-K>ppGV-MI}Gp_N(8~LPpH|~r?qJM$)`CM zX;%h*2UMbJxSvWvhruVNi`~ufCt;4+O1U{3v4CI8LK_HtQ$z6kA~ByKah&jK)9dIz zBdpB-7-4DN{AYws{w0zK?jK)Lt~#gTOCj2~q-_tv13bZ$_O5X`x46^UP03VGHfhj3 zAqS+uQz|;$K0A(zTcc3Cb0Mmgb zWI(|g=-vSA)6foBgZkVVAn4|_uP>fPmD3#S2rvknJ)=xbFao0TDR951B;?SGJ&h{& z|K;hvmx4p$ah~3nkZv_8yx5>wXpQa;R-Rba6V4(ynWQHga-xAJJ{0!jDKR4=?LQ~D z^Z&=>01Au>or?S4^Sg>zG`ba}@czw*;t@04r>~aEDxE{W{=@sENlZlMhowG^$^)yt zo8dGG))v@*hMKJ}4Z08McWC5kSR$JsTJiaGdKdhd*j`CJrnYNYa2A6f;BNZ{dyd=T zO!yr!-^E^Czi83%Qq9S|W ztsYuBV3gJ*72 zfRw_R#EKvDqXd!go3CsMG0?^8Kx14P&I@+Yo(1aaofP5nWT{Ae0fU3BI*$n`2Sq|D|^yo~}9r2+@Vd}D(1h)Vb=EtuxoF->W4D!0nQ@Q$l1s_Dev9;NDGJm7k#AZp=1N;J~cTJY{ z8z$B?c}qy>@e4*-X$JG<*B3V=ToSkSCm5f+04SzLG_*>o#a#PhELWTViz;D_9X?lB z8{+36+c%?os#Rb1C(k;Ud~ek^&V6~SE&?mR)v&i@5P7_P>?)v8Wg=3nab=i&?(xf( zG4EKs97k?zPgIyTeFo-bDL0;*!7ISf5W9zT_^yEH&1YVwJemW=-)~A67c9?`3CgIW z+o3OMg<&z)rXI1A0=Xuxitx-U=Z#r%i>o8^tUIymA0*hoFvp+q)gP=w=NIqIWrueV zJNbhyR>Z^d&T(Ed9V#YRUXN4CvxQNO%r?-Ju`^NZ^nGhgczzy`%i&cK^a|5GSRiXY zy3#ba$q4{<_^;nYtcu?Vt3D6d+SHsq0=J^9l=FM-Ob(c|zt4-5|eBx_oF;5sf)foAubo@ZA>urfWLz&sg z_;+bm`ufMwdvoyvzJuYn{9*ks;57oaB$Qt~Tu0C<0Yi3bd(!{%IF&&2M?hDwk3=xf zb%!YNJXw@?qY~njp(P`t?`Gw(mY{M8fmRjU&jxuqs@_S3=4>i@?ayWioM{bul4C|m zlkI(+pVw+R%luKS<1PF6xnh!p zj9?2K-90LWXB6yR=}G5`fpNJ$hUWe3cY3K7XJ}J}aJ|{42?Lw=WTIq+p=K=C>xEbS zG#R0Z=G&KpXoXRkxaqTnjq2Se=xQ1&LZ2KxgVYv2>>OCeC?kgNdT0udmO~f|cg;Kn z)|Z`F1?Bq3Tfv9O$vHpttJzcfbk$9lGDR4{bl;4-8z1#vBY=-?rgJ5tCZ>(>vXII2 ztKIa0lgsMIP}%N!qG1B+$<_YJab9JO{^lBjQYde?=kHN4VXFyd*5-ScK>xTbb2iiR z^rbJm@7UqhwsE5b{02u!n~JKnoYjff)C@|;-YixImuIEh^9AXr?p(rj2JD_@?qKsW zevUuqq}BWyBJ)CAA?e2gE~%+zJhpga6ASHA!dYKN8}3lHuyrWZghWPfM|j#18etEh@W^m{-CbJ`cAS=PQih1xK=;VDk-2} zG{I%)R*3epxXt3v-HiNk8#Nv{sK_$#W!u`8dYB~2GIv}48wU8v^8SNVR^}Y>&$~r_ zYM05T9Crr@an48d9$81XMH3yUd!&L@MRqXwRebm_FA~}8>?%I;569nK#1_vs1xH1G zJd`Bn5BS^deA<;)C8$)2d>!kxM?P*Dnk62`kY2TV(ekSB(81!P;PjlBSeg#xTy}z> z%B8P9#~N50*~rkH-4yw_&)aOujM&~C^Rh-*OeRE;Ieh^qV`?*ZJ7g7}h^xMGF#Wsh z+h#M4s;c$TbZb}nQRiYiAb+I=reZw4J6pz|#28d;x zvQMw(3H_sf628h&l-s;JI{H?Yz4a95d>ix4NHDRQ3mnz6dL%hBovOiO9MjaeV0(7w zL{n4yd)iig_`Dnc^j9ObB-RrZCl64ny`ztg74p1g6+grN^kB`L0-zLZt3qd@mZzlV zt=NTCq>b-x{vQ3k;s}Dh9lw{DrNZ)-`1Vdkzj5?Nbg$hv0aw&`DrhTpr`?yV-OQQh zUG1Yc#FB)lsop6A-Atxad)=h6GW$(VSp6{G*ZD;Qjaabln@JXo>R`^*#9S$L(F;%~ zX(#u*(PmH?k%-Pxo^!z{v$42mzvfKW^vAbK_Q2E?ihTZdtG`I_YqBPXtrY z>iz1Xit^nH3_1FEX$&fLOHzzUSp zj%sNr!En|29x})iHC>OBWhjG=O7CtGKe>Mf?w_o-lEVY*iby3=o|mLv1zx(rHqBeF zRY+ev!ei_{Yj^4UN2W6tS*gxyrvA8S_>k=(ZWt_O!E_@frx-7S&e~7DtYk5(McANR z{Q{RkBkH&xWIi=C**}BrdfBKaAvPS}^LV!>fQF8>xI^o7;3D*niI#b80GrP)W>LZA zG4>hx6y6U#ho-ILrN-$ZE)jW9g5&00gHV!74MSC^?VtX53jQ1K?oF)AcUhMQXy2~x zv#UbiWp#0gMoDW$rwE__E_!ijFLSy41r8zWUs*15=E<~IAdZ@>SL>8r?j_e( zkyYE_I#bIt`zCQpelGQnj^*N>h%_%G1WzW~Y%J4#`{3o;QtqylwKG@Yj)v3=7B&C= z*t=H`J2QQN1YOQ55Hn>(^N=nQxG;Q`?hQ-gtDSt5^bd)(sq0TC zv2(Q>_ojaTEZa6kAoJtRShM@-y>Tvs89qkV$lJJlHeeeX^P^W%H{Yia^L1wLXC=Rf z^C5jaL;@M!v^?JCP=Wh%KJu!*8j~8B(9j{`v6Yta@MUZ1Zl#Q*T^@wkLu2 zr>H~$Nzi5Ld1000@6sU4NrM~UT@iHxCH!@Vly)Q%Mq%H0Tvhh~9Z!yv>J!_WMc%R$ zAJYlLSmzQFa_st8eX7j5Qcs%bT{IBxGm2F3T)NYj~RuEXOeF~}r+dpWYr?uq3XL%mj(?7DDKjxHJ8TY-AQ|Mq zQu1s}bMJsH;`tjJ5yGlmS1IlrQBrg9a6%4{@P9ZH>iFvpj3!52QS{o^=;-M%RR(^9;*pLB$K*2VuNm${}@1dq4gRy8g1F zXkj&Kh1N|`k0JccoAw;PU|hrb#$)TglJ|e4)l}6)QeSxw{rc*i!Kg65y=fE!q|HOr zuNC{_Uk?PrTt}$q<}SjAu^vn=QY8fsSZ8nO+KWs%nwDla%57bB9gbLOsy~Jt>EFTR z{d|_H`rG7Y1At~^N{qa;akCFA;?X#Ds=<8xknrjV>67x04 z#ak!sWRuF&(7$~lL(xuOxbM`#T_ijXw~15w*L3?#yQY{Z+rw!j z6MWqwlQ;Yo1V-@~A}=W)JxWMTUN0;^8Mh}fSLEF-t6?OYVK1Vn=mBp7V zONfe@i?B%~>A6ct;&?3Sne1O(3@Y2Sv6O?mdHto(y&=bugGZNsEFGj0pD6Bw%W}1c z69aBaPrYPET${t$yZ1lJ<02x-cs@C5nQd!74I^9MKF@T&uEhSuHjvott?2fn-UK!EU#S`jmReug%$13yvlGS z)nx8AuR6$NAtx__$r^@9%y$O@ZrE0Q1nfi$$Y}$(8TM;K?j{;gW2aoX{=qWU}z)1m7{o z%?0C_a^gYG3kfH=iq@e&{C&;YRfsxYg<{Iglgj1iKL&Ki1@Tv1KFKa`*@D)gq+6iX z$R|o!p932TKXxdheD?&%W94f=2c05PN}r^bUqvkcDi>(~G&1CWkHI*#Ael6@O4yUc zQ<7{r07b(sABg*xXB1q(vX+Jb#3cPw$|QkEbKbxwp;zC5LH}GePPir200dh9#QgQ7YaRyw zwt)P@!s+xB+f}MSiVPwDfi*L@Mro2*-TMMLkjYcya6{T@TrM@f_Sxn~Fb9W@RjeU_JO0AwNP1TxZ5u<$@B zCD(5O5Y2drEpY!EftUULG{Z8OfOwfjRlqXOW>a;@W)u4Qer)U<4(!8#*W3AFb#l)~N!Wkv>-XneX|IW;IJ%En5DpIQ&5bq|eAFoyS9o>^E zL9tdNv?Lozh{o!JWMSxR(;R)BMPmX6eaT2mtuWM`U~6ezxq9pwFypBYh}x?@%1&G%SjaTS9#R~FMK zX=yJm&YQSvL>$HMJ#RVk@do`rNud`QQv{<$r^VjP63XRprCDQ>UA&C?Y$a5Djq`W) z#%<;IP6iq8kgP1Nv@0JA-KFl`@pX7NOb-qBSxVP77+uT7PtYAAVx-Q?Q|qNO^6Sq{ zPn&0Dy|_Q{Du1Kh`re-uoc!@iL<0U?Amgm+*%S%l1T;9;EG>v*48RwgK&ShYH>VxcJ}Z! z>IvE@r+@IfmNa>WC{EmZYNmeir%Mrn?_fXscD?Rq#ZI4SzPX4dVI|8l}=!KK1(UrJffFrC5i+!DI9G-?Q@Tw4ujcrh#*t z^ar{2(#_d~1K5QvK~Z_j@MFV6MmqOxF~J+>w#UVCfr{nJUw;xQ$XJZ!dr^eYyO8=C zPTe6t@!S0=s0Y8sZlp(3;7v@_%<;V0YT6#^sM=m`t+#RUWiv}}YjE1cBOpp1>_aG$ zA?|l6g5|fXVOJcAABn0ZTzBTTd-#`Ld~$RdG8$~o&UPCzf*OeG=|ID6!in2K93hFk zTo8z_kCC%`HXnD@nr1&b*6!wfiFAz&Pq~-5NnBm2&OxVusPDCTv{Gfoy=wc11XUPc z-mL&^EA*L=eLDg)pPd+9C?)c<0Z3EQrG3`|4YckHb<5cr@q+&>Wt$ zsNPbj_n3gfVKj1E_rRmqWt7-(dvm2kC6gAislI`hy8%u@QZ;76IZH*~tL1CN z?;aZ}iZ0@=Q+nqkAE8-tr#{Mx5;mB zJ>TOa`XWp&|LIKK{^BsYhBo6Xw}k@eg5O^C zxu-mydTp)5J)70r-8$F&DJ1&}=NttlJ=w4g;%?{iyn2KUT2lbk3R#;zawg(3gLH+^ zeVXc)9MR_iM$lkL^%R?@`uu_1F{a?+dir@cdEWL^{hgfq3#R5lOM2qz7VlhMT}DrV z;yc;Ral7OI%;X$mUn}Mzsn20KgHmgb$Pz2mok-;;rY5WpWpmG#waQ~IR^ zu>c|m;)SP_#|^yQrO6$xeJHdyZ#%%Tlyc@?Jwlj4BE+BIagygsdn7&WZ5Lzf5GvFI z`?lP+79Ku&jOo_5K0=$zlNKPCvyR(4Cyc1Wqn!?iq!IgyoZegg>b$D0Fms+Mq`c-o zrRUamtY|SA3?=gPCx*74N}h{-Y|^2gq(?$SaSokEA+n<#pE5(jVG1s>k+#u!tq+Ui4yqFV-8y%f1}0$L4i5(vi)> zQSxP8;*Ri_A@AJOV&fHni zLF19i0f%;7$CD+gZ@bTX&dNuQtZvT3Hzb7-~ zb5G2)e6Y58km*Hr8|LuwzAjhzlO4JRl7-XHy1fk8-Sv%whKs*WxGGpLay-taIlk>OV6F zD8@MZnRkx4>Q3%;ubJ#I>wIj?YJ@W7^$LSiEa#!erV|B;E%t)!xtQul-Z%sSm z1H#)m$v~<{h>QAGhRxOQ)i~K<{aNe2Mk4q^DB}cSYYQbz@mTi3a|L$zl~wA_4D+$k z>O@+F+iV1yy~L+nT>U2KL`IhHPjSFwE~?%7BaJ)4^>iERDZd;sH;sT#?Tc);-P}Lq zQNEL^?H;+e$~XaJmqyFiBc@uzlR5-P@)FMMe;cH!6YFsHp4)AS9-$Kq=-n8P#=&;L?z z#QA!vl3V4r#eaIzhtcs1ZWu>39={o`*_jyK7-W~PdW~m-a1f^1){4phX&0NBtAjN$ zO|%K7mTvXt99*b@hlV(a)~VH9Wa=pwghx{OV_W&Oe*C7a$c(a>tC)jUp=>!*dD)}Y zzX=3W)T+S8bBnK_Xt!~2#fupg#redhYEzNU1j$`S%U{oZWc~UyiFkB@M#USWxWPH` zAeYo^x(5Gtzv%|yfDo6~r88yPE0^qVFfYv6H8oe;>FS>=AzCJR`RiVd4}C0H<w z#KByc(r6i}Cq_4o2X>{d2;Z1r-YLHm44M7v71Mq3%o*Y>BG7ej4B>zthEoi`pa3!> zMmr+zR9WqCC^_*Fs>K7bo;UYaIl?FARl)~swBGol=hpKGGW4dSMI^r0iEqsc@05;e z;ga*Cr8hW`doErho_XuRo0l<&Vix)Cu;29lwVA7tqUEU!-l~Fv{f=D^Hw7hzt?86H zBFL6zUIUu5{#NwEAB=}A)5)m)dU8X{dh$ZGXH|7ozTlYD@YLybri>N0+?KUr>%O^p z>q{Q>?fM4Cm+c*b$@)w8i1H~R0&?Owi2s~TMwmwuClz) z7xCS@H%djT;{dlR_qe1NZ*Es1HXZrBd0|X{B{})t;7q2MVCc=OE4Bf#_kLfGcsQg6 zu?~k#-sIF)))lUeEmvt)vGor-)#_rhQdTwEtCBGu-L+t_f9z87Y#CfZ7*?sYNh!l8 zSipXr@H!R$nttIGA4dvNW{tGZ2d(8YVbOumkzVQ#=fwQHeoNCrdBvcYI4?^gC?f0? zO^`k&dwR|fA?vXPQXzz=&s_Izi}F+hJ6w^mhFGDP2VJABx9FbTBFOx!6cA_)F-Y{FvoJ-K63OuCk)`R!02h3=-MG+K0cteRChsZ<%&#^ap|G?Y_wMc z`Z|VV$Na4XGh3sk;l%DaGAGqJ3yV$Xr$ZGVF87BbB-L$EfGOyh86JY&@X;)e-u-0x zQ1qH-wnDsU$_;WfppeT~%+&CpvJ|3{sIz0B)=FBxME#P!29(_bs+GpU&6|`CIGt*b zi;#3I{9s=FqQ}jg35A;7n6Mfj7B8D|3-OUv@W8-vy@?$cCj#xfkwF~O!#n|42i*aNZKXZ6pSUNiOj z)C`f<2K`*wAGYiNe1{a1Eu<{PoaSR0fLhVGS=waINXw8I^g=`T#=28z>xC#q zEohAfrnR@`*uUvI8CJfZz>^zu6pqjZF@$6mNKqc@itit2P>{d>XkEOkA+*b4tq??k zKU?92=TnfwWapowM%5HL!!P@I`1dF(jXg%R7<|jg(9gk83wkVn9a>Ug^P06%#(5sj zFzsXZ$aE-Yo+;hJc&t(T&E-li=mA30A-F?fvD!{LKt!WXZhxA)=Eu> zch)r3gT>gyMO#atv8svQL0!Hw?AD?Xm#MFh1h^S^C{-qRsU_ zu~FTmkuVJFXJIlV1uR)dHh|++c+l(libs`JDv?yH9#Q)vwR8pXF5MAEYylp7fstpq zcJH87oL891s_GQ8o99!eSnjoiyR=wmlCP~5+JhW1Wuz>n5}H<1hgrT5 z#2gg`3Wgcx%Wz5q#_bGK7Oi7Pf=|cw?V?!OQ|H2^<6Gk{8o61ohla;IE`f&ERs}Z; ziw}onCjv-kClaHaA3Wr|CqK1kfu;}M#L<9(fEmd>C{mMiIeom(K6`Dp%)2Wc zJf72%X58ADa}`|y-WYAaI<=L^u!onzFXY>Klwh4r>svN$Y3%P?sQFhNu9{Pn{xZv+n z)p1ek0Y>^8TYtC03l`!JA&SF?7%Xg>74*$**ZyeuErh}G1$xDBA2^RGHtSpMpp;c3 zZDB{{0C^REMDXie_d^+(FHPB3yB}OwddO%#1q*HhL6(813Vw|FcGHTjKkAlHlMvZDs*=t1g zGi(SNxkc`&`6VHp-5m;9!o%#1V=o9sGW%NhzvT5oGQR%8?ANTr;w@tC`7zV7`&D`L zH{vl!kBrdgP`@4>S5pIOP;BrMyo+iY^nB2_D>8BEj5E&|hiu8UCD*v;taKtmUxVDq zdnrL)BbiDR@nHM3ovkPzF4IhPrEY@rZY5*3wX=*)t#)pDPR&ymz4z2-X5gBk#TKr- z|CK&I!@GHHEOK5NY$8t_?61i#wIrskFf9{%Q|4h=-<0tO)kYeFZUey=02BW61<;;j z#14f#1FXoBQP8Ef#fmQXyQrYe8(* z_%ukhAd{$))|`S(aIH=oRx^97-q?upCt-@EH;Zv^lbv+}e(dZ=i#Zc)XTC+$_YVr{ znBSgI*K8SE2uu*sv}ul22@+U9xg;A=IEEFLGbjXCvMbb!d+jEu;?~gnclljpMU}iz zXH{JO`Yw`Mh)6DxETpX@=rTjlNpKJ?F6ebsRdr=1moSyBC!dUWPiT7~t~68X<*Q>o zVhyJlV>_P=^^Dpyc6|!7gYZ>p1paT#HqnXbg0c0FqTBd{G;=;eO^xjz&x_((O0gI3 zU8mytUAI(kE_bs~3Kv9EqAXVn2``U|Z)paYN(YZHwZ-3~EqvI;)zX>p-`!6!-P%~b zF$@DlFOzJA$qc-uch6E{ABSm@Qom;mgi)X!evBs6hjKr-&5doL{SBUN$@xX&KzS6> z{g(mFK%s%|+9X3ce56U$rIIx_X%ZXl>I`~ezcdwLAM?$3H2uRU<%CJW7o6^?$t6@{ zh4iN1L&Rh_(o%T&E|VLSqS8{7XkW*E)#TK@QJI)0+1-`E+8jq1I7j?mz}pu=fz-kH zwJ0GT;q<0sfpD`@;>;2vg`}+m1(Z|>KE<@UI5~A;9JO2A1z}hJJ*EcUX#ljfw0||d z7ZAxNk$tM*3j2A$Pn`Yn4&z4+BSZgxjh$s!R8hCb5e5MX2_*#S4k;xh2M`zs5RmTf zE`cFOzySng=#UN(1tdpd=N4U)bVP6ciHt3`cz}+#j3m8Q_QP+MRu}wUl);u+BLBDOs=C@ZwE~w)2({3 zjz8u4=VBH@4g^LyX(zUi{P_ureBaX^q~@eynxqOi2FhU8!bX36q4O<|E*O|C5LCc} z$~sYGargcCO16$1uO4nD*bIQ}17>u+kLRsNbDoK$bBsA*eNcoN6#M;PwTHMzzc4|* z)#qZU(v~JoF-l`(CZb*0<<_&FIpB{mUzI8$bk!0y;`tl6iXln@8*VVKBOqT zs%NtxRy4%J=6(SicXc$OcPaAECwSO{rHUul1J(2Zbwin36&F`*Vz1qWPRz!<^9`%d z3y18lBR0anXyXCV9dj$}GQ1&2?{5osJ%?pTZF7F%_*^!-K z5}>%dd{|M5hbt#Mxo+IO!Tr*Uv6YmNFEWFl3oD&Jk`aMe$>xVdvoeT3q=DQ&R|bI< z2%hbX6hD(zfnql$KM;@+1(PZ~P_Jfnuckc>BOgWL!&9-h*VuZ3$kx~Z#lx8B5NYKK zNk{=b0+=Wud9=uc={VYY`9Xk(gUASd`3RH&r?zk)FostCVfkjeij;Uks(|1;`evV8 zncV(|?H~{NSYB9AdeiNBS662UBYvw0r5}W(=j80c+H6knZx@poywSPYrR+N?Jb2b#R=!@ooX-MDNI~G~ zPQW2psjWqam~#3f8hMC956$_&ch~T%f-hhH5UIt`IRUWek=B!Y9B=|o|)N7 zX!~r=hlH5avZq2szzZj@_7A--aEKX*N7A+R-nQ*jP|I&9lIR0tmj@O=eHjs)%rJ;S z28&%aYvJVm4KPsucq?gGpkaFm@cnPi0z2`*q?@rQ0_z66_SNy_+?JNm#>j7iQRnz@0Ty8n z-C|%anB7zIlYY_a2w9nJAaH1Bmvib^sQ^?J6OiG6s-%JSZ=7PpJ9_`UJ7Cf?N5YP;%4(E4FA>*x_RWIO3jzTjwm@ zFtCk|mjKR~7rJ~@W6clA2Yd;vz)&S$ohpUdtTI$Op6$G5DQX&GV6w=LQ9x^8^Af&uw$(u|`tO&x z8j@FmWe;Fo%B=?08CvGsXH72&kCsm~ux`5w4RqFnp`(8gV)v9sdCDabS7!hGf@hvNq$k$9p?c=bxl z2e~W#QL1=ME)^eB+Sl;a0iq2iAEOXzj9<-6+TZ7AVObzR@Yx*)tOy8h(*_C%dcmId ztSOsjN;+m$-MnJS$8p~fJP0Bt3ev1w&abfz#&Xs#%~r+hX#C`SD5cx0zf+ROcWLHt zmYKqOtpRM>pEs;l_MTyv)7F`R)_+ zHD+)H^W^*2rA~YFRPUrscKMcmdF=s(Te)mgUBSD>8^_{|sl^HUg0ickfH5>zw&+&o zK@ZugX9*s|c0>f;cXzVWT?`eu>#01ecbV{(A(#A{uDdrF{9jnrkt_>JCE}5uy2U3! z=XUEp4N?l%HDhZt+2h|2SRV`x-Q{&MC5+aw{mAXHM=_K)_6)DjvG&WSZ&WCzbK3H! zuAuxa>zB8>;zr!thx-gT?K0Ow3h*obU&k9JOxNZ0Aji)O3*}$P`3tgMhn6~sNc)*A zSiP9Ht#}lS-$o;@*+ z8QiCA1N4{~O8tOT=i_jxa-K^`PnO`7E~VOwsb9u~{|&u~%`W4Qf4ns!+N5fY-Rv!y zVsw#(C4Oi1%L%kvR|_ycmw%&(7FFDz-p4n8F5tHD-Zxv~T4UBsXIH1nW;C+>`ld0c zUMsv4D!x^Di_M9P=vtE29B7zZ8dZaWm)8f6L>ZeZ6#c_%S#~<@ z{dPt^5@-7!K?`^qY@!#3=}IN1?nLWwPw5TJW>oSXe@}lH-QdiCdnR^i8X$sRX&5}6 zX{=>LuSEG`87^WDs@yibM?BIzzf{icP0x99xTw&&_;Pz*t{EgN3k%dAt@)R zhG2QV>pwQBC0GTwy^P1w^>?qG2y|RzU#q3^O{1%y-VFC`wsrDk<&R8bpV5g7##ke` z?Id#Y3l#$c8|tY>tB&~UW-fkTU_xu7mH24q#2EIW0H-q{_Dyn_9vG~$c=3&bRljL-X{jBb^6CJ>Lz10J01qFxFvTv zOvP7Uf&d*`8=a+^R!qy}{i{Ep!@CNKZB`!XzB(05^#ECBumDFOB(EmG7PJg;N7mg} z`iD>Eoc2#@J$YtfM6P>hIL$GA$mQkXTZXA>_t&kTR~3UgSlGpC)uuATWm zYQ0@~GhXWGKcr`Wr~lncVS(P)cTUO)NX%D*8ctDhbJ@*L#3c<76vHW@wx#LE2f7!V zx*h2;Za>StcVX|iuq>p4w?-KkrrV8^{m?e9I1D&d7~(>J5fF52XTtf!xvyE(#+?_J(gH? zI1A-r1NXH^I;@dfrpIA$Gp2U{yf@74ie6}@6*?&14z(p9JltY`( z(%P!10Y+(dZ>7IhdT9e+qNrGWoTo6+bhQRc3O0S_+EBO^If(Q{G99elp~lTE}& zM7sSV?;w?l{?qr?3r;J3$O^>(H2}ca_#wR{2sIPrw0rEEJz@-QKy`hwgZHFGS;RjU z&c^ZED8~Tv|779twt(lDDgxEqCi0=5$Jy&g4@r`z)`E<*Lry31U;EQ@*orIRy$y3J zYbe0Pys`@7!R-q)mEIHsg6zbxnLLVvbKKUVgH5#;Yrh=A2oryn-4Cu^A;b^Y*aOjf z2C(|95G(ExcI6gRkN~HsxYK+{S&e>aLSCiRpNo$|n{GW0fg5edX?es8aM!z=VA!U; zdHz`2wPfMzwYE#n&>`Sj8Jx<3V=S(NZ4J4yQ;@&K3k35;h?7rmM$;Y_W%F=;YIKZE z4lE^obFL)Wvk|)t(@7EinXi3gUB&`wJhG-L%yYiLG%bjHd$~@G)}tOCNj$xxIx1+b zTx*)q2)^#9ky)&1*n4x_RAP92x~Cmgntb!_G#MHrp)ZvxDE%}GqhNGgKVSt+UIPSs zq2%6~H|7sqDopmPpUEW4zu!^fo@}<=o2)pzPT~}QMr~ZzWh%WG-}pmL?aNZ35m8gp zfS;adun6#;oKRPvaSJ)XIfK!W7ohLpW5YEzFvxvzg}gam2&Dw0ci(s zUHKd38YS{GavnYr^Twgm&9$4Rt-B+=-TcSWBUDZ2CRZ?Hthc6^SB2l6`BwY(%eI4} z8(6ObAdTIz?g_Z|LrL6anlxvC7)Pt-S7Dob?`$Nb-}vQFms*(acjv@8mu=p6b>*BA zTCX)`CKi0yhfh{cEwF%K#@jps`}S}!4MlM8T_@LtH4zP0N zSW2@_d&Je@=)--*t9RLVIvG{Cdm1#m7jC4Zps{hezi@he>KC}>Cr)F=Ao(+FBVI_H zq9T(88vm8k{&q|<NT~hpnK@Lhs{3Sz5SHl%!^D3I+H6p5!vtknfO1xaLWX9$4tGKVJ-#>fH*x zCMmrlP&1cY==?EY6woQc0jze1$Wj%nURq$;)$gg4Q&4DFU|LXSCH-bMk0R_F^245x z>!d#&8dS#F^X_OQI$4BgWrDk*me>h{r#+WJYy3@%^D{|X@KDO3DYLjRkk746y#tiZ zOf++n3(C?(fGO2GqWdahwi3J%wdO;QPReIgu}O`yq&d&dVf;T*KVO;i@S1i#U%G2> zolV_q9dO`xbEr+2xErit1~0S0+3N`ln<0_3@ukx{ed%nUbyHY(URTpq&sfy07C-qL zq1a`cn8Nzv%~Mlyvb$|)OV4|n^v8M~a&{rt`-{79;>nwgwe>@z(}1&#d4|fVPc-N{ zqs%q7EBjLqS(^H$)799E1WiW`aSL+PC~=)t7SFmNYca>)y3UR{uH}lQLUesrBjsGX zN;u6g^Mo00AH^DRWU>_y0>Q!`i60HjO}Ar3p@{C(*&_*QUSHAD7e0Q!ozGAEjHl~w z-rhG%JJvTuG>*z#CSWCd89CB;wa8RSi)j#2Io3;8@26&##1dq?va5zTw!r=Jy2K05 z-aEy9@hL8lVSA=7akJ)(1>_@3|BE7XhZwk6{A?`q3&tJGQoo8)j$LN#uV<8vgBZ0)q?qj>iT7D(gd9H zef^_MF8x6zwEKKg5^{%*Aqyb`8=pe`3Gvlgp)pCngHcd=@}|S)Rom{Sh~F&;pPBs+ z2oe_YDMj6XjO2=ILus+$Ny& z-l&sS+6g2LdB%7CX+fVb!K>g$Y4!e-(`}JwnXSa_ADk!W+GkVf<>)jH<#J2W8zCOQ zK04Z^MrV;NVo&v+Y8lN|SJ!%#&<@T>YDgW|CnayQqe~xIxF!{;LtBR$Xv$8%6Q(VW zEFMzU|L)joa$?$lW>8mEH8Q?+Ai7e!rW2Yr!LMIiab$s>4hpilyUJ&XH-D5Bnw;>9 zmak_p&?D!y=-o2d z0;yC#@h-*JdHx+ffLf>Pg*K66^RXnI@#Tq$zhz5GPaWu_ zNf<4>K8mRaV*erqzyX#9*@uR#DrA}%AZa~V&hoKF`HWNcmUg&fG6^klFN79qt2C=7 z`=-zte;^6Vt4WWaJ9$wICtQv$2vx_`{FzI7A@1?`XZ|k<;(A(h$0Q5nDgzSN1qRms zi0FY&VRmwJ-@^+MRsN_CQFpov6pGo3Yi4_0bOzSRL<`?^-#_{#_4!XL#{V_t7k~CP z*v{eVpyuy*zgZXX`vdo}ZvY378?}ct^XK0!exeki_6k~bx$&J>WAF=Pb|*;JUcYEg zG@kFebv|&Ra@6A7Y;w80Y=_4AvSGk5QFyVLfn5%_93zoUauAnHM@z@R@RnM}KxicC zLt>VIH05GkvXey6fsKZK$!E)Ct%_^Pj}3fLxy>$_8HC1HeA!UjpsA@q_0%*+ccz?I z>fbj#(5x=JT$kn=5q0Mt{X=eQP&n5RRBrFCk29ye(~4C3uEc`oM(t7=uQAa_UDev1 zjW-X=4MwLYxgjA}s(NN^Eig5@Gd@Mb3B!T#AD+6d$*tNYubkh`PR#h;`Ejqzyn_9j z9@-mqbWxr?;bd6xx!L;hC`E3l4%SWI{iQ+WR3x6;eV>^2;A=AO;Sm3=+&1fNZCguu z&j`hZ6aVy!sI?jcjpJ7RR|h1uxi54L6h{Ef*keY~iHsOnV8Z z74)JVgX>x9t$q-T*E1FXL{g|8wu3|sm=DIs-NF?Ml3U=_PWBHPDt$->=nNhBHiX(e ztaA|9L4ftSgIN^7BcF2ZAftpOiYLhTHrT_`lqmpesdO_yPN-mE{rhepQ8g>W?Qf}& zi2w&uU?`&xZ>bHXq95cr9|0)X2nZ$yKoP|)^kI&z7=&V*+y?gnz$y>h)wbH2)r}q~nj-nUMcA@cVqFBi%DWIuQ*V|Jj2c&(%p<+C|DiW z-|+0#WyWu1pEMxPP5~u!LpRT%R4QkqRlf@dUqaoU_lN)$8>W7UL;RELTf-uluGc>__!Ba24zN=m zA_yr077@=&E>Lv;SK&B7L`9yRg!n@U!~89z!@+UPm;9=5Zw`f0`$x|>SwIdD5A;)x zg2uj>TU+NLUIF>;;&_;Dd6%9g6e`rKMqYf$@ea6DWtXHKxkTHSy`R}gqp-}UXe0m{M+LzTSrR5L!I`83)(fS&`dQ)(JCh>RZ}+36i)IFJhYmaP)v0J^ z+g*S{hF3f$Y00r#&{^y(aoWI<=F<#PATZ^Zk(Dh;8I~Gt&qI4lostD~;;9{Ly*d{B za)0AuZ4>;)7;pam`X-R8w#Oy>*Y~jLOh!anq?vl9lgqHm05rW_(}43?Yr&~J)L_)( zf**I^5C;Z*LN(g-bRTFwjB>I9f#%=i-JvUh+;!lRY~5l?xm{f>lvEf|S!qJUhXju1x~!O8)PZuN_rGT6EaNvK=G>3XbIfYosV!=CSeFj%uRwu6pB1 zN9jS)CxHGn8slFTV`jvpzDNgF5e4mDOpIfP(GwF;W*{8t%rNwF%}=o7nt#wI#xdJ% z;<8<9=^j2~+RolA$=YPma`lnA*KkK;mM|g6)a6alPI#gh8*Fwo_~1o9nhEGNim0#^ zuv`3PdS8w}e_4oERhs3;>{LF0++(*sU-#X6->X&RG2S~lm5&vVI!P*EOAx_0G^K!A zq^KFr#eim_OnQSKVmh?OcBlL|H9u}&O?3eqpM8m8`nuAb=I}V^FN(k{CTIIry1+K9 zWzKmaYeEhhV%1F7tH}ilLi!-QZI4z&X1=h&8Ets;JXxphZ4qq+1Wm76s$*_;zW!8C z>(vUP>ou^nnlTY@w%lNwsqk`;->_@PVQz|Kq^BAuG{x^RNnQD{6G`5;(Fcd1t!IK0 zb6ug<+^@y2`w|*Dou?`OB8^BN++mN&^kPHvoU4Nb=hdYcuMM*1Vz;Z^95OCZDL*S4 zOe!2K^Ci!Rik9qcBhzBY@c)lC!084IoUx1v|AsGipg$9X|EG8w(C_`i7XpTrb~3Af sGQzbCu&L|j#@|6V5BPBRc8!jM^*p0;JnrUN6}W>1d7-9ICT9l!ACyKCnE(I) literal 0 HcmV?d00001 diff --git a/.trellis/tasks/07-01-dashboard-visual-refresh/check.jsonl b/.trellis/tasks/07-01-dashboard-visual-refresh/check.jsonl new file mode 100644 index 0000000..26c4ca7 --- /dev/null +++ b/.trellis/tasks/07-01-dashboard-visual-refresh/check.jsonl @@ -0,0 +1,2 @@ +{"file": ".trellis/spec/backend/codex-continuation-contracts.md", "reason": "Verify dashboard data contracts, route exposure, and sidecar boundaries"} +{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "General backend and frontend smoke quality gate"} diff --git a/.trellis/tasks/07-01-dashboard-visual-refresh/design.md b/.trellis/tasks/07-01-dashboard-visual-refresh/design.md new file mode 100644 index 0000000..f4ecca3 --- /dev/null +++ b/.trellis/tasks/07-01-dashboard-visual-refresh/design.md @@ -0,0 +1,74 @@ +# Dashboard Visual Refresh Design + +## Visual System + +Both dashboards use a shared CPAMP-inspired dark operations aesthetic: + +- Dark page background and slightly lighter panels. +- 8px or smaller panel radius, compact spacing, no marketing hero layout. +- Blue primary buttons, green success chips, red failure chips, amber warning + chips, neutral muted chips. +- Stable card and table dimensions with explicit column widths to prevent + vertical text compression. +- Top toolbar instead of left navigation. + +The pages do not need pixel-perfect parity with CPAMP. They should feel like +they belong in the same operational family. + +## CPA Usage Portal + +The portal remains a static page served by `cpa_usage_portal`. + +Data flow is unchanged: + +```text +browser -> cpa-usage-portal -> Key Policy state + CPAMP analytics +``` + +The main event table changes from raw detail display to a scan-first view: + +- visible row fields: time, status, model, latency, tokens, reasoning, cost, + details action. +- hidden detail row: endpoint, request id, status code, service tier, + reasoning effort, quota hints, and redacted failure details. +- `failure_brief` is a short human-readable projection intended for detail + headers and table hints. + +Backend projection remains the safety boundary. The frontend formats safe +fields but must not receive secret material. + +## CodexCont Dashboard + +The CodexCont dashboard keeps the current admin APIs: + +- `GET status` +- `GET requests?limit=100` +- `GET logs?limit=200` +- `GET logs/stream` + +Only the static HTML/CSS/JS changes. The first screen becomes: + +- top toolbar: title, stream status, refresh action. +- metrics row: total requests, protected requests, auto continuations, + truncation hits, failures. +- recent requests table with protection chips and expandable protection detail. +- advanced logs as a lower-priority collapsible panel. + +## Safety And Compatibility + +- Existing API fields remain compatible. +- `failure` stays available for compatibility, but bounded and redacted. +- `failure_brief` is additive. +- No new public route is introduced. +- The admin/user split is unchanged: + `cpa-usage.konbakuyomu.us` for users, `cpa-admin.../codexcont/` for admin. + +## Rollback + +- If the usage portal page fails, redeploy the previous + `/opt/codex-stacks/cpa-usage-portal` backup and restart only that container. +- If the CodexCont dashboard fails, redeploy the previous + `/opt/codex-stacks/codexcont` backup and restart only `codexcont`. +- If public admin route checks fail, revert the touched Caddy route only after + confirming this task changed Caddy; expected implementation does not change + Caddy. diff --git a/.trellis/tasks/07-01-dashboard-visual-refresh/implement.jsonl b/.trellis/tasks/07-01-dashboard-visual-refresh/implement.jsonl new file mode 100644 index 0000000..a7d4f10 --- /dev/null +++ b/.trellis/tasks/07-01-dashboard-visual-refresh/implement.jsonl @@ -0,0 +1,3 @@ +{"file": ".trellis/spec/backend/codex-continuation-contracts.md", "reason": "Dashboard, CPA usage portal, CPAMP, and admin/public route contracts"} +{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "Safe projections travel from CPAMP/diagnostics APIs into browser tables and detail rows"} +{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "Failure-summary projection should be centralized in the backend redaction layer"} diff --git a/.trellis/tasks/07-01-dashboard-visual-refresh/implement.md b/.trellis/tasks/07-01-dashboard-visual-refresh/implement.md new file mode 100644 index 0000000..98a58a8 --- /dev/null +++ b/.trellis/tasks/07-01-dashboard-visual-refresh/implement.md @@ -0,0 +1,122 @@ +# Dashboard Visual Refresh Implementation Plan + +## Phase 1: Planning And Specs + +1. Record the approved product plan in `prd.md`, `design.md`, and this file. +2. Read backend specs and shared thinking guides before code edits. +3. Start the Trellis task. + +## Phase 2: Local Implementation + +1. Update `cpa_usage_portal.redaction`: + - add bounded `failure_brief`; + - keep `failure` redacted and bounded; + - strip noisy response-header blobs from table-facing output. +2. Rebuild `cpa_usage_portal/static/dashboard.html`: + - CPAMP-like dark top toolbar and metric cards; + - compact model table and recent request table; + - expandable event detail rows; + - no long summary column. +3. Rebuild `middleware/dashboard.html`: + - same dark visual language; + - recent request protection table remains first-class; + - advanced logs stay collapsible and lower priority. +4. Update tests for failure summary projection and existing admin smoke. + +## Phase 3: Local Validation + +1. Run `.venv\Scripts\python.exe tests\test_cpa_usage_portal.py`. +2. Run `.venv\Scripts\python.exe tests\test_middleware.py`. +3. Run `.venv\Scripts\python.exe -m compileall cpa_usage_portal run_usage_portal.py middleware run.py`. +4. Use Playwright screenshots for desktop and 390px mobile when a local server + can be started without disturbing production. + +## Phase 4: Server Rollout + +1. Confirm SJC disk and running containers. +2. Create root-only backups of `/opt/codex-stacks/cpa-usage-portal` and + `/opt/codex-stacks/codexcont`. +3. Upload changed custom files only. +4. Rebuild/restart only `cpa-usage-portal` and `codexcont`. +5. Verify: + - `https://cpa-usage.konbakuyomu.us/` loads; + - `https://cpa-admin.konbakuyomu.us/codexcont/` loads; + - recent real requests still appear; + - public `https://cpa.konbakuyomu.us` returns `404` for admin/dashboard + paths. + +## Phase 5: Closeout + +1. Record screenshots, server evidence, and any known follow-ups. +2. Commit implementation and Trellis task artifacts. + +## Implementation Evidence + +Local changes: + +- `cpa_usage_portal.redaction.safe_event` now adds bounded `failure_brief`, + caps `failure` at 600 characters, and strips noisy response-header blobs from + success events. +- `cpa_usage_portal/static/dashboard.html` was rebuilt as a dark, no-sidebar + operations page. The main request table now shows time, status, model, + latency, tokens, reasoning, cost, and an expand action only. +- `middleware/dashboard.html` was rebuilt with the same visual language. + Recent request protection remains the first-screen focus and advanced logs + are collapsed below the request table. +- CPA, CPAMP, and CPA Key Policy official source/artifacts were not modified. + +Local validation on 2026-07-01: + +- `.venv\Scripts\python.exe tests\test_cpa_usage_portal.py` -> 36/36 checks + passed. +- `.venv\Scripts\python.exe tests\test_middleware.py` -> 143/143 checks + passed. +- `.venv\Scripts\python.exe -m compileall cpa_usage_portal run_usage_portal.py middleware run.py` + -> passed. +- Playwright screenshot review covered desktop and 390px mobile mock data for + both pages. Screenshots are in `artifacts/usage-desktop.png`, + `artifacts/usage-mobile.png`, `artifacts/codexcont-desktop.png`, and + `artifacts/codexcont-mobile.png`. The screenshots show no short-field + vertical compression or incoherent overlap; mobile tables use horizontal + scroll inside the table area instead of compressing columns. + +Server rollout on SJC: + +- Preflight: `/` was 9.6G total, 8.8G used, 759M available, 93% used. +- Root-only backup path: + `/root/codex-backups/dashboard-visual-refresh-20260701-212946/`. + Backup tarballs were created with mode `600`. +- Uploaded only changed custom files: + `cpa_usage_portal/redaction.py`, + `cpa_usage_portal/static/dashboard.html`, and + `middleware/dashboard.html`. +- Rebuilt/restarted only `cpa-usage-portal` and `codexcont`. + `cpa`, `cpamp`, `caddy-edge`, `cpa-admin-proxy`, and + `cpa-admin-tunnel` kept their prior uptime. + +Server validation: + +- `cpa-usage-portal` health via internal route returned + `{"ok":true,"key_policy_state":true,"cpamp":true}`. +- CodexCont `/admin/healthz` via internal route returned `200`. +- Admin proxy internal route `http://127.0.0.1:8327/codexcont/` returned + `200`. +- Admin proxy SSE route `/codexcont/logs/stream?once=1` returned `200` with + `ready` and `request` events. +- Public `https://cpa-usage.konbakuyomu.us/` returned `200` and the new page + contains `CPA 用量自助页`, `最近请求`, and `failure_brief`. +- Public `https://cpa-admin.konbakuyomu.us/codexcont/` returned a Cloudflare + Access login redirect, preserving the admin boundary. +- Public `https://cpa.konbakuyomu.us/admin/`, + `https://cpa.konbakuyomu.us/codexcont/`, and + `https://cpa.konbakuyomu.us/admin/requests` returned `404`. +- CodexCont request summaries returned real recent traffic including + `protected_clean` and `auto_continued` entries, proving the dashboard data + path still reflects live Codex traffic. +- Post-rollout disk remained tight but stable: 9.6G total, 8.8G used, 759M + available, 93% used. No Docker prune or broad filesystem cleanup was used. + +Known follow-up: + +- The mobile screenshots intentionally show horizontally scrollable tables. + This is preferred over compressing short columns into vertical text. diff --git a/.trellis/tasks/07-01-dashboard-visual-refresh/prd.md b/.trellis/tasks/07-01-dashboard-visual-refresh/prd.md new file mode 100644 index 0000000..f37c47b --- /dev/null +++ b/.trellis/tasks/07-01-dashboard-visual-refresh/prd.md @@ -0,0 +1,58 @@ +# Dashboard Visual Refresh + +## Goal + +Make the two custom dashboards feel like a polished CPAMP-style operations +surface while keeping the ordinary-user and admin boundaries clear. + +The affected pages are: + +- `cpa-usage-portal`: ordinary users inspect their own Key Policy key usage. +- `CodexCont` admin dashboard: administrators inspect 516/518n-2 protection + status and live diagnostics. + +## Requirements + +- Use a shared dark, dense operations style inspired by CPAMP: dark shell, + compact top toolbar, status chips, metric cards, segmented controls, and + scan-friendly tables. +- Do not add a left admin sidebar. The user usage portal must not look like it + grants access to CPAMP or CPA administration. +- Keep CPA, CPAMP, and CPA Key Policy official artifacts untouched. Only custom + pages and custom safe projections may change. +- The CPA usage page must remove long failure summaries from the main table. + Main rows show only time, status, model, latency, tokens, reasoning, cost, + and an action. +- Long failure details must be available only in an expanded detail row and + must be short, redacted, and bounded. +- The CodexCont dashboard must keep the recent request protection result as the + primary first-screen signal. +- Tables must not compress short fields into vertical text on desktop or mobile. + Long fields may truncate, wrap in detail rows, or move behind expand actions. +- No React/Vue/npm build chain. Keep static HTML/CSS/JS. +- Do not expose additional public routes or weaken current public/admin + separation. + +## Acceptance Criteria + +- [x] CPA usage page uses a dark CPAMP-like layout with no side navigation. +- [x] CPA usage page main table has no long summary column and does not render + response headers or raw failure blobs in the first screen. +- [x] CPA usage events include `failure_brief`; full `failure` remains redacted + and is shown only in an expanded row. +- [x] CodexCont dashboard uses the same visual language and keeps protection + states visually distinct. +- [x] Desktop and 390px mobile screenshots show no incoherent overlap or short + fields rendered vertically. +- [x] `tests/test_cpa_usage_portal.py`, `tests/test_middleware.py`, and + compile checks pass. +- [x] Server rollout only restarts `cpa-usage-portal` and `codexcont`; CPA, + CPAMP, and CPA Key Policy remain untouched. +- [x] Public `https://cpa.konbakuyomu.us` still blocks admin/dashboard paths. + +## Out Of Scope + +- Replacing CPAMP or changing CPAMP source. +- Adding a frontend build system. +- Creating new auth flows, user management, or key migration behavior. +- Changing CodexCont `/v1/responses` folding logic. diff --git a/.trellis/tasks/07-01-dashboard-visual-refresh/task.json b/.trellis/tasks/07-01-dashboard-visual-refresh/task.json new file mode 100644 index 0000000..cbd5099 --- /dev/null +++ b/.trellis/tasks/07-01-dashboard-visual-refresh/task.json @@ -0,0 +1,26 @@ +{ + "id": "dashboard-visual-refresh", + "name": "dashboard-visual-refresh", + "title": "Dashboard visual refresh", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "dxt98", + "assignee": "dxt98", + "createdAt": "2026-07-01", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/cpa_usage_portal/redaction.py b/cpa_usage_portal/redaction.py index 169124e..924b40d 100644 --- a/cpa_usage_portal/redaction.py +++ b/cpa_usage_portal/redaction.py @@ -1,6 +1,7 @@ """Safe projections for user-facing usage data.""" from __future__ import annotations +import json import re from typing import Any @@ -27,6 +28,18 @@ r"(?i)\b(api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|cookie|secret)" r"\s*[:=]\s*['\"]?[^'\"\s,;]+" ) +_SPACE_RE = re.compile(r"\s+") +_HEADER_BLOB_MARKERS = ( + "cf-cache-status", + "set-cookie", + "strict-transport-security", + "cross-origin-opener-policy", + "x-codex-", + "x-openai-", + "report-to", +) +MAX_FAILURE_BRIEF = 120 +MAX_FAILURE_DETAIL = 600 def redact(value: Any, *, key: str = "") -> Any: @@ -45,6 +58,54 @@ def redact(value: Any, *, key: str = "") -> Any: return str(value) +def _as_text(value: Any) -> str: + if value is None or value == "": + return "" + redacted = redact(value) + if isinstance(redacted, str): + return redacted + try: + return json.dumps(redacted, ensure_ascii=False, sort_keys=True) + except TypeError: + return str(redacted) + + +def _compact(value: str) -> str: + return _SPACE_RE.sub(" ", value).strip() + + +def _truncate(value: str, limit: int) -> str: + text = _compact(value) + if len(text) <= limit: + return text + if limit <= 3: + return "." * max(0, limit) + return text[: max(0, limit - 3)].rstrip() + "..." + + +def _looks_like_response_headers(value: str) -> bool: + lower = value.lower() + return sum(1 for marker in _HEADER_BLOB_MARKERS if marker in lower) >= 2 + + +def _failure_projection(raw: Any, *, failed: bool, status_code: Any) -> tuple[str, str]: + text = _as_text(raw) + if not text: + if failed and status_code: + return f"HTTP {status_code}", f"HTTP {status_code}" + return "", "" + if _looks_like_response_headers(text): + if not failed: + return "", "" + text = "Upstream response headers omitted; inspect status code and quota fields." + detail = _truncate(text, MAX_FAILURE_DETAIL) + brief_source = detail.split("|", 1)[0].split("\n", 1)[0] + brief = _truncate(brief_source, MAX_FAILURE_BRIEF) + if not brief and failed and status_code: + brief = f"HTTP {status_code}" + return brief, detail + + def safe_event(event: dict[str, Any], *, expected_hash: str) -> dict[str, Any] | None: api_key_hash = str(event.get("api_key_hash") or "").strip() try: @@ -55,8 +116,13 @@ def safe_event(event: dict[str, Any], *, expected_hash: str) -> dict[str, Any] | if normalized != expected: return None - failed = bool(event.get("failed")) status_code = event.get("fail_status_code") + failed = bool(event.get("failed")) + failure_brief, failure_detail = _failure_projection( + event.get("fail_summary") or "", + failed=failed, + status_code=status_code, + ) return { "request_id": event.get("request_id") or "", "event_hash": event.get("event_hash") or "", @@ -80,7 +146,8 @@ def safe_event(event: dict[str, Any], *, expected_hash: str) -> dict[str, Any] | "service_tier": event.get("service_tier") or "", "reasoning_effort": event.get("reasoning_effort") or "", "api_key_preview": hash_preview(expected), - "failure": redact(event.get("fail_summary") or ""), + "failure_brief": failure_brief, + "failure": failure_detail, "quota": { "used_percent": event.get("header_quota_used_percent"), "recover_at_ms": event.get("header_quota_recover_at_ms"), diff --git a/cpa_usage_portal/static/dashboard.html b/cpa_usage_portal/static/dashboard.html index 8582eb6..ff6a652 100644 --- a/cpa_usage_portal/static/dashboard.html +++ b/cpa_usage_portal/static/dashboard.html @@ -6,248 +6,465 @@ CPA 用量自助页
-
-
-

CPA 用量自助页

-

用自己的 API Key 登录,只查看自己的限额、用量和最近请求。

+
+
+
C
+
+

CPA 用量自助页

+
使用 Key Policy 的 cpa_... Key 登录,只查看自己的用量。
+
- 实时未连接 - + 实时未连接 + + +
-
+ -
diff --git a/middleware/dashboard.html b/middleware/dashboard.html index 72cc48e..3d2d909 100644 --- a/middleware/dashboard.html +++ b/middleware/dashboard.html @@ -7,175 +7,336 @@ CodexCont 保护状态面板
-
-
-

CodexCont 保护状态面板

-
正在读取运行状态...
+
+
+
C
+
+

CodexCont 保护状态面板

+
正在读取运行状态
+
- 连接中 - + 连接中 + 活跃 0 +
-
-
-

服务状态

-
- CodexCont未知 - 运行时间- - 启动时间- -
+
+
+
#总请求
+
0
+
-
-
-

CPA 上游

-
- 健康未知 - 状态码- - 地址- -
+
+
P进入保护链
+
0
+
由 CodexCont 折叠处理
-
-

保护配置

-
- 模式- - 续写- - 日志保留- -
+
+
C自动续写
+
0
+
-
-
-

实时流

-
- 连接连接中 - 活跃请求0 - 请求记录0 -
+
+
516疑似截断
+
0
+
516 / 518n-2 指纹
+
+
+
!失败
+
0
+
-
-
-
总请求
0
-
-
进入保护链
0
由 CodexCont 折叠处理
-
自动续写
0
-
-
疑似截断
0
516 / 518n-2 指纹
-
失败
0
-
-
- -
+
-

最近请求

+
+

最近请求

+

保护结果、命中轮和末轮 reasoning 是主视图。

+
- + + 0 条
-
+
@@ -304,10 +470,10 @@

最近请求

-
+
高级日志 - 用于排查原始事件 + 原始脱敏事件
@@ -318,12 +484,12 @@

最近请求

- +
- - - + + +
@@ -334,7 +500,7 @@

最近请求

- + @@ -345,23 +511,16 @@

最近请求

diff --git a/middleware/dashboard.html b/middleware/dashboard.html index 3d2d909..f16d571 100644 --- a/middleware/dashboard.html +++ b/middleware/dashboard.html @@ -61,6 +61,7 @@ cursor: pointer; white-space: nowrap; } + #refresh-btn { min-width: 72px; } button.active, button:hover, select:hover, input:focus { border-color: var(--line-strong); outline: none; @@ -70,6 +71,18 @@ background: linear-gradient(180deg, #43a2ff, #2d86ef); font-weight: 760; } + button.is-loading::before { + content: ""; + width: 14px; + height: 14px; + border: 2px solid rgba(232, 237, 245, .28); + border-top-color: currentColor; + border-radius: 50%; + animation: spin .75s linear infinite; + } + button.refresh-flash { + animation: buttonPulse .8s ease; + } .shell { width: min(1680px, calc(100% - 32px)); margin: 0 auto; @@ -146,8 +159,21 @@ height: 8px; border-radius: 50%; background: var(--muted); + position: relative; flex: 0 0 auto; } + .chip.stream .dot { + animation: statusBlink 1.45s ease-in-out infinite; + } + .chip.stream .dot::after { + content: ""; + position: absolute; + inset: -5px; + border-radius: inherit; + border: 1px solid currentColor; + opacity: .55; + animation: statusPing 1.45s ease-out infinite; + } .chip.ok { border-color: rgba(97, 211, 79, .28); background: var(--green-soft); color: #a8ef9c; } .chip.ok .dot { background: var(--green); } .chip.live { border-color: rgba(21, 199, 212, .28); background: var(--teal-soft); color: #a1eef3; } @@ -367,6 +393,29 @@ background: transparent !important; border: 0 !important; } + @keyframes spin { + to { transform: rotate(360deg); } + } + @keyframes buttonPulse { + 0% { box-shadow: 0 0 0 0 rgba(59, 150, 255, .42); } + 100% { box-shadow: 0 0 0 10px rgba(59, 150, 255, 0); } + } + @keyframes statusBlink { + 0%, 100% { transform: scale(1); opacity: .72; } + 50% { transform: scale(1.35); opacity: 1; } + } + @keyframes statusPing { + 0% { transform: scale(.65); opacity: .58; } + 100% { transform: scale(1.9); opacity: 0; } + } + @media (prefers-reduced-motion: reduce) { + button.is-loading::before, + button.refresh-flash, + .chip.stream .dot, + .chip.stream .dot::after { + animation: none; + } + } @media (max-width: 1180px) { .metrics { grid-template-columns: repeat(3, minmax(160px, 1fr)); } } @@ -395,7 +444,7 @@

CodexCont 保护状态面板

- 连接中 + 连接中 活跃 0
@@ -511,7 +560,19 @@

最近请求

diff --git a/tests/test_cpa_usage_portal.py b/tests/test_cpa_usage_portal.py index ee42f2b..e945d74 100644 --- a/tests/test_cpa_usage_portal.py +++ b/tests/test_cpa_usage_portal.py @@ -46,6 +46,7 @@ async def health(self): return {"ok": True} async def analytics(self, *, api_key_hash, window, include_events=False, + include_model_stats=False, event_limit=100, before_ms=None, before_id=None): self.seen_hashes.append(api_key_hash) data = { @@ -54,12 +55,33 @@ async def analytics(self, *, api_key_hash, window, include_events=False, "success_calls": 1, "failure_calls": 1, "success_rate": 0.5, + "input_tokens": 100, + "output_tokens": 50, + "cached_tokens": 20, + "cache_read_tokens": 0, + "cache_creation_tokens": 0, "total_tokens": 300, "reasoning_tokens": 100, - "total_cost": 0.0123, + "total_cost": 0, }, "timeline": [], - "model_share": [{"model": "gpt-5.5", "calls": 2, "tokens": 300, "cost": 0.0123}], + "model_share": [{"model": "gpt-5.5", "calls": 2, "tokens": 300, "cost": 0}], + "model_stats": [ + { + "model": "gpt-5.5", + "calls": 2, + "success_calls": 1, + "failure_calls": 1, + "success_rate": 0.5, + "input_tokens": 100, + "output_tokens": 50, + "cached_tokens": 20, + "cache_read_tokens": 0, + "cache_creation_tokens": 0, + "total_tokens": 300, + "cost": 0, + } + ] if include_model_stats else [], "api_key_stats": [ {"api_key_hash": api_key_hash, "calls": 2, "total_tokens": 300}, {"api_key_hash": self.other_hash, "calls": 99, "total_tokens": 999}, @@ -75,10 +97,13 @@ async def analytics(self, *, api_key_hash, window, include_events=False, "api_key_hash": api_key_hash, "model": "gpt-5.5", "failed": False, + "input_tokens": 100, + "output_tokens": 50, + "cached_tokens": 20, "total_tokens": 200, "reasoning_tokens": 80, "latency_ms": 1234, - "cost": 0.01, + "cost": 0, }, { "event_hash": "evt-b", @@ -110,18 +135,33 @@ def write_state(path: Path, raw_key: str, disabled_key: str) -> tuple[str, str, "name": "Alice", "enabled": True, "rpm": 12, - "models": ["gpt-5.5"], + "models": [ + { + "alias": "gpt-5.5", + "provider": "codex", + "target_model": "gpt-5.5", + "input_price_per_million": 125, + "output_price_per_million": 750, + "cache_read_price_per_million": 12.5, + } + ], "daily_limit_usd": 5, "weekly_limit_usd": 30, "daily_usage_usd": 0.5, - "model_prices": {"gpt-5.5": {"input": 1}}, }, { "id": "disabled-key", "key_hash": key_policy_hash(disabled_hash), "name": "Disabled", "enabled": False, - "model_prices": {"gpt-5.5": {"input": 1}}, + "models": [ + { + "alias": "gpt-5.5", + "input_price_per_million": 125, + "output_price_per_million": 750, + "cache_read_price_per_million": 12.5, + } + ], }, ] }, @@ -156,13 +196,27 @@ def test_key_policy_and_budget(tmp: Path) -> None: check("key policy finds cpamp hash", state.get_by_cpamp_hash(cpamp_hash) is record) check("key policy safe dict hides full hash", record is not None and key_hash not in json.dumps(record.safe_dict())) + check("key policy keeps clean model aliases", + record is not None and record.models == ["gpt-5.5"], + str(record.models if record else None)) + check("key policy parses per-model prices", + record is not None + and record.model_prices["gpt-5.5"].input_per_million == 125 + and record.model_prices["gpt-5.5"].output_per_million == 750 + and record.model_prices["gpt-5.5"].cache_read_per_million == 12.5, + str(record.model_prices if record else None)) + check("key policy safe dict exposes limits", + record is not None + and record.safe_dict()["limits"]["daily_usd"] == 5 + and record.safe_dict()["limits"]["weekly_usd"] == 30, + str(record.safe_dict() if record else None)) suggestion = suggest_equal_budget(state.enabled_keys(), total_daily_usd=10, total_weekly_usd=70) check("budget enabled count", suggestion.enabled_key_count == 1) check("budget daily assigned", suggestion.per_key_daily_usd == 10) check("budget patch uses policy hash", suggestion.patches[0]["key_hash"].startswith("sha256:")) - bad_state = KeyPolicyState([record.__class__(**{**record.__dict__, "raw": {}})]) + bad_state = KeyPolicyState([record.__class__(**{**record.__dict__, "raw": {}, "model_prices": {}})]) try: suggest_equal_budget(bad_state.enabled_keys(), total_daily_usd=1) blocked = False @@ -266,6 +320,14 @@ def test_app_routes(tmp: Path) -> None: health = client.get("/healthz") check("portal healthz ok", health.status_code == 200 and health.json().get("ok") is True) + html = client.get("/") + check("portal dashboard html ok", html.status_code == 200 and "CPA 用量自助页" in html.text) + check("portal dashboard refresh reconnects stream", "startStream({ force: true })" in html.text) + check("portal dashboard revives after background", "visibilitychange" in html.text) + check("portal dashboard shows refresh animation", "is-loading" in html.text and "stream warn" in html.text) + check("portal dashboard shows key limits", "用量限额" in html.text and "日限" in html.text and "周限" in html.text) + check("portal dashboard follows delayed usage updates", + "mergeEvent(JSON.parse(ev.data))" in html.text and "scheduleFollowUpRefreshes" in html.text) bad = client.post("/api/session", json={"api_key": "nope"}) check("portal rejects unknown key", bad.status_code == 401) @@ -281,6 +343,14 @@ def test_app_routes(tmp: Path) -> None: usage = client.get("/api/usage?range=24h") usage_body = usage.json() check("portal usage ok", usage.status_code == 200 and usage_body["summary"]["total_calls"] == 2) + check("portal usage includes daily and weekly limits", + me.json()["me"]["limits"]["daily_usd"] == 5 and me.json()["me"]["limits"]["weekly_usd"] == 30, + me.text) + check("portal usage recomputes cost from key policy prices", + usage_body["summary"]["total_cost"] > 0 + and usage_body["model_share"][0]["cost"] > 0 + and usage_body["summary"]["cost_source"] == "key_policy", + str(usage_body)) check("portal usage stat hides cpamp hash", cpamp_hash not in json.dumps(usage_body), str(usage_body)) check("portal usage stats reject other hash", len(usage_body["api_key_stats"]) == 1 and usage_body["api_key_stats"][0]["calls"] == 2, @@ -288,6 +358,9 @@ def test_app_routes(tmp: Path) -> None: events = client.get("/api/events?limit=100") body = events.json() check("portal filters events to own key", len(body["events"]) == 1, str(body)) + check("portal events recompute cost from key policy prices", + body["events"][0]["cost"] > 0 and body["events"][0]["cost_source"] == "key_policy", + str(body)) check("portal never returns full raw api hash", key_hash not in json.dumps(body), str(body)) check("portal does not use disabled raw hash", other_raw_hash not in json.dumps(body), str(body)) check("portal cpamp filter used policy-id hash", diff --git a/tests/test_middleware.py b/tests/test_middleware.py index fb0c886..bd81b77 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -582,6 +582,11 @@ def test_admin_routes_smoke(): check("admin dashboard Chinese first screen", "最近请求" in html.text) check("admin dashboard has trigger round column", "命中轮" in html.text) check("admin dashboard has latest reasoning column", "末轮思考量" in html.text) + check("admin dashboard refresh reconnects stream", "connectStream({ force: true })" in html.text) + check("admin dashboard revives after background", "visibilitychange" in html.text) + check("admin dashboard shows refresh animation", "is-loading" in html.text and "stream warn" in html.text) + check("admin dashboard follows processing requests", + "scheduleRequestFollowUp" in html.text and "setInterval(loadRequests, 5000)" in html.text) stream = client.get("/admin/logs/stream?once=1") check("admin logs stream ready", "event: ready" in stream.text, stream.text[:80]) From 48c03f954ac14f89357b3c13264ab0199131ef40 Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Wed, 1 Jul 2026 23:22:10 +0800 Subject: [PATCH 20/68] fix: align usage range selector with events --- .../backend/codex-continuation-contracts.md | 8 ++- .../implement.md | 55 +++++++++++++++++++ cpa_usage_portal/app.py | 16 +++++- cpa_usage_portal/static/dashboard.html | 25 ++++++--- tests/test_cpa_usage_portal.py | 11 +++- 5 files changed, 102 insertions(+), 13 deletions(-) diff --git a/.trellis/spec/backend/codex-continuation-contracts.md b/.trellis/spec/backend/codex-continuation-contracts.md index f11d3f9..d7fcfeb 100644 --- a/.trellis/spec/backend/codex-continuation-contracts.md +++ b/.trellis/spec/backend/codex-continuation-contracts.md @@ -168,7 +168,7 @@ This spreads the event contract into JavaScript and makes the beginner-facing st - `DELETE /api/session` - `GET /api/me` - `GET /api/usage?range=24h|7d` - - `GET /api/events?limit=N&before=...` + - `GET /api/events?range=24h|7d&limit=N&before=...` - `GET /api/events/stream` - Production user route: `https://cpa-usage.konbakuyomu.us/` - Production admin route for CPAMP: `https://cpa-admin.konbakuyomu.us/` @@ -235,6 +235,10 @@ This spreads the event contract into JavaScript and makes the beginner-facing st - `/api/me` must expose safe daily/weekly USD limits and a safe pricing summary. The user dashboard must show both daily and weekly limits directly, not only as a selected-range hint. +- The usage portal's selected time range controls both `/api/usage` aggregates + and the visible `/api/events` recent-request table. The page must also render + the active range label, because 24h and 7d can legitimately return identical + numbers when all retained usage happened in the last day. - Public `cpa.konbakuyomu.us` must continue to block management, plugin, admin, CodexCont dashboard, CPAMP, and usage-portal internals. - The usage portal frontend must not rely on an old `/api/events/stream` @@ -269,6 +273,8 @@ This spreads the event contract into JavaScript and makes the beginner-facing st - Key Policy prices exist but CPAMP returns zero cost -> user portal shows nonzero estimated cost from Key Policy prices and marks the source as `key_policy`. +- `GET /api/events?range=24h` -> recent events are fetched from the 24h window; + omitting `range` keeps the compatibility default. - Key Policy daily/weekly USD limits exist -> `/api/me` and the dashboard show both values safely. - `GET /api/usage` must not include the full raw-key hash or full policy-id diff --git a/.trellis/tasks/07-01-dashboard-visual-refresh/implement.md b/.trellis/tasks/07-01-dashboard-visual-refresh/implement.md index 725a6bc..4d3f102 100644 --- a/.trellis/tasks/07-01-dashboard-visual-refresh/implement.md +++ b/.trellis/tasks/07-01-dashboard-visual-refresh/implement.md @@ -277,3 +277,58 @@ Validation: - Node parsed the inline scripts from both HTML files successfully: `cpa_usage_portal/static/dashboard.html: js parse ok` and `middleware/dashboard.html: js parse ok`. + +## Follow-up: Usage Range Visibility And Event Window + +Problem reported on 2026-07-01: + +- The user usage page's `24 小时` / `7 天` selector appeared to do nothing. +- The page did not clearly show which time window was active, and the recent + request table was still fetched from a fixed 7-day window. + +Evidence: + +- A server-side CPAMP check for `kuma专用` showed the same totals for both + windows because all retained traffic was inside the last 24 hours: + `24h calls=242`, `7d calls=242`, same tokens, cost, and model distribution. +- After the fix and redeploy, the same key still legitimately returned + identical totals (`260` calls in both windows), but the APIs now returned + explicit `usage_range` / `events_range` values for `24h` and `7d`. + +Implemented fix: + +- `GET /api/events` now accepts `range=24h|7d` and returns `range`, + `from_ms`, and `to_ms`; the compatibility default remains 7 days. +- The frontend now sends the selected range to both `/api/usage` and + `/api/events`, so top metrics, model distribution, and recent requests share + one selected window. +- The model and recent-request section subtitles now render the active range + and the resolved time window. This makes a same-number 24h/7d result visibly + understandable instead of looking like a dead dropdown. + +Validation: + +- `.venv\Scripts\python.exe tests\test_cpa_usage_portal.py` -> 50/50 checks + passed. +- `.venv\Scripts\python.exe tests\test_middleware.py` -> 147/147 checks + passed. +- `.venv\Scripts\python.exe -m compileall cpa_usage_portal run_usage_portal.py middleware run.py` + -> passed. +- Node parsed `cpa_usage_portal/static/dashboard.html` and + `middleware/dashboard.html` inline scripts successfully. + +Server rollout: + +- Preflight remained disk-tight but stable: `/` was 9.6G total, 8.8G used, + 751M available, 93% used. +- Root-only backup path: + `/root/codex-backups/usage-range-refresh-20260701-231819/`. +- Uploaded only `cpa_usage_portal/app.py` and + `cpa_usage_portal/static/dashboard.html`. +- Rebuilt/restarted only `cpa-usage-portal`; `cpa`, `cpamp`, `codexcont`, and + `caddy-edge` kept their prior uptime. +- Container-internal `/healthz` returned + `{"ok":true,"key_policy_state":true,"cpamp":true}`. +- Public `https://cpa-usage.konbakuyomu.us/` returned `200` and contains the + new selected-range UI code; public `https://cpa.konbakuyomu.us/cpa-usage/` + returned `404`. diff --git a/cpa_usage_portal/app.py b/cpa_usage_portal/app.py index eafe7fb..00c050e 100644 --- a/cpa_usage_portal/app.py +++ b/cpa_usage_portal/app.py @@ -73,6 +73,11 @@ def _parse_before(request: Request) -> tuple[int | None, int | None]: return parsed_ms, parsed_id +def _parse_range(request: Request, *, default: str) -> str: + value = request.query_params.get("range", default) + return value if value in {"24h", "7d"} else default + + async def dashboard(_request: Request) -> HTMLResponse: return HTMLResponse(_DASHBOARD.read_text(encoding="utf-8")) @@ -145,7 +150,8 @@ async def usage(request: Request) -> JSONResponse: record = _current_key(request) except AuthError as exc: return _json_error(str(exc), 401) - window = range_window(request.query_params.get("range", "24h")) + range_name = _parse_range(request, default="24h") + window = range_window(range_name) data = await request.app.state.cpamp.analytics( api_key_hash=record.cpamp_hash, window=window, @@ -154,7 +160,7 @@ async def usage(request: Request) -> JSONResponse: ) data = apply_key_policy_pricing(data, record.model_prices) return JSONResponse({ - "range": request.query_params.get("range", "24h") if request.query_params.get("range") in {"24h", "7d"} else "24h", + "range": range_name, "from_ms": window.from_ms, "to_ms": window.to_ms, "summary": data.get("summary") or {}, @@ -176,7 +182,8 @@ async def events(request: Request) -> JSONResponse: except ValueError: limit = 100 before_ms, before_id = _parse_before(request) - window = range_window("7d") + range_name = _parse_range(request, default="7d") + window = range_window(range_name) data = await request.app.state.cpamp.analytics( api_key_hash=record.cpamp_hash, window=window, @@ -191,6 +198,9 @@ async def events(request: Request) -> JSONResponse: record.model_prices, ) return JSONResponse({ + "range": range_name, + "from_ms": window.from_ms, + "to_ms": window.to_ms, "events": items, "next_before_ms": page.get("next_before_ms") or 0, "next_before_id": page.get("next_before_id") or 0, diff --git a/cpa_usage_portal/static/dashboard.html b/cpa_usage_portal/static/dashboard.html index 666d8f8..654a6f5 100644 --- a/cpa_usage_portal/static/dashboard.html +++ b/cpa_usage_portal/static/dashboard.html @@ -512,7 +512,7 @@

登录用量面板

模型分布

-

按当前 Key 和所选时间范围统计。

+

按当前 Key 和所选时间范围统计。

@@ -527,7 +527,7 @@

模型分布

最近请求

-

主表只显示摘要;展开后查看脱敏详情。

+

当前范围内最近 100 条;展开后查看脱敏详情。

0 条
@@ -563,6 +563,7 @@

最近请求

refreshSeq: 0, refreshTimer: null, lastRefreshAt: 0, + currentRange: "24h", }; function escapeHtml(value) { @@ -597,6 +598,9 @@

最近请求

if (n < 1000) return `${Math.round(n)} ms`; return `${(n / 1000).toFixed(2)} s`; } + function rangeLabel(range) { + return range === "7d" ? "最近 7 天" : "最近 24 小时"; + } function setLive(kind, text) { const chip = $("liveChip"); chip.className = `chip stream ${kind}`; @@ -661,8 +665,12 @@

最近请求

function renderUsage(data) { const s = data.summary || {}; const me = state.me || {}; - const range = $("rangeSelect").value; + const range = data.range || $("rangeSelect").value; + state.currentRange = range; const limit = range === "7d" ? me.limits?.weekly_usd : me.limits?.daily_usd; + const windowText = data.from_ms && data.to_ms ? ` · ${time(data.from_ms)} 至 ${time(data.to_ms)}` : ""; + $("modelRangeHint").textContent = `按当前 Key 统计:${rangeLabel(range)}${windowText}`; + $("eventsRangeHint").textContent = `当前显示 ${rangeLabel(range)} 内最近 100 条请求;展开后查看脱敏详情。`; $("calls").textContent = num(s.total_calls); $("successRate").textContent = pct(s.success_rate); $("failureCount").textContent = `失败 ${num(s.failure_calls)}`; @@ -709,9 +717,10 @@

最近请求

`; } - function renderEvents(events) { + function renderEvents(events, meta = {}) { state.events = events || []; - $("eventCountChip").innerHTML = `${num(state.events.length)} 条`; + const range = meta.range || state.currentRange || $("rangeSelect").value; + $("eventCountChip").innerHTML = `${num(state.events.length)} 条 · ${rangeLabel(range)}`; if (!state.events.length) { $("eventRows").innerHTML = '
'; return; @@ -749,19 +758,19 @@

最近请求

async function refreshDashboard(options = {}) { if (state.refreshing && !options.force) return; const seq = ++state.refreshSeq; + const range = $("rangeSelect").value; state.refreshing = true; if (options.feedback) setRefreshBusy(true); try { const meData = await api("/api/me"); if (seq !== state.refreshSeq) return; renderMe(meData.me); - const range = $("rangeSelect").value; const usage = await api(`/api/usage?range=${encodeURIComponent(range)}`); if (seq !== state.refreshSeq) return; renderUsage(usage); - const events = await api("/api/events?limit=100"); + const events = await api(`/api/events?range=${encodeURIComponent(range)}&limit=100`); if (seq !== state.refreshSeq) return; - renderEvents(events.events || []); + renderEvents(events.events || [], { range: events.range || range }); showLoggedIn(true); state.lastRefreshAt = Date.now(); } finally { diff --git a/tests/test_cpa_usage_portal.py b/tests/test_cpa_usage_portal.py index e945d74..7fda39c 100644 --- a/tests/test_cpa_usage_portal.py +++ b/tests/test_cpa_usage_portal.py @@ -41,6 +41,7 @@ def __init__(self, expected_hash: str, other_hash: str) -> None: self.expected_hash = expected_hash self.other_hash = other_hash self.seen_hashes: list[str] = [] + self.seen_windows: list[tuple[bool, int]] = [] async def health(self): return {"ok": True} @@ -49,6 +50,7 @@ async def analytics(self, *, api_key_hash, window, include_events=False, include_model_stats=False, event_limit=100, before_ms=None, before_id=None): self.seen_hashes.append(api_key_hash) + self.seen_windows.append((include_events, window.to_ms - window.from_ms)) data = { "summary": { "total_calls": 2, @@ -328,6 +330,9 @@ def test_app_routes(tmp: Path) -> None: check("portal dashboard shows key limits", "用量限额" in html.text and "日限" in html.text and "周限" in html.text) check("portal dashboard follows delayed usage updates", "mergeEvent(JSON.parse(ev.data))" in html.text and "scheduleFollowUpRefreshes" in html.text) + check("portal dashboard applies selected range to events", + "api(`/api/events?range=${encodeURIComponent(range)}&limit=100`)" in html.text + and "当前显示" in html.text) bad = client.post("/api/session", json={"api_key": "nope"}) check("portal rejects unknown key", bad.status_code == 401) @@ -355,8 +360,12 @@ def test_app_routes(tmp: Path) -> None: check("portal usage stats reject other hash", len(usage_body["api_key_stats"]) == 1 and usage_body["api_key_stats"][0]["calls"] == 2, str(usage_body)) - events = client.get("/api/events?limit=100") + events = client.get("/api/events?range=24h&limit=100") body = events.json() + check("portal events return selected range", + body.get("range") == "24h" + and any(include_events and delta <= 25 * 60 * 60 * 1000 for include_events, delta in fake.seen_windows), + str(body)) check("portal filters events to own key", len(body["events"]) == 1, str(body)) check("portal events recompute cost from key policy prices", body["events"][0]["cost"] > 0 and body["events"][0]["cost_source"] == "key_policy", From 762b9e6eca1703938784f9f0441259c79ea238d2 Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Thu, 2 Jul 2026 00:59:29 +0800 Subject: [PATCH 21/68] feat: add CPA usage quota admin --- .../backend/codex-continuation-contracts.md | 39 +- .../07-01-dashboard-visual-refresh/design.md | 60 +++ .../implement.md | 121 +++++ .../07-01-dashboard-visual-refresh/prd.md | 46 ++ cpa_usage_portal/app.py | 350 +++++++++++++- cpa_usage_portal/config.py | 6 + cpa_usage_portal/cpamp.py | 19 +- cpa_usage_portal/quota_state.py | 241 ++++++++++ cpa_usage_portal/static/admin.html | 427 ++++++++++++++++++ cpa_usage_portal/static/dashboard.html | 27 +- .../docker-compose.example.yaml | 2 + tests/test_cpa_usage_portal.py | 89 +++- 12 files changed, 1393 insertions(+), 34 deletions(-) create mode 100644 cpa_usage_portal/quota_state.py create mode 100644 cpa_usage_portal/static/admin.html diff --git a/.trellis/spec/backend/codex-continuation-contracts.md b/.trellis/spec/backend/codex-continuation-contracts.md index d7fcfeb..5b2b5cb 100644 --- a/.trellis/spec/backend/codex-continuation-contracts.md +++ b/.trellis/spec/backend/codex-continuation-contracts.md @@ -167,10 +167,17 @@ This spreads the event contract into JavaScript and makes the beginner-facing st - `POST /api/session` - `DELETE /api/session` - `GET /api/me` - - `GET /api/usage?range=24h|7d` - - `GET /api/events?range=24h|7d&limit=N&before=...` + - `GET /api/usage?range=5h|24h|7d|month` + - `GET /api/events?range=5h|24h|7d|month&limit=N&before=...` - `GET /api/events/stream` + - `GET /admin/` + - `GET /admin/api/keys` + - `PUT /admin/api/keys/{id}/limits` + - `POST /admin/api/keys/{id}/reset` + - `GET /admin/api/events?key_id=...&range=5h|24h|7d|month` - Production user route: `https://cpa-usage.konbakuyomu.us/` +- Production local quota admin route: + `https://cpa-admin.konbakuyomu.us/usage-admin/` - Production admin route for CPAMP: `https://cpa-admin.konbakuyomu.us/` - Key Policy state path on SJC: `/opt/codex-stacks/cpa/plugin-state/cpa-key-policy-state.json` @@ -187,9 +194,10 @@ This spreads the event contract into JavaScript and makes the beginner-facing st - Keep CPA, CPAMP, CodexCont, and `cpa-usage-portal` as separate containers / stacks on the shared `cpa_net`. Do not bundle them into one image because independent updates are part of the maintenance contract. -- The user portal is a read-only sidecar. It may read Key Policy state and +- The user portal may mutate only its own local SQLite metadata: 5H/month + limits, reset watermarks, and audit entries. It may read Key Policy state and query CPAMP monitoring, but it must not mutate CPA, OAuth accounts, proxy - routing, or Key Policy records in v1. + routing, CPAMP source events, or Key Policy records. - Ordinary users should receive Key Policy `cpa_...` keys. CPA native `sk...` keys are compatibility/admin escape hatches and should not be treated as self-service user credentials. @@ -233,12 +241,22 @@ This spreads the event contract into JavaScript and makes the beginner-facing st book. Do not interpret CPAMP zero cost as "free" when Key Policy prices are configured. - `/api/me` must expose safe daily/weekly USD limits and a safe pricing - summary. The user dashboard must show both daily and weekly limits directly, - not only as a selected-range hint. + summary. It must also expose local 5H/month USD limits and reset points when + the portal SQLite has them. The user dashboard must show 5H, daily, weekly, + and monthly limits directly, not only as a selected-range hint. - The usage portal's selected time range controls both `/api/usage` aggregates and the visible `/api/events` recent-request table. The page must also render the active range label, because 24h and 7d can legitimately return identical numbers when all retained usage happened in the last day. +- Supported portal ranges are `5h`, `24h`, `7d`, and `month`. `month` is the + current Asia/Shanghai calendar month. `5h`, `24h`, and `7d` are rolling + windows. +- A portal soft reset writes a reset watermark and narrows future CPAMP query + windows to `max(base_window_start, reset_at_ms)`. It must not delete CPAMP + rows or rewrite Key Policy's own historical usage display. +- All `/admin/*` portal routes require a proxy-injected admin header from + `cpa-admin.konbakuyomu.us/usage-admin/`. Public `cpa-usage.konbakuyomu.us` + must not be able to call these routes successfully. - Public `cpa.konbakuyomu.us` must continue to block management, plugin, admin, CodexCont dashboard, CPAMP, and usage-portal internals. - The usage portal frontend must not rely on an old `/api/events/stream` @@ -277,6 +295,11 @@ This spreads the event contract into JavaScript and makes the beginner-facing st omitting `range` keeps the compatibility default. - Key Policy daily/weekly USD limits exist -> `/api/me` and the dashboard show both values safely. +- Portal local 5H/month limits exist -> `/api/me` and the dashboard show both + values safely. +- `POST /admin/api/keys/{id}/reset` -> updates only portal reset watermarks; + CPAMP original rows remain visible in CPAMP itself. +- `GET /admin/api/keys` without the proxy-injected admin header -> `404`. - `GET /api/usage` must not include the full raw-key hash or full policy-id hash anywhere in the JSON response. - DNS for `cpa-usage.konbakuyomu.us` may be absent while the sidecar and Caddy @@ -309,6 +332,10 @@ This spreads the event contract into JavaScript and makes the beginner-facing st safe daily/weekly limits, and per-model prices. - Unit: `/api/usage` and `/api/events` recompute nonzero costs from Key Policy prices when CPAMP cost fields are zero. +- Unit: portal local SQLite stores 5H/month limits, applies reset watermarks, + and closes connections cleanly on Windows. +- Unit: `/admin/*` routes reject requests without the proxy-injected admin + header and expose safe quota projections when the header is present. - Unit: redaction covers Authorization, cookies, API keys, tokens, management keys, and encrypted reasoning fields while preserving numeric token counters. - Unit: retention deletes old CPAMP `usage_events` in batches and does not run diff --git a/.trellis/tasks/07-01-dashboard-visual-refresh/design.md b/.trellis/tasks/07-01-dashboard-visual-refresh/design.md index f4ecca3..20751fb 100644 --- a/.trellis/tasks/07-01-dashboard-visual-refresh/design.md +++ b/.trellis/tasks/07-01-dashboard-visual-refresh/design.md @@ -72,3 +72,63 @@ Only the static HTML/CSS/JS changes. The first screen becomes: - If public admin route checks fail, revert the touched Caddy route only after confirming this task changed Caddy; expected implementation does not change Caddy. + +## Follow-up Design: Local Quota Admin + +### Data Flow + +```text +browser -> cpa-usage-portal -> Key Policy state + -> CPAMP analytics + -> local portal SQLite +``` + +Key Policy remains the identity and price source for user keys. CPAMP remains +the immutable request-event source. The portal overlays local 5H/month limits +and reset watermarks, then queries CPAMP from the effective window start. + +### Local SQLite + +The portal owns a small SQLite database at `/data/portal/usage_portal.sqlite` +in production. It stores only: + +- `key_limits`: `policy_id`, `five_hour_limit_usd`, `monthly_limit_usd`. +- `reset_watermarks`: `policy_id`, `window`, `reset_at_ms`. +- `audit_log`: operator actions and before/after JSON for local metadata. + +It does not store raw API keys, request bodies, response bodies, OAuth tokens, +management keys, or encrypted reasoning content. + +### Windows And Reset + +Supported ranges are `5h`, `24h`, `7d`, and `month`. + +- `5h`: rolling five hours. +- `24h`: rolling twenty-four hours, matched to Key Policy daily limit display. +- `7d`: rolling seven days, matched to Key Policy weekly limit display. +- `month`: current Asia/Shanghai calendar month. + +A soft reset writes a watermark. For a selected range, the effective CPAMP +window starts at `max(base_window_start, reset_at_ms)`. CPAMP original event +rows are not deleted or rewritten. + +### Admin Boundary + +The admin UI is served by the same portal container but only under the admin +proxy. The app requires a proxy-injected header (`X-Usage-Admin: 1`) for all +`/admin/*` routes. The public user route does not set this header, so admin +routes return `404` there even if DNS points at the same container. + +The planned production mount is: + +```text +cpa-admin.konbakuyomu.us/usage-admin/* -> admin proxy injects header + -> cpa-usage-portal /admin/* +``` + +### Price Correction + +CPAMP Model Prices and Key Policy per-key model entries both use USD per 1M +tokens. The portal still recomputes self-service costs from Key Policy prices +because CPAMP can legitimately return zero for custom aliases until its global +price book is configured. diff --git a/.trellis/tasks/07-01-dashboard-visual-refresh/implement.md b/.trellis/tasks/07-01-dashboard-visual-refresh/implement.md index 4d3f102..599fdbc 100644 --- a/.trellis/tasks/07-01-dashboard-visual-refresh/implement.md +++ b/.trellis/tasks/07-01-dashboard-visual-refresh/implement.md @@ -332,3 +332,124 @@ Server rollout: - Public `https://cpa-usage.konbakuyomu.us/` returned `200` and contains the new selected-range UI code; public `https://cpa.konbakuyomu.us/cpa-usage/` returned `404`. + +## Follow-up: Price Correction And Local Quota Admin + +Planned implementation: + +1. Add `cpa_usage_portal.quota_state` as the single owner of local SQLite + metadata: 5H/month limits, reset watermarks, and audit log. +2. Extend usage ranges to `5h`, `24h`, `7d`, and `month`. +3. Apply reset watermarks by narrowing CPAMP analytics windows, without + deleting or mutating CPAMP source events. +4. Expose safe local limits/reset points through user `/api/me`, `/api/usage`, + and `/api/events`. +5. Add `/admin/*` routes guarded by a proxy-injected header, plus a static + CPAMP-style admin page for per-key limits and soft reset. +6. Add a writable `/data/portal` mount to the portal deployment example. +7. Correct production Key Policy and CPAMP price data after root-only backups. +8. Route `cpa-admin.konbakuyomu.us/usage-admin/` through the existing admin + proxy, while keeping public usage/API domains from exposing admin routes. + +Local implementation evidence: + +- Added `cpa_usage_portal/quota_state.py` with SQLite tables for local limits, + reset watermarks, and audit log. SQLite connections are explicitly closed so + Windows temp-directory tests can clean up database files. +- `cpa_usage_portal/cpamp.py` now supports `5h`, `24h`, `7d`, and calendar + `month` windows. +- `cpa_usage_portal/app.py` now applies effective reset windows to CPAMP + analytics, returns safe quota projections, and exposes guarded admin APIs. +- `cpa_usage_portal/static/dashboard.html` now lets users switch 5H/day/week/ + month ranges and shows 5H/day/week/month limits plus selected-window + remaining quota. +- Added `cpa_usage_portal/static/admin.html` as a no-build, dark operations + admin page for local quota editing and soft reset. +- Updated `deploy/cpa-usage-portal/docker-compose.example.yaml` with the + writable `/data/portal` mount. +- CPA, CPAMP, and CPA Key Policy source/images remain untouched by the local + implementation. + +Local validation on 2026-07-02: + +- `.venv\Scripts\python.exe tests\test_cpa_usage_portal.py` -> 65/65 checks + passed. +- `.venv\Scripts\python.exe tests\test_middleware.py` -> 147/147 checks + passed. +- `.venv\Scripts\python.exe -m compileall cpa_usage_portal run_usage_portal.py middleware run.py` + -> passed. +- Node parsed inline scripts from + `cpa_usage_portal/static/dashboard.html`, + `cpa_usage_portal/static/admin.html`, and `middleware/dashboard.html` + successfully. + +Server rollout on SJC: + +- Preflight: `/` was 9.6G total, 8.8G used, about 738M available, 93% used. +- Root-only backup path: + `/root/codex-backups/usage-quota-admin-20260702-002407/`. + Backup covered the usage portal stack, Key Policy state, CPAMP SQLite, + admin-proxy Caddyfile, and edge Caddyfile. Backup files were chmod `600`. +- Uploaded only changed custom portal files into + `/opt/codex-stacks/cpa-usage-portal/app`. +- Added the usage portal writable data mount: + `/opt/codex-stacks/cpa-usage-portal/data:/data/portal`. +- Added admin proxy route: + `cpa-admin.konbakuyomu.us/usage-admin/* -> cpa-usage-portal /admin/*` + with `X-Usage-Admin: 1`. +- Added public blocks for `/usage-admin*` on both `cpa.konbakuyomu.us` and + `cpa-usage.konbakuyomu.us`. +- Corrected Key Policy model prices while `cpa` was stopped, then restarted + CPA so it did not overwrite the state file with the old in-memory prices. +- Corrected CPAMP global `model_prices` rows and restarted CPAMP once so its + analytics reloaded the price book. +- Rebuilt/restarted only `cpa-usage-portal`; restarted `cpa-admin-proxy` and + `caddy-edge` for Caddy route changes. CPA and CPAMP were restarted only for + price-state reload. + +Server validation: + +- Key Policy state now has all four enabled keys priced with: + `gpt-5.5 5/30/0.5`, `gpt-5.4 2.5/15/0.25`, + `gpt-5.4-mini 0.75/4.5/0.075`, + `gpt-5.3-codex-spark 1.75/14/0.175`, and + `codex-auto-review 5/30/0.5`. +- CPAMP `model_prices` table has the same five text aliases. After CPAMP + restart, CPAMP monitoring returned nonzero 24h cost; the probe showed + `summary.total_cost = 4.144221850000001` and model-share costs for + `gpt-5.5` and `gpt-5.4`. +- `cpa-usage-portal` health inside its container returned + `{"ok":true,"key_policy_state":true,"cpamp":true}`. +- Admin proxy `http://127.0.0.1:8327/usage-admin/` returned `200` and contains + `CPA 用量管理`. +- Admin API `http://127.0.0.1:8327/usage-admin/api/keys` returned `200`, + listed 4 keys, and exposed `5h`, `24h`, `7d`, and `month` windows with safe + previews only. +- User API with a short-lived internal test session returned safe `/api/me` + limits/reset fields and nonzero Key Policy-derived usage for both `5h` and + `month` ranges. `/api/events?range=5h` returned safe accounting metadata + listing included windows. +- Soft reset was tested on one 5H window: portal 5H usage went from + `0.9155539999999999` to `0.0` after writing the watermark. The test + watermark was then removed, and the 5H usage returned to + `0.9155539999999999`, proving CPAMP rows were not deleted. +- Public `https://cpa.konbakuyomu.us/healthz` returned `200`. +- Public `https://cpa-usage.konbakuyomu.us/` returned `200`. +- `https://cpa-admin.konbakuyomu.us/usage-admin/` returned `302`, preserving + Cloudflare Access. +- Public `https://cpa.konbakuyomu.us/admin/`, + `https://cpa.konbakuyomu.us/usage-admin/`, and + `https://cpa-usage.konbakuyomu.us/admin/` returned `404`. +- Post-rollout disk remained tight: `/` was 9.6G total, 8.9G used, about 670M + available, 94% used. No Docker prune or broad filesystem cleanup was used. + +Known notes: + +- `docker compose up -d cpa` pulled the current `eceasy/cli-proxy-api:latest` + because of the existing compose/image policy. This consumed about 69M of + root disk. No prune or bulk cleanup was performed. +- CPAMP global Model Prices affect CPAMP analytics after CPAMP reload. The + self-service portal still recomputes user-facing costs from Key Policy + prices as a safety overlay. +- The first version only displays and soft-resets local quota windows. It does + not hard-block production CPA requests. diff --git a/.trellis/tasks/07-01-dashboard-visual-refresh/prd.md b/.trellis/tasks/07-01-dashboard-visual-refresh/prd.md index f37c47b..3d57b92 100644 --- a/.trellis/tasks/07-01-dashboard-visual-refresh/prd.md +++ b/.trellis/tasks/07-01-dashboard-visual-refresh/prd.md @@ -56,3 +56,49 @@ The affected pages are: - Adding a frontend build system. - Creating new auth flows, user management, or key migration behavior. - Changing CodexCont `/v1/responses` folding logic. + +## Follow-up: Price Correction, Local Quotas, And Soft Reset + +### Goal + +Fix inflated/zero cost display and add operator-controlled local quota views +without forking CPA, CPAMP, or CPA Key Policy. + +### Requirements + +- Keep CPA, CPAMP, and CPA Key Policy official source/images untouched. +- Correct server configuration data so Key Policy per-key model prices and + CPAMP global Model Prices use USD per 1M tokens. +- Use the confirmed text-model prices: + `gpt-5.5 = 5 / 30 / 0.5`, `gpt-5.4 = 2.5 / 15 / 0.25`, + `gpt-5.4-mini = 0.75 / 4.5 / 0.075`, + `gpt-5.3-codex-spark = 1.75 / 14 / 0.175`, and + `codex-auto-review = 5 / 30 / 0.5`. +- Do not guess image model prices. +- Add a self-owned portal admin entry at + `cpa-admin.konbakuyomu.us/usage-admin/`, still protected by Cloudflare + Access and a proxy-injected admin header. +- Store only local portal metadata in SQLite: 5H/month limits, reset + watermarks, and admin audit entries. +- Add soft reset only: reset portal statistics from a watermark while keeping + CPAMP original events intact. +- User self-service pages must show 5H/day/week/month limits and remaining + estimated quota. +- First version does not hard-block production requests when a local limit is + exceeded. + +### Acceptance Criteria + +- [ ] CPAMP global model price table and Key Policy model entries are corrected + for the text aliases above. +- [ ] User `/api/usage` and `/api/events` support + `range=5h|24h|7d|month`. +- [ ] User `/api/me` exposes safe 5H/month local limits and reset points in + addition to Key Policy daily/weekly limits. +- [ ] Admin page lists every Key with 5H/day/week/month used/limit/remaining + estimates. +- [ ] Admin page can set 5H/month limits and soft-reset one or all windows. +- [ ] Public `cpa-usage.konbakuyomu.us` cannot access admin APIs without the + admin proxy header. +- [ ] No raw API keys, full hashes, OAuth tokens, management keys, request + bodies, response bodies, or encrypted reasoning content are returned. diff --git a/cpa_usage_portal/app.py b/cpa_usage_portal/app.py index 00c050e..3fc14d8 100644 --- a/cpa_usage_portal/app.py +++ b/cpa_usage_portal/app.py @@ -14,14 +14,16 @@ from starlette.routing import Route from .config import PortalConfig -from .cpamp import CPAMPClient, range_window -from .key_policy import KeyPolicyState +from .cpamp import CPAMPClient, SUPPORTED_RANGES +from .key_policy import KeyPolicyState, KeyRecord from .pricing import apply_event_pricing, apply_key_policy_pricing +from .quota_state import QuotaState, RESET_WINDOWS from .redaction import safe_api_key_stats, safe_events from .security import sha256_hex, sign_session, verify_session _STATIC = Path(__file__).with_name("static") _DASHBOARD = _STATIC / "dashboard.html" +_ADMIN_DASHBOARD = _STATIC / "admin.html" class AuthError(Exception): @@ -55,6 +57,31 @@ def _current_key(request: Request): return record +def _quota(request: Request) -> QuotaState: + return request.app.state.quota + + +def _record_id(record: KeyRecord) -> str: + return record.policy_id or record.raw_key_hash + + +def _safe_record(record: KeyRecord, quota: QuotaState) -> dict[str, Any]: + safe = record.safe_dict() + local_limits = quota.get_limits(_record_id(record)) + limits = dict(safe.get("limits") or {}) + limits.update(local_limits.safe_dict()) + safe["limits"] = limits + safe["reset_points"] = quota.get_reset_points(_record_id(record)) + return safe + + +def _find_record(state: KeyPolicyState, key_id: str) -> KeyRecord | None: + for record in state.keys: + if _record_id(record) == key_id: + return record + return None + + def _parse_before(request: Request) -> tuple[int | None, int | None]: before_ms = request.query_params.get("before_ms") before_id = request.query_params.get("before_id") @@ -75,7 +102,125 @@ def _parse_before(request: Request) -> tuple[int | None, int | None]: def _parse_range(request: Request, *, default: str) -> str: value = request.query_params.get("range", default) - return value if value in {"24h", "7d"} else default + return value if value in SUPPORTED_RANGES else default + + +def _parse_float_limit(value: Any) -> float | None: + if value is None or value == "": + return None + try: + parsed = float(value) + except (TypeError, ValueError): + raise ValueError("invalid_limit") + if parsed < 0: + raise ValueError("invalid_limit") + return parsed + + +def _actor(request: Request) -> str: + return ( + request.headers.get("cf-access-authenticated-user-email") + or request.headers.get("x-usage-admin-actor") + or "admin" + ) + + +def _admin_allowed(request: Request) -> bool: + cfg: PortalConfig = request.app.state.cfg + return request.headers.get(cfg.admin_header_name) == cfg.admin_header_value + + +def _admin_guard(request: Request) -> JSONResponse | None: + if _admin_allowed(request): + return None + return _json_error("not_found", 404) + + +def _limit_for(record: KeyRecord, quota: QuotaState, range_name: str) -> float | None: + local = quota.get_limits(_record_id(record)) + if range_name == "5h": + return local.five_hour_usd + if range_name == "24h": + return record.daily_limit_usd + if range_name == "7d": + return record.weekly_limit_usd + if range_name == "month": + return local.monthly_usd + return None + + +def _quota_projection( + record: KeyRecord, + quota: QuotaState, + *, + range_name: str, + window_from_ms: int, + window_to_ms: int, + reset_at_ms: int | None, + used_usd: Any, +) -> dict[str, Any]: + limit = _limit_for(record, quota, range_name) + used = _float(used_usd) + remaining = None + percent = None + if limit is not None and limit > 0: + remaining = max(limit - used, 0.0) + percent = used / limit + return { + "range": range_name, + "from_ms": window_from_ms, + "to_ms": window_to_ms, + "reset_at_ms": reset_at_ms, + "limit_usd": limit, + "used_usd": used, + "remaining_usd": remaining, + "used_percent": percent, + } + + +def _float(value: Any) -> float: + try: + return float(value or 0) + except (TypeError, ValueError): + return 0.0 + + +def _attach_event_accounting( + events: list[dict[str, Any]], + *, + record: KeyRecord, + quota: QuotaState, + selected_range: str, + selected_quota: dict[str, Any], + now_ms_value: int, +) -> list[dict[str, Any]]: + effective_windows: dict[str, tuple[int, int, int | None]] = {} + key_id = _record_id(record) + for name in RESET_WINDOWS: + window, reset_at = quota.effective_window(key_id, name, now_ms_value=now_ms_value) + effective_windows[name] = (window.from_ms, window.to_ms, reset_at) + + projected: list[dict[str, Any]] = [] + for event in events: + row = dict(event) + ts = int(row.get("timestamp_ms") or 0) + included = [ + name + for name, (from_ms, to_ms, _reset_at) in effective_windows.items() + if ts and from_ms <= ts <= to_ms + ] + window_from, window_to, reset_at = effective_windows.get(selected_range, (0, 0, None)) + row["accounting"] = { + "selected_range": selected_range, + "included_windows": included, + "window_from_ms": window_from, + "window_to_ms": window_to, + "reset_at_ms": reset_at, + "current_window_remaining_usd": selected_quota.get("remaining_usd"), + "current_window_limit_usd": selected_quota.get("limit_usd"), + } + projected.append(row) + return projected async def dashboard(_request: Request) -> HTMLResponse: @@ -117,7 +262,7 @@ async def create_session(request: Request) -> JSONResponse: cfg.session_secret, ttl_seconds=cfg.session_ttl_seconds, ) - response = JSONResponse({"me": record.safe_dict()}) + response = JSONResponse({"me": _safe_record(record, _quota(request))}) response.set_cookie( cfg.session_cookie_name, token, @@ -142,7 +287,7 @@ async def me(request: Request) -> JSONResponse: record = _current_key(request) except AuthError as exc: return _json_error(str(exc), 401) - return JSONResponse({"me": record.safe_dict()}) + return JSONResponse({"me": _safe_record(record, _quota(request))}) async def usage(request: Request) -> JSONResponse: @@ -151,7 +296,8 @@ async def usage(request: Request) -> JSONResponse: except AuthError as exc: return _json_error(str(exc), 401) range_name = _parse_range(request, default="24h") - window = range_window(range_name) + quota_state = _quota(request) + window, reset_at = quota_state.effective_window(_record_id(record), range_name) data = await request.app.state.cpamp.analytics( api_key_hash=record.cpamp_hash, window=window, @@ -159,10 +305,23 @@ async def usage(request: Request) -> JSONResponse: include_model_stats=True, ) data = apply_key_policy_pricing(data, record.model_prices) + quota_summary = _quota_projection( + record, + quota_state, + range_name=range_name, + window_from_ms=window.from_ms, + window_to_ms=window.to_ms, + reset_at_ms=reset_at, + used_usd=(data.get("summary") or {}).get("total_cost"), + ) return JSONResponse({ "range": range_name, "from_ms": window.from_ms, "to_ms": window.to_ms, + "reset_at_ms": reset_at, + "limits": _safe_record(record, quota_state).get("limits") or {}, + "reset_points": quota_state.get_reset_points(_record_id(record)), + "quota": quota_summary, "summary": data.get("summary") or {}, "timeline": data.get("timeline") or [], "model_share": data.get("model_share") or [], @@ -183,24 +342,46 @@ async def events(request: Request) -> JSONResponse: limit = 100 before_ms, before_id = _parse_before(request) range_name = _parse_range(request, default="7d") - window = range_window(range_name) + quota_state = _quota(request) + window, reset_at = quota_state.effective_window(_record_id(record), range_name) data = await request.app.state.cpamp.analytics( api_key_hash=record.cpamp_hash, window=window, include_events=True, + include_model_stats=True, event_limit=limit, before_ms=before_ms, before_id=before_id, ) + data = apply_key_policy_pricing(data, record.model_prices) + quota_summary = _quota_projection( + record, + quota_state, + range_name=range_name, + window_from_ms=window.from_ms, + window_to_ms=window.to_ms, + reset_at_ms=reset_at, + used_usd=(data.get("summary") or {}).get("total_cost"), + ) page = data.get("events") or {} items = apply_event_pricing( safe_events(page.get("items") or [], expected_hash=record.cpamp_hash), record.model_prices, ) + items = _attach_event_accounting( + items, + record=record, + quota=quota_state, + selected_range=range_name, + selected_quota=quota_summary, + now_ms_value=window.to_ms, + ) return JSONResponse({ "range": range_name, "from_ms": window.from_ms, "to_ms": window.to_ms, + "reset_at_ms": reset_at, + "quota": quota_summary, "events": items, "next_before_ms": page.get("next_before_ms") or 0, "next_before_id": page.get("next_before_id") or 0, @@ -231,7 +412,7 @@ async def stream(): try: data = await request.app.state.cpamp.analytics( api_key_hash=record.cpamp_hash, - window=range_window("24h"), + window=_quota(request).effective_window(_record_id(record), "24h")[0], include_events=True, event_limit=20, ) @@ -256,6 +437,153 @@ async def stream(): ) +async def admin_dashboard(request: Request) -> Response: + blocked = _admin_guard(request) + if blocked is not None: + return blocked + return HTMLResponse(_ADMIN_DASHBOARD.read_text(encoding="utf-8")) + + +async def _analytics_for_quota(request: Request, record: KeyRecord, range_name: str) -> dict[str, Any]: + quota_state = _quota(request) + window, reset_at = quota_state.effective_window(_record_id(record), range_name) + data = await request.app.state.cpamp.analytics( + api_key_hash=record.cpamp_hash, + window=window, + include_events=False, + include_model_stats=True, + ) + data = apply_key_policy_pricing(data, record.model_prices) + summary = data.get("summary") or {} + return _quota_projection( + record, + quota_state, + range_name=range_name, + window_from_ms=window.from_ms, + window_to_ms=window.to_ms, + reset_at_ms=reset_at, + used_usd=summary.get("total_cost"), + ) + + +async def admin_keys(request: Request) -> JSONResponse: + blocked = _admin_guard(request) + if blocked is not None: + return blocked + state = _load_key_state(request) + quota_state = _quota(request) + keys = [] + for record in state.keys: + usage_windows: dict[str, Any] = {} + for name in RESET_WINDOWS: + try: + usage_windows[name] = await _analytics_for_quota(request, record, name) + except Exception as exc: + usage_windows[name] = {"range": name, "error": type(exc).__name__} + row = _safe_record(record, quota_state) + row["usage_windows"] = usage_windows + keys.append(row) + return JSONResponse({"keys": keys}) + + +async def admin_update_limits(request: Request) -> JSONResponse: + blocked = _admin_guard(request) + if blocked is not None: + return blocked + key_id = request.path_params["key_id"] + state = _load_key_state(request) + record = _find_record(state, key_id) + if record is None: + return _json_error("key_not_found", 404) + try: + body = await request.json() + five_hour = _parse_float_limit((body or {}).get("five_hour_usd")) + monthly = _parse_float_limit((body or {}).get("monthly_usd")) + except (json.JSONDecodeError, ValueError) as exc: + return _json_error(str(exc) or "invalid_json", 400) + quota_state = _quota(request) + quota_state.set_limits(_record_id(record), five_hour_usd=five_hour, monthly_usd=monthly, actor=_actor(request)) + return JSONResponse({"me": _safe_record(record, quota_state)}) + + +async def admin_reset_usage(request: Request) -> JSONResponse: + blocked = _admin_guard(request) + if blocked is not None: + return blocked + key_id = request.path_params["key_id"] + state = _load_key_state(request) + record = _find_record(state, key_id) + if record is None: + return _json_error("key_not_found", 404) + try: + body = await request.json() + except json.JSONDecodeError: + return _json_error("invalid_json", 400) + window = str((body or {}).get("window") or "all") + try: + points = _quota(request).reset(_record_id(record), window=window, actor=_actor(request)) + except ValueError as exc: + return _json_error(str(exc), 400) + return JSONResponse({"ok": True, "reset_points": points}) + + +async def admin_events(request: Request) -> JSONResponse: + blocked = _admin_guard(request) + if blocked is not None: + return blocked + key_id = request.query_params.get("key_id", "") + state = _load_key_state(request) + record = _find_record(state, key_id) + if record is None: + return _json_error("key_not_found", 404) + limit_raw = request.query_params.get("limit", "100") + try: + limit = max(1, min(int(limit_raw), 200)) + except ValueError: + limit = 100 + range_name = _parse_range(request, default="24h") + quota_state = _quota(request) + window, reset_at = quota_state.effective_window(_record_id(record), range_name) + data = await request.app.state.cpamp.analytics( + api_key_hash=record.cpamp_hash, + window=window, + include_events=True, + include_model_stats=True, + event_limit=limit, + ) + data = apply_key_policy_pricing(data, record.model_prices) + quota_summary = _quota_projection( + record, + quota_state, + range_name=range_name, + window_from_ms=window.from_ms, + window_to_ms=window.to_ms, + reset_at_ms=reset_at, + used_usd=(data.get("summary") or {}).get("total_cost"), + ) + page = data.get("events") or {} + items = apply_event_pricing( + safe_events(page.get("items") or [], expected_hash=record.cpamp_hash), + record.model_prices, + ) + items = _attach_event_accounting( + items, + record=record, + quota=quota_state, + selected_range=range_name, + selected_quota=quota_summary, + now_ms_value=window.to_ms, + ) + return JSONResponse({ + "range": range_name, + "from_ms": window.from_ms, + "to_ms": window.to_ms, + "reset_at_ms": reset_at, + "quota": quota_summary, + "events": items, + }) + + def create_app(cfg: PortalConfig) -> Starlette: if not cfg.session_secret: raise ValueError("CPA_USAGE_PORTAL_SESSION_SECRET or file is required") @@ -268,6 +596,7 @@ async def lifespan(app: Starlette): app.state.cfg = cfg app.state.client = client app.state.cpamp = CPAMPClient(cfg.cpamp_base_url, cfg.cpamp_admin_key, client) + app.state.quota = QuotaState(cfg.local_state_db_path) try: yield finally: @@ -282,5 +611,10 @@ async def lifespan(app: Starlette): Route("/api/usage", usage, methods=["GET"]), Route("/api/events", events, methods=["GET"]), Route("/api/events/stream", events_stream, methods=["GET"]), + Route("/admin/", admin_dashboard, methods=["GET"]), + Route("/admin/api/keys", admin_keys, methods=["GET"]), + Route("/admin/api/keys/{key_id:str}/limits", admin_update_limits, methods=["PUT"]), + Route("/admin/api/keys/{key_id:str}/reset", admin_reset_usage, methods=["POST"]), + Route("/admin/api/events", admin_events, methods=["GET"]), ] return Starlette(routes=routes, lifespan=lifespan) diff --git a/cpa_usage_portal/config.py b/cpa_usage_portal/config.py index c4e9994..1a31bfd 100644 --- a/cpa_usage_portal/config.py +++ b/cpa_usage_portal/config.py @@ -11,6 +11,7 @@ class PortalConfig: host: str = "0.0.0.0" port: int = 8797 key_policy_state_path: str = "/data/cpa-key-policy-state.json" + local_state_db_path: str = "/data/portal/usage_portal.sqlite" cpamp_base_url: str = "http://cpamp:18317" cpamp_admin_key: str = "" session_secret: str = "" @@ -18,6 +19,8 @@ class PortalConfig: session_ttl_seconds: int = 24 * 60 * 60 cookie_secure: bool = True poll_seconds: float = 3.0 + admin_header_name: str = "x-usage-admin" + admin_header_value: str = "1" def _read_secret(value: str, file_path: str) -> str: @@ -43,6 +46,7 @@ def load_config_from_env() -> PortalConfig: "CPA_USAGE_PORTAL_KEY_POLICY_STATE", "/data/cpa-key-policy-state.json", ), + local_state_db_path=os.environ.get("CPA_USAGE_PORTAL_LOCAL_STATE_DB", "/data/portal/usage_portal.sqlite"), cpamp_base_url=os.environ.get("CPA_USAGE_PORTAL_CPAMP_URL", "http://cpamp:18317"), cpamp_admin_key=_read_secret( os.environ.get("CPA_USAGE_PORTAL_CPAMP_ADMIN_KEY", ""), @@ -56,4 +60,6 @@ def load_config_from_env() -> PortalConfig: session_ttl_seconds=int(os.environ.get("CPA_USAGE_PORTAL_SESSION_TTL_SECONDS", str(24 * 60 * 60))), cookie_secure=_bool_env("CPA_USAGE_PORTAL_COOKIE_SECURE", True), poll_seconds=float(os.environ.get("CPA_USAGE_PORTAL_POLL_SECONDS", "3")), + admin_header_name=os.environ.get("CPA_USAGE_PORTAL_ADMIN_HEADER_NAME", "x-usage-admin"), + admin_header_value=os.environ.get("CPA_USAGE_PORTAL_ADMIN_HEADER_VALUE", "1"), ) diff --git a/cpa_usage_portal/cpamp.py b/cpa_usage_portal/cpamp.py index e2730dc..d67ab9e 100644 --- a/cpa_usage_portal/cpamp.py +++ b/cpa_usage_portal/cpamp.py @@ -3,6 +3,7 @@ import time from dataclasses import dataclass +from datetime import datetime, timedelta, timezone from typing import Any import httpx @@ -14,17 +15,29 @@ class AnalyticsWindow: to_ms: int +SUPPORTED_RANGES = {"5h", "24h", "7d", "month"} +_SHANGHAI_TZ = timezone(timedelta(hours=8)) + + def now_ms() -> int: return int(time.time() * 1000) -def range_window(range_name: str) -> AnalyticsWindow: - current = now_ms() +def range_window(range_name: str, *, now_ms_value: int | None = None) -> AnalyticsWindow: + current = int(now_ms_value if now_ms_value is not None else now_ms()) + if range_name == "5h": + delta = 5 * 60 * 60 * 1000 + return AnalyticsWindow(from_ms=current - delta, to_ms=current) if range_name == "7d": delta = 7 * 24 * 60 * 60 * 1000 + return AnalyticsWindow(from_ms=current - delta, to_ms=current) + if range_name == "month": + local_now = datetime.fromtimestamp(current / 1000, _SHANGHAI_TZ) + start = local_now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + return AnalyticsWindow(from_ms=int(start.timestamp() * 1000), to_ms=current) else: delta = 24 * 60 * 60 * 1000 - return AnalyticsWindow(from_ms=current - delta, to_ms=current) + return AnalyticsWindow(from_ms=current - delta, to_ms=current) class CPAMPClient: diff --git a/cpa_usage_portal/quota_state.py b/cpa_usage_portal/quota_state.py new file mode 100644 index 0000000..866b196 --- /dev/null +++ b/cpa_usage_portal/quota_state.py @@ -0,0 +1,241 @@ +"""Local quota metadata and soft-reset watermarks for the usage portal.""" +from __future__ import annotations + +import json +import sqlite3 +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from .cpamp import AnalyticsWindow, SUPPORTED_RANGES, now_ms, range_window + +RESET_WINDOWS = ("5h", "24h", "7d", "month") + + +@dataclass(frozen=True) +class LocalLimits: + five_hour_usd: float | None = None + monthly_usd: float | None = None + updated_at_ms: int | None = None + + def safe_dict(self) -> dict[str, Any]: + return { + "five_hour_usd": self.five_hour_usd, + "monthly_usd": self.monthly_usd, + "updated_at_ms": self.updated_at_ms, + } + + +class QuotaState: + """Small SQLite-backed state owned by the custom portal. + + The database stores only operator metadata. It never stores raw API keys, + request/response bodies, OAuth tokens, or CPAMP management secrets. + """ + + def __init__(self, path: str | Path) -> None: + self.path = Path(path) + self.path.parent.mkdir(parents=True, exist_ok=True) + self._ensure_schema() + + def _connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(str(self.path), timeout=10) + conn.row_factory = sqlite3.Row + return conn + + @contextmanager + def _connection(self): + conn = self._connect() + try: + yield conn + conn.commit() + finally: + conn.close() + + def _ensure_schema(self) -> None: + with self._connection() as conn: + conn.execute("pragma journal_mode=wal") + conn.execute( + """ + create table if not exists key_limits ( + policy_id text primary key, + five_hour_limit_usd real, + monthly_limit_usd real, + updated_at_ms integer not null + ) + """ + ) + conn.execute( + """ + create table if not exists reset_watermarks ( + policy_id text not null, + window text not null, + reset_at_ms integer not null, + updated_at_ms integer not null, + primary key (policy_id, window) + ) + """ + ) + conn.execute( + """ + create table if not exists audit_log ( + id integer primary key autoincrement, + timestamp_ms integer not null, + actor text, + action text not null, + policy_id text not null, + window text, + before_json text, + after_json text + ) + """ + ) + + def get_limits(self, policy_id: str) -> LocalLimits: + with self._connection() as conn: + row = conn.execute( + "select five_hour_limit_usd, monthly_limit_usd, updated_at_ms from key_limits where policy_id = ?", + (policy_id,), + ).fetchone() + if row is None: + return LocalLimits() + return LocalLimits( + five_hour_usd=_float_or_none(row["five_hour_limit_usd"]), + monthly_usd=_float_or_none(row["monthly_limit_usd"]), + updated_at_ms=_int_or_none(row["updated_at_ms"]), + ) + + def set_limits( + self, + policy_id: str, + *, + five_hour_usd: float | None, + monthly_usd: float | None, + actor: str = "admin", + ) -> LocalLimits: + before = self.get_limits(policy_id).safe_dict() + updated = now_ms() + with self._connection() as conn: + conn.execute( + """ + insert into key_limits(policy_id, five_hour_limit_usd, monthly_limit_usd, updated_at_ms) + values (?, ?, ?, ?) + on conflict(policy_id) do update set + five_hour_limit_usd = excluded.five_hour_limit_usd, + monthly_limit_usd = excluded.monthly_limit_usd, + updated_at_ms = excluded.updated_at_ms + """, + (policy_id, five_hour_usd, monthly_usd, updated), + ) + after = LocalLimits(five_hour_usd=five_hour_usd, monthly_usd=monthly_usd, updated_at_ms=updated).safe_dict() + self._insert_audit( + conn, + actor=actor, + action="set_limits", + policy_id=policy_id, + window=None, + before=before, + after=after, + ) + return LocalLimits(five_hour_usd=five_hour_usd, monthly_usd=monthly_usd, updated_at_ms=updated) + + def get_reset_points(self, policy_id: str) -> dict[str, int | None]: + points: dict[str, int | None] = {name: None for name in RESET_WINDOWS} + with self._connection() as conn: + rows = conn.execute( + "select window, reset_at_ms from reset_watermarks where policy_id = ?", + (policy_id,), + ).fetchall() + for row in rows: + window = str(row["window"] or "") + if window in points: + points[window] = _int_or_none(row["reset_at_ms"]) + return points + + def reset(self, policy_id: str, *, window: str, actor: str = "admin", reset_at_ms: int | None = None) -> dict[str, int | None]: + windows = list(RESET_WINDOWS) if window == "all" else [window] + invalid = [item for item in windows if item not in RESET_WINDOWS] + if invalid: + raise ValueError("invalid_reset_window") + reset_at = int(reset_at_ms if reset_at_ms is not None else now_ms()) + before = self.get_reset_points(policy_id) + with self._connection() as conn: + for item in windows: + conn.execute( + """ + insert into reset_watermarks(policy_id, window, reset_at_ms, updated_at_ms) + values (?, ?, ?, ?) + on conflict(policy_id, window) do update set + reset_at_ms = excluded.reset_at_ms, + updated_at_ms = excluded.updated_at_ms + """, + (policy_id, item, reset_at, reset_at), + ) + after = dict(before) + for item in windows: + after[item] = reset_at + self._insert_audit( + conn, + actor=actor, + action="reset_usage", + policy_id=policy_id, + window=window, + before=before, + after=after, + ) + return self.get_reset_points(policy_id) + + def effective_window(self, policy_id: str, range_name: str, *, now_ms_value: int | None = None) -> tuple[AnalyticsWindow, int | None]: + if range_name not in SUPPORTED_RANGES: + range_name = "24h" + base = range_window(range_name, now_ms_value=now_ms_value) + reset_at = self.get_reset_points(policy_id).get(range_name) + if reset_at is None or reset_at <= base.from_ms: + return base, reset_at + return AnalyticsWindow(from_ms=min(reset_at, base.to_ms), to_ms=base.to_ms), reset_at + + def _insert_audit( + self, + conn: sqlite3.Connection, + *, + actor: str, + action: str, + policy_id: str, + window: str | None, + before: dict[str, Any], + after: dict[str, Any], + ) -> None: + conn.execute( + """ + insert into audit_log(timestamp_ms, actor, action, policy_id, window, before_json, after_json) + values (?, ?, ?, ?, ?, ?, ?) + """, + ( + now_ms(), + actor[:160], + action, + policy_id, + window, + json.dumps(before, ensure_ascii=False, sort_keys=True), + json.dumps(after, ensure_ascii=False, sort_keys=True), + ), + ) + + +def _float_or_none(value: Any) -> float | None: + if value is None or value == "": + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _int_or_none(value: Any) -> int | None: + if value is None or value == "": + return None + try: + return int(value) + except (TypeError, ValueError): + return None diff --git a/cpa_usage_portal/static/admin.html b/cpa_usage_portal/static/admin.html new file mode 100644 index 0000000..f573268 --- /dev/null +++ b/cpa_usage_portal/static/admin.html @@ -0,0 +1,427 @@ + + + + + + CPA 用量管理 + + + +
+
+
+
U
+
+

CPA 用量管理

+
自有门户的限额、估算用量与软清零;不修改 CPAMP 原始请求记录。
+
+
+
+ 等待刷新 + + +
+
+ +
+
+
Key 数量
+
0
+
启用 0
+
+
+
所选窗口总费用
+
$0.0000
+
-
+
+
+
超限风险
+
0
+
按门户估算,不是官方账单
+
+
+
清零方式
+
Soft
+
只写 reset watermark
+
+
+ +
+
+
+

Key 限额与窗口用量

+

日限/周限来自 Key Policy;5H/月限和清零点保存在自有 SQLite。

+
+
+
+
级别 事件 请求消息 / 字段字段
暂无日志
暂无请求记录
+ + + + + + + + + + + + + +
Key状态5H本地限额操作
正在读取...
+
+
+ +
+
+
+

最近请求

+

选择一条 Key 后查看安全摘要。

+
+ +
+
+ + + + + + + + + + + + + + +
时间状态模型延迟TokensReasoning费用计入窗口
暂无请求记录
+
+
+
+ + + + diff --git a/cpa_usage_portal/static/dashboard.html b/cpa_usage_portal/static/dashboard.html index 654a6f5..fea1a2b 100644 --- a/cpa_usage_portal/static/dashboard.html +++ b/cpa_usage_portal/static/dashboard.html @@ -451,8 +451,10 @@

CPA 用量自助页

实时未连接 @@ -599,7 +601,10 @@

最近请求

return `${(n / 1000).toFixed(2)} s`; } function rangeLabel(range) { - return range === "7d" ? "最近 7 天" : "最近 24 小时"; + if (range === "5h") return "最近 5 小时"; + if (range === "7d") return "最近 7 天"; + if (range === "month") return "本月"; + return "最近 24 小时"; } function setLive(kind, text) { const chip = $("liveChip"); @@ -655,19 +660,22 @@

最近请求

state.me = me; const daily = me.limits?.daily_usd; const weekly = me.limits?.weekly_usd; + const fiveHour = me.limits?.five_hour_usd; + const monthly = me.limits?.monthly_usd; const pricingCount = me.pricing?.priced_model_count || 0; - $("keyStatus").textContent = `日限 ${limitMoney(daily)}`; + $("keyStatus").textContent = `5H ${limitMoney(fiveHour)}`; $("keyStatus").className = `metric-value ${me.enabled ? "success" : "danger"}`; $("keyMeta").textContent = `${me.name || "未命名"} / ${me.preview || "-"} `; - $("rpmHint").textContent = `周限 ${limitMoney(weekly)} · ${me.enabled ? "启用中" : "已停用"} · RPM ${me.rpm || "不限"} · 价格 ${pricingCount || 0} 个模型`; + $("rpmHint").textContent = `日限 ${limitMoney(daily)} · 周限 ${limitMoney(weekly)} · 月限 ${limitMoney(monthly)} · RPM ${me.rpm || "不限"} · 价格 ${pricingCount || 0} 个模型`; $("keyLine").textContent = `${me.name || "当前 Key"} · ${me.preview || ""}`; } function renderUsage(data) { const s = data.summary || {}; const me = state.me || {}; const range = data.range || $("rangeSelect").value; + const quota = data.quota || {}; state.currentRange = range; - const limit = range === "7d" ? me.limits?.weekly_usd : me.limits?.daily_usd; + const limit = quota.limit_usd ?? (range === "5h" ? me.limits?.five_hour_usd : range === "7d" ? me.limits?.weekly_usd : range === "month" ? me.limits?.monthly_usd : me.limits?.daily_usd); const windowText = data.from_ms && data.to_ms ? ` · ${time(data.from_ms)} 至 ${time(data.to_ms)}` : ""; $("modelRangeHint").textContent = `按当前 Key 统计:${rangeLabel(range)}${windowText}`; $("eventsRangeHint").textContent = `当前显示 ${rangeLabel(range)} 内最近 100 条请求;展开后查看脱敏详情。`; @@ -676,7 +684,9 @@

最近请求

$("failureCount").textContent = `失败 ${num(s.failure_calls)}`; $("totalCost").textContent = money(s.total_cost); if (Number(limit) > 0) { - $("limitHint").textContent = `${range === "7d" ? "周限" : "日限"} ${money(limit)} / 已用 ${Math.min(100, Number(s.total_cost || 0) / Number(limit) * 100).toFixed(1)}%`; + const usedPercent = quota.used_percent == null ? Number(s.total_cost || 0) / Number(limit) : Number(quota.used_percent || 0); + const remaining = quota.remaining_usd == null ? Math.max(Number(limit) - Number(s.total_cost || 0), 0) : Number(quota.remaining_usd || 0); + $("limitHint").textContent = `${rangeLabel(range)}限额 ${money(limit)} / 已用 ${Math.min(100, usedPercent * 100).toFixed(1)}% / 剩余 ${money(remaining)}`; } else { const unpriced = Array.isArray(s.unpriced_models) && s.unpriced_models.length ? `未定价 ${s.unpriced_models.slice(0, 3).join(", ")}` : ""; $("limitHint").textContent = unpriced || (s.cost_source === "key_policy" ? "按 Key Policy 单价估算" : "未设置 USD 限额"); @@ -697,6 +707,8 @@

最近请求

} function detailRow(event, id) { const quota = event.quota || {}; + const accounting = event.accounting || {}; + const included = Array.isArray(accounting.included_windows) ? accounting.included_windows.map(rangeLabel).join(" / ") : "-"; const failure = event.failure || event.failure_brief || "无失败详情"; return ` @@ -707,7 +719,10 @@

最近请求

状态码${escapeHtml(event.status_code || "-")} Service Tier${escapeHtml(event.service_tier || "-")} Reasoning Effort${escapeHtml(event.reasoning_effort || "-")} - Quota${escapeHtml(quota.used_percent == null ? "-" : quota.used_percent + "%")} + Provider Quota${escapeHtml(quota.used_percent == null ? "-" : quota.used_percent + "%")} + 计入窗口${escapeHtml(included)} + 当前窗口剩余${escapeHtml(limitMoney(accounting.current_window_remaining_usd))} + 统计起点${escapeHtml(time(accounting.reset_at_ms || accounting.window_from_ms))}
脱敏失败详情
diff --git a/deploy/cpa-usage-portal/docker-compose.example.yaml b/deploy/cpa-usage-portal/docker-compose.example.yaml index 8a2db79..e9dac4f 100644 --- a/deploy/cpa-usage-portal/docker-compose.example.yaml +++ b/deploy/cpa-usage-portal/docker-compose.example.yaml @@ -9,12 +9,14 @@ services: - cpa_net environment: CPA_USAGE_PORTAL_KEY_POLICY_STATE: /data/plugin-state/cpa-key-policy-state.json + CPA_USAGE_PORTAL_LOCAL_STATE_DB: /data/portal/usage_portal.sqlite CPA_USAGE_PORTAL_CPAMP_URL: http://cpamp:18317 CPA_USAGE_PORTAL_CPAMP_ADMIN_KEY_FILE: /run/secrets/cpamp_admin_key CPA_USAGE_PORTAL_SESSION_SECRET_FILE: /run/secrets/session_secret CPA_USAGE_PORTAL_COOKIE_SECURE: "true" volumes: - /opt/codex-stacks/cpa/plugin-state:/data/plugin-state:ro + - ./data:/data/portal - ./secrets/cpamp_admin_key:/run/secrets/cpamp_admin_key:ro - ./secrets/session_secret:/run/secrets/session_secret:ro diff --git a/tests/test_cpa_usage_portal.py b/tests/test_cpa_usage_portal.py index 7fda39c..c2f70ff 100644 --- a/tests/test_cpa_usage_portal.py +++ b/tests/test_cpa_usage_portal.py @@ -16,7 +16,9 @@ from cpa_usage_portal.app import create_app from cpa_usage_portal.budget import suggest_equal_budget from cpa_usage_portal.config import PortalConfig +from cpa_usage_portal.cpamp import range_window from cpa_usage_portal.key_policy import KeyPolicyState +from cpa_usage_portal.quota_state import QuotaState from cpa_usage_portal.redaction import redact, safe_event from cpa_usage_portal.retention import enforce_usage_retention from cpa_usage_portal.security import ( @@ -95,7 +97,7 @@ async def analytics(self, *, api_key_hash, window, include_events=False, { "event_hash": "evt-a", "request_id": "req-a", - "timestamp_ms": 1800000000000, + "timestamp_ms": window.to_ms - 1000, "api_key_hash": api_key_hash, "model": "gpt-5.5", "failed": False, @@ -110,7 +112,7 @@ async def analytics(self, *, api_key_hash, window, include_events=False, { "event_hash": "evt-b", "request_id": "req-b", - "timestamp_ms": 1800000001000, + "timestamp_ms": window.to_ms - 500, "api_key_hash": self.other_hash, "model": "gpt-5.5", "failed": True, @@ -142,9 +144,9 @@ def write_state(path: Path, raw_key: str, disabled_key: str) -> tuple[str, str, "alias": "gpt-5.5", "provider": "codex", "target_model": "gpt-5.5", - "input_price_per_million": 125, - "output_price_per_million": 750, - "cache_read_price_per_million": 12.5, + "input_price_per_million": 5, + "output_price_per_million": 30, + "cache_read_price_per_million": 0.5, } ], "daily_limit_usd": 5, @@ -159,9 +161,9 @@ def write_state(path: Path, raw_key: str, disabled_key: str) -> tuple[str, str, "models": [ { "alias": "gpt-5.5", - "input_price_per_million": 125, - "output_price_per_million": 750, - "cache_read_price_per_million": 12.5, + "input_price_per_million": 5, + "output_price_per_million": 30, + "cache_read_price_per_million": 0.5, } ], }, @@ -203,9 +205,9 @@ def test_key_policy_and_budget(tmp: Path) -> None: str(record.models if record else None)) check("key policy parses per-model prices", record is not None - and record.model_prices["gpt-5.5"].input_per_million == 125 - and record.model_prices["gpt-5.5"].output_per_million == 750 - and record.model_prices["gpt-5.5"].cache_read_per_million == 12.5, + and record.model_prices["gpt-5.5"].input_per_million == 5 + and record.model_prices["gpt-5.5"].output_per_million == 30 + and record.model_prices["gpt-5.5"].cache_read_per_million == 0.5, str(record.model_prices if record else None)) check("key policy safe dict exposes limits", record is not None @@ -307,11 +309,28 @@ def test_retention(tmp: Path) -> None: check("retention deletes old rows", result.deleted == 1 and count == 1, str(result)) +def test_quota_state(tmp: Path) -> None: + quota = QuotaState(tmp / "quota.sqlite") + limits = quota.get_limits("alice-key") + check("quota state defaults empty", limits.five_hour_usd is None and limits.monthly_usd is None) + updated = quota.set_limits("alice-key", five_hour_usd=1.5, monthly_usd=20, actor="tester") + check("quota state stores local limits", updated.five_hour_usd == 1.5 and updated.monthly_usd == 20) + points = quota.reset("alice-key", window="5h", actor="tester", reset_at_ms=1_800_000_000_000) + check("quota state stores reset watermark", points["5h"] == 1_800_000_000_000, str(points)) + window, reset_at = quota.effective_window("alice-key", "5h", now_ms_value=1_800_000_100_000) + check("quota state applies reset watermark", + reset_at == 1_800_000_000_000 and window.from_ms == 1_800_000_000_000, + str((window, reset_at))) + month = range_window("month", now_ms_value=1_783_108_800_000) + check("range window supports month", month.from_ms < month.to_ms) + + def test_app_routes(tmp: Path) -> None: key_hash, cpamp_hash, other_raw_hash = write_state(tmp / "state.json", "cpa_live", "cpa_disabled") other_hash = sha256_hex("other-policy-id") cfg = PortalConfig( key_policy_state_path=str(tmp / "state.json"), + local_state_db_path=str(tmp / "quota.sqlite"), cpamp_admin_key="cpamp_test", session_secret="session_secret", cookie_secure=False, @@ -324,6 +343,11 @@ def test_app_routes(tmp: Path) -> None: check("portal healthz ok", health.status_code == 200 and health.json().get("ok") is True) html = client.get("/") check("portal dashboard html ok", html.status_code == 200 and "CPA 用量自助页" in html.text) + admin_public = client.get("/admin/") + check("portal admin hidden without proxy header", admin_public.status_code == 404) + admin_html = client.get("/admin/", headers={"x-usage-admin": "1"}) + check("portal admin dashboard html ok", admin_html.status_code == 200 and "CPA 用量管理" in admin_html.text) + check("portal admin supports usage-admin mount", "API_BASE" in admin_html.text and "/usage-admin" in admin_html.text) check("portal dashboard refresh reconnects stream", "startStream({ force: true })" in html.text) check("portal dashboard revives after background", "visibilitychange" in html.text) check("portal dashboard shows refresh animation", "is-loading" in html.text and "stream warn" in html.text) @@ -333,6 +357,34 @@ def test_app_routes(tmp: Path) -> None: check("portal dashboard applies selected range to events", "api(`/api/events?range=${encodeURIComponent(range)}&limit=100`)" in html.text and "当前显示" in html.text) + check("portal dashboard supports 5h and month ranges", + '' in html.text + and '' in html.text) + + admin_keys = client.get("/admin/api/keys", headers={"x-usage-admin": "1"}) + admin_body = admin_keys.json() + check("portal admin lists quota windows", + admin_keys.status_code == 200 + and {"5h", "24h", "7d", "month"}.issubset(set(admin_body["keys"][0]["usage_windows"].keys())), + str(admin_body)) + limits_update = client.put( + "/admin/api/keys/alice-key/limits", + headers={"x-usage-admin": "1"}, + json={"five_hour_usd": 1.25, "monthly_usd": 20}, + ) + check("portal admin updates local limits", + limits_update.status_code == 200 + and limits_update.json()["me"]["limits"]["five_hour_usd"] == 1.25 + and limits_update.json()["me"]["limits"]["monthly_usd"] == 20, + limits_update.text) + reset = client.post( + "/admin/api/keys/alice-key/reset", + headers={"x-usage-admin": "1"}, + json={"window": "5h"}, + ) + check("portal admin soft resets window", reset.status_code == 200 and reset.json()["reset_points"]["5h"], reset.text) + fake.seen_hashes.clear() + fake.seen_windows.clear() bad = client.post("/api/session", json={"api_key": "nope"}) check("portal rejects unknown key", bad.status_code == 401) @@ -345,6 +397,11 @@ def test_app_routes(tmp: Path) -> None: me = client.get("/api/me") check("portal me ok", me.status_code == 200 and me.json()["me"]["name"] == "Alice", me.text) + check("portal me exposes local limits and reset points", + me.json()["me"]["limits"]["five_hour_usd"] == 1.25 + and me.json()["me"]["limits"]["monthly_usd"] == 20 + and me.json()["me"]["reset_points"]["5h"], + me.text) usage = client.get("/api/usage?range=24h") usage_body = usage.json() check("portal usage ok", usage.status_code == 200 and usage_body["summary"]["total_calls"] == 2) @@ -370,6 +427,14 @@ def test_app_routes(tmp: Path) -> None: check("portal events recompute cost from key policy prices", body["events"][0]["cost"] > 0 and body["events"][0]["cost_source"] == "key_policy", str(body)) + check("portal events include accounting windows", + "accounting" in body["events"][0] + and "24h" in body["events"][0]["accounting"]["included_windows"], + str(body)) + month_usage = client.get("/api/usage?range=month") + check("portal usage supports month range", + month_usage.status_code == 200 and month_usage.json()["range"] == "month", + month_usage.text) check("portal never returns full raw api hash", key_hash not in json.dumps(body), str(body)) check("portal does not use disabled raw hash", other_raw_hash not in json.dumps(body), str(body)) check("portal cpamp filter used policy-id hash", @@ -383,6 +448,8 @@ def main() -> None: test_redaction_and_safe_event() with tempfile.TemporaryDirectory() as d: test_retention(Path(d)) + with tempfile.TemporaryDirectory() as d: + test_quota_state(Path(d)) with tempfile.TemporaryDirectory() as d: test_app_routes(Path(d)) From 3f960f5f1ffd0ce788b65b1a45f963a0d1dc35cc Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Thu, 2 Jul 2026 01:00:37 +0800 Subject: [PATCH 22/68] chore(task): archive 07-01-dashboard-visual-refresh --- .../artifacts/codexcont-desktop.png | Bin .../artifacts/codexcont-mobile.png | Bin .../artifacts/usage-desktop.png | Bin .../artifacts/usage-mobile.png | Bin .../07-01-dashboard-visual-refresh/check.jsonl | 0 .../07-01-dashboard-visual-refresh/design.md | 0 .../07-01-dashboard-visual-refresh/implement.jsonl | 0 .../07-01-dashboard-visual-refresh/implement.md | 0 .../2026-07}/07-01-dashboard-visual-refresh/prd.md | 0 .../07-01-dashboard-visual-refresh/task.json | 4 ++-- 10 files changed, 2 insertions(+), 2 deletions(-) rename .trellis/tasks/{ => archive/2026-07}/07-01-dashboard-visual-refresh/artifacts/codexcont-desktop.png (100%) rename .trellis/tasks/{ => archive/2026-07}/07-01-dashboard-visual-refresh/artifacts/codexcont-mobile.png (100%) rename .trellis/tasks/{ => archive/2026-07}/07-01-dashboard-visual-refresh/artifacts/usage-desktop.png (100%) rename .trellis/tasks/{ => archive/2026-07}/07-01-dashboard-visual-refresh/artifacts/usage-mobile.png (100%) rename .trellis/tasks/{ => archive/2026-07}/07-01-dashboard-visual-refresh/check.jsonl (100%) rename .trellis/tasks/{ => archive/2026-07}/07-01-dashboard-visual-refresh/design.md (100%) rename .trellis/tasks/{ => archive/2026-07}/07-01-dashboard-visual-refresh/implement.jsonl (100%) rename .trellis/tasks/{ => archive/2026-07}/07-01-dashboard-visual-refresh/implement.md (100%) rename .trellis/tasks/{ => archive/2026-07}/07-01-dashboard-visual-refresh/prd.md (100%) rename .trellis/tasks/{ => archive/2026-07}/07-01-dashboard-visual-refresh/task.json (89%) diff --git a/.trellis/tasks/07-01-dashboard-visual-refresh/artifacts/codexcont-desktop.png b/.trellis/tasks/archive/2026-07/07-01-dashboard-visual-refresh/artifacts/codexcont-desktop.png similarity index 100% rename from .trellis/tasks/07-01-dashboard-visual-refresh/artifacts/codexcont-desktop.png rename to .trellis/tasks/archive/2026-07/07-01-dashboard-visual-refresh/artifacts/codexcont-desktop.png diff --git a/.trellis/tasks/07-01-dashboard-visual-refresh/artifacts/codexcont-mobile.png b/.trellis/tasks/archive/2026-07/07-01-dashboard-visual-refresh/artifacts/codexcont-mobile.png similarity index 100% rename from .trellis/tasks/07-01-dashboard-visual-refresh/artifacts/codexcont-mobile.png rename to .trellis/tasks/archive/2026-07/07-01-dashboard-visual-refresh/artifacts/codexcont-mobile.png diff --git a/.trellis/tasks/07-01-dashboard-visual-refresh/artifacts/usage-desktop.png b/.trellis/tasks/archive/2026-07/07-01-dashboard-visual-refresh/artifacts/usage-desktop.png similarity index 100% rename from .trellis/tasks/07-01-dashboard-visual-refresh/artifacts/usage-desktop.png rename to .trellis/tasks/archive/2026-07/07-01-dashboard-visual-refresh/artifacts/usage-desktop.png diff --git a/.trellis/tasks/07-01-dashboard-visual-refresh/artifacts/usage-mobile.png b/.trellis/tasks/archive/2026-07/07-01-dashboard-visual-refresh/artifacts/usage-mobile.png similarity index 100% rename from .trellis/tasks/07-01-dashboard-visual-refresh/artifacts/usage-mobile.png rename to .trellis/tasks/archive/2026-07/07-01-dashboard-visual-refresh/artifacts/usage-mobile.png diff --git a/.trellis/tasks/07-01-dashboard-visual-refresh/check.jsonl b/.trellis/tasks/archive/2026-07/07-01-dashboard-visual-refresh/check.jsonl similarity index 100% rename from .trellis/tasks/07-01-dashboard-visual-refresh/check.jsonl rename to .trellis/tasks/archive/2026-07/07-01-dashboard-visual-refresh/check.jsonl diff --git a/.trellis/tasks/07-01-dashboard-visual-refresh/design.md b/.trellis/tasks/archive/2026-07/07-01-dashboard-visual-refresh/design.md similarity index 100% rename from .trellis/tasks/07-01-dashboard-visual-refresh/design.md rename to .trellis/tasks/archive/2026-07/07-01-dashboard-visual-refresh/design.md diff --git a/.trellis/tasks/07-01-dashboard-visual-refresh/implement.jsonl b/.trellis/tasks/archive/2026-07/07-01-dashboard-visual-refresh/implement.jsonl similarity index 100% rename from .trellis/tasks/07-01-dashboard-visual-refresh/implement.jsonl rename to .trellis/tasks/archive/2026-07/07-01-dashboard-visual-refresh/implement.jsonl diff --git a/.trellis/tasks/07-01-dashboard-visual-refresh/implement.md b/.trellis/tasks/archive/2026-07/07-01-dashboard-visual-refresh/implement.md similarity index 100% rename from .trellis/tasks/07-01-dashboard-visual-refresh/implement.md rename to .trellis/tasks/archive/2026-07/07-01-dashboard-visual-refresh/implement.md diff --git a/.trellis/tasks/07-01-dashboard-visual-refresh/prd.md b/.trellis/tasks/archive/2026-07/07-01-dashboard-visual-refresh/prd.md similarity index 100% rename from .trellis/tasks/07-01-dashboard-visual-refresh/prd.md rename to .trellis/tasks/archive/2026-07/07-01-dashboard-visual-refresh/prd.md diff --git a/.trellis/tasks/07-01-dashboard-visual-refresh/task.json b/.trellis/tasks/archive/2026-07/07-01-dashboard-visual-refresh/task.json similarity index 89% rename from .trellis/tasks/07-01-dashboard-visual-refresh/task.json rename to .trellis/tasks/archive/2026-07/07-01-dashboard-visual-refresh/task.json index cbd5099..8e3f743 100644 --- a/.trellis/tasks/07-01-dashboard-visual-refresh/task.json +++ b/.trellis/tasks/archive/2026-07/07-01-dashboard-visual-refresh/task.json @@ -3,7 +3,7 @@ "name": "dashboard-visual-refresh", "title": "Dashboard visual refresh", "description": "", - "status": "in_progress", + "status": "completed", "dev_type": null, "scope": null, "package": null, @@ -11,7 +11,7 @@ "creator": "dxt98", "assignee": "dxt98", "createdAt": "2026-07-01", - "completedAt": null, + "completedAt": "2026-07-02", "branch": null, "base_branch": "main", "worktree_path": null, From 4b6186a4148992c2b357091ceeafec061b776db0 Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Thu, 2 Jul 2026 01:00:51 +0800 Subject: [PATCH 23/68] chore: record journal --- .trellis/workspace/dxt98/index.md | 7 +++--- .trellis/workspace/dxt98/journal-1.md | 33 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/.trellis/workspace/dxt98/index.md b/.trellis/workspace/dxt98/index.md index c72fc4c..161c36c 100644 --- a/.trellis/workspace/dxt98/index.md +++ b/.trellis/workspace/dxt98/index.md @@ -8,8 +8,8 @@ - **Active File**: `journal-1.md` -- **Total Sessions**: 3 -- **Last Active**: 2026-07-01 +- **Total Sessions**: 4 +- **Last Active**: 2026-07-02 --- @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~110 | Active | +| `journal-1.md` | ~143 | Active | --- @@ -29,6 +29,7 @@ | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 4 | 2026-07-02 | CPA usage quota admin | `762b9e6` | `main` | | 3 | 2026-07-01 | CPA key management and usage portal | `b20a983`, `61911b0` | `main` | | 2 | 2026-07-01 | CodexCont status dashboard | `82e54b8`, `5b578e1`, `42c1b14` | `main` | | 1 | 2026-07-01 | SJC CPA migration closeout | `98bf0df`, `aa113c2` | `main` | diff --git a/.trellis/workspace/dxt98/journal-1.md b/.trellis/workspace/dxt98/journal-1.md index 5125820..a233f5b 100644 --- a/.trellis/workspace/dxt98/journal-1.md +++ b/.trellis/workspace/dxt98/journal-1.md @@ -108,3 +108,36 @@ Deployed CPAMP, CPA Key Policy, and a separate user usage portal; clarified cpa_ ### Next Steps - None - task complete + + +## Session 4: CPA usage quota admin + +**Date**: 2026-07-02 +**Task**: CPA usage quota admin +**Branch**: `main` + +### Summary + +Added the custom CPA usage portal local quota admin, 5H/month windows, soft reset watermarks, server price correction evidence, and deployment validation without modifying CPA/CPAMP/Key Policy source. + +### Main Changes + +(Add details) + +### Git Commits + +| Hash | Message | +|------|---------| +| `762b9e6` | (see git log) | + +### Testing + +- [OK] (Add test results) + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete From 432ca380e35bb36ed32c6319afe98a1aa97f4b9e Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Thu, 2 Jul 2026 03:02:58 +0800 Subject: [PATCH 24/68] feat: improve CPA usage request details --- .../backend/codex-continuation-contracts.md | 31 +- .../check.jsonl | 1 + .../design.md | 227 ++++++++++++ .../implement.jsonl | 1 + .../implement.md | 259 ++++++++++++++ .../prd.md | 100 ++++++ .../research/playwright-layout-check.js | 322 ++++++++++++++++++ .../research/sub2api-usage-detail-patterns.md | 70 ++++ .../task.json | 26 ++ README.md | 5 + README_zh.md | 5 + cpa_usage_portal/app.py | 173 +++++++--- cpa_usage_portal/pricing.py | 164 ++++++++- cpa_usage_portal/static/admin.html | 242 +++++++++++-- cpa_usage_portal/static/dashboard.html | 109 ++++-- middleware/admin.py | 1 + middleware/app.py | 7 +- middleware/config.py | 1 + middleware/dashboard.html | 43 ++- middleware/diagnostics.py | 18 +- middleware/key_identity.py | 124 +++++++ tests/test_cpa_usage_portal.py | 123 ++++++- tests/test_middleware.py | 41 ++- 23 files changed, 1956 insertions(+), 137 deletions(-) create mode 100644 .trellis/tasks/07-02-usage-admin-batch-save-request-details/check.jsonl create mode 100644 .trellis/tasks/07-02-usage-admin-batch-save-request-details/design.md create mode 100644 .trellis/tasks/07-02-usage-admin-batch-save-request-details/implement.jsonl create mode 100644 .trellis/tasks/07-02-usage-admin-batch-save-request-details/implement.md create mode 100644 .trellis/tasks/07-02-usage-admin-batch-save-request-details/prd.md create mode 100644 .trellis/tasks/07-02-usage-admin-batch-save-request-details/research/playwright-layout-check.js create mode 100644 .trellis/tasks/07-02-usage-admin-batch-save-request-details/research/sub2api-usage-detail-patterns.md create mode 100644 .trellis/tasks/07-02-usage-admin-batch-save-request-details/task.json create mode 100644 middleware/key_identity.py diff --git a/.trellis/spec/backend/codex-continuation-contracts.md b/.trellis/spec/backend/codex-continuation-contracts.md index 5b2b5cb..8dca1aa 100644 --- a/.trellis/spec/backend/codex-continuation-contracts.md +++ b/.trellis/spec/backend/codex-continuation-contracts.md @@ -87,6 +87,8 @@ The continuation owner must sit at executor level, where it can inspect raw upst - `rounds[]`, `latest_round`, `latest_reasoning_tokens` - `first_truncation_round`, `first_truncation_reasoning_tokens`, `first_truncation_n`, `first_truncation_decision`, `continuation_count` - `truncation_match`, `final_status`, `stopped_reason`, `failure_reason`, `failure_detail` + - optional safe `key_identity`: `known`, `name`, `id`, `preview`, `source`, + `enabled` - Protection values: - `protected_clean`, `auto_continued`, `risk_uncontinued`, `passthrough`, `failed`, `incomplete`, `processing` @@ -94,6 +96,10 @@ The continuation owner must sit at executor level, where it can inspect raw upst - `Diagnostics` owns the request-summary projection. The frontend may format labels, but it must not re-derive protection status from raw log event names or ad hoc field parsing. - Admin data is memory-only. Do not add persistent log files, databases, Redis, or CPA Manager dependencies for dashboard v1/v2 behavior. - Request summaries and logs must not include request bodies, Authorization headers, API keys, OAuth tokens, encrypted reasoning content, or internal implementation-only fields such as `_started_perf`. +- If CodexCont is configured with a Key Policy state path, request summaries + may include safe key identity. The resolver must hash a bearer credential + only long enough to match Key Policy state, then discard the raw key and + expose only safe name/preview/source fields. - `event: log` behavior is backward-compatible with the original dashboard stream. Adding request updates must use a separate `event: request` SSE event. - The beginner-facing dashboard must distinguish "entered CodexCont protection and no continuation was needed" from "516/518n-2 was detected and a hidden continuation round was opened". - When a continued request ends with a clean final round, `latest_reasoning_tokens` may be below 516. The dashboard must label it as latest-round reasoning and separately display the first 516/518n-2 trigger round from the request summary. @@ -172,15 +178,19 @@ This spreads the event contract into JavaScript and makes the beginner-facing st - `GET /api/events/stream` - `GET /admin/` - `GET /admin/api/keys` + - `PUT /admin/api/keys/limits` - `PUT /admin/api/keys/{id}/limits` - `POST /admin/api/keys/{id}/reset` - - `GET /admin/api/events?key_id=...&range=5h|24h|7d|month` + - `GET /admin/api/events?key_id=all|...&range=5h|24h|7d|month` - Production user route: `https://cpa-usage.konbakuyomu.us/` - Production local quota admin route: `https://cpa-admin.konbakuyomu.us/usage-admin/` - Production admin route for CPAMP: `https://cpa-admin.konbakuyomu.us/` - Key Policy state path on SJC: `/opt/codex-stacks/cpa/plugin-state/cpa-key-policy-state.json` +- Containers that read Key Policy state must use a mounted in-container path + such as `/data/plugin-state/cpa-key-policy-state.json`; host paths are not + valid from inside CodexCont or the usage portal unless explicitly mounted. ### 3. Contracts - CPA stays on the official image. Do not fork CPA to implement per-key usage @@ -240,10 +250,29 @@ This spreads the event contract into JavaScript and makes the beginner-facing st `/api/events` should overlay costs using the current key's Key Policy price book. Do not interpret CPAMP zero cost as "free" when Key Policy prices are configured. +- CPAMP Management API is the source of truth for usage token projection. Its + public `cached_tokens` field is already the compatibility cached-input bucket + used by the main CPA/CPAMP dashboard: + `max(max(cached_tokens, cache_tokens) - cache_read_tokens - + cache_creation_tokens, 0)`. The portal must treat that field as a real + OpenAI/Codex cache hit even when fine-grained `cache_read_tokens` and + `cache_creation_tokens` are both zero. +- Keep CPAMP-compatible cached input separate from fine-grained cache + read/create fields in user-facing details. For OpenAI/Codex, a large + `cached_tokens` value with `cache_read_tokens = 0` is normal and must not be + displayed as "no cache read". - `/api/me` must expose safe daily/weekly USD limits and a safe pricing summary. It must also expose local 5H/month USD limits and reset points when the portal SQLite has them. The user dashboard must show 5H, daily, weekly, and monthly limits directly, not only as a selected-range hint. +- `usage-admin` bulk limit saves must validate every submitted key id and + numeric 5H/month value before reporting success. The UI should expose one + global save action for local limits and keep per-key soft reset actions + separate, because reset changes the local watermark rather than the limit + configuration. +- `usage-admin` all-key event mode (`key_id=all`) must merge enabled Key Policy + keys, attach only a safe key summary (`id`, `name`, `preview`, `enabled`), + sort newest first, and cap the merged result by the requested limit. - The usage portal's selected time range controls both `/api/usage` aggregates and the visible `/api/events` recent-request table. The page must also render the active range label, because 24h and 7d can legitimately return identical diff --git a/.trellis/tasks/07-02-usage-admin-batch-save-request-details/check.jsonl b/.trellis/tasks/07-02-usage-admin-batch-save-request-details/check.jsonl new file mode 100644 index 0000000..9cd59d4 --- /dev/null +++ b/.trellis/tasks/07-02-usage-admin-batch-save-request-details/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/07-02-usage-admin-batch-save-request-details/design.md b/.trellis/tasks/07-02-usage-admin-batch-save-request-details/design.md new file mode 100644 index 0000000..1168ff6 --- /dev/null +++ b/.trellis/tasks/07-02-usage-admin-batch-save-request-details/design.md @@ -0,0 +1,227 @@ +# Usage Admin Batch Save And Request Details Design + +## Boundary + +This task modifies only the custom `cpa-usage-portal` service: + +- backend: `cpa_usage_portal/app.py`, `pricing.py`, `redaction.py`, and local + quota state helpers if needed +- frontend: `cpa_usage_portal/static/admin.html` and + `cpa_usage_portal/static/dashboard.html` +- tests: `tests/test_cpa_usage_portal.py` + +CPA, CPAMP, CPA Key Policy, and their images/source trees remain untouched. + +## Admin Batch Save + +Current flow: + +- `GET /admin/api/keys` returns safe key projections plus local 5H/month limits. +- Each row renders a per-key `保存` button. +- Clicking it sends `PUT /admin/api/keys/{key_id}/limits`. + +New flow: + +- The table renders editable 5H/month inputs only. +- A toolbar-level `保存全部` button compares current input values with the last + loaded snapshot. +- The button is disabled when there are no dirty edits. +- Dirty state is visible in the toolbar and, optionally, on changed rows. +- Submit one payload: + +```json +{ + "limits": [ + { + "id": "alice-key", + "five_hour_usd": 1.25, + "monthly_usd": 20 + } + ] +} +``` + +Backend adds `PUT /admin/api/keys/limits`: + +- requires the existing admin guard +- validates that `limits` is a list +- validates each id resolves to a Key Policy record +- parses each numeric limit with the existing `_parse_float_limit` +- writes all valid limits to local `QuotaState` +- returns refreshed safe records, or an explicit error with the bad id/index + +The existing per-key route can remain for backwards compatibility and tests. + +## Admin Recent Requests + +`usage-admin` changes from "one selected Key only" to "all Keys by default": + +- `key_id=all` merges recent events from enabled Key Policy records. +- Each event receives a safe `key` projection with `id`, `name`, `preview`, and + `enabled`. +- The UI renders an `全部 Key` select option first, then one option per key. +- The recent-request table adds a `用户/Key` column. +- Single-key filtering remains available by passing the concrete policy id. + +## Pricing Breakdown + +Current `pricing.py` recomputes only `cost` and `cost_source`. + +New backend projection: + +- keep the current total cost fields for compatibility +- add `cost_breakdown` to event rows after `safe_events(...)` and + `apply_event_pricing(...)` +- compute breakdown in Python from the same Key Policy `ModelPrice` used for + table totals, so frontend math does not duplicate pricing rules +- align cache semantics with CPAMP's own management panel. CPAMP API + `cached_tokens` is already the compatibility cached-input bucket. The portal + displays it as `CPAMP 缓存命中`, while fine-grained cache read/create remain + separate. If a raw row includes `cache_tokens`, normalize it with CPAMP's + formula before pricing to avoid double counting. + +Suggested `cost_breakdown` shape: + +```json +{ + "source": "key_policy", + "price_model": "gpt-5.5", + "unit": "usd_per_1m_tokens", + "service_tier": "priority", + "service_tier_multiplier": 2.5, + "prices": { + "input_per_million": 5, + "output_per_million": 30, + "cache_read_per_million": 0.5, + "cache_creation_per_million": 5 + }, + "tokens": { + "input": 955, + "cached_input": 86016, + "cpamp_cached_input": 86016, + "billable_uncached_input": 955, + "cache_read": 0, + "cache_creation": 0, + "fine_grained_cache_read": 0, + "fine_grained_cache_creation": 0, + "effective_cache_read_for_hit_rate": 86016, + "cache_hit_rate": 0.989, + "cache_semantics": "cpamp_compatible_cached_tokens", + "output": 2455, + "reasoning": 2270, + "visible_output_estimate": 185, + "total": 89426 + }, + "costs": { + "input": 0.004775, + "cached_input": 0.043008, + "cache_read": 0, + "cache_creation": 0, + "output": 0.07365, + "subtotal": 0.121433, + "total": 0.3035825 + } +} +``` + +`visible_output_estimate` is a safe derived number: + +```text +max(output_tokens - reasoning_tokens, 0) +``` + +It must be clearly labeled as an estimate because upstream accounting can vary. + +## Detail UI + +The user page keeps the compact main table: + +- time/request id +- status +- model +- latency/TTFT +- total tokens with input/output hint +- reasoning tokens +- cost +- expand action + +Expanded detail should be reorganized into sections: + +- `请求信息`: request id, endpoint, status code, model/requested model, + service tier, reasoning effort +- `Token 组成`: input, cached input, cache read, cache creation, output, + reasoning, visible output estimate, total +- `费用组成`: price model, source, per-million prices, service tier multiplier, + individual cost parts, total +- `限额窗口`: included windows, current range, remaining, reset/start point +- `失败详情`: short redacted failure reason, expanded redacted detail only when + failed + +The admin events area can reuse the same rendering helpers or a simplified +variant, but it should expose the same cost/token composition for operators. + +## Sub2API Lessons Applied + +Sub2API stores and exposes separate token and cost categories instead of one +opaque amount: + +- input tokens/cost +- output tokens/cost +- cache creation tokens/cost +- cache read tokens/cost +- total cost and actual cost +- service tier and reasoning effort +- request type, stream/openai-ws mode, latency, TTFT + +Our portal cannot recover fields CPAMP never records, and should not invent +secret/raw payload fields. The useful adaptation is to present all safe fields +CPAMP already provides, plus a deterministic pricing projection using our Key +Policy prices. + +## CodexCont Protection Correlation And Key Identity + +This task does not join usage events to CodexCont request summaries. It does add +safe Key identity to CodexCont's own request summaries: + +- Add optional `[admin] key_policy_state_path`. +- On request start, parse `Authorization: Bearer ...` only long enough to hash + the key and match the Key Policy state. +- Store only a safe `key_identity` projection on diagnostics request summaries. +- If the key is missing, display `未携带 Key`; if unmatched, display + `未识别 Key` plus a safe hash preview. + +This gives the CodexCont table the same operator-friendly "who made this +request" context as `usage-admin` without introducing a cross-service join. + +## Visual Consistency + +Both custom dashboards use the same dark operations-console style: + +- remove the current `metric::after` crescent decoration +- keep 8px cards, restrained gradients, compact chips, fixed-width tables, and + dense but readable detail panels +- avoid decorative shapes that can be mistaken for broken chart widgets + +## Compatibility And Security + +- Existing `/api/events` and `/admin/api/events` fields stay compatible. +- New fields are additive. +- Redaction remains server-side. +- The frontend must not reconstruct costs from hidden raw event data. +- No raw request body, response body, authorization header, cookies, OAuth + token, full hash, management key, or encrypted reasoning content is exposed. + +## Rollout + +Local first: + +- run unit/smoke tests +- inspect the pages with desktop and narrow mobile widths if implementation + changes layout materially + +Server rollout later: + +- back up `/opt/codex-stacks/cpa-usage-portal` +- upload/rebuild/restart only `cpa-usage-portal` +- do not touch CPA, CPAMP, or Key Policy images/source +- do not use Docker prune or batch deletion diff --git a/.trellis/tasks/07-02-usage-admin-batch-save-request-details/implement.jsonl b/.trellis/tasks/07-02-usage-admin-batch-save-request-details/implement.jsonl new file mode 100644 index 0000000..9cd59d4 --- /dev/null +++ b/.trellis/tasks/07-02-usage-admin-batch-save-request-details/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/07-02-usage-admin-batch-save-request-details/implement.md b/.trellis/tasks/07-02-usage-admin-batch-save-request-details/implement.md new file mode 100644 index 0000000..bee609f --- /dev/null +++ b/.trellis/tasks/07-02-usage-admin-batch-save-request-details/implement.md @@ -0,0 +1,259 @@ +# Usage Admin Batch Save And Request Details Implementation Plan + +## Evidence Already Collected + +- `cpa_usage_portal/static/admin.html` currently renders `button data-save` + per key and calls `saveLimits(keyId)`. +- `cpa_usage_portal/app.py` currently exposes only + `PUT /admin/api/keys/{key_id}/limits` for limit updates. +- `cpa_usage_portal/static/dashboard.html` currently has a thin expanded + request row without itemized pricing. +- `cpa_usage_portal/pricing.py` currently computes only total cost and the + matched price model. +- Sub2API's usage types and billing service separate token and cost categories + into input, output, cache creation, cache read, total, actual, service tier, + reasoning effort, latency, and TTFT. + +## Implementation Checklist + +1. Add backend batch save route. + - Add `admin_update_limits_batch`. + - Route: `PUT /admin/api/keys/limits`. + - Validate list shape, ids, numeric limits. + - Save through `QuotaState.set_limits`. + - Return refreshed safe key projections. + +2. Refactor `usage-admin` frontend. + - Remove per-row `保存`. + - Add toolbar `保存全部`. + - Track loaded snapshot and current input values. + - Mark dirty state and disable save when clean. + - Keep reset buttons per row. + - Add saving/saved/error feedback. + +3. Add admin all-key events. + - Support `key_id=all` in `/admin/api/events`. + - Fetch each enabled key's CPAMP events, attach safe key summary, merge, + sort by timestamp descending, and cap by requested limit. + - Update admin UI default select option to `全部 Key`. + +4. Add pricing breakdown projection. + - Add a function in `pricing.py` that returns both total cost and itemized + breakdown from `ModelTokens` and `ModelPrice`. + - Reuse the existing `service_tier_multiplier`. + - Preserve existing `cost`, `cost_source`, and `price_model`. + - Add `cost_breakdown` to priced events. + +5. Extend safe event projection if needed. + - Keep only safe scalar fields. + - Include fields needed by breakdown that are already safe: + `cached_tokens`, `cache_read_tokens`, `cache_creation_tokens`, + `reasoning_tokens`, `total_tokens`, `service_tier`, `reasoning_effort`. + - Do not expose raw CPAMP event payloads. + +6. Add CodexCont key identity. + - Add optional `AdminCfg.key_policy_state_path`. + - Add a small read-only identity resolver that parses Key Policy state and + hashes `Authorization` bearer values without retaining the raw key. + - Pass safe identity into `Diagnostics.request_started`. + - Render a `用户/Key` column in the CodexCont request table. + +7. Redesign expanded details. + - Update `dashboard.html` `detailRow`. + - Add token composition, cost composition, and quota/accounting sections. + - Keep failure detail compact and redacted. + - Update `admin.html` event rendering to expose comparable detail or a + compact operator version. + +8. Unify visual style. + - Remove `metric::after` crescent decorations from custom dashboard CSS. + - Keep refresh/loading/live-state animations consistent. + +9. Tests. + - Batch save succeeds and persists all edited keys. + - Batch save rejects malformed values/unknown ids. + - Existing per-key route still works. + - Admin all-key events include key summaries and never leak raw hashes. + - CodexCont request summaries include safe key identity when configured. + - Event pricing breakdown sums to displayed total. + - Redaction still removes secret-like fields. + - HTML smoke checks for `保存全部`, dirty state markers, and cost breakdown + labels. + +10. Validation. + - `python -m compileall cpa_usage_portal run_usage_portal.py` + - `python -m compileall middleware run.py` + - `python tests/test_cpa_usage_portal.py` + - `python tests/test_middleware.py` + - `git status --short --branch` + +## Files Likely To Change + +- `cpa_usage_portal/app.py` +- `cpa_usage_portal/pricing.py` +- `cpa_usage_portal/redaction.py` +- `cpa_usage_portal/static/admin.html` +- `cpa_usage_portal/static/dashboard.html` +- `middleware/config.py` +- `middleware/diagnostics.py` +- `middleware/app.py` +- `middleware/dashboard.html` +- `tests/test_cpa_usage_portal.py` +- `tests/test_middleware.py` + +## Risks And Rollback + +- Pricing math risk: table total and breakdown must use the same backend + calculation. Do not let the frontend recalculate totals independently. +- Partial save risk: batch route should fail visibly on invalid input before + the UI claims success. +- Security risk: expanded details should not become a raw CPAMP event viewer. +- Rollback is simple because this affects only `cpa-usage-portal`; revert these + files or redeploy the previous portal container. + +## Implementation Notes 2026-07-02 + +- Added `PUT /admin/api/keys/limits` for all-key local 5H/month limit saves. + Validation runs for the full payload before any `QuotaState` write. +- Added `key_id=all` to `/admin/api/events`; it queries enabled Key Policy + records, attaches safe `key` summaries, merges newest-first, and keeps each + row's own accounting/reset context. +- Added backend-owned `cost_breakdown` on priced events. The table `cost` is + the same value as `cost_breakdown.costs.total`; frontends only render it. +- Added CodexCont `middleware/key_identity.py` plus optional + `[admin] key_policy_state_path`. It hashes the bearer key for matching and + stores only `known/name/id/preview/source/enabled`. +- Reworked `cpa_usage_portal/static/admin.html` to use a single global + `保存全部`, all-key default request view, safe `用户/Key` column, and detailed + token/cost/reasoning/accounting expansion. +- Reworked `cpa_usage_portal/static/dashboard.html` expansion to show the same + useful breakdown sections for ordinary users. +- Added a CodexCont request table `用户/Key` column and removed the metric-card + crescent decoration from both custom dashboards. +- Documented the optional `key_policy_state_path` in `config.toml`, + `README.md`, and `README_zh.md`. + +## Verification 2026-07-02 + +- `.venv/Scripts/python.exe -m compileall cpa_usage_portal run_usage_portal.py` + passed. +- `.venv/Scripts/python.exe -m compileall middleware run.py` passed. +- `.venv/Scripts/python.exe tests/test_cpa_usage_portal.py` passed: + 79/79 checks. +- `.venv/Scripts/python.exe tests/test_middleware.py` passed: + 152/152 checks. +- Node inline-script syntax check passed for: + `cpa_usage_portal/static/admin.html`, + `cpa_usage_portal/static/dashboard.html`, and `middleware/dashboard.html`. +- `playwright-cli --version` confirmed the global Mise-managed CLI is available + (`0.1.14`). +- `playwright-cli -s=codex run-code --filename=.../research/playwright-layout-check.js` + passed for desktop and 390px mobile views of `usage-admin`, + `usage-dashboard`, and `codexcont-dashboard`. +- Playwright initially caught `usage-admin/mobile` horizontal body overflow. + The fix was to make the topbar brand flex child shrinkable (`min-width: 0`) + and full-width on narrow screens; the same guard now covers all three custom + pages. + +## Deployment Notes 2026-07-02 + +- Server backup created at + `/root/codex-backups/20260702-usage-admin-batch-save/self-owned-services-before-deploy.tgz`. +- Uploaded and rebuilt only `codexcont` and `cpa-usage-portal`; CPA, CPAMP, and + CPA Key Policy official images/source were not modified. +- Server validation caught one deployment-only issue: CodexCont's + `key_policy_state_path` must point to a container-visible mount, not the host + `/opt/...` path. Fixed by mounting + `/opt/codex-stacks/cpa/plugin-state:/data/plugin-state:ro` into `codexcont` + and setting + `key_policy_state_path = "/data/plugin-state/cpa-key-policy-state.json"`. +- Post-deploy validation: + - `codexcont` and `cpa-usage-portal` containers are running. + - `cpa-usage-portal /healthz` reports `key_policy_state=true` and + `cpamp=true`. + - CodexCont `/admin/requests` shows current requests with known + Key Policy identity. + - `cpa-admin` internal proxy renders `usage-admin` with `保存全部`, + `全部 Key`, and `用户/Key`, and renders the CodexCont dashboard with + `用户/Key`. + - Public `cpa.konbakuyomu.us/admin/requests` and `/codexcont/` return `404`. + - Public `cpa-usage.konbakuyomu.us/admin/api/keys` and `/usage-admin/` + return `404`. + - Root filesystem remained at about `667M` free after rebuild; no Docker + prune or broad deletion was used. + +## Cache Semantics Follow-up 2026-07-02 + +- User reported that the custom usage detail displayed `Cache Read = 0` and + `Cache Write = 0` even though the main panel showed high cache hit behavior. +- CPAMP source confirms that its analytics API projects `cached_tokens` with a + compatibility expression: + `max(max(cached_tokens, cache_tokens) - cache_read_tokens - + cache_creation_tokens, 0)`. +- CPAMP's monitoring UI uses `cached_tokens + cache_read_tokens` as cache-hit + tokens for hit-rate display. Therefore OpenAI/Codex rows can correctly have + large `cached_tokens` and zero fine-grained cache read/write fields. +- The portal fix keeps CPA, CPAMP, and CPA Key Policy untouched. It updates only + our `cpa-usage-portal` pricing projection and static pages: + - backend `cost_breakdown.tokens` now exposes `cpamp_cached_input`, + `fine_grained_cache_read`, `fine_grained_cache_creation`, + `effective_cache_read_for_hit_rate`, `cache_hit_rate`, and + `cache_semantics`; + - frontend labels now show `CPAMP 缓存命中` separately from + `细粒度 Cache Read/Write`, with an inline note explaining the OpenAI/Codex + zero-read case; + - regression tests cover CPAMP-compatible cached rows and raw + `cache_tokens` normalization without double counting. + +## Verification Follow-up 2026-07-02 + +- `.venv/Scripts/python.exe -m compileall cpa_usage_portal run_usage_portal.py` + passed. +- `.venv/Scripts/python.exe tests/test_cpa_usage_portal.py` passed: + 84/84 checks. +- `.venv/Scripts/python.exe -m compileall middleware run.py` passed. +- `.venv/Scripts/python.exe tests/test_middleware.py` passed: + 152/152 checks. +- HTML script extraction syntax check passed for `cpa_usage_portal` admin/user + pages and `middleware/dashboard.html`. +- Playwright layout check passed for `usage-admin`, `usage-dashboard`, and + `codexcont-dashboard` on desktop and 390px mobile. Playwright first caught a + mobile clipped-chip risk in the CodexCont realtime status chip; it was fixed + by giving chips a stable 32px min-height and explicit line-height. + +## Deployment Follow-up 2026-07-02 + +- Pre-deploy SJC root disk: `9.6G` total, `8.9G` used, about `661M` free. + `docker system df` showed build cache available, but no Docker prune or broad + deletion was used. +- Backup created at + `/root/codex-backups/20260702-cache-semantics-fix/self-owned-files-before-cache-fix.tgz`. +- Uploaded only self-owned files: + - `/opt/codex-stacks/cpa-usage-portal/app/cpa_usage_portal/pricing.py` + - `/opt/codex-stacks/cpa-usage-portal/app/cpa_usage_portal/static/admin.html` + - `/opt/codex-stacks/cpa-usage-portal/app/cpa_usage_portal/static/dashboard.html` + - `/opt/codex-stacks/codexcont/app/middleware/dashboard.html` +- Rebuilt/restarted only `cpa-usage-portal` and `codexcont`; CPA, CPAMP, and + CPA Key Policy official images/source were not modified. +- Post-deploy validation: + - `cpa-usage-portal` container health returned + `{"ok": true, "key_policy_state": true, "cpamp": true}`. + - `http://127.0.0.1:8327/usage-admin/` contained `CPAMP 缓存命中` and + `细粒度 Cache Read`. + - `http://127.0.0.1:8327/usage-admin/api/events?key_id=all&range=24h&limit=5` + returned 5 events; one recent event projected + `cpamp_cached_input=201216`, `fine_grained_cache_read=0`, + `effective_cache_read_for_hit_rate=201216`, + `cache_hit_rate≈0.9935`, and + `cache_semantics=cpamp_compatible_cached_tokens`. + - `codexcont` `/admin/healthz` returned `200`, and + `/codexcont/requests?limit=5` returned request summaries with + `key_identity`. + - `https://cpa-usage.konbakuyomu.us/` returned `200` and contains the new + cache labels; `https://cpa-admin.konbakuyomu.us/usage-admin/` returned + Cloudflare Access `302`. + - Public `https://cpa.konbakuyomu.us/admin/requests`, + `/codexcont/`, and `/usage-admin/` stayed `404`; public + `https://cpa-usage.konbakuyomu.us/admin/api/keys` and `/usage-admin/` + stayed `404`. + - Post-deploy root disk remained about `659M` free. diff --git a/.trellis/tasks/07-02-usage-admin-batch-save-request-details/prd.md b/.trellis/tasks/07-02-usage-admin-batch-save-request-details/prd.md new file mode 100644 index 0000000..f04d946 --- /dev/null +++ b/.trellis/tasks/07-02-usage-admin-batch-save-request-details/prd.md @@ -0,0 +1,100 @@ +# Usage admin batch save and request details + +## Goal + +Improve the custom CPA usage portal so it is easier to operate multiple keys +and easier to understand what each request actually cost. + +The task stays inside our own `cpa-usage-portal` service. It must not fork or +modify CPA, CPAMP, or CPA Key Policy official source/images. + +## Requirements + +- On `usage-admin`, replace per-row limit save buttons with one global + `保存全部` action. +- Keep per-key soft reset actions (`清零当前` / `清零全部`) as explicit, + destructive actions with confirmation. +- Track unsaved 5H/month limit edits in the admin UI so the operator can see + whether the page is clean, dirty, saving, saved, or failed. +- Add a batch limits API in `cpa-usage-portal` so all edited key limits are + validated and saved in one request. +- Improve request expansion details in the user usage page and the admin + request view by learning from Sub2API's usage display model: + - token breakdown: input, output, cached input, cache read, cache creation, + total, reasoning tokens + - cost breakdown: input cost, cached input cost, cache read cost, cache + creation cost, output cost, subtotal/total, price model, pricing source, + service tier multiplier + - request metadata: request id, endpoint, status code, latency, TTFT, + requested/resolved model, service tier, reasoning effort + - quota/accounting context: which windows the request counts into, selected + window remaining amount, reset watermark/start point + - failure details: short visible reason by default, longer redacted detail + only inside the expanded panel +- Preserve the existing security boundary: + - never return request body, response body, raw API key, full key hash, + Authorization, cookies, OAuth token, CPA/CPAMP management key, or encrypted + reasoning content + - never display real chain-of-thought; only display safe metrics such as + reasoning token counts and reasoning effort +- Cache display must follow CPAMP main-panel semantics: `cached_tokens` is the + CPAMP-compatible cache-hit bucket for OpenAI/Codex, while + `cache_read_tokens` / `cache_creation_tokens` are fine-grained fields that + may legitimately be zero. +- Keep the existing dark, compact dashboard style. The new details should feel + like an operational breakdown, not a log dump. +- `usage-admin` 最近请求默认显示全部 Key 的请求,表格必须显示 + 用户/Key 摘要,并保留单 Key 筛选。 +- CodexCont 保护状态面板也要显示安全的用户/Key 归属;来源为可选 + Key Policy state,只做哈希匹配和安全预览,不保存原始 key。 +- 两个自定义页面必须统一视觉语言,并移除指标卡中的月牙形装饰。 + +## Acceptance Criteria + +- [x] `usage-admin` shows one global `保存全部` button and no per-row `保存` + buttons. +- [x] Editing any 5H/month limit marks the admin page dirty; saving persists + all edited keys and then refreshes the displayed quota windows. +- [x] Batch save validates malformed numeric limits and unknown key ids without + partially hiding errors. +- [x] Per-row soft reset actions still work and still warn that they only write + our local reset watermark. +- [x] `usage-admin` 最近请求默认是全部 Key;表格能分清每条请求属于哪个 + 用户/Key,并可筛选到单个 Key。 +- [x] `/api/events` and `/admin/api/events` continue to return existing + compatible fields and additionally include a safe `cost_breakdown` + projection when Key Policy pricing is available. +- [x] `/admin/api/events?key_id=all` returns merged events across enabled keys, + sorted newest first and capped by `limit`. +- [x] CodexCont `/admin/requests` and request SSE include safe `key_identity` + when Authorization can be identified. +- [x] Expanded request detail shows useful cost/token/reasoning/accounting + sections and no longer spends most of the space on low-value raw failure + blobs. +- [x] The cost shown in the table equals the sum of the cost breakdown parts + after service-tier multiplier. +- [x] The metric cards in both custom pages no longer show the crescent-shaped + decorative arc. +- [x] Tests cover batch save, pricing breakdown, redaction safety, and request + detail HTML markers. +- [x] `.venv/Scripts/python.exe -m compileall cpa_usage_portal run_usage_portal.py` passes. +- [x] `.venv/Scripts/python.exe tests/test_cpa_usage_portal.py` passes. + +## Notes + +- Confirmed from current code: `cpa_usage_portal/static/admin.html` has + per-row `data-save` buttons calling `saveLimits(keyId)`, and + `cpa_usage_portal/app.py` only has a per-key + `PUT /admin/api/keys/{key_id}/limits` route. +- Confirmed from current code: `cpa_usage_portal/static/dashboard.html` + expansion currently shows request id, endpoint, status code, service tier, + reasoning effort, provider quota, accounting windows, and redacted failure + text, but does not show itemized pricing. +- Confirmed from Sub2API reference: + `frontend/src/types/index.ts` models usage with input/output/cache creation/ + cache read token and cost fields, plus `total_cost`, `actual_cost`, + `rate_multiplier`, `service_tier`, `reasoning_effort`, request type, stream, + latency, and TTFT. Its backend `CostBreakdown` separates input/output/image/ + cache creation/cache read costs before summing total/actual cost. +- Product decision resolved on 2026-07-02: `usage-admin` 最近请求采用 + "全部 Key 默认,单 Key 可筛选"。 diff --git a/.trellis/tasks/07-02-usage-admin-batch-save-request-details/research/playwright-layout-check.js b/.trellis/tasks/07-02-usage-admin-batch-save-request-details/research/playwright-layout-check.js new file mode 100644 index 0000000..95670a9 --- /dev/null +++ b/.trellis/tasks/07-02-usage-admin-batch-save-request-details/research/playwright-layout-check.js @@ -0,0 +1,322 @@ +async page => { + const results = []; + const consoleErrors = []; + page.on("console", msg => { + if (msg.type() === "error") consoleErrors.push(msg.text()); + }); + page.on("pageerror", err => consoleErrors.push(err.message)); + + await page.addInitScript(() => { + const key = { + id: "alice-key", + name: "Alice", + enabled: true, + preview: "cpa_...live", + rpm: 12, + limits: { five_hour_usd: 2.5, daily_usd: 5, weekly_usd: 30, monthly_usd: 25 }, + reset_points: { "5h": 0, "24h": 0, "7d": 0, month: 0 }, + usage_windows: { + "5h": { range: "5h", used_usd: 0.22, limit_usd: 2.5, used_percent: 0.088, reset_at_ms: null }, + "24h": { range: "24h", used_usd: 0.46, limit_usd: 5, used_percent: 0.092, reset_at_ms: null }, + "7d": { range: "7d", used_usd: 1.8, limit_usd: 30, used_percent: 0.06, reset_at_ms: null }, + month: { range: "month", used_usd: 6.2, limit_usd: 25, used_percent: 0.248, reset_at_ms: null }, + }, + pricing: { + priced_model_count: 1, + models: [{ + model: "gpt-5.5", + input_per_million: 5, + output_per_million: 30, + cache_read_per_million: 0.5, + cache_creation_per_million: 5, + }], + }, + }; + const event = { + request_id: "req_visual_a", + event_hash: "evt_visual_a", + timestamp_ms: Date.now() - 10000, + model: "gpt-5.5", + requested_model: "gpt-5.5", + endpoint: "/v1/responses", + status: "success", + failed: false, + status_code: 200, + latency_ms: 1280, + ttft_ms: 320, + input_tokens: 1000, + output_tokens: 500, + cached_tokens: 800, + cache_read_tokens: 0, + cache_creation_tokens: 0, + reasoning_tokens: 320, + total_tokens: 1500, + cost: 0.016, + cost_source: "key_policy", + price_model: "gpt-5.5", + service_tier: "priority", + reasoning_effort: "high", + api_key_preview: "94c1ab2d...51ee22", + key: { id: "alice-key", name: "Alice", preview: "cpa_...live", enabled: true }, + cost_breakdown: { + source: "key_policy", + price_model: "gpt-5.5", + unit: "usd_per_1m_tokens", + service_tier: "priority", + service_tier_multiplier: 2.5, + prices: { + input_per_million: 5, + output_per_million: 30, + cache_read_per_million: 0.5, + cache_creation_per_million: 5, + }, + tokens: { + input: 1000, + cached_input: 800, + cpamp_cached_input: 800, + billable_uncached_input: 200, + cache_read: 0, + cache_creation: 0, + fine_grained_cache_read: 0, + fine_grained_cache_creation: 0, + effective_cache_read_for_hit_rate: 800, + cache_hit_rate: 0.8, + cache_semantics: "cpamp_compatible_cached_tokens", + total_cache_activity: 800, + output: 500, + reasoning: 320, + visible_output_estimate: 180, + total: 1500, + }, + costs: { + input: 0.0025, + cached_input: 0.001, + cache_read: 0, + cache_creation: 0, + output: 0.0375, + subtotal: 0.0164, + total: 0.041, + }, + }, + accounting: { + selected_range: "24h", + included_windows: ["5h", "24h", "7d", "month"], + window_from_ms: Date.now() - 86400000, + window_to_ms: Date.now(), + reset_at_ms: null, + current_window_limit_usd: 5, + current_window_remaining_usd: 4.54, + }, + quota: { used_percent: 8.2, plan: "pro" }, + failure_brief: "", + failure: "", + }; + const usage = { + range: "24h", + from_ms: Date.now() - 86400000, + to_ms: Date.now(), + quota: { limit_usd: 5, used_usd: 0.46, remaining_usd: 4.54, used_percent: 0.092 }, + summary: { + total_calls: 8, + success_calls: 8, + failure_calls: 0, + success_rate: 1, + total_cost: 0.46, + total_tokens: 18000, + cached_tokens: 13000, + output_tokens: 2600, + reasoning_tokens: 1800, + cost_source: "key_policy", + }, + timeline: [], + model_share: [{ model: "gpt-5.5", calls: 8, tokens: 18000, cost: 0.46 }], + model_stats: [], + api_key_stats: [], + }; + const codexRequest = { + request_id: "cc_visual_a", + model: "gpt-5.5", + path: "/v1/responses", + started_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ended_at: new Date().toISOString(), + duration_ms: 2100, + status: "completed", + protection: "protected_clean", + folded: true, + passthrough: false, + passthrough_reason: null, + key_identity: { known: true, source: "key_policy_state", id: "alice-key", name: "Alice", preview: "cpa_...live", enabled: true }, + rounds: [{ round: 1, reasoning_tokens: 320, n: null, decision: "clean", buffered: ["message"], truncation_match: false }], + latest_round: 1, + latest_reasoning_tokens: 320, + first_truncation_round: null, + first_truncation_reasoning_tokens: null, + first_truncation_n: null, + first_truncation_decision: null, + continuation_count: 0, + truncation_match: false, + final_status: "completed", + stopped_reason: "natural", + failure_reason: null, + failure_detail: null, + }; + window.EventSource = class { + constructor(url) { + this.url = url; + this.readyState = 1; + this.listeners = {}; + setTimeout(() => { + if (this.onopen) this.onopen({}); + this.dispatch("ready", { ok: true }); + }, 30); + } + addEventListener(name, fn) { + this.listeners[name] = this.listeners[name] || []; + this.listeners[name].push(fn); + } + dispatch(name, data) { + for (const fn of this.listeners[name] || []) fn({ data: JSON.stringify(data) }); + } + close() { + this.readyState = 2; + } + }; + window.fetch = async input => { + const url = String(input); + const ok = data => new Response(JSON.stringify(data), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + if (url.includes("/admin/api/keys/limits")) return ok({ ok: true, keys: [key] }); + if (url.includes("/admin/api/keys")) return ok({ keys: [key] }); + if (url.includes("/admin/api/events")) { + return ok({ + key_id: url.includes("key_id=all") ? "all" : "alice-key", + range: "24h", + from_ms: Date.now() - 86400000, + to_ms: Date.now(), + reset_at_ms: null, + quota: null, + events: [event], + }); + } + if (url.includes("/api/me")) return ok({ me: key }); + if (url.includes("/api/usage")) return ok(usage); + if (url.includes("/api/events")) return ok({ + range: "24h", + from_ms: usage.from_ms, + to_ms: usage.to_ms, + reset_at_ms: null, + quota: usage.quota, + events: [event], + has_more: false, + }); + if (url.includes("status")) { + return ok({ + ok: true, + uptime_seconds: 120, + counters: { + total_requests: 8, + active_requests: 0, + folded_requests: 8, + continuations: 1, + truncation_hits: 1, + failures: 0, + }, + upstream: { ok: true }, + config: { upstream_host: "cpa:8317" }, + last_request_at: new Date().toISOString(), + last_continuation_at: new Date().toISOString(), + last_error_at: null, + }); + } + if (url.includes("requests")) return ok({ requests: [codexRequest], max_requests: 200 }); + if (url.includes("logs")) return ok({ events: [], max_events: 800 }); + return ok({}); + }; + }); + + const pages = [ + { + name: "usage-admin", + url: "file:///D:/Dev/20_Software/23_Reference/llm-gateway/CodexCont/cpa_usage_portal/static/admin.html", + must: ["保存全部", "全部 Key", "用户/Key", "Alice"], + detail: ["Token 组成", "费用组成", "CPAMP 缓存命中"], + }, + { + name: "usage-dashboard", + url: "file:///D:/Dev/20_Software/23_Reference/llm-gateway/CodexCont/cpa_usage_portal/static/dashboard.html", + must: ["CPA 用量自助页", "模型分布", "最近请求", "Alice"], + detail: ["Token 组成", "费用组成", "CPAMP 缓存命中"], + }, + { + name: "codexcont-dashboard", + url: "file:///D:/Dev/20_Software/23_Reference/llm-gateway/CodexCont/middleware/dashboard.html", + must: ["CodexCont 保护状态面板", "用户/Key", "Alice", "cc_visual_a"], + detail: ["思维链保护判断", "身份来源"], + }, + ]; + const viewports = [ + { label: "desktop", width: 1440, height: 900 }, + { label: "mobile", width: 390, height: 844 }, + ]; + + for (const viewport of viewports) { + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + for (const target of pages) { + await page.goto(target.url); + await page.waitForTimeout(650); + for (const text of target.must) { + await page.getByText(text, { exact: false }).first().waitFor({ timeout: 5000 }); + } + const firstExpand = page.getByRole("button", { name: /展开/ }).first(); + if (await firstExpand.count()) { + await firstExpand.click(); + await page.waitForTimeout(120); + for (const text of target.detail) { + await page.getByText(text, { exact: false }).first().waitFor({ timeout: 5000 }); + } + } + if (target.name === "usage-admin") { + await page.locator('input[data-key="alice-key"][data-limit="5h"]').fill("3"); + await page.getByRole("button", { name: "保存全部" }).click(); + await page.waitForTimeout(250); + await page.waitForFunction(() => { + const button = document.querySelector("#saveAllBtn"); + const chip = document.querySelector("#saveChip"); + return button && button.disabled && chip && chip.textContent.includes("无改动"); + }, null, { timeout: 5000 }); + } + const metricsHaveArc = await page.evaluate(() => getComputedStyle(document.querySelector(".metric"), "::after").content !== "none"); + const layout = await page.evaluate(() => { + const root = document.scrollingElement || document.documentElement; + const badButtons = [...document.querySelectorAll("button, .chip, th")] + .map(el => { + const rect = el.getBoundingClientRect(); + return { + text: el.textContent.trim(), + width: rect.width, + height: rect.height, + scrollWidth: el.scrollWidth, + scrollHeight: el.scrollHeight, + }; + }) + .filter(item => item.text.length > 0 && item.width > 0 && item.height > 0) + .filter(item => item.scrollWidth > Math.ceil(item.width) + 2 || item.scrollHeight > Math.ceil(item.height) + 4); + return { + pageOverflow: root.scrollWidth > root.clientWidth + 2, + badButtons, + }; + }); + if (metricsHaveArc) throw new Error(`${target.name}/${viewport.label}: metric arc is still visible`); + if (layout.pageOverflow) throw new Error(`${target.name}/${viewport.label}: body has horizontal overflow`); + if (layout.badButtons.length) throw new Error(`${target.name}/${viewport.label}: clipped control text ${JSON.stringify(layout.badButtons.slice(0, 3))}`); + results.push(`${target.name}/${viewport.label}: ok`); + } + } + if (consoleErrors.length) { + throw new Error(`console errors: ${consoleErrors.join(" | ")}`); + } + return results; +} diff --git a/.trellis/tasks/07-02-usage-admin-batch-save-request-details/research/sub2api-usage-detail-patterns.md b/.trellis/tasks/07-02-usage-admin-batch-save-request-details/research/sub2api-usage-detail-patterns.md new file mode 100644 index 0000000..4b36123 --- /dev/null +++ b/.trellis/tasks/07-02-usage-admin-batch-save-request-details/research/sub2api-usage-detail-patterns.md @@ -0,0 +1,70 @@ +# Sub2API Usage Detail Patterns + +## Files Inspected + +- `D:/Dev/20_Software/23_Reference/llm-gateway/sub2api/frontend/src/views/KeyUsageView.vue` +- `D:/Dev/20_Software/23_Reference/llm-gateway/sub2api/frontend/src/api/usage.ts` +- `D:/Dev/20_Software/23_Reference/llm-gateway/sub2api/frontend/src/types/index.ts` +- `D:/Dev/20_Software/23_Reference/llm-gateway/sub2api/frontend/src/utils/usagePricing.ts` +- `D:/Dev/20_Software/23_Reference/llm-gateway/sub2api/frontend/src/utils/usageServiceTier.ts` +- `D:/Dev/20_Software/23_Reference/llm-gateway/sub2api/backend/internal/pkg/usagestats/usage_log_types.go` +- `D:/Dev/20_Software/23_Reference/llm-gateway/sub2api/backend/internal/service/billing_service.go` +- `D:/Dev/20_Software/23_Reference/llm-gateway/sub2api/backend/internal/service/usage_log.go` +- `D:/Dev/20_Software/23_Reference/llm-gateway/sub2api/backend/internal/handler/dto/mappers.go` + +## Useful Concepts To Borrow + +Sub2API's useful design is not a specific table layout. The useful part is the +data model: + +- request identity: request id, model, requested/upstream model, endpoint +- performance: duration, first token latency, stream/request type +- reasoning metadata: reasoning effort +- token categories: input, output, cache creation, cache read, total +- cost categories: input cost, output cost, cache creation cost, cache read + cost, total cost, actual cost +- billing context: service tier, rate multiplier, billing mode/type + +The user-facing DTO intentionally hides admin-only fields such as account rate +multiplier and account details. That matches our portal's security model: +show useful cost/token composition, but do not expose internal secrets or raw +payloads. + +## Cost Logic Shape + +Sub2API's backend `CostBreakdown` keeps separate fields: + +- `InputCost` +- `OutputCost` +- `ImageOutputCost` +- `CacheCreationCost` +- `CacheReadCost` +- `TotalCost` +- `ActualCost` +- `BillingMode` + +Its token calculation applies service-tier / long-context / cache multipliers +before summing total cost. For our portal, the closest safe adaptation is: + +- use Key Policy prices already available to the portal +- calculate itemized costs server-side +- expose the breakdown as an additive safe projection on each event +- keep the main table's cost equal to the breakdown total + +## Differences In Our Portal + +Our portal receives CPAMP monitoring events, not Sub2API's native usage log +schema. Therefore: + +- we can only show fields CPAMP records and Key Policy prices can price +- we should not invent `actual_cost` semantics unless we add a rate multiplier + model later +- we should label the cost as an estimate based on Key Policy prices +- we should not expose raw CPAMP event JSON + +## Recommended MVP + +Implement itemized token/cost breakdown from CPAMP safe fields plus Key Policy +prices. Defer cross-service CodexCont protection correlation unless the user +explicitly wants that extra scope in this task. + diff --git a/.trellis/tasks/07-02-usage-admin-batch-save-request-details/task.json b/.trellis/tasks/07-02-usage-admin-batch-save-request-details/task.json new file mode 100644 index 0000000..5677f84 --- /dev/null +++ b/.trellis/tasks/07-02-usage-admin-batch-save-request-details/task.json @@ -0,0 +1,26 @@ +{ + "id": "usage-admin-batch-save-request-details", + "name": "usage-admin-batch-save-request-details", + "title": "Usage admin batch save and request details", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "dxt98", + "assignee": "dxt98", + "createdAt": "2026-07-02", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "Completed: usage-admin now uses one batch save, all-key recent requests, richer request cost/token/reasoning details, and safe key identity in CodexCont. CPAMP-compatible cached_tokens semantics are documented and deployed: OpenAI/Codex cache hits can appear as large cached_tokens with zero fine-grained cache_read/cache_creation. Deployed only self-owned cpa-usage-portal and codexcont sidecars; CPA/CPAMP/Key Policy official artifacts were not modified.", + "meta": {} +} diff --git a/README.md b/README.md index 4e2fd2f..a30052f 100644 --- a/README.md +++ b/README.md @@ -120,8 +120,13 @@ It exposes service status, upstream health, in-memory request metrics, recent re ```toml [admin] max_log_events = 800 +key_policy_state_path = "" # optional; set to the CPA Key Policy state JSON to show safe key names ``` +When running in Docker, this must be a path visible inside the CodexCont +container. For example, mount the CPA plugin state read-only and set +`key_policy_state_path = "/data/plugin-state/cpa-key-policy-state.json"`. + Do not expose `/admin/` on a public API hostname unless it is protected by an external access layer such as Cloudflare Access. ## CPA Usage Portal diff --git a/README_zh.md b/README_zh.md index 4433161..e2d776e 100644 --- a/README_zh.md +++ b/README_zh.md @@ -127,8 +127,13 @@ http://127.0.0.1:8787/admin/ ```toml [admin] max_log_events = 800 +key_policy_state_path = "" # 可选:填 CPA Key Policy state JSON 路径后显示安全的 Key 名称 ``` +Docker 部署时这里必须填容器内部可读到的路径。例如把 CPA plugin state +只读挂载到容器内,然后设置 +`key_policy_state_path = "/data/plugin-state/cpa-key-policy-state.json"`。 + 不要把 `/admin/` 直接暴露在公网 API 域名上;生产环境应放在 Cloudflare Access 这类外层访问控制之后。 ## CPA 用量自助页 diff --git a/cpa_usage_portal/app.py b/cpa_usage_portal/app.py index 3fc14d8..e5a2ff5 100644 --- a/cpa_usage_portal/app.py +++ b/cpa_usage_portal/app.py @@ -4,6 +4,7 @@ import asyncio import contextlib import json +import math from pathlib import Path from typing import Any @@ -14,7 +15,7 @@ from starlette.routing import Route from .config import PortalConfig -from .cpamp import CPAMPClient, SUPPORTED_RANGES +from .cpamp import CPAMPClient, SUPPORTED_RANGES, range_window from .key_policy import KeyPolicyState, KeyRecord from .pricing import apply_event_pricing, apply_key_policy_pricing from .quota_state import QuotaState, RESET_WINDOWS @@ -75,6 +76,16 @@ def _safe_record(record: KeyRecord, quota: QuotaState) -> dict[str, Any]: return safe +def _safe_key_summary(record: KeyRecord) -> dict[str, Any]: + safe = record.safe_dict() + return { + "id": safe.get("id") or _record_id(record), + "name": safe.get("name") or "", + "preview": safe.get("preview") or "", + "enabled": bool(safe.get("enabled")), + } + + def _find_record(state: KeyPolicyState, key_id: str) -> KeyRecord | None: for record in state.keys: if _record_id(record) == key_id: @@ -112,7 +123,7 @@ def _parse_float_limit(value: Any) -> float | None: parsed = float(value) except (TypeError, ValueError): raise ValueError("invalid_limit") - if parsed < 0: + if parsed < 0 or not math.isfinite(parsed): raise ValueError("invalid_limit") return parsed @@ -223,6 +234,57 @@ def _attach_event_accounting( return projected +async def _events_for_record( + request: Request, + record: KeyRecord, + *, + range_name: str, + limit: int, +) -> tuple[list[dict[str, Any]], dict[str, Any]]: + quota_state = _quota(request) + window, reset_at = quota_state.effective_window(_record_id(record), range_name) + data = await request.app.state.cpamp.analytics( + api_key_hash=record.cpamp_hash, + window=window, + include_events=True, + include_model_stats=True, + event_limit=limit, + ) + data = apply_key_policy_pricing(data, record.model_prices) + quota_summary = _quota_projection( + record, + quota_state, + range_name=range_name, + window_from_ms=window.from_ms, + window_to_ms=window.to_ms, + reset_at_ms=reset_at, + used_usd=(data.get("summary") or {}).get("total_cost"), + ) + page = data.get("events") or {} + items = apply_event_pricing( + safe_events(page.get("items") or [], expected_hash=record.cpamp_hash), + record.model_prices, + ) + items = _attach_event_accounting( + items, + record=record, + quota=quota_state, + selected_range=range_name, + selected_quota=quota_summary, + now_ms_value=window.to_ms, + ) + key_summary = _safe_key_summary(record) + for item in items: + item["key"] = key_summary + return items, { + "range": range_name, + "from_ms": window.from_ms, + "to_ms": window.to_ms, + "reset_at_ms": reset_at, + "quota": quota_summary, + } + + async def dashboard(_request: Request) -> HTMLResponse: return HTMLResponse(_DASHBOARD.read_text(encoding="utf-8")) @@ -506,6 +568,46 @@ async def admin_update_limits(request: Request) -> JSONResponse: return JSONResponse({"me": _safe_record(record, quota_state)}) +async def admin_update_limits_batch(request: Request) -> JSONResponse: + blocked = _admin_guard(request) + if blocked is not None: + return blocked + try: + body = await request.json() + except json.JSONDecodeError: + return _json_error("invalid_json", 400) + items = (body or {}).get("limits") + if not isinstance(items, list): + return _json_error("invalid_limits", 400) + + state = _load_key_state(request) + validated: list[tuple[KeyRecord, float | None, float | None]] = [] + for index, item in enumerate(items): + if not isinstance(item, dict): + return _json_error(f"invalid_limit_item:{index}", 400) + key_id = str(item.get("id") or "").strip() + record = _find_record(state, key_id) + if record is None: + return _json_error(f"key_not_found:{key_id or index}", 404) + try: + five_hour = _parse_float_limit(item.get("five_hour_usd")) + monthly = _parse_float_limit(item.get("monthly_usd")) + except ValueError as exc: + return _json_error(f"{str(exc)}:{key_id}", 400) + validated.append((record, five_hour, monthly)) + + quota_state = _quota(request) + actor = _actor(request) + for record, five_hour, monthly in validated: + quota_state.set_limits( + _record_id(record), + five_hour_usd=five_hour, + monthly_usd=monthly, + actor=actor, + ) + return JSONResponse({"ok": True, "keys": [_safe_record(record, quota_state) for record, _, _ in validated]}) + + async def admin_reset_usage(request: Request) -> JSONResponse: blocked = _admin_guard(request) if blocked is not None: @@ -531,55 +633,39 @@ async def admin_events(request: Request) -> JSONResponse: blocked = _admin_guard(request) if blocked is not None: return blocked - key_id = request.query_params.get("key_id", "") + key_id = request.query_params.get("key_id", "all") or "all" state = _load_key_state(request) - record = _find_record(state, key_id) - if record is None: - return _json_error("key_not_found", 404) limit_raw = request.query_params.get("limit", "100") try: limit = max(1, min(int(limit_raw), 200)) except ValueError: limit = 100 range_name = _parse_range(request, default="24h") - quota_state = _quota(request) - window, reset_at = quota_state.effective_window(_record_id(record), range_name) - data = await request.app.state.cpamp.analytics( - api_key_hash=record.cpamp_hash, - window=window, - include_events=True, - include_model_stats=True, - event_limit=limit, - ) - data = apply_key_policy_pricing(data, record.model_prices) - quota_summary = _quota_projection( - record, - quota_state, - range_name=range_name, - window_from_ms=window.from_ms, - window_to_ms=window.to_ms, - reset_at_ms=reset_at, - used_usd=(data.get("summary") or {}).get("total_cost"), - ) - page = data.get("events") or {} - items = apply_event_pricing( - safe_events(page.get("items") or [], expected_hash=record.cpamp_hash), - record.model_prices, - ) - items = _attach_event_accounting( - items, - record=record, - quota=quota_state, - selected_range=range_name, - selected_quota=quota_summary, - now_ms_value=window.to_ms, - ) + if key_id == "all": + merged: list[dict[str, Any]] = [] + for record in state.enabled_keys(): + try: + items, _item_meta = await _events_for_record(request, record, range_name=range_name, limit=limit) + except Exception: + continue + merged.extend(items) + merged.sort(key=lambda item: int(item.get("timestamp_ms") or 0), reverse=True) + window = range_window(range_name) + meta = {"range": range_name, "from_ms": window.from_ms, "to_ms": window.to_ms, "reset_at_ms": None} + return JSONResponse({ + **meta, + "quota": None, + "key_id": "all", + "events": merged[:limit], + }) + + record = _find_record(state, key_id) + if record is None: + return _json_error("key_not_found", 404) + items, meta = await _events_for_record(request, record, range_name=range_name, limit=limit) return JSONResponse({ - "range": range_name, - "from_ms": window.from_ms, - "to_ms": window.to_ms, - "reset_at_ms": reset_at, - "quota": quota_summary, + **meta, + "key_id": _record_id(record), "events": items, }) @@ -613,6 +699,7 @@ async def lifespan(app: Starlette): Route("/api/events/stream", events_stream, methods=["GET"]), Route("/admin/", admin_dashboard, methods=["GET"]), Route("/admin/api/keys", admin_keys, methods=["GET"]), + Route("/admin/api/keys/limits", admin_update_limits_batch, methods=["PUT"]), Route("/admin/api/keys/{key_id:str}/limits", admin_update_limits, methods=["PUT"]), Route("/admin/api/keys/{key_id:str}/reset", admin_reset_usage, methods=["POST"]), Route("/admin/api/events", admin_events, methods=["GET"]), diff --git a/cpa_usage_portal/pricing.py b/cpa_usage_portal/pricing.py index 5222d90..2f7a7a6 100644 --- a/cpa_usage_portal/pricing.py +++ b/cpa_usage_portal/pricing.py @@ -14,10 +14,24 @@ class ModelTokens: input_tokens: int = 0 output_tokens: int = 0 cached_tokens: int = 0 + cache_tokens: int = 0 cache_read_tokens: int = 0 cache_creation_tokens: int = 0 +@dataclass(frozen=True) +class CacheProjection: + compatible_cached_tokens: int = 0 + raw_cached_tokens: int = 0 + raw_cache_tokens: int = 0 + cache_read_tokens: int = 0 + cache_creation_tokens: int = 0 + cache_hit_tokens: int = 0 + cache_input_side_tokens: int = 0 + cache_hit_rate: float = 0.0 + semantics: str = "cpamp_compatible_cached_tokens" + + def _number(value: Any) -> float: try: return float(value or 0) @@ -37,11 +51,45 @@ def tokens_from_row(row: dict[str, Any]) -> ModelTokens: input_tokens=_int(row.get("input_tokens") or row.get("inputTokens")), output_tokens=_int(row.get("output_tokens") or row.get("outputTokens")), cached_tokens=_int(row.get("cached_tokens") or row.get("cachedTokens")), + cache_tokens=_int(row.get("cache_tokens") or row.get("cacheTokens")), cache_read_tokens=_int(row.get("cache_read_tokens") or row.get("cacheReadTokens")), cache_creation_tokens=_int(row.get("cache_creation_tokens") or row.get("cacheCreationTokens")), ) +def cache_projection_for_tokens(tokens: ModelTokens) -> CacheProjection: + input_tokens = max(tokens.input_tokens, 0) + raw_cached_tokens = max(tokens.cached_tokens, 0) + raw_cache_tokens = max(tokens.cache_tokens, 0) + cache_read_tokens = max(tokens.cache_read_tokens, 0) + cache_creation_tokens = max(tokens.cache_creation_tokens, 0) + if raw_cache_tokens > 0: + cached_base = max(raw_cached_tokens, raw_cache_tokens) + compatible_cached_tokens = max(cached_base - cache_read_tokens - cache_creation_tokens, 0) + semantics = "raw_cache_tokens_normalized_to_cpamp" + else: + # CPAMP Management API already projects cached_tokens with its + # compatibility expression, so do not subtract fine-grained fields again. + compatible_cached_tokens = raw_cached_tokens + semantics = "cpamp_compatible_cached_tokens" + cache_hit_tokens = compatible_cached_tokens + cache_read_tokens + cache_input_side_tokens = max(input_tokens, compatible_cached_tokens) + cache_read_tokens + cache_creation_tokens + cache_hit_rate = 0.0 + if cache_input_side_tokens > 0: + cache_hit_rate = min(max(cache_hit_tokens / cache_input_side_tokens, 0.0), 1.0) + return CacheProjection( + compatible_cached_tokens=compatible_cached_tokens, + raw_cached_tokens=raw_cached_tokens, + raw_cache_tokens=raw_cache_tokens, + cache_read_tokens=cache_read_tokens, + cache_creation_tokens=cache_creation_tokens, + cache_hit_tokens=cache_hit_tokens, + cache_input_side_tokens=cache_input_side_tokens, + cache_hit_rate=cache_hit_rate, + semantics=semantics, + ) + + def price_for_model( price_book: dict[str, ModelPrice], *model_names: Any, @@ -59,22 +107,79 @@ def price_for_model( def cost_for_tokens(price: ModelPrice, tokens: ModelTokens, *, model: str = "", service_tier: str = "") -> float: + return cost_breakdown_for_tokens(price, tokens, model=model, service_tier=service_tier)["costs"]["total"] + + +def cost_breakdown_for_tokens( + price: ModelPrice, + tokens: ModelTokens, + *, + model: str = "", + service_tier: str = "", + reasoning_tokens: Any = 0, +) -> dict[str, Any]: input_tokens = max(tokens.input_tokens, 0) output_tokens = max(tokens.output_tokens, 0) - cached_tokens = max(tokens.cached_tokens, 0) - cache_read_tokens = max(tokens.cache_read_tokens, 0) - cache_creation_tokens = max(tokens.cache_creation_tokens, 0) + cache_projection = cache_projection_for_tokens(tokens) + cached_tokens = cache_projection.compatible_cached_tokens + cache_read_tokens = cache_projection.cache_read_tokens + cache_creation_tokens = cache_projection.cache_creation_tokens + reasoning = max(_int(reasoning_tokens), 0) prompt_tokens = max(input_tokens - cached_tokens, 0) cache_read_price = price.cache_read_per_million or price.input_per_million cache_creation_price = price.cache_creation_per_million or price.input_per_million - cost = ( - prompt_tokens * price.input_per_million / PER_MILLION - + output_tokens * price.output_per_million / PER_MILLION - + cached_tokens * (price.cache_read_per_million or price.input_per_million) / PER_MILLION - + cache_read_tokens * cache_read_price / PER_MILLION - + cache_creation_tokens * cache_creation_price / PER_MILLION - ) - return cost * service_tier_multiplier(model or price.model, service_tier) + input_cost = prompt_tokens * price.input_per_million / PER_MILLION + cached_cost = cached_tokens * cache_read_price / PER_MILLION + cache_read_cost = cache_read_tokens * cache_read_price / PER_MILLION + cache_creation_cost = cache_creation_tokens * cache_creation_price / PER_MILLION + output_cost = output_tokens * price.output_per_million / PER_MILLION + subtotal = input_cost + cached_cost + cache_read_cost + cache_creation_cost + output_cost + multiplier = service_tier_multiplier(model or price.model, service_tier) + return { + "source": "key_policy", + "price_model": model or price.model, + "unit": "usd_per_1m_tokens", + "service_tier": service_tier or "", + "service_tier_multiplier": multiplier, + "prices": { + "input_per_million": price.input_per_million, + "output_per_million": price.output_per_million, + "cache_read_per_million": cache_read_price, + "cache_creation_per_million": cache_creation_price, + }, + "tokens": { + "input": input_tokens, + "cached_input": cached_tokens, + "cpamp_cached_input": cached_tokens, + "raw_cached_input": cache_projection.raw_cached_tokens, + "raw_cache_tokens": cache_projection.raw_cache_tokens, + "billable_uncached_input": prompt_tokens, + "cache_read": cache_read_tokens, + "cache_creation": cache_creation_tokens, + "fine_grained_cache_read": cache_read_tokens, + "fine_grained_cache_creation": cache_creation_tokens, + "cache_hit_input": cache_projection.cache_hit_tokens, + "effective_cache_read_for_hit_rate": cache_projection.cache_hit_tokens, + "cache_input_side": cache_projection.cache_input_side_tokens, + "cache_hit_rate": cache_projection.cache_hit_rate, + "cache_semantics": cache_projection.semantics, + "total_cache_activity": cached_tokens + cache_read_tokens + cache_creation_tokens, + "output": output_tokens, + "reasoning": reasoning, + "visible_output_estimate": max(output_tokens - reasoning, 0), + "total": max(_int(input_tokens + output_tokens), 0), + }, + "costs": { + "input": input_cost * multiplier, + "cached_input": cached_cost * multiplier, + "cache_read": cache_read_cost * multiplier, + "cache_creation": cache_creation_cost * multiplier, + "cache_total": (cached_cost + cache_read_cost + cache_creation_cost) * multiplier, + "output": output_cost * multiplier, + "subtotal": subtotal, + "total": subtotal * multiplier, + }, + } def cost_for_row( @@ -83,11 +188,36 @@ def cost_for_row( *, model_fields: tuple[str, ...] = ("model", "resolved_model", "requested_model"), ) -> tuple[float, str] | None: + breakdown = cost_breakdown_for_row(row, price_book, model_fields=model_fields) + if breakdown is None: + return None + return _number((breakdown.get("costs") or {}).get("total")), str(breakdown.get("price_model") or "") + + +def cost_breakdown_for_row( + row: dict[str, Any], + price_book: dict[str, ModelPrice], + *, + model_fields: tuple[str, ...] = ("model", "resolved_model", "requested_model"), +) -> dict[str, Any] | None: matched = price_for_model(price_book, *(row.get(field) for field in model_fields)) if matched is None: return None model, price = matched - return cost_for_tokens(price, tokens_from_row(row), model=model, service_tier=str(row.get("service_tier") or "")), model + breakdown = cost_breakdown_for_tokens( + price, + tokens_from_row(row), + model=model, + service_tier=str(row.get("service_tier") or ""), + reasoning_tokens=row.get("reasoning_tokens"), + ) + explicit_total = _int(row.get("total_tokens") or row.get("totalTokens")) + if explicit_total: + breakdown = { + **breakdown, + "tokens": {**breakdown["tokens"], "total": explicit_total}, + } + return breakdown def service_tier_multiplier(model_name: str, service_tier: str) -> float: @@ -167,11 +297,11 @@ def apply_event_pricing(events: list[dict[str, Any]], price_book: dict[str, Mode projected: list[dict[str, Any]] = [] for event in events: row = dict(event) - priced = cost_for_row(row, price_book, model_fields=("model", "requested_model")) - if priced is not None: - cost, matched_model = priced - row["cost"] = cost + breakdown = cost_breakdown_for_row(row, price_book, model_fields=("model", "requested_model")) + if breakdown is not None: + row["cost"] = _number((breakdown.get("costs") or {}).get("total")) row["cost_source"] = "key_policy" - row["price_model"] = matched_model + row["price_model"] = breakdown.get("price_model") or "" + row["cost_breakdown"] = breakdown projected.append(row) return projected diff --git a/cpa_usage_portal/static/admin.html b/cpa_usage_portal/static/admin.html index f573268..1471496 100644 --- a/cpa_usage_portal/static/admin.html +++ b/cpa_usage_portal/static/admin.html @@ -84,6 +84,7 @@ background: rgba(17, 24, 39, .9); } .brand { display: flex; align-items: center; gap: 12px; min-width: 0; } + .brand > div { min-width: 0; } .mark { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 8px; background: linear-gradient(135deg, #2d86ef, #15c7d4); font-weight: 850; @@ -132,8 +133,19 @@ padding: 13px 10px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); background: #161e2d; vertical-align: middle; overflow: hidden; text-overflow: ellipsis; } - tbody tr td:first-child { border-left: 1px solid var(--line); border-radius: 8px 0 0 8px; } - tbody tr td:last-child { border-right: 1px solid var(--line); border-radius: 0 8px 8px 0; } + tbody tr.data-row td:first-child, + tbody tr.model-row td:first-child, + tbody tr:not(.detail-row) td:first-child { border-left: 1px solid var(--line); border-radius: 8px 0 0 8px; } + tbody tr.data-row td:last-child, + tbody tr.model-row td:last-child, + tbody tr:not(.detail-row) td:last-child { border-right: 1px solid var(--line); border-radius: 0 8px 8px 0; } + tbody tr.detail-row td { + border: 1px solid var(--line); + border-radius: 8px; + background: #111827; + overflow: visible; + text-overflow: clip; + } .mono { font-family: var(--mono); font-size: 12px; } .cell-stack { display: grid; gap: 5px; min-width: 0; } .quota { display: grid; gap: 4px; } @@ -141,6 +153,14 @@ .bar span { display: block; height: 100%; width: 0%; background: linear-gradient(90deg, var(--teal), var(--blue)); } .bar.danger span { background: linear-gradient(90deg, var(--amber), var(--red)); } .actions { display: flex; gap: 6px; flex-wrap: wrap; } + .detail-grid { display: grid; grid-template-columns: repeat(4, minmax(220px, 1fr)); gap: 12px; } + .detail-card { border: 1px solid var(--line); border-radius: 8px; background: #0d1422; padding: 12px; } + .detail-card h3 { margin: 0 0 10px; font-size: 13px; } + .detail-list { display: grid; grid-template-columns: 112px minmax(0, 1fr); gap: 7px 10px; line-height: 1.45; } + .detail-list span:nth-child(odd) { color: var(--muted); } + .detail-note { margin-top: 10px; color: var(--muted); font-size: 12px; line-height: 1.5; } + .failure-box { max-height: 160px; overflow: auto; white-space: pre-wrap; word-break: break-word; } + .dirty-row td { border-color: rgba(240, 170, 43, .48); } .empty { padding: 28px; color: var(--muted); text-align: center; background: transparent !important; border: 0 !important; } @keyframes spin { to { transform: rotate(360deg); } } @keyframes statusBlink { 0%, 100% { transform: scale(1); opacity: .72; } 50% { transform: scale(1.35); opacity: 1; } } @@ -149,9 +169,11 @@ @media (max-width: 720px) { .shell { width: min(100% - 18px, 1560px); padding-top: 10px; } .topbar, .section-head { align-items: flex-start; flex-direction: column; } + .brand { width: 100%; } .toolbar { justify-content: flex-start; width: 100%; } .metrics { grid-template-columns: 1fr; } - table { min-width: 1040px; } + .detail-grid { grid-template-columns: 1fr; } + table { min-width: 1180px; } } @@ -206,6 +228,10 @@

CPA 用量管理

Key 限额与窗口用量

日限/周限来自 Key Policy;5H/月限和清零点保存在自有 SQLite。

+
+ 无改动 + +
@@ -218,7 +244,7 @@

Key 限额与窗口用量

- + @@ -230,7 +256,7 @@

Key 限额与窗口用量

最近请求

-

选择一条 Key 后查看安全摘要。

+

默认显示全部 Key,可按单个用户/Key 排障。

@@ -239,16 +265,17 @@

最近请求

+ - + - +
本地限额操作操作
正在读取...
时间用户/Key 状态 模型 延迟 Tokens Reasoning 费用计入窗口详情
暂无请求记录
暂无请求记录
@@ -256,7 +283,7 @@

最近请求

` +} + +func userHTML() string { + return `CPA 用量

CPA 用量自助页

查看自己的额度、请求明细和思维链保护状态

实时轮询

` +} + +func sharedCSS() string { + return `:root{color-scheme:dark;--bg:#0b1020;--panel:#111827;--panel2:#0f172a;--line:#243047;--text:#e5e7eb;--muted:#94a3b8;--brand:#38bdf8;--ok:#22c55e;--bad:#fb7185;--warn:#fbbf24}*{box-sizing:border-box}body{margin:0;background:linear-gradient(180deg,#0b1020,#111827);color:var(--text);font:14px/1.5 ui-sans-serif,system-ui,Segoe UI,Arial}.shell{max-width:1180px;margin:0 auto;padding:24px}.top{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:18px}.top h1{margin:0;font-size:24px}.top p{margin:4px 0 0;color:var(--muted)}.toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}button{border:1px solid var(--line);background:#172033;color:var(--text);border-radius:8px;padding:9px 13px;cursor:pointer}button:hover{border-color:var(--brand)}.spin{animation:spin .8s linear infinite}.pulse{color:var(--ok);animation:pulse 1.4s ease-in-out infinite}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:14px}.grid article,.panel{background:rgba(17,24,39,.92);border:1px solid var(--line);border-radius:10px;box-shadow:0 12px 32px rgba(0,0,0,.25)}.grid article{padding:14px}.grid b{display:block;font-size:22px}.grid span,small{display:block;color:var(--muted);overflow-wrap:anywhere}.panel{padding:16px}.tabs{display:flex;gap:8px;margin-bottom:14px}.tabs .active{background:#0e7490;border-color:#38bdf8}table{width:100%;border-collapse:collapse;table-layout:fixed}th,td{border-bottom:1px solid var(--line);padding:10px;text-align:left;vertical-align:top;white-space:normal;overflow-wrap:break-word}th{color:#cbd5e1;font-size:12px;text-transform:uppercase}.chip{display:inline-block;border-radius:999px;padding:2px 8px;background:#334155}.chip.ok{color:#86efac}.chip.bad{color:#fecdd3}.chip.warn{color:#fde68a}.detail{background:#0f172a;border:1px solid var(--line);border-radius:8px;padding:14px}.detail h2{font-size:18px;margin:0 0 10px}.detail dl{display:grid;grid-template-columns:140px 1fr;gap:8px 12px}.detail dt{color:#94a3b8}.detail dd{margin:0;overflow-wrap:anywhere}input{width:min(520px,100%);padding:11px;border-radius:8px;border:1px solid var(--line);background:#0b1220;color:var(--text);margin-right:8px}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{0%,100%{opacity:.55}50%{opacity:1}}@media(max-width:620px){.shell{padding:14px}.top{align-items:flex-start;flex-direction:column}.panel{overflow-x:auto}table{min-width:860px;font-size:12px}th,td{padding:8px;white-space:normal;overflow-wrap:break-word}.tabs{min-width:max-content;overflow:auto}.detail dl{grid-template-columns:1fr}}` +} diff --git a/cpa_governor_plugin/go/main_test.go b/cpa_governor_plugin/go/main_test.go new file mode 100644 index 0000000..a4fbaa5 --- /dev/null +++ b/cpa_governor_plugin/go/main_test.go @@ -0,0 +1,262 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "codexcont/cpa-governor-plugin/internal/governor" +) + +func configureTestState(t *testing.T) governor.KeyRecord { + t.Helper() + store, err := governor.OpenStore(filepath.Join(t.TempDir(), "governor.sqlite")) + if err != nil { + t.Fatal(err) + } + key := governor.KeyRecord{ + ID: "alice-key", + Name: "Alice", + KeyHash: "sha256:" + governor.SHA256Hex("cpa_live"), + Enabled: true, + Preview: governor.HashPreview(governor.SHA256Hex("cpa_live")), + RPM: 60, + Concurrency: 2, + Models: []string{"gpt-5.5"}, + Prices: map[string]governor.ModelPrice{ + "gpt-5.5": { + Model: "gpt-5.5", + InputPerMillion: 5, + OutputPerMillion: 30, + CacheReadPerMillion: 0.5, + }, + }, + } + if err := store.UpsertKey(context.Background(), key); err != nil { + t.Fatal(err) + } + state.mu.Lock() + state.cfg = governor.DefaultConfig() + state.cfg.SessionSecret = "secret" + state.store = store + state.keyState = governor.KeyPolicyState{} + state.rpmBuckets = map[string][]time.Time{} + state.concurrency = map[string]int{} + state.mu.Unlock() + t.Cleanup(func() { + state.mu.Lock() + if state.store != nil { + _ = state.store.Close() + state.store = nil + } + state.mu.Unlock() + }) + return key +} + +func unwrapEnvelope(t *testing.T, raw []byte, out any) { + t.Helper() + var env envelope + if err := json.Unmarshal(raw, &env); err != nil { + t.Fatal(err) + } + if !env.OK { + t.Fatalf("envelope error: %#v", env.Error) + } + if err := json.Unmarshal(env.Result, out); err != nil { + t.Fatal(err) + } +} + +func TestPluginRegistrationUsesLocalABI(t *testing.T) { + raw, err := handleMethod(methodPluginRegister, nil) + if err != nil { + t.Fatal(err) + } + var reg registration + unwrapEnvelope(t, raw, ®) + if reg.SchemaVersion != schemaVersion || !reg.Capabilities.ManagementAPI || !reg.Capabilities.UsagePlugin { + t.Fatalf("registration = %#v", reg) + } + if len(reg.Metadata.ConfigFields) == 0 { + t.Fatal("config fields should be exposed") + } + + var env envelope + if err := json.Unmarshal(raw, &env); err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(env.Result, &payload); err != nil { + t.Fatal(err) + } + caps, _ := payload["capabilities"].(map[string]any) + for _, key := range []string{"frontend_auth_provider", "model_router", "executor", "usage_plugin", "management_api"} { + if caps[key] != true { + t.Fatalf("capability %s missing or false in RPC registration: %s", key, string(env.Result)) + } + } + if _, ok := caps["FrontendAuthProvider"]; ok { + t.Fatalf("unexpected Go-style capability field in RPC registration: %s", string(env.Result)) + } +} + +func TestManagementRegisterUsesCPARPCSchema(t *testing.T) { + raw, err := managementRegister() + if err != nil { + t.Fatal(err) + } + var env envelope + if err := json.Unmarshal(raw, &env); err != nil { + t.Fatal(err) + } + if !env.OK { + t.Fatalf("envelope error: %#v", env.Error) + } + var payload map[string]any + if err := json.Unmarshal(env.Result, &payload); err != nil { + t.Fatal(err) + } + if _, ok := payload["routes"]; !ok { + t.Fatalf("missing lowercase routes field: %s", string(env.Result)) + } + if _, ok := payload["resources"]; !ok { + t.Fatalf("missing lowercase resources field: %s", string(env.Result)) + } + if _, ok := payload["Routes"]; ok { + t.Fatalf("unexpected uppercase Routes field: %s", string(env.Result)) + } + if _, ok := payload["Resources"]; ok { + t.Fatalf("unexpected uppercase Resources field: %s", string(env.Result)) + } +} + +func TestFrontendAuthAcceptsManagedKeyAndRejectsDisallowedModel(t *testing.T) { + configureTestState(t) + authBody, _ := json.Marshal(frontendAuthRequest{ + Headers: http.Header{"Authorization": []string{"Bearer cpa_live"}}, + Body: []byte(`{"model":"gpt-5.5","stream":true}`), + }) + raw, err := frontendAuth(authBody) + if err != nil { + t.Fatal(err) + } + var resp frontendAuthResponse + unwrapEnvelope(t, raw, &resp) + if !resp.Authenticated || resp.Principal != "alice-key" { + t.Fatalf("auth response = %#v", resp) + } + releaseConcurrency("alice-key") + + disallowed, _ := json.Marshal(frontendAuthRequest{ + Headers: http.Header{"Authorization": []string{"Bearer cpa_live"}}, + Body: []byte(`{"model":"other-model","stream":true}`), + }) + raw, err = frontendAuth(disallowed) + if err != nil { + t.Fatal(err) + } + unwrapEnvelope(t, raw, &resp) + if resp.Authenticated { + t.Fatalf("disallowed model authenticated: %#v", resp) + } +} + +func TestUsageHandleStoresCostAndReleasesConcurrency(t *testing.T) { + key := configureTestState(t) + if !acquireConcurrency(key) { + t.Fatal("expected concurrency acquire") + } + rec := usageRecord{ + Model: "gpt-5.5", + Alias: "gpt-5.5", + APIKey: "alice-key", + RequestedAt: time.Now(), + Latency: 1500 * time.Millisecond, + Detail: usageDetail{ + InputTokens: 100, + CachedTokens: 20, + OutputTokens: 50, + ReasoningTokens: 30, + TotalTokens: 150, + }, + } + rawRec, _ := json.Marshal(rec) + if _, err := usageHandle(rawRec); err != nil { + t.Fatal(err) + } + if state.concurrency["alice-key"] != 0 { + t.Fatalf("concurrency not released: %d", state.concurrency["alice-key"]) + } + events, err := loadedStore().RecentEvents(context.Background(), "alice-key", 10) + if err != nil { + t.Fatal(err) + } + if len(events) != 1 || events[0].Cost <= 0 || events[0].Usage.ReasoningTokens != 30 { + t.Fatalf("events = %#v", events) + } +} + +func TestRefreshKeyPolicyStateImportsNewKeys(t *testing.T) { + dir := t.TempDir() + store, err := governor.OpenStore(filepath.Join(dir, "governor.sqlite")) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "policy.json") + writePolicy := func(id, rawKey string) { + t.Helper() + body := map[string]any{"keys": []map[string]any{{ + "id": id, + "name": id, + "key_hash": "sha256:" + governor.SHA256Hex(rawKey), + "enabled": true, + }}} + raw, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + } + writePolicy("alice", "cpa_alice") + + state.mu.Lock() + state.cfg = governor.DefaultConfig() + state.store = store + state.keyState = governor.KeyPolicyState{} + state.keyStatePath = path + state.keyStateModTime = time.Time{} + state.keyStateLastCheck = time.Time{} + state.rpmBuckets = map[string][]time.Time{} + state.concurrency = map[string]int{} + state.mu.Unlock() + t.Cleanup(func() { + state.mu.Lock() + if state.store != nil { + _ = state.store.Close() + state.store = nil + } + state.mu.Unlock() + }) + + if err := refreshKeyPolicyState(true); err != nil { + t.Fatal(err) + } + if key, ok := findKeyByRaw("cpa_alice"); !ok || key.ID != "alice" { + t.Fatalf("alice not imported: %#v ok=%v", key, ok) + } + + writePolicy("bob", "cpa_bob") + if err := refreshKeyPolicyState(true); err != nil { + t.Fatal(err) + } + if key, ok := findKeyByRaw("cpa_bob"); !ok || key.ID != "bob" { + t.Fatalf("bob not imported after refresh: %#v ok=%v", key, ok) + } +} diff --git a/cpa_governor_plugin/go/plugin_export.go b/cpa_governor_plugin/go/plugin_export.go new file mode 100644 index 0000000..f0db1d4 --- /dev/null +++ b/cpa_governor_plugin/go/plugin_export.go @@ -0,0 +1,166 @@ +//go:build cliproxy_plugin + +package main + +/* +#include +#include + +typedef struct { + void* ptr; + size_t len; +} cliproxy_buffer; + +typedef int (*cliproxy_host_call_fn)(void*, const char*, const uint8_t*, size_t, cliproxy_buffer*); +typedef void (*cliproxy_host_free_fn)(void*, size_t); + +typedef struct { + uint32_t abi_version; + void* host_ctx; + cliproxy_host_call_fn call; + cliproxy_host_free_fn free_buffer; +} cliproxy_host_api; + +typedef int (*cliproxy_plugin_call_fn)(char*, uint8_t*, size_t, cliproxy_buffer*); +typedef void (*cliproxy_plugin_free_fn)(void*, size_t); +typedef void (*cliproxy_plugin_shutdown_fn)(void); + +typedef struct { + uint32_t abi_version; + cliproxy_plugin_call_fn call; + cliproxy_plugin_free_fn free_buffer; + cliproxy_plugin_shutdown_fn shutdown; +} cliproxy_plugin_api; + +extern int cliproxyPluginCall(char*, uint8_t*, size_t, cliproxy_buffer*); +extern void cliproxyPluginFree(void*, size_t); +extern void cliproxyPluginShutdown(void); + +static const cliproxy_host_api* stored_host; + +static void store_host_api(const cliproxy_host_api* host) { + stored_host = host; +} + +static int call_host_api(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) { + if (stored_host == NULL || stored_host->call == NULL) { + return 1; + } + return stored_host->call(stored_host->host_ctx, method, request, request_len, response); +} + +static void free_host_buffer(void* ptr, size_t len) { + if (stored_host != NULL && stored_host->free_buffer != NULL && ptr != NULL) { + stored_host->free_buffer(ptr, len); + } +} +*/ +import "C" + +import ( + "encoding/json" + "fmt" + "unsafe" +) + +//export cliproxy_plugin_init +func cliproxy_plugin_init(host *C.cliproxy_host_api, plugin *C.cliproxy_plugin_api) C.int { + if plugin == nil { + return 1 + } + C.store_host_api(host) + hostCall = cgoHostCall + plugin.abi_version = C.uint32_t(abiVersion) + plugin.call = C.cliproxy_plugin_call_fn(C.cliproxyPluginCall) + plugin.free_buffer = C.cliproxy_plugin_free_fn(C.cliproxyPluginFree) + plugin.shutdown = C.cliproxy_plugin_shutdown_fn(C.cliproxyPluginShutdown) + return 0 +} + +//export cliproxyPluginCall +func cliproxyPluginCall(method *C.char, request *C.uint8_t, requestLen C.size_t, response *C.cliproxy_buffer) C.int { + if response != nil { + response.ptr = nil + response.len = 0 + } + if method == nil { + writeCResponse(response, errorEnvelope("invalid_method", "method is required")) + return 1 + } + var requestBytes []byte + if request != nil && requestLen > 0 { + requestBytes = C.GoBytes(unsafe.Pointer(request), C.int(requestLen)) + } + raw, errHandle := handleMethod(C.GoString(method), requestBytes) + if errHandle != nil { + writeCResponse(response, errorEnvelope("plugin_error", errHandle.Error())) + return 1 + } + writeCResponse(response, raw) + return 0 +} + +//export cliproxyPluginFree +func cliproxyPluginFree(ptr unsafe.Pointer, _ C.size_t) { + if ptr != nil { + C.free(ptr) + } +} + +//export cliproxyPluginShutdown +func cliproxyPluginShutdown() { + shutdownPlugin() +} + +func writeCResponse(response *C.cliproxy_buffer, raw []byte) { + if response == nil || len(raw) == 0 { + return + } + ptr := C.CBytes(raw) + if ptr == nil { + return + } + response.ptr = ptr + response.len = C.size_t(len(raw)) +} + +func cgoHostCall(method string, payload any) (json.RawMessage, error) { + rawPayload, err := json.Marshal(payload) + if err != nil { + return nil, err + } + cMethod := C.CString(method) + defer C.free(unsafe.Pointer(cMethod)) + var response C.cliproxy_buffer + var requestPtr *C.uint8_t + if len(rawPayload) > 0 { + cPayload := C.CBytes(rawPayload) + if cPayload == nil { + return nil, fmt.Errorf("allocate host callback") + } + defer C.free(cPayload) + requestPtr = (*C.uint8_t)(cPayload) + } + callCode := C.call_host_api(cMethod, requestPtr, C.size_t(len(rawPayload)), &response) + var rawResponse []byte + if response.ptr != nil && response.len > 0 { + rawResponse = C.GoBytes(response.ptr, C.int(response.len)) + } + if response.ptr != nil { + C.free_host_buffer(response.ptr, response.len) + } + if callCode != 0 || len(rawResponse) == 0 { + return nil, fmt.Errorf("host callback %s returned code=%d", method, int(callCode)) + } + var env envelope + if err := json.Unmarshal(rawResponse, &env); err != nil { + return nil, err + } + if !env.OK { + if env.Error != nil { + return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message) + } + return nil, fmt.Errorf("host callback failed") + } + return env.Result, nil +} diff --git a/cpa_governor_plugin/go/plugin_types.go b/cpa_governor_plugin/go/plugin_types.go new file mode 100644 index 0000000..991e717 --- /dev/null +++ b/cpa_governor_plugin/go/plugin_types.go @@ -0,0 +1,231 @@ +package main + +import ( + "net/http" + "net/url" + "time" +) + +const ( + abiVersion uint32 = 1 + schemaVersion uint32 = 1 + + methodPluginRegister = "plugin.register" + methodPluginReconfigure = "plugin.reconfigure" + methodFrontendAuthIdentifier = "frontend_auth.identifier" + methodFrontendAuthAuthenticate = "frontend_auth.authenticate" + methodModelRoute = "model.route" + methodExecutorIdentifier = "executor.identifier" + methodExecutorExecute = "executor.execute" + methodExecutorExecuteStream = "executor.execute_stream" + methodExecutorCountTokens = "executor.count_tokens" + methodUsageHandle = "usage.handle" + methodManagementRegister = "management.register" + methodManagementHandle = "management.handle" + methodHostModelExecute = "host.model.execute" + methodHostModelExecuteStream = "host.model.execute_stream" + methodHostModelStreamRead = "host.model.stream_read" + methodHostModelStreamClose = "host.model.stream_close" + methodHostStreamEmit = "host.stream.emit" + methodHostStreamClose = "host.stream.close" +) + +const ( + configString = "string" + configBoolean = "boolean" + configEnum = "enum" + + routeTargetSelf = "self" +) + +type configField struct { + Name string `json:"Name"` + Type string `json:"Type"` + EnumValues []string `json:"EnumValues,omitempty"` + Description string `json:"Description"` +} + +type frontendAuthRequest struct { + Method string `json:"Method"` + Path string `json:"Path"` + Headers http.Header `json:"Headers"` + Query url.Values `json:"Query"` + Body []byte `json:"Body"` +} + +type frontendAuthResponse struct { + Authenticated bool `json:"Authenticated"` + Principal string `json:"Principal,omitempty"` + Metadata map[string]string `json:"Metadata,omitempty"` +} + +type modelRouteRequest struct { + PluginID string `json:"PluginID"` + SourceFormat string `json:"SourceFormat"` + RequestedModel string `json:"RequestedModel"` + Stream bool `json:"Stream"` + Headers http.Header `json:"Headers"` + Query url.Values `json:"Query"` + Body []byte `json:"Body"` + Metadata map[string]any `json:"Metadata"` + AvailableProviders []string `json:"AvailableProviders"` +} + +type modelRouteResponse struct { + Handled bool `json:"Handled"` + TargetKind string `json:"TargetKind,omitempty"` + Target string `json:"Target,omitempty"` + TargetModel string `json:"TargetModel,omitempty"` + Reason string `json:"Reason,omitempty"` +} + +type managementRegistrationResponse struct { + Routes []managementRoute `json:"routes,omitempty"` + Resources []resourceRoute `json:"resources,omitempty"` +} + +type managementRoute struct { + Method string `json:"Method"` + Path string `json:"Path"` + Menu string `json:"Menu,omitempty"` + Description string `json:"Description,omitempty"` +} + +type resourceRoute struct { + Path string `json:"Path"` + Menu string `json:"Menu,omitempty"` + Description string `json:"Description,omitempty"` +} + +type managementRequest struct { + Method string `json:"Method"` + Path string `json:"Path"` + Headers http.Header `json:"Headers"` + Query url.Values `json:"Query"` + Body []byte `json:"Body"` + HostCallbackID string `json:"host_callback_id,omitempty"` +} + +type managementResponse struct { + StatusCode int `json:"StatusCode"` + Headers http.Header `json:"Headers"` + Body []byte `json:"Body"` +} + +type executorResponse struct { + Payload []byte `json:"Payload"` + Headers http.Header `json:"Headers,omitempty"` +} + +type usageRecord struct { + Provider string `json:"Provider"` + ExecutorType string `json:"ExecutorType"` + Model string `json:"Model"` + Alias string `json:"Alias"` + APIKey string `json:"APIKey"` + AuthID string `json:"AuthID"` + AuthIndex string `json:"AuthIndex"` + AuthType string `json:"AuthType"` + Source string `json:"Source"` + ReasoningEffort string `json:"ReasoningEffort"` + ServiceTier string `json:"ServiceTier"` + RequestedAt time.Time `json:"RequestedAt"` + Latency time.Duration `json:"Latency"` + TTFT time.Duration `json:"TTFT"` + Failed bool `json:"Failed"` + Failure usageFailure `json:"Failure"` + Detail usageDetail `json:"Detail"` + ResponseHeaders http.Header `json:"ResponseHeaders"` +} + +type executorRequest struct { + AuthID string `json:"AuthID"` + AuthProvider string `json:"AuthProvider"` + Model string `json:"Model"` + Format string `json:"Format"` + Stream bool `json:"Stream"` + Alt string `json:"Alt"` + Headers http.Header `json:"Headers"` + Query url.Values `json:"Query"` + OriginalRequest []byte `json:"OriginalRequest"` + SourceFormat string `json:"SourceFormat"` + Payload []byte `json:"Payload"` + Metadata map[string]any `json:"Metadata"` + StorageJSON []byte `json:"StorageJSON"` + AuthMetadata map[string]any `json:"AuthMetadata"` + AuthAttributes map[string]string `json:"AuthAttributes"` +} + +type executorCallRequest struct { + ExecutorRequest executorRequest `json:"ExecutorRequest"` + StreamID string `json:"stream_id,omitempty"` + HostCallbackID string `json:"host_callback_id,omitempty"` +} + +type executorStreamResponse struct { + Headers http.Header `json:"headers,omitempty"` +} + +type hostModelExecutionRequest struct { + EntryProtocol string `json:"entry_protocol"` + ExitProtocol string `json:"exit_protocol"` + Model string `json:"model"` + Stream bool `json:"stream"` + Body []byte `json:"body"` + Headers http.Header `json:"headers"` + Query url.Values `json:"query"` + Alt string `json:"alt,omitempty"` + HostCallbackID string `json:"host_callback_id,omitempty"` +} + +type hostModelExecutionResponse struct { + StatusCode int `json:"status_code"` + Headers http.Header `json:"headers"` + Body []byte `json:"body"` +} + +type hostModelStreamResponse struct { + StatusCode int `json:"status_code"` + Headers http.Header `json:"headers"` + StreamID string `json:"stream_id"` +} + +type hostModelStreamReadRequest struct { + StreamID string `json:"stream_id"` +} + +type hostModelStreamReadResponse struct { + Payload []byte `json:"payload"` + Error string `json:"error"` + Done bool `json:"done"` +} + +type hostModelStreamCloseRequest struct { + StreamID string `json:"stream_id"` +} + +type hostStreamEmitRequest struct { + StreamID string `json:"stream_id"` + Payload []byte `json:"payload,omitempty"` + Error string `json:"error,omitempty"` +} + +type hostStreamCloseRequest struct { + StreamID string `json:"stream_id"` + Error string `json:"error,omitempty"` +} + +type usageFailure struct { + StatusCode int `json:"StatusCode"` + Body string `json:"Body"` +} + +type usageDetail struct { + InputTokens int64 `json:"InputTokens"` + OutputTokens int64 `json:"OutputTokens"` + ReasoningTokens int64 `json:"ReasoningTokens"` + CachedTokens int64 `json:"CachedTokens"` + CacheReadTokens int64 `json:"CacheReadTokens"` + CacheCreationTokens int64 `json:"CacheCreationTokens"` + TotalTokens int64 `json:"TotalTokens"` +} diff --git a/middleware/app.py b/middleware/app.py index 2fc5e74..37526ec 100644 --- a/middleware/app.py +++ b/middleware/app.py @@ -28,6 +28,7 @@ admin_redirect, admin_status, ) +from .engine import engine_analyze, engine_healthz from .codex import ( build_round_payload, declares_continue_tool, @@ -312,6 +313,8 @@ async def lifespan(app: Starlette): await app.state.client.aclose() routes = [ + Route("/engine/healthz", engine_healthz, methods=["GET"]), + Route("/engine/v1/responses/analyze", engine_analyze, methods=["POST"]), Route("/admin", admin_redirect, methods=["GET"]), Route("/admin/", admin_dashboard, methods=["GET"]), Route("/admin/healthz", admin_healthz, methods=["GET"]), diff --git a/middleware/engine.py b/middleware/engine.py new file mode 100644 index 0000000..382180a --- /dev/null +++ b/middleware/engine.py @@ -0,0 +1,159 @@ +"""Internal CodexCont engine API. + +The engine endpoints are deliberately narrower than the public proxy path. They +return only safe protection summaries that a CPA plugin can persist or display. +""" +from __future__ import annotations + +import json +import time +from dataclasses import asdict, dataclass +from typing import Any + +from starlette.requests import Request +from starlette.responses import JSONResponse + +from .codex import is_truncation_pattern, tier_n +from .diagnostics import redact_value + + +STARTED_AT = time.time() + + +@dataclass(frozen=True) +class EngineRoundSummary: + round: int + reasoning_tokens: int | None = None + truncation_match: bool = False + truncation_n: int | None = None + decision: str = "unknown" + + +def _int_or_none(value: Any) -> int | None: + try: + if value is None or value == "": + return None + return int(value) + except (TypeError, ValueError): + return None + + +def _string(value: Any, default: str = "") -> str: + text = str(value or "").strip() + return text or default + + +def _extract_rounds(payload: dict[str, Any]) -> list[EngineRoundSummary]: + raw_rounds = payload.get("rounds") + rounds: list[EngineRoundSummary] = [] + if isinstance(raw_rounds, list): + for idx, item in enumerate(raw_rounds, start=1): + if not isinstance(item, dict): + continue + round_no = _int_or_none(item.get("round") or item.get("round_no")) or idx + tokens = _int_or_none( + item.get("reasoning_tokens") + or item.get("reasoningTokens") + or item.get("output_tokens_details", {}).get("reasoning_tokens") + ) + trunc = bool(item.get("truncation_match") or is_truncation_pattern(tokens)) + rounds.append( + EngineRoundSummary( + round=round_no, + reasoning_tokens=tokens, + truncation_match=trunc, + truncation_n=tier_n(tokens) if trunc else None, + decision=_string(item.get("decision"), "continue" if trunc else "clean"), + ) + ) + if not rounds: + tokens = _int_or_none( + payload.get("reasoning_tokens") + or payload.get("reasoningTokens") + or payload.get("usage", {}) + .get("output_tokens_details", {}) + .get("reasoning_tokens") + ) + trunc = bool(payload.get("truncation_match") or is_truncation_pattern(tokens)) + rounds.append( + EngineRoundSummary( + round=1, + reasoning_tokens=tokens, + truncation_match=trunc, + truncation_n=tier_n(tokens) if trunc else None, + decision=_string(payload.get("decision"), "continue" if trunc else "clean"), + ) + ) + return rounds + + +def summarize_engine_payload(payload: dict[str, Any]) -> dict[str, Any]: + """Project a safe CodexCont protection summary from engine input. + + This is intentionally tolerant. The CPA plugin can send already-known round + summaries, while tests and smoke probes can send only a usage object. + """ + rounds = _extract_rounds(payload) + latest = rounds[-1] if rounds else EngineRoundSummary(round=1) + first_hit = next((item for item in rounds if item.truncation_match), None) + continuation_count = max(0, int(payload.get("continuation_count") or 0)) + if continuation_count == 0 and len(rounds) > 1: + continuation_count = len(rounds) - 1 + + failure = _string(payload.get("failure_reason") or payload.get("failure")) + stopped_reason = _string(payload.get("stopped_reason") or payload.get("stop_reason")) + folded = bool(payload.get("folded") or continuation_count > 0 or len(rounds) > 1) + passthrough = bool(payload.get("passthrough")) + + if failure: + protection = "failed" + elif passthrough: + protection = "passthrough" + elif continuation_count > 0: + protection = "auto_continued" + elif first_hit is not None: + protection = "risk_uncontinued" + else: + protection = "protected_clean" + + summary = { + "ok": True, + "request_id": _string(payload.get("request_id")), + "model": _string(payload.get("model")), + "protection": protection, + "folded": folded, + "passthrough": passthrough, + "rounds": [asdict(item) for item in rounds], + "latest_round": latest.round, + "latest_reasoning_tokens": latest.reasoning_tokens, + "first_truncation_round": first_hit.round if first_hit else None, + "first_truncation_reasoning_tokens": first_hit.reasoning_tokens if first_hit else None, + "first_truncation_n": first_hit.truncation_n if first_hit else None, + "continuation_count": continuation_count, + "stopped_reason": stopped_reason or None, + "failure_reason": failure or None, + "safe": True, + } + return redact_value(summary) + + +async def engine_healthz(request: Request) -> JSONResponse: + _ = request + return JSONResponse( + { + "ok": True, + "mode": "codexcont-engine", + "uptime_seconds": round(time.time() - STARTED_AT, 3), + } + ) + + +async def engine_analyze(request: Request) -> JSONResponse: + raw = await request.body() + try: + body = json.loads(raw or b"{}") + except (json.JSONDecodeError, UnicodeDecodeError): + return JSONResponse({"ok": False, "error": "invalid_json_body"}, status_code=400) + if not isinstance(body, dict): + return JSONResponse({"ok": False, "error": "body_must_be_object"}, status_code=400) + return JSONResponse(summarize_engine_payload(body)) diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 8517bd1..baea754 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -40,6 +40,7 @@ from middleware.config import load_config from middleware.creds import build_upstream_headers, would_inject_authorization from middleware.diagnostics import Diagnostics, redact_value +from middleware.engine import summarize_engine_payload from middleware.key_identity import KeyIdentityResolver from middleware.proxy import fold_stream from middleware.sse import DONE, incremental_sse @@ -631,6 +632,47 @@ def test_admin_routes_smoke(): check("admin logs stream ready", "event: ready" in stream.text, stream.text[:80]) check("admin logs stream request event", "event: request" in stream.text, stream.text[:200]) + engine_health = client.get("/engine/healthz") + check("engine healthz 200", engine_health.status_code == 200, str(engine_health.status_code)) + check("engine healthz mode", engine_health.json().get("mode") == "codexcont-engine", + engine_health.text) + engine_summary = client.post("/engine/v1/responses/analyze", json={ + "model": "gpt-5.5", + "rounds": [ + {"round": 1, "reasoning_tokens": 516, "decision": "continue"}, + {"round": 2, "reasoning_tokens": 181, "decision": "clean"}, + ], + }) + body = engine_summary.json() + check("engine analyze 200", engine_summary.status_code == 200, engine_summary.text) + check("engine analyze auto continued", body.get("protection") == "auto_continued", str(body)) + check("engine analyze first hit", body.get("first_truncation_round") == 1, str(body)) + + +def test_engine_summary_projection(): + clean = summarize_engine_payload({ + "model": "gpt-5.5", + "usage": {"output_tokens_details": {"reasoning_tokens": 140}}, + }) + check("engine summary clean", clean.get("protection") == "protected_clean", str(clean)) + check("engine summary latest tokens", clean.get("latest_reasoning_tokens") == 140, str(clean)) + + risk = summarize_engine_payload({ + "model": "gpt-5.5", + "reasoning_tokens": 516, + "stopped_reason": "no_encrypted_content", + }) + check("engine summary risk", risk.get("protection") == "risk_uncontinued", str(risk)) + check("engine summary truncation n", risk.get("first_truncation_n") == 1, str(risk)) + + failed = summarize_engine_payload({ + "model": "gpt-5.5", + "failure_reason": "Authorization: Bearer secret-token", + }) + check("engine summary failed", failed.get("protection") == "failed", str(failed)) + check("engine summary redacts failure", + "secret-token" not in json.dumps(failed), str(failed)) + # --- upstream URL resolution via Responses-API-Base header ------------------ @@ -855,6 +897,7 @@ async def _main(): test_diagnostics_request_summaries() await test_diagnostics_subscriber_broadcast() test_admin_routes_smoke() + test_engine_summary_projection() test_upstream_url_resolution() test_auth_safety_guard() test_auth_injection() From d9047c87db120167d607fb2a297fa8b5d5eb177d Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Thu, 2 Jul 2026 09:45:02 +0800 Subject: [PATCH 28/68] fix: clarify Governor user key login --- .../backend/codex-continuation-contracts.md | 17 +++++ .../implement.md | 10 +++ cpa_governor_plugin/README.md | 15 ++++ .../go/internal/governor/governor_test.go | 28 +++++++ .../go/internal/governor/security.go | 35 ++++++++- cpa_governor_plugin/go/main.go | 23 +++--- cpa_governor_plugin/go/main_test.go | 75 +++++++++++++++++++ 7 files changed, 192 insertions(+), 11 deletions(-) diff --git a/.trellis/spec/backend/codex-continuation-contracts.md b/.trellis/spec/backend/codex-continuation-contracts.md index 387e1c0..6b5501b 100644 --- a/.trellis/spec/backend/codex-continuation-contracts.md +++ b/.trellis/spec/backend/codex-continuation-contracts.md @@ -438,10 +438,19 @@ credential, while CPAMP remains admin-only. - `cpa-usage.konbakuyomu.us` may expose only the Governor user page and `.../user/api/*`; it must return 404 for Governor admin resources and other management paths. +- CPA plugin `ResourceRoute` dispatch is GET-only in the current CPA host. + User self-service APIs under `/v0/resource/plugins/cpa-governor/user/api/*` + must therefore use GET requests, including session creation with the raw key + passed in the `Authorization` header. Do not put the key in the URL, and do + not implement user-resource mutations as POST unless they move behind a + management route or another authenticated proxy surface. - `cpa-admin.konbakuyomu.us/governor/` is protected by Cloudflare Access and may route through the local admin proxy to CPA's plugin resource endpoint. - Do not put CPA management keys, API keys, OAuth tokens, cookies, or encrypted reasoning into Caddy rewrites, browser URLs, Trellis docs, or git. +- The Governor user portal must explain key identity clearly: Key Policy + `cpa_...` full keys are accepted, native CPA `sk...` keys and shortened + previews are rejected with human-readable messages. - When updating the plugin binary, record the SHA256 and verify CPA logs show the plugin loaded and registered from the platform directory. - Dense admin/user tables on mobile must keep a stable minimum table width @@ -464,6 +473,14 @@ credential, while CPAMP remains admin-only. returns 200 -> rollback user-host route before accepting the rollout. - User API without session -> `401`; invalid CPA user key -> `401 invalid_api_key`. +- User session with a native `sk...` key -> `401 + native_cpa_key_not_supported` and a message telling the user to use the full + Key Policy `cpa_...` key. +- User session with a shortened `cpa_...` preview -> `401 + key_preview_not_usable` and a message telling the user to use the full key + shown at create/rotation time. +- `POST` to a user resource API -> CPA returns `404` before the plugin; the + browser UI must call these resource APIs with GET. - Mobile Playwright snapshot shows table columns narrower than practical text width or vertical labels -> add panel overflow/min-width and revalidate. diff --git a/.trellis/tasks/07-02-cpa-governor-codexcont-engine/implement.md b/.trellis/tasks/07-02-cpa-governor-codexcont-engine/implement.md index 7a0e737..4ca6893 100644 --- a/.trellis/tasks/07-02-cpa-governor-codexcont-engine/implement.md +++ b/.trellis/tasks/07-02-cpa-governor-codexcont-engine/implement.md @@ -66,6 +66,16 @@ - Local SSH tunnel to admin proxy showed Governor admin page, Key management, request details, and CodexCont tabs render. - Mobile 390px viewport initially exposed compressed table columns; fixed by giving tables a mobile minimum width inside an overflowed panel. - Re-validated mobile: Key table no longer collapses into vertical text; user page opens on `https://cpa-usage.konbakuyomu.us/`. +- Follow-up fix on 2026-07-02: + - User report: `cpa-usage.konbakuyomu.us` returned `invalid_api_key` for native `sk...` / newly created keys, and `cpa-admin.konbakuyomu.us/governor/` looked like a duplicate of the CPAMP sidebar `CPA Governor`. + - Evidence: Key Policy state on SJC updated and Governor saw 4 keys through `127.0.0.1:8327/governor/api/keys`, so the main issue was not missing state sync. Native CPA `sk...` keys are not valid user-portal credentials; the portal expects the full Key Policy `cpa_...` key. + - Root cause found during smoke: CPA plugin `ResourceRoute` dispatch is GET-only. `GET /v0/resource/plugins/cpa-governor/user/api/session` entered the plugin and returned the new Chinese error body, while `POST` returned `404` before the plugin. The frontend now uses GET plus `Authorization` header; keys are not placed in URLs. + - UX fix: user portal now normalizes pasted `Authorization: Bearer ...` / `Bearer ...` shapes and returns clear Chinese messages for native `sk...` keys, shortened previews, unsupported formats, disabled keys, and unmatched full `cpa_...` keys. + - UX fix: Governor admin page now states that CPAMP sidebar `CPA Governor` and `/governor/` are the same plugin page; `/governor/` is only a direct/debug entrypoint. + - Built linux/amd64 plugin SHA256 `103c63a4f151c47cda932855cee2b5d4d6fe8bbe203460be0b20cbef9cd6351d`; backed up previous plugin to `/root/cpa-governor-plugin-backup-20260702-093207`; uploaded only the `.so` and restarted only `cpa`. + - SJC verification: CPA loaded and registered Governor from `/CLIProxyAPI/plugins/linux/amd64/cpa-governor.so`; `https://cpa.konbakuyomu.us/healthz` returned `200`; authenticated `/v1/models` returned `200`; Governor API returned `ok=true`, `keys=4`, `codexcont.health_ok=true`, `route=false`; public API admin/plugin paths still returned `404`; root disk remained tight at about `629M` free. + - Playwright verification: `https://cpa-usage.konbakuyomu.us/` displayed the new full-`cpa_` helper text; clicking login with `sk-test` issued `GET /v0/resource/plugins/cpa-governor/user/api/session` and rendered `登录失败:这是 CPA 原生 sk Key,不能登录用量自助页。请使用 Key Policy 创建时弹窗里的完整 cpa_ 用户 Key。` + - Local checks after fix: `go test ./...` in `cpa_governor_plugin/go`; `.venv\Scripts\python.exe -m compileall middleware cpa_usage_portal run.py run_usage_portal.py`; scoped `git diff --check`. ## Residual risk / next cutover gate diff --git a/cpa_governor_plugin/README.md b/cpa_governor_plugin/README.md index c1c9a7e..aea97e7 100644 --- a/cpa_governor_plugin/README.md +++ b/cpa_governor_plugin/README.md @@ -78,3 +78,18 @@ The first SJC rollout uses the plugin as the unified UI and usage surface: The public API host must keep plugin/admin paths blocked. The user host should only expose the Governor user resource and user APIs; the admin resource must return 404 there. + +## User Key Login + +The user page accepts the full Key Policy `cpa_...` key. Native CPA `sk...` +keys and shortened previews are rejected with explanatory messages because they +are not the quota-managed user identity in this deployment. + +CPA plugin resource routes are GET-only in the current host. The user login +request therefore calls `/user/api/session` with `GET` and passes the key only +through the `Authorization` header. Do not put user keys in query strings. + +The CPAMP sidebar entry named `CPA Governor` and the direct +`https://cpa-admin.konbakuyomu.us/governor/` route are the same admin page. +Prefer the CPAMP sidebar for normal administration; the direct route is a +convenience/debug entrypoint, not a second system. diff --git a/cpa_governor_plugin/go/internal/governor/governor_test.go b/cpa_governor_plugin/go/internal/governor/governor_test.go index a20b1d0..30291b7 100644 --- a/cpa_governor_plugin/go/internal/governor/governor_test.go +++ b/cpa_governor_plugin/go/internal/governor/governor_test.go @@ -160,6 +160,9 @@ func TestSecurityAndRedaction(t *testing.T) { if hash != SHA256Hex(strings.TrimSpace(raw)) { t.Fatal("SHA256Hex should trim raw keys") } + if hash != SHA256Hex("Bearer cpa_live") || hash != SHA256Hex("Authorization: Bearer cpa_live") { + t.Fatal("SHA256Hex should normalize pasted bearer prefixes") + } token, err := SignSession(SessionPayload{KeyID: "alice", KeyHash: "sha256:" + hash, ExpiresAt: time.Now().Add(time.Hour).Unix()}, "secret") if err != nil { t.Fatal(err) @@ -176,3 +179,28 @@ func TestSecurityAndRedaction(t *testing.T) { t.Fatalf("secret leaked in brief: %s", brief) } } + +func TestSubmittedKeyHints(t *testing.T) { + cases := []struct { + name string + in string + code string + }{ + {name: "missing", in: " ", code: "missing_api_key"}, + {name: "native", in: "sk-abc", code: "native_cpa_key_not_supported"}, + {name: "preview", in: "cpa_abcd...efgh", code: "key_preview_not_usable"}, + {name: "unsupported", in: "abc", code: "unsupported_key_format"}, + {name: "full cpa", in: "Bearer cpa_live", code: "invalid_api_key"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + hint := ExplainUnmatchedSubmittedKey(tc.in) + if hint.Error != tc.code || hint.Message == "" { + t.Fatalf("hint = %#v", hint) + } + }) + } + if got := NormalizeSubmittedKey("Authorization: Bearer Bearer cpa_live "); got != "cpa_live" { + t.Fatalf("normalized key = %q", got) + } +} diff --git a/cpa_governor_plugin/go/internal/governor/security.go b/cpa_governor_plugin/go/internal/governor/security.go index 18d300f..e196e6c 100644 --- a/cpa_governor_plugin/go/internal/governor/security.go +++ b/cpa_governor_plugin/go/internal/governor/security.go @@ -7,15 +7,48 @@ import ( "encoding/hex" "encoding/json" "errors" + "regexp" "strings" "time" ) func SHA256Hex(value string) string { - sum := sha256.Sum256([]byte(strings.TrimSpace(value))) + sum := sha256.Sum256([]byte(NormalizeSubmittedKey(value))) return hex.EncodeToString(sum[:]) } +var bearerPrefixPattern = regexp.MustCompile(`(?i)^\s*(authorization\s*:\s*)?(bearer\s+)+`) + +// NormalizeSubmittedKey accepts the common clipboard shapes users paste into +// the self-service portal, while keeping hashing deterministic. +func NormalizeSubmittedKey(value string) string { + text := strings.TrimSpace(value) + text = bearerPrefixPattern.ReplaceAllString(text, "") + return strings.TrimSpace(text) +} + +type SubmittedKeyHint struct { + Error string + Message string +} + +func ExplainUnmatchedSubmittedKey(value string) SubmittedKeyHint { + key := NormalizeSubmittedKey(value) + lower := strings.ToLower(key) + switch { + case key == "": + return SubmittedKeyHint{Error: "missing_api_key", Message: "请粘贴完整的 cpa_ 开头用户 Key。"} + case strings.HasPrefix(lower, "sk-") || strings.HasPrefix(lower, "sk_"): + return SubmittedKeyHint{Error: "native_cpa_key_not_supported", Message: "这是 CPA 原生 sk Key,不能登录用量自助页。请使用 Key Policy 创建时弹窗里的完整 cpa_ 用户 Key。"} + case strings.Contains(key, "...") || strings.Contains(key, "…"): + return SubmittedKeyHint{Error: "key_preview_not_usable", Message: "你粘贴的是缩略预览,不是完整 Key。Key Policy 创建或轮换时弹窗里的完整 cpa_ Key 才能登录。"} + case !strings.HasPrefix(lower, "cpa_"): + return SubmittedKeyHint{Error: "unsupported_key_format", Message: "用量自助页只接受 Key Policy 的完整 cpa_ 用户 Key。"} + default: + return SubmittedKeyHint{Error: "invalid_api_key", Message: "这个 cpa_ Key 没有匹配到当前 Key Policy 记录。请确认粘贴的是创建时弹窗里的完整 Key,且该 Key 未被轮换。"} + } +} + func NormalizeHash(value string) (string, error) { text := strings.TrimSpace(value) text = strings.TrimPrefix(text, "sha256:") diff --git a/cpa_governor_plugin/go/main.go b/cpa_governor_plugin/go/main.go index a0da4db..15a2f77 100644 --- a/cpa_governor_plugin/go/main.go +++ b/cpa_governor_plugin/go/main.go @@ -866,16 +866,18 @@ func adminCodexCont(req managementRequest) ([]byte, error) { } func userSession(req managementRequest) ([]byte, error) { - key := bearer(req.Headers.Get("Authorization")) + key := governor.NormalizeSubmittedKey(bearer(req.Headers.Get("Authorization"))) if key == "" { - return jsonResponse(http.StatusUnauthorized, map[string]any{"ok": false, "error": "missing_api_key"}) + hint := governor.ExplainUnmatchedSubmittedKey(key) + return jsonResponse(http.StatusUnauthorized, map[string]any{"ok": false, "error": hint.Error, "message": hint.Message}) } record, ok := findKeyByRaw(key) if !ok { - return jsonResponse(http.StatusUnauthorized, map[string]any{"ok": false, "error": "invalid_api_key"}) + hint := governor.ExplainUnmatchedSubmittedKey(key) + return jsonResponse(http.StatusUnauthorized, map[string]any{"ok": false, "error": hint.Error, "message": hint.Message}) } if !record.Enabled { - return jsonResponse(http.StatusForbidden, map[string]any{"ok": false, "error": "api_key_disabled"}) + return jsonResponse(http.StatusForbidden, map[string]any{"ok": false, "error": "api_key_disabled", "message": "这个 Key 当前已禁用,请联系管理员。"}) } cfg := loadedConfig() token, err := governor.SignSession(governor.SessionPayload{ @@ -893,8 +895,8 @@ func userSession(req managementRequest) ([]byte, error) { resp := managementResponse{ StatusCode: http.StatusOK, Headers: http.Header{ - "content-type": []string{"application/json; charset=utf-8"}, - "set-cookie": []string{fmt.Sprintf("cpa_governor_session=%s; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400", token)}, + "Content-Type": []string{"application/json; charset=utf-8"}, + "Set-Cookie": []string{fmt.Sprintf("cpa_governor_session=%s; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400", token)}, }, Body: body, } @@ -1175,7 +1177,7 @@ func callHost(method string, payload any) (json.RawMessage, error) { } func adminHTML() string { - return `CPA Governor

CPA Governor 管理

统一管理 Key、限额、请求明细与 CodexCont 状态

实时轮询
加载中...
` } func sharedCSS() string { - return `:root{color-scheme:dark;--bg:#0b1020;--panel:#111827;--panel2:#0f172a;--line:#243047;--text:#e5e7eb;--muted:#94a3b8;--brand:#38bdf8;--ok:#22c55e;--bad:#fb7185;--warn:#fbbf24}*{box-sizing:border-box}body{margin:0;background:linear-gradient(180deg,#0b1020,#111827);color:var(--text);font:14px/1.5 ui-sans-serif,system-ui,Segoe UI,Arial}.shell{max-width:1180px;margin:0 auto;padding:24px}.top{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:18px}.top h1{margin:0;font-size:24px}.top p{margin:4px 0 0;color:var(--muted)}.toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}button{border:1px solid var(--line);background:#172033;color:var(--text);border-radius:8px;padding:9px 13px;cursor:pointer}button:hover{border-color:var(--brand)}.spin{animation:spin .8s linear infinite}.pulse{color:var(--ok);animation:pulse 1.4s ease-in-out infinite}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:14px}.grid article,.panel{background:rgba(17,24,39,.92);border:1px solid var(--line);border-radius:10px;box-shadow:0 12px 32px rgba(0,0,0,.25)}.grid article{padding:14px}.grid b{display:block;font-size:22px}.grid span,small{display:block;color:var(--muted);overflow-wrap:anywhere}.panel{padding:16px}.notice{margin-bottom:14px;color:#cbd5e1;background:rgba(15,23,42,.96)}.hint{margin:10px 0 0;color:var(--muted)}#err{color:#fecdd3}.tabs{display:flex;gap:8px;margin-bottom:14px}.tabs .active{background:#0e7490;border-color:#38bdf8}table{width:100%;border-collapse:collapse;table-layout:fixed}th,td{border-bottom:1px solid var(--line);padding:10px;text-align:left;vertical-align:top;white-space:normal;overflow-wrap:break-word}th{color:#cbd5e1;font-size:12px;text-transform:uppercase}.chip{display:inline-block;border-radius:999px;padding:2px 8px;background:#334155}.chip.ok{color:#86efac}.chip.bad{color:#fecdd3}.chip.warn{color:#fde68a}.detail{background:#0f172a;border:1px solid var(--line);border-radius:8px;padding:14px}.detail h2{font-size:18px;margin:0 0 10px}.detail dl{display:grid;grid-template-columns:140px 1fr;gap:8px 12px}.detail dt{color:#94a3b8}.detail dd{margin:0;overflow-wrap:anywhere}input{width:min(520px,100%);padding:11px;border-radius:8px;border:1px solid var(--line);background:#0b1220;color:var(--text);margin-right:8px}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{0%,100%{opacity:.55}50%{opacity:1}}@media(max-width:620px){.shell{padding:14px}.top{align-items:flex-start;flex-direction:column}.panel{overflow-x:auto}table{min-width:860px;font-size:12px}th,td{padding:8px;white-space:normal;overflow-wrap:break-word}.tabs{min-width:max-content;overflow:auto}.detail dl{grid-template-columns:1fr}}` + return `:root{color-scheme:dark;--bg:#0b1020;--panel:#111827;--panel2:#0f172a;--line:#243047;--text:#e5e7eb;--muted:#94a3b8;--brand:#38bdf8;--ok:#22c55e;--bad:#fb7185;--warn:#fbbf24}*{box-sizing:border-box}body{margin:0;background:linear-gradient(180deg,#0b1020,#111827);color:var(--text);font:14px/1.5 ui-sans-serif,system-ui,Segoe UI,Arial}.shell{max-width:1180px;margin:0 auto;padding:24px}.top{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:18px}.top h1{margin:0;font-size:24px}.top p{margin:4px 0 0;color:var(--muted)}.toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}button{border:1px solid var(--line);background:#172033;color:var(--text);border-radius:8px;padding:9px 13px;cursor:pointer}button:hover{border-color:var(--brand)}.spin{animation:spin .8s linear infinite}.pulse{color:var(--ok);animation:pulse 1.4s ease-in-out infinite}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:14px}.grid article,.panel{background:rgba(17,24,39,.92);border:1px solid var(--line);border-radius:10px;box-shadow:0 12px 32px rgba(0,0,0,.25)}.grid article{padding:14px}.grid b{display:block;font-size:22px}.grid span,small{display:block;color:var(--muted);overflow-wrap:anywhere}.panel{padding:16px}.notice{margin-bottom:14px;color:#cbd5e1;background:rgba(15,23,42,.96)}.hint{margin:10px 0 0;color:var(--muted)}#err{color:#fecdd3}.tabs{display:flex;gap:8px;margin-bottom:14px}.tabs .active{background:#0e7490;border-color:#38bdf8}.actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}table{width:100%;border-collapse:collapse;table-layout:fixed}th,td{border-bottom:1px solid var(--line);padding:10px;text-align:left;vertical-align:top;white-space:normal;overflow-wrap:break-word}th{color:#cbd5e1;font-size:12px;text-transform:uppercase}.chip{display:inline-block;border-radius:999px;padding:2px 8px;background:#334155}.chip.ok{color:#86efac}.chip.bad{color:#fecdd3}.chip.warn{color:#fde68a}.detail{background:#0f172a;border:1px solid var(--line);border-radius:8px;padding:14px}.detail h2{font-size:18px;margin:0 0 10px}.detail dl{display:grid;grid-template-columns:140px 1fr;gap:8px 12px}.detail dt{color:#94a3b8}.detail dd{margin:0;overflow-wrap:anywhere}.switchline{display:flex;align-items:center;gap:8px;color:var(--text)}input,select{width:min(520px,100%);padding:11px;border-radius:8px;border:1px solid var(--line);background:#0b1220;color:var(--text);margin-right:8px}input[type=checkbox]{width:auto;margin:0;accent-color:var(--brand)}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{0%,100%{opacity:.55}50%{opacity:1}}@media(max-width:620px){.shell{padding:14px}.top{align-items:flex-start;flex-direction:column}.panel{overflow-x:auto}table{min-width:860px;font-size:12px}th,td{padding:8px;white-space:normal;overflow-wrap:break-word}.tabs{min-width:max-content;overflow:auto}.detail dl{grid-template-columns:1fr}}` } diff --git a/cpa_governor_plugin/go/main_test.go b/cpa_governor_plugin/go/main_test.go index 2852a97..6140ec6 100644 --- a/cpa_governor_plugin/go/main_test.go +++ b/cpa_governor_plugin/go/main_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "net/http" + "net/url" "os" "path/filepath" "strings" @@ -189,6 +190,9 @@ func TestUserSessionNormalizesPastedBearerKey(t *testing.T) { if got := resp.Headers.Get("set-cookie"); got == "" { t.Fatal("session cookie was not set") } + if got := resp.Headers.Get("Cache-Control"); got != "no-store" { + t.Fatalf("cache-control = %q", got) + } var body map[string]any if err := json.Unmarshal(resp.Body, &body); err != nil { t.Fatal(err) @@ -198,6 +202,29 @@ func TestUserSessionNormalizesPastedBearerKey(t *testing.T) { } } +func TestUserSessionPrefersDedicatedHeaderOverAuthorization(t *testing.T) { + configureTestState(t) + raw, err := userSession(managementRequest{ + Headers: http.Header{ + "Authorization": []string{"Bearer cpamp-admin-token"}, + "X-CPA-Governor-Key": []string{"Authorization: Bearer cpa_live"}, + }, + }) + if err != nil { + t.Fatal(err) + } + resp := unwrapManagementResponse(t, raw) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d body=%s", resp.StatusCode, string(resp.Body)) + } + if got := resp.Headers.Get("set-cookie"); got == "" { + t.Fatal("session cookie was not set") + } + if got := resp.Headers.Get("Cache-Control"); got != "no-store" { + t.Fatalf("cache-control = %q", got) + } +} + func TestUserSessionExplainsNativeAndPreviewKeys(t *testing.T) { configureTestState(t) cases := []struct { @@ -233,14 +260,58 @@ func TestUserSessionExplainsNativeAndPreviewKeys(t *testing.T) { func TestUserHTMLSessionUsesGETResourceRoute(t *testing.T) { html := userHTML() - if !strings.Contains(html, "fetch(BASE+'/user/api/session',{headers:") { + if !strings.Contains(html, "fetch(USER_API+'/session',{headers:{'X-CPA-Governor-Key':raw}") { t.Fatal("user login should call the GET-only resource route without a POST method") } + if strings.Contains(html, "Authorization:'Bearer '+raw") || strings.Contains(html, `Authorization:"Bearer "+raw`) { + t.Fatal("CPAMP embeds plugin pages behind its own auth; user login must use a dedicated key header") + } if strings.Contains(strings.ToLower(html), "method:'post'") || strings.Contains(strings.ToLower(html), `method:"post"`) { t.Fatal("CPA resource routes are GET-only; user login must not use POST") } } +func TestAdminHTMLCodexContSaveUsesGETResourceRoute(t *testing.T) { + html := adminHTML() + if !strings.Contains(html, "action:'save'") || !strings.Contains(html, "j('/codexcont?'") { + t.Fatal("admin CodexCont save should call the GET-only resource route with query parameters") + } + if strings.Contains(strings.ToLower(html), "method:'put'") || strings.Contains(strings.ToLower(html), `method:"put"`) { + t.Fatal("CPA resource routes are GET-only; admin resource page must not save with PUT") + } +} + +func TestAdminCodexContGETSavePersistsSettings(t *testing.T) { + configureTestState(t) + raw, err := adminCodexCont(managementRequest{ + Method: http.MethodGet, + Query: url.Values{ + "action": []string{"save"}, + "enabled": []string{"true"}, + "url": []string{"http://codexcont:8787/"}, + "fail_mode": []string{"fail_closed"}, + }, + }) + if err != nil { + t.Fatal(err) + } + resp := unwrapManagementResponse(t, raw) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d body=%s", resp.StatusCode, string(resp.Body)) + } + cfg := loadedConfig() + if !cfg.CodexContEnabled || cfg.CodexContURL != "http://codexcont:8787" || cfg.FailMode != "fail_closed" { + t.Fatalf("cfg = %#v", cfg) + } + settings, err := loadedStore().LoadSettings(context.Background()) + if err != nil { + t.Fatal(err) + } + if settings["codexcont_enabled"] != "true" || settings["codexcont_url"] != "http://codexcont:8787" || settings["fail_mode"] != "fail_closed" { + t.Fatalf("settings = %#v", settings) + } +} + func TestUsageHandleStoresCostAndReleasesConcurrency(t *testing.T) { key := configureTestState(t) if !acquireConcurrency(key) { @@ -334,4 +405,7 @@ func TestRefreshKeyPolicyStateImportsNewKeys(t *testing.T) { if key, ok := findKeyByRaw("cpa_bob"); !ok || key.ID != "bob" { t.Fatalf("bob not imported after refresh: %#v ok=%v", key, ok) } + if key, ok := findKeyByRaw("cpa_alice"); ok { + t.Fatalf("deleted alice should not remain in Governor mirror: %#v", key) + } } From 69e429efedf3c28ea20c1f96df171e865b90ae9d Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Thu, 2 Jul 2026 11:58:19 +0800 Subject: [PATCH 30/68] docs: record Governor login closeout --- .../tasks/07-02-cpa-governor-codexcont-engine/implement.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.trellis/tasks/07-02-cpa-governor-codexcont-engine/implement.md b/.trellis/tasks/07-02-cpa-governor-codexcont-engine/implement.md index ff76b0c..ec0a156 100644 --- a/.trellis/tasks/07-02-cpa-governor-codexcont-engine/implement.md +++ b/.trellis/tasks/07-02-cpa-governor-codexcont-engine/implement.md @@ -84,6 +84,10 @@ - Tests: added Go regression coverage for dedicated-header precedence over `Authorization`, case-insensitive header lookup, and no-store session responses. `go test ./...` passed in `cpa_governor_plugin/go`; scoped `git diff --check` passed. - Built linux/amd64 plugin SHA256 `efa1133a896b0cac58777b0477f2cc6bd38ccc65a3c58b95ca9bdb9babb78ea6`; backed up previous plugin to `/root/cpa-governor-nostore-hotfix-20260702-114432`; uploaded only the `.so` and restarted only `cpa`. - SJC verification: CPA loaded and registered Governor after restart; `https://cpa.konbakuyomu.us/healthz` returned `200`; `https://cpa-usage.konbakuyomu.us/` returned `200` with `Cache-Control: no-store`; public API plugin path and user-host admin path returned `404`; unauthenticated user API returned `401`; valid current key login via both user host and admin proxy returned `200`; stale QQ key returned `401 invalid_api_key`. Root disk remained tight at about `515M` free. +- Final closeout on 2026-07-02: + - User confirmed the previously reported Governor / CPA Usage login problems were fixed. + - Experience retained in code-spec: CPAMP embedded plugin pages must not rely on `Authorization` for user-key login because admin shells may own that header; Key Policy rotation means only the newly generated full `cpa_...` key can match the current `key_hash`, while old full keys and list previews must fail safely. + - Task accepted as complete in passive Governor mode. Remaining executor-level CodexCont cutover stays explicitly documented as a future gate, not part of this closeout. ## Residual risk / next cutover gate From 5eb46535c2aeccaa36df518574bba398ac10c86f Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Thu, 2 Jul 2026 11:58:32 +0800 Subject: [PATCH 31/68] chore(task): archive 07-02-cpa-governor-codexcont-engine --- .../2026-07}/07-02-cpa-governor-codexcont-engine/check.jsonl | 0 .../deploy/build-linux-plugin-wsl.sh | 0 .../deploy/deploy-governor-sjc.sh | 0 .../2026-07}/07-02-cpa-governor-codexcont-engine/design.md | 0 .../07-02-cpa-governor-codexcont-engine/implement.jsonl | 0 .../2026-07}/07-02-cpa-governor-codexcont-engine/implement.md | 0 .../2026-07}/07-02-cpa-governor-codexcont-engine/prd.md | 0 .../2026-07}/07-02-cpa-governor-codexcont-engine/task.json | 4 ++-- 8 files changed, 2 insertions(+), 2 deletions(-) rename .trellis/tasks/{ => archive/2026-07}/07-02-cpa-governor-codexcont-engine/check.jsonl (100%) rename .trellis/tasks/{ => archive/2026-07}/07-02-cpa-governor-codexcont-engine/deploy/build-linux-plugin-wsl.sh (100%) rename .trellis/tasks/{ => archive/2026-07}/07-02-cpa-governor-codexcont-engine/deploy/deploy-governor-sjc.sh (100%) rename .trellis/tasks/{ => archive/2026-07}/07-02-cpa-governor-codexcont-engine/design.md (100%) rename .trellis/tasks/{ => archive/2026-07}/07-02-cpa-governor-codexcont-engine/implement.jsonl (100%) rename .trellis/tasks/{ => archive/2026-07}/07-02-cpa-governor-codexcont-engine/implement.md (100%) rename .trellis/tasks/{ => archive/2026-07}/07-02-cpa-governor-codexcont-engine/prd.md (100%) rename .trellis/tasks/{ => archive/2026-07}/07-02-cpa-governor-codexcont-engine/task.json (90%) diff --git a/.trellis/tasks/07-02-cpa-governor-codexcont-engine/check.jsonl b/.trellis/tasks/archive/2026-07/07-02-cpa-governor-codexcont-engine/check.jsonl similarity index 100% rename from .trellis/tasks/07-02-cpa-governor-codexcont-engine/check.jsonl rename to .trellis/tasks/archive/2026-07/07-02-cpa-governor-codexcont-engine/check.jsonl diff --git a/.trellis/tasks/07-02-cpa-governor-codexcont-engine/deploy/build-linux-plugin-wsl.sh b/.trellis/tasks/archive/2026-07/07-02-cpa-governor-codexcont-engine/deploy/build-linux-plugin-wsl.sh similarity index 100% rename from .trellis/tasks/07-02-cpa-governor-codexcont-engine/deploy/build-linux-plugin-wsl.sh rename to .trellis/tasks/archive/2026-07/07-02-cpa-governor-codexcont-engine/deploy/build-linux-plugin-wsl.sh diff --git a/.trellis/tasks/07-02-cpa-governor-codexcont-engine/deploy/deploy-governor-sjc.sh b/.trellis/tasks/archive/2026-07/07-02-cpa-governor-codexcont-engine/deploy/deploy-governor-sjc.sh similarity index 100% rename from .trellis/tasks/07-02-cpa-governor-codexcont-engine/deploy/deploy-governor-sjc.sh rename to .trellis/tasks/archive/2026-07/07-02-cpa-governor-codexcont-engine/deploy/deploy-governor-sjc.sh diff --git a/.trellis/tasks/07-02-cpa-governor-codexcont-engine/design.md b/.trellis/tasks/archive/2026-07/07-02-cpa-governor-codexcont-engine/design.md similarity index 100% rename from .trellis/tasks/07-02-cpa-governor-codexcont-engine/design.md rename to .trellis/tasks/archive/2026-07/07-02-cpa-governor-codexcont-engine/design.md diff --git a/.trellis/tasks/07-02-cpa-governor-codexcont-engine/implement.jsonl b/.trellis/tasks/archive/2026-07/07-02-cpa-governor-codexcont-engine/implement.jsonl similarity index 100% rename from .trellis/tasks/07-02-cpa-governor-codexcont-engine/implement.jsonl rename to .trellis/tasks/archive/2026-07/07-02-cpa-governor-codexcont-engine/implement.jsonl diff --git a/.trellis/tasks/07-02-cpa-governor-codexcont-engine/implement.md b/.trellis/tasks/archive/2026-07/07-02-cpa-governor-codexcont-engine/implement.md similarity index 100% rename from .trellis/tasks/07-02-cpa-governor-codexcont-engine/implement.md rename to .trellis/tasks/archive/2026-07/07-02-cpa-governor-codexcont-engine/implement.md diff --git a/.trellis/tasks/07-02-cpa-governor-codexcont-engine/prd.md b/.trellis/tasks/archive/2026-07/07-02-cpa-governor-codexcont-engine/prd.md similarity index 100% rename from .trellis/tasks/07-02-cpa-governor-codexcont-engine/prd.md rename to .trellis/tasks/archive/2026-07/07-02-cpa-governor-codexcont-engine/prd.md diff --git a/.trellis/tasks/07-02-cpa-governor-codexcont-engine/task.json b/.trellis/tasks/archive/2026-07/07-02-cpa-governor-codexcont-engine/task.json similarity index 90% rename from .trellis/tasks/07-02-cpa-governor-codexcont-engine/task.json rename to .trellis/tasks/archive/2026-07/07-02-cpa-governor-codexcont-engine/task.json index 3232974..68fb99b 100644 --- a/.trellis/tasks/07-02-cpa-governor-codexcont-engine/task.json +++ b/.trellis/tasks/archive/2026-07/07-02-cpa-governor-codexcont-engine/task.json @@ -3,7 +3,7 @@ "name": "cpa-governor-codexcont-engine", "title": "CPA Governor plugin and CodexCont engine", "description": "", - "status": "in_progress", + "status": "completed", "dev_type": null, "scope": null, "package": null, @@ -11,7 +11,7 @@ "creator": "dxt98", "assignee": "dxt98", "createdAt": "2026-07-02", - "completedAt": null, + "completedAt": "2026-07-02", "branch": null, "base_branch": "main", "worktree_path": null, From 8a54fba2c1ec595817d9841566b0df0e452f7e2e Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Thu, 2 Jul 2026 11:58:53 +0800 Subject: [PATCH 32/68] chore: record journal --- .trellis/workspace/dxt98/index.md | 5 ++-- .trellis/workspace/dxt98/journal-1.md | 36 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/.trellis/workspace/dxt98/index.md b/.trellis/workspace/dxt98/index.md index 1109b99..596cc38 100644 --- a/.trellis/workspace/dxt98/index.md +++ b/.trellis/workspace/dxt98/index.md @@ -8,7 +8,7 @@ - **Active File**: `journal-1.md` -- **Total Sessions**: 5 +- **Total Sessions**: 6 - **Last Active**: 2026-07-02 @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~176 | Active | +| `journal-1.md` | ~212 | Active | --- @@ -29,6 +29,7 @@ | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 6 | 2026-07-02 | CPA Governor and CodexCont engine rollout | `429bed5`, `d9047c8`, `50e77d0`, `69e429e` | `main` | | 5 | 2026-07-02 | CPA usage request detail closeout | `432ca38` | `main` | | 4 | 2026-07-02 | CPA usage quota admin | `762b9e6` | `main` | | 3 | 2026-07-01 | CPA key management and usage portal | `b20a983`, `61911b0` | `main` | diff --git a/.trellis/workspace/dxt98/journal-1.md b/.trellis/workspace/dxt98/journal-1.md index 65c97cb..58f3e93 100644 --- a/.trellis/workspace/dxt98/journal-1.md +++ b/.trellis/workspace/dxt98/journal-1.md @@ -174,3 +174,39 @@ Completed and deployed the CPA usage admin batch-save/request-detail task, inclu ### Next Steps - None - task complete + + +## Session 6: CPA Governor and CodexCont engine rollout + +**Date**: 2026-07-02 +**Task**: CPA Governor and CodexCont engine rollout +**Branch**: `main` + +### Summary + +Built and deployed the CPA Governor plugin in passive mode, added the CodexCont engine surface, unified Governor admin/user pages, fixed Key Policy login sync and CPAMP embedded user-login header conflicts, recorded Key Policy rotation and no-store cache contracts, and archived the completed task. + +### Main Changes + +(Add details) + +### Git Commits + +| Hash | Message | +|------|---------| +| `429bed5` | (see git log) | +| `d9047c8` | (see git log) | +| `50e77d0` | (see git log) | +| `69e429e` | (see git log) | + +### Testing + +- [OK] (Add test results) + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete From 9d04f7b0f3941443c51dbd05722c7f84f3dfb421 Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Thu, 2 Jul 2026 15:54:19 +0800 Subject: [PATCH 33/68] feat: refresh Governor realtime UI --- .gitignore | 1 + .../backend/codex-continuation-contracts.md | 43 +- .../check.jsonl | 1 + .../07-02-governor-ui-route-cleanup/design.md | 99 +++ .../implement.jsonl | 1 + .../implement.md | 221 +++++++ .../07-02-governor-ui-route-cleanup/prd.md | 105 ++++ .../07-02-governor-ui-route-cleanup/task.json | 26 + cpa_governor_plugin/README.md | 4 +- cpa_governor_plugin/go/assets/admin.html | 325 ++++++++++ cpa_governor_plugin/go/assets/shared.css | 571 +++++++++++++++++ cpa_governor_plugin/go/assets/user.html | 586 ++++++++++++++++++ .../go/internal/governor/governor_test.go | 96 +++ .../go/internal/governor/store.go | 210 ++++++- cpa_governor_plugin/go/main.go | 309 +++++++-- cpa_governor_plugin/go/main_test.go | 211 ++++++- 16 files changed, 2711 insertions(+), 98 deletions(-) create mode 100644 .trellis/tasks/07-02-governor-ui-route-cleanup/check.jsonl create mode 100644 .trellis/tasks/07-02-governor-ui-route-cleanup/design.md create mode 100644 .trellis/tasks/07-02-governor-ui-route-cleanup/implement.jsonl create mode 100644 .trellis/tasks/07-02-governor-ui-route-cleanup/implement.md create mode 100644 .trellis/tasks/07-02-governor-ui-route-cleanup/prd.md create mode 100644 .trellis/tasks/07-02-governor-ui-route-cleanup/task.json create mode 100644 cpa_governor_plugin/go/assets/admin.html create mode 100644 cpa_governor_plugin/go/assets/shared.css create mode 100644 cpa_governor_plugin/go/assets/user.html diff --git a/.gitignore b/.gitignore index 0d1333e..5db1520 100644 --- a/.gitignore +++ b/.gitignore @@ -23,5 +23,6 @@ legacy/ # Local browser/test/build artifacts .playwright-cli/ +artifacts/ cpa_governor_plugin/dist/ cpa_governor_plugin/go/*.sqlite* diff --git a/.trellis/spec/backend/codex-continuation-contracts.md b/.trellis/spec/backend/codex-continuation-contracts.md index c0eaa50..75fc5ef 100644 --- a/.trellis/spec/backend/codex-continuation-contracts.md +++ b/.trellis/spec/backend/codex-continuation-contracts.md @@ -105,6 +105,15 @@ The continuation owner must sit at executor level, where it can inspect raw upst - When a continued request ends with a clean final round, `latest_reasoning_tokens` may be below 516. The dashboard must label it as latest-round reasoning and separately display the first 516/518n-2 trigger round from the request summary. - The dashboard frontend must treat the SSE connection as recoverable browser state, not as a durable data source. Manual refresh and foreground resume (`visibilitychange`, `pageshow`, or stale `focus`) must re-fetch the JSON snapshots with no-store/cache-bust semantics and force-create a new `EventSource`. Late responses from older fetches must not overwrite newer snapshots. - The refresh action must provide visible busy/completion feedback, and the realtime connection chip must animate in all states (`connected`, `connecting/reconnecting`, and `disconnected`) so an operator can see that the page is alive after returning from an idle tab. +- The dashboard's top-right refresh button light must be driven by the same + realtime state as the connection chip. During a refresh it may temporarily + show `syncing`, but after the label falls back to "refresh" the light must + keep the live state class (`live-ok`, `live-warn`, or `live-bad`) instead of + returning to a neutral grey dot. +- Admin snapshot refresh must keep a short minimum visible `syncing` duration + on manual/foreground refresh, just like the user page. Fast local admin + snapshot responses must not collapse the operator feedback into a single + imperceptible frame. - When an SSE request update is still `processing`, the dashboard should immediately refresh status counters and follow up with short delayed `/admin/requests` snapshot reloads. Do not rely only on the next long polling @@ -296,6 +305,14 @@ This spreads the event contract into JavaScript and makes the beginner-facing st - The refresh button and realtime chip must expose visible state changes: refresh shows busy/completion animation, while the realtime chip pulses for connected, reconnecting, and disconnected states. +- The visible refresh state must not disappear just because the local or + cached API responds quickly. Keep a short minimum `syncing` state before the + completion confirmation, then use row/card highlights to show what changed + without flashing or blanking the table. +- The refresh button's small status light and the realtime chip must share the + same state transition. After the completion label returns to "refresh", the + light must still pulse as connected/reconnecting/error rather than reverting + to a grey idle light. - A realtime usage event should update the visible recent-request table immediately and then schedule delayed snapshot refreshes, because CPAMP aggregate views may update slightly after the event row appears. @@ -419,6 +436,12 @@ credential, while CPAMP remains admin-only. - Admin proxy convenience routes: `https://cpa-admin.konbakuyomu.us/governor/` and `https://cpa-admin.konbakuyomu.us/governor-user/`. +- Embedded admin CodexCont data channel: + `GET /governor/codexcont/admin/status`, + `GET /governor/codexcont/admin/requests?limit=N`, and + `GET /governor/codexcont/admin/logs/stream?once=1`. These are admin-host + only proxy paths to CodexCont `/admin/*`; the retired standalone + `/codexcont/` dashboard must return `404`. - User portal route: `https://cpa-usage.konbakuyomu.us/`. - CodexCont Engine: @@ -452,6 +475,11 @@ credential, while CPAMP remains admin-only. be cached by the browser or an intermediate admin proxy. - `cpa-admin.konbakuyomu.us/governor/` is protected by Cloudflare Access and may route through the local admin proxy to CPA's plugin resource endpoint. + The Governor admin resource is read-only daily observability: it may show + CodexCont status, request summaries, hit rounds, latest reasoning counters, + continuation counts, failures, and advanced logs, but it must not expose Key + management, request-management tabs, or server-side CodexCont save controls. + Persistent Governor settings live in CPA's plugin configuration drawer. - Do not put CPA management keys, API keys, OAuth tokens, cookies, or encrypted reasoning into Caddy rewrites, browser URLs, Trellis docs, or git. - The Governor user portal must explain key identity clearly: Key Policy @@ -475,6 +503,9 @@ credential, while CPAMP remains admin-only. through the existing working route and return a real successful response. - Public `cpa.konbakuyomu.us/v0/resource/plugins/cpa-governor/admin` returns 200 -> rollback Caddy public block before accepting the rollout. +- Public `cpa.konbakuyomu.us/governor/` or any + `/governor/codexcont/admin/*` path returns 200 -> rollback Caddy public + block before accepting the rollout. - Public `cpa-usage.konbakuyomu.us/v0/resource/plugins/cpa-governor/admin` returns 200 -> rollback user-host route before accepting the rollout. - User API without session -> `401`; invalid CPA user key -> `401 @@ -497,9 +528,10 @@ credential, while CPAMP remains admin-only. width or vertical labels -> add panel overflow/min-width and revalidate. ### 5. Good/Base/Bad Cases -- Good: Governor is loaded by CPA, admin page shows 3 Key Policy keys, user - page opens on `cpa-usage`, CodexCont health is green, public API admin paths - return 404, and real `/v1/responses` still succeeds. +- Good: Governor is loaded by CPA, the sidebar `CPA Governor` page shows the + read-only CodexCont protection dashboard, user page opens on `cpa-usage`, the + embedded admin data channel works, retired `/codexcont/` returns 404, public + API admin paths return 404, and real `/v1/responses` still succeeds. - Base: Governor user page opens but no user is logged in. `/user/api/me` returns `401 not_authenticated`, and the page waits for a raw `cpa_...` key. - Base: A Key Policy key is rotated. The old full key is unrecoverable and @@ -520,6 +552,11 @@ credential, while CPAMP remains admin-only. store usage events, admin/user handler responses. - Go unit: user login must prefer `X-CPA-Governor-Key` over `Authorization`, read headers case-insensitively, and mark JSON/HTML responses `no-store`. +- Go unit: admin HTML must be read-only and must not contain Key management, + request-detail tabs, CodexCont save actions, or spinner/diagonal animation + hooks. +- Go unit: user CodexCont summaries must be filtered to the current session key + by safe identity and must not leak other users' request ids. - Python unit: CodexCont Engine summary projection and route smoke. - Build verification: linux/amd64 `.so` SHA256 recorded and `file` reports an ELF x86-64 shared object compatible with the Debian/glibc CPA image. diff --git a/.trellis/tasks/07-02-governor-ui-route-cleanup/check.jsonl b/.trellis/tasks/07-02-governor-ui-route-cleanup/check.jsonl new file mode 100644 index 0000000..9cd59d4 --- /dev/null +++ b/.trellis/tasks/07-02-governor-ui-route-cleanup/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/07-02-governor-ui-route-cleanup/design.md b/.trellis/tasks/07-02-governor-ui-route-cleanup/design.md new file mode 100644 index 0000000..23bbc6d --- /dev/null +++ b/.trellis/tasks/07-02-governor-ui-route-cleanup/design.md @@ -0,0 +1,99 @@ +# Governor UI route cleanup design + +## Architecture + +The task is a presentation and routing cleanup, not a provider-path migration. +CPA remains the public API entrypoint, CPAMP remains the admin shell, Key Policy +remains the current key source, and CodexCont remains the proven production +continuation service. + +Governor keeps two browser resources: + +- `/v0/resource/plugins/cpa-governor/admin` renders the admin CodexCont status + dashboard. +- `/v0/resource/plugins/cpa-governor/user` renders the user self-service page. + It remains a routable resource for the dedicated `cpa-usage` host, but it no + longer registers a CPAMP sidebar menu. + +Governor no longer exposes daily-use mutable controls inside the admin browser +resource. Low-level plugin configuration stays in CPA plugin metadata and the +CPA plugin configuration drawer. + +## Data flow + +Admin CodexCont status: + +1. CPAMP loads the Governor admin resource. +2. The page fetches snapshots from `/governor/codexcont/admin/status` and + `/governor/codexcont/admin/requests`. +3. The page opens `EventSource('/governor/codexcont/admin/logs/stream')`. +4. Caddy rewrites those admin-only paths to CodexCont `/admin/*`. +5. Public `cpa.konbakuyomu.us` never exposes these routes. + +User page: + +1. User enters a full Key Policy `cpa_...` key. +2. Browser sends it in `X-CPA-Governor-Key` to the GET-only session route. +3. Governor resolves the key against the Key Policy mirror and stores only safe + session state. +4. User quota/details come from Governor's SQLite usage projection. +5. User CodexCont status is filtered by safe key identity where available. + +The user page is a single-key monitor inspired by CPAMP realtime monitoring, not +a copy of CPAMP's global monitoring center. Admin/global dimensions such as +account summaries, client-key summaries, provider/account filters, and global +key display controls are intentionally omitted. + +Usage-event projection owns the durable request-detail contract. CPA +`UsageRecord` fields such as requested model, provider, executor type, +reasoning effort, service tier, TTFT, and failure status code are stored as +optional columns so old rows remain readable. The browser renders these fields +when present and displays `-` for older records. + +## UI contracts + +- Use the existing CodexCont dashboard style as the source visual language: + compact dark topbar, status chips, dot pulse, bounded metric cards, stable + table widths, and expandable detail panels. +- No refresh-button rotation, diagonal badge, or full-screen marketing layout. +- Manual refresh is modeled as a small state machine (`syncing`, `updated`, + `error`) instead of a decorative spinner. The syncing state keeps a brief + minimum visible duration so fast cached/local API responses still communicate + that a live refresh happened. +- Polling should feel alive without jank: use a thin live sweep, changed + metric/request-row highlights, and content enter transitions; do not blank + the table or rebuild the whole visual shell on each tick. +- Tab switches must be local and immediate. The page keeps cached state for + both tabs, starts an async refresh after switching, and ignores stale fetches + from older refresh sequences. +- Realtime polling compares stable per-row signatures. If no data changed, the + existing DOM stays in place; if data changed, only the visible metrics/table + content is updated and changed rows are highlighted. +- Admin Governor page is read-only. Controls may exist only for local UI state + such as pause/autoscroll/filter, not for server configuration. +- User page has exactly two main tabs: quota/request details and protection + status. + +## Compatibility and rollback + +- Keep existing Governor management APIs for compatibility unless removal is + required by tests. The admin browser page simply stops exposing those controls. +- Keep `Cache-Control: no-store` for plugin HTML and JSON. +- If embedded SSE fails in CPAMP, the page falls back to snapshot refresh and + reports reconnecting instead of breaking the page. +- Both custom pages use recoverable browser state. Snapshot fetches have an + AbortController timeout, foreground resume aborts stale work and starts a new + snapshot, and SSE connections are closed while hidden and recreated when the + page becomes visible again. +- Rollback is replacing the previous Governor plugin binary and restoring the + previous Caddy admin route block from backup. + +## Security + +- Do not include API keys, OAuth tokens, cookies, request bodies, response + bodies, encrypted reasoning, or complete key hashes in HTML, JSON, logs, or + Trellis docs. +- Public API domain blocks plugin resource and admin paths. +- User protection summaries are scoped to the logged-in key. Unknown key + identity must degrade to "no data" for the user page rather than showing + global data. diff --git a/.trellis/tasks/07-02-governor-ui-route-cleanup/implement.jsonl b/.trellis/tasks/07-02-governor-ui-route-cleanup/implement.jsonl new file mode 100644 index 0000000..9cd59d4 --- /dev/null +++ b/.trellis/tasks/07-02-governor-ui-route-cleanup/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/07-02-governor-ui-route-cleanup/implement.md b/.trellis/tasks/07-02-governor-ui-route-cleanup/implement.md new file mode 100644 index 0000000..e582528 --- /dev/null +++ b/.trellis/tasks/07-02-governor-ui-route-cleanup/implement.md @@ -0,0 +1,221 @@ +# Governor UI route cleanup implementation plan + +## Checklist + +1. Load backend specs for Governor, CodexCont dashboard, and usage portal + contracts. +2. Refactor Governor admin HTML into a CodexCont status dashboard: + - read-only page, + - snapshot fetches via `/governor/codexcont/admin/*`, + - SSE with reconnect/foreground resume, + - no Key/request/config tabs. +3. Refactor Governor user HTML into two tabs: + - quota and recent request detail, + - per-key protection status. +4. Hide the user resource from CPAMP sidebar menus while keeping the resource + routable for `cpa-usage.konbakuyomu.us`. +5. Extend usage-event storage/projection with optional CPAMP realtime-style + fields: requested/actual model, provider, executor type, reasoning effort, + service tier, TTFT, and failure status code. +6. Extend user CodexCont API to return safe per-key protection summaries, or a + safe empty projection when no matching summaries exist. +7. Adjust shared CSS to match CodexCont dashboard style and remove spinner / + diagonal animation. +8. Rework user refresh behavior: + - tab switches render cached state immediately, + - manual refresh keeps a visible short sync state, + - background polling pauses, + - foreground resume fetches a fresh snapshot, + - row signatures prevent no-op full table redraws. +9. Rework admin Governor refresh/SSE behavior: + - snapshot fetches are abortable and time out, + - hidden tabs close the SSE stream, + - foreground resume forces a fresh snapshot and stream reconnect, + - stale delayed processing follow-ups cannot keep the page stuck. +10. Update tests for menu hiding, schema migration, admin/user HTML shape, + removed controls, user filtering, request-detail projection, and no-store + behavior. +11. Run local checks: + - `go test ./...` in `cpa_governor_plugin/go` + - `.venv\\Scripts\\python.exe tests\\test_middleware.py` + - `.venv\\Scripts\\python.exe tests\\test_cpa_usage_portal.py` + - compileall for Python runtime files + - `git diff --check` +12. Use Playwright CLI to validate local/static or test-served admin/user pages + at desktop and 390px mobile widths, including immediate tab switching, + two no-op polling cycles without table jitter, changed-row highlighting, + and hung-refresh recovery for both admin and user pages. +13. Build linux/amd64 Governor plugin artifact and record SHA256. +14. Deploy to SJC: + - check disk, + - backup CPA plugin binary/config/Caddyfile/CodexCont route config, + - upload self-owned artifact and Caddy route patch, + - restart only necessary services. +15. Server smoke: + - CPA health and authenticated model path, + - CPAMP sidebar Governor page, + - CPAMP sidebar no longer advertises `CPA Usage`, + - dedicated user page login, + - Kuma test key user page refresh remains recoverable after idle/manual + refresh and current conversation traffic appears in realtime rows, + - `/codexcont/` returns 404, + - `/governor/codexcont/admin/*` works behind admin host, + - public API domain blocks admin/plugin paths. +16. Update specs or task notes with learned contracts, commit, and archive. + +## Risk points + +- CPA plugin ResourceRoute is GET-only. Do not add POST-only user-resource + behavior. +- CPAMP embedded pages may keep stale JS. Keep no-store and advise hard refresh + only if validation shows old HTML still loaded. +- Admin SSE data path must remain admin-host-only; public exposure fails + acceptance. +- The task must not switch `codexcont_route` or change the production execution + path. + +## Implementation Evidence + +- Local Go tests passed in `cpa_governor_plugin/go`: `go test ./...`. +- Python regression passed: `.venv\Scripts\python.exe tests\test_middleware.py` with 163/163 checks and `.venv\Scripts\python.exe tests\test_cpa_usage_portal.py` with 84/84 checks. +- Python compile smoke passed: `.venv\Scripts\python.exe -m compileall middleware cpa_usage_portal run.py run_usage_portal.py`. +- `git diff --check` passed. +- Playwright CLI verified local preview pages: + - `artifacts/governor-admin-desktop.png` + - `artifacts/governor-admin-mobile.png` + - `artifacts/governor-user-desktop.png` + - `artifacts/governor-user-mobile.png` +- Linux plugin artifact built from WSL: + - `cpa_governor_plugin/dist/linux/amd64/cpa-governor.so` + - SHA256 `257228790455b9bc345bdf6d390a01b114482e8044e8c71251b3018e3a5e4538` + - `file` reported ELF 64-bit x86-64 shared object. + +## Server Deployment Evidence + +- Pre-deploy SJC root disk was tight but usable: `9.6G` total, about `507M` free. No Docker prune, no image pull, and no container rebuild were used. +- Backup path: `/root/cpa-governor-ui-route-cleanup-backups/20260702-governor-ui-route-cleanup-130447`. +- Uploaded only the Governor `.so`; installed server SHA256 matches local artifact. +- Restarted only `cpa` and `cpa-admin-proxy`; reloaded `caddy-edge` config. Existing `codexcont`, `cpamp`, and `cpa-usage-portal` containers were not rebuilt. +- Admin proxy Caddy now returns `404` for `/codexcont/` and exposes admin-only `/governor/codexcont/admin/*` to CodexCont `/admin/*`. +- Public `cpa.konbakuyomu.us` blocker now includes `/governor*` alongside `/codexcont*` and plugin/admin paths. +- Server smoke results: + - `http://127.0.0.1:8317/healthz`: `200`. + - `http://127.0.0.1:8327/governor/`: `200`, contains `CodexCont 实时保护状态` and no old Key management controls. + - `http://127.0.0.1:8327/governor-user/`: `200`, contains only `额度与明细` and `思维链保护` tabs. + - `http://127.0.0.1:8327/codexcont/`: `404`. + - `http://127.0.0.1:8327/governor/codexcont/admin/status`: `200`. + - `http://127.0.0.1:8327/governor/codexcont/admin/requests?limit=2`: `200`. + - `http://127.0.0.1:8327/governor/codexcont/admin/logs/stream?once=1`: `200`, emitted `ready` and `request` events. + - Public `https://cpa.konbakuyomu.us/v0/resource/plugins/cpa-governor/admin`: `404`. + - Public `https://cpa.konbakuyomu.us/codexcont/`: `404`. + - Public `https://cpa.konbakuyomu.us/governor/`: `404`. + - Public `https://cpa-usage.konbakuyomu.us/`: `200`, contains the two-tab user page. +- CPA logs after restart showed `plugin loaded plugin_id=cpa-governor` and `plugin registered plugin_id=cpa-governor`. +- User API smoke used a server-signed short-lived session without printing raw keys or secrets: + - `/governor-user/api/me`: `200`, safe name/preview only. + - `/governor-user/api/codexcont?limit=80` for `QQ专用`: `200`, returned only that key's CodexCont summaries. + - `/governor-user/api/events?range=24h&limit=3`: `200`. +- Post-deploy disk remained about `495M` free. + +## Animation Refinement Evidence + +- User feedback after the first rollout: `CPA Usage` still felt stiff, and the + top-right refresh button lacked clear realtime feedback. +- Added a refresh state model to the user page: `同步中` with `aria-busy`, then + `刚刚更新` or `同步失败`; hand-triggered refresh keeps a short minimum visible + syncing state so fast responses do not appear as no-op clicks. +- Added shared CSS hooks for smooth realtime behavior without reintroducing + the old spinner or diagonal metric decoration: + `syncSweep`, live topbar sweep, metric bump, changed row highlight, and + content enter transition. +- Local validation after the refinement: + - `go test ./...` in `cpa_governor_plugin/go`. + - `.venv\Scripts\python.exe tests\test_middleware.py` with 163/163 checks. + - `.venv\Scripts\python.exe tests\test_cpa_usage_portal.py` with 84/84 checks. + - `.venv\Scripts\python.exe -m compileall middleware cpa_usage_portal run.py run_usage_portal.py`. + - `git diff --check`. +- Playwright preview validation: + - `artifacts/governor-user-animation-desktop.png` + - `artifacts/governor-user-animation-mobile.png` + - `artifacts/governor-user-refresh-syncing.png` + - Refresh state sample confirmed `syncing` at 0 ms and 180 ms, then + `just-updated` at 600 ms. +- Linux plugin artifact rebuilt after the animation refinement: + - SHA256 `bd980ad3ea82e224ed6becfc1e1e9729eb9a7e7f539c3853494fe005362c1813`. +- SJC deployment evidence for the animation refinement: + - Pre-deploy root disk remained tight: about `489M` free; no Docker prune, + image pull, or container rebuild was used. + - Backup path: + `/root/cpa-governor-ui-route-cleanup-backups/20260702-governor-usage-animation-140408`. + - Uploaded only `cpa-governor.so`, installed server SHA256 matched the local + artifact, and restarted only `cpa`. + - CPA logs showed `plugin loaded plugin_id=cpa-governor` and + `plugin registered plugin_id=cpa-governor` after restart. + - Server smoke: `http://127.0.0.1:8317/healthz` returned `200`; + `http://127.0.0.1:8327/governor-user/` contained `sync-button`, + `content-refreshing`, and `row-fresh`; public + `https://cpa.konbakuyomu.us/governor/` returned `404`; + public `https://cpa-usage.konbakuyomu.us/` contained the new animation + hooks. + - Post-deploy disk was about `477M` free, and `/tmp/cpa-governor.so` was + removed explicitly. + +## Sync Light Follow-up Evidence + +- User feedback after the animation refinement: the right-top realtime/sync + status light also needs to follow the same state as the refresh label and + realtime chip. +- Fixed both custom Governor pages so the refresh button light keeps a live + state class (`live-ok`, `live-info`, `live-warn`, or `live-bad`) driven by + the same status function as the connection chip. When the temporary + `刚刚更新` label resets to `刷新`, the light remains in the current live + state instead of falling back to grey. +- Admin Governor page now also keeps a minimum visible `同步中` duration for + manual/foreground snapshot refreshes, matching the user page and preventing + fast local snapshots from making the click feel like a no-op. +- Local validation after the sync-light follow-up: + - `go test ./...` in `cpa_governor_plugin/go`. + - JavaScript syntax check by extracting ` + + diff --git a/cpa_governor_plugin/go/assets/shared.css b/cpa_governor_plugin/go/assets/shared.css new file mode 100644 index 0000000..77b4600 --- /dev/null +++ b/cpa_governor_plugin/go/assets/shared.css @@ -0,0 +1,571 @@ +:root { + color-scheme: dark; + --bg: #0b1220; + --panel: #151d2c; + --panel-2: #1a2434; + --line: #283448; + --line-strong: #33445e; + --text: #e8edf5; + --muted: #9aa7b8; + --blue: #3b96ff; + --blue-soft: rgba(59, 150, 255, .14); + --green: #61d34f; + --green-soft: rgba(97, 211, 79, .13); + --red: #ff646d; + --red-soft: rgba(255, 100, 109, .13); + --amber: #f0aa2b; + --amber-soft: rgba(240, 170, 43, .14); + --teal: #15c7d4; + --teal-soft: rgba(21, 199, 212, .12); + --shadow: 0 18px 48px rgba(0, 0, 0, .26); + --mono: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + --sans: Inter, "Segoe UI", "Microsoft YaHei", system-ui, sans-serif; + font-family: var(--sans); +} +* { box-sizing: border-box; } +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at 60% -10%, rgba(59, 150, 255, .16), transparent 30%), + linear-gradient(180deg, #101827 0%, var(--bg) 38%, #080e19 100%); + color: var(--text); + font: 14px/1.48 var(--sans); + letter-spacing: 0; +} +button, select, input { + min-height: 38px; + border: 1px solid var(--line); + border-radius: 8px; + background: #111827; + color: var(--text); + padding: 0 12px; + font: inherit; +} +button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + cursor: pointer; + white-space: nowrap; + transition: border-color .18s ease, background .18s ease, color .18s ease, box-shadow .18s ease, transform .18s ease; +} +button:hover, button.active, select:hover, input:focus { + border-color: var(--line-strong); + outline: none; +} +button:hover { transform: translateY(-1px); } +button:disabled { + cursor: wait; + opacity: .92; +} +button.primary { + border-color: rgba(21, 199, 212, .42); + background: rgba(21, 199, 212, .18); +} +button.ghost { background: var(--panel-2); } +button.compact { + min-height: 28px; + padding: 0 9px; + margin-top: 6px; + font-size: 12px; +} +.sync-button { + position: relative; + overflow: hidden; + min-width: 96px; + isolation: isolate; +} +.sync-button::after { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + background: linear-gradient(90deg, transparent, rgba(21, 199, 212, .22), transparent); + transform: translate3d(-120%, 0, 0); + opacity: 0; +} +.sync-button.syncing { + border-color: rgba(21, 199, 212, .5); + color: #baf7ff; + box-shadow: 0 0 0 1px rgba(21, 199, 212, .1), 0 0 22px rgba(21, 199, 212, .12); +} +.sync-button.syncing::after { + opacity: 1; + animation: syncSweep 1s ease-in-out infinite; +} +.sync-button.just-updated { + border-color: rgba(97, 211, 79, .5); + color: #c8ffc0; + animation: syncConfirm .95s ease both; +} +.sync-button.sync-error { + border-color: rgba(255, 100, 109, .55); + color: #ffc3c7; + animation: syncError .95s ease both; +} +.sync-light { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--muted); + box-shadow: 0 0 0 0 rgba(154, 167, 184, .2); + position: relative; + flex: 0 0 auto; + transition: background .18s ease, box-shadow .18s ease; +} +.sync-button.live-ok .sync-light { + background: var(--green); + box-shadow: 0 0 14px rgba(97, 211, 79, .34); + animation: statusBlink 1.45s ease-in-out infinite; +} +.sync-button.live-info .sync-light { + background: var(--blue); + box-shadow: 0 0 14px rgba(59, 150, 255, .34); + animation: statusBlink 1.45s ease-in-out infinite; +} +.sync-button.live-warn .sync-light { + background: var(--amber); + box-shadow: 0 0 14px rgba(240, 170, 43, .34); + animation: statusBlink 1.45s ease-in-out infinite; +} +.sync-button.live-bad .sync-light { + background: var(--red); + box-shadow: 0 0 14px rgba(255, 100, 109, .34); + animation: statusBlink 1.45s ease-in-out infinite; +} +.sync-button.syncing .sync-light { + background: var(--teal); + animation: statusBlink 1s ease-in-out infinite; +} +.sync-button.just-updated .sync-light { + background: var(--green); + box-shadow: 0 0 16px rgba(97, 211, 79, .42); +} +.sync-button.sync-error .sync-light { + background: var(--red); + box-shadow: 0 0 16px rgba(255, 100, 109, .38); +} +.shell { + width: min(1680px, calc(100% - 32px)); + margin: 0 auto; + padding: 22px 0 34px; +} +.topbar { + position: relative; + overflow: hidden; + min-height: 58px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 0 16px; + margin-bottom: 16px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(17, 24, 39, .9); + box-shadow: var(--shadow); +} +.topbar::after { + content: ""; + position: absolute; + left: 14px; + right: 14px; + bottom: 0; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(21, 199, 212, .78), transparent); + transform: translate3d(-115%, 0, 0); + opacity: 0; + pointer-events: none; +} +.topbar.live-active::after { + opacity: .78; + animation: liveSweep 3s ease-in-out infinite; +} +.topbar.live-tick::after { + opacity: 1; + animation: syncSweep .72s ease-out 1; +} +.brand { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} +.brand > div { min-width: 0; } +.mark { + width: 34px; + height: 34px; + display: grid; + place-items: center; + border-radius: 8px; + background: linear-gradient(135deg, #2d86ef, #15c7d4); + color: #fff; + font-weight: 850; +} +h1, h2, h3, p { margin: 0; } +h1 { + font-size: 20px; + line-height: 1.2; + font-weight: 780; +} +h2 { font-size: 15px; font-weight: 760; } +h3 { font-size: 13px; font-weight: 760; } +.subtitle { + margin-top: 2px; + color: var(--muted); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.toolbar, .filters { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} +.chip { + display: inline-flex; + align-items: center; + gap: 7px; + min-height: 32px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid var(--line); + background: var(--panel-2); + color: var(--muted); + font-size: 12px; + font-weight: 720; + line-height: 1.2; + white-space: nowrap; + transition: border-color .18s ease, background .18s ease, color .18s ease, box-shadow .18s ease; +} +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--muted); + position: relative; + flex: 0 0 auto; +} +.chip.stream .dot { + animation: statusBlink 1.45s ease-in-out infinite; +} +.chip.stream .dot::after { + content: ""; + position: absolute; + inset: -5px; + border-radius: inherit; + border: 1px solid currentColor; + opacity: .55; + animation: statusPing 1.45s ease-out infinite; +} +.chip.ok { border-color: rgba(97, 211, 79, .28); background: var(--green-soft); color: #a8ef9c; } +.chip.ok .dot { background: var(--green); } +.chip.bad { border-color: rgba(255, 100, 109, .3); background: var(--red-soft); color: #ffb1b6; } +.chip.bad .dot { background: var(--red); } +.chip.warn { border-color: rgba(240, 170, 43, .3); background: var(--amber-soft); color: #ffd38a; } +.chip.warn .dot { background: var(--amber); } +.chip.info { border-color: rgba(59, 150, 255, .32); background: var(--blue-soft); color: #9dccff; } +.chip.info .dot { background: var(--blue); } +.metrics { + display: grid; + grid-template-columns: repeat(4, minmax(150px, 1fr)); + gap: 10px; + margin-bottom: 14px; +} +.metrics.cards-updated .metric { + animation: metricBump .74s ease both; +} +.metric { + min-height: 86px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(17, 24, 39, .84); + box-shadow: var(--shadow); + transition: border-color .2s ease, background .2s ease, box-shadow .2s ease, transform .2s ease; +} +.metric .label { + color: var(--muted); + font-size: 12px; + font-weight: 720; +} +.metric .value { + margin-top: 6px; + font-size: 25px; + line-height: 1; + font-weight: 820; +} +.metric .hint { + margin-top: 7px; + color: var(--muted); + font-size: 12px; +} +.panel { + margin-bottom: 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(17, 24, 39, .9); + box-shadow: var(--shadow); + overflow: hidden; +} +.panel-head { + min-height: 48px; + padding: 12px 14px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid var(--line); +} +.panel-body { padding: 14px; } +.embedded-panel { + margin-bottom: 0; + box-shadow: none; +} +.tabs { + display: flex; + gap: 8px; + margin-bottom: 14px; + overflow-x: auto; +} +.tabs button { + position: relative; + overflow: hidden; +} +.tabs button.active::after { + content: ""; + position: absolute; + left: 12px; + right: 12px; + bottom: 4px; + height: 2px; + border-radius: 999px; + background: rgba(21, 199, 212, .72); + animation: tabUnderline .24s ease-out both; +} +.tabs button.active { + border-color: rgba(21, 199, 212, .42); + background: rgba(21, 199, 212, .18); + color: #baf7ff; +} +#content { + transition: opacity .18s ease, transform .18s ease; +} +#content.content-refreshing { + opacity: .72; + transform: translate3d(0, 3px, 0); +} +#content.view-enter { + animation: viewRise .26s ease-out both; +} +.table-wrap { overflow-x: auto; } +table { + width: 100%; + min-width: 980px; + border-collapse: collapse; + table-layout: fixed; +} +.realtime-table { + min-width: 1180px; +} +th, td { + border-bottom: 1px solid rgba(40, 52, 72, .75); + padding: 11px 12px; + text-align: left; + vertical-align: middle; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +tbody tr { + transition: background-color .24s ease, box-shadow .24s ease, transform .2s ease; +} +tbody tr:hover { + background: rgba(59, 150, 255, .045); +} +tbody tr.data-row { + background-clip: padding-box; +} +tbody tr.row-fresh { + animation: rowFresh 1.45s ease-out both; +} +th { + color: var(--muted); + font-size: 12px; + font-weight: 760; +} +td strong { display: block; } +.strong { + display: block; + color: var(--text); + font-weight: 780; +} +.success { color: var(--green); } +.danger { color: var(--red); } +small, .muted { + color: var(--muted); + font-size: 12px; +} +.mono { font-family: var(--mono); } +.good { color: var(--green); font-weight: 760; } +.bad-text { color: var(--red); font-weight: 760; } +.blue { color: var(--blue); font-weight: 760; } +.detail-row td { + white-space: normal; + overflow: visible; + background: rgba(11, 18, 32, .75); +} +.detail-grid { + display: grid; + grid-template-columns: repeat(4, minmax(220px, 1fr)); + gap: 10px; +} +.detail-card { + min-width: 0; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(15, 23, 42, .82); +} +.kv { + display: grid; + grid-template-columns: minmax(92px, 42%) 1fr; + gap: 7px 10px; + margin-top: 10px; +} +.kv dt { color: var(--muted); } +.kv dd { + margin: 0; + min-width: 0; + overflow-wrap: anywhere; +} +.detail-note { + margin-top: 10px; + color: var(--muted); + font-size: 12px; +} +.failure-box { + margin-top: 10px; + min-height: 46px; + max-height: 132px; + overflow: auto; + padding: 10px; + border: 1px solid rgba(40, 52, 72, .78); + border-radius: 8px; + background: rgba(8, 14, 25, .7); + color: #d7deea; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.empty { + padding: 22px; + text-align: center; + color: var(--muted); +} +.login { + max-width: 720px; + margin: 52px auto; +} +.login-row { + display: flex; + gap: 10px; + margin-top: 14px; +} +.login input { + flex: 1; + min-width: 0; +} +#err { margin-top: 10px; color: #ffb1b6; } +details.advanced > summary { + cursor: pointer; + padding: 12px 14px; + color: var(--muted); + font-weight: 760; +} +.logs { + max-height: 320px; + overflow: auto; + padding: 0 14px 14px; +} +.log-line { + display: grid; + grid-template-columns: 92px 86px 150px 1fr; + gap: 10px; + padding: 8px 0; + border-top: 1px solid rgba(40, 52, 72, .6); + font-family: var(--mono); + font-size: 12px; +} +.hidden { display: none !important; } +@keyframes statusBlink { + 0%, 100% { transform: scale(.9); opacity: .65; } + 50% { transform: scale(1.18); opacity: 1; } +} +@keyframes statusPing { + 0% { transform: scale(.5); opacity: .55; } + 80%, 100% { transform: scale(1.8); opacity: 0; } +} +@keyframes syncSweep { + 0% { transform: translate3d(-120%, 0, 0); } + 100% { transform: translate3d(120%, 0, 0); } +} +@keyframes liveSweep { + 0% { transform: translate3d(-115%, 0, 0); opacity: .18; } + 15% { opacity: .78; } + 55% { opacity: .78; } + 100% { transform: translate3d(115%, 0, 0); opacity: .18; } +} +@keyframes syncConfirm { + 0% { box-shadow: 0 0 0 rgba(97, 211, 79, 0); } + 35% { box-shadow: 0 0 0 3px rgba(97, 211, 79, .16), 0 0 24px rgba(97, 211, 79, .16); } + 100% { box-shadow: 0 0 0 rgba(97, 211, 79, 0); } +} +@keyframes syncError { + 0% { box-shadow: 0 0 0 rgba(255, 100, 109, 0); } + 35% { box-shadow: 0 0 0 3px rgba(255, 100, 109, .15), 0 0 24px rgba(255, 100, 109, .14); } + 100% { box-shadow: 0 0 0 rgba(255, 100, 109, 0); } +} +@keyframes metricBump { + 0% { transform: translate3d(0, 0, 0); border-color: var(--line); } + 34% { transform: translate3d(0, -2px, 0); border-color: rgba(21, 199, 212, .34); } + 100% { transform: translate3d(0, 0, 0); border-color: var(--line); } +} +@keyframes rowFresh { + 0% { background: rgba(21, 199, 212, .18); box-shadow: inset 3px 0 0 rgba(21, 199, 212, .8); } + 60% { background: rgba(21, 199, 212, .08); box-shadow: inset 3px 0 0 rgba(21, 199, 212, .35); } + 100% { background: transparent; box-shadow: inset 3px 0 0 rgba(21, 199, 212, 0); } +} +@keyframes viewRise { + 0% { opacity: .78; transform: translate3d(0, 4px, 0); } + 100% { opacity: 1; transform: translate3d(0, 0, 0); } +} +@keyframes tabUnderline { + 0% { transform: translate3d(-8px, 0, 0); opacity: 0; } + 100% { transform: translate3d(0, 0, 0); opacity: 1; } +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: .01ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + transition-duration: .01ms !important; + } +} +@media (max-width: 900px) { + .metrics { grid-template-columns: repeat(2, minmax(140px, 1fr)); } + .detail-grid { grid-template-columns: 1fr; } +} +@media (max-width: 620px) { + .shell { width: min(100% - 20px, 1680px); padding-top: 12px; } + .topbar { align-items: flex-start; flex-direction: column; padding: 12px; } + .toolbar, .filters { justify-content: flex-start; } + .metrics { grid-template-columns: 1fr; } + .login-row { flex-direction: column; } + table { min-width: 900px; } + .log-line { grid-template-columns: 1fr; gap: 3px; } +} diff --git a/cpa_governor_plugin/go/assets/user.html b/cpa_governor_plugin/go/assets/user.html new file mode 100644 index 0000000..1aee5b2 --- /dev/null +++ b/cpa_governor_plugin/go/assets/user.html @@ -0,0 +1,586 @@ + + + + + + CPA 用量自助页 + + + +
+
+
+
U
+
+

CPA 用量自助页

+

单 Key 实时监控、额度明细和思维链保护状态

+
+
+
+ + 未登录 + + +
+
+ + + + +
+ + + + diff --git a/cpa_governor_plugin/go/internal/governor/governor_test.go b/cpa_governor_plugin/go/internal/governor/governor_test.go index 9006d8d..cdc0106 100644 --- a/cpa_governor_plugin/go/internal/governor/governor_test.go +++ b/cpa_governor_plugin/go/internal/governor/governor_test.go @@ -2,6 +2,7 @@ package governor import ( "context" + "database/sql" "encoding/json" "os" "path/filepath" @@ -152,6 +153,101 @@ func TestStoreUsageWindowsAndSoftReset(t *testing.T) { if used != 0.25 { t.Fatalf("used after reset = %v", used) } + summary, err := store.UsageSummary(ctx, "alice-key", WindowFor(Range5H, now)) + if err != nil { + t.Fatal(err) + } + if summary.Calls != 1 || summary.TotalCost != 0.25 { + t.Fatalf("summary after reset = %#v", summary) + } +} + +func TestStoreMigratesOldUsageEventsSchema(t *testing.T) { + path := filepath.Join(t.TempDir(), "governor.sqlite") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + _, err = db.Exec(`create table usage_events ( + id integer primary key autoincrement, + request_id text, + key_id text, + key_preview text, + model text, + endpoint text, + requested_at integer not null, + latency_ms integer, + failed integer not null, + failure text, + input_tokens integer, + output_tokens integer, + cached_tokens integer, + cache_read_tokens integer, + cache_creation_tokens integer, + reasoning_tokens integer, + total_tokens integer, + cost real, + cost_breakdown_json text + )`) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`insert into usage_events(request_id, key_id, model, requested_at, failed, cost, cost_breakdown_json) values('old-1', 'alice-key', 'gpt-5.5', ?, 0, 0.1, '{}')`, time.Now().Unix()); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + store, err := OpenStore(path) + if err != nil { + t.Fatal(err) + } + defer store.Close() + events, err := store.RecentEvents(context.Background(), "alice-key", 10) + if err != nil { + t.Fatal(err) + } + if len(events) != 1 || events[0].RequestID != "old-1" { + t.Fatalf("events = %#v", events) + } + if events[0].RequestedModel != "" || events[0].TTFTMS != 0 || events[0].StatusCode != 0 { + t.Fatalf("old row should read safe zero values: %#v", events[0]) + } +} + +func TestStoreRecentCodexSummariesFiltersByKey(t *testing.T) { + ctx := context.Background() + store, err := OpenStore(filepath.Join(t.TempDir(), "governor.sqlite")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if err := store.SaveCodexSummary(ctx, "req-a", "alice-key", "gpt-5.5", "auto_continued", map[string]any{ + "request_id": "req-a", + "protection": "auto_continued", + }); err != nil { + t.Fatal(err) + } + if err := store.SaveCodexSummary(ctx, "req-b", "bob-key", "gpt-5.5", "protected_clean", map[string]any{ + "request_id": "req-b", + "protection": "protected_clean", + }); err != nil { + t.Fatal(err) + } + alice, err := store.RecentCodexSummaries(ctx, "alice-key", 10) + if err != nil { + t.Fatal(err) + } + if len(alice) != 1 || alice[0].RequestID != "req-a" || alice[0].Protection != "auto_continued" { + t.Fatalf("alice summaries = %#v", alice) + } + all, err := store.RecentCodexSummaries(ctx, "all", 10) + if err != nil { + t.Fatal(err) + } + if len(all) != 2 { + t.Fatalf("all summaries = %#v", all) + } } func TestSecurityAndRedaction(t *testing.T) { diff --git a/cpa_governor_plugin/go/internal/governor/store.go b/cpa_governor_plugin/go/internal/governor/store.go index b9791ca..f8d5b15 100644 --- a/cpa_governor_plugin/go/internal/governor/store.go +++ b/cpa_governor_plugin/go/internal/governor/store.go @@ -17,18 +17,42 @@ type Store struct { } type UsageEvent struct { - RequestID string `json:"request_id"` - KeyID string `json:"key_id"` - KeyPreview string `json:"key_preview"` - Model string `json:"model"` - Endpoint string `json:"endpoint,omitempty"` - RequestedAt time.Time `json:"requested_at"` - LatencyMS int64 `json:"latency_ms"` - Failed bool `json:"failed"` - Failure string `json:"failure,omitempty"` - Usage TokenUsage `json:"usage"` - Cost float64 `json:"cost"` - CostBreakdown CostBreakdown `json:"cost_breakdown"` + RequestID string `json:"request_id"` + KeyID string `json:"key_id"` + KeyPreview string `json:"key_preview"` + Model string `json:"model"` + RequestedModel string `json:"requested_model,omitempty"` + ActualModel string `json:"actual_model,omitempty"` + Provider string `json:"provider,omitempty"` + ExecutorType string `json:"executor_type,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + RequestedAt time.Time `json:"requested_at"` + LatencyMS int64 `json:"latency_ms"` + TTFTMS int64 `json:"ttft_ms,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` + ServiceTier string `json:"service_tier,omitempty"` + StatusCode int `json:"status_code,omitempty"` + Failed bool `json:"failed"` + Failure string `json:"failure,omitempty"` + Usage TokenUsage `json:"usage"` + Cost float64 `json:"cost"` + CostBreakdown CostBreakdown `json:"cost_breakdown"` +} + +type UsageSummary struct { + Calls int64 `json:"calls"` + Failed int64 `json:"failed"` + TotalCost float64 `json:"total_cost"` + Usage TokenUsage `json:"usage"` +} + +type CodexSummary struct { + RequestID string `json:"request_id"` + KeyID string `json:"key_id,omitempty"` + Model string `json:"model,omitempty"` + Protection string `json:"protection,omitempty"` + Summary map[string]any `json:"summary"` + UpdatedAt time.Time `json:"updated_at"` } func OpenStore(path string) (*Store, error) { @@ -88,9 +112,17 @@ func (s *Store) EnsureSchema(ctx context.Context) error { key_id text, key_preview text, model text, + requested_model text, + actual_model text, + provider text, + executor_type text, endpoint text, requested_at integer not null, latency_ms integer, + ttft_ms integer, + reasoning_effort text, + service_tier text, + status_code integer, failed integer not null, failure text, input_tokens integer, @@ -130,6 +162,50 @@ func (s *Store) EnsureSchema(ctx context.Context) error { return err } } + if err := s.ensureColumns(ctx, "usage_events", map[string]string{ + "requested_model": "text default ''", + "actual_model": "text default ''", + "provider": "text default ''", + "executor_type": "text default ''", + "ttft_ms": "integer default 0", + "reasoning_effort": "text default ''", + "service_tier": "text default ''", + "status_code": "integer default 0", + }); err != nil { + return err + } + return nil +} + +func (s *Store) ensureColumns(ctx context.Context, table string, columns map[string]string) error { + rows, err := s.db.QueryContext(ctx, fmt.Sprintf(`pragma table_info(%s)`, table)) + if err != nil { + return err + } + defer rows.Close() + existing := map[string]bool{} + for rows.Next() { + var cid int + var name, typ string + var notNull int + var defaultValue sql.NullString + var pk int + if err := rows.Scan(&cid, &name, &typ, ¬Null, &defaultValue, &pk); err != nil { + return err + } + existing[name] = true + } + if err := rows.Err(); err != nil { + return err + } + for name, typ := range columns { + if existing[name] { + continue + } + if _, err := s.db.ExecContext(ctx, fmt.Sprintf(`alter table %s add column %s %s`, table, name, typ)); err != nil { + return err + } + } return nil } @@ -292,12 +368,15 @@ func (s *Store) InsertUsage(ctx context.Context, event UsageEvent) error { _, err := s.db.ExecContext( ctx, `insert into usage_events( - request_id, key_id, key_preview, model, endpoint, requested_at, latency_ms, failed, failure, + request_id, key_id, key_preview, model, requested_model, actual_model, provider, executor_type, + endpoint, requested_at, latency_ms, ttft_ms, reasoning_effort, service_tier, status_code, failed, failure, input_tokens, output_tokens, cached_tokens, cache_read_tokens, cache_creation_tokens, reasoning_tokens, total_tokens, cost, cost_breakdown_json - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - event.RequestID, event.KeyID, event.KeyPreview, event.Model, event.Endpoint, event.RequestedAt.Unix(), - event.LatencyMS, boolInt(event.Failed), event.Failure, + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + event.RequestID, event.KeyID, event.KeyPreview, event.Model, event.RequestedModel, event.ActualModel, + event.Provider, event.ExecutorType, event.Endpoint, event.RequestedAt.Unix(), + event.LatencyMS, event.TTFTMS, event.ReasoningEffort, event.ServiceTier, event.StatusCode, + boolInt(event.Failed), event.Failure, event.Usage.InputTokens, event.Usage.OutputTokens, event.Usage.CachedTokens, event.Usage.CacheReadTokens, event.Usage.CacheCreationTokens, event.Usage.ReasoningTokens, event.Usage.TotalTokens, event.Cost, string(breakdown), @@ -337,12 +416,56 @@ func (s *Store) ResetAt(ctx context.Context, keyID, window string) (int64, bool) return resetAt.Int64, resetAt.Valid } +func (s *Store) UsageSummary(ctx context.Context, keyID string, window Window) (UsageSummary, error) { + from := window.From.Unix() + if resetAt, ok := s.ResetAt(ctx, keyID, window.Name); ok && resetAt > from { + from = resetAt + } + args := []any{from, window.To.Unix()} + query := `select + count(*), + coalesce(sum(case when failed != 0 then 1 else 0 end), 0), + coalesce(sum(cost), 0), + coalesce(sum(input_tokens), 0), + coalesce(sum(output_tokens), 0), + coalesce(sum(cached_tokens), 0), + coalesce(sum(cache_read_tokens), 0), + coalesce(sum(cache_creation_tokens), 0), + coalesce(sum(reasoning_tokens), 0), + coalesce(sum(total_tokens), 0) + from usage_events where requested_at >= ? and requested_at <= ?` + if keyID != "" && keyID != "all" { + query += ` and key_id = ?` + args = append(args, keyID) + } + var summary UsageSummary + if err := s.db.QueryRowContext(ctx, query, args...).Scan( + &summary.Calls, + &summary.Failed, + &summary.TotalCost, + &summary.Usage.InputTokens, + &summary.Usage.OutputTokens, + &summary.Usage.CachedTokens, + &summary.Usage.CacheReadTokens, + &summary.Usage.CacheCreationTokens, + &summary.Usage.ReasoningTokens, + &summary.Usage.TotalTokens, + ); err != nil { + return UsageSummary{}, err + } + return summary, nil +} + func (s *Store) RecentEvents(ctx context.Context, keyID string, limit int) ([]UsageEvent, error) { if limit <= 0 || limit > 200 { limit = 100 } - query := `select request_id, key_id, key_preview, model, endpoint, requested_at, latency_ms, failed, failure, - input_tokens, output_tokens, cached_tokens, cache_read_tokens, cache_creation_tokens, reasoning_tokens, total_tokens, cost, cost_breakdown_json + query := `select coalesce(request_id, ''), coalesce(key_id, ''), coalesce(key_preview, ''), coalesce(model, ''), + coalesce(requested_model, ''), coalesce(actual_model, ''), coalesce(provider, ''), coalesce(executor_type, ''), + coalesce(endpoint, ''), requested_at, coalesce(latency_ms, 0), coalesce(ttft_ms, 0), + coalesce(reasoning_effort, ''), coalesce(service_tier, ''), coalesce(status_code, 0), coalesce(failed, 0), coalesce(failure, ''), + coalesce(input_tokens, 0), coalesce(output_tokens, 0), coalesce(cached_tokens, 0), coalesce(cache_read_tokens, 0), + coalesce(cache_creation_tokens, 0), coalesce(reasoning_tokens, 0), coalesce(total_tokens, 0), coalesce(cost, 0), coalesce(cost_breakdown_json, '{}') from usage_events` args := []any{} if keyID != "" && keyID != "all" { @@ -363,7 +486,9 @@ func (s *Store) RecentEvents(ctx context.Context, keyID string, limit int) ([]Us var failed int var breakdown string if err := rows.Scan( - &event.RequestID, &event.KeyID, &event.KeyPreview, &event.Model, &event.Endpoint, &ts, &event.LatencyMS, &failed, &event.Failure, + &event.RequestID, &event.KeyID, &event.KeyPreview, &event.Model, &event.RequestedModel, &event.ActualModel, + &event.Provider, &event.ExecutorType, &event.Endpoint, &ts, &event.LatencyMS, &event.TTFTMS, + &event.ReasoningEffort, &event.ServiceTier, &event.StatusCode, &failed, &event.Failure, &event.Usage.InputTokens, &event.Usage.OutputTokens, &event.Usage.CachedTokens, &event.Usage.CacheReadTokens, &event.Usage.CacheCreationTokens, &event.Usage.ReasoningTokens, &event.Usage.TotalTokens, &event.Cost, &breakdown, ); err != nil { @@ -385,8 +510,12 @@ func (s *Store) RecentEventsWindow(ctx context.Context, keyID string, window Win if resetAt, ok := s.ResetAt(ctx, keyID, window.Name); ok && resetAt > from { from = resetAt } - query := `select request_id, key_id, key_preview, model, endpoint, requested_at, latency_ms, failed, failure, - input_tokens, output_tokens, cached_tokens, cache_read_tokens, cache_creation_tokens, reasoning_tokens, total_tokens, cost, cost_breakdown_json + query := `select coalesce(request_id, ''), coalesce(key_id, ''), coalesce(key_preview, ''), coalesce(model, ''), + coalesce(requested_model, ''), coalesce(actual_model, ''), coalesce(provider, ''), coalesce(executor_type, ''), + coalesce(endpoint, ''), requested_at, coalesce(latency_ms, 0), coalesce(ttft_ms, 0), + coalesce(reasoning_effort, ''), coalesce(service_tier, ''), coalesce(status_code, 0), coalesce(failed, 0), coalesce(failure, ''), + coalesce(input_tokens, 0), coalesce(output_tokens, 0), coalesce(cached_tokens, 0), coalesce(cache_read_tokens, 0), + coalesce(cache_creation_tokens, 0), coalesce(reasoning_tokens, 0), coalesce(total_tokens, 0), coalesce(cost, 0), coalesce(cost_breakdown_json, '{}') from usage_events where requested_at >= ? and requested_at <= ?` args := []any{from, window.To.Unix()} if keyID != "" && keyID != "all" { @@ -407,7 +536,9 @@ func (s *Store) RecentEventsWindow(ctx context.Context, keyID string, window Win var failed int var breakdown string if err := rows.Scan( - &event.RequestID, &event.KeyID, &event.KeyPreview, &event.Model, &event.Endpoint, &ts, &event.LatencyMS, &failed, &event.Failure, + &event.RequestID, &event.KeyID, &event.KeyPreview, &event.Model, &event.RequestedModel, &event.ActualModel, + &event.Provider, &event.ExecutorType, &event.Endpoint, &ts, &event.LatencyMS, &event.TTFTMS, + &event.ReasoningEffort, &event.ServiceTier, &event.StatusCode, &failed, &event.Failure, &event.Usage.InputTokens, &event.Usage.OutputTokens, &event.Usage.CachedTokens, &event.Usage.CacheReadTokens, &event.Usage.CacheCreationTokens, &event.Usage.ReasoningTokens, &event.Usage.TotalTokens, &event.Cost, &breakdown, ); err != nil { @@ -431,6 +562,41 @@ func (s *Store) SaveCodexSummary(ctx context.Context, requestID, keyID, model, p return err } +func (s *Store) RecentCodexSummaries(ctx context.Context, keyID string, limit int) ([]CodexSummary, error) { + if limit <= 0 || limit > 200 { + limit = 100 + } + query := `select request_id, key_id, model, protection, summary_json, updated_at from codexcont_summaries` + args := []any{} + if keyID != "" && keyID != "all" { + query += ` where key_id = ?` + args = append(args, keyID) + } + query += ` order by updated_at desc limit ?` + args = append(args, limit) + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []CodexSummary + for rows.Next() { + var item CodexSummary + var raw string + var ts int64 + if err := rows.Scan(&item.RequestID, &item.KeyID, &item.Model, &item.Protection, &raw, &ts); err != nil { + return nil, err + } + item.UpdatedAt = time.Unix(ts, 0) + _ = json.Unmarshal([]byte(raw), &item.Summary) + if item.Summary == nil { + item.Summary = map[string]any{} + } + out = append(out, item) + } + return out, rows.Err() +} + func (s *Store) Audit(ctx context.Context, actor, action, target string, detail any) error { raw, _ := json.Marshal(detail) _, err := s.db.ExecContext(ctx, `insert into audit_log(timestamp, actor, action, target, detail_json) values(?, ?, ?, ?, ?)`, diff --git a/cpa_governor_plugin/go/main.go b/cpa_governor_plugin/go/main.go index 9fc1388..0bb78f7 100644 --- a/cpa_governor_plugin/go/main.go +++ b/cpa_governor_plugin/go/main.go @@ -13,11 +13,21 @@ import ( "time" "codexcont/cpa-governor-plugin/internal/governor" + _ "embed" "gopkg.in/yaml.v3" ) const pluginID = "cpa-governor" +//go:embed assets/admin.html +var adminHTMLTemplate string + +//go:embed assets/user.html +var userHTMLTemplate string + +//go:embed assets/shared.css +var sharedCSSTemplate string + type envelope struct { OK bool `json:"ok"` Result json.RawMessage `json:"result,omitempty"` @@ -84,19 +94,25 @@ func runPreviewIfRequested() { return } cfg := governor.DefaultConfig() - cfg.StateDBPath = ":memory:" + previewDir, err := os.MkdirTemp("", "cpa-governor-preview-*") + if err != nil { + panic(err) + } + cfg.StateDBPath = previewDir + "/governor.sqlite" cfg.SessionSecret = "preview-secret" cfg.CodexContEnabled = true + cfg.CodexContURL = "http://" + addr store, err := governor.OpenStore(cfg.StateDBPath) if err != nil { panic(err) } + previewRawKey := "cpa_preview_abcdefghijklmnopqrstuvwxyz0123456789AB" previewKey := governor.KeyRecord{ ID: "preview-key", Name: "演示用户", - KeyHash: "sha256:" + governor.SHA256Hex("cpa_preview"), + KeyHash: "sha256:" + governor.SHA256Hex(previewRawKey), Enabled: true, - Preview: governor.HashPreview(governor.SHA256Hex("cpa_preview")), + Preview: governor.HashPreview(governor.SHA256Hex(previewRawKey)), RPM: 60, Concurrency: 2, Models: []string{"gpt-5.5", "gpt-5.4"}, @@ -110,13 +126,21 @@ func runPreviewIfRequested() { } _ = store.UpsertKey(context.Background(), previewKey) _ = store.InsertUsage(context.Background(), governor.UsageEvent{ - RequestID: "req-preview-1", - KeyID: previewKey.ID, - KeyPreview: previewKey.Preview, - Model: "gpt-5.5", - Endpoint: "/v1/responses", - RequestedAt: time.Now().Add(-3 * time.Minute), - LatencyMS: 14320, + RequestID: "req-preview-a", + KeyID: previewKey.ID, + KeyPreview: previewKey.Preview, + Model: "gpt-5.5", + RequestedModel: "gpt-5.5", + ActualModel: "gpt-5.5", + Provider: "openai", + ExecutorType: "codex", + Endpoint: "/v1/responses", + RequestedAt: time.Now().Add(-3 * time.Minute), + LatencyMS: 14320, + TTFTMS: 1180, + ReasoningEffort: "high", + ServiceTier: "default", + StatusCode: 200, Usage: governor.TokenUsage{ InputTokens: 120000, CachedTokens: 103000, @@ -133,6 +157,25 @@ func runPreviewIfRequested() { TotalTokens: 122100, }, "gpt-5.5"), }) + _ = store.SaveCodexSummary(context.Background(), "req-preview-a", previewKey.ID, "gpt-5.5", "auto_continued", map[string]any{ + "request_id": "req-preview-a", + "model": "gpt-5.5", + "path": "/v1/responses", + "started_at": time.Now().Add(-2 * time.Minute).Format(time.RFC3339), + "updated_at": time.Now().Add(-90 * time.Second).Format(time.RFC3339), + "duration_ms": 5570, + "status": "completed", + "protection": "auto_continued", + "key_identity": previewKey.Safe(), + "latest_round": 2, + "latest_reasoning_tokens": 181, + "first_truncation_round": 1, + "first_truncation_reasoning_tokens": 516, + "first_truncation_decision": "continue", + "continuation_count": 1, + "stopped_reason": "completed", + "rounds": []map[string]any{{"round": 1, "reasoning_tokens": 516, "decision": "continue", "truncation_match": true}, {"round": 2, "reasoning_tokens": 181, "decision": "clean", "truncation_match": false}}, + }) state.mu.Lock() state.cfg = cfg state.store = store @@ -151,6 +194,28 @@ func runPreviewIfRequested() { w.Header().Set("content-type", "text/html; charset=utf-8") _, _ = w.Write([]byte(userHTML())) }) + mux.HandleFunc("/governor/codexcont/admin/status", func(w http.ResponseWriter, r *http.Request) { + _ = r + w.Header().Set("content-type", "application/json; charset=utf-8") + _, _ = w.Write([]byte(`{"ok":true,"counters":{"total_requests":3,"active_requests":1,"continuations":1,"truncation_hits":1,"failures":0},"last_error":null}`)) + }) + mux.HandleFunc("/governor/codexcont/admin/requests", func(w http.ResponseWriter, r *http.Request) { + _ = r + w.Header().Set("content-type", "application/json; charset=utf-8") + _, _ = w.Write([]byte(`{"requests":[{"request_id":"req-preview-a","model":"gpt-5.5","path":"/v1/responses","started_at":"2026-07-02T02:09:31Z","updated_at":"2026-07-02T02:09:42Z","duration_ms":5570,"status":"completed","protection":"auto_continued","key_identity":{"known":true,"name":"演示用户","id":"preview-key","preview":"cpa_...view","enabled":true},"latest_round":2,"latest_reasoning_tokens":181,"first_truncation_round":1,"first_truncation_reasoning_tokens":516,"continuation_count":1,"rounds":[{"round":1,"reasoning_tokens":516,"decision":"continue","truncation_match":true},{"round":2,"reasoning_tokens":181,"decision":"clean","truncation_match":false}]},{"request_id":"req-preview-b","model":"gpt-5.5","path":"/v1/responses","started_at":"2026-07-02T02:09:54Z","updated_at":"2026-07-02T02:09:59Z","duration_ms":3200,"status":"processing","protection":"processing","key_identity":{"known":true,"name":"演示用户","id":"preview-key","preview":"cpa_...view","enabled":true},"latest_round":1,"latest_reasoning_tokens":140,"continuation_count":0,"rounds":[{"round":1,"reasoning_tokens":140,"decision":"clean","truncation_match":false}]}]}`)) + }) + mux.HandleFunc("/governor/codexcont/admin/logs/stream", func(w http.ResponseWriter, r *http.Request) { + _ = r + w.Header().Set("content-type", "text/event-stream") + w.Header().Set("cache-control", "no-cache") + _, _ = w.Write([]byte("event: ready\ndata: {\"ok\":true}\n\n")) + _, _ = w.Write([]byte("event: log\ndata: {\"ts\":\"2026-07-02T02:09:59Z\",\"level\":\"info\",\"event\":\"round_decision\",\"message\":\"preview round decision\",\"fields\":{\"request_id\":\"req-preview-a\"}}\n\n")) + }) + mux.HandleFunc("/engine/healthz", func(w http.ResponseWriter, r *http.Request) { + _ = r + w.Header().Set("content-type", "application/json; charset=utf-8") + _, _ = w.Write([]byte(`{"ok":true,"mode":"preview"}`)) + }) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { rawReq, _ := json.Marshal(managementRequest{ Method: r.Method, @@ -698,18 +763,26 @@ func usageHandle(raw []byte) ([]byte, error) { } } event := governor.UsageEvent{ - RequestID: rec.ResponseHeaders.Get("x-request-id"), - KeyID: key.ID, - KeyPreview: key.Preview, - Model: firstNonEmpty(rec.Alias, rec.Model), - Endpoint: rec.Source, - RequestedAt: rec.RequestedAt, - LatencyMS: rec.Latency.Milliseconds(), - Failed: rec.Failed, - Failure: governor.Brief(rec.Failure.Body, 600), - Usage: usage, - Cost: cost, - CostBreakdown: breakdown, + RequestID: firstNonEmpty(rec.ResponseHeaders.Get("x-request-id"), rec.ResponseHeaders.Get("x-openai-request-id")), + KeyID: key.ID, + KeyPreview: key.Preview, + Model: firstNonEmpty(rec.Alias, rec.Model), + RequestedModel: firstNonEmpty(rec.Alias, rec.Model), + ActualModel: rec.Model, + Provider: rec.Provider, + ExecutorType: rec.ExecutorType, + Endpoint: rec.Source, + RequestedAt: rec.RequestedAt, + LatencyMS: rec.Latency.Milliseconds(), + TTFTMS: rec.TTFT.Milliseconds(), + ReasoningEffort: rec.ReasoningEffort, + ServiceTier: rec.ServiceTier, + StatusCode: rec.Failure.StatusCode, + Failed: rec.Failed, + Failure: governor.Brief(rec.Failure.Body, 600), + Usage: usage, + Cost: cost, + CostBreakdown: breakdown, } _ = store.InsertUsage(context.Background(), event) return okEnvelope(map[string]any{}) @@ -732,7 +805,7 @@ func managementRegister() ([]byte, error) { {Path: "/admin/api/keys/reset"}, {Path: "/admin/api/events"}, {Path: "/admin/api/codexcont"}, - {Path: "/user", Menu: "CPA Usage", Description: "Self-service usage dashboard"}, + {Path: "/user", Description: "Self-service usage dashboard"}, {Path: "/user/api/session"}, {Path: "/user/api/me"}, {Path: "/user/api/usage"}, @@ -773,7 +846,7 @@ func managementHandle(raw []byte) ([]byte, error) { case strings.Contains(path, "/user/api/events"): return userEvents(req) case strings.Contains(path, "/user/api/codexcont"): - return jsonResponse(http.StatusOK, map[string]any{"ok": true, "codexcont": codexcontStatus()}) + return userCodexCont(req) case strings.HasSuffix(path, "/plugins/cpa-governor/keys"): return adminKeys(req) case strings.HasSuffix(path, "/plugins/cpa-governor/keys/limits"): @@ -992,8 +1065,28 @@ func userUsage(req managementRequest) ([]byte, error) { if rangeName == "" { rangeName = governor.Range24H } - used, _ := store.UsageSum(context.Background(), key.ID, governor.WindowFor(rangeName, time.Now())) - return jsonResponse(http.StatusOK, map[string]any{"ok": true, "range": rangeName, "summary": map[string]any{"total_cost": used}, "limits": key.Safe()["limits"]}) + summary, err := store.UsageSummary(context.Background(), key.ID, governor.WindowFor(rangeName, time.Now())) + if err != nil { + return jsonResponse(http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()}) + } + success := summary.Calls - summary.Failed + successRate := 0.0 + if summary.Calls > 0 { + successRate = float64(success) / float64(summary.Calls) + } + return jsonResponse(http.StatusOK, map[string]any{ + "ok": true, + "range": rangeName, + "limits": key.Safe()["limits"], + "summary": map[string]any{ + "calls": summary.Calls, + "success": success, + "failed": summary.Failed, + "success_rate": successRate, + "total_cost": summary.TotalCost, + "usage": summary.Usage, + }, + }) } func userEvents(req managementRequest) ([]byte, error) { @@ -1004,6 +1097,29 @@ func userEvents(req managementRequest) ([]byte, error) { return eventsResponseFromRequest(req, key.ID) } +func userCodexCont(req managementRequest) ([]byte, error) { + key, ok := keyFromSession(req) + if !ok { + return jsonResponse(http.StatusUnauthorized, map[string]any{"ok": false, "error": "not_authenticated"}) + } + limit := 80 + if rawLimit := strings.TrimSpace(req.Query.Get("limit")); rawLimit != "" { + if parsed, err := strconv.Atoi(rawLimit); err == nil { + limit = parsed + } + } + if limit <= 0 || limit > 200 { + limit = 80 + } + requests, source := codexRequestsForKey(key, limit) + return jsonResponse(http.StatusOK, map[string]any{ + "ok": true, + "codexcont": codexcontStatus(), + "requests": requests, + "source": source, + }) +} + func eventsResponse(keyID string, limit int) ([]byte, error) { return eventsResponseWithRange(keyID, "", limit) } @@ -1045,6 +1161,106 @@ func usageWindows(ctx context.Context, store *governor.Store, keyID string, now return out } +func codexRequestsForKey(key governor.KeyRecord, limit int) ([]map[string]any, string) { + if requests, ok := fetchCodexContRequests(key, limit); ok { + return requests, "codexcont_admin" + } + store := loadedStore() + if store == nil { + return []map[string]any{}, "unavailable" + } + items, err := store.RecentCodexSummaries(context.Background(), key.ID, limit) + if err != nil { + return []map[string]any{}, "store_error" + } + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + safe := safeCodexSummary(item.Summary, key) + if safe == nil { + continue + } + out = append(out, safe) + } + return out, "governor_store" +} + +func fetchCodexContRequests(key governor.KeyRecord, limit int) ([]map[string]any, bool) { + cfg := loadedConfig() + if !cfg.CodexContEnabled { + return nil, false + } + base := strings.TrimRight(strings.TrimSpace(cfg.CodexContURL), "/") + if base == "" { + return nil, false + } + parsed, err := url.Parse(base + "/admin/requests?limit=" + strconv.Itoa(limit)) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return nil, false + } + client := http.Client{Timeout: 1200 * time.Millisecond} + resp, err := client.Get(parsed.String()) + if err != nil { + return nil, false + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, false + } + var body struct { + Requests []map[string]any `json:"requests"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, false + } + out := make([]map[string]any, 0, len(body.Requests)) + for _, req := range body.Requests { + safe := safeCodexSummary(req, key) + if safe == nil { + continue + } + out = append(out, safe) + } + return out, true +} + +func safeCodexSummary(req map[string]any, key governor.KeyRecord) map[string]any { + if req == nil { + return nil + } + identity, _ := req["key_identity"].(map[string]any) + if !codexIdentityMatches(identity, key) { + return nil + } + fields := []string{ + "request_id", "model", "path", "started_at", "updated_at", "ended_at", + "duration_ms", "status", "protection", "latest_round", + "latest_reasoning_tokens", "first_truncation_round", + "first_truncation_reasoning_tokens", "first_truncation_decision", + "continuation_count", "stopped_reason", "failure_reason", + "passthrough_reason", "rounds", + } + out := map[string]any{} + for _, field := range fields { + if value, ok := req[field]; ok { + out[field] = value + } + } + out["key_identity"] = key.Safe() + return out +} + +func codexIdentityMatches(identity map[string]any, key governor.KeyRecord) bool { + if strings.TrimSpace(key.ID) == "" || identity == nil { + return false + } + id := strings.TrimSpace(fmt.Sprint(identity["id"])) + if id != "" && id == key.ID { + return true + } + preview := strings.TrimSpace(fmt.Sprint(identity["preview"])) + return preview != "" && preview == key.Preview +} + func forwardHostStream(targetStreamID string, sourceStreamID string) { defer func() { _, _ = callHost(methodHostModelStreamClose, hostModelStreamCloseRequest{StreamID: sourceStreamID}) @@ -1267,44 +1483,17 @@ func callHost(method string, payload any) (json.RawMessage, error) { } func adminHTML() string { - return `CPA Governor

CPA Governor 管理

这是 CPA 插件里的同一个管理页;CPAMP 左侧的 CPA Governor 与 /governor/ 入口显示的是同一套数据。

实时轮询
建议日常从 CPAMP 左侧菜单进入;/governor/ 只是给管理员排障用的直达入口,不是另一套系统。
加载中...
` + return renderHTML(adminHTMLTemplate) } func userHTML() string { - return `CPA 用量

CPA 用量自助页

查看自己的额度、请求明细和思维链保护状态

实时轮询

请使用 Key Policy 创建时弹窗里的完整 cpa_ Key;不是 CPA 原生 sk Key,也不是列表里的缩略预览。

` + return renderHTML(userHTMLTemplate) } func sharedCSS() string { - return `:root{color-scheme:dark;--bg:#0b1020;--panel:#111827;--panel2:#0f172a;--line:#243047;--text:#e5e7eb;--muted:#94a3b8;--brand:#38bdf8;--ok:#22c55e;--bad:#fb7185;--warn:#fbbf24}*{box-sizing:border-box}body{margin:0;background:linear-gradient(180deg,#0b1020,#111827);color:var(--text);font:14px/1.5 ui-sans-serif,system-ui,Segoe UI,Arial}.shell{max-width:1180px;margin:0 auto;padding:24px}.top{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:18px}.top h1{margin:0;font-size:24px}.top p{margin:4px 0 0;color:var(--muted)}.toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}button{border:1px solid var(--line);background:#172033;color:var(--text);border-radius:8px;padding:9px 13px;cursor:pointer}button:hover{border-color:var(--brand)}.spin{animation:spin .8s linear infinite}.pulse{color:var(--ok);animation:pulse 1.4s ease-in-out infinite}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:14px}.grid article,.panel{background:rgba(17,24,39,.92);border:1px solid var(--line);border-radius:10px;box-shadow:0 12px 32px rgba(0,0,0,.25)}.grid article{padding:14px}.grid b{display:block;font-size:22px}.grid span,small{display:block;color:var(--muted);overflow-wrap:anywhere}.panel{padding:16px}.notice{margin-bottom:14px;color:#cbd5e1;background:rgba(15,23,42,.96)}.hint{margin:10px 0 0;color:var(--muted)}#err{color:#fecdd3}.tabs{display:flex;gap:8px;margin-bottom:14px}.tabs .active{background:#0e7490;border-color:#38bdf8}.actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}table{width:100%;border-collapse:collapse;table-layout:fixed}th,td{border-bottom:1px solid var(--line);padding:10px;text-align:left;vertical-align:top;white-space:normal;overflow-wrap:break-word}th{color:#cbd5e1;font-size:12px;text-transform:uppercase}.chip{display:inline-block;border-radius:999px;padding:2px 8px;background:#334155}.chip.ok{color:#86efac}.chip.bad{color:#fecdd3}.chip.warn{color:#fde68a}.detail{background:#0f172a;border:1px solid var(--line);border-radius:8px;padding:14px}.detail h2{font-size:18px;margin:0 0 10px}.detail dl{display:grid;grid-template-columns:140px 1fr;gap:8px 12px}.detail dt{color:#94a3b8}.detail dd{margin:0;overflow-wrap:anywhere}.switchline{display:flex;align-items:center;gap:8px;color:var(--text)}input,select{width:min(520px,100%);padding:11px;border-radius:8px;border:1px solid var(--line);background:#0b1220;color:var(--text);margin-right:8px}input[type=checkbox]{width:auto;margin:0;accent-color:var(--brand)}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{0%,100%{opacity:.55}50%{opacity:1}}@media(max-width:620px){.shell{padding:14px}.top{align-items:flex-start;flex-direction:column}.panel{overflow-x:auto}table{min-width:860px;font-size:12px}th,td{padding:8px;white-space:normal;overflow-wrap:break-word}.tabs{min-width:max-content;overflow:auto}.detail dl{grid-template-columns:1fr}}` + return sharedCSSTemplate +} + +func renderHTML(tpl string) string { + return strings.ReplaceAll(tpl, "{{CSS}}", sharedCSS()) } diff --git a/cpa_governor_plugin/go/main_test.go b/cpa_governor_plugin/go/main_test.go index 6140ec6..b98ff8a 100644 --- a/cpa_governor_plugin/go/main_test.go +++ b/cpa_governor_plugin/go/main_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "net/http" + "net/http/httptest" "net/url" "os" "path/filepath" @@ -142,6 +143,24 @@ func TestManagementRegisterUsesCPARPCSchema(t *testing.T) { if _, ok := payload["Resources"]; ok { t.Fatalf("unexpected uppercase Resources field: %s", string(env.Result)) } + resources, ok := payload["resources"].([]any) + if !ok { + t.Fatalf("resources should be an array: %#v", payload["resources"]) + } + userResourceSeen := false + for _, item := range resources { + resource, _ := item.(map[string]any) + if resource["Path"] != "/user" { + continue + } + userResourceSeen = true + if menu, _ := resource["Menu"].(string); strings.TrimSpace(menu) != "" { + t.Fatalf("user resource should stay routable but hidden from CPAMP sidebar menu: %#v", resource) + } + } + if !userResourceSeen { + t.Fatal("user resource should remain registered for the dedicated cpa-usage host") + } } func TestFrontendAuthAcceptsManagedKeyAndRejectsDisallowedModel(t *testing.T) { @@ -260,8 +279,8 @@ func TestUserSessionExplainsNativeAndPreviewKeys(t *testing.T) { func TestUserHTMLSessionUsesGETResourceRoute(t *testing.T) { html := userHTML() - if !strings.Contains(html, "fetch(USER_API+'/session',{headers:{'X-CPA-Governor-Key':raw}") { - t.Fatal("user login should call the GET-only resource route without a POST method") + if !strings.Contains(html, `api("/session"`) || !strings.Contains(html, `"X-CPA-Governor-Key": raw`) { + t.Fatal("user login should call the GET-only resource session route with the dedicated user-key header") } if strings.Contains(html, "Authorization:'Bearer '+raw") || strings.Contains(html, `Authorization:"Bearer "+raw`) { t.Fatal("CPAMP embeds plugin pages behind its own auth; user login must use a dedicated key header") @@ -271,13 +290,94 @@ func TestUserHTMLSessionUsesGETResourceRoute(t *testing.T) { } } -func TestAdminHTMLCodexContSaveUsesGETResourceRoute(t *testing.T) { +func TestAdminHTMLIsReadOnlyCodexContDashboard(t *testing.T) { html := adminHTML() - if !strings.Contains(html, "action:'save'") || !strings.Contains(html, "j('/codexcont?'") { - t.Fatal("admin CodexCont save should call the GET-only resource route with query parameters") + for _, want := range []string{ + `/governor/codexcont/admin`, + `EventSource(BASE + "/logs/stream")`, + `最近请求`, + `高级日志`, + `命中轮`, + `末轮 reasoning`, + `AbortController`, + `cancelSnapshot`, + `reconnectAll`, + `sync-button`, + `applySyncLight`, + `refreshMinimumDelay`, + `live-bad`, + } { + if !strings.Contains(html, want) { + t.Fatalf("admin dashboard missing %q", want) + } } - if strings.Contains(strings.ToLower(html), "method:'put'") || strings.Contains(strings.ToLower(html), `method:"put"`) { - t.Fatal("CPA resource routes are GET-only; admin resource page must not save with PUT") + for _, forbidden := range []string{ + `Key 管理`, + `>请求明细`, + `保存 CodexCont`, + `action:'save'`, + `action:"save"`, + `ccEnabled`, + `ccUrl`, + `ccFail`, + } { + if strings.Contains(html, forbidden) { + t.Fatalf("admin dashboard should be read-only and not contain %q", forbidden) + } + } +} + +func TestUserHTMLHasTwoTabsAndNoSpinner(t *testing.T) { + html := userHTML() + if !strings.Contains(html, `data-tab="usage"`) || !strings.Contains(html, `额度与明细`) { + t.Fatal("user page should expose the quota/details tab") + } + if !strings.Contains(html, `data-tab="codex"`) || !strings.Contains(html, `思维链保护`) { + t.Fatal("user page should expose the protection tab") + } + if strings.Contains(html, `data-tab="requests"`) || strings.Contains(html, `>请求明细`) { + t.Fatal("request details should be merged into the quota/details tab, not a third tab") + } + for _, want := range []string{ + `setRefreshState`, + `markRefreshStart`, + `row-fresh`, + `just-updated`, + `refreshActive`, + `switchTab`, + `AbortController`, + `cancelRefresh`, + `visibilitychange`, + `applySyncLight`, + `live-bad`, + `setInterval(() => refreshActive(false), 5000)`, + `单 Key 实时监控`, + } { + if !strings.Contains(html, want) { + t.Fatalf("user page should keep realtime refresh animation hook %q", want) + } + } + if strings.Contains(html, `load(true)`) || strings.Contains(html, `setInterval(() => load(false), 3000)`) { + t.Fatal("user page should not use the old blocking tab switch or 3s full reload loop") + } + css := sharedCSS() + for _, want := range []string{ + `@keyframes syncSweep`, + `.sync-button.syncing`, + `.sync-button.just-updated`, + `.sync-button.live-ok .sync-light`, + `.sync-button.live-bad .sync-light`, + `.topbar.live-active::after`, + `.metrics.cards-updated .metric`, + } { + if !strings.Contains(css, want) { + t.Fatalf("shared css should keep realtime refresh animation style %q", want) + } + } + for _, forbidden := range []string{".spin", "spin ", "rotate(", ".metric::after"} { + if strings.Contains(css, forbidden) { + t.Fatalf("custom pages should not use old spinner/diagonal metric effects: %q", forbidden) + } } } @@ -312,17 +412,94 @@ func TestAdminCodexContGETSavePersistsSettings(t *testing.T) { } } +func TestUserCodexContFiltersToCurrentKey(t *testing.T) { + key := configureTestState(t) + otherPreview := governor.HashPreview(governor.SHA256Hex("cpa_bob")) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/engine/healthz" { + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + return + } + if r.URL.Path != "/admin/requests" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`{"requests":[ + {"request_id":"alice-1","model":"gpt-5.5","protection":"auto_continued","key_identity":{"known":true,"id":"alice-key","name":"Alice","preview":"` + key.Preview + `"},"latest_reasoning_tokens":181,"continuation_count":1}, + {"request_id":"bob-1","model":"gpt-5.5","protection":"protected_clean","key_identity":{"known":true,"id":"bob-key","name":"Bob","preview":"` + otherPreview + `"},"latest_reasoning_tokens":120,"continuation_count":0}, + {"request_id":"unknown-1","model":"gpt-5.5","protection":"protected_clean","key_identity":{"known":false,"preview":"nope"}} + ]}`)) + })) + defer srv.Close() + + cfg := loadedConfig() + cfg.CodexContEnabled = true + cfg.CodexContURL = srv.URL + state.mu.Lock() + state.cfg = cfg + state.mu.Unlock() + + token, err := governor.SignSession(governor.SessionPayload{ + KeyID: key.ID, + KeyHash: key.KeyHash, + ExpiresAt: time.Now().Add(time.Hour).Unix(), + }, cfg.SessionSecret) + if err != nil { + t.Fatal(err) + } + raw, err := userCodexCont(managementRequest{ + Headers: http.Header{"Cookie": []string{"cpa_governor_session=" + token}}, + Query: url.Values{"limit": []string{"20"}}, + }) + if err != nil { + t.Fatal(err) + } + resp := unwrapManagementResponse(t, raw) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d body=%s", resp.StatusCode, string(resp.Body)) + } + var body struct { + OK bool `json:"ok"` + Source string `json:"source"` + Requests []map[string]any `json:"requests"` + } + if err := json.Unmarshal(resp.Body, &body); err != nil { + t.Fatal(err) + } + if !body.OK || body.Source != "codexcont_admin" { + t.Fatalf("body = %#v", body) + } + if len(body.Requests) != 1 || body.Requests[0]["request_id"] != "alice-1" { + t.Fatalf("requests were not filtered to current key: %#v", body.Requests) + } + encoded, _ := json.Marshal(body.Requests) + if strings.Contains(string(encoded), "bob-1") || strings.Contains(string(encoded), "unknown-1") { + t.Fatalf("other users leaked into codexcont response: %s", encoded) + } +} + func TestUsageHandleStoresCostAndReleasesConcurrency(t *testing.T) { key := configureTestState(t) if !acquireConcurrency(key) { t.Fatal("expected concurrency acquire") } rec := usageRecord{ - Model: "gpt-5.5", - Alias: "gpt-5.5", - APIKey: "alice-key", - RequestedAt: time.Now(), - Latency: 1500 * time.Millisecond, + Provider: "openai", + ExecutorType: "codex", + Model: "gpt-5.5-real", + Alias: "gpt-5.5", + APIKey: "alice-key", + Source: "/v1/responses", + ReasoningEffort: "high", + ServiceTier: "default", + RequestedAt: time.Now(), + Latency: 1500 * time.Millisecond, + TTFT: 220 * time.Millisecond, + Failure: usageFailure{ + StatusCode: 200, + }, + ResponseHeaders: http.Header{"X-Request-Id": []string{"req-usage-1"}}, Detail: usageDetail{ InputTokens: 100, CachedTokens: 20, @@ -345,6 +522,16 @@ func TestUsageHandleStoresCostAndReleasesConcurrency(t *testing.T) { if len(events) != 1 || events[0].Cost <= 0 || events[0].Usage.ReasoningTokens != 30 { t.Fatalf("events = %#v", events) } + got := events[0] + if got.RequestID != "req-usage-1" || got.Model != "gpt-5.5" || got.ActualModel != "gpt-5.5-real" { + t.Fatalf("model/request fields not projected: %#v", got) + } + if got.Provider != "openai" || got.ExecutorType != "codex" || got.Endpoint != "/v1/responses" { + t.Fatalf("source fields not projected: %#v", got) + } + if got.ReasoningEffort != "high" || got.ServiceTier != "default" || got.TTFTMS != 220 || got.StatusCode != 200 { + t.Fatalf("realtime detail fields not projected: %#v", got) + } } func TestRefreshKeyPolicyStateImportsNewKeys(t *testing.T) { From 582068056c7cafa7c686289451231331cedaee76 Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Thu, 2 Jul 2026 15:54:51 +0800 Subject: [PATCH 34/68] chore(task): archive 07-02-governor-ui-route-cleanup --- .../2026-07}/07-02-governor-ui-route-cleanup/check.jsonl | 0 .../2026-07}/07-02-governor-ui-route-cleanup/design.md | 0 .../2026-07}/07-02-governor-ui-route-cleanup/implement.jsonl | 0 .../2026-07}/07-02-governor-ui-route-cleanup/implement.md | 0 .../2026-07}/07-02-governor-ui-route-cleanup/prd.md | 0 .../2026-07}/07-02-governor-ui-route-cleanup/task.json | 4 ++-- 6 files changed, 2 insertions(+), 2 deletions(-) rename .trellis/tasks/{ => archive/2026-07}/07-02-governor-ui-route-cleanup/check.jsonl (100%) rename .trellis/tasks/{ => archive/2026-07}/07-02-governor-ui-route-cleanup/design.md (100%) rename .trellis/tasks/{ => archive/2026-07}/07-02-governor-ui-route-cleanup/implement.jsonl (100%) rename .trellis/tasks/{ => archive/2026-07}/07-02-governor-ui-route-cleanup/implement.md (100%) rename .trellis/tasks/{ => archive/2026-07}/07-02-governor-ui-route-cleanup/prd.md (100%) rename .trellis/tasks/{ => archive/2026-07}/07-02-governor-ui-route-cleanup/task.json (89%) diff --git a/.trellis/tasks/07-02-governor-ui-route-cleanup/check.jsonl b/.trellis/tasks/archive/2026-07/07-02-governor-ui-route-cleanup/check.jsonl similarity index 100% rename from .trellis/tasks/07-02-governor-ui-route-cleanup/check.jsonl rename to .trellis/tasks/archive/2026-07/07-02-governor-ui-route-cleanup/check.jsonl diff --git a/.trellis/tasks/07-02-governor-ui-route-cleanup/design.md b/.trellis/tasks/archive/2026-07/07-02-governor-ui-route-cleanup/design.md similarity index 100% rename from .trellis/tasks/07-02-governor-ui-route-cleanup/design.md rename to .trellis/tasks/archive/2026-07/07-02-governor-ui-route-cleanup/design.md diff --git a/.trellis/tasks/07-02-governor-ui-route-cleanup/implement.jsonl b/.trellis/tasks/archive/2026-07/07-02-governor-ui-route-cleanup/implement.jsonl similarity index 100% rename from .trellis/tasks/07-02-governor-ui-route-cleanup/implement.jsonl rename to .trellis/tasks/archive/2026-07/07-02-governor-ui-route-cleanup/implement.jsonl diff --git a/.trellis/tasks/07-02-governor-ui-route-cleanup/implement.md b/.trellis/tasks/archive/2026-07/07-02-governor-ui-route-cleanup/implement.md similarity index 100% rename from .trellis/tasks/07-02-governor-ui-route-cleanup/implement.md rename to .trellis/tasks/archive/2026-07/07-02-governor-ui-route-cleanup/implement.md diff --git a/.trellis/tasks/07-02-governor-ui-route-cleanup/prd.md b/.trellis/tasks/archive/2026-07/07-02-governor-ui-route-cleanup/prd.md similarity index 100% rename from .trellis/tasks/07-02-governor-ui-route-cleanup/prd.md rename to .trellis/tasks/archive/2026-07/07-02-governor-ui-route-cleanup/prd.md diff --git a/.trellis/tasks/07-02-governor-ui-route-cleanup/task.json b/.trellis/tasks/archive/2026-07/07-02-governor-ui-route-cleanup/task.json similarity index 89% rename from .trellis/tasks/07-02-governor-ui-route-cleanup/task.json rename to .trellis/tasks/archive/2026-07/07-02-governor-ui-route-cleanup/task.json index f162690..923ed72 100644 --- a/.trellis/tasks/07-02-governor-ui-route-cleanup/task.json +++ b/.trellis/tasks/archive/2026-07/07-02-governor-ui-route-cleanup/task.json @@ -3,7 +3,7 @@ "name": "governor-ui-route-cleanup", "title": "Governor UI route cleanup", "description": "", - "status": "in_progress", + "status": "completed", "dev_type": null, "scope": null, "package": null, @@ -11,7 +11,7 @@ "creator": "dxt98", "assignee": "dxt98", "createdAt": "2026-07-02", - "completedAt": null, + "completedAt": "2026-07-02", "branch": null, "base_branch": "main", "worktree_path": null, From 322b8a80ec2bc66f2fca09d7a77e8c3aa3f62392 Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Thu, 2 Jul 2026 17:13:10 +0800 Subject: [PATCH 35/68] feat: align governor dashboards with CPAMP style --- .../backend/codex-continuation-contracts.md | 9 + .../check.jsonl | 1 + .../design.md | 90 +++++++++ .../implement.jsonl | 1 + .../implement.md | 107 ++++++++++ .../prd.md | 69 +++++++ .../task.json | 26 +++ cpa_governor_plugin/go/assets/admin.html | 34 +++- cpa_governor_plugin/go/assets/shared.css | 190 ++++++------------ cpa_governor_plugin/go/assets/user.html | 52 ++++- cpa_governor_plugin/go/main.go | 156 +++++++++++++- cpa_governor_plugin/go/main_test.go | 35 +++- 12 files changed, 613 insertions(+), 157 deletions(-) create mode 100644 .trellis/tasks/07-02-governor-cpamp-style-alignment/check.jsonl create mode 100644 .trellis/tasks/07-02-governor-cpamp-style-alignment/design.md create mode 100644 .trellis/tasks/07-02-governor-cpamp-style-alignment/implement.jsonl create mode 100644 .trellis/tasks/07-02-governor-cpamp-style-alignment/implement.md create mode 100644 .trellis/tasks/07-02-governor-cpamp-style-alignment/prd.md create mode 100644 .trellis/tasks/07-02-governor-cpamp-style-alignment/task.json diff --git a/.trellis/spec/backend/codex-continuation-contracts.md b/.trellis/spec/backend/codex-continuation-contracts.md index 75fc5ef..695f72c 100644 --- a/.trellis/spec/backend/codex-continuation-contracts.md +++ b/.trellis/spec/backend/codex-continuation-contracts.md @@ -313,6 +313,15 @@ This spreads the event contract into JavaScript and makes the beginner-facing st same state transition. After the completion label returns to "refresh", the light must still pulse as connected/reconnecting/error rather than reverting to a grey idle light. +- Custom Governor/CodexCont dashboards must derive the visible `活跃` chip from + the current request list's non-stale `processing` rows rather than directly + rendering a backend `active_requests` counter. Backend counters can remain + high after abnormal communication; stale processing rows may stay in history + but must not keep the active chip inflated. +- CPAMP-aligned custom dashboards should use restrained status-dot animation + only. Do not reintroduce page sweep bars, refresh-button sweep lights, + metric-card bump animations, or broad row flash effects as the primary + realtime feedback. - A realtime usage event should update the visible recent-request table immediately and then schedule delayed snapshot refreshes, because CPAMP aggregate views may update slightly after the event row appears. diff --git a/.trellis/tasks/07-02-governor-cpamp-style-alignment/check.jsonl b/.trellis/tasks/07-02-governor-cpamp-style-alignment/check.jsonl new file mode 100644 index 0000000..9cd59d4 --- /dev/null +++ b/.trellis/tasks/07-02-governor-cpamp-style-alignment/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/07-02-governor-cpamp-style-alignment/design.md b/.trellis/tasks/07-02-governor-cpamp-style-alignment/design.md new file mode 100644 index 0000000..a74e08c --- /dev/null +++ b/.trellis/tasks/07-02-governor-cpamp-style-alignment/design.md @@ -0,0 +1,90 @@ +# Governor CPAMP style alignment design + +## Architecture + +This is a presentation and ordering fix inside the self-owned Governor plugin. +The runtime architecture stays unchanged: + +- `cpa-governor` serves admin/user HTML through CPA plugin resources. +- `cpa-usage.konbakuyomu.us` exposes only the user resource. +- CPAMP remains the admin shell and visual reference. +- CodexCont remains the production protection data source. + +The shared visual system lives in the plugin's embedded assets. Both +`admin.html` and `user.html` consume the same `shared.css`, so style changes +should be made once through shared tokens/classes rather than one-off per page +overrides. + +## Visual Contract + +- Use a flatter CPAMP-like dark background and panel stack: + - no radial page glow, + - restrained panel shadow, + - dark slate panels, + - muted grey table headers. +- Keep the product-specific `U` / `G` marks, but make the rest of the surface + feel like CPAMP's operations UI. +- Remove high-motion effects: + - `.topbar::after` sweep and `liveSweep`, + - `.sync-button::after` sweep and `syncSweep`, + - `.metrics.cards-updated .metric` bump, + - `rowFresh` broad row animation. +- Preserve only lightweight status-dot pulse (`statusBlink` / `statusPing`) so + operators still see live state without a strong light strip. +- Keep table layout fixed with horizontal overflow on mobile. Do not shrink + dense tables until short fields wrap vertically. + +## Ordering Contract + +User CodexCont summaries can come from two sources: + +- live CodexCont `/admin/requests`, filtered by key identity; +- Governor local `codexcont_summaries` fallback. + +The user API should return a deterministic newest-first list regardless of +source. Sort by the displayed request time: + +1. `started_at` +2. `updated_at` +3. `ended_at` + +If parsing fails, keep that row behind rows with valid timestamps while +preserving stable fallback order. The frontend may also apply the same sort as +a defensive display guard, but the backend should own the API contract. + +## Active Request Contract + +The `活跃` chip is an operator-facing "how many requests are still processing +right now" indicator. It must not directly render CodexCont +`status.counters.active_requests`, because a broken SSE/admin communication +period can leave that counter high long after those rows stop being visible. + +Both admin and user pages derive active count from the current request list: + +- include only rows whose explicit status or protection is `processing`; +- exclude rows whose latest visible timestamp is older than a short stale + threshold; +- keep stale processing rows in the history table if returned by the API, but + do not count them as active. + +The timestamp priority for stale detection mirrors sorting: `updated_at`, +`started_at`, then `ended_at`. This keeps "currently being updated" rows alive +while preventing old abnormal rows from pinning `活跃` forever. + +## Compatibility + +- Keep HTML response cache behavior and refresh recovery logic unchanged. +- Keep existing class names where tests or JS rely on them, but change their + visual effect to CPAMP-like styling. +- Existing screenshot artifacts are not tracked; new Playwright artifacts stay + under ignored `artifacts/`. +- Deployment rollback is replacing the previous + `/opt/codex-stacks/cpa/plugins/linux/amd64/cpa-governor.so` from backup and + restarting `cpa`. + +## Security + +- Do not add or print raw keys, cookies, Authorization headers, OAuth tokens, + or encrypted reasoning content. +- User protection rows remain scoped to the logged-in Key Policy key. +- Public API host must keep blocking plugin/admin/governor/codexcont paths. diff --git a/.trellis/tasks/07-02-governor-cpamp-style-alignment/implement.jsonl b/.trellis/tasks/07-02-governor-cpamp-style-alignment/implement.jsonl new file mode 100644 index 0000000..9cd59d4 --- /dev/null +++ b/.trellis/tasks/07-02-governor-cpamp-style-alignment/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/07-02-governor-cpamp-style-alignment/implement.md b/.trellis/tasks/07-02-governor-cpamp-style-alignment/implement.md new file mode 100644 index 0000000..b8bd0fe --- /dev/null +++ b/.trellis/tasks/07-02-governor-cpamp-style-alignment/implement.md @@ -0,0 +1,107 @@ +# Governor CPAMP style alignment implementation plan + +## Checklist + +1. Load applicable Trellis specs before editing. +2. Start this task with `task.py start`. +3. Update shared Governor CSS: + - flatten page background, + - tune CPAMP-like colors/borders/shadows, + - remove topbar sweep, refresh sweep, metric bump, and row broad highlight, + - keep small status-dot pulse. +4. Review `admin.html` and `user.html` class usage and adjust only if the + shared CSS cannot express the desired CPAMP-like layout. +5. Add a backend ordering helper for user CodexCont summaries and apply it to + both live and fallback sources. +6. Add shared frontend helpers for protection ordering and non-stale + `processing` active counts; use them in both admin and user pages. +7. Add/adjust Go tests: + - visual CSS no longer contains sweep/bump hooks, + - status-dot animation remains, + - user CodexCont response is newest-first. + - admin/user HTML derives active count from non-stale `processing` rows. +8. Run local validation: + - `go test ./...` in `cpa_governor_plugin/go`, + - extracted inline JS syntax check for `assets/user.html` and + `assets/admin.html`, + - `.venv\Scripts\python.exe tests\test_middleware.py`, + - `.venv\Scripts\python.exe tests\test_cpa_usage_portal.py`, + - `git diff --check`. +9. Playwright local preview: + - desktop and 390px mobile for user/admin pages, + - assert no sweep/bump classes produce visible animation hooks, + - assert protection table first visible row is newer than the second row. +10. Build linux/amd64 `cpa-governor.so` and record SHA256. +11. Deploy to SJC: + - check disk, + - backup old plugin, + - upload only `.so`, + - restart only `cpa`, + - remove temp upload. +12. Production smoke: + - `cpa-usage` login with the known test key, + - verify visual hook presence/absence via HTML and Playwright, + - verify protection newest-first, + - verify `活跃` equals current non-stale `processing` rows on admin and user + pages, + - verify CPAMP sidebar `CPA Governor`, + - verify public API route boundaries. +13. Update task evidence/specs as needed, commit, archive. + +## Risk Points + +- CPAMP visual parity is subjective. Use the provided screenshots as the target: + restrained dark panels, muted table headers, and no bright custom sweep. +- CodexCont live API may already order rows differently than local fallback. + The Governor user API should normalize order after filtering. +- SJC disk is tight. Do not pull images or rebuild containers for this plugin + asset-only change. + +## Validation Evidence + +- Started task with Trellis and loaded backend/guides specs before editing. +- Local tests: + - `go test ./...` in `cpa_governor_plugin/go` passed. + - Extracted `assets/user.html` and `assets/admin.html` scripts and ran + `node --check --input-type=commonjs -` for both; both passed. + - `.venv\Scripts\python.exe tests\test_middleware.py` passed `163/163`. + - `.venv\Scripts\python.exe tests\test_cpa_usage_portal.py` passed `84/84`. + - `.venv\Scripts\python.exe -m compileall middleware cpa_usage_portal run.py run_usage_portal.py` passed. + - `git diff --check` passed, with only expected CRLF warnings. +- Playwright local preview: + - Saved screenshots under ignored `artifacts/`: + `governor-admin-desktop.png`, `governor-admin-mobile.png`, + `governor-user-protection-desktop.png`, + `governor-user-protection-mobile.png`. + - Preview deliberately reported backend `active_requests=17` while the + visible request list had one fresh `processing` row and one stale + `processing` row; admin and user pages both displayed `活跃 1`. + - DOM checks found no `syncSweep`, `liveSweep`, `metricBump`, `rowFresh`, + `.sync-button::after`, `.topbar::after`, or `cards-updated` hooks. + - DOM checks confirmed status dots still animate with `statusBlink`. + - User `思维链保护` order was newest-first: + `req-preview-live`, `req-preview-a`, `req-preview-stale`. +- Build: + - Built `cpa-governor.so` on WSL with Go `1.22.6` for linux/amd64. + - SHA256: `57a353a5f45cf22cb3d80bf666c1f39f5557f1cdf8c3cf28f0e0f4705d7a7887`. + - `file` reported ELF 64-bit x86-64 shared object. +- Server deployment: + - SJC root disk before deploy: `450M` free; after deploy: `433M` free. + - Backed up old plugin to + `/root/cpa-governor-cpamp-style-backups/20260702-165855/cpa-governor.so` + with SHA256 `6d8970fd0168efbb691bb8e322fcea380d4d0e311025a6d50b96c3a6c4bc82f1`. + - Uploaded only the new `.so`, restarted only `cpa`, and removed only the + single temporary upload file `/tmp/cpa-governor-cpamp-style.so`. + - CPA `/healthz` returned `{"status":"ok"}`. + - CPA logs showed `plugin loaded` and `plugin registered` for + `cpa-governor` from `/CLIProxyAPI/plugins/linux/amd64/cpa-governor.so`. + - Public `https://cpa.konbakuyomu.us/governor/`, + `/v0/resource/plugins/cpa-governor/admin`, + `/v0/resource/plugins/cpa-governor/user`, and `/codexcont/` all returned + `404`. + - `https://cpa-usage.konbakuyomu.us/` API login with Kuma test key returned + `200`, `/me` returned the expected safe preview, and `/codexcont?limit=20` + returned 20 protection records with active count 0 at the time of smoke. + - Server-local plugin HTML checks found no removed animation hooks and found + `statusBlink`, `activeProcessingCount`, and `PROCESSING_STALE_MS` in both + admin and user resources. diff --git a/.trellis/tasks/07-02-governor-cpamp-style-alignment/prd.md b/.trellis/tasks/07-02-governor-cpamp-style-alignment/prd.md new file mode 100644 index 0000000..293b423 --- /dev/null +++ b/.trellis/tasks/07-02-governor-cpamp-style-alignment/prd.md @@ -0,0 +1,69 @@ +# Governor CPAMP style alignment + +## Goal + +Align the self-owned CPA Governor user/admin pages with the CPAMP admin +visual language and fix the user CodexCont protection table ordering. + +The user-facing result should make `https://cpa-usage.konbakuyomu.us/` feel +like part of the same operations product as CPAMP: restrained dark theme, +compact cards, stable tables, modest status feedback, and newest requests at +the top in every request-like table. + +## Requirements + +- Only modify self-owned `cpa-governor` plugin page assets and tests. +- Do not modify CPA, CPAMP, or CPA Key Policy official source, images, or + runtime data. +- Update the shared custom-page visual tokens to be closer to CPAMP: + darker flat background, subdued borders, compact panels, muted table header, + less saturated cyan, and simpler buttons/chips. +- Remove the current custom sweep/glow effects: page topbar sweep, refresh + button sweep, metric-card bump, and broad row highlight. +- Keep subtle live feedback through small status dots only. +- Keep existing refresh safety behavior: abort stale requests, recover after + background/idle, and keep latest refresh results authoritative. +- Make `额度与明细` and `思维链保护` use the same table, detail-card, chip, and + button style. +- Make user `思维链保护` sort newest requests first. The visible time order + should match `额度与明细` and CPAMP request monitoring. +- Make the top-right `活跃` count mean the number of currently visible, + non-stale `processing` protection requests, not a backend counter that can be + left high after abnormal communication. +- Add the same `活跃 N` status chip to the user self-service page so `CPA Usage` + and `CPA Governor` expose the same realtime state vocabulary. +- Treat old `processing` rows as stale so they do not keep the active count high + forever. Stale rows may remain in history, but they must not be counted as + active work. +- Keep the user page without a CPAMP sidebar so ordinary users do not see an + admin-shaped navigation surface. +- Preserve public/admin route boundaries after deployment. + +## Acceptance Criteria + +- [ ] `CPA Usage` and `CPA Governor` custom pages use one shared CPAMP-like + dark style and no longer show the current bright cyan sweep/glow theme. +- [ ] Refresh buttons still show `同步中`, `刚刚更新`, and `同步失败`, but without + sweep animation or broad glow. +- [ ] Small status dots still pulse lightly for connected/syncing/error states. +- [ ] `额度与明细` and `思维链保护` have matching table density, chip shape, + detail panels, and button style. +- [ ] User `思维链保护` rows are newest-first by request time. +- [ ] Admin `活跃` count equals current non-stale `processing` rows instead of + stale `status.counters.active_requests`. +- [ ] User page topbar shows `活跃 N` with the same chip style as the Governor + admin page. +- [ ] Desktop and 390px mobile layouts have no overlapping text and no short + fields forced vertical. +- [ ] Local Go tests and existing Python regressions pass. +- [ ] Playwright validates desktop/mobile visual shape and the protection row + order. +- [ ] SJC deployment uploads only the new Governor `.so`, restarts only `cpa`, + and keeps public `cpa.konbakuyomu.us` admin/plugin routes blocked. + +## Out of Scope + +- Pixel-perfect copying of CPAMP. +- Adding a CPAMP left sidebar to the ordinary user page. +- Changing CPAMP, CPA, or Key Policy official artifacts. +- Changing the production `/v1/responses` execution path. diff --git a/.trellis/tasks/07-02-governor-cpamp-style-alignment/task.json b/.trellis/tasks/07-02-governor-cpamp-style-alignment/task.json new file mode 100644 index 0000000..2454ba7 --- /dev/null +++ b/.trellis/tasks/07-02-governor-cpamp-style-alignment/task.json @@ -0,0 +1,26 @@ +{ + "id": "governor-cpamp-style-alignment", + "name": "governor-cpamp-style-alignment", + "title": "Governor CPAMP style alignment", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "dxt98", + "assignee": "dxt98", + "createdAt": "2026-07-02", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/cpa_governor_plugin/go/assets/admin.html b/cpa_governor_plugin/go/assets/admin.html index 3800deb..2ed2f6c 100644 --- a/cpa_governor_plugin/go/assets/admin.html +++ b/cpa_governor_plugin/go/assets/admin.html @@ -94,9 +94,31 @@

最近请求

request_failed:"请求失败", passthrough:"透传" }; +const PROCESSING_STALE_MS = 10 * 60 * 1000; const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[c])); const num = (v) => Number(v || 0).toLocaleString(); const dt = (v) => v ? new Date(v).toLocaleString() : "-"; +function requestTimeMs(req, fields=["started_at", "updated_at", "ended_at"]){ + for (const field of fields) { + const raw = req && req[field]; + if (!raw) continue; + const ms = Date.parse(raw); + if (Number.isFinite(ms)) return ms; + } + return Number.NaN; +} +function activeTimeMs(req){ + return requestTimeMs(req, ["updated_at", "started_at", "ended_at"]); +} +function isActiveProcessing(req, now=Date.now()){ + if (!req || (req.status !== "processing" && req.protection !== "processing")) return false; + const ms = activeTimeMs(req); + return !Number.isFinite(ms) || (now - ms <= PROCESSING_STALE_MS); +} +function activeProcessingCount(items){ + const now = Date.now(); + return (items || []).filter(req => isActiveProcessing(req, now)).length; +} function chip(value){ const item = labels[value] || [value || "未知",""]; return `${esc(item[0])}`; @@ -170,9 +192,10 @@

最近请求

const s = state.status || {}; const c = s.counters || {}; const reqs = [...state.requests.values()]; + const active = activeProcessingCount(reqs); const continued = reqs.filter(r => r.protection === "auto_continued").length; const risky = reqs.filter(r => r.protection === "risk_uncontinued").length; - document.getElementById("active-chip").querySelector("span:last-child").textContent = `活跃 ${num(c.active_requests)}`; + document.getElementById("active-chip").querySelector("span:last-child").textContent = `活跃 ${num(active)}`; document.getElementById("metrics").innerHTML = `
总请求
${num(c.total_requests)}
CodexCont 捕获
自动续写
${num(c.continuations || continued)}
隐藏续写轮
@@ -192,7 +215,14 @@

最近请求

function renderRequests(){ const filter = document.getElementById("protection-filter").value; const body = document.getElementById("request-body"); - let rows = [...state.requests.values()].sort((a,b) => String(b.updated_at || b.started_at).localeCompare(String(a.updated_at || a.started_at))); + let rows = [...state.requests.values()].sort((a,b) => { + const left = requestTimeMs(b, ["started_at", "updated_at", "ended_at"]); + const right = requestTimeMs(a, ["started_at", "updated_at", "ended_at"]); + if (Number.isFinite(left) && Number.isFinite(right)) return left - right; + if (Number.isFinite(left)) return -1; + if (Number.isFinite(right)) return 1; + return String(b.updated_at || b.started_at || "").localeCompare(String(a.updated_at || a.started_at || "")); + }); if (filter) rows = rows.filter(r => r.protection === filter); if (!rows.length) { body.innerHTML = `
暂无请求
`; diff --git a/cpa_governor_plugin/go/assets/shared.css b/cpa_governor_plugin/go/assets/shared.css index 77b4600..e53f813 100644 --- a/cpa_governor_plugin/go/assets/shared.css +++ b/cpa_governor_plugin/go/assets/shared.css @@ -1,23 +1,24 @@ :root { color-scheme: dark; - --bg: #0b1220; - --panel: #151d2c; - --panel-2: #1a2434; - --line: #283448; - --line-strong: #33445e; - --text: #e8edf5; - --muted: #9aa7b8; - --blue: #3b96ff; - --blue-soft: rgba(59, 150, 255, .14); - --green: #61d34f; - --green-soft: rgba(97, 211, 79, .13); - --red: #ff646d; - --red-soft: rgba(255, 100, 109, .13); - --amber: #f0aa2b; - --amber-soft: rgba(240, 170, 43, .14); - --teal: #15c7d4; - --teal-soft: rgba(21, 199, 212, .12); - --shadow: 0 18px 48px rgba(0, 0, 0, .26); + --bg: #111722; + --panel: #151b27; + --panel-2: #1b2230; + --panel-3: #202838; + --line: #283244; + --line-strong: #39465c; + --text: #e6eaf0; + --muted: #94a3b8; + --blue: #3b82f6; + --blue-soft: rgba(59, 130, 246, .14); + --green: #68d65f; + --green-soft: rgba(104, 214, 95, .12); + --red: #f87171; + --red-soft: rgba(248, 113, 113, .12); + --amber: #f4b942; + --amber-soft: rgba(244, 185, 66, .12); + --teal: #2bb3c5; + --teal-soft: rgba(43, 179, 197, .11); + --shadow: 0 10px 26px rgba(0, 0, 0, .18); --mono: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; --sans: Inter, "Segoe UI", "Microsoft YaHei", system-ui, sans-serif; font-family: var(--sans); @@ -26,9 +27,7 @@ body { margin: 0; min-height: 100vh; - background: - radial-gradient(circle at 60% -10%, rgba(59, 150, 255, .16), transparent 30%), - linear-gradient(180deg, #101827 0%, var(--bg) 38%, #080e19 100%); + background: linear-gradient(180deg, #151b26 0%, var(--bg) 46%, #0f1520 100%); color: var(--text); font: 14px/1.48 var(--sans); letter-spacing: 0; @@ -49,22 +48,21 @@ button { gap: 8px; cursor: pointer; white-space: nowrap; - transition: border-color .18s ease, background .18s ease, color .18s ease, box-shadow .18s ease, transform .18s ease; + transition: border-color .18s ease, background .18s ease, color .18s ease; } button:hover, button.active, select:hover, input:focus { border-color: var(--line-strong); outline: none; } -button:hover { transform: translateY(-1px); } button:disabled { cursor: wait; opacity: .92; } button.primary { - border-color: rgba(21, 199, 212, .42); - background: rgba(21, 199, 212, .18); + border-color: rgba(59, 130, 246, .48); + background: rgba(59, 130, 246, .18); } -button.ghost { background: var(--panel-2); } +button.ghost { background: #1a2230; } button.compact { min-height: 28px; padding: 0 9px; @@ -73,37 +71,22 @@ button.compact { } .sync-button { position: relative; - overflow: hidden; min-width: 96px; - isolation: isolate; -} -.sync-button::after { - content: ""; - position: absolute; - inset: 0; - z-index: -1; - background: linear-gradient(90deg, transparent, rgba(21, 199, 212, .22), transparent); - transform: translate3d(-120%, 0, 0); - opacity: 0; } .sync-button.syncing { - border-color: rgba(21, 199, 212, .5); - color: #baf7ff; - box-shadow: 0 0 0 1px rgba(21, 199, 212, .1), 0 0 22px rgba(21, 199, 212, .12); -} -.sync-button.syncing::after { - opacity: 1; - animation: syncSweep 1s ease-in-out infinite; + border-color: rgba(59, 130, 246, .5); + background: rgba(59, 130, 246, .14); + color: #c8dcff; } .sync-button.just-updated { - border-color: rgba(97, 211, 79, .5); - color: #c8ffc0; - animation: syncConfirm .95s ease both; + border-color: rgba(104, 214, 95, .5); + background: rgba(104, 214, 95, .12); + color: #d3f8cf; } .sync-button.sync-error { - border-color: rgba(255, 100, 109, .55); - color: #ffc3c7; - animation: syncError .95s ease both; + border-color: rgba(248, 113, 113, .55); + background: rgba(248, 113, 113, .12); + color: #ffc7c7; } .sync-light { width: 8px; @@ -117,22 +100,22 @@ button.compact { } .sync-button.live-ok .sync-light { background: var(--green); - box-shadow: 0 0 14px rgba(97, 211, 79, .34); + box-shadow: 0 0 10px rgba(104, 214, 95, .28); animation: statusBlink 1.45s ease-in-out infinite; } .sync-button.live-info .sync-light { background: var(--blue); - box-shadow: 0 0 14px rgba(59, 150, 255, .34); + box-shadow: 0 0 10px rgba(59, 130, 246, .28); animation: statusBlink 1.45s ease-in-out infinite; } .sync-button.live-warn .sync-light { background: var(--amber); - box-shadow: 0 0 14px rgba(240, 170, 43, .34); + box-shadow: 0 0 10px rgba(244, 185, 66, .28); animation: statusBlink 1.45s ease-in-out infinite; } .sync-button.live-bad .sync-light { background: var(--red); - box-shadow: 0 0 14px rgba(255, 100, 109, .34); + box-shadow: 0 0 10px rgba(248, 113, 113, .28); animation: statusBlink 1.45s ease-in-out infinite; } .sync-button.syncing .sync-light { @@ -154,7 +137,6 @@ button.compact { } .topbar { position: relative; - overflow: hidden; min-height: 58px; display: flex; align-items: center; @@ -164,29 +146,9 @@ button.compact { margin-bottom: 16px; border: 1px solid var(--line); border-radius: 8px; - background: rgba(17, 24, 39, .9); + background: #151b27; box-shadow: var(--shadow); } -.topbar::after { - content: ""; - position: absolute; - left: 14px; - right: 14px; - bottom: 0; - height: 1px; - background: linear-gradient(90deg, transparent, rgba(21, 199, 212, .78), transparent); - transform: translate3d(-115%, 0, 0); - opacity: 0; - pointer-events: none; -} -.topbar.live-active::after { - opacity: .78; - animation: liveSweep 3s ease-in-out infinite; -} -.topbar.live-tick::after { - opacity: 1; - animation: syncSweep .72s ease-out 1; -} .brand { display: flex; align-items: center; @@ -200,7 +162,7 @@ button.compact { display: grid; place-items: center; border-radius: 8px; - background: linear-gradient(135deg, #2d86ef, #15c7d4); + background: linear-gradient(135deg, #2b8df0, #24b8cf); color: #fff; font-weight: 850; } @@ -235,7 +197,7 @@ h3 { font-size: 13px; font-weight: 760; } padding: 0 10px; border-radius: 999px; border: 1px solid var(--line); - background: var(--panel-2); + background: #1a2230; color: var(--muted); font-size: 12px; font-weight: 720; @@ -263,13 +225,13 @@ h3 { font-size: 13px; font-weight: 760; } opacity: .55; animation: statusPing 1.45s ease-out infinite; } -.chip.ok { border-color: rgba(97, 211, 79, .28); background: var(--green-soft); color: #a8ef9c; } +.chip.ok { border-color: rgba(104, 214, 95, .28); background: var(--green-soft); color: #a9f2a2; } .chip.ok .dot { background: var(--green); } -.chip.bad { border-color: rgba(255, 100, 109, .3); background: var(--red-soft); color: #ffb1b6; } +.chip.bad { border-color: rgba(248, 113, 113, .3); background: var(--red-soft); color: #ffb8b8; } .chip.bad .dot { background: var(--red); } -.chip.warn { border-color: rgba(240, 170, 43, .3); background: var(--amber-soft); color: #ffd38a; } +.chip.warn { border-color: rgba(244, 185, 66, .3); background: var(--amber-soft); color: #ffd893; } .chip.warn .dot { background: var(--amber); } -.chip.info { border-color: rgba(59, 150, 255, .32); background: var(--blue-soft); color: #9dccff; } +.chip.info { border-color: rgba(59, 130, 246, .32); background: var(--blue-soft); color: #a9c8ff; } .chip.info .dot { background: var(--blue); } .metrics { display: grid; @@ -277,17 +239,14 @@ h3 { font-size: 13px; font-weight: 760; } gap: 10px; margin-bottom: 14px; } -.metrics.cards-updated .metric { - animation: metricBump .74s ease both; -} .metric { min-height: 86px; padding: 14px; border: 1px solid var(--line); border-radius: 8px; - background: rgba(17, 24, 39, .84); + background: #151b27; box-shadow: var(--shadow); - transition: border-color .2s ease, background .2s ease, box-shadow .2s ease, transform .2s ease; + transition: border-color .2s ease, background .2s ease; } .metric .label { color: var(--muted); @@ -309,7 +268,7 @@ h3 { font-size: 13px; font-weight: 760; } margin-bottom: 14px; border: 1px solid var(--line); border-radius: 8px; - background: rgba(17, 24, 39, .9); + background: #151b27; box-shadow: var(--shadow); overflow: hidden; } @@ -345,13 +304,12 @@ h3 { font-size: 13px; font-weight: 760; } bottom: 4px; height: 2px; border-radius: 999px; - background: rgba(21, 199, 212, .72); - animation: tabUnderline .24s ease-out both; + background: rgba(59, 130, 246, .72); } .tabs button.active { - border-color: rgba(21, 199, 212, .42); - background: rgba(21, 199, 212, .18); - color: #baf7ff; + border-color: rgba(59, 130, 246, .42); + background: rgba(59, 130, 246, .16); + color: #c8dcff; } #content { transition: opacity .18s ease, transform .18s ease; @@ -386,18 +344,16 @@ tbody tr { transition: background-color .24s ease, box-shadow .24s ease, transform .2s ease; } tbody tr:hover { - background: rgba(59, 150, 255, .045); + background: rgba(148, 163, 184, .055); } tbody tr.data-row { background-clip: padding-box; } -tbody tr.row-fresh { - animation: rowFresh 1.45s ease-out both; -} th { color: var(--muted); font-size: 12px; font-weight: 760; + background: #303442; } td strong { display: block; } .strong { @@ -418,7 +374,7 @@ small, .muted { .detail-row td { white-space: normal; overflow: visible; - background: rgba(11, 18, 32, .75); + background: #111722; } .detail-grid { display: grid; @@ -430,7 +386,7 @@ small, .muted { padding: 12px; border: 1px solid var(--line); border-radius: 8px; - background: rgba(15, 23, 42, .82); + background: #151b27; } .kv { display: grid; @@ -457,7 +413,7 @@ small, .muted { padding: 10px; border: 1px solid rgba(40, 52, 72, .78); border-radius: 8px; - background: rgba(8, 14, 25, .7); + background: #101620; color: #d7deea; white-space: pre-wrap; overflow-wrap: anywhere; @@ -510,44 +466,10 @@ details.advanced > summary { 0% { transform: scale(.5); opacity: .55; } 80%, 100% { transform: scale(1.8); opacity: 0; } } -@keyframes syncSweep { - 0% { transform: translate3d(-120%, 0, 0); } - 100% { transform: translate3d(120%, 0, 0); } -} -@keyframes liveSweep { - 0% { transform: translate3d(-115%, 0, 0); opacity: .18; } - 15% { opacity: .78; } - 55% { opacity: .78; } - 100% { transform: translate3d(115%, 0, 0); opacity: .18; } -} -@keyframes syncConfirm { - 0% { box-shadow: 0 0 0 rgba(97, 211, 79, 0); } - 35% { box-shadow: 0 0 0 3px rgba(97, 211, 79, .16), 0 0 24px rgba(97, 211, 79, .16); } - 100% { box-shadow: 0 0 0 rgba(97, 211, 79, 0); } -} -@keyframes syncError { - 0% { box-shadow: 0 0 0 rgba(255, 100, 109, 0); } - 35% { box-shadow: 0 0 0 3px rgba(255, 100, 109, .15), 0 0 24px rgba(255, 100, 109, .14); } - 100% { box-shadow: 0 0 0 rgba(255, 100, 109, 0); } -} -@keyframes metricBump { - 0% { transform: translate3d(0, 0, 0); border-color: var(--line); } - 34% { transform: translate3d(0, -2px, 0); border-color: rgba(21, 199, 212, .34); } - 100% { transform: translate3d(0, 0, 0); border-color: var(--line); } -} -@keyframes rowFresh { - 0% { background: rgba(21, 199, 212, .18); box-shadow: inset 3px 0 0 rgba(21, 199, 212, .8); } - 60% { background: rgba(21, 199, 212, .08); box-shadow: inset 3px 0 0 rgba(21, 199, 212, .35); } - 100% { background: transparent; box-shadow: inset 3px 0 0 rgba(21, 199, 212, 0); } -} @keyframes viewRise { 0% { opacity: .78; transform: translate3d(0, 4px, 0); } 100% { opacity: 1; transform: translate3d(0, 0, 0); } } -@keyframes tabUnderline { - 0% { transform: translate3d(-8px, 0, 0); opacity: 0; } - 100% { transform: translate3d(0, 0, 0); opacity: 1; } -} @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; diff --git a/cpa_governor_plugin/go/assets/user.html b/cpa_governor_plugin/go/assets/user.html index 1aee5b2..f0d4b89 100644 --- a/cpa_governor_plugin/go/assets/user.html +++ b/cpa_governor_plugin/go/assets/user.html @@ -24,6 +24,7 @@

CPA 用量自助页

未登录 + + + +
+ + +
+ +
+
+
+

Key 策略

+

5H/24H/7D 是滚动窗口,月限额按 Asia/Shanghai 自然月;清零是 soft reset,不删除历史明细。

+
+
+
+ + + + + + + + + + + + + + + + + + + +
用户/Key状态RPM请求并发Codex窗口5H24H7D模型操作
加载中...
+
+
+ +
+
+
+

审计提示

+

窗口数限制优先使用 Codex window/session 信号;缺失信号时 v1 放行并记录告警,避免误封。

+
+
+

+    
+ + + + + diff --git a/cpa_key_policy_plus_plugin/go/assets/shared.css b/cpa_key_policy_plus_plugin/go/assets/shared.css new file mode 100644 index 0000000..0ad3700 --- /dev/null +++ b/cpa_key_policy_plus_plugin/go/assets/shared.css @@ -0,0 +1,533 @@ +:root { + color-scheme: dark; + --bg: #111722; + --panel: #151b27; + --panel-2: #1b2230; + --panel-3: #202838; + --line: #283244; + --line-strong: #39465c; + --text: #e6eaf0; + --muted: #94a3b8; + --blue: #3b82f6; + --blue-soft: rgba(59, 130, 246, .14); + --green: #68d65f; + --green-soft: rgba(104, 214, 95, .12); + --red: #f87171; + --red-soft: rgba(248, 113, 113, .12); + --amber: #f4b942; + --amber-soft: rgba(244, 185, 66, .12); + --teal: #2bb3c5; + --teal-soft: rgba(43, 179, 197, .11); + --shadow: 0 10px 26px rgba(0, 0, 0, .18); + --mono: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + --sans: Inter, "Segoe UI", "Microsoft YaHei", system-ui, sans-serif; + font-family: var(--sans); +} +* { box-sizing: border-box; } +body { + margin: 0; + min-height: 100vh; + background: linear-gradient(180deg, #151b26 0%, var(--bg) 46%, #0f1520 100%); + color: var(--text); + font: 14px/1.48 var(--sans); + letter-spacing: 0; +} +button, select, input { + min-height: 38px; + border: 1px solid var(--line); + border-radius: 8px; + background: #111827; + color: var(--text); + padding: 0 12px; + font: inherit; +} +button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + cursor: pointer; + white-space: nowrap; + transition: border-color .18s ease, background .18s ease, color .18s ease; +} +button:hover, button.active, select:hover, input:focus { + border-color: var(--line-strong); + outline: none; +} +button:disabled { + cursor: wait; + opacity: .92; +} +button.primary { + border-color: rgba(59, 130, 246, .48); + background: rgba(59, 130, 246, .18); +} +button.ghost { background: #1a2230; } +button.compact { + min-height: 28px; + padding: 0 9px; + margin-top: 6px; + font-size: 12px; +} +.sync-button { + position: relative; + min-width: 96px; +} +.sync-button.syncing { + border-color: rgba(59, 130, 246, .5); + background: rgba(59, 130, 246, .14); + color: #c8dcff; +} +.sync-button.just-updated { + border-color: rgba(104, 214, 95, .5); + background: rgba(104, 214, 95, .12); + color: #d3f8cf; +} +.sync-button.sync-error { + border-color: rgba(248, 113, 113, .55); + background: rgba(248, 113, 113, .12); + color: #ffc7c7; +} +.sync-light { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--muted); + box-shadow: 0 0 0 0 rgba(154, 167, 184, .2); + position: relative; + flex: 0 0 auto; + transition: background .18s ease, box-shadow .18s ease; +} +.sync-button.live-ok .sync-light { + background: var(--green); + box-shadow: 0 0 10px rgba(104, 214, 95, .28); + animation: statusBlink 1.45s ease-in-out infinite; +} +.sync-button.live-info .sync-light { + background: var(--blue); + box-shadow: 0 0 10px rgba(59, 130, 246, .28); + animation: statusBlink 1.45s ease-in-out infinite; +} +.sync-button.live-warn .sync-light { + background: var(--amber); + box-shadow: 0 0 10px rgba(244, 185, 66, .28); + animation: statusBlink 1.45s ease-in-out infinite; +} +.sync-button.live-bad .sync-light { + background: var(--red); + box-shadow: 0 0 10px rgba(248, 113, 113, .28); + animation: statusBlink 1.45s ease-in-out infinite; +} +.sync-button.syncing .sync-light { + background: var(--teal); + animation: statusBlink 1s ease-in-out infinite; +} +.sync-button.just-updated .sync-light { + background: var(--green); + box-shadow: 0 0 16px rgba(97, 211, 79, .42); +} +.sync-button.sync-error .sync-light { + background: var(--red); + box-shadow: 0 0 16px rgba(255, 100, 109, .38); +} +.shell { + width: min(1680px, calc(100% - 32px)); + margin: 0 auto; + padding: 22px 0 34px; +} +.topbar { + position: relative; + min-height: 58px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 0 16px; + margin-bottom: 16px; + border: 1px solid var(--line); + border-radius: 8px; + background: #151b27; + box-shadow: var(--shadow); +} +.brand { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} +.brand > div { min-width: 0; } +.mark { + width: 34px; + height: 34px; + display: grid; + place-items: center; + border-radius: 8px; + background: linear-gradient(135deg, #2b8df0, #24b8cf); + color: #fff; + font-weight: 850; +} +h1, h2, h3, p { margin: 0; } +h1 { + font-size: 20px; + line-height: 1.2; + font-weight: 780; +} +h2 { font-size: 15px; font-weight: 760; } +h3 { font-size: 13px; font-weight: 760; } +.subtitle { + margin-top: 2px; + color: var(--muted); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.toolbar, .filters { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} +.chip { + display: inline-flex; + align-items: center; + gap: 7px; + min-height: 32px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid var(--line); + background: #1a2230; + color: var(--muted); + font-size: 12px; + font-weight: 720; + line-height: 1.2; + white-space: nowrap; + transition: border-color .18s ease, background .18s ease, color .18s ease, box-shadow .18s ease; +} +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--muted); + position: relative; + flex: 0 0 auto; +} +.chip.stream .dot { + animation: statusBlink 1.45s ease-in-out infinite; +} +.chip.stream .dot::after { + content: ""; + position: absolute; + inset: -5px; + border-radius: inherit; + border: 1px solid currentColor; + opacity: .55; + animation: statusPing 1.45s ease-out infinite; +} +.chip.ok { border-color: rgba(104, 214, 95, .28); background: var(--green-soft); color: #a9f2a2; } +.chip.ok .dot { background: var(--green); } +.chip.bad { border-color: rgba(248, 113, 113, .3); background: var(--red-soft); color: #ffb8b8; } +.chip.bad .dot { background: var(--red); } +.chip.warn { border-color: rgba(244, 185, 66, .3); background: var(--amber-soft); color: #ffd893; } +.chip.warn .dot { background: var(--amber); } +.chip.info { border-color: rgba(59, 130, 246, .32); background: var(--blue-soft); color: #a9c8ff; } +.chip.info .dot { background: var(--blue); } +.metrics { + display: grid; + grid-template-columns: repeat(4, minmax(150px, 1fr)); + gap: 10px; + margin-bottom: 14px; +} +.metric { + min-height: 86px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: #151b27; + box-shadow: var(--shadow); + transition: border-color .2s ease, background .2s ease; +} +.metric .label { + color: var(--muted); + font-size: 12px; + font-weight: 720; +} +.metric .value { + margin-top: 6px; + font-size: 25px; + line-height: 1; + font-weight: 820; +} +.metric .hint { + margin-top: 7px; + color: var(--muted); + font-size: 12px; +} +.panel { + margin-bottom: 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: #151b27; + box-shadow: var(--shadow); + overflow: hidden; +} +.panel-head { + min-height: 48px; + padding: 12px 14px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid var(--line); +} +.panel-body { padding: 14px; } +.embedded-panel { + margin-bottom: 0; + box-shadow: none; +} +.tabs { + display: flex; + gap: 8px; + margin-bottom: 14px; + overflow-x: auto; +} +.tabs button { + position: relative; + overflow: hidden; +} +.tabs button.active::after { + content: ""; + position: absolute; + left: 12px; + right: 12px; + bottom: 4px; + height: 2px; + border-radius: 999px; + background: rgba(59, 130, 246, .72); +} +.tabs button.active { + border-color: rgba(59, 130, 246, .42); + background: rgba(59, 130, 246, .16); + color: #c8dcff; +} +#content { + transition: opacity .18s ease, transform .18s ease; +} +#content.content-refreshing { + opacity: .72; + transform: translate3d(0, 3px, 0); +} +#content.view-enter { + animation: viewRise .26s ease-out both; +} +.table-wrap { overflow-x: auto; } +table { + width: 100%; + min-width: 980px; + border-collapse: collapse; + table-layout: fixed; +} +.realtime-table { + min-width: 1180px; +} +th, td { + border-bottom: 1px solid rgba(40, 52, 72, .75); + padding: 11px 12px; + text-align: left; + vertical-align: middle; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +tbody tr { + transition: background-color .24s ease, box-shadow .24s ease, transform .2s ease; +} +tbody tr:hover { + background: rgba(148, 163, 184, .055); +} +tbody tr.data-row { + background-clip: padding-box; +} +th { + color: var(--muted); + font-size: 12px; + font-weight: 760; + background: #303442; +} +td strong { display: block; } +.strong { + display: block; + color: var(--text); + font-weight: 780; +} +.success { color: var(--green); } +.danger { color: var(--red); } +small, .muted { + color: var(--muted); + font-size: 12px; +} +.mono { font-family: var(--mono); } +.good { color: var(--green); font-weight: 760; } +.bad-text { color: var(--red); font-weight: 760; } +.blue { color: var(--blue); font-weight: 760; } +.detail-row td { + white-space: normal; + overflow: visible; + background: #111722; +} +.detail-grid { + display: grid; + grid-template-columns: repeat(4, minmax(220px, 1fr)); + gap: 10px; +} +.detail-card { + min-width: 0; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: #151b27; +} +.kv { + display: grid; + grid-template-columns: minmax(92px, 42%) 1fr; + gap: 7px 10px; + margin-top: 10px; +} +.kv dt { color: var(--muted); } +.kv dd { + margin: 0; + min-width: 0; + overflow-wrap: anywhere; +} +.detail-note { + margin-top: 10px; + color: var(--muted); + font-size: 12px; +} +.failure-box { + margin-top: 10px; + min-height: 46px; + max-height: 132px; + overflow: auto; + padding: 10px; + border: 1px solid rgba(40, 52, 72, .78); + border-radius: 8px; + background: #101620; + color: #d7deea; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.empty { + padding: 22px; + text-align: center; + color: var(--muted); +} +.key-cell input { min-width: 170px; } +input.narrow { width: 82px; min-width: 74px; } +textarea { + width: 220px; + min-height: 38px; + margin: 2px 0; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 7px; + background: #0f151f; + color: var(--text); + font-family: var(--mono); + font-size: 12px; + resize: vertical; +} +.check { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text); + white-space: nowrap; +} +.actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-width: 150px; +} +button.mini { + min-height: 28px; + padding: 4px 8px; + font-size: 12px; +} +.logbox { + margin: 0; + max-height: 260px; + overflow: auto; + padding: 14px; + color: var(--muted); +} +.login { + max-width: 720px; + margin: 52px auto; +} +.login-row { + display: flex; + gap: 10px; + margin-top: 14px; +} +.login input { + flex: 1; + min-width: 0; +} +#err { margin-top: 10px; color: #ffb1b6; } +details.advanced > summary { + cursor: pointer; + padding: 12px 14px; + color: var(--muted); + font-weight: 760; +} +.logs { + max-height: 320px; + overflow: auto; + padding: 0 14px 14px; +} +.log-line { + display: grid; + grid-template-columns: 92px 86px 150px 1fr; + gap: 10px; + padding: 8px 0; + border-top: 1px solid rgba(40, 52, 72, .6); + font-family: var(--mono); + font-size: 12px; +} +.hidden { display: none !important; } +@keyframes statusBlink { + 0%, 100% { transform: scale(.9); opacity: .65; } + 50% { transform: scale(1.18); opacity: 1; } +} +@keyframes statusPing { + 0% { transform: scale(.5); opacity: .55; } + 80%, 100% { transform: scale(1.8); opacity: 0; } +} +@keyframes viewRise { + 0% { opacity: .78; transform: translate3d(0, 4px, 0); } + 100% { opacity: 1; transform: translate3d(0, 0, 0); } +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: .01ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + transition-duration: .01ms !important; + } +} +@media (max-width: 900px) { + .metrics { grid-template-columns: repeat(2, minmax(140px, 1fr)); } + .detail-grid { grid-template-columns: 1fr; } +} +@media (max-width: 620px) { + .shell { width: min(100% - 20px, 1680px); padding-top: 12px; } + .topbar { align-items: flex-start; flex-direction: column; padding: 12px; } + .toolbar, .filters { justify-content: flex-start; } + .metrics { grid-template-columns: 1fr; } + .login-row { flex-direction: column; } + table { min-width: 900px; } + .log-line { grid-template-columns: 1fr; gap: 3px; } +} diff --git a/cpa_key_policy_plus_plugin/go/assets/user.html b/cpa_key_policy_plus_plugin/go/assets/user.html new file mode 100644 index 0000000..b0a7fa3 --- /dev/null +++ b/cpa_key_policy_plus_plugin/go/assets/user.html @@ -0,0 +1,624 @@ + + + + + + CPA 用量自助页 + + + +
+
+
+
U
+
+

CPA 用量自助页

+

单 Key 实时监控、额度明细和思维链保护状态

+
+
+
+ + 未登录 + + + +
+
+ + + + +
+ + + + diff --git a/cpa_key_policy_plus_plugin/go/go.mod b/cpa_key_policy_plus_plugin/go/go.mod new file mode 100644 index 0000000..632ad2a --- /dev/null +++ b/cpa_key_policy_plus_plugin/go/go.mod @@ -0,0 +1,24 @@ +module codexcont/cpa-key-policy-plus-plugin + +go 1.22 + +require ( + gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.33.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.22.0 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect +) diff --git a/cpa_key_policy_plus_plugin/go/go.sum b/cpa_key_policy_plus_plugin/go/go.sum new file mode 100644 index 0000000..6ea7e08 --- /dev/null +++ b/cpa_key_policy_plus_plugin/go/go.sum @@ -0,0 +1,53 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM= +modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/cpa_key_policy_plus_plugin/go/internal/policyplus/config.go b/cpa_key_policy_plus_plugin/go/internal/policyplus/config.go new file mode 100644 index 0000000..3c59fe0 --- /dev/null +++ b/cpa_key_policy_plus_plugin/go/internal/policyplus/config.go @@ -0,0 +1,56 @@ +package policyplus + +import ( + "strings" + "time" +) + +type Config struct { + Enabled bool `yaml:"enabled"` + ExclusiveAuth bool `yaml:"exclusive_auth"` + StateDBPath string `yaml:"state_db_path"` + KeyPolicyStatePath string `yaml:"key_policy_state_path"` + LegacyQuotaDBPath string `yaml:"legacy_quota_db_path"` + GovernorStateDBPath string `yaml:"governor_state_db_path"` + SessionSecret string `yaml:"session_secret"` + CodexContEnabled bool `yaml:"codexcont_enabled"` + CodexContRoute bool `yaml:"codexcont_route"` + CodexContURL string `yaml:"codexcont_url"` + FailMode string `yaml:"fail_mode"` + PollIntervalMS int `yaml:"poll_interval_ms"` +} + +func DefaultConfig() Config { + return Config{ + Enabled: true, + ExclusiveAuth: true, + StateDBPath: "cpa-policyplus.sqlite", + SessionSecret: "change-me", + CodexContEnabled: false, + CodexContURL: "http://codexcont:8787", + FailMode: "fallback", + PollIntervalMS: 1500, + } +} + +func (c Config) Normalize() Config { + if strings.TrimSpace(c.StateDBPath) == "" { + c.StateDBPath = DefaultConfig().StateDBPath + } + c.FailMode = strings.ToLower(strings.TrimSpace(c.FailMode)) + if c.FailMode == "" { + c.FailMode = "fallback" + } + c.CodexContURL = strings.TrimRight(strings.TrimSpace(c.CodexContURL), "/") + if c.CodexContURL == "" { + c.CodexContURL = DefaultConfig().CodexContURL + } + if c.PollIntervalMS <= 0 { + c.PollIntervalMS = 1500 + } + return c +} + +func SessionTTL() time.Duration { + return 24 * time.Hour +} diff --git a/cpa_key_policy_plus_plugin/go/internal/policyplus/models.go b/cpa_key_policy_plus_plugin/go/internal/policyplus/models.go new file mode 100644 index 0000000..3f13f4a --- /dev/null +++ b/cpa_key_policy_plus_plugin/go/internal/policyplus/models.go @@ -0,0 +1,408 @@ +package policyplus + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +type ModelPrice struct { + Model string `json:"model"` + TargetModel string `json:"target_model,omitempty"` + Provider string `json:"provider,omitempty"` + InputPerMillion float64 `json:"input_per_million"` + OutputPerMillion float64 `json:"output_per_million"` + CacheReadPerMillion float64 `json:"cache_read_per_million"` + CacheCreationPerMillion float64 `json:"cache_creation_per_million"` +} + +type KeyRecord struct { + ID string `json:"id"` + Name string `json:"name"` + KeyHash string `json:"key_hash"` + Enabled bool `json:"enabled"` + Preview string `json:"preview"` + RPM int `json:"rpm,omitempty"` + Concurrency int `json:"concurrency,omitempty"` + MaxActiveSessions int `json:"max_active_sessions,omitempty"` + Models []string `json:"models"` + Prices map[string]ModelPrice `json:"prices,omitempty"` + DailyLimitUSD *float64 `json:"daily_limit_usd,omitempty"` + WeeklyLimitUSD *float64 `json:"weekly_limit_usd,omitempty"` + FiveHourUSD *float64 `json:"five_hour_usd,omitempty"` + MonthlyLimitUSD *float64 `json:"monthly_limit_usd,omitempty"` +} + +func (k KeyRecord) Safe() map[string]any { + return map[string]any{ + "id": k.ID, + "name": k.Name, + "enabled": k.Enabled, + "preview": k.Preview, + "rpm": k.RPM, + "concurrency": k.Concurrency, + "max_active_sessions": k.MaxActiveSessions, + "models": append([]string(nil), k.Models...), + "limits": map[string]any{ + "five_hour_usd": k.FiveHourUSD, + "daily_usd": k.DailyLimitUSD, + "weekly_usd": k.WeeklyLimitUSD, + "monthly_usd": k.MonthlyLimitUSD, + }, + "pricing": map[string]any{ + "models": k.Prices, + }, + } +} + +type KeyPolicyState struct { + Keys []KeyRecord +} + +func ValidateKeyRecord(key KeyRecord) error { + if strings.TrimSpace(key.ID) == "" { + return fmt.Errorf("missing key id") + } + if key.RPM < 0 || key.Concurrency < 0 || key.MaxActiveSessions < 0 { + return fmt.Errorf("limits must not be negative") + } + for _, limit := range []*float64{key.FiveHourUSD, key.DailyLimitUSD, key.WeeklyLimitUSD, key.MonthlyLimitUSD} { + if limit != nil && *limit < 0 { + return fmt.Errorf("usd limits must not be negative") + } + } + for name, price := range key.Prices { + if price.InputPerMillion < 0 || price.OutputPerMillion < 0 || + price.CacheReadPerMillion < 0 || price.CacheCreationPerMillion < 0 { + return fmt.Errorf("model price must not be negative: %s", name) + } + } + return nil +} + +func LoadKeyPolicyState(path string) (KeyPolicyState, error) { + raw, err := os.ReadFile(path) + if err != nil { + return KeyPolicyState{}, err + } + var data any + if err := json.Unmarshal(raw, &data); err != nil { + return KeyPolicyState{}, err + } + keys := extractKeys(data) + out := make([]KeyRecord, 0, len(keys)) + for _, rawKey := range keys { + if key, ok := parseKey(rawKey); ok { + out = append(out, key) + } + } + return KeyPolicyState{Keys: out}, nil +} + +func (s KeyPolicyState) FindByRawKey(rawKey string) (KeyRecord, bool) { + hash := SHA256Hex(rawKey) + return s.FindByRawHash(hash) +} + +func (s KeyPolicyState) FindByRawHash(hash string) (KeyRecord, bool) { + normalized, err := NormalizeHash(hash) + if err != nil { + return KeyRecord{}, false + } + for _, key := range s.Keys { + keyHash, err := NormalizeHash(key.KeyHash) + if err == nil && keyHash == normalized { + return key, true + } + } + return KeyRecord{}, false +} + +func extractKeys(data any) []map[string]any { + if arr, ok := data.([]any); ok { + return mapsFromArray(arr) + } + obj, ok := data.(map[string]any) + if !ok { + return nil + } + for _, path := range [][]string{{"keys"}, {"state", "keys"}, {"data", "keys"}, {"config", "keys"}} { + var cur any = obj + for _, part := range path { + m, ok := cur.(map[string]any) + if !ok { + cur = nil + break + } + cur = m[part] + } + if arr, ok := cur.([]any); ok { + return mapsFromArray(arr) + } + } + return nil +} + +func mapsFromArray(arr []any) []map[string]any { + out := make([]map[string]any, 0, len(arr)) + for _, item := range arr { + if m, ok := item.(map[string]any); ok { + out = append(out, m) + } + } + return out +} + +func parseKey(raw map[string]any) (KeyRecord, bool) { + rawHash := firstString(raw, "key_hash", "keyHash", "hash", "api_key_hash", "apiKeyHash") + if rawHash == "" { + return KeyRecord{}, false + } + normalized, err := NormalizeHash(rawHash) + if err != nil { + return KeyRecord{}, false + } + id := firstString(raw, "id", "key_id", "keyId") + if id == "" { + id = HashPreview(normalized) + } + name := firstString(raw, "name", "label", "alias", "description") + if name == "" { + name = id + } + enabled, hasEnabled := firstBool(raw, "enabled", "is_enabled", "isEnabled") + disabled, _ := firstBool(raw, "disabled", "is_disabled", "isDisabled") + if !hasEnabled { + enabled = !disabled + } + modelItems := asList(firstAny(raw, "models", "allowed_models", "allowedModels", "model_allowlist", "modelAllowlist", "aliases")) + models := parseModels(modelItems) + prices := parsePrices(raw, modelItems) + return KeyRecord{ + ID: strings.TrimSpace(id), + Name: strings.TrimSpace(name), + KeyHash: "sha256:" + normalized, + Enabled: enabled && !disabled, + Preview: firstNonEmpty(firstString(raw, "preview", "key_preview", "keyPreview"), HashPreview(normalized)), + RPM: firstInt(raw, "rpm", "rpm_limit", "rpmLimit", "rpm_per_minute", "rpmPerMinute"), + Concurrency: firstInt(raw, "concurrency", "concurrency_limit", "concurrencyLimit", "max_concurrent", "maxConcurrent", "request_concurrency", "requestConcurrency"), + MaxActiveSessions: firstInt(raw, "max_active_sessions", "maxActiveSessions", "max_sessions", "maxSessions", "session_limit", "sessionLimit", "max_codex_windows", "maxCodexWindows"), + Models: models, + Prices: prices, + DailyLimitUSD: firstFloatPtr(raw, "daily_limit_usd", "dailyLimitUsd", "daily_limit", "dailyLimit", "daily_usd", "dailyUsd"), + WeeklyLimitUSD: firstFloatPtr(raw, "weekly_limit_usd", "weeklyLimitUsd", "weekly_limit", "weeklyLimit", "weekly_usd", "weeklyUsd"), + FiveHourUSD: firstFloatPtr(raw, "five_hour_limit_usd", "fiveHourLimitUsd", "five_hour_usd", "fiveHourUsd", "5h_limit_usd"), + MonthlyLimitUSD: firstFloatPtr(raw, "monthly_limit_usd", "monthlyLimitUsd", "monthly_usd", "monthlyUsd", "month_limit_usd"), + }, true +} + +func parseModels(items []any) []string { + seen := map[string]bool{} + var out []string + for _, item := range items { + name := "" + if m, ok := item.(map[string]any); ok { + name = modelName(m) + } else { + name = strings.TrimSpace(toString(item)) + } + if name != "" && !seen[name] { + seen[name] = true + out = append(out, name) + } + } + return out +} + +func parsePrices(raw map[string]any, modelItems []any) map[string]ModelPrice { + out := map[string]ModelPrice{} + for _, item := range modelItems { + if m, ok := item.(map[string]any); ok { + if p, ok := parsePriceEntry(m, ""); ok { + out[p.Model] = p + } + } + } + priceRaw := firstAny(raw, "model_prices", "modelPrices", "prices") + switch v := priceRaw.(type) { + case map[string]any: + for name, item := range v { + if m, ok := item.(map[string]any); ok { + if p, ok := parsePriceEntry(m, name); ok { + out[p.Model] = p + } + } + } + case []any: + for _, item := range v { + if m, ok := item.(map[string]any); ok { + if p, ok := parsePriceEntry(m, ""); ok { + out[p.Model] = p + } + } + } + } + return out +} + +func parsePriceEntry(raw map[string]any, defaultModel string) (ModelPrice, bool) { + model := modelName(raw) + if model == "" { + model = strings.TrimSpace(defaultModel) + } + if model == "" { + return ModelPrice{}, false + } + price := ModelPrice{ + Model: model, + TargetModel: firstString(raw, "target_model", "targetModel", "upstream_model", "upstreamModel"), + Provider: firstString(raw, "provider", "type"), + InputPerMillion: firstFloat(raw, "input_price_per_million", "inputPricePerMillion", "input", "prompt", "prompt_price_per_million"), + OutputPerMillion: firstFloat(raw, "output_price_per_million", "outputPricePerMillion", "output", "completion", "completion_price_per_million"), + CacheReadPerMillion: firstFloat(raw, "cache_read_price_per_million", "cacheReadPricePerMillion", "cache_price_per_million", "cachePricePerMillion", "cache_read", "cacheRead", "cache"), + CacheCreationPerMillion: firstFloat(raw, "cache_creation_price_per_million", "cacheCreationPricePerMillion", "cache_write_price_per_million", "cacheWritePricePerMillion", "cache_creation", "cacheCreation", "cache_write", "cacheWrite"), + } + if price.InputPerMillion <= 0 && price.OutputPerMillion <= 0 && price.CacheReadPerMillion <= 0 && price.CacheCreationPerMillion <= 0 { + return ModelPrice{}, false + } + return price, true +} + +func modelName(raw map[string]any) string { + return firstString(raw, "alias", "model", "name", "id", "target_model", "targetModel", "upstream_model", "upstreamModel") +} + +func firstAny(raw map[string]any, names ...string) any { + for _, name := range names { + if value, ok := raw[name]; ok { + return value + } + } + return nil +} + +func firstString(raw map[string]any, names ...string) string { + for _, name := range names { + if value, ok := raw[name]; ok { + text := strings.TrimSpace(toString(value)) + if text != "" { + return text + } + } + } + return "" +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func firstBool(raw map[string]any, names ...string) (bool, bool) { + for _, name := range names { + if value, ok := raw[name]; ok { + switch v := value.(type) { + case bool: + return v, true + case string: + switch strings.ToLower(strings.TrimSpace(v)) { + case "true", "1", "yes", "enabled": + return true, true + case "false", "0", "no", "disabled": + return false, true + } + } + } + } + return false, false +} + +func firstInt(raw map[string]any, names ...string) int { + for _, name := range names { + if value, ok := raw[name]; ok { + switch v := value.(type) { + case float64: + return int(v) + case int: + return v + case string: + var n int + if err := json.NewDecoder(strings.NewReader(v)).Decode(&n); err == nil { + return n + } + } + } + } + return 0 +} + +func firstFloat(raw map[string]any, names ...string) float64 { + ptr := firstFloatPtr(raw, names...) + if ptr == nil { + return 0 + } + return *ptr +} + +func firstFloatPtr(raw map[string]any, names ...string) *float64 { + for _, name := range names { + if value, ok := raw[name]; ok { + switch v := value.(type) { + case float64: + return &v + case int: + f := float64(v) + return &f + case string: + var f float64 + if err := json.NewDecoder(strings.NewReader(v)).Decode(&f); err == nil { + return &f + } + } + } + } + return nil +} + +func asList(value any) []any { + switch v := value.(type) { + case []any: + return v + case []string: + out := make([]any, len(v)) + for i := range v { + out[i] = v[i] + } + return out + case string: + parts := strings.Split(v, ",") + out := make([]any, 0, len(parts)) + for _, part := range parts { + if text := strings.TrimSpace(part); text != "" { + out = append(out, text) + } + } + return out + default: + return nil + } +} + +func toString(value any) string { + switch v := value.(type) { + case string: + return v + case json.Number: + return v.String() + default: + if value == nil { + return "" + } + return strings.TrimSpace(fmt.Sprint(value)) + } +} diff --git a/cpa_key_policy_plus_plugin/go/internal/policyplus/policyplus_test.go b/cpa_key_policy_plus_plugin/go/internal/policyplus/policyplus_test.go new file mode 100644 index 0000000..e94467c --- /dev/null +++ b/cpa_key_policy_plus_plugin/go/internal/policyplus/policyplus_test.go @@ -0,0 +1,445 @@ +package policyplus + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func ptr(v float64) *float64 { return &v } + +func writePolicyState(t *testing.T, dir string, rawKey string) string { + t.Helper() + path := filepath.Join(dir, "key-policy.json") + body := map[string]any{ + "keys": []map[string]any{{ + "id": "alice-key", + "name": "Alice", + "key_hash": "sha256:" + SHA256Hex(rawKey), + "enabled": true, + "rpm": 12, + "models": []map[string]any{{ + "alias": "gpt-5.5", + "target_model": "gpt-5.5", + "input_price_per_million": 5, + "output_price_per_million": 30, + "cache_read_price_per_million": 0.5, + }}, + "daily_limit_usd": 5, + "weekly_limit_usd": 30, + }}, + } + raw, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestKeyPolicyStateParsesSafeRecords(t *testing.T) { + dir := t.TempDir() + path := writePolicyState(t, dir, "cpa_live") + state, err := LoadKeyPolicyState(path) + if err != nil { + t.Fatal(err) + } + key, ok := state.FindByRawKey(" cpa_live ") + if !ok { + t.Fatal("raw key did not match policy state") + } + if key.ID != "alice-key" || key.Name != "Alice" || !key.Enabled { + t.Fatalf("unexpected key: %#v", key) + } + if len(key.Models) != 1 || key.Models[0] != "gpt-5.5" { + t.Fatalf("models = %#v", key.Models) + } + price, ok := PriceForModel(key.Prices, "GPT-5.5") + if !ok || price.InputPerMillion != 5 || price.OutputPerMillion != 30 || price.CacheReadPerMillion != 0.5 { + t.Fatalf("price = %#v ok=%v", price, ok) + } + safe := key.Safe() + encoded, _ := json.Marshal(safe) + if strings.Contains(string(encoded), SHA256Hex("cpa_live")) || strings.Contains(string(encoded), "cpa_live") { + t.Fatalf("safe projection leaked key material: %s", encoded) + } +} + +func TestImportKeysDoesNotDeletePlusNativeKeys(t *testing.T) { + ctx := context.Background() + store, err := OpenStore(filepath.Join(t.TempDir(), "policyplus.sqlite")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + native := KeyRecord{ + ID: "native-plus-key", + Name: "Native", + KeyHash: "sha256:" + SHA256Hex("cpa_native_plus"), + Enabled: true, + Preview: HashPreview(SHA256Hex("cpa_native_plus")), + } + if err := store.UpsertKey(ctx, native); err != nil { + t.Fatal(err) + } + imported := KeyRecord{ + ID: "imported-key", + Name: "Imported", + KeyHash: "sha256:" + SHA256Hex("cpa_imported"), + Enabled: true, + Preview: HashPreview(SHA256Hex("cpa_imported")), + } + if err := store.ImportKeys(ctx, KeyPolicyState{Keys: []KeyRecord{imported}}); err != nil { + t.Fatal(err) + } + keys, err := store.ListKeys(ctx) + if err != nil { + t.Fatal(err) + } + seen := map[string]bool{} + for _, key := range keys { + seen[key.ID] = true + } + if !seen[native.ID] || !seen[imported.ID] { + t.Fatalf("native/imported keys should both remain: %#v", seen) + } +} + +func TestPricingBreakdownUsesPerMillionAndCachedInput(t *testing.T) { + price := ModelPrice{ + Model: "gpt-5.5", + InputPerMillion: 5, + OutputPerMillion: 30, + CacheReadPerMillion: 0.5, + } + breakdown := CostForUsage(price, TokenUsage{ + InputTokens: 100, + CachedTokens: 20, + OutputTokens: 50, + ReasoningTokens: 30, + TotalTokens: 150, + }, "gpt-5.5") + if got := breakdown.Tokens["billable_uncached_input"]; got != 80 { + t.Fatalf("billable input = %d", got) + } + if got := breakdown.Tokens["visible_output_estimate"]; got != 20 { + t.Fatalf("visible output estimate = %d", got) + } + if breakdown.Costs["total"] <= 0 { + t.Fatalf("total cost should be positive: %#v", breakdown.Costs) + } + if breakdown.Costs["cached_input"] <= 0 { + t.Fatalf("cached input should be charged with cache read price: %#v", breakdown.Costs) + } +} + +func TestStoreUsageWindowsAndSoftReset(t *testing.T) { + ctx := context.Background() + store, err := OpenStore(filepath.Join(t.TempDir(), "policyplus.sqlite")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + key := KeyRecord{ + ID: "alice-key", + Name: "Alice", + KeyHash: "sha256:" + SHA256Hex("cpa_live"), + Enabled: true, + Preview: HashPreview(SHA256Hex("cpa_live")), + FiveHourUSD: ptr(1), + MonthlyLimitUSD: ptr(10), + } + if err := store.UpsertKey(ctx, key); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + if err := store.InsertUsage(ctx, UsageEvent{ + RequestID: "req-old", + KeyID: "alice-key", + RequestedAt: now.Add(-2 * time.Hour), + Cost: 0.5, + }); err != nil { + t.Fatal(err) + } + if err := store.InsertUsage(ctx, UsageEvent{ + RequestID: "req-new", + KeyID: "alice-key", + RequestedAt: now.Add(-30 * time.Minute), + Cost: 0.25, + }); err != nil { + t.Fatal(err) + } + used, err := store.UsageSum(ctx, "alice-key", WindowFor(Range5H, now)) + if err != nil { + t.Fatal(err) + } + if used != 0.75 { + t.Fatalf("used before reset = %v", used) + } + if err := store.Reset(ctx, "alice-key", Range5H, now.Add(-time.Hour)); err != nil { + t.Fatal(err) + } + used, err = store.UsageSum(ctx, "alice-key", WindowFor(Range5H, now)) + if err != nil { + t.Fatal(err) + } + if used != 0.25 { + t.Fatalf("used after reset = %v", used) + } + summary, err := store.UsageSummary(ctx, "alice-key", WindowFor(Range5H, now)) + if err != nil { + t.Fatal(err) + } + if summary.Calls != 1 || summary.TotalCost != 0.25 { + t.Fatalf("summary after reset = %#v", summary) + } +} + +func TestStoreImportsLegacyQuotaSQLite(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + plus, err := OpenStore(filepath.Join(dir, "policyplus.sqlite")) + if err != nil { + t.Fatal(err) + } + defer plus.Close() + key := KeyRecord{ + ID: "alice-key", + Name: "Alice", + KeyHash: "sha256:" + SHA256Hex("cpa_live"), + Enabled: true, + Preview: HashPreview(SHA256Hex("cpa_live")), + } + if err := plus.UpsertKey(ctx, key); err != nil { + t.Fatal(err) + } + legacyPath := filepath.Join(dir, "usage-admin.sqlite") + legacy, err := sql.Open("sqlite", legacyPath) + if err != nil { + t.Fatal(err) + } + if _, err := legacy.Exec(`create table key_limits(policy_id text primary key, five_hour_limit_usd real, monthly_limit_usd real, updated_at_ms integer)`); err != nil { + t.Fatal(err) + } + if _, err := legacy.Exec(`create table reset_watermarks(policy_id text, window text, reset_at_ms integer, updated_at_ms integer, primary key(policy_id, window))`); err != nil { + t.Fatal(err) + } + if _, err := legacy.Exec(`insert into key_limits(policy_id, five_hour_limit_usd, monthly_limit_usd, updated_at_ms) values('alice-key', 1.5, 20, 1000)`); err != nil { + t.Fatal(err) + } + if _, err := legacy.Exec(`insert into reset_watermarks(policy_id, window, reset_at_ms, updated_at_ms) values('alice-key', '5h', 2000000000000, 2000000000000)`); err != nil { + t.Fatal(err) + } + if err := legacy.Close(); err != nil { + t.Fatal(err) + } + result, err := plus.ImportLegacyQuotaSQLite(ctx, legacyPath, "usage-admin") + if err != nil { + t.Fatal(err) + } + if result.Limits != 1 || result.Resets != 1 { + t.Fatalf("import result = %#v", result) + } + keys, err := plus.ListKeys(ctx) + if err != nil || len(keys) != 1 { + t.Fatalf("keys err=%v keys=%#v", err, keys) + } + if keys[0].FiveHourUSD == nil || *keys[0].FiveHourUSD != 1.5 || keys[0].MonthlyLimitUSD == nil || *keys[0].MonthlyLimitUSD != 20 { + t.Fatalf("legacy limits not imported: %#v", keys[0]) + } + resetAt, ok := plus.ResetAt(ctx, "alice-key", Range5H) + if !ok || resetAt != 2000000000 { + t.Fatalf("legacy reset not imported: resetAt=%d ok=%v", resetAt, ok) + } +} + +func TestStoreMigratesOldUsageEventsSchema(t *testing.T) { + path := filepath.Join(t.TempDir(), "policyplus.sqlite") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + _, err = db.Exec(`create table usage_events ( + id integer primary key autoincrement, + request_id text, + key_id text, + key_preview text, + model text, + endpoint text, + requested_at integer not null, + latency_ms integer, + failed integer not null, + failure text, + input_tokens integer, + output_tokens integer, + cached_tokens integer, + cache_read_tokens integer, + cache_creation_tokens integer, + reasoning_tokens integer, + total_tokens integer, + cost real, + cost_breakdown_json text + )`) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`insert into usage_events(request_id, key_id, model, requested_at, failed, cost, cost_breakdown_json) values('old-1', 'alice-key', 'gpt-5.5', ?, 0, 0.1, '{}')`, time.Now().Unix()); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + store, err := OpenStore(path) + if err != nil { + t.Fatal(err) + } + defer store.Close() + events, err := store.RecentEvents(context.Background(), "alice-key", 10) + if err != nil { + t.Fatal(err) + } + if len(events) != 1 || events[0].RequestID != "old-1" { + t.Fatalf("events = %#v", events) + } + if events[0].RequestedModel != "" || events[0].TTFTMS != 0 || events[0].StatusCode != 0 { + t.Fatalf("old row should read safe zero values: %#v", events[0]) + } +} + +func TestSessionIdentityPriorityAndExpiry(t *testing.T) { + headers := http.Header{ + "X-Codex-Turn-Metadata": []string{`{"window_id":"turn-window","prompt_cache_key":"turn-cache"}`}, + "X-Session-ID": []string{"session-header"}, + } + body := []byte(`{"client_metadata":{"x-codex-window-id":"client-window"},"prompt_cache_key":"body-cache","conversation_id":"conv"}`) + identity := ExtractSessionIdentity(headers, body) + if identity.Source != "client_metadata.x-codex-window-id" { + t.Fatalf("identity priority = %#v", identity) + } + headers.Set("X-Codex-Window-Id", "header-window") + identity = ExtractSessionIdentity(headers, body) + if identity.Source != "x-codex-window-id" { + t.Fatalf("header window should win: %#v", identity) + } + ctx := context.Background() + store, err := OpenStore(filepath.Join(t.TempDir(), "policyplus.sqlite")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + now := time.Unix(1000, 0) + first, err := store.RegisterActiveSession(ctx, "alice", newSessionIdentity("test", "a"), 1, DefaultSessionIdle, now) + if err != nil || !first.Allowed || first.Active != 1 { + t.Fatalf("first session = %#v err=%v", first, err) + } + second, err := store.RegisterActiveSession(ctx, "alice", newSessionIdentity("test", "b"), 1, DefaultSessionIdle, now.Add(31*time.Minute)) + if err != nil || !second.Allowed || second.Active != 1 { + t.Fatalf("expired slot should be reusable: %#v err=%v", second, err) + } + missing, err := store.RegisterActiveSession(ctx, "alice", SessionIdentity{}, 1, DefaultSessionIdle, now) + if err != nil || !missing.Allowed || !missing.Missing { + t.Fatalf("missing identity should be allowed: %#v err=%v", missing, err) + } + count, err := store.AuditCount(ctx, "missing_session_identity") + if err != nil || count != 1 { + t.Fatalf("missing identity audit count=%d err=%v", count, err) + } +} + +func TestStoreRecentCodexSummariesFiltersByKey(t *testing.T) { + ctx := context.Background() + store, err := OpenStore(filepath.Join(t.TempDir(), "policyplus.sqlite")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if err := store.SaveCodexSummary(ctx, "req-a", "alice-key", "gpt-5.5", "auto_continued", map[string]any{ + "request_id": "req-a", + "protection": "auto_continued", + }); err != nil { + t.Fatal(err) + } + if err := store.SaveCodexSummary(ctx, "req-b", "bob-key", "gpt-5.5", "protected_clean", map[string]any{ + "request_id": "req-b", + "protection": "protected_clean", + }); err != nil { + t.Fatal(err) + } + alice, err := store.RecentCodexSummaries(ctx, "alice-key", 10) + if err != nil { + t.Fatal(err) + } + if len(alice) != 1 || alice[0].RequestID != "req-a" || alice[0].Protection != "auto_continued" { + t.Fatalf("alice summaries = %#v", alice) + } + all, err := store.RecentCodexSummaries(ctx, "all", 10) + if err != nil { + t.Fatal(err) + } + if len(all) != 2 { + t.Fatalf("all summaries = %#v", all) + } +} + +func TestSecurityAndRedaction(t *testing.T) { + raw := " cpa_live " + hash := SHA256Hex(raw) + if hash != SHA256Hex(strings.TrimSpace(raw)) { + t.Fatal("SHA256Hex should trim raw keys") + } + if hash != SHA256Hex("Bearer cpa_live") || hash != SHA256Hex("Authorization: Bearer cpa_live") { + t.Fatal("SHA256Hex should normalize pasted bearer prefixes") + } + token, err := SignSession(SessionPayload{KeyID: "alice", KeyHash: "sha256:" + hash, ExpiresAt: time.Now().Add(time.Hour).Unix()}, "secret") + if err != nil { + t.Fatal(err) + } + payload, ok := VerifySession(token, "secret", time.Now()) + if !ok || payload.KeyID != "alice" { + t.Fatalf("session verify failed: %#v ok=%v", payload, ok) + } + if _, ok := VerifySession(token, "wrong", time.Now()); ok { + t.Fatal("session verified with wrong secret") + } + brief := Brief("Authorization: Bearer secret and api_key=abc", 200) + if strings.Contains(brief, "secret") || strings.Contains(brief, "abc") { + t.Fatalf("secret leaked in brief: %s", brief) + } +} + +func TestSubmittedKeyHints(t *testing.T) { + cases := []struct { + name string + in string + code string + }{ + {name: "missing", in: " ", code: "missing_api_key"}, + {name: "native", in: "sk-abc", code: "native_cpa_key_not_supported"}, + {name: "preview", in: "cpa_abcd...efgh", code: "key_preview_not_usable"}, + {name: "unsupported", in: "abc", code: "unsupported_key_format"}, + {name: "short cpa", in: "Bearer cpa_live", code: "key_preview_not_usable"}, + {name: "full cpa", in: "Bearer cpa_abcdefghijklmnopqrstuvwxyz0123456789", code: "invalid_api_key"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + hint := ExplainUnmatchedSubmittedKey(tc.in) + if hint.Error != tc.code || hint.Message == "" { + t.Fatalf("hint = %#v", hint) + } + }) + } + if got := NormalizeSubmittedKey("Authorization: Bearer Bearer cpa_live "); got != "cpa_live" { + t.Fatalf("normalized key = %q", got) + } + if got := NormalizeSubmittedKey("\ufeff“Bearer cpa_live\u200b”"); got != "cpa_live" { + t.Fatalf("normalized decorated key = %q", got) + } +} diff --git a/cpa_key_policy_plus_plugin/go/internal/policyplus/pricing.go b/cpa_key_policy_plus_plugin/go/internal/policyplus/pricing.go new file mode 100644 index 0000000..d054dd6 --- /dev/null +++ b/cpa_key_policy_plus_plugin/go/internal/policyplus/pricing.go @@ -0,0 +1,102 @@ +package policyplus + +import "strings" + +const perMillion = 1_000_000.0 + +type TokenUsage struct { + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CachedTokens int64 `json:"cached_tokens"` + CacheReadTokens int64 `json:"cache_read_tokens"` + CacheCreationTokens int64 `json:"cache_creation_tokens"` + ReasoningTokens int64 `json:"reasoning_tokens"` + TotalTokens int64 `json:"total_tokens"` +} + +type CostBreakdown struct { + Source string `json:"source"` + Model string `json:"model"` + Prices ModelPrice `json:"prices"` + Tokens map[string]int64 `json:"tokens"` + Costs map[string]float64 `json:"costs"` +} + +func PriceForModel(prices map[string]ModelPrice, model string) (ModelPrice, bool) { + model = strings.TrimSpace(model) + if model == "" { + return ModelPrice{}, false + } + if price, ok := prices[model]; ok { + return price, true + } + lower := strings.ToLower(model) + for name, price := range prices { + if strings.ToLower(name) == lower { + return price, true + } + } + return ModelPrice{}, false +} + +func CostForUsage(price ModelPrice, usage TokenUsage, model string) CostBreakdown { + input := max64(usage.InputTokens, 0) + output := max64(usage.OutputTokens, 0) + cached := max64(usage.CachedTokens, 0) + cacheRead := max64(usage.CacheReadTokens, 0) + cacheCreation := max64(usage.CacheCreationTokens, 0) + reasoning := max64(usage.ReasoningTokens, 0) + billableInput := max64(input-cached, 0) + cacheReadPrice := price.CacheReadPerMillion + if cacheReadPrice <= 0 { + cacheReadPrice = price.InputPerMillion + } + cacheCreationPrice := price.CacheCreationPerMillion + if cacheCreationPrice <= 0 { + cacheCreationPrice = price.InputPerMillion + } + inputCost := float64(billableInput) * price.InputPerMillion / perMillion + cachedCost := float64(cached) * cacheReadPrice / perMillion + cacheReadCost := float64(cacheRead) * cacheReadPrice / perMillion + cacheCreationCost := float64(cacheCreation) * cacheCreationPrice / perMillion + outputCost := float64(output) * price.OutputPerMillion / perMillion + total := inputCost + cachedCost + cacheReadCost + cacheCreationCost + outputCost + totalTokens := usage.TotalTokens + if totalTokens <= 0 { + totalTokens = input + output + } + if strings.TrimSpace(model) == "" { + model = price.Model + } + return CostBreakdown{ + Source: "key_policy_plus_price_book", + Model: model, + Prices: price, + Tokens: map[string]int64{ + "input": input, + "billable_uncached_input": billableInput, + "cached_input": cached, + "cache_read": cacheRead, + "cache_creation": cacheCreation, + "output": output, + "reasoning": reasoning, + "visible_output_estimate": max64(output-reasoning, 0), + "total": totalTokens, + }, + Costs: map[string]float64{ + "input": inputCost, + "cached_input": cachedCost, + "cache_read": cacheReadCost, + "cache_creation": cacheCreationCost, + "output": outputCost, + "total": total, + }, + } +} + +func max64(a, b int64) int64 { + if a > b { + return a + } + return b +} diff --git a/cpa_key_policy_plus_plugin/go/internal/policyplus/quota.go b/cpa_key_policy_plus_plugin/go/internal/policyplus/quota.go new file mode 100644 index 0000000..4607210 --- /dev/null +++ b/cpa_key_policy_plus_plugin/go/internal/policyplus/quota.go @@ -0,0 +1,66 @@ +package policyplus + +import ( + "strings" + "time" +) + +const ( + Range5H = "5h" + Range24H = "24h" + Range7D = "7d" + RangeMonth = "month" +) + +type Window struct { + Name string `json:"name"` + From time.Time `json:"from"` + To time.Time `json:"to"` +} + +func WindowFor(rangeName string, now time.Time) Window { + now = now.UTC() + switch strings.ToLower(strings.TrimSpace(rangeName)) { + case Range5H: + return Window{Name: Range5H, From: now.Add(-5 * time.Hour), To: now} + case Range7D: + return Window{Name: Range7D, From: now.Add(-7 * 24 * time.Hour), To: now} + case RangeMonth: + loc := time.FixedZone("Asia/Shanghai", 8*60*60) + local := now.In(loc) + start := time.Date(local.Year(), local.Month(), 1, 0, 0, 0, 0, loc) + return Window{Name: RangeMonth, From: start.UTC(), To: now} + default: + return Window{Name: Range24H, From: now.Add(-24 * time.Hour), To: now} + } +} + +type QuotaDecision struct { + Allowed bool `json:"allowed"` + Reason string `json:"reason,omitempty"` + UsedUSD float64 `json:"used_usd"` + LimitUSD *float64 `json:"limit_usd,omitempty"` +} + +func CheckLimit(used float64, limit *float64) QuotaDecision { + if limit == nil || *limit <= 0 { + return QuotaDecision{Allowed: true, UsedUSD: used} + } + if used >= *limit { + return QuotaDecision{Allowed: false, Reason: "quota_exceeded", UsedUSD: used, LimitUSD: limit} + } + return QuotaDecision{Allowed: true, UsedUSD: used, LimitUSD: limit} +} + +func ModelAllowed(allowed []string, model string) bool { + if len(allowed) == 0 { + return true + } + model = strings.TrimSpace(model) + for _, item := range allowed { + if strings.EqualFold(strings.TrimSpace(item), model) { + return true + } + } + return false +} diff --git a/cpa_key_policy_plus_plugin/go/internal/policyplus/redaction.go b/cpa_key_policy_plus_plugin/go/internal/policyplus/redaction.go new file mode 100644 index 0000000..009650a --- /dev/null +++ b/cpa_key_policy_plus_plugin/go/internal/policyplus/redaction.go @@ -0,0 +1,35 @@ +package policyplus + +import ( + "regexp" + "strings" +) + +var ( + bearerRe = regexp.MustCompile(`(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+`) + secretRe = regexp.MustCompile(`(?i)\b(api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|cookie|secret)\s*[:=]\s*['"]?[^'"\s,;]+`) + spaceRe = regexp.MustCompile(`\s+`) +) + +func RedactString(value string) string { + value = bearerRe.ReplaceAllString(value, "Bearer [REDACTED]") + value = secretRe.ReplaceAllStringFunc(value, func(match string) string { + parts := strings.FieldsFunc(match, func(r rune) bool { return r == ':' || r == '=' }) + if len(parts) == 0 { + return "[REDACTED]" + } + return strings.TrimSpace(parts[0]) + "=[REDACTED]" + }) + return value +} + +func Brief(value string, limit int) string { + value = spaceRe.ReplaceAllString(strings.TrimSpace(RedactString(value)), " ") + if limit <= 0 || len(value) <= limit { + return value + } + if limit <= 3 { + return value[:limit] + } + return strings.TrimSpace(value[:limit-3]) + "..." +} diff --git a/cpa_key_policy_plus_plugin/go/internal/policyplus/security.go b/cpa_key_policy_plus_plugin/go/internal/policyplus/security.go new file mode 100644 index 0000000..a224255 --- /dev/null +++ b/cpa_key_policy_plus_plugin/go/internal/policyplus/security.go @@ -0,0 +1,131 @@ +package policyplus + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "regexp" + "strings" + "time" + "unicode" +) + +func SHA256Hex(value string) string { + sum := sha256.Sum256([]byte(NormalizeSubmittedKey(value))) + return hex.EncodeToString(sum[:]) +} + +var bearerPrefixPattern = regexp.MustCompile(`(?i)^\s*(authorization\s*:\s*)?(bearer\s+)+`) + +// NormalizeSubmittedKey accepts the common clipboard shapes users paste into +// the self-service portal, while keeping hashing deterministic. +func NormalizeSubmittedKey(value string) string { + text := strings.TrimSpace(value) + text = strings.Map(func(r rune) rune { + if unicode.Is(unicode.Cf, r) { + return -1 + } + return r + }, text) + text = strings.TrimSpace(strings.Trim(text, `"'`+"`"+`“”‘’「」『』<>`)) + text = bearerPrefixPattern.ReplaceAllString(text, "") + return strings.TrimSpace(strings.Trim(text, `"'`+"`"+`“”‘’「」『』<>`)) +} + +type SubmittedKeyHint struct { + Error string + Message string +} + +func ExplainUnmatchedSubmittedKey(value string) SubmittedKeyHint { + key := NormalizeSubmittedKey(value) + lower := strings.ToLower(key) + switch { + case key == "": + return SubmittedKeyHint{Error: "missing_api_key", Message: "请粘贴完整的 cpa_ 开头用户 Key。"} + case strings.HasPrefix(lower, "sk-") || strings.HasPrefix(lower, "sk_"): + return SubmittedKeyHint{Error: "native_cpa_key_not_supported", Message: "这是 CPA 原生 sk Key,不能登录用量自助页。请使用 Key Policy 创建时弹窗里的完整 cpa_ 用户 Key。"} + case strings.Contains(key, "...") || strings.Contains(key, "…"): + return SubmittedKeyHint{Error: "key_preview_not_usable", Message: "你粘贴的是缩略预览,不是完整 Key。Key Policy 创建或轮换时弹窗里的完整 cpa_ Key 才能登录。"} + case !strings.HasPrefix(lower, "cpa_"): + return SubmittedKeyHint{Error: "unsupported_key_format", Message: "用量自助页只接受 Key Policy 的完整 cpa_ 用户 Key。"} + case len(key) < 40: + return SubmittedKeyHint{Error: "key_preview_not_usable", Message: "这个 cpa_ Key 太短,像是列表里的预览,不是完整 Key。请在 Key Policy 里点击“轮换”,复制弹窗中新生成的完整 Key。"} + default: + return SubmittedKeyHint{Error: "invalid_api_key", Message: "这个 cpa_ Key 没有匹配到当前 Key Policy 记录。请确认粘贴的是创建或轮换弹窗里的完整 Key;列表里的 cpa_xxx...xxx 只是预览,旧 Key 关闭弹窗后无法找回,需要在 Key Policy 里轮换生成新的完整 Key。"} + } +} + +func NormalizeHash(value string) (string, error) { + text := strings.TrimSpace(value) + text = strings.TrimPrefix(text, "sha256:") + if len(text) != 64 { + return "", errors.New("invalid hash length") + } + _, err := hex.DecodeString(text) + if err != nil { + return "", err + } + return strings.ToLower(text), nil +} + +func HashPreview(value string) string { + normalized, err := NormalizeHash(value) + if err != nil { + normalized = SHA256Hex(value) + } + if len(normalized) <= 16 { + return normalized + } + return normalized[:8] + "..." + normalized[len(normalized)-6:] +} + +type SessionPayload struct { + KeyID string `json:"key_id"` + KeyHash string `json:"key_hash"` + ExpiresAt int64 `json:"expires_at"` +} + +func SignSession(payload SessionPayload, secret string) (string, error) { + if strings.TrimSpace(secret) == "" { + return "", errors.New("session secret is required") + } + raw, err := json.Marshal(payload) + if err != nil { + return "", err + } + body := base64.RawURLEncoding.EncodeToString(raw) + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(body)) + sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + return body + "." + sig, nil +} + +func VerifySession(token, secret string, now time.Time) (SessionPayload, bool) { + parts := strings.Split(token, ".") + if len(parts) != 2 || strings.TrimSpace(secret) == "" { + return SessionPayload{}, false + } + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(parts[0])) + want := mac.Sum(nil) + got, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil || !hmac.Equal(got, want) { + return SessionPayload{}, false + } + raw, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return SessionPayload{}, false + } + var payload SessionPayload + if err := json.Unmarshal(raw, &payload); err != nil { + return SessionPayload{}, false + } + if payload.ExpiresAt > 0 && now.Unix() > payload.ExpiresAt { + return SessionPayload{}, false + } + return payload, true +} diff --git a/cpa_key_policy_plus_plugin/go/internal/policyplus/session.go b/cpa_key_policy_plus_plugin/go/internal/policyplus/session.go new file mode 100644 index 0000000..d3f523b --- /dev/null +++ b/cpa_key_policy_plus_plugin/go/internal/policyplus/session.go @@ -0,0 +1,162 @@ +package policyplus + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "strings" + "time" +) + +const DefaultSessionIdle = 30 * time.Minute + +type SessionIdentity struct { + Source string `json:"source"` + Value string `json:"-"` + Hash string `json:"hash"` +} + +type SessionDecision struct { + Allowed bool `json:"allowed"` + Active int `json:"active"` + Limit int `json:"limit"` + Missing bool `json:"missing"` +} + +func ExtractSessionIdentity(headers http.Header, body []byte) SessionIdentity { + if headers != nil { + if value := strings.TrimSpace(headers.Get("X-Codex-Window-Id")); value != "" { + return newSessionIdentity("x-codex-window-id", value) + } + } + if value := nestedString(body, "client_metadata", "x-codex-window-id"); value != "" { + return newSessionIdentity("client_metadata.x-codex-window-id", value) + } + if headers != nil { + if raw := strings.TrimSpace(headers.Get("X-Codex-Turn-Metadata")); raw != "" { + if value := stringFromJSON([]byte(raw), "window_id"); value != "" { + return newSessionIdentity("x-codex-turn-metadata.window_id", value) + } + if value := stringFromJSON([]byte(raw), "prompt_cache_key"); value != "" { + return newSessionIdentity("x-codex-turn-metadata.prompt_cache_key", value) + } + } + } + if raw := nestedString(body, "client_metadata", "x-codex-turn-metadata"); raw != "" { + if value := stringFromJSON([]byte(raw), "window_id"); value != "" { + return newSessionIdentity("client_metadata.x-codex-turn-metadata.window_id", value) + } + if value := stringFromJSON([]byte(raw), "prompt_cache_key"); value != "" { + return newSessionIdentity("client_metadata.x-codex-turn-metadata.prompt_cache_key", value) + } + } + if value := nestedString(body, "prompt_cache_key"); value != "" { + return newSessionIdentity("prompt_cache_key", value) + } + if headers != nil { + for _, name := range []string{"Session_id", "session_id", "Session-Id", "X-Session-ID"} { + if value := strings.TrimSpace(headers.Get(name)); value != "" { + return newSessionIdentity(strings.ToLower(name), value) + } + } + } + if value := nestedString(body, "conversation_id"); value != "" { + return newSessionIdentity("conversation_id", value) + } + return SessionIdentity{} +} + +func newSessionIdentity(source, value string) SessionIdentity { + value = strings.TrimSpace(value) + if value == "" { + return SessionIdentity{} + } + sum := sha256.Sum256([]byte(source + "\x00" + value)) + return SessionIdentity{ + Source: source, + Value: value, + Hash: "sha256:" + hex.EncodeToString(sum[:]), + } +} + +func nestedString(body []byte, path ...string) string { + if len(body) == 0 { + return "" + } + var cur any + if err := json.Unmarshal(body, &cur); err != nil { + return "" + } + for _, part := range path { + m, ok := cur.(map[string]any) + if !ok { + return "" + } + cur = m[part] + } + if text, ok := cur.(string); ok { + return strings.TrimSpace(text) + } + return "" +} + +func stringFromJSON(body []byte, key string) string { + var raw map[string]any + if err := json.Unmarshal(body, &raw); err != nil { + return "" + } + if text, ok := raw[key].(string); ok { + return strings.TrimSpace(text) + } + return "" +} + +func (s *Store) RegisterActiveSession(ctx context.Context, keyID string, identity SessionIdentity, limit int, idle time.Duration, now time.Time) (SessionDecision, error) { + if limit <= 0 { + return SessionDecision{Allowed: true, Limit: limit, Missing: identity.Hash == ""}, nil + } + if identity.Hash == "" { + _ = s.Audit(ctx, "frontend_auth", "missing_session_identity", keyID, map[string]any{"limit": limit}) + active, _ := s.ActiveSessionCount(ctx, keyID, idle, now) + return SessionDecision{Allowed: true, Active: active, Limit: limit, Missing: true}, nil + } + if idle <= 0 { + idle = DefaultSessionIdle + } + cutoff := now.Add(-idle).Unix() + _, _ = s.db.ExecContext(ctx, `delete from active_sessions where key_id=? and last_seen < ?`, keyID, cutoff) + var existing int + if err := s.db.QueryRowContext(ctx, `select count(1) from active_sessions where key_id=? and session_id=?`, keyID, identity.Hash).Scan(&existing); err != nil { + return SessionDecision{}, err + } + if existing > 0 { + _, err := s.db.ExecContext(ctx, `update active_sessions set last_seen=?, source=? where key_id=? and session_id=?`, now.Unix(), identity.Source, keyID, identity.Hash) + active, _ := s.ActiveSessionCount(ctx, keyID, idle, now) + return SessionDecision{Allowed: err == nil, Active: active, Limit: limit}, err + } + active, err := s.ActiveSessionCount(ctx, keyID, idle, now) + if err != nil { + return SessionDecision{}, err + } + if active >= limit { + _ = s.Audit(ctx, "frontend_auth", "session_limit_exceeded", keyID, map[string]any{"active": active, "limit": limit, "source": identity.Source}) + return SessionDecision{Allowed: false, Active: active, Limit: limit}, nil + } + _, err = s.db.ExecContext(ctx, `insert into active_sessions(key_id, session_id, source, first_seen, last_seen) values(?, ?, ?, ?, ?)`, keyID, identity.Hash, identity.Source, now.Unix(), now.Unix()) + if err != nil { + return SessionDecision{}, err + } + return SessionDecision{Allowed: true, Active: active + 1, Limit: limit}, nil +} + +func (s *Store) ActiveSessionCount(ctx context.Context, keyID string, idle time.Duration, now time.Time) (int, error) { + if idle <= 0 { + idle = DefaultSessionIdle + } + cutoff := now.Add(-idle).Unix() + var count int + err := s.db.QueryRowContext(ctx, `select count(1) from active_sessions where key_id=? and last_seen >= ?`, keyID, cutoff).Scan(&count) + return count, err +} diff --git a/cpa_key_policy_plus_plugin/go/internal/policyplus/store.go b/cpa_key_policy_plus_plugin/go/internal/policyplus/store.go new file mode 100644 index 0000000..3b309ce --- /dev/null +++ b/cpa_key_policy_plus_plugin/go/internal/policyplus/store.go @@ -0,0 +1,939 @@ +package policyplus + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +type Store struct { + db *sql.DB +} + +type UsageEvent struct { + RequestID string `json:"request_id"` + KeyID string `json:"key_id"` + KeyPreview string `json:"key_preview"` + Model string `json:"model"` + RequestedModel string `json:"requested_model,omitempty"` + ActualModel string `json:"actual_model,omitempty"` + Provider string `json:"provider,omitempty"` + ExecutorType string `json:"executor_type,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + RequestedAt time.Time `json:"requested_at"` + LatencyMS int64 `json:"latency_ms"` + TTFTMS int64 `json:"ttft_ms,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` + ServiceTier string `json:"service_tier,omitempty"` + StatusCode int `json:"status_code,omitempty"` + Failed bool `json:"failed"` + Failure string `json:"failure,omitempty"` + Usage TokenUsage `json:"usage"` + Cost float64 `json:"cost"` + CostBreakdown CostBreakdown `json:"cost_breakdown"` +} + +type UsageSummary struct { + Calls int64 `json:"calls"` + Failed int64 `json:"failed"` + TotalCost float64 `json:"total_cost"` + Usage TokenUsage `json:"usage"` +} + +type CodexSummary struct { + RequestID string `json:"request_id"` + KeyID string `json:"key_id,omitempty"` + Model string `json:"model,omitempty"` + Protection string `json:"protection,omitempty"` + Summary map[string]any `json:"summary"` + UpdatedAt time.Time `json:"updated_at"` +} + +type LegacyImportResult struct { + Limits int + Resets int +} + +func OpenStore(path string) (*Store, error) { + if path == "" { + path = "cpa-policyplus.sqlite" + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil && filepath.Dir(path) != "." { + return nil, err + } + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + store := &Store{db: db} + if err := store.EnsureSchema(context.Background()); err != nil { + _ = db.Close() + return nil, err + } + return store, nil +} + +func (s *Store) Close() error { + if s == nil || s.db == nil { + return nil + } + return s.db.Close() +} + +func (s *Store) EnsureSchema(ctx context.Context) error { + stmts := []string{ + `pragma journal_mode=wal`, + `create table if not exists keys ( + id text primary key, + name text not null, + key_hash text not null unique, + enabled integer not null, + preview text, + rpm integer, + concurrency integer, + max_active_sessions integer, + models_json text, + prices_json text, + five_hour_limit_usd real, + daily_limit_usd real, + weekly_limit_usd real, + monthly_limit_usd real, + updated_at integer not null + )`, + `create table if not exists reset_watermarks ( + key_id text not null, + window text not null, + reset_at integer not null, + primary key(key_id, window) + )`, + `create table if not exists active_sessions ( + key_id text not null, + session_id text not null, + source text, + first_seen integer not null, + last_seen integer not null, + primary key(key_id, session_id) + )`, + `create table if not exists usage_events ( + id integer primary key autoincrement, + request_id text, + key_id text, + key_preview text, + model text, + requested_model text, + actual_model text, + provider text, + executor_type text, + endpoint text, + requested_at integer not null, + latency_ms integer, + ttft_ms integer, + reasoning_effort text, + service_tier text, + status_code integer, + failed integer not null, + failure text, + input_tokens integer, + output_tokens integer, + cached_tokens integer, + cache_read_tokens integer, + cache_creation_tokens integer, + reasoning_tokens integer, + total_tokens integer, + cost real, + cost_breakdown_json text + )`, + `create table if not exists codexcont_summaries ( + request_id text primary key, + key_id text, + model text, + protection text, + summary_json text, + updated_at integer not null + )`, + `create table if not exists audit_log ( + id integer primary key autoincrement, + timestamp integer not null, + actor text, + action text not null, + target text, + detail_json text + )`, + `create table if not exists settings ( + key text primary key, + value text not null, + updated_at integer not null + )`, + } + for _, stmt := range stmts { + if _, err := s.db.ExecContext(ctx, stmt); err != nil { + return err + } + } + if err := s.ensureColumns(ctx, "usage_events", map[string]string{ + "requested_model": "text default ''", + "actual_model": "text default ''", + "provider": "text default ''", + "executor_type": "text default ''", + "ttft_ms": "integer default 0", + "reasoning_effort": "text default ''", + "service_tier": "text default ''", + "status_code": "integer default 0", + }); err != nil { + return err + } + if err := s.ensureColumns(ctx, "keys", map[string]string{ + "max_active_sessions": "integer default 0", + }); err != nil { + return err + } + return nil +} + +func (s *Store) ensureColumns(ctx context.Context, table string, columns map[string]string) error { + rows, err := s.db.QueryContext(ctx, fmt.Sprintf(`pragma table_info(%s)`, table)) + if err != nil { + return err + } + defer rows.Close() + existing := map[string]bool{} + for rows.Next() { + var cid int + var name, typ string + var notNull int + var defaultValue sql.NullString + var pk int + if err := rows.Scan(&cid, &name, &typ, ¬Null, &defaultValue, &pk); err != nil { + return err + } + existing[name] = true + } + if err := rows.Err(); err != nil { + return err + } + for name, typ := range columns { + if existing[name] { + continue + } + if _, err := s.db.ExecContext(ctx, fmt.Sprintf(`alter table %s add column %s %s`, table, name, typ)); err != nil { + return err + } + } + return nil +} + +func (s *Store) UpsertKey(ctx context.Context, key KeyRecord) error { + if err := ValidateKeyRecord(key); err != nil { + return err + } + models, _ := json.Marshal(key.Models) + prices, _ := json.Marshal(key.Prices) + _, err := s.db.ExecContext( + ctx, + `insert into keys( + id, name, key_hash, enabled, preview, rpm, concurrency, max_active_sessions, models_json, prices_json, + five_hour_limit_usd, daily_limit_usd, weekly_limit_usd, monthly_limit_usd, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + on conflict(id) do update set + name=excluded.name, + key_hash=excluded.key_hash, + enabled=excluded.enabled, + preview=excluded.preview, + rpm=excluded.rpm, + concurrency=excluded.concurrency, + max_active_sessions=excluded.max_active_sessions, + models_json=excluded.models_json, + prices_json=excluded.prices_json, + five_hour_limit_usd=coalesce(keys.five_hour_limit_usd, excluded.five_hour_limit_usd), + daily_limit_usd=excluded.daily_limit_usd, + weekly_limit_usd=excluded.weekly_limit_usd, + monthly_limit_usd=coalesce(keys.monthly_limit_usd, excluded.monthly_limit_usd), + updated_at=excluded.updated_at`, + key.ID, + key.Name, + key.KeyHash, + boolInt(key.Enabled), + key.Preview, + key.RPM, + key.Concurrency, + key.MaxActiveSessions, + string(models), + string(prices), + key.FiveHourUSD, + key.DailyLimitUSD, + key.WeeklyLimitUSD, + key.MonthlyLimitUSD, + time.Now().Unix(), + ) + return err +} + +func (s *Store) ImportKeys(ctx context.Context, state KeyPolicyState) error { + for _, key := range state.Keys { + if err := s.UpsertKey(ctx, key); err != nil { + return err + } + } + return nil +} + +func (s *Store) ImportLegacyQuotaSQLite(ctx context.Context, path, source string) (LegacyImportResult, error) { + path = strings.TrimSpace(path) + if path == "" { + return LegacyImportResult{}, nil + } + db, err := sql.Open("sqlite", path) + if err != nil { + return LegacyImportResult{}, err + } + defer db.Close() + result := LegacyImportResult{} + if ok, _ := legacyTableExists(ctx, db, "key_limits"); ok { + n, err := s.importLegacyPortalLimits(ctx, db) + if err != nil { + return result, err + } + result.Limits += n + } + if ok, _ := legacyTableExists(ctx, db, "keys"); ok { + n, err := s.importLegacyGovernorLimits(ctx, db) + if err != nil { + return result, err + } + result.Limits += n + } + if ok, _ := legacyTableExists(ctx, db, "reset_watermarks"); ok { + n, err := s.importLegacyResets(ctx, db) + if err != nil { + return result, err + } + result.Resets += n + } + if result.Limits > 0 || result.Resets > 0 { + _ = s.Audit(ctx, "system", "import_legacy_quota_sqlite", source, map[string]any{ + "path": path, + "limits": result.Limits, + "resets": result.Resets, + }) + } + return result, nil +} + +func legacyTableExists(ctx context.Context, db *sql.DB, name string) (bool, error) { + var count int + err := db.QueryRowContext(ctx, `select count(1) from sqlite_master where type='table' and name=?`, name).Scan(&count) + return count > 0, err +} + +func legacyColumns(ctx context.Context, db *sql.DB, table string) (map[string]bool, error) { + rows, err := db.QueryContext(ctx, fmt.Sprintf(`pragma table_info(%s)`, table)) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]bool{} + for rows.Next() { + var cid int + var name, typ string + var notNull int + var defaultValue sql.NullString + var pk int + if err := rows.Scan(&cid, &name, &typ, ¬Null, &defaultValue, &pk); err != nil { + return nil, err + } + out[name] = true + } + return out, rows.Err() +} + +func (s *Store) importLegacyPortalLimits(ctx context.Context, db *sql.DB) (int, error) { + rows, err := db.QueryContext(ctx, `select policy_id, five_hour_limit_usd, monthly_limit_usd from key_limits`) + if err != nil { + return 0, err + } + defer rows.Close() + count := 0 + for rows.Next() { + var id string + var fiveHour, monthly sql.NullFloat64 + if err := rows.Scan(&id, &fiveHour, &monthly); err != nil { + return count, err + } + if strings.TrimSpace(id) == "" { + continue + } + res, err := s.db.ExecContext(ctx, `update keys set + five_hour_limit_usd=coalesce(five_hour_limit_usd, ?), + monthly_limit_usd=coalesce(monthly_limit_usd, ?), + updated_at=? + where id=?`, + nullFloatValue(fiveHour), nullFloatValue(monthly), time.Now().Unix(), id) + if err != nil { + return count, err + } + if affected, _ := res.RowsAffected(); affected > 0 { + count++ + } + } + return count, rows.Err() +} + +func (s *Store) importLegacyGovernorLimits(ctx context.Context, db *sql.DB) (int, error) { + columns, err := legacyColumns(ctx, db, "keys") + if err != nil { + return 0, err + } + for _, required := range []string{"id", "five_hour_limit_usd", "daily_limit_usd", "weekly_limit_usd", "monthly_limit_usd"} { + if !columns[required] { + return 0, nil + } + } + rows, err := db.QueryContext(ctx, `select id, five_hour_limit_usd, daily_limit_usd, weekly_limit_usd, monthly_limit_usd from keys`) + if err != nil { + return 0, err + } + defer rows.Close() + count := 0 + for rows.Next() { + var id string + var fiveHour, daily, weekly, monthly sql.NullFloat64 + if err := rows.Scan(&id, &fiveHour, &daily, &weekly, &monthly); err != nil { + return count, err + } + if strings.TrimSpace(id) == "" { + continue + } + res, err := s.db.ExecContext(ctx, `update keys set + five_hour_limit_usd=coalesce(five_hour_limit_usd, ?), + daily_limit_usd=coalesce(daily_limit_usd, ?), + weekly_limit_usd=coalesce(weekly_limit_usd, ?), + monthly_limit_usd=coalesce(monthly_limit_usd, ?), + updated_at=? + where id=?`, + nullFloatValue(fiveHour), nullFloatValue(daily), nullFloatValue(weekly), + nullFloatValue(monthly), time.Now().Unix(), id) + if err != nil { + return count, err + } + if affected, _ := res.RowsAffected(); affected > 0 { + count++ + } + } + return count, rows.Err() +} + +func (s *Store) importLegacyResets(ctx context.Context, db *sql.DB) (int, error) { + columns, err := legacyColumns(ctx, db, "reset_watermarks") + if err != nil { + return 0, err + } + idColumn := "" + for _, candidate := range []string{"key_id", "policy_id"} { + if columns[candidate] { + idColumn = candidate + break + } + } + timeColumn := "" + for _, candidate := range []string{"reset_at", "reset_at_ms"} { + if columns[candidate] { + timeColumn = candidate + break + } + } + if idColumn == "" || timeColumn == "" || !columns["window"] { + return 0, nil + } + rows, err := db.QueryContext(ctx, fmt.Sprintf(`select %s, window, %s from reset_watermarks`, idColumn, timeColumn)) + if err != nil { + return 0, err + } + defer rows.Close() + count := 0 + for rows.Next() { + var id, window string + var resetAt sql.NullInt64 + if err := rows.Scan(&id, &window, &resetAt); err != nil { + return count, err + } + if strings.TrimSpace(id) == "" || strings.TrimSpace(window) == "" || !resetAt.Valid { + continue + } + ts := resetAt.Int64 + if ts > 1_000_000_000_000 { + ts = ts / 1000 + } + res, err := s.db.ExecContext(ctx, `insert into reset_watermarks(key_id, window, reset_at) values(?, ?, ?) + on conflict(key_id, window) do update set reset_at=excluded.reset_at + where excluded.reset_at > reset_watermarks.reset_at`, id, window, ts) + if err != nil { + return count, err + } + if affected, _ := res.RowsAffected(); affected > 0 { + count++ + } + } + return count, rows.Err() +} + +func (s *Store) syncDeletedKeys(ctx context.Context, seen map[string]bool) error { + rows, err := s.db.QueryContext(ctx, `select id from keys`) + if err != nil { + return err + } + var stale []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + _ = rows.Close() + return err + } + if !seen[id] { + stale = append(stale, id) + } + } + if err := rows.Close(); err != nil { + return err + } + for _, id := range stale { + if _, err := s.db.ExecContext(ctx, `delete from keys where id=?`, id); err != nil { + return err + } + } + return nil +} + +func (s *Store) ListKeys(ctx context.Context) ([]KeyRecord, error) { + rows, err := s.db.QueryContext(ctx, `select id, name, key_hash, enabled, preview, rpm, concurrency, max_active_sessions, models_json, prices_json, + five_hour_limit_usd, daily_limit_usd, weekly_limit_usd, monthly_limit_usd from keys order by name collate nocase`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []KeyRecord + for rows.Next() { + var key KeyRecord + var enabled int + var modelsJSON, pricesJSON string + var fiveHour, daily, weekly, monthly sql.NullFloat64 + if err := rows.Scan( + &key.ID, &key.Name, &key.KeyHash, &enabled, &key.Preview, &key.RPM, &key.Concurrency, &key.MaxActiveSessions, + &modelsJSON, &pricesJSON, &fiveHour, &daily, &weekly, &monthly, + ); err != nil { + return nil, err + } + key.Enabled = enabled != 0 + key.FiveHourUSD = nullFloatPtr(fiveHour) + key.DailyLimitUSD = nullFloatPtr(daily) + key.WeeklyLimitUSD = nullFloatPtr(weekly) + key.MonthlyLimitUSD = nullFloatPtr(monthly) + _ = json.Unmarshal([]byte(modelsJSON), &key.Models) + _ = json.Unmarshal([]byte(pricesJSON), &key.Prices) + out = append(out, key) + } + return out, rows.Err() +} + +func (s *Store) FindKeyByHash(ctx context.Context, hash string) (KeyRecord, bool, error) { + keys, err := s.ListKeys(ctx) + if err != nil { + return KeyRecord{}, false, err + } + normalized, err := NormalizeHash(hash) + if err != nil { + return KeyRecord{}, false, nil + } + for _, key := range keys { + keyHash, err := NormalizeHash(key.KeyHash) + if err == nil && keyHash == normalized { + return key, true, nil + } + } + return KeyRecord{}, false, nil +} + +func (s *Store) SetLimits(ctx context.Context, id string, fiveHour, monthly *float64) error { + for _, limit := range []*float64{fiveHour, monthly} { + if limit != nil && *limit < 0 { + return fmt.Errorf("usd limits must not be negative") + } + } + res, err := s.db.ExecContext(ctx, `update keys set five_hour_limit_usd=?, monthly_limit_usd=?, updated_at=? where id=?`, fiveHour, monthly, time.Now().Unix(), id) + if err != nil { + return err + } + if affected, _ := res.RowsAffected(); affected == 0 { + return fmt.Errorf("unknown key: %s", id) + } + return s.Audit(ctx, "admin", "set_limits", id, map[string]any{"five_hour_usd": fiveHour, "monthly_usd": monthly}) +} + +func (s *Store) SaveKeySettings(ctx context.Context, key KeyRecord) error { + if err := ValidateKeyRecord(key); err != nil { + return err + } + models, _ := json.Marshal(key.Models) + prices, _ := json.Marshal(key.Prices) + res, err := s.db.ExecContext(ctx, `update keys set + name=?, + enabled=?, + rpm=?, + concurrency=?, + max_active_sessions=?, + models_json=?, + prices_json=?, + five_hour_limit_usd=?, + daily_limit_usd=?, + weekly_limit_usd=?, + monthly_limit_usd=?, + updated_at=? + where id=?`, + key.Name, + boolInt(key.Enabled), + key.RPM, + key.Concurrency, + key.MaxActiveSessions, + string(models), + string(prices), + key.FiveHourUSD, + key.DailyLimitUSD, + key.WeeklyLimitUSD, + key.MonthlyLimitUSD, + time.Now().Unix(), + key.ID, + ) + if err != nil { + return err + } + if affected, _ := res.RowsAffected(); affected == 0 { + return fmt.Errorf("unknown key: %s", key.ID) + } + return s.Audit(ctx, "admin", "save_key_settings", key.ID, map[string]any{ + "enabled": key.Enabled, + "rpm": key.RPM, + "concurrency": key.Concurrency, + "max_active_sessions": key.MaxActiveSessions, + }) +} + +func (s *Store) Reset(ctx context.Context, id, window string, at time.Time) error { + _, err := s.db.ExecContext(ctx, `insert into reset_watermarks(key_id, window, reset_at) values(?, ?, ?) + on conflict(key_id, window) do update set reset_at=excluded.reset_at`, id, window, at.Unix()) + if err != nil { + return err + } + return s.Audit(ctx, "admin", "reset_usage", id, map[string]any{"window": window, "reset_at": at.Unix()}) +} + +func (s *Store) InsertUsage(ctx context.Context, event UsageEvent) error { + if event.RequestedAt.IsZero() { + event.RequestedAt = time.Now() + } + breakdown, _ := json.Marshal(event.CostBreakdown) + _, err := s.db.ExecContext( + ctx, + `insert into usage_events( + request_id, key_id, key_preview, model, requested_model, actual_model, provider, executor_type, + endpoint, requested_at, latency_ms, ttft_ms, reasoning_effort, service_tier, status_code, failed, failure, + input_tokens, output_tokens, cached_tokens, cache_read_tokens, cache_creation_tokens, + reasoning_tokens, total_tokens, cost, cost_breakdown_json + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + event.RequestID, event.KeyID, event.KeyPreview, event.Model, event.RequestedModel, event.ActualModel, + event.Provider, event.ExecutorType, event.Endpoint, event.RequestedAt.Unix(), + event.LatencyMS, event.TTFTMS, event.ReasoningEffort, event.ServiceTier, event.StatusCode, + boolInt(event.Failed), event.Failure, + event.Usage.InputTokens, event.Usage.OutputTokens, event.Usage.CachedTokens, event.Usage.CacheReadTokens, + event.Usage.CacheCreationTokens, event.Usage.ReasoningTokens, event.Usage.TotalTokens, + event.Cost, string(breakdown), + ) + return err +} + +func (s *Store) UsageSum(ctx context.Context, keyID string, window Window) (float64, error) { + var total sql.NullFloat64 + from := window.From.Unix() + if resetAt, ok := s.ResetAt(ctx, keyID, window.Name); ok && resetAt > from { + from = resetAt + } + args := []any{from, window.To.Unix()} + query := `select coalesce(sum(cost), 0) from usage_events where requested_at >= ? and requested_at <= ?` + if keyID != "" && keyID != "all" { + query += ` and key_id = ?` + args = append(args, keyID) + } + if err := s.db.QueryRowContext(ctx, query, args...).Scan(&total); err != nil { + return 0, err + } + if !total.Valid { + return 0, nil + } + return total.Float64, nil +} + +func (s *Store) ResetAt(ctx context.Context, keyID, window string) (int64, bool) { + if s == nil || s.db == nil || keyID == "" || window == "" || keyID == "all" { + return 0, false + } + var resetAt sql.NullInt64 + if err := s.db.QueryRowContext(ctx, `select reset_at from reset_watermarks where key_id=? and window=?`, keyID, window).Scan(&resetAt); err != nil { + return 0, false + } + return resetAt.Int64, resetAt.Valid +} + +func (s *Store) UsageSummary(ctx context.Context, keyID string, window Window) (UsageSummary, error) { + from := window.From.Unix() + if resetAt, ok := s.ResetAt(ctx, keyID, window.Name); ok && resetAt > from { + from = resetAt + } + args := []any{from, window.To.Unix()} + query := `select + count(*), + coalesce(sum(case when failed != 0 then 1 else 0 end), 0), + coalesce(sum(cost), 0), + coalesce(sum(input_tokens), 0), + coalesce(sum(output_tokens), 0), + coalesce(sum(cached_tokens), 0), + coalesce(sum(cache_read_tokens), 0), + coalesce(sum(cache_creation_tokens), 0), + coalesce(sum(reasoning_tokens), 0), + coalesce(sum(total_tokens), 0) + from usage_events where requested_at >= ? and requested_at <= ?` + if keyID != "" && keyID != "all" { + query += ` and key_id = ?` + args = append(args, keyID) + } + var summary UsageSummary + if err := s.db.QueryRowContext(ctx, query, args...).Scan( + &summary.Calls, + &summary.Failed, + &summary.TotalCost, + &summary.Usage.InputTokens, + &summary.Usage.OutputTokens, + &summary.Usage.CachedTokens, + &summary.Usage.CacheReadTokens, + &summary.Usage.CacheCreationTokens, + &summary.Usage.ReasoningTokens, + &summary.Usage.TotalTokens, + ); err != nil { + return UsageSummary{}, err + } + return summary, nil +} + +func (s *Store) RecentEvents(ctx context.Context, keyID string, limit int) ([]UsageEvent, error) { + if limit <= 0 || limit > 200 { + limit = 100 + } + query := `select coalesce(request_id, ''), coalesce(key_id, ''), coalesce(key_preview, ''), coalesce(model, ''), + coalesce(requested_model, ''), coalesce(actual_model, ''), coalesce(provider, ''), coalesce(executor_type, ''), + coalesce(endpoint, ''), requested_at, coalesce(latency_ms, 0), coalesce(ttft_ms, 0), + coalesce(reasoning_effort, ''), coalesce(service_tier, ''), coalesce(status_code, 0), coalesce(failed, 0), coalesce(failure, ''), + coalesce(input_tokens, 0), coalesce(output_tokens, 0), coalesce(cached_tokens, 0), coalesce(cache_read_tokens, 0), + coalesce(cache_creation_tokens, 0), coalesce(reasoning_tokens, 0), coalesce(total_tokens, 0), coalesce(cost, 0), coalesce(cost_breakdown_json, '{}') + from usage_events` + args := []any{} + if keyID != "" && keyID != "all" { + query += ` where key_id = ?` + args = append(args, keyID) + } + query += ` order by requested_at desc, id desc limit ?` + args = append(args, limit) + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []UsageEvent + for rows.Next() { + var event UsageEvent + var ts int64 + var failed int + var breakdown string + if err := rows.Scan( + &event.RequestID, &event.KeyID, &event.KeyPreview, &event.Model, &event.RequestedModel, &event.ActualModel, + &event.Provider, &event.ExecutorType, &event.Endpoint, &ts, &event.LatencyMS, &event.TTFTMS, + &event.ReasoningEffort, &event.ServiceTier, &event.StatusCode, &failed, &event.Failure, + &event.Usage.InputTokens, &event.Usage.OutputTokens, &event.Usage.CachedTokens, &event.Usage.CacheReadTokens, + &event.Usage.CacheCreationTokens, &event.Usage.ReasoningTokens, &event.Usage.TotalTokens, &event.Cost, &breakdown, + ); err != nil { + return nil, err + } + event.RequestedAt = time.Unix(ts, 0) + event.Failed = failed != 0 + _ = json.Unmarshal([]byte(breakdown), &event.CostBreakdown) + out = append(out, event) + } + return out, rows.Err() +} + +func (s *Store) RecentEventsWindow(ctx context.Context, keyID string, window Window, limit int) ([]UsageEvent, error) { + if limit <= 0 || limit > 200 { + limit = 100 + } + from := window.From.Unix() + if resetAt, ok := s.ResetAt(ctx, keyID, window.Name); ok && resetAt > from { + from = resetAt + } + query := `select coalesce(request_id, ''), coalesce(key_id, ''), coalesce(key_preview, ''), coalesce(model, ''), + coalesce(requested_model, ''), coalesce(actual_model, ''), coalesce(provider, ''), coalesce(executor_type, ''), + coalesce(endpoint, ''), requested_at, coalesce(latency_ms, 0), coalesce(ttft_ms, 0), + coalesce(reasoning_effort, ''), coalesce(service_tier, ''), coalesce(status_code, 0), coalesce(failed, 0), coalesce(failure, ''), + coalesce(input_tokens, 0), coalesce(output_tokens, 0), coalesce(cached_tokens, 0), coalesce(cache_read_tokens, 0), + coalesce(cache_creation_tokens, 0), coalesce(reasoning_tokens, 0), coalesce(total_tokens, 0), coalesce(cost, 0), coalesce(cost_breakdown_json, '{}') + from usage_events where requested_at >= ? and requested_at <= ?` + args := []any{from, window.To.Unix()} + if keyID != "" && keyID != "all" { + query += ` and key_id = ?` + args = append(args, keyID) + } + query += ` order by requested_at desc, id desc limit ?` + args = append(args, limit) + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []UsageEvent + for rows.Next() { + var event UsageEvent + var ts int64 + var failed int + var breakdown string + if err := rows.Scan( + &event.RequestID, &event.KeyID, &event.KeyPreview, &event.Model, &event.RequestedModel, &event.ActualModel, + &event.Provider, &event.ExecutorType, &event.Endpoint, &ts, &event.LatencyMS, &event.TTFTMS, + &event.ReasoningEffort, &event.ServiceTier, &event.StatusCode, &failed, &event.Failure, + &event.Usage.InputTokens, &event.Usage.OutputTokens, &event.Usage.CachedTokens, &event.Usage.CacheReadTokens, + &event.Usage.CacheCreationTokens, &event.Usage.ReasoningTokens, &event.Usage.TotalTokens, &event.Cost, &breakdown, + ); err != nil { + return nil, err + } + event.RequestedAt = time.Unix(ts, 0) + event.Failed = failed != 0 + _ = json.Unmarshal([]byte(breakdown), &event.CostBreakdown) + out = append(out, event) + } + return out, rows.Err() +} + +func (s *Store) SaveCodexSummary(ctx context.Context, requestID, keyID, model, protection string, summary any) error { + raw, _ := json.Marshal(summary) + _, err := s.db.ExecContext(ctx, `insert into codexcont_summaries(request_id, key_id, model, protection, summary_json, updated_at) + values(?, ?, ?, ?, ?, ?) + on conflict(request_id) do update set key_id=excluded.key_id, model=excluded.model, + protection=excluded.protection, summary_json=excluded.summary_json, updated_at=excluded.updated_at`, + requestID, keyID, model, protection, string(raw), time.Now().Unix()) + return err +} + +func (s *Store) RecentCodexSummaries(ctx context.Context, keyID string, limit int) ([]CodexSummary, error) { + if limit <= 0 || limit > 200 { + limit = 100 + } + query := `select request_id, key_id, model, protection, summary_json, updated_at from codexcont_summaries` + args := []any{} + if keyID != "" && keyID != "all" { + query += ` where key_id = ?` + args = append(args, keyID) + } + query += ` order by updated_at desc limit ?` + args = append(args, limit) + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []CodexSummary + for rows.Next() { + var item CodexSummary + var raw string + var ts int64 + if err := rows.Scan(&item.RequestID, &item.KeyID, &item.Model, &item.Protection, &raw, &ts); err != nil { + return nil, err + } + item.UpdatedAt = time.Unix(ts, 0) + _ = json.Unmarshal([]byte(raw), &item.Summary) + if item.Summary == nil { + item.Summary = map[string]any{} + } + out = append(out, item) + } + return out, rows.Err() +} + +func (s *Store) Audit(ctx context.Context, actor, action, target string, detail any) error { + raw, _ := json.Marshal(detail) + _, err := s.db.ExecContext(ctx, `insert into audit_log(timestamp, actor, action, target, detail_json) values(?, ?, ?, ?, ?)`, + time.Now().Unix(), actor, action, target, string(raw)) + return err +} + +func (s *Store) AuditCount(ctx context.Context, action string) (int, error) { + var count int + args := []any{} + query := `select count(1) from audit_log` + if strings.TrimSpace(action) != "" { + query += ` where action = ?` + args = append(args, action) + } + err := s.db.QueryRowContext(ctx, query, args...).Scan(&count) + return count, err +} + +func (s *Store) LoadSettings(ctx context.Context) (map[string]string, error) { + rows, err := s.db.QueryContext(ctx, `select key, value from settings`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]string{} + for rows.Next() { + var key, value string + if err := rows.Scan(&key, &value); err != nil { + return nil, err + } + out[key] = value + } + return out, rows.Err() +} + +func (s *Store) SaveSettings(ctx context.Context, values map[string]string) error { + for key, value := range values { + if _, err := s.db.ExecContext(ctx, `insert into settings(key, value, updated_at) values(?, ?, ?) + on conflict(key) do update set value=excluded.value, updated_at=excluded.updated_at`, + key, value, time.Now().Unix()); err != nil { + return err + } + } + return s.Audit(ctx, "admin", "set_settings", "policyplus", values) +} + +func boolInt(value bool) int { + if value { + return 1 + } + return 0 +} + +func nullFloatPtr(value sql.NullFloat64) *float64 { + if !value.Valid { + return nil + } + v := value.Float64 + return &v +} + +func nullFloatValue(value sql.NullFloat64) any { + if !value.Valid { + return nil + } + return value.Float64 +} diff --git a/cpa_key_policy_plus_plugin/go/main.go b/cpa_key_policy_plus_plugin/go/main.go new file mode 100644 index 0000000..e4cd777 --- /dev/null +++ b/cpa_key_policy_plus_plugin/go/main.go @@ -0,0 +1,1843 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "sort" + "strconv" + "strings" + "sync" + "time" + + "codexcont/cpa-key-policy-plus-plugin/internal/policyplus" + _ "embed" + "gopkg.in/yaml.v3" +) + +const pluginID = "cpa-key-policy-plus" + +//go:embed assets/admin.html +var adminHTMLTemplate string + +//go:embed assets/user.html +var userHTMLTemplate string + +//go:embed assets/shared.css +var sharedCSSTemplate string + +type envelope struct { + OK bool `json:"ok"` + Result json.RawMessage `json:"result,omitempty"` + Error *envelopeError `json:"error,omitempty"` +} + +type envelopeError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type lifecycleRequest struct { + ConfigYAML []byte `json:"config_yaml"` +} + +type registration struct { + SchemaVersion uint32 `json:"schema_version"` + Metadata metadata `json:"metadata"` + Capabilities capabilities `json:"capabilities"` +} + +type metadata struct { + Name string `json:"Name"` + Version string `json:"Version"` + Author string `json:"Author"` + GitHubRepository string `json:"GitHubRepository"` + ConfigFields []configField `json:"ConfigFields"` +} + +type capabilities struct { + FrontendAuthProvider bool `json:"frontend_auth_provider"` + FrontendAuthProviderExclusive bool `json:"frontend_auth_provider_exclusive"` + ModelRouter bool `json:"model_router"` + Executor bool `json:"executor"` + ExecutorModelScope string `json:"executor_model_scope"` + ExecutorInputFormats []string `json:"executor_input_formats"` + ExecutorOutputFormats []string `json:"executor_output_formats"` + UsagePlugin bool `json:"usage_plugin"` + ManagementAPI bool `json:"management_api"` +} + +type runtimeState struct { + mu sync.RWMutex + cfg policyplus.Config + store *policyplus.Store + keyState policyplus.KeyPolicyState + keyStatePath string + keyStateModTime time.Time + keyStateLastCheck time.Time + rpmBuckets map[string][]time.Time + concurrency map[string]int +} + +var state = runtimeState{ + rpmBuckets: map[string][]time.Time{}, + concurrency: map[string]int{}, +} + +func main() { runPreviewIfRequested() } + +func runPreviewIfRequested() { + addr := strings.TrimSpace(os.Getenv("CPA_KEY_POLICY_PLUS_PREVIEW_ADDR")) + if addr == "" { + return + } + cfg := policyplus.DefaultConfig() + previewDir, err := os.MkdirTemp("", "cpa-key-policy-plus-preview-*") + if err != nil { + panic(err) + } + cfg.StateDBPath = previewDir + "/policyplus.sqlite" + cfg.SessionSecret = "preview-secret" + cfg.CodexContEnabled = true + cfg.CodexContURL = "http://" + addr + store, err := policyplus.OpenStore(cfg.StateDBPath) + if err != nil { + panic(err) + } + previewRawKey := "cpa_preview_abcdefghijklmnopqrstuvwxyz0123456789AB" + previewKey := policyplus.KeyRecord{ + ID: "preview-key", + Name: "演示用户", + KeyHash: "sha256:" + policyplus.SHA256Hex(previewRawKey), + Enabled: true, + Preview: policyplus.HashPreview(policyplus.SHA256Hex(previewRawKey)), + RPM: 60, + Concurrency: 2, + Models: []string{"gpt-5.5", "gpt-5.4"}, + FiveHourUSD: floatPtr(2), + DailyLimitUSD: floatPtr(8), + WeeklyLimitUSD: floatPtr(40), + MonthlyLimitUSD: floatPtr(120), + Prices: map[string]policyplus.ModelPrice{ + "gpt-5.5": {Model: "gpt-5.5", InputPerMillion: 5, OutputPerMillion: 30, CacheReadPerMillion: 0.5}, + }, + } + _ = store.UpsertKey(context.Background(), previewKey) + _ = store.InsertUsage(context.Background(), policyplus.UsageEvent{ + RequestID: "req-preview-a", + KeyID: previewKey.ID, + KeyPreview: previewKey.Preview, + Model: "gpt-5.5", + RequestedModel: "gpt-5.5", + ActualModel: "gpt-5.5", + Provider: "openai", + ExecutorType: "codex", + Endpoint: "/v1/responses", + RequestedAt: time.Now().Add(-3 * time.Minute), + LatencyMS: 14320, + TTFTMS: 1180, + ReasoningEffort: "high", + ServiceTier: "default", + StatusCode: 200, + Usage: policyplus.TokenUsage{ + InputTokens: 120000, + CachedTokens: 103000, + OutputTokens: 2100, + ReasoningTokens: 516, + TotalTokens: 122100, + }, + Cost: 0.151, + CostBreakdown: policyplus.CostForUsage(previewKey.Prices["gpt-5.5"], policyplus.TokenUsage{ + InputTokens: 120000, + CachedTokens: 103000, + OutputTokens: 2100, + ReasoningTokens: 516, + TotalTokens: 122100, + }, "gpt-5.5"), + }) + _ = store.SaveCodexSummary(context.Background(), "req-preview-a", previewKey.ID, "gpt-5.5", "auto_continued", map[string]any{ + "request_id": "req-preview-a", + "model": "gpt-5.5", + "path": "/v1/responses", + "started_at": time.Now().Add(-2 * time.Minute).Format(time.RFC3339), + "updated_at": time.Now().Add(-90 * time.Second).Format(time.RFC3339), + "duration_ms": 5570, + "status": "completed", + "protection": "auto_continued", + "key_identity": previewKey.Safe(), + "latest_round": 2, + "latest_reasoning_tokens": 181, + "first_truncation_round": 1, + "first_truncation_reasoning_tokens": 516, + "first_truncation_decision": "continue", + "continuation_count": 1, + "stopped_reason": "completed", + "rounds": []map[string]any{{"round": 1, "reasoning_tokens": 516, "decision": "continue", "truncation_match": true}, {"round": 2, "reasoning_tokens": 181, "decision": "clean", "truncation_match": false}}, + }) + state.mu.Lock() + state.cfg = cfg + state.store = store + state.keyState = policyplus.KeyPolicyState{Keys: []policyplus.KeyRecord{previewKey}} + state.rpmBuckets = map[string][]time.Time{} + state.concurrency = map[string]int{} + state.mu.Unlock() + mux := http.NewServeMux() + mux.HandleFunc("/v0/resource/plugins/cpa-key-policy-plus/admin", func(w http.ResponseWriter, r *http.Request) { + _ = r + w.Header().Set("content-type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(adminHTML())) + }) + mux.HandleFunc("/v0/resource/plugins/cpa-key-policy-plus/user", func(w http.ResponseWriter, r *http.Request) { + _ = r + w.Header().Set("content-type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(userHTML())) + }) + writePreviewStatus := func(w http.ResponseWriter) { + w.Header().Set("content-type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "counters": map[string]any{ + "total_requests": 4, + "active_requests": 17, + "continuations": 1, + "truncation_hits": 1, + "failures": 0, + }, + "last_error": nil, + }) + } + writePreviewRequests := func(w http.ResponseWriter) { + w.Header().Set("content-type", "application/json; charset=utf-8") + now := time.Now().UTC() + identity := previewKey.Safe() + _ = json.NewEncoder(w).Encode(map[string]any{"requests": []map[string]any{ + { + "request_id": "req-preview-a", + "model": "gpt-5.5", + "path": "/v1/responses", + "started_at": now.Add(-4 * time.Minute).Format(time.RFC3339), + "updated_at": now.Add(-3 * time.Minute).Format(time.RFC3339), + "duration_ms": 5570, + "status": "completed", + "protection": "auto_continued", + "key_identity": identity, + "latest_round": 2, + "latest_reasoning_tokens": 181, + "first_truncation_round": 1, + "first_truncation_reasoning_tokens": 516, + "continuation_count": 1, + "rounds": []map[string]any{{"round": 1, "reasoning_tokens": 516, "decision": "continue", "truncation_match": true}, {"round": 2, "reasoning_tokens": 181, "decision": "clean", "truncation_match": false}}, + }, + { + "request_id": "req-preview-stale", + "model": "gpt-5.5", + "path": "/v1/responses", + "started_at": now.Add(-45 * time.Minute).Format(time.RFC3339), + "updated_at": now.Add(-44 * time.Minute).Format(time.RFC3339), + "status": "processing", + "protection": "processing", + "key_identity": identity, + "latest_round": 1, + "latest_reasoning_tokens": 140, + "continuation_count": 0, + "rounds": []map[string]any{{"round": 1, "reasoning_tokens": 140, "decision": "clean", "truncation_match": false}}, + }, + { + "request_id": "req-preview-live", + "model": "gpt-5.5", + "path": "/v1/responses", + "started_at": now.Add(-30 * time.Second).Format(time.RFC3339), + "updated_at": now.Add(-5 * time.Second).Format(time.RFC3339), + "status": "processing", + "protection": "processing", + "key_identity": identity, + "latest_round": 1, + "latest_reasoning_tokens": 140, + "continuation_count": 0, + "rounds": []map[string]any{{"round": 1, "reasoning_tokens": 140, "decision": "clean", "truncation_match": false}}, + }, + }}) + } + mux.HandleFunc("/governor/codexcont/admin/status", func(w http.ResponseWriter, r *http.Request) { + _ = r + writePreviewStatus(w) + }) + mux.HandleFunc("/admin/status", func(w http.ResponseWriter, r *http.Request) { + _ = r + writePreviewStatus(w) + }) + mux.HandleFunc("/governor/codexcont/admin/requests", func(w http.ResponseWriter, r *http.Request) { + _ = r + writePreviewRequests(w) + }) + mux.HandleFunc("/admin/requests", func(w http.ResponseWriter, r *http.Request) { + _ = r + writePreviewRequests(w) + }) + mux.HandleFunc("/governor/codexcont/admin/logs/stream", func(w http.ResponseWriter, r *http.Request) { + _ = r + w.Header().Set("content-type", "text/event-stream") + w.Header().Set("cache-control", "no-cache") + _, _ = w.Write([]byte("event: ready\ndata: {\"ok\":true}\n\n")) + _, _ = w.Write([]byte("event: log\ndata: {\"ts\":\"2026-07-02T02:09:59Z\",\"level\":\"info\",\"event\":\"round_decision\",\"message\":\"preview round decision\",\"fields\":{\"request_id\":\"req-preview-a\"}}\n\n")) + }) + mux.HandleFunc("/engine/healthz", func(w http.ResponseWriter, r *http.Request) { + _ = r + w.Header().Set("content-type", "application/json; charset=utf-8") + _, _ = w.Write([]byte(`{"ok":true,"mode":"preview"}`)) + }) + mux.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { + _ = r + w.WriteHeader(http.StatusNoContent) + }) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + rawReq, _ := json.Marshal(managementRequest{ + Method: r.Method, + Path: r.URL.Path, + Headers: r.Header, + Query: r.URL.Query(), + }) + rawResp, _ := managementHandle(rawReq) + var env envelope + _ = json.Unmarshal(rawResp, &env) + var resp managementResponse + _ = json.Unmarshal(env.Result, &resp) + for key, values := range resp.Headers { + for _, value := range values { + w.Header().Add(key, value) + } + } + if resp.StatusCode != 0 { + w.WriteHeader(resp.StatusCode) + } + _, _ = w.Write(resp.Body) + }) + if err := http.ListenAndServe(addr, mux); err != nil { + panic(err) + } +} + +func floatPtr(value float64) *float64 { return &value } + +func shutdownPlugin() { + state.mu.Lock() + defer state.mu.Unlock() + if state.store != nil { + _ = state.store.Close() + state.store = nil + } +} + +func handleMethod(method string, request []byte) ([]byte, error) { + switch method { + case methodPluginRegister, methodPluginReconfigure: + if err := configure(request); err != nil { + return nil, err + } + return okEnvelope(pluginRegistration()) + case methodFrontendAuthIdentifier: + return okEnvelope(map[string]string{"identifier": pluginID}) + case methodFrontendAuthAuthenticate: + return frontendAuth(request) + case methodModelRoute: + return routeModel(request) + case methodExecutorIdentifier: + return okEnvelope(map[string]string{"identifier": pluginID}) + case methodExecutorExecute: + return executorExecute(request) + case methodExecutorExecuteStream: + return executorExecuteStream(request) + case methodExecutorCountTokens: + return okEnvelope(executorResponse{Payload: []byte(`{"input_tokens":0}`)}) + case methodUsageHandle: + return usageHandle(request) + case methodManagementRegister: + return managementRegister() + case methodManagementHandle: + return managementHandle(request) + default: + return errorEnvelope("unknown_method", "unknown method: "+method), nil + } +} + +func configure(raw []byte) error { + cfg := policyplus.DefaultConfig() + if len(raw) > 0 { + var req lifecycleRequest + if err := json.Unmarshal(raw, &req); err != nil { + return err + } + if len(req.ConfigYAML) > 0 { + if err := yaml.Unmarshal(req.ConfigYAML, &cfg); err != nil { + return err + } + } + } + cfg = cfg.Normalize() + store, err := policyplus.OpenStore(cfg.StateDBPath) + if err != nil { + return err + } + cfg = applyStoredSettings(cfg, store) + var keyState policyplus.KeyPolicyState + if strings.TrimSpace(cfg.KeyPolicyStatePath) != "" { + loaded, err := policyplus.LoadKeyPolicyState(cfg.KeyPolicyStatePath) + if err == nil { + keyState = loaded + _ = store.ImportKeys(context.Background(), loaded) + } + } + importLegacySQLite(store, cfg.LegacyQuotaDBPath, "usage-admin") + importLegacySQLite(store, cfg.GovernorStateDBPath, "governor") + state.mu.Lock() + old := state.store + state.cfg = cfg + state.store = store + state.keyState = keyState + state.keyStatePath = strings.TrimSpace(cfg.KeyPolicyStatePath) + state.keyStateModTime = time.Time{} + state.keyStateLastCheck = time.Time{} + state.mu.Unlock() + if old != nil { + _ = old.Close() + } + _ = refreshKeyPolicyState(true) + return nil +} + +func applyStoredSettings(cfg policyplus.Config, store *policyplus.Store) policyplus.Config { + if store == nil { + return cfg + } + settings, err := store.LoadSettings(context.Background()) + if err != nil { + return cfg + } + if value, ok := settings["codexcont_enabled"]; ok { + if parsed, err := strconv.ParseBool(value); err == nil { + cfg.CodexContEnabled = parsed + } + } + if value := strings.TrimSpace(settings["codexcont_url"]); value != "" { + cfg.CodexContURL = strings.TrimRight(value, "/") + } + if value := strings.TrimSpace(settings["fail_mode"]); value != "" { + cfg.FailMode = strings.ToLower(value) + } + return cfg.Normalize() +} + +func importLegacySQLite(store *policyplus.Store, path, source string) { + path = strings.TrimSpace(path) + if store == nil || path == "" { + return + } + if _, err := os.Stat(path); err != nil { + _ = store.Audit(context.Background(), "system", "legacy_import_skipped", source, map[string]any{ + "path": path, + "error": policyplus.Brief(err.Error(), 240), + }) + return + } + result, err := store.ImportLegacyQuotaSQLite(context.Background(), path, source) + if err != nil { + _ = store.Audit(context.Background(), "system", "legacy_import_failed", source, map[string]any{ + "path": path, + "error": policyplus.Brief(err.Error(), 240), + }) + return + } + if result.Limits > 0 || result.Resets > 0 { + _ = store.Audit(context.Background(), "system", "legacy_import_completed", source, map[string]any{ + "path": path, + "limits": result.Limits, + "resets": result.Resets, + }) + } +} + +func pluginRegistration() registration { + cfg := loadedConfig() + return registration{ + SchemaVersion: schemaVersion, + Metadata: metadata{ + Name: "cpa-key-policy-plus", + Version: "0.1.0", + Author: "konbakuyomu/CodexCont", + GitHubRepository: "https://local/CodexCont", + ConfigFields: []configField{ + {Name: "enabled", Type: configBoolean, Description: "Enable Key Policy Plus user-key authentication and management surfaces."}, + {Name: "exclusive_auth", Type: configBoolean, Description: "When true, Key Policy Plus is the exclusive frontend auth provider for user keys."}, + {Name: "state_db_path", Type: configString, Description: "SQLite path for Key Policy Plus state."}, + {Name: "key_policy_state_path", Type: configString, Description: "Optional old CPA Key Policy state JSON path to import once or mirror during cutover."}, + {Name: "legacy_quota_db_path", Type: configString, Description: "Optional old usage-admin SQLite path for one-way 5H/month limit and reset import."}, + {Name: "governor_state_db_path", Type: configString, Description: "Optional old Governor SQLite path for one-way limit and reset import."}, + {Name: "session_secret", Type: configString, Description: "Secret used to sign user portal sessions."}, + {Name: "codexcont_enabled", Type: configBoolean, Description: "Enable CodexCont status lookup for user summaries."}, + {Name: "codexcont_route", Type: configBoolean, Description: "Deprecated in Key Policy Plus; keep false and let Governor own CodexCont routing."}, + {Name: "codexcont_url", Type: configString, Description: "Internal CodexCont engine base URL."}, + {Name: "fail_mode", Type: configEnum, EnumValues: []string{"fallback", "fail_closed"}, Description: "Behavior when engine is unavailable."}, + }, + }, + Capabilities: capabilities{ + FrontendAuthProvider: true, + FrontendAuthProviderExclusive: cfg.ExclusiveAuth, + ModelRouter: false, + Executor: false, + UsagePlugin: true, + ManagementAPI: true, + }, + } +} + +func loadedConfig() policyplus.Config { + state.mu.RLock() + defer state.mu.RUnlock() + if state.cfg.StateDBPath == "" { + return policyplus.DefaultConfig() + } + return state.cfg +} + +func loadedStore() *policyplus.Store { + state.mu.RLock() + defer state.mu.RUnlock() + return state.store +} + +func frontendAuth(raw []byte) ([]byte, error) { + var req frontendAuthRequest + if err := json.Unmarshal(raw, &req); err != nil { + return okEnvelope(frontendAuthResponse{Authenticated: false}) + } + cfg := loadedConfig() + if !cfg.Enabled { + return okEnvelope(frontendAuthResponse{Authenticated: false}) + } + key := bearer(req.Headers.Get("Authorization")) + if key == "" { + return okEnvelope(frontendAuthResponse{Authenticated: false}) + } + record, ok := findKeyByRaw(key) + if !ok || !record.Enabled { + return okEnvelope(frontendAuthResponse{Authenticated: false}) + } + model := requestedModelFromBody(req.Body) + if model != "" && !policyplus.ModelAllowed(record.Models, model) { + return okEnvelope(frontendAuthResponse{Authenticated: false}) + } + if !allowRPM(record) { + return okEnvelope(frontendAuthResponse{Authenticated: false}) + } + if !allowQuota(record) { + return okEnvelope(frontendAuthResponse{Authenticated: false}) + } + if !allowActiveSession(record, req) { + return okEnvelope(frontendAuthResponse{Authenticated: false}) + } + if !acquireConcurrency(record) { + return okEnvelope(frontendAuthResponse{Authenticated: false}) + } + return okEnvelope(frontendAuthResponse{ + Authenticated: true, + Principal: record.ID, + Metadata: map[string]string{ + "provider": "cpa-key-policy-plus", + "key_id": record.ID, + "key_name": record.Name, + "preview": record.Preview, + }, + }) +} + +func findKeyByRaw(rawKey string) (policyplus.KeyRecord, bool) { + _ = refreshKeyPolicyState(false) + if key, ok := lookupKeyByRaw(rawKey); ok { + return key, true + } + if strings.HasPrefix(strings.ToLower(policyplus.NormalizeSubmittedKey(rawKey)), "cpa_") { + _ = refreshKeyPolicyState(true) + return lookupKeyByRaw(rawKey) + } + return policyplus.KeyRecord{}, false +} + +func lookupKeyByRaw(rawKey string) (policyplus.KeyRecord, bool) { + state.mu.RLock() + keyState := state.keyState + store := state.store + state.mu.RUnlock() + if key, ok := keyState.FindByRawKey(rawKey); ok { + return key, true + } + if store != nil { + key, ok, err := store.FindKeyByHash(context.Background(), policyplus.SHA256Hex(rawKey)) + if err == nil && ok { + return key, true + } + } + return policyplus.KeyRecord{}, false +} + +func refreshKeyPolicyState(force bool) error { + state.mu.RLock() + path := strings.TrimSpace(state.keyStatePath) + lastMod := state.keyStateModTime + store := state.store + state.mu.RUnlock() + if path == "" || store == nil { + return nil + } + now := time.Now() + info, err := os.Stat(path) + if err != nil { + state.mu.Lock() + state.keyStateLastCheck = now + state.mu.Unlock() + return err + } + if !force && !info.ModTime().After(lastMod) { + state.mu.Lock() + state.keyStateLastCheck = now + state.mu.Unlock() + return nil + } + loaded, err := policyplus.LoadKeyPolicyState(path) + if err != nil { + state.mu.Lock() + state.keyStateLastCheck = now + state.mu.Unlock() + return err + } + if err := store.ImportKeys(context.Background(), loaded); err != nil { + return err + } + state.mu.Lock() + state.keyState = loaded + state.keyStateModTime = info.ModTime() + state.keyStateLastCheck = now + state.mu.Unlock() + return nil +} + +func routeModel(raw []byte) ([]byte, error) { + var req modelRouteRequest + if err := json.Unmarshal(raw, &req); err != nil { + return nil, err + } + cfg := loadedConfig() + if !cfg.Enabled || !cfg.CodexContRoute { + return okEnvelope(modelRouteResponse{Handled: false}) + } + if !isResponsesRequest(req.SourceFormat, req.Body) { + return okEnvelope(modelRouteResponse{Handled: false}) + } + return okEnvelope(modelRouteResponse{ + Handled: true, + TargetKind: routeTargetSelf, + Reason: "cpa_key_policy_plus_codexcont_route_deprecated", + }) +} + +func isResponsesRequest(source string, body []byte) bool { + source = strings.ToLower(strings.TrimSpace(source)) + if strings.Contains(source, "response") || source == "openai" { + return true + } + return strings.Contains(string(body), `"stream"`) && strings.Contains(string(body), `"model"`) +} + +func requestedModelFromBody(body []byte) string { + if len(body) == 0 { + return "" + } + var raw map[string]any + if err := json.Unmarshal(body, &raw); err != nil { + return "" + } + if text, ok := raw["model"].(string); ok { + return strings.TrimSpace(text) + } + return "" +} + +func allowRPM(key policyplus.KeyRecord) bool { + if key.RPM <= 0 { + return true + } + state.mu.Lock() + defer state.mu.Unlock() + now := time.Now() + cutoff := now.Add(-time.Minute) + bucket := state.rpmBuckets[key.ID] + kept := bucket[:0] + for _, ts := range bucket { + if ts.After(cutoff) { + kept = append(kept, ts) + } + } + if len(kept) >= key.RPM { + state.rpmBuckets[key.ID] = kept + return false + } + state.rpmBuckets[key.ID] = append(kept, now) + return true +} + +func allowQuota(key policyplus.KeyRecord) bool { + store := loadedStore() + if store == nil { + return true + } + ctx := context.Background() + now := time.Now() + limits := []struct { + name string + limit *float64 + }{ + {policyplus.Range5H, key.FiveHourUSD}, + {policyplus.Range24H, key.DailyLimitUSD}, + {policyplus.Range7D, key.WeeklyLimitUSD}, + {policyplus.RangeMonth, key.MonthlyLimitUSD}, + } + for _, item := range limits { + used, err := store.UsageSum(ctx, key.ID, policyplus.WindowFor(item.name, now)) + if err != nil { + continue + } + if !policyplus.CheckLimit(used, item.limit).Allowed { + return false + } + } + return true +} + +func allowActiveSession(key policyplus.KeyRecord, req frontendAuthRequest) bool { + if key.MaxActiveSessions <= 0 { + return true + } + store := loadedStore() + if store == nil { + return true + } + identity := policyplus.ExtractSessionIdentity(req.Headers, req.Body) + decision, err := store.RegisterActiveSession(context.Background(), key.ID, identity, key.MaxActiveSessions, policyplus.DefaultSessionIdle, time.Now()) + if err != nil { + return false + } + return decision.Allowed +} + +func acquireConcurrency(key policyplus.KeyRecord) bool { + if key.Concurrency <= 0 { + return true + } + state.mu.Lock() + if state.concurrency[key.ID] >= key.Concurrency { + state.mu.Unlock() + return false + } + state.concurrency[key.ID]++ + state.mu.Unlock() + go func() { + time.Sleep(10 * time.Minute) + releaseConcurrency(key.ID) + }() + return true +} + +func releaseConcurrency(keyID string) { + if strings.TrimSpace(keyID) == "" { + return + } + state.mu.Lock() + defer state.mu.Unlock() + if state.concurrency[keyID] > 0 { + state.concurrency[keyID]-- + } +} + +func executorUnavailable() ([]byte, error) { + cfg := loadedConfig() + if cfg.CodexContRoute && cfg.FailMode == "fail_closed" { + return errorEnvelope("codexcont_executor_pending", "CPA Key Policy+ protected executor is not enabled in this build"), nil + } + return errorEnvelope("codexcont_executor_pending", "CPA Key Policy+ protected executor is in passive mode; disable codexcont_enabled to route through CPA provider path"), nil +} + +func executorExecute(raw []byte) ([]byte, error) { + var req executorCallRequest + if err := json.Unmarshal(raw, &req); err != nil { + return nil, err + } + cfg := loadedConfig() + if !cfg.CodexContRoute { + return executorUnavailable() + } + payload := req.ExecutorRequest.Payload + if len(payload) == 0 { + payload = req.ExecutorRequest.OriginalRequest + } + result, err := callHost(methodHostModelExecute, hostModelExecutionRequest{ + EntryProtocol: firstNonEmpty(req.ExecutorRequest.SourceFormat, "openai"), + ExitProtocol: firstNonEmpty(req.ExecutorRequest.Format, "openai"), + Model: req.ExecutorRequest.Model, + Stream: false, + Body: payload, + Headers: cloneHeader(req.ExecutorRequest.Headers), + Query: cloneValues(req.ExecutorRequest.Query), + Alt: req.ExecutorRequest.Alt, + HostCallbackID: req.HostCallbackID, + }) + if err != nil { + return errorEnvelope("host_model_execute_error", err.Error()), nil + } + var resp hostModelExecutionResponse + if err := json.Unmarshal(result, &resp); err != nil { + return errorEnvelope("host_model_execute_decode_error", err.Error()), nil + } + if resp.StatusCode >= 400 { + return errorEnvelope("host_model_execute_http_error", fmt.Sprintf("upstream returned %d", resp.StatusCode)), nil + } + return okEnvelope(executorResponse{Payload: resp.Body, Headers: resp.Headers}) +} + +func executorExecuteStream(raw []byte) ([]byte, error) { + var req executorCallRequest + if err := json.Unmarshal(raw, &req); err != nil { + return nil, err + } + cfg := loadedConfig() + if !cfg.CodexContRoute { + return executorUnavailable() + } + if strings.TrimSpace(req.StreamID) == "" { + return errorEnvelope("stream_id_required", "stream_id is required for executor.execute_stream"), nil + } + payload := req.ExecutorRequest.Payload + if len(payload) == 0 { + payload = req.ExecutorRequest.OriginalRequest + } + result, err := callHost(methodHostModelExecuteStream, hostModelExecutionRequest{ + EntryProtocol: firstNonEmpty(req.ExecutorRequest.SourceFormat, "openai"), + ExitProtocol: firstNonEmpty(req.ExecutorRequest.Format, "openai"), + Model: req.ExecutorRequest.Model, + Stream: true, + Body: payload, + Headers: cloneHeader(req.ExecutorRequest.Headers), + Query: cloneValues(req.ExecutorRequest.Query), + Alt: req.ExecutorRequest.Alt, + HostCallbackID: req.HostCallbackID, + }) + if err != nil { + return errorEnvelope("host_model_stream_error", err.Error()), nil + } + var resp hostModelStreamResponse + if err := json.Unmarshal(result, &resp); err != nil { + return errorEnvelope("host_model_stream_decode_error", err.Error()), nil + } + if resp.StatusCode >= 400 { + return errorEnvelope("host_model_stream_http_error", fmt.Sprintf("upstream returned %d", resp.StatusCode)), nil + } + if resp.StreamID == "" { + return errorEnvelope("host_model_stream_empty", "host returned empty stream id"), nil + } + go forwardHostStream(req.StreamID, resp.StreamID) + return okEnvelope(executorStreamResponse{Headers: resp.Headers}) +} + +func usageHandle(raw []byte) ([]byte, error) { + var rec usageRecord + if err := json.Unmarshal(raw, &rec); err != nil { + return nil, err + } + _ = refreshKeyPolicyState(false) + store := loadedStore() + if store == nil { + return okEnvelope(map[string]any{}) + } + keys, _ := store.ListKeys(context.Background()) + keyByID := map[string]policyplus.KeyRecord{} + for _, key := range keys { + keyByID[key.ID] = key + } + key := keyByID[rec.APIKey] + if key.ID == "" { + key = keyByID[rec.Source] + } + if key.ID != "" { + releaseConcurrency(key.ID) + } + usage := policyplus.TokenUsage{ + InputTokens: rec.Detail.InputTokens, + OutputTokens: rec.Detail.OutputTokens, + CachedTokens: rec.Detail.CachedTokens, + CacheReadTokens: rec.Detail.CacheReadTokens, + CacheCreationTokens: rec.Detail.CacheCreationTokens, + ReasoningTokens: rec.Detail.ReasoningTokens, + TotalTokens: rec.Detail.TotalTokens, + } + var cost float64 + var breakdown policyplus.CostBreakdown + if key.ID != "" { + if price, ok := policyplus.PriceForModel(key.Prices, firstNonEmpty(rec.Alias, rec.Model)); ok { + breakdown = policyplus.CostForUsage(price, usage, firstNonEmpty(rec.Alias, rec.Model)) + cost = breakdown.Costs["total"] + } + } + event := policyplus.UsageEvent{ + RequestID: firstNonEmpty(rec.ResponseHeaders.Get("x-request-id"), rec.ResponseHeaders.Get("x-openai-request-id")), + KeyID: key.ID, + KeyPreview: key.Preview, + Model: firstNonEmpty(rec.Alias, rec.Model), + RequestedModel: firstNonEmpty(rec.Alias, rec.Model), + ActualModel: rec.Model, + Provider: rec.Provider, + ExecutorType: rec.ExecutorType, + Endpoint: rec.Source, + RequestedAt: rec.RequestedAt, + LatencyMS: rec.Latency.Milliseconds(), + TTFTMS: rec.TTFT.Milliseconds(), + ReasoningEffort: rec.ReasoningEffort, + ServiceTier: rec.ServiceTier, + StatusCode: rec.Failure.StatusCode, + Failed: rec.Failed, + Failure: policyplus.Brief(rec.Failure.Body, 600), + Usage: usage, + Cost: cost, + CostBreakdown: breakdown, + } + _ = store.InsertUsage(context.Background(), event) + return okEnvelope(map[string]any{}) +} + +func managementRegister() ([]byte, error) { + resp := managementRegistrationResponse{ + Routes: []managementRoute{ + {Method: http.MethodGet, Path: "/plugins/cpa-key-policy-plus/keys"}, + {Method: http.MethodPost, Path: "/plugins/cpa-key-policy-plus/keys/create"}, + {Method: http.MethodPut, Path: "/plugins/cpa-key-policy-plus/keys/save"}, + {Method: http.MethodPut, Path: "/plugins/cpa-key-policy-plus/keys/limits"}, + {Method: http.MethodPost, Path: "/plugins/cpa-key-policy-plus/keys/reset"}, + {Method: http.MethodGet, Path: "/plugins/cpa-key-policy-plus/events"}, + {Method: http.MethodGet, Path: "/plugins/cpa-key-policy-plus/codexcont"}, + {Method: http.MethodPut, Path: "/plugins/cpa-key-policy-plus/codexcont"}, + }, + Resources: []resourceRoute{ + {Path: "/admin", Menu: "CPA Key Policy+", Description: "Unified user key policy dashboard"}, + {Path: "/admin/api/keys"}, + {Path: "/admin/api/keys/create"}, + {Path: "/admin/api/keys/save"}, + {Path: "/admin/api/keys/limits"}, + {Path: "/admin/api/keys/reset"}, + {Path: "/admin/api/events"}, + {Path: "/admin/api/codexcont"}, + {Path: "/user", Description: "Self-service usage dashboard"}, + {Path: "/user/api/session"}, + {Path: "/user/api/me"}, + {Path: "/user/api/usage"}, + {Path: "/user/api/events"}, + {Path: "/user/api/codexcont"}, + }, + } + return okEnvelope(resp) +} + +func managementHandle(raw []byte) ([]byte, error) { + var req managementRequest + if err := json.Unmarshal(raw, &req); err != nil { + return nil, err + } + path := strings.TrimSpace(req.Path) + switch { + case path == "/v0/resource/plugins/cpa-key-policy-plus/admin" || path == "/admin": + return managementHTML(adminHTML()) + case path == "/v0/resource/plugins/cpa-key-policy-plus/user" || path == "/user": + return managementHTML(userHTML()) + case strings.HasSuffix(path, "/admin/api/keys"): + return adminKeys(req) + case strings.HasSuffix(path, "/admin/api/keys/create"): + return adminCreateKey(req) + case strings.HasSuffix(path, "/admin/api/keys/save"): + return adminSaveKeys(req) + case strings.HasSuffix(path, "/admin/api/keys/limits"): + return adminSetLimits(req) + case strings.HasSuffix(path, "/admin/api/keys/reset"): + return adminReset(req) + case strings.HasSuffix(path, "/admin/api/events"): + return adminEvents(req) + case strings.HasSuffix(path, "/admin/api/codexcont"): + return adminCodexCont(req) + case strings.Contains(path, "/user/api/session"): + return userSession(req) + case strings.Contains(path, "/user/api/me"): + return userMe(req) + case strings.Contains(path, "/user/api/usage"): + return userUsage(req) + case strings.Contains(path, "/user/api/events"): + return userEvents(req) + case strings.Contains(path, "/user/api/codexcont"): + return userCodexCont(req) + case strings.HasSuffix(path, "/plugins/cpa-key-policy-plus/keys"): + return adminKeys(req) + case strings.HasSuffix(path, "/plugins/cpa-key-policy-plus/keys/create"): + return adminCreateKey(req) + case strings.HasSuffix(path, "/plugins/cpa-key-policy-plus/keys/save"): + return adminSaveKeys(req) + case strings.HasSuffix(path, "/plugins/cpa-key-policy-plus/keys/limits"): + return adminSetLimits(req) + case strings.HasSuffix(path, "/plugins/cpa-key-policy-plus/keys/reset"): + return adminReset(req) + case strings.HasSuffix(path, "/plugins/cpa-key-policy-plus/events"): + return adminEvents(req) + case strings.HasSuffix(path, "/plugins/cpa-key-policy-plus/codexcont"): + return adminCodexCont(req) + default: + return jsonResponse(http.StatusNotFound, map[string]any{"ok": false, "error": "not_found"}) + } +} + +func adminKeys(_ managementRequest) ([]byte, error) { + _ = refreshKeyPolicyState(false) + store := loadedStore() + if store == nil { + return jsonResponse(http.StatusServiceUnavailable, map[string]any{"ok": false, "error": "store_unavailable"}) + } + keys, err := store.ListKeys(context.Background()) + if err != nil { + return jsonResponse(http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()}) + } + safe := make([]map[string]any, 0, len(keys)) + now := time.Now() + for _, key := range keys { + row := key.Safe() + row["usage"] = usageWindows(context.Background(), store, key.ID, now) + if active, err := store.ActiveSessionCount(context.Background(), key.ID, policyplus.DefaultSessionIdle, now); err == nil { + row["active_sessions"] = active + } + safe = append(safe, row) + } + return jsonResponse(http.StatusOK, map[string]any{"ok": true, "keys": safe, "codexcont": codexcontStatus()}) +} + +func adminCreateKey(req managementRequest) ([]byte, error) { + var body struct { + Name string `json:"name"` + } + _ = json.Unmarshal(req.Body, &body) + rawKey, err := generateCPAKey() + if err != nil { + return jsonResponse(http.StatusInternalServerError, map[string]any{"ok": false, "error": "key_generation_failed"}) + } + hash := policyplus.SHA256Hex(rawKey) + name := strings.TrimSpace(body.Name) + if name == "" { + name = "new key" + } + store := loadedStore() + if store == nil { + return jsonResponse(http.StatusServiceUnavailable, map[string]any{"ok": false, "error": "store_unavailable"}) + } + key := policyplus.KeyRecord{ + ID: "key_" + policyplus.HashPreview(hash), + Name: name, + KeyHash: "sha256:" + hash, + Enabled: true, + Preview: policyplus.HashPreview(hash), + RPM: 60, + Concurrency: 2, + MaxActiveSessions: 2, + Models: []string{}, + FiveHourUSD: nil, + DailyLimitUSD: nil, + WeeklyLimitUSD: nil, + MonthlyLimitUSD: nil, + } + if err := store.UpsertKey(context.Background(), key); err != nil { + return jsonResponse(http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()}) + } + return jsonResponse(http.StatusOK, map[string]any{"ok": true, "key": key.Safe(), "raw_key": rawKey}) +} + +func generateCPAKey() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return "cpa_" + base64.RawURLEncoding.EncodeToString(buf), nil +} + +func adminSaveKeys(req managementRequest) ([]byte, error) { + var body struct { + Keys []struct { + ID string `json:"id"` + Name string `json:"name"` + Enabled *bool `json:"enabled"` + RPM int `json:"rpm"` + Concurrency int `json:"concurrency"` + MaxActiveSessions int `json:"max_active_sessions"` + Models []string `json:"models"` + Prices map[string]policyplus.ModelPrice `json:"prices"` + FiveHourUSD *float64 `json:"five_hour_usd"` + DailyUSD *float64 `json:"daily_usd"` + WeeklyUSD *float64 `json:"weekly_usd"` + MonthlyUSD *float64 `json:"monthly_usd"` + } `json:"keys"` + } + if err := json.Unmarshal(req.Body, &body); err != nil { + return jsonResponse(http.StatusBadRequest, map[string]any{"ok": false, "error": "invalid_json"}) + } + store := loadedStore() + if store == nil { + return jsonResponse(http.StatusServiceUnavailable, map[string]any{"ok": false, "error": "store_unavailable"}) + } + existing, err := store.ListKeys(context.Background()) + if err != nil { + return jsonResponse(http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()}) + } + byID := map[string]policyplus.KeyRecord{} + for _, key := range existing { + byID[key.ID] = key + } + for _, item := range body.Keys { + key, ok := byID[strings.TrimSpace(item.ID)] + if !ok { + return jsonResponse(http.StatusBadRequest, map[string]any{"ok": false, "error": "unknown_key"}) + } + if item.Enabled != nil { + key.Enabled = *item.Enabled + } + if strings.TrimSpace(item.Name) != "" { + key.Name = strings.TrimSpace(item.Name) + } + key.RPM = item.RPM + key.Concurrency = item.Concurrency + key.MaxActiveSessions = item.MaxActiveSessions + key.Models = cleanStrings(item.Models) + if item.Prices != nil { + key.Prices = item.Prices + } + key.FiveHourUSD = item.FiveHourUSD + key.DailyLimitUSD = item.DailyUSD + key.WeeklyLimitUSD = item.WeeklyUSD + key.MonthlyLimitUSD = item.MonthlyUSD + if err := store.SaveKeySettings(context.Background(), key); err != nil { + return jsonResponse(http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()}) + } + } + return adminKeys(req) +} + +func cleanStrings(items []string) []string { + seen := map[string]bool{} + out := make([]string, 0, len(items)) + for _, item := range items { + item = strings.TrimSpace(item) + if item == "" || seen[item] { + continue + } + seen[item] = true + out = append(out, item) + } + return out +} + +func adminSetLimits(req managementRequest) ([]byte, error) { + var body struct { + Limits []struct { + ID string `json:"id"` + FiveHourUSD *float64 `json:"five_hour_usd"` + MonthlyUSD *float64 `json:"monthly_usd"` + } `json:"limits"` + } + if err := json.Unmarshal(req.Body, &body); err != nil { + return jsonResponse(http.StatusBadRequest, map[string]any{"ok": false, "error": "invalid_json"}) + } + store := loadedStore() + if store == nil { + return jsonResponse(http.StatusServiceUnavailable, map[string]any{"ok": false, "error": "store_unavailable"}) + } + for _, item := range body.Limits { + if strings.TrimSpace(item.ID) == "" { + return jsonResponse(http.StatusBadRequest, map[string]any{"ok": false, "error": "missing_key_id"}) + } + if err := store.SetLimits(context.Background(), item.ID, item.FiveHourUSD, item.MonthlyUSD); err != nil { + return jsonResponse(http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()}) + } + } + return adminKeys(req) +} + +func adminReset(req managementRequest) ([]byte, error) { + var body struct { + ID string `json:"id"` + Window string `json:"window"` + } + if err := json.Unmarshal(req.Body, &body); err != nil { + return jsonResponse(http.StatusBadRequest, map[string]any{"ok": false, "error": "invalid_json"}) + } + if body.Window == "" { + body.Window = "all" + } + windows := []string{body.Window} + if body.Window == "all" { + windows = []string{policyplus.Range5H, policyplus.Range24H, policyplus.Range7D, policyplus.RangeMonth} + } + store := loadedStore() + if store == nil { + return jsonResponse(http.StatusServiceUnavailable, map[string]any{"ok": false, "error": "store_unavailable"}) + } + for _, window := range windows { + if err := store.Reset(context.Background(), body.ID, window, time.Now()); err != nil { + return jsonResponse(http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()}) + } + } + return jsonResponse(http.StatusOK, map[string]any{"ok": true}) +} + +func adminEvents(req managementRequest) ([]byte, error) { + keyID := req.Query.Get("key_id") + if keyID == "" { + keyID = "all" + } + return eventsResponseFromRequest(req, keyID) +} + +func adminCodexCont(req managementRequest) ([]byte, error) { + if req.Method == http.MethodPut || (req.Method == http.MethodGet && req.Query.Get("action") == "save") { + var body struct { + Enabled *bool `json:"enabled"` + URL string `json:"url"` + FailMode string `json:"fail_mode"` + } + if req.Method == http.MethodGet { + if rawEnabled := strings.TrimSpace(req.Query.Get("enabled")); rawEnabled != "" { + if parsed, err := strconv.ParseBool(rawEnabled); err == nil { + body.Enabled = &parsed + } + } + body.URL = req.Query.Get("url") + body.FailMode = req.Query.Get("fail_mode") + } else { + _ = json.Unmarshal(req.Body, &body) + } + store := loadedStore() + state.mu.Lock() + if body.Enabled != nil { + state.cfg.CodexContEnabled = *body.Enabled + } + if strings.TrimSpace(body.URL) != "" { + state.cfg.CodexContURL = strings.TrimRight(strings.TrimSpace(body.URL), "/") + } + if strings.TrimSpace(body.FailMode) != "" { + state.cfg.FailMode = strings.ToLower(strings.TrimSpace(body.FailMode)) + } + state.cfg = state.cfg.Normalize() + cfg := state.cfg + state.mu.Unlock() + if store != nil { + settings := map[string]string{ + "codexcont_enabled": strconv.FormatBool(cfg.CodexContEnabled), + "codexcont_url": cfg.CodexContURL, + "fail_mode": cfg.FailMode, + } + if err := store.SaveSettings(context.Background(), settings); err != nil { + return jsonResponse(http.StatusInternalServerError, map[string]any{"ok": false, "error": "save_settings_failed"}) + } + } + return jsonResponse(http.StatusOK, map[string]any{"ok": true, "codexcont": codexcontStatus()}) + } + return jsonResponse(http.StatusOK, map[string]any{"ok": true, "codexcont": codexcontStatus()}) +} + +func userSession(req managementRequest) ([]byte, error) { + key := userSubmittedKey(req) + if key == "" { + hint := policyplus.ExplainUnmatchedSubmittedKey(key) + return jsonResponse(http.StatusUnauthorized, map[string]any{"ok": false, "error": hint.Error, "message": hint.Message}) + } + record, ok := findKeyByRaw(key) + if !ok { + hint := policyplus.ExplainUnmatchedSubmittedKey(key) + return jsonResponse(http.StatusUnauthorized, map[string]any{"ok": false, "error": hint.Error, "message": hint.Message}) + } + if !record.Enabled { + return jsonResponse(http.StatusForbidden, map[string]any{"ok": false, "error": "api_key_disabled", "message": "这个 Key 当前已禁用,请联系管理员。"}) + } + cfg := loadedConfig() + token, err := policyplus.SignSession(policyplus.SessionPayload{ + KeyID: record.ID, + KeyHash: record.KeyHash, + ExpiresAt: time.Now().Add(policyplus.SessionTTL()).Unix(), + }, cfg.SessionSecret) + if err != nil { + return jsonResponse(http.StatusInternalServerError, map[string]any{"ok": false, "error": "session_error"}) + } + body, err := json.Marshal(map[string]any{"ok": true, "me": record.Safe()}) + if err != nil { + return nil, err + } + resp := managementResponse{ + StatusCode: http.StatusOK, + Headers: http.Header{ + "Content-Type": []string{"application/json; charset=utf-8"}, + "Cache-Control": []string{"no-store"}, + "Set-Cookie": []string{fmt.Sprintf("cpa_key_policy_plus_session=%s; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400", token)}, + }, + Body: body, + } + return okEnvelope(resp) +} + +func userSubmittedKey(req managementRequest) string { + raw := firstNonEmpty( + headerFirst(req.Headers, "X-CPA-Key-Policy-Plus-Key"), + headerFirst(req.Headers, "X-CPA-Governor-Key"), + headerFirst(req.Headers, "X-CPA-User-Key"), + ) + if raw == "" { + raw = bearer(headerFirst(req.Headers, "Authorization")) + } + return policyplus.NormalizeSubmittedKey(raw) +} + +func userMe(req managementRequest) ([]byte, error) { + key, ok := keyFromSession(req) + if !ok { + return jsonResponse(http.StatusUnauthorized, map[string]any{"ok": false, "error": "not_authenticated"}) + } + row := key.Safe() + if store := loadedStore(); store != nil { + row["usage"] = usageWindows(context.Background(), store, key.ID, time.Now()) + } + return jsonResponse(http.StatusOK, map[string]any{"ok": true, "me": row}) +} + +func userUsage(req managementRequest) ([]byte, error) { + key, ok := keyFromSession(req) + if !ok { + return jsonResponse(http.StatusUnauthorized, map[string]any{"ok": false, "error": "not_authenticated"}) + } + store := loadedStore() + if store == nil { + return jsonResponse(http.StatusServiceUnavailable, map[string]any{"ok": false, "error": "store_unavailable"}) + } + rangeName := req.Query.Get("range") + if rangeName == "" { + rangeName = policyplus.Range24H + } + summary, err := store.UsageSummary(context.Background(), key.ID, policyplus.WindowFor(rangeName, time.Now())) + if err != nil { + return jsonResponse(http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()}) + } + success := summary.Calls - summary.Failed + successRate := 0.0 + if summary.Calls > 0 { + successRate = float64(success) / float64(summary.Calls) + } + return jsonResponse(http.StatusOK, map[string]any{ + "ok": true, + "range": rangeName, + "limits": key.Safe()["limits"], + "summary": map[string]any{ + "calls": summary.Calls, + "success": success, + "failed": summary.Failed, + "success_rate": successRate, + "total_cost": summary.TotalCost, + "usage": summary.Usage, + }, + }) +} + +func userEvents(req managementRequest) ([]byte, error) { + key, ok := keyFromSession(req) + if !ok { + return jsonResponse(http.StatusUnauthorized, map[string]any{"ok": false, "error": "not_authenticated"}) + } + return eventsResponseFromRequest(req, key.ID) +} + +func userCodexCont(req managementRequest) ([]byte, error) { + key, ok := keyFromSession(req) + if !ok { + return jsonResponse(http.StatusUnauthorized, map[string]any{"ok": false, "error": "not_authenticated"}) + } + limit := 80 + if rawLimit := strings.TrimSpace(req.Query.Get("limit")); rawLimit != "" { + if parsed, err := strconv.Atoi(rawLimit); err == nil { + limit = parsed + } + } + if limit <= 0 || limit > 200 { + limit = 80 + } + requests, source := codexRequestsForKey(key, limit) + sortCodexSummariesNewestFirst(requests) + return jsonResponse(http.StatusOK, map[string]any{ + "ok": true, + "codexcont": codexcontStatus(), + "requests": requests, + "source": source, + }) +} + +func eventsResponse(keyID string, limit int) ([]byte, error) { + return eventsResponseWithRange(keyID, "", limit) +} + +func eventsResponseFromRequest(req managementRequest, keyID string) ([]byte, error) { + limit := 100 + if rawLimit := strings.TrimSpace(req.Query.Get("limit")); rawLimit != "" { + if parsed, err := strconv.Atoi(rawLimit); err == nil { + limit = parsed + } + } + return eventsResponseWithRange(keyID, req.Query.Get("range"), limit) +} + +func eventsResponseWithRange(keyID string, rangeName string, limit int) ([]byte, error) { + store := loadedStore() + if store == nil { + return jsonResponse(http.StatusServiceUnavailable, map[string]any{"ok": false, "error": "store_unavailable"}) + } + var events []policyplus.UsageEvent + var err error + if strings.TrimSpace(rangeName) == "" { + events, err = store.RecentEvents(context.Background(), keyID, limit) + } else { + events, err = store.RecentEventsWindow(context.Background(), keyID, policyplus.WindowFor(rangeName, time.Now()), limit) + } + if err != nil { + return jsonResponse(http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()}) + } + return jsonResponse(http.StatusOK, map[string]any{"ok": true, "events": events}) +} + +func usageWindows(ctx context.Context, store *policyplus.Store, keyID string, now time.Time) map[string]float64 { + out := map[string]float64{} + for _, name := range []string{policyplus.Range5H, policyplus.Range24H, policyplus.Range7D, policyplus.RangeMonth} { + value, _ := store.UsageSum(ctx, keyID, policyplus.WindowFor(name, now)) + out[name] = value + } + return out +} + +func codexRequestsForKey(key policyplus.KeyRecord, limit int) ([]map[string]any, string) { + if requests, ok := fetchCodexContRequests(key, limit); ok { + return requests, "codexcont_admin" + } + store := loadedStore() + if store == nil { + return []map[string]any{}, "unavailable" + } + items, err := store.RecentCodexSummaries(context.Background(), key.ID, limit) + if err != nil { + return []map[string]any{}, "store_error" + } + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + safe := safeCodexSummary(item.Summary, key) + if safe == nil { + continue + } + out = append(out, safe) + } + sortCodexSummariesNewestFirst(out) + return out, "governor_store" +} + +func fetchCodexContRequests(key policyplus.KeyRecord, limit int) ([]map[string]any, bool) { + cfg := loadedConfig() + if !cfg.CodexContEnabled { + return nil, false + } + base := strings.TrimRight(strings.TrimSpace(cfg.CodexContURL), "/") + if base == "" { + return nil, false + } + parsed, err := url.Parse(base + "/admin/requests?limit=" + strconv.Itoa(limit)) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return nil, false + } + client := http.Client{Timeout: 1200 * time.Millisecond} + resp, err := client.Get(parsed.String()) + if err != nil { + return nil, false + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, false + } + var body struct { + Requests []map[string]any `json:"requests"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, false + } + out := make([]map[string]any, 0, len(body.Requests)) + for _, req := range body.Requests { + safe := safeCodexSummary(req, key) + if safe == nil { + continue + } + out = append(out, safe) + } + sortCodexSummariesNewestFirst(out) + return out, true +} + +func sortCodexSummariesNewestFirst(items []map[string]any) { + sort.SliceStable(items, func(i, j int) bool { + left, leftOK := codexSummaryDisplayTime(items[i]) + right, rightOK := codexSummaryDisplayTime(items[j]) + if leftOK != rightOK { + return leftOK + } + if !leftOK { + return false + } + return left.After(right) + }) +} + +func codexSummaryDisplayTime(req map[string]any) (time.Time, bool) { + for _, field := range []string{"started_at", "updated_at", "ended_at"} { + if parsed, ok := parseCodexSummaryTime(req[field]); ok { + return parsed, true + } + } + return time.Time{}, false +} + +func parseCodexSummaryTime(value any) (time.Time, bool) { + switch v := value.(type) { + case time.Time: + if v.IsZero() { + return time.Time{}, false + } + return v, true + case string: + raw := strings.TrimSpace(v) + if raw == "" { + return time.Time{}, false + } + for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05"} { + if parsed, err := time.Parse(layout, raw); err == nil { + return parsed, true + } + } + return time.Time{}, false + case json.Number: + if asInt, err := v.Int64(); err == nil { + return unixLikeTime(asInt) + } + if asFloat, err := v.Float64(); err == nil { + return unixLikeTime(int64(asFloat)) + } + case float64: + return unixLikeTime(int64(v)) + case int64: + return unixLikeTime(v) + case int: + return unixLikeTime(int64(v)) + } + return time.Time{}, false +} + +func unixLikeTime(raw int64) (time.Time, bool) { + if raw <= 0 { + return time.Time{}, false + } + if raw > 1_000_000_000_000 { + return time.UnixMilli(raw), true + } + return time.Unix(raw, 0), true +} + +func safeCodexSummary(req map[string]any, key policyplus.KeyRecord) map[string]any { + if req == nil { + return nil + } + identity, _ := req["key_identity"].(map[string]any) + if !codexIdentityMatches(identity, key) { + return nil + } + fields := []string{ + "request_id", "model", "path", "started_at", "updated_at", "ended_at", + "duration_ms", "status", "protection", "latest_round", + "latest_reasoning_tokens", "first_truncation_round", + "first_truncation_reasoning_tokens", "first_truncation_decision", + "continuation_count", "stopped_reason", "failure_reason", + "passthrough_reason", "rounds", + } + out := map[string]any{} + for _, field := range fields { + if value, ok := req[field]; ok { + out[field] = value + } + } + out["key_identity"] = key.Safe() + return out +} + +func codexIdentityMatches(identity map[string]any, key policyplus.KeyRecord) bool { + if strings.TrimSpace(key.ID) == "" || identity == nil { + return false + } + id := strings.TrimSpace(fmt.Sprint(identity["id"])) + if id != "" && id == key.ID { + return true + } + preview := strings.TrimSpace(fmt.Sprint(identity["preview"])) + return preview != "" && preview == key.Preview +} + +func forwardHostStream(targetStreamID string, sourceStreamID string) { + defer func() { + _, _ = callHost(methodHostModelStreamClose, hostModelStreamCloseRequest{StreamID: sourceStreamID}) + _, _ = callHost(methodHostStreamClose, hostStreamCloseRequest{StreamID: targetStreamID}) + }() + for { + result, err := callHost(methodHostModelStreamRead, hostModelStreamReadRequest{StreamID: sourceStreamID}) + if err != nil { + _, _ = callHost(methodHostStreamEmit, hostStreamEmitRequest{ + StreamID: targetStreamID, + Error: policyplus.Brief(err.Error(), 400), + }) + return + } + var chunk hostModelStreamReadResponse + if err := json.Unmarshal(result, &chunk); err != nil { + _, _ = callHost(methodHostStreamEmit, hostStreamEmitRequest{ + StreamID: targetStreamID, + Error: "decode host stream chunk: " + policyplus.Brief(err.Error(), 300), + }) + return + } + if len(chunk.Payload) > 0 { + _, _ = callHost(methodHostStreamEmit, hostStreamEmitRequest{ + StreamID: targetStreamID, + Payload: chunk.Payload, + }) + } + if chunk.Error != "" { + _, _ = callHost(methodHostStreamEmit, hostStreamEmitRequest{ + StreamID: targetStreamID, + Error: policyplus.Brief(chunk.Error, 400), + }) + return + } + if chunk.Done { + return + } + } +} + +func keyFromSession(req managementRequest) (policyplus.KeyRecord, bool) { + _ = refreshKeyPolicyState(false) + cookie := headerFirst(req.Headers, "Cookie") + token := "" + for _, part := range strings.Split(cookie, ";") { + part = strings.TrimSpace(part) + if strings.HasPrefix(part, "cpa_key_policy_plus_session=") { + token = strings.TrimPrefix(part, "cpa_key_policy_plus_session=") + break + } + if strings.HasPrefix(part, "cpa_governor_session=") { + token = strings.TrimPrefix(part, "cpa_governor_session=") + break + } + } + cfg := loadedConfig() + payload, ok := policyplus.VerifySession(token, cfg.SessionSecret, time.Now()) + if !ok { + return policyplus.KeyRecord{}, false + } + store := loadedStore() + if store == nil { + return policyplus.KeyRecord{}, false + } + keys, err := store.ListKeys(context.Background()) + if err != nil { + return policyplus.KeyRecord{}, false + } + for _, key := range keys { + currentHash, errCurrent := policyplus.NormalizeHash(key.KeyHash) + sessionHash, errSession := policyplus.NormalizeHash(payload.KeyHash) + if key.ID == payload.KeyID && key.Enabled && errCurrent == nil && errSession == nil && currentHash == sessionHash { + return key, true + } + } + return policyplus.KeyRecord{}, false +} + +func codexcontStatus() map[string]any { + cfg := loadedConfig() + status := map[string]any{ + "enabled": cfg.CodexContEnabled, + "route": cfg.CodexContRoute, + "url": cfg.CodexContURL, + "fail_mode": cfg.FailMode, + "mode": "passive_until_executor_cutover", + } + if cfg.CodexContEnabled { + health := probeCodexContHealth(cfg.CodexContURL) + for key, value := range health { + status[key] = value + } + } + return status +} + +func probeCodexContHealth(baseURL string) map[string]any { + out := map[string]any{ + "health_ok": false, + } + parsed, err := url.Parse(strings.TrimRight(baseURL, "/") + "/engine/healthz") + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + out["health_error"] = "invalid_engine_url" + return out + } + client := http.Client{Timeout: 800 * time.Millisecond} + resp, err := client.Get(parsed.String()) + if err != nil { + out["health_error"] = policyplus.Brief(err.Error(), 200) + return out + } + defer resp.Body.Close() + out["health_status"] = resp.StatusCode + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + out["health_ok"] = true + } + return out +} + +func bearer(value string) string { + value = strings.TrimSpace(value) + if strings.HasPrefix(strings.ToLower(value), "bearer ") { + return strings.TrimSpace(value[7:]) + } + return "" +} + +func headerFirst(headers http.Header, name string) string { + if headers == nil { + return "" + } + if value := strings.TrimSpace(headers.Get(name)); value != "" { + return value + } + for key, values := range headers { + if !strings.EqualFold(key, name) { + continue + } + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + } + return "" +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func cloneHeader(headers http.Header) http.Header { + if headers == nil { + return nil + } + cloned := make(http.Header, len(headers)) + for key, values := range headers { + cloned[key] = append([]string(nil), values...) + } + return cloned +} + +func cloneValues(values map[string][]string) map[string][]string { + if values == nil { + return nil + } + cloned := make(map[string][]string, len(values)) + for key, items := range values { + cloned[key] = append([]string(nil), items...) + } + return cloned +} + +func okEnvelope(v any) ([]byte, error) { + raw, err := json.Marshal(v) + if err != nil { + return nil, err + } + return json.Marshal(envelope{OK: true, Result: raw}) +} + +func errorEnvelope(code, message string) []byte { + raw, _ := json.Marshal(envelope{OK: false, Error: &envelopeError{Code: code, Message: message}}) + return raw +} + +func jsonResponse(status int, v any) ([]byte, error) { + body, err := json.Marshal(v) + if err != nil { + return nil, err + } + return okEnvelope(managementResponse{ + StatusCode: status, + Headers: http.Header{ + "Content-Type": []string{"application/json; charset=utf-8"}, + "Cache-Control": []string{"no-store"}, + }, + Body: body, + }) +} + +func managementHTML(html string) ([]byte, error) { + return okEnvelope(managementResponse{ + StatusCode: http.StatusOK, + Headers: http.Header{ + "Content-Type": []string{"text/html; charset=utf-8"}, + "Cache-Control": []string{"no-store"}, + }, + Body: []byte(html), + }) +} + +var hostCall = func(method string, payload any) (json.RawMessage, error) { + _ = payload + return nil, fmt.Errorf("host callback %s is unavailable", method) +} + +func callHost(method string, payload any) (json.RawMessage, error) { + return hostCall(method, payload) +} + +func adminHTML() string { + return renderHTML(adminHTMLTemplate) +} + +func userHTML() string { + return renderHTML(userHTMLTemplate) +} + +func sharedCSS() string { + return sharedCSSTemplate +} + +func renderHTML(tpl string) string { + tpl = strings.ReplaceAll(tpl, "{{SHARED_CSS}}", sharedCSS()) + return strings.ReplaceAll(tpl, "{{CSS}}", sharedCSS()) +} diff --git a/cpa_key_policy_plus_plugin/go/main_test.go b/cpa_key_policy_plus_plugin/go/main_test.go new file mode 100644 index 0000000..b5ea80e --- /dev/null +++ b/cpa_key_policy_plus_plugin/go/main_test.go @@ -0,0 +1,267 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "codexcont/cpa-key-policy-plus-plugin/internal/policyplus" +) + +func setupTestState(t *testing.T) policyplus.KeyRecord { + t.Helper() + state.mu.Lock() + state.cfg = policyplus.DefaultConfig() + state.cfg.StateDBPath = filepath.Join(t.TempDir(), "policyplus.sqlite") + state.cfg.SessionSecret = "test-secret" + state.cfg.ExclusiveAuth = true + state.keyState = policyplus.KeyPolicyState{} + state.keyStatePath = "" + state.rpmBuckets = map[string][]time.Time{} + state.concurrency = map[string]int{} + old := state.store + state.store = nil + state.mu.Unlock() + if old != nil { + _ = old.Close() + } + store, err := policyplus.OpenStore(state.cfg.StateDBPath) + if err != nil { + t.Fatal(err) + } + key := policyplus.KeyRecord{ + ID: "alice-key", + Name: "Alice", + KeyHash: "sha256:" + policyplus.SHA256Hex("cpa_alice_secret"), + Enabled: true, + Preview: "cpa_ali...cret", + RPM: 10, + Concurrency: 2, + MaxActiveSessions: 1, + Models: []string{"gpt-5.5"}, + DailyLimitUSD: floatPtr(10), + WeeklyLimitUSD: floatPtr(50), + } + if err := store.UpsertKey(context.Background(), key); err != nil { + t.Fatal(err) + } + state.mu.Lock() + state.store = store + state.mu.Unlock() + t.Cleanup(func() { + state.mu.Lock() + if state.store != nil { + _ = state.store.Close() + } + state.store = nil + state.mu.Unlock() + }) + return key +} + +func TestPluginRegistrationIsPolicyPlusExclusiveAuth(t *testing.T) { + setupTestState(t) + raw, err := okEnvelope(pluginRegistration()) + if err != nil { + t.Fatal(err) + } + var env envelope + if err := json.Unmarshal(raw, &env); err != nil { + t.Fatal(err) + } + var reg registration + if err := json.Unmarshal(env.Result, ®); err != nil { + t.Fatal(err) + } + if reg.Metadata.Name != pluginID { + t.Fatalf("plugin name = %s", reg.Metadata.Name) + } + if !reg.Capabilities.FrontendAuthProvider || !reg.Capabilities.FrontendAuthProviderExclusive { + t.Fatalf("frontend auth capabilities = %#v", reg.Capabilities) + } + if reg.Capabilities.ModelRouter || reg.Capabilities.Executor { + t.Fatalf("plus must not steal Governor/CodexCont routing: %#v", reg.Capabilities) + } +} + +func TestFrontendAuthEnforcesActiveSessionLimit(t *testing.T) { + setupTestState(t) + req := frontendAuthRequest{ + Headers: http.Header{"Authorization": []string{"Bearer cpa_alice_secret"}}, + Body: []byte(`{"model":"gpt-5.5","prompt_cache_key":"window-a"}`), + } + if !authOK(t, req) { + t.Fatal("first session should authenticate") + } + req.Body = []byte(`{"model":"gpt-5.5","prompt_cache_key":"window-a"}`) + if !authOK(t, req) { + t.Fatal("same session should refresh and authenticate") + } + req.Body = []byte(`{"model":"gpt-5.5","prompt_cache_key":"window-b"}`) + if authOK(t, req) { + t.Fatal("second active session should be rejected") + } +} + +func TestFrontendAuthAllowsMissingSessionAndAudits(t *testing.T) { + setupTestState(t) + req := frontendAuthRequest{ + Headers: http.Header{"Authorization": []string{"Bearer cpa_alice_secret"}}, + Body: []byte(`{"model":"gpt-5.5"}`), + } + if !authOK(t, req) { + t.Fatal("missing session identity should not be rejected in v1") + } +} + +func TestAdminSaveKeysPersistsUnifiedLimits(t *testing.T) { + setupTestState(t) + enabled := false + body := map[string]any{"keys": []map[string]any{{ + "id": "alice-key", + "name": "Alice Plus", + "enabled": enabled, + "rpm": 5, + "concurrency": 1, + "max_active_sessions": 3, + "models": []string{"gpt-5.4"}, + "five_hour_usd": 1.25, + "daily_usd": 2.5, + "weekly_usd": 7.5, + "monthly_usd": 20.0, + }}} + rawBody, _ := json.Marshal(body) + raw, err := adminSaveKeys(managementRequest{Body: rawBody}) + if err != nil { + t.Fatal(err) + } + bodyBytes := decodeManagementBody(t, raw) + if !strings.Contains(string(bodyBytes), "Alice Plus") { + t.Fatalf("save response missing updated key: %s", bodyBytes) + } + store := loadedStore() + keys, err := store.ListKeys(context.Background()) + if err != nil || len(keys) != 1 { + t.Fatalf("ListKeys err=%v keys=%#v", err, keys) + } + got := keys[0] + if got.Enabled || got.RPM != 5 || got.Concurrency != 1 || got.MaxActiveSessions != 3 { + t.Fatalf("updated key = %#v", got) + } + if got.FiveHourUSD == nil || *got.FiveHourUSD != 1.25 || got.MonthlyLimitUSD == nil || *got.MonthlyLimitUSD != 20 { + t.Fatalf("limits not persisted: %#v", got) + } +} + +func TestAdminCreateKeyReturnsRawKeyOnlyOnce(t *testing.T) { + setupTestState(t) + raw, err := adminCreateKey(managementRequest{Body: []byte(`{"name":"Bob"}`)}) + if err != nil { + t.Fatal(err) + } + bodyBytes := decodeManagementBody(t, raw) + var resp struct { + OK bool `json:"ok"` + RawKey string `json:"raw_key"` + } + if err := json.Unmarshal(bodyBytes, &resp); err != nil { + t.Fatal(err) + } + if !resp.OK || !strings.HasPrefix(resp.RawKey, "cpa_") { + t.Fatalf("create response = %s", bodyBytes) + } + if strings.Contains(string(bodyBytes), policyplus.SHA256Hex(resp.RawKey)) { + t.Fatalf("response leaked full hash: %s", bodyBytes) + } +} + +func TestUserHTMLUsesPolicyPlusHeader(t *testing.T) { + html := userHTML() + if !strings.Contains(html, "X-CPA-Key-Policy-Plus-Key") { + t.Fatal("user page must send the Plus login header") + } + if strings.Contains(html, "X-CPA-Governor-Key") { + t.Fatal("user page should not keep the Governor login header") + } +} + +func TestAdminHTMLHasRenderedSharedCSS(t *testing.T) { + html := adminHTML() + if strings.Contains(html, "{{CSS}}") || strings.Contains(html, "{{SHARED_CSS}}") { + t.Fatalf("admin html still has css placeholder") + } + if !strings.Contains(html, "--panel") || !strings.Contains(html, "CPA Key Policy+") { + t.Fatal("admin html should embed shared style and key policy UI") + } +} + +func TestSessionInvalidatedWhenKeyHashChanges(t *testing.T) { + key := setupTestState(t) + req := managementRequest{Headers: http.Header{"X-CPA-Key-Policy-Plus-Key": []string{"cpa_alice_secret"}}} + raw, err := userSession(req) + if err != nil { + t.Fatal(err) + } + var env envelope + if err := json.Unmarshal(raw, &env); err != nil { + t.Fatal(err) + } + var resp managementResponse + if err := json.Unmarshal(env.Result, &resp); err != nil { + t.Fatal(err) + } + cookie := strings.Join(resp.Headers.Values("Set-Cookie"), "; ") + if cookie == "" { + t.Fatal("session did not set cookie") + } + if _, ok := keyFromSession(managementRequest{Headers: http.Header{"Cookie": []string{cookie}}}); !ok { + t.Fatal("fresh session should resolve") + } + key.KeyHash = "sha256:" + policyplus.SHA256Hex("cpa_rotated_secret") + if err := loadedStore().UpsertKey(context.Background(), key); err != nil { + t.Fatal(err) + } + if _, ok := keyFromSession(managementRequest{Headers: http.Header{"Cookie": []string{cookie}}}); ok { + t.Fatal("old session should not survive key rotation") + } +} + +func authOK(t *testing.T, req frontendAuthRequest) bool { + t.Helper() + rawReq, _ := json.Marshal(req) + raw, err := frontendAuth(rawReq) + if err != nil { + t.Fatal(err) + } + var env envelope + if err := json.Unmarshal(raw, &env); err != nil { + t.Fatal(err) + } + var resp frontendAuthResponse + if err := json.Unmarshal(env.Result, &resp); err != nil { + t.Fatal(err) + } + return resp.Authenticated +} + +func decodeManagementBody(t *testing.T, raw []byte) []byte { + t.Helper() + var env envelope + if err := json.Unmarshal(raw, &env); err != nil { + t.Fatal(err) + } + var resp managementResponse + if err := json.Unmarshal(env.Result, &resp); err != nil { + t.Fatal(err) + } + return resp.Body +} + +func TestMain(m *testing.M) { + os.Exit(m.Run()) +} diff --git a/cpa_key_policy_plus_plugin/go/plugin_export.go b/cpa_key_policy_plus_plugin/go/plugin_export.go new file mode 100644 index 0000000..f0db1d4 --- /dev/null +++ b/cpa_key_policy_plus_plugin/go/plugin_export.go @@ -0,0 +1,166 @@ +//go:build cliproxy_plugin + +package main + +/* +#include +#include + +typedef struct { + void* ptr; + size_t len; +} cliproxy_buffer; + +typedef int (*cliproxy_host_call_fn)(void*, const char*, const uint8_t*, size_t, cliproxy_buffer*); +typedef void (*cliproxy_host_free_fn)(void*, size_t); + +typedef struct { + uint32_t abi_version; + void* host_ctx; + cliproxy_host_call_fn call; + cliproxy_host_free_fn free_buffer; +} cliproxy_host_api; + +typedef int (*cliproxy_plugin_call_fn)(char*, uint8_t*, size_t, cliproxy_buffer*); +typedef void (*cliproxy_plugin_free_fn)(void*, size_t); +typedef void (*cliproxy_plugin_shutdown_fn)(void); + +typedef struct { + uint32_t abi_version; + cliproxy_plugin_call_fn call; + cliproxy_plugin_free_fn free_buffer; + cliproxy_plugin_shutdown_fn shutdown; +} cliproxy_plugin_api; + +extern int cliproxyPluginCall(char*, uint8_t*, size_t, cliproxy_buffer*); +extern void cliproxyPluginFree(void*, size_t); +extern void cliproxyPluginShutdown(void); + +static const cliproxy_host_api* stored_host; + +static void store_host_api(const cliproxy_host_api* host) { + stored_host = host; +} + +static int call_host_api(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) { + if (stored_host == NULL || stored_host->call == NULL) { + return 1; + } + return stored_host->call(stored_host->host_ctx, method, request, request_len, response); +} + +static void free_host_buffer(void* ptr, size_t len) { + if (stored_host != NULL && stored_host->free_buffer != NULL && ptr != NULL) { + stored_host->free_buffer(ptr, len); + } +} +*/ +import "C" + +import ( + "encoding/json" + "fmt" + "unsafe" +) + +//export cliproxy_plugin_init +func cliproxy_plugin_init(host *C.cliproxy_host_api, plugin *C.cliproxy_plugin_api) C.int { + if plugin == nil { + return 1 + } + C.store_host_api(host) + hostCall = cgoHostCall + plugin.abi_version = C.uint32_t(abiVersion) + plugin.call = C.cliproxy_plugin_call_fn(C.cliproxyPluginCall) + plugin.free_buffer = C.cliproxy_plugin_free_fn(C.cliproxyPluginFree) + plugin.shutdown = C.cliproxy_plugin_shutdown_fn(C.cliproxyPluginShutdown) + return 0 +} + +//export cliproxyPluginCall +func cliproxyPluginCall(method *C.char, request *C.uint8_t, requestLen C.size_t, response *C.cliproxy_buffer) C.int { + if response != nil { + response.ptr = nil + response.len = 0 + } + if method == nil { + writeCResponse(response, errorEnvelope("invalid_method", "method is required")) + return 1 + } + var requestBytes []byte + if request != nil && requestLen > 0 { + requestBytes = C.GoBytes(unsafe.Pointer(request), C.int(requestLen)) + } + raw, errHandle := handleMethod(C.GoString(method), requestBytes) + if errHandle != nil { + writeCResponse(response, errorEnvelope("plugin_error", errHandle.Error())) + return 1 + } + writeCResponse(response, raw) + return 0 +} + +//export cliproxyPluginFree +func cliproxyPluginFree(ptr unsafe.Pointer, _ C.size_t) { + if ptr != nil { + C.free(ptr) + } +} + +//export cliproxyPluginShutdown +func cliproxyPluginShutdown() { + shutdownPlugin() +} + +func writeCResponse(response *C.cliproxy_buffer, raw []byte) { + if response == nil || len(raw) == 0 { + return + } + ptr := C.CBytes(raw) + if ptr == nil { + return + } + response.ptr = ptr + response.len = C.size_t(len(raw)) +} + +func cgoHostCall(method string, payload any) (json.RawMessage, error) { + rawPayload, err := json.Marshal(payload) + if err != nil { + return nil, err + } + cMethod := C.CString(method) + defer C.free(unsafe.Pointer(cMethod)) + var response C.cliproxy_buffer + var requestPtr *C.uint8_t + if len(rawPayload) > 0 { + cPayload := C.CBytes(rawPayload) + if cPayload == nil { + return nil, fmt.Errorf("allocate host callback") + } + defer C.free(cPayload) + requestPtr = (*C.uint8_t)(cPayload) + } + callCode := C.call_host_api(cMethod, requestPtr, C.size_t(len(rawPayload)), &response) + var rawResponse []byte + if response.ptr != nil && response.len > 0 { + rawResponse = C.GoBytes(response.ptr, C.int(response.len)) + } + if response.ptr != nil { + C.free_host_buffer(response.ptr, response.len) + } + if callCode != 0 || len(rawResponse) == 0 { + return nil, fmt.Errorf("host callback %s returned code=%d", method, int(callCode)) + } + var env envelope + if err := json.Unmarshal(rawResponse, &env); err != nil { + return nil, err + } + if !env.OK { + if env.Error != nil { + return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message) + } + return nil, fmt.Errorf("host callback failed") + } + return env.Result, nil +} diff --git a/cpa_key_policy_plus_plugin/go/plugin_types.go b/cpa_key_policy_plus_plugin/go/plugin_types.go new file mode 100644 index 0000000..991e717 --- /dev/null +++ b/cpa_key_policy_plus_plugin/go/plugin_types.go @@ -0,0 +1,231 @@ +package main + +import ( + "net/http" + "net/url" + "time" +) + +const ( + abiVersion uint32 = 1 + schemaVersion uint32 = 1 + + methodPluginRegister = "plugin.register" + methodPluginReconfigure = "plugin.reconfigure" + methodFrontendAuthIdentifier = "frontend_auth.identifier" + methodFrontendAuthAuthenticate = "frontend_auth.authenticate" + methodModelRoute = "model.route" + methodExecutorIdentifier = "executor.identifier" + methodExecutorExecute = "executor.execute" + methodExecutorExecuteStream = "executor.execute_stream" + methodExecutorCountTokens = "executor.count_tokens" + methodUsageHandle = "usage.handle" + methodManagementRegister = "management.register" + methodManagementHandle = "management.handle" + methodHostModelExecute = "host.model.execute" + methodHostModelExecuteStream = "host.model.execute_stream" + methodHostModelStreamRead = "host.model.stream_read" + methodHostModelStreamClose = "host.model.stream_close" + methodHostStreamEmit = "host.stream.emit" + methodHostStreamClose = "host.stream.close" +) + +const ( + configString = "string" + configBoolean = "boolean" + configEnum = "enum" + + routeTargetSelf = "self" +) + +type configField struct { + Name string `json:"Name"` + Type string `json:"Type"` + EnumValues []string `json:"EnumValues,omitempty"` + Description string `json:"Description"` +} + +type frontendAuthRequest struct { + Method string `json:"Method"` + Path string `json:"Path"` + Headers http.Header `json:"Headers"` + Query url.Values `json:"Query"` + Body []byte `json:"Body"` +} + +type frontendAuthResponse struct { + Authenticated bool `json:"Authenticated"` + Principal string `json:"Principal,omitempty"` + Metadata map[string]string `json:"Metadata,omitempty"` +} + +type modelRouteRequest struct { + PluginID string `json:"PluginID"` + SourceFormat string `json:"SourceFormat"` + RequestedModel string `json:"RequestedModel"` + Stream bool `json:"Stream"` + Headers http.Header `json:"Headers"` + Query url.Values `json:"Query"` + Body []byte `json:"Body"` + Metadata map[string]any `json:"Metadata"` + AvailableProviders []string `json:"AvailableProviders"` +} + +type modelRouteResponse struct { + Handled bool `json:"Handled"` + TargetKind string `json:"TargetKind,omitempty"` + Target string `json:"Target,omitempty"` + TargetModel string `json:"TargetModel,omitempty"` + Reason string `json:"Reason,omitempty"` +} + +type managementRegistrationResponse struct { + Routes []managementRoute `json:"routes,omitempty"` + Resources []resourceRoute `json:"resources,omitempty"` +} + +type managementRoute struct { + Method string `json:"Method"` + Path string `json:"Path"` + Menu string `json:"Menu,omitempty"` + Description string `json:"Description,omitempty"` +} + +type resourceRoute struct { + Path string `json:"Path"` + Menu string `json:"Menu,omitempty"` + Description string `json:"Description,omitempty"` +} + +type managementRequest struct { + Method string `json:"Method"` + Path string `json:"Path"` + Headers http.Header `json:"Headers"` + Query url.Values `json:"Query"` + Body []byte `json:"Body"` + HostCallbackID string `json:"host_callback_id,omitempty"` +} + +type managementResponse struct { + StatusCode int `json:"StatusCode"` + Headers http.Header `json:"Headers"` + Body []byte `json:"Body"` +} + +type executorResponse struct { + Payload []byte `json:"Payload"` + Headers http.Header `json:"Headers,omitempty"` +} + +type usageRecord struct { + Provider string `json:"Provider"` + ExecutorType string `json:"ExecutorType"` + Model string `json:"Model"` + Alias string `json:"Alias"` + APIKey string `json:"APIKey"` + AuthID string `json:"AuthID"` + AuthIndex string `json:"AuthIndex"` + AuthType string `json:"AuthType"` + Source string `json:"Source"` + ReasoningEffort string `json:"ReasoningEffort"` + ServiceTier string `json:"ServiceTier"` + RequestedAt time.Time `json:"RequestedAt"` + Latency time.Duration `json:"Latency"` + TTFT time.Duration `json:"TTFT"` + Failed bool `json:"Failed"` + Failure usageFailure `json:"Failure"` + Detail usageDetail `json:"Detail"` + ResponseHeaders http.Header `json:"ResponseHeaders"` +} + +type executorRequest struct { + AuthID string `json:"AuthID"` + AuthProvider string `json:"AuthProvider"` + Model string `json:"Model"` + Format string `json:"Format"` + Stream bool `json:"Stream"` + Alt string `json:"Alt"` + Headers http.Header `json:"Headers"` + Query url.Values `json:"Query"` + OriginalRequest []byte `json:"OriginalRequest"` + SourceFormat string `json:"SourceFormat"` + Payload []byte `json:"Payload"` + Metadata map[string]any `json:"Metadata"` + StorageJSON []byte `json:"StorageJSON"` + AuthMetadata map[string]any `json:"AuthMetadata"` + AuthAttributes map[string]string `json:"AuthAttributes"` +} + +type executorCallRequest struct { + ExecutorRequest executorRequest `json:"ExecutorRequest"` + StreamID string `json:"stream_id,omitempty"` + HostCallbackID string `json:"host_callback_id,omitempty"` +} + +type executorStreamResponse struct { + Headers http.Header `json:"headers,omitempty"` +} + +type hostModelExecutionRequest struct { + EntryProtocol string `json:"entry_protocol"` + ExitProtocol string `json:"exit_protocol"` + Model string `json:"model"` + Stream bool `json:"stream"` + Body []byte `json:"body"` + Headers http.Header `json:"headers"` + Query url.Values `json:"query"` + Alt string `json:"alt,omitempty"` + HostCallbackID string `json:"host_callback_id,omitempty"` +} + +type hostModelExecutionResponse struct { + StatusCode int `json:"status_code"` + Headers http.Header `json:"headers"` + Body []byte `json:"body"` +} + +type hostModelStreamResponse struct { + StatusCode int `json:"status_code"` + Headers http.Header `json:"headers"` + StreamID string `json:"stream_id"` +} + +type hostModelStreamReadRequest struct { + StreamID string `json:"stream_id"` +} + +type hostModelStreamReadResponse struct { + Payload []byte `json:"payload"` + Error string `json:"error"` + Done bool `json:"done"` +} + +type hostModelStreamCloseRequest struct { + StreamID string `json:"stream_id"` +} + +type hostStreamEmitRequest struct { + StreamID string `json:"stream_id"` + Payload []byte `json:"payload,omitempty"` + Error string `json:"error,omitempty"` +} + +type hostStreamCloseRequest struct { + StreamID string `json:"stream_id"` + Error string `json:"error,omitempty"` +} + +type usageFailure struct { + StatusCode int `json:"StatusCode"` + Body string `json:"Body"` +} + +type usageDetail struct { + InputTokens int64 `json:"InputTokens"` + OutputTokens int64 `json:"OutputTokens"` + ReasoningTokens int64 `json:"ReasoningTokens"` + CachedTokens int64 `json:"CachedTokens"` + CacheReadTokens int64 `json:"CacheReadTokens"` + CacheCreationTokens int64 `json:"CacheCreationTokens"` + TotalTokens int64 `json:"TotalTokens"` +} From 3ebd91070bb44fb5c29f72a21424cf73a9800dba Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Thu, 2 Jul 2026 19:48:53 +0800 Subject: [PATCH 38/68] chore(task): archive 07-02-cpa-key-policy-plus-unified-control --- .../07-02-cpa-key-policy-plus-unified-control/check.jsonl | 0 .../07-02-cpa-key-policy-plus-unified-control/design.md | 0 .../07-02-cpa-key-policy-plus-unified-control/implement.jsonl | 0 .../07-02-cpa-key-policy-plus-unified-control/implement.md | 0 .../2026-07}/07-02-cpa-key-policy-plus-unified-control/prd.md | 0 .../07-02-cpa-key-policy-plus-unified-control/task.json | 4 ++-- 6 files changed, 2 insertions(+), 2 deletions(-) rename .trellis/tasks/{ => archive/2026-07}/07-02-cpa-key-policy-plus-unified-control/check.jsonl (100%) rename .trellis/tasks/{ => archive/2026-07}/07-02-cpa-key-policy-plus-unified-control/design.md (100%) rename .trellis/tasks/{ => archive/2026-07}/07-02-cpa-key-policy-plus-unified-control/implement.jsonl (100%) rename .trellis/tasks/{ => archive/2026-07}/07-02-cpa-key-policy-plus-unified-control/implement.md (100%) rename .trellis/tasks/{ => archive/2026-07}/07-02-cpa-key-policy-plus-unified-control/prd.md (100%) rename .trellis/tasks/{ => archive/2026-07}/07-02-cpa-key-policy-plus-unified-control/task.json (90%) diff --git a/.trellis/tasks/07-02-cpa-key-policy-plus-unified-control/check.jsonl b/.trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-unified-control/check.jsonl similarity index 100% rename from .trellis/tasks/07-02-cpa-key-policy-plus-unified-control/check.jsonl rename to .trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-unified-control/check.jsonl diff --git a/.trellis/tasks/07-02-cpa-key-policy-plus-unified-control/design.md b/.trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-unified-control/design.md similarity index 100% rename from .trellis/tasks/07-02-cpa-key-policy-plus-unified-control/design.md rename to .trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-unified-control/design.md diff --git a/.trellis/tasks/07-02-cpa-key-policy-plus-unified-control/implement.jsonl b/.trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-unified-control/implement.jsonl similarity index 100% rename from .trellis/tasks/07-02-cpa-key-policy-plus-unified-control/implement.jsonl rename to .trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-unified-control/implement.jsonl diff --git a/.trellis/tasks/07-02-cpa-key-policy-plus-unified-control/implement.md b/.trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-unified-control/implement.md similarity index 100% rename from .trellis/tasks/07-02-cpa-key-policy-plus-unified-control/implement.md rename to .trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-unified-control/implement.md diff --git a/.trellis/tasks/07-02-cpa-key-policy-plus-unified-control/prd.md b/.trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-unified-control/prd.md similarity index 100% rename from .trellis/tasks/07-02-cpa-key-policy-plus-unified-control/prd.md rename to .trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-unified-control/prd.md diff --git a/.trellis/tasks/07-02-cpa-key-policy-plus-unified-control/task.json b/.trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-unified-control/task.json similarity index 90% rename from .trellis/tasks/07-02-cpa-key-policy-plus-unified-control/task.json rename to .trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-unified-control/task.json index dac581e..57bfbcd 100644 --- a/.trellis/tasks/07-02-cpa-key-policy-plus-unified-control/task.json +++ b/.trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-unified-control/task.json @@ -3,7 +3,7 @@ "name": "cpa-key-policy-plus-unified-control", "title": "CPA Key Policy Plus unified control", "description": "", - "status": "in_progress", + "status": "completed", "dev_type": null, "scope": null, "package": null, @@ -11,7 +11,7 @@ "creator": "dxt98", "assignee": "dxt98", "createdAt": "2026-07-02", - "completedAt": null, + "completedAt": "2026-07-02", "branch": null, "base_branch": "main", "worktree_path": null, From d206871622330e01faf457f189761046ad565354 Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Thu, 2 Jul 2026 19:49:01 +0800 Subject: [PATCH 39/68] chore: record journal --- .trellis/workspace/dxt98/index.md | 5 ++-- .trellis/workspace/dxt98/journal-1.md | 33 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/.trellis/workspace/dxt98/index.md b/.trellis/workspace/dxt98/index.md index 596cc38..804ee30 100644 --- a/.trellis/workspace/dxt98/index.md +++ b/.trellis/workspace/dxt98/index.md @@ -8,7 +8,7 @@ - **Active File**: `journal-1.md` -- **Total Sessions**: 6 +- **Total Sessions**: 7 - **Last Active**: 2026-07-02 @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~212 | Active | +| `journal-1.md` | ~245 | Active | --- @@ -29,6 +29,7 @@ | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 7 | 2026-07-02 | CPA Key Policy Plus cutover | `160af51` | `main` | | 6 | 2026-07-02 | CPA Governor and CodexCont engine rollout | `429bed5`, `d9047c8`, `50e77d0`, `69e429e` | `main` | | 5 | 2026-07-02 | CPA usage request detail closeout | `432ca38` | `main` | | 4 | 2026-07-02 | CPA usage quota admin | `762b9e6` | `main` | diff --git a/.trellis/workspace/dxt98/journal-1.md b/.trellis/workspace/dxt98/journal-1.md index 58f3e93..ffe3d54 100644 --- a/.trellis/workspace/dxt98/journal-1.md +++ b/.trellis/workspace/dxt98/journal-1.md @@ -210,3 +210,36 @@ Built and deployed the CPA Governor plugin in passive mode, added the CodexCont ### Next Steps - None - task complete + + +## Session 7: CPA Key Policy Plus cutover + +**Date**: 2026-07-02 +**Task**: CPA Key Policy Plus cutover +**Branch**: `main` + +### Summary + +Implemented and deployed cpa-key-policy-plus as the unified cpa_ key authority, migrated limits/state, retired usage-admin backend, verified cpa-usage login/API/UI, and kept public /v1/responses on the known-good CodexCont sidecar until executor-level folding is ready. + +### Main Changes + +(Add details) + +### Git Commits + +| Hash | Message | +|------|---------| +| `160af51` | (see git log) | + +### Testing + +- [OK] (Add test results) + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete From 4c6613b496fab94a2989df5ea1ad1b7a69041c81 Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Thu, 2 Jul 2026 21:28:21 +0800 Subject: [PATCH 40/68] fix: repair CPA Key Policy Plus admin flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 Key Policy+ 管理页的新建 Key、保存、清零 API 通路,改为通过 admin management alias 写入。 新增模型目录安全投影、结构化模型/价格编辑器、移动端布局修正,并沉淀 Plus 管理路由与部署契约。 --- .../backend/codex-continuation-contracts.md | 71 +- .../go/assets/admin.html | 932 ++++++++++++++++-- .../go/assets/shared.css | 4 +- .../go/internal/policyplus/models.go | 185 ++++ .../go/internal/policyplus/policyplus_test.go | 36 + cpa_key_policy_plus_plugin/go/main.go | 170 +++- cpa_key_policy_plus_plugin/go/main_test.go | 126 ++- cpa_key_policy_plus_plugin/go/plugin_types.go | 1 + 8 files changed, 1408 insertions(+), 117 deletions(-) diff --git a/.trellis/spec/backend/codex-continuation-contracts.md b/.trellis/spec/backend/codex-continuation-contracts.md index ba0e119..340c47e 100644 --- a/.trellis/spec/backend/codex-continuation-contracts.md +++ b/.trellis/spec/backend/codex-continuation-contracts.md @@ -441,9 +441,20 @@ credential, while CPAMP remains admin-only. `active_requests`, `active_sessions`, `audit_log`, `codexcont_summaries`, and `settings`. - Admin resource: `GET /v0/resource/plugins/cpa-key-policy-plus/admin`. +- Admin management routes: + - `GET /v0/management/plugins/cpa-key-policy-plus/keys` + - `GET /v0/management/plugins/cpa-key-policy-plus/models` + - `POST /v0/management/plugins/cpa-key-policy-plus/keys/create` + - `PUT /v0/management/plugins/cpa-key-policy-plus/keys/save` + - `PUT /v0/management/plugins/cpa-key-policy-plus/keys/limits` + - `POST /v0/management/plugins/cpa-key-policy-plus/keys/reset` - User resource: `GET /v0/resource/plugins/cpa-key-policy-plus/user`. - Admin convenience route: `https://cpa-admin.konbakuyomu.us/key-policy-plus/`. +- Admin API alias: + `https://cpa-admin.konbakuyomu.us/key-policy-plus/api/*` rewrites to the + corresponding Plus management route. This alias is admin-host only and must + not exist on `cpa.konbakuyomu.us`. - User route: `https://cpa-usage.konbakuyomu.us/`. - User API session creation is GET-only on CPA resource routes and sends the raw user key in `X-CPA-Key-Policy-Plus-Key`; do not put the key in the URL. @@ -459,6 +470,20 @@ credential, while CPAMP remains admin-only. - Plus must never store or return raw API keys, Authorization headers, full key hashes, cookies, request bodies, response bodies, OAuth tokens, or encrypted reasoning content. +- CPA plugin `ResourceRoute` is GET-only in the current host. The Plus admin + HTML may be served from a resource route, but create/save/reset mutations + must go through `/key-policy-plus/api/*` -> CPA management routes. Do not + send `POST` or `PUT` to `/v0/resource/plugins/cpa-key-policy-plus/admin/api/*`. +- The admin proxy route for `/key-policy-plus/api/*` must inject the CPA + management key from a mounted secret or equivalent process environment; do + not commit the raw key to Caddyfile, Trellis docs, or git. CPAMP iframe + context must not be relied on to add an Authorization header for embedded + plugin HTML, because the plugin page owns its own `fetch()` calls. +- The model catalog endpoint returns safe `ModelOption` projections only: + `id`, optional display metadata, `source`, and `known`. It may merge CPA host + model hints with already configured Plus models, but it must preserve unknown + configured models instead of deleting them when online discovery is empty or + stale. - `5h`, `24h`, and `7d` are rolling USD windows. `month` is the current Asia/Shanghai calendar month. Reset writes a soft watermark and does not delete historical `usage_events`. @@ -495,9 +520,19 @@ credential, while CPAMP remains admin-only. CodexCont during migration, but CPA must not execute the upstream provider. - Missing session id -> request is allowed and `missing_session_identity` is recorded in audit. +- `POST/PUT /v0/resource/plugins/cpa-key-policy-plus/admin/api/*` -> fails + before reaching plugin logic; this is a deployment/config bug if the admin + page depends on it. +- `/key-policy-plus/api/*` without a working CPA management-key injection -> + `401 missing management key` or `invalid_admin_key`; deployment is not + accepted until the alias returns safe Plus JSON and create/save/reset work. +- CPA/host model discovery unavailable -> `/models` still returns the union of + currently configured Plus model names and prices, with a warning instead of + stripping key allowlists. - `cpa-usage.konbakuyomu.us` exposes only the user resource and user APIs. - Public `cpa.konbakuyomu.us/v0/resource/plugins/*`, `/usage-admin*`, - `/codexcont*`, `/governor*`, `/management*`, and `/admin*` -> `404`. + `/codexcont*`, `/governor*`, `/management*`, `/key-policy-plus*`, and + `/admin*` -> `404`. ### 5. Good/Base/Bad Cases - Good: CPA logs show Governor plus `cpa-key-policy-plus` loaded, Plus DB has @@ -505,8 +540,14 @@ credential, while CPAMP remains admin-only. `/v1/responses` still succeeds through the known-good CodexCont sidecar. - Base: A key has no usage yet. User login still shows key metadata, configured models, prices, and empty usage tables. +- Good: The admin page is a resource HTML page, while its mutations use + `/key-policy-plus/api/*` and reach Plus management handlers with the CPA + management key injected by the admin proxy. - Bad: `cpa-usage` still reads `cpa_usage_portal` SQLite as the authority after Plus is enabled. That preserves the split-brain limit problem. +- Bad: The Plus admin page tries to create keys through + `/v0/resource/plugins/cpa-key-policy-plus/admin/api/keys/create`. The current + CPA host treats ResourceRoute as GET-only, so writes fail before plugin code. - Bad: Caddy is changed to route public `/v1/responses` directly to CPA before executor-level folding exists. That bypasses the current 516/518n-2 mitigation. @@ -519,10 +560,17 @@ credential, while CPAMP remains admin-only. - Go unit: admin/user HTML resources are `no-store`, user login uses `X-CPA-Key-Policy-Plus-Key`, and responses do not leak raw keys/full hashes. - Go unit: user events and CodexCont summaries are filtered to the current key. +- Go unit: admin HTML points mutations at `/key-policy-plus/api`, model + normalization preserves unknown configured models, and create/save/reset + through the admin alias persist settings. +- Frontend/Playwright: Plus admin can create a key, select discovered models, + edit per-model prices, save, reload, and keep dense tables horizontally + scrollable on 390px without page-level overflow. - Production smoke: plugin SHA256 matches the built artifact, CPA logs show Plus loaded, Plus DB key count is nonzero, `cpa-usage` login works, admin - backend `/key-policy-plus/` works, `usage-admin` backend returns `404`, and - public blocked paths return `404`. + backend `/key-policy-plus/` and `/key-policy-plus/api/models` work, a + disabled smoke key can be created through `/key-policy-plus/api/keys/create`, + `usage-admin` backend returns `404`, and public blocked paths return `404`. - Production smoke: a tiny authenticated `/v1/responses` request succeeds through the current sidecar route; do not claim CPA-first public routing until a separate executor-level continuation test passes. @@ -544,6 +592,23 @@ public /v1/responses -> CodexCont sidecar -> CPA future executor-level folding task -> then consider CPA-first public routing ``` +#### Wrong +```text +admin HTML -> POST /v0/resource/plugins/cpa-key-policy-plus/admin/api/keys/create +``` + +This depends on a mutating ResourceRoute, but the current CPA host dispatches +resource plugin routes as GET-only browser resources. + +#### Correct +```text +admin HTML -> /key-policy-plus/api/keys/create +admin proxy -> /v0/management/plugins/cpa-key-policy-plus/keys/create +``` + +The admin proxy injects the CPA management key from a mounted secret or process +environment, and the public API host still blocks `/key-policy-plus*`. + Separate the key authority migration from the continuation-owner migration. ## Scenario: CPA Governor plugin and CodexCont Engine rollout diff --git a/cpa_key_policy_plus_plugin/go/assets/admin.html b/cpa_key_policy_plus_plugin/go/assets/admin.html index d358e50..565d33e 100644 --- a/cpa_key_policy_plus_plugin/go/assets/admin.html +++ b/cpa_key_policy_plus_plugin/go/assets/admin.html @@ -5,18 +5,227 @@ CPA Key Policy+ +
-
-

CPA Key Policy+

-

统一管理用户 Key、模型权限、用量限额、并发和 Codex 窗口数

+
+
K+
+
+

CPA Key Policy+

+

统一管理用户 Key、模型权限、用量限额、并发和 Codex 窗口数

+
- 准备同步 - - + 准备同步 + +
@@ -27,11 +236,12 @@

CPA Key Policy+

Key 策略

-

5H/24H/7D 是滚动窗口,月限额按 Asia/Shanghai 自然月;清零是 soft reset,不删除历史明细。

+

空模型列表表示允许全部模型;5H/24H/7D 是滚动窗口,月限额按 Asia/Shanghai 自然月。

+
- +
@@ -39,16 +249,14 @@

Key 策略

- - - - - + + + - +
用户/Key RPM 请求并发 Codex窗口5H24H7D模型额度 USD模型/价格24H 用量 操作
加载中...
加载中...
@@ -57,37 +265,169 @@

Key 策略

-

审计提示

-

窗口数限制优先使用 Codex window/session 信号;缺失信号时 v1 放行并记录告警,避免误封。

+

同步提示

+

模型列表优先使用 CPA/Plus 当前可见模型;在线获取失败时保留已有配置,不会硬删未知模型。


     
+ + + + + + diff --git a/cpa_key_policy_plus_plugin/go/assets/shared.css b/cpa_key_policy_plus_plugin/go/assets/shared.css index 0ad3700..bdc37f0 100644 --- a/cpa_key_policy_plus_plugin/go/assets/shared.css +++ b/cpa_key_policy_plus_plugin/go/assets/shared.css @@ -31,6 +31,7 @@ body { color: var(--text); font: 14px/1.48 var(--sans); letter-spacing: 0; + overflow-x: hidden; } button, select, input { min-height: 38px; @@ -523,8 +524,9 @@ details.advanced > summary { .detail-grid { grid-template-columns: 1fr; } } @media (max-width: 620px) { - .shell { width: min(100% - 20px, 1680px); padding-top: 12px; } + .shell { width: min(1680px, calc(100% - 20px)); padding-top: 12px; } .topbar { align-items: flex-start; flex-direction: column; padding: 12px; } + .topbar .brand { width: 100%; max-width: 100%; } .toolbar, .filters { justify-content: flex-start; } .metrics { grid-template-columns: 1fr; } .login-row { flex-direction: column; } diff --git a/cpa_key_policy_plus_plugin/go/internal/policyplus/models.go b/cpa_key_policy_plus_plugin/go/internal/policyplus/models.go index 3f13f4a..4632925 100644 --- a/cpa_key_policy_plus_plugin/go/internal/policyplus/models.go +++ b/cpa_key_policy_plus_plugin/go/internal/policyplus/models.go @@ -17,6 +17,15 @@ type ModelPrice struct { CacheCreationPerMillion float64 `json:"cache_creation_per_million"` } +type ModelOption struct { + ID string `json:"id"` + DisplayName string `json:"display_name,omitempty"` + Type string `json:"type,omitempty"` + OwnedBy string `json:"owned_by,omitempty"` + Source string `json:"source,omitempty"` + Known bool `json:"known"` +} + type KeyRecord struct { ID string `json:"id"` Name string `json:"name"` @@ -215,6 +224,182 @@ func parseModels(items []any) []string { return out } +func ModelOptionsFromIDs(ids []string, source string, known bool) []ModelOption { + out := make([]ModelOption, 0, len(ids)) + seen := map[string]bool{} + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + continue + } + key := strings.ToLower(id) + if seen[key] { + continue + } + seen[key] = true + out = append(out, ModelOption{ID: id, DisplayName: id, Source: source, Known: known}) + } + return out +} + +func NormalizeModelOptions(data any, source string) []ModelOption { + items := modelOptionItems(data) + out := make([]ModelOption, 0, len(items)) + seen := map[string]bool{} + for _, item := range items { + option, ok := parseModelOption(item, source) + if !ok { + continue + } + key := strings.ToLower(option.ID) + if seen[key] { + continue + } + seen[key] = true + out = append(out, option) + } + return out +} + +func MergeModelOptions(groups ...[]ModelOption) []ModelOption { + out := []ModelOption{} + byID := map[string]int{} + for _, group := range groups { + for _, item := range group { + item.ID = strings.TrimSpace(item.ID) + if item.ID == "" { + continue + } + if strings.TrimSpace(item.DisplayName) == "" { + item.DisplayName = item.ID + } + key := strings.ToLower(item.ID) + if idx, ok := byID[key]; ok { + existing := out[idx] + if existing.DisplayName == existing.ID && item.DisplayName != "" { + existing.DisplayName = item.DisplayName + } + if existing.Type == "" { + existing.Type = item.Type + } + if existing.OwnedBy == "" { + existing.OwnedBy = item.OwnedBy + } + if existing.Source == "" { + existing.Source = item.Source + } + existing.Known = existing.Known || item.Known + out[idx] = existing + continue + } + byID[key] = len(out) + out = append(out, item) + } + } + return out +} + +func modelOptionItems(data any) []any { + switch v := data.(type) { + case nil: + return nil + case []any: + return v + case []string: + out := make([]any, len(v)) + for i := range v { + out[i] = v[i] + } + return out + case []ModelOption: + out := make([]any, len(v)) + for i := range v { + out[i] = v[i] + } + return out + case map[string]any: + for _, name := range []string{"models", "data", "items", "result"} { + if raw, ok := v[name]; ok { + if items := modelOptionItems(raw); len(items) > 0 { + return items + } + } + } + out := make([]any, 0, len(v)) + for id, raw := range v { + if m, ok := raw.(map[string]any); ok { + if _, hasID := m["id"]; !hasID { + m["id"] = id + } + out = append(out, m) + continue + } + out = append(out, id) + } + return out + default: + text := strings.TrimSpace(toString(v)) + if text == "" { + return nil + } + return []any{text} + } +} + +func parseModelOption(item any, source string) (ModelOption, bool) { + switch v := item.(type) { + case ModelOption: + v.ID = strings.TrimSpace(v.ID) + if v.ID == "" { + return ModelOption{}, false + } + if strings.TrimSpace(v.DisplayName) == "" { + v.DisplayName = v.ID + } + if v.Source == "" { + v.Source = source + } + return v, true + case string: + id := strings.TrimSpace(v) + if id == "" { + return ModelOption{}, false + } + return ModelOption{ID: id, DisplayName: id, Source: source, Known: true}, true + case map[string]any: + id := modelName(v) + if id == "" { + return ModelOption{}, false + } + display := firstString(v, "display_name", "displayName", "label", "name") + if display == "" { + display = id + } + known, hasKnown := firstBool(v, "known") + if !hasKnown { + known = true + } + src := firstString(v, "source") + if src == "" { + src = source + } + return ModelOption{ + ID: id, + DisplayName: display, + Type: firstString(v, "type", "object"), + OwnedBy: firstString(v, "owned_by", "ownedBy", "provider"), + Source: src, + Known: known, + }, true + default: + id := strings.TrimSpace(toString(v)) + if id == "" { + return ModelOption{}, false + } + return ModelOption{ID: id, DisplayName: id, Source: source, Known: true}, true + } +} + func parsePrices(raw map[string]any, modelItems []any) map[string]ModelPrice { out := map[string]ModelPrice{} for _, item := range modelItems { diff --git a/cpa_key_policy_plus_plugin/go/internal/policyplus/policyplus_test.go b/cpa_key_policy_plus_plugin/go/internal/policyplus/policyplus_test.go index e94467c..2b39b03 100644 --- a/cpa_key_policy_plus_plugin/go/internal/policyplus/policyplus_test.go +++ b/cpa_key_policy_plus_plugin/go/internal/policyplus/policyplus_test.go @@ -73,6 +73,42 @@ func TestKeyPolicyStateParsesSafeRecords(t *testing.T) { } } +func TestNormalizeModelOptionsAcceptsCommonPayloadShapes(t *testing.T) { + payload := map[string]any{ + "models": []any{ + map[string]any{"id": "gpt-5.5", "display_name": "GPT 5.5", "owned_by": "openai"}, + map[string]any{"model": "gpt-5.4"}, + "gpt-5.5", + map[string]any{"alias": "codex-auto-review", "target_model": "gpt-5.5"}, + }, + } + options := NormalizeModelOptions(payload, "online") + if len(options) != 3 { + t.Fatalf("options = %#v", options) + } + if options[0].ID != "gpt-5.5" || options[0].DisplayName != "GPT 5.5" || options[0].OwnedBy != "openai" { + t.Fatalf("first option = %#v", options[0]) + } + if options[2].ID != "codex-auto-review" { + t.Fatalf("alias model was not parsed: %#v", options) + } +} + +func TestMergeModelOptionsPreservesUnknownConfiguredModels(t *testing.T) { + known := NormalizeModelOptions([]any{map[string]any{"id": "gpt-5.5", "display_name": "GPT 5.5"}}, "online") + configured := ModelOptionsFromIDs([]string{"gpt-5.5", "legacy-custom"}, "plus_configured", false) + merged := MergeModelOptions(known, configured) + if len(merged) != 2 { + t.Fatalf("merged = %#v", merged) + } + if !merged[0].Known || merged[0].DisplayName != "GPT 5.5" { + t.Fatalf("known metadata should win: %#v", merged[0]) + } + if merged[1].ID != "legacy-custom" || merged[1].Known { + t.Fatalf("unknown configured model should be preserved: %#v", merged[1]) + } +} + func TestImportKeysDoesNotDeletePlusNativeKeys(t *testing.T) { ctx := context.Background() store, err := OpenStore(filepath.Join(t.TempDir(), "policyplus.sqlite")) diff --git a/cpa_key_policy_plus_plugin/go/main.go b/cpa_key_policy_plus_plugin/go/main.go index e4cd777..0f7f8fc 100644 --- a/cpa_key_policy_plus_plugin/go/main.go +++ b/cpa_key_policy_plus_plugin/go/main.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "io" "net/http" "net/url" "os" @@ -296,11 +297,13 @@ func runPreviewIfRequested() { w.WriteHeader(http.StatusNoContent) }) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) rawReq, _ := json.Marshal(managementRequest{ Method: r.Method, Path: r.URL.Path, Headers: r.Header, Query: r.URL.Query(), + Body: body, }) rawResp, _ := managementHandle(rawReq) var env envelope @@ -920,6 +923,7 @@ func managementRegister() ([]byte, error) { resp := managementRegistrationResponse{ Routes: []managementRoute{ {Method: http.MethodGet, Path: "/plugins/cpa-key-policy-plus/keys"}, + {Method: http.MethodGet, Path: "/plugins/cpa-key-policy-plus/models"}, {Method: http.MethodPost, Path: "/plugins/cpa-key-policy-plus/keys/create"}, {Method: http.MethodPut, Path: "/plugins/cpa-key-policy-plus/keys/save"}, {Method: http.MethodPut, Path: "/plugins/cpa-key-policy-plus/keys/limits"}, @@ -931,6 +935,7 @@ func managementRegister() ([]byte, error) { Resources: []resourceRoute{ {Path: "/admin", Menu: "CPA Key Policy+", Description: "Unified user key policy dashboard"}, {Path: "/admin/api/keys"}, + {Path: "/admin/api/models"}, {Path: "/admin/api/keys/create"}, {Path: "/admin/api/keys/save"}, {Path: "/admin/api/keys/limits"}, @@ -961,6 +966,8 @@ func managementHandle(raw []byte) ([]byte, error) { return managementHTML(userHTML()) case strings.HasSuffix(path, "/admin/api/keys"): return adminKeys(req) + case strings.HasSuffix(path, "/admin/api/models"): + return adminModels(req) case strings.HasSuffix(path, "/admin/api/keys/create"): return adminCreateKey(req) case strings.HasSuffix(path, "/admin/api/keys/save"): @@ -985,6 +992,8 @@ func managementHandle(raw []byte) ([]byte, error) { return userCodexCont(req) case strings.HasSuffix(path, "/plugins/cpa-key-policy-plus/keys"): return adminKeys(req) + case strings.HasSuffix(path, "/plugins/cpa-key-policy-plus/models"): + return adminModels(req) case strings.HasSuffix(path, "/plugins/cpa-key-policy-plus/keys/create"): return adminCreateKey(req) case strings.HasSuffix(path, "/plugins/cpa-key-policy-plus/keys/save"): @@ -997,6 +1006,22 @@ func managementHandle(raw []byte) ([]byte, error) { return adminEvents(req) case strings.HasSuffix(path, "/plugins/cpa-key-policy-plus/codexcont"): return adminCodexCont(req) + case strings.HasSuffix(path, "/key-policy-plus/api/keys"): + return adminKeys(req) + case strings.HasSuffix(path, "/key-policy-plus/api/models"): + return adminModels(req) + case strings.HasSuffix(path, "/key-policy-plus/api/keys/create"): + return adminCreateKey(req) + case strings.HasSuffix(path, "/key-policy-plus/api/keys/save"): + return adminSaveKeys(req) + case strings.HasSuffix(path, "/key-policy-plus/api/keys/limits"): + return adminSetLimits(req) + case strings.HasSuffix(path, "/key-policy-plus/api/keys/reset"): + return adminReset(req) + case strings.HasSuffix(path, "/key-policy-plus/api/events"): + return adminEvents(req) + case strings.HasSuffix(path, "/key-policy-plus/api/codexcont"): + return adminCodexCont(req) default: return jsonResponse(http.StatusNotFound, map[string]any{"ok": false, "error": "not_found"}) } @@ -1025,11 +1050,96 @@ func adminKeys(_ managementRequest) ([]byte, error) { return jsonResponse(http.StatusOK, map[string]any{"ok": true, "keys": safe, "codexcont": codexcontStatus()}) } +func adminModels(_ managementRequest) ([]byte, error) { + models, warnings := adminModelCatalog() + return jsonResponse(http.StatusOK, map[string]any{"ok": true, "models": models, "warnings": warnings}) +} + +func adminModelCatalog() ([]policyplus.ModelOption, []string) { + warnings := []string{} + hostModels, hostWarnings := hostAuthModelHints() + warnings = append(warnings, hostWarnings...) + configured := configuredModelOptions() + models := policyplus.MergeModelOptions(hostModels, configured) + if len(models) == 0 { + warnings = append(warnings, "当前没有从 CPA 或 Plus 配置中发现模型;可以先创建允许全部模型的 Key,或在编辑模型时手动输入模型名。") + } + sort.SliceStable(models, func(i, j int) bool { + if models[i].Known != models[j].Known { + return models[i].Known + } + return strings.ToLower(models[i].ID) < strings.ToLower(models[j].ID) + }) + return models, warnings +} + +func configuredModelOptions() []policyplus.ModelOption { + store := loadedStore() + if store == nil { + return nil + } + keys, err := store.ListKeys(context.Background()) + if err != nil { + return nil + } + ids := []string{} + for _, key := range keys { + ids = append(ids, key.Models...) + for model := range key.Prices { + ids = append(ids, model) + } + } + return policyplus.ModelOptionsFromIDs(cleanStrings(ids), "plus_configured", false) +} + +func hostAuthModelHints() ([]policyplus.ModelOption, []string) { + result, err := callHost(methodHostAuthList, map[string]any{}) + if err != nil { + return nil, []string{"宿主 auth 列表不可用,已使用 Plus 当前配置模型兜底。"} + } + var body struct { + Files []map[string]any `json:"files"` + } + if err := json.Unmarshal(result, &body); err != nil { + return nil, []string{"宿主 auth 列表格式无法解析,已使用 Plus 当前配置模型兜底。"} + } + ids := []string{} + for _, file := range body.Files { + for _, field := range []string{"models", "available_models", "model_aliases"} { + ids = append(ids, modelIDsFromAny(file[field])...) + } + } + return policyplus.ModelOptionsFromIDs(cleanStrings(ids), "host_auth", true), nil +} + +func modelIDsFromAny(raw any) []string { + options := policyplus.NormalizeModelOptions(raw, "host_auth") + out := make([]string, 0, len(options)) + for _, option := range options { + out = append(out, option.ID) + } + return out +} + func adminCreateKey(req managementRequest) ([]byte, error) { var body struct { - Name string `json:"name"` + Name string `json:"name"` + Enabled *bool `json:"enabled"` + RPM int `json:"rpm"` + Concurrency int `json:"concurrency"` + MaxActiveSessions int `json:"max_active_sessions"` + Models []string `json:"models"` + Prices map[string]policyplus.ModelPrice `json:"prices"` + FiveHourUSD *float64 `json:"five_hour_usd"` + DailyUSD *float64 `json:"daily_usd"` + WeeklyUSD *float64 `json:"weekly_usd"` + MonthlyUSD *float64 `json:"monthly_usd"` + } + if len(req.Body) > 0 { + if err := json.Unmarshal(req.Body, &body); err != nil { + return jsonResponse(http.StatusBadRequest, map[string]any{"ok": false, "error": "invalid_json"}) + } } - _ = json.Unmarshal(req.Body, &body) rawKey, err := generateCPAKey() if err != nil { return jsonResponse(http.StatusInternalServerError, map[string]any{"ok": false, "error": "key_generation_failed"}) @@ -1039,6 +1149,22 @@ func adminCreateKey(req managementRequest) ([]byte, error) { if name == "" { name = "new key" } + enabled := true + if body.Enabled != nil { + enabled = *body.Enabled + } + rpm := body.RPM + if rpm == 0 { + rpm = 60 + } + concurrency := body.Concurrency + if concurrency == 0 { + concurrency = 2 + } + maxActiveSessions := body.MaxActiveSessions + if maxActiveSessions == 0 { + maxActiveSessions = 2 + } store := loadedStore() if store == nil { return jsonResponse(http.StatusServiceUnavailable, map[string]any{"ok": false, "error": "store_unavailable"}) @@ -1047,16 +1173,17 @@ func adminCreateKey(req managementRequest) ([]byte, error) { ID: "key_" + policyplus.HashPreview(hash), Name: name, KeyHash: "sha256:" + hash, - Enabled: true, + Enabled: enabled, Preview: policyplus.HashPreview(hash), - RPM: 60, - Concurrency: 2, - MaxActiveSessions: 2, - Models: []string{}, - FiveHourUSD: nil, - DailyLimitUSD: nil, - WeeklyLimitUSD: nil, - MonthlyLimitUSD: nil, + RPM: rpm, + Concurrency: concurrency, + MaxActiveSessions: maxActiveSessions, + Models: cleanStrings(body.Models), + Prices: cleanPrices(body.Prices), + FiveHourUSD: body.FiveHourUSD, + DailyLimitUSD: body.DailyUSD, + WeeklyLimitUSD: body.WeeklyUSD, + MonthlyLimitUSD: body.MonthlyUSD, } if err := store.UpsertKey(context.Background(), key); err != nil { return jsonResponse(http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()}) @@ -1120,7 +1247,7 @@ func adminSaveKeys(req managementRequest) ([]byte, error) { key.MaxActiveSessions = item.MaxActiveSessions key.Models = cleanStrings(item.Models) if item.Prices != nil { - key.Prices = item.Prices + key.Prices = cleanPrices(item.Prices) } key.FiveHourUSD = item.FiveHourUSD key.DailyLimitUSD = item.DailyUSD @@ -1133,6 +1260,25 @@ func adminSaveKeys(req managementRequest) ([]byte, error) { return adminKeys(req) } +func cleanPrices(items map[string]policyplus.ModelPrice) map[string]policyplus.ModelPrice { + if items == nil { + return nil + } + out := map[string]policyplus.ModelPrice{} + for name, price := range items { + model := strings.TrimSpace(price.Model) + if model == "" { + model = strings.TrimSpace(name) + } + if model == "" { + continue + } + price.Model = model + out[model] = price + } + return out +} + func cleanStrings(items []string) []string { seen := map[string]bool{} out := make([]string, 0, len(items)) diff --git a/cpa_key_policy_plus_plugin/go/main_test.go b/cpa_key_policy_plus_plugin/go/main_test.go index b5ea80e..556fa6f 100644 --- a/cpa_key_policy_plus_plugin/go/main_test.go +++ b/cpa_key_policy_plus_plugin/go/main_test.go @@ -129,11 +129,14 @@ func TestAdminSaveKeysPersistsUnifiedLimits(t *testing.T) { "rpm": 5, "concurrency": 1, "max_active_sessions": 3, - "models": []string{"gpt-5.4"}, - "five_hour_usd": 1.25, - "daily_usd": 2.5, - "weekly_usd": 7.5, - "monthly_usd": 20.0, + "models": []string{"gpt-5.4", "custom-unknown"}, + "prices": map[string]any{ + "custom-unknown": map[string]any{"input_per_million": 1.2}, + }, + "five_hour_usd": 1.25, + "daily_usd": 2.5, + "weekly_usd": 7.5, + "monthly_usd": 20.0, }}} rawBody, _ := json.Marshal(body) raw, err := adminSaveKeys(managementRequest{Body: rawBody}) @@ -156,11 +159,17 @@ func TestAdminSaveKeysPersistsUnifiedLimits(t *testing.T) { if got.FiveHourUSD == nil || *got.FiveHourUSD != 1.25 || got.MonthlyLimitUSD == nil || *got.MonthlyLimitUSD != 20 { t.Fatalf("limits not persisted: %#v", got) } + if len(got.Models) != 2 || got.Models[1] != "custom-unknown" { + t.Fatalf("models should preserve unknown selected model: %#v", got.Models) + } + if price, ok := got.Prices["custom-unknown"]; !ok || price.Model != "custom-unknown" || price.InputPerMillion != 1.2 { + t.Fatalf("custom price not normalized: %#v", got.Prices) + } } func TestAdminCreateKeyReturnsRawKeyOnlyOnce(t *testing.T) { setupTestState(t) - raw, err := adminCreateKey(managementRequest{Body: []byte(`{"name":"Bob"}`)}) + raw, err := adminCreateKey(managementRequest{Body: []byte(`{"name":"Bob","enabled":false,"rpm":9,"concurrency":4,"max_active_sessions":3,"models":["gpt-5.4-mini"]}`)}) if err != nil { t.Fatal(err) } @@ -178,6 +187,96 @@ func TestAdminCreateKeyReturnsRawKeyOnlyOnce(t *testing.T) { if strings.Contains(string(bodyBytes), policyplus.SHA256Hex(resp.RawKey)) { t.Fatalf("response leaked full hash: %s", bodyBytes) } + keys, err := loadedStore().ListKeys(context.Background()) + if err != nil { + t.Fatal(err) + } + var created policyplus.KeyRecord + for _, key := range keys { + if key.Name == "Bob" { + created = key + break + } + } + if created.ID == "" || created.Enabled || created.RPM != 9 || created.Concurrency != 4 || created.MaxActiveSessions != 3 { + t.Fatalf("created key did not persist requested settings: %#v", created) + } + if len(created.Models) != 1 || created.Models[0] != "gpt-5.4-mini" { + t.Fatalf("created models = %#v", created.Models) + } +} + +func TestManagementAliasCreateSaveAndReset(t *testing.T) { + setupTestState(t) + createRaw, _ := json.Marshal(map[string]any{"name": "Alias", "models": []string{"gpt-5.4"}}) + raw, err := managementHandle(mustJSON(t, managementRequest{ + Method: http.MethodPost, + Path: "/key-policy-plus/api/keys/create", + Body: createRaw, + })) + if err != nil { + t.Fatal(err) + } + createBody := decodeManagementBody(t, raw) + if !strings.Contains(string(createBody), `"raw_key"`) { + t.Fatalf("create via alias failed: %s", createBody) + } + saveRaw, _ := json.Marshal(map[string]any{"keys": []map[string]any{{ + "id": "alice-key", + "name": "Alias Saved", + "enabled": true, + "rpm": 11, + "concurrency": 2, + "max_active_sessions": 1, + "models": []string{"gpt-5.5"}, + }}}) + raw, err = managementHandle(mustJSON(t, managementRequest{ + Method: http.MethodPut, + Path: "/key-policy-plus/api/keys/save", + Body: saveRaw, + })) + if err != nil { + t.Fatal(err) + } + saveBody := decodeManagementBody(t, raw) + if !strings.Contains(string(saveBody), "Alias Saved") { + t.Fatalf("save via alias failed: %s", saveBody) + } + resetRaw, _ := json.Marshal(map[string]any{"id": "alice-key", "window": "5h"}) + raw, err = managementHandle(mustJSON(t, managementRequest{ + Method: http.MethodPost, + Path: "/key-policy-plus/api/keys/reset", + Body: resetRaw, + })) + if err != nil { + t.Fatal(err) + } + resetBody := decodeManagementBody(t, raw) + if !strings.Contains(string(resetBody), `"ok":true`) { + t.Fatalf("reset via alias failed: %s", resetBody) + } +} + +func TestAdminModelsFallsBackToConfiguredModels(t *testing.T) { + key := setupTestState(t) + key.Models = []string{"gpt-5.5", "legacy-custom"} + key.Prices = map[string]policyplus.ModelPrice{ + "price-only": {Model: "price-only", InputPerMillion: 1}, + } + if err := loadedStore().SaveKeySettings(context.Background(), key); err != nil { + t.Fatal(err) + } + raw, err := adminModels(managementRequest{}) + if err != nil { + t.Fatal(err) + } + body := decodeManagementBody(t, raw) + text := string(body) + for _, want := range []string{"gpt-5.5", "legacy-custom", "price-only"} { + if !strings.Contains(text, want) { + t.Fatalf("model catalog missing %s: %s", want, text) + } + } } func TestUserHTMLUsesPolicyPlusHeader(t *testing.T) { @@ -198,6 +297,12 @@ func TestAdminHTMLHasRenderedSharedCSS(t *testing.T) { if !strings.Contains(html, "--panel") || !strings.Contains(html, "CPA Key Policy+") { t.Fatal("admin html should embed shared style and key policy UI") } + if strings.Contains(html, "/v0/resource/plugins/cpa-key-policy-plus/admin/api") { + t.Fatal("admin html must not send mutating requests through GET-only resource routes") + } + if !strings.Contains(html, "/key-policy-plus/api") || !strings.Contains(html, "编辑模型/价格") { + t.Fatal("admin html should use the management alias and structured model editor") + } } func TestSessionInvalidatedWhenKeyHashChanges(t *testing.T) { @@ -262,6 +367,15 @@ func decodeManagementBody(t *testing.T, raw []byte) []byte { return resp.Body } +func mustJSON(t *testing.T, value any) []byte { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return raw +} + func TestMain(m *testing.M) { os.Exit(m.Run()) } diff --git a/cpa_key_policy_plus_plugin/go/plugin_types.go b/cpa_key_policy_plus_plugin/go/plugin_types.go index 991e717..196ae92 100644 --- a/cpa_key_policy_plus_plugin/go/plugin_types.go +++ b/cpa_key_policy_plus_plugin/go/plugin_types.go @@ -28,6 +28,7 @@ const ( methodHostModelStreamClose = "host.model.stream_close" methodHostStreamEmit = "host.stream.emit" methodHostStreamClose = "host.stream.close" + methodHostAuthList = "host.auth.list" ) const ( From ce12c1f8159dbd4a80f2356e39704b8df08ad129 Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Thu, 2 Jul 2026 21:28:41 +0800 Subject: [PATCH 41/68] chore(task): archive 07-02-cpa-key-policy-plus-admin-fixes --- .../check.jsonl | 1 + .../design.md | 102 ++++++++++++++++++ .../implement.jsonl | 1 + .../implement.md | 81 ++++++++++++++ .../prd.md | 44 ++++++++ .../task.json | 26 +++++ 6 files changed, 255 insertions(+) create mode 100644 .trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-admin-fixes/check.jsonl create mode 100644 .trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-admin-fixes/design.md create mode 100644 .trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-admin-fixes/implement.jsonl create mode 100644 .trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-admin-fixes/implement.md create mode 100644 .trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-admin-fixes/prd.md create mode 100644 .trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-admin-fixes/task.json diff --git a/.trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-admin-fixes/check.jsonl b/.trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-admin-fixes/check.jsonl new file mode 100644 index 0000000..9cd59d4 --- /dev/null +++ b/.trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-admin-fixes/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-admin-fixes/design.md b/.trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-admin-fixes/design.md new file mode 100644 index 0000000..6f314bc --- /dev/null +++ b/.trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-admin-fixes/design.md @@ -0,0 +1,102 @@ +# Design + +## Boundaries + +This task modifies only the self-owned `cpa_key_policy_plus_plugin` and any deployment/admin-proxy route notes required for `/key-policy-plus/api/*`. CPA, CPAMP, and old Key Policy upstream code remain untouched. + +The Plus admin HTML remains a CPA plugin resource because CPAMP can render plugin resources from the left menu. Resource routes are safe for HTML and read-only GETs only. Mutations use CPA plugin management routes. + +## Admin API Transport + +The browser uses a single API base resolver: + +1. Prefer `/key-policy-plus/api` on `cpa-admin.konbakuyomu.us`. +2. In local preview/test, support an explicit `window.CPA_KEY_POLICY_PLUS_API_BASE` override. +3. Do not fall back to mutating `/v0/resource/plugins/cpa-key-policy-plus/admin/api`. + +The admin proxy must map: + +```text +/key-policy-plus/api/* -> /v0/management/plugins/cpa-key-policy-plus/* +``` + +The plugin already owns management routes such as `GET /plugins/cpa-key-policy-plus/keys`, `POST /plugins/cpa-key-policy-plus/keys/create`, `PUT /plugins/cpa-key-policy-plus/keys/save`, and `POST /plugins/cpa-key-policy-plus/keys/reset`. + +## Model Discovery + +Add a management endpoint: + +```text +GET /plugins/cpa-key-policy-plus/models +``` + +The endpoint returns a safe normalized shape: + +```json +{ + "models": [ + {"id": "gpt-5.5", "source": "configured", "known": true} + ], + "warnings": [] +} +``` + +Model sources are merged with stable de-duplication: + +- optional configured static/default model catalog from plugin config, +- current key allowlist union from Plus SQLite, +- preserved unknown models from existing keys. + +If CPA management model discovery is wired through the admin proxy in a future step, the frontend can merge those returned models with this endpoint without changing persistence. For this task, Plus must at least stop hard-coding a tiny model list and must not delete configured unknowns. + +## Admin UI Data Flow + +```text +Plus store -> /keys -> admin state +Plus model projection -> /models -> model selector state +admin edits -> normalized payload -> /keys/save +create dialog -> /keys/create -> one-time raw key modal -> reload keys +reset button -> /keys/reset -> reload keys +``` + +The table owns a draft copy of keys. Rendering formats the draft only; validation and normalization happen before sending to the API. + +## Model And Price Editing + +Main table columns show summary fields instead of long text: + +- model count and unknown count, +- price coverage `priced/selected`, +- `编辑模型/价格` action. + +The modal edits one selected key at a time. It contains: + +- search box, +- all visible/clear buttons, +- selected model chips, +- discovered model list with checkboxes, +- unknown configured model notice, +- structured price rows for selected models. + +Price units are fixed to USD per 1M tokens: + +- input, +- output, +- cache read, +- cache write/creation. + +Unknown selected models stay selected and writable. Saving never removes a model unless the admin explicitly unchecks/removes it. + +## Security + +Raw key material appears only in the successful-create response modal. The UI does not persist it in local storage or write it into URLs. API responses must keep existing safe key previews and must not add full hashes or secrets. + +## Compatibility + +Existing API payload fields are preserved where possible (`models`, `prices`, `limits`, `rpm`, `concurrency`, `max_active_sessions`). The UI may send both structured `model_prices` and compatibility `prices` shapes if the backend already expects one of them. + +If `/models` fails, the UI falls back to the union of models already present in loaded keys and displays a warning instead of blocking all edits. + +## Rollout And Rollback + +Rollout is plugin-only plus a small admin-proxy alias. Back up the old `.so`, Plus SQLite DB, and admin proxy route file before replacing the plugin. Roll back by restoring the previous `.so` and proxy route, then restarting/reloading only the affected services. diff --git a/.trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-admin-fixes/implement.jsonl b/.trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-admin-fixes/implement.jsonl new file mode 100644 index 0000000..9cd59d4 --- /dev/null +++ b/.trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-admin-fixes/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-admin-fixes/implement.md b/.trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-admin-fixes/implement.md new file mode 100644 index 0000000..f9c1f84 --- /dev/null +++ b/.trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-admin-fixes/implement.md @@ -0,0 +1,81 @@ +# Implementation Plan + +## Checklist + +1. Start the Trellis task after these artifacts are written. +2. Inspect the existing Plus store/API payloads and tests. +3. Add or refine backend management handlers: + - `/plugins/cpa-key-policy-plus/models`, + - safe model normalization and key-union fallback, + - transport tests for create/save/reset through management routes. +4. Rework `assets/admin.html`: + - remove raw model/price textarea workflow, + - add create dialog, + - add model/price modal, + - resolve API base to `/key-policy-plus/api`, + - keep no-store and secret-safe behavior. +5. Add tests: + - Go backend tests for model projection and management writes, + - HTML static tests proving no mutating ResourceRoute fallback, + - JS syntax check helper command. +6. Run local validation. +7. Build linux/amd64 plugin artifact only if local validation passes. +8. Prepare server rollout notes: + - backup old `.so`, Plus SQLite, CPA config, admin proxy config, + - add `/key-policy-plus/api/*` admin-proxy alias, + - restart/reload only CPA/admin proxy as needed, + - verify new key creation and public/admin route boundaries. + +## Validation Commands + +```powershell +go test ./... +node --check +git diff --check +``` + +If the repo-level Python tests are affected or touched, also run: + +```powershell +.venv\Scripts\python.exe tests\test_middleware.py +.venv\Scripts\python.exe tests\test_cpa_usage_portal.py +.venv\Scripts\python.exe -m compileall middleware cpa_usage_portal run.py run_usage_portal.py +``` + +## Risk Points + +- ResourceRoute remains GET-only; any accidental `POST`/`PUT` fallback to `/v0/resource/plugins/...` will recreate the original failure. +- Existing configured models must be preserved when the current discovery source returns an empty or stale list. +- The full generated key is unrecoverable after the create modal closes; do not imply it can be re-read. +- Do not leak management keys, raw user keys, full hashes, or auth-file details while adding model discovery. + +## Rollback Points + +- Before deployment: keep the previous plugin `.so` and admin proxy route file. +- If create/save fails after deploy: restore old `.so`, remove or bypass `/key-policy-plus/api/*` alias, restart CPA/admin proxy. +- If public routes expose admin/plugin resources: roll back Caddy/admin proxy change first, then investigate plugin behavior. + +## Execution Notes + +- Implemented Plus admin model catalog, safe model option normalization, structured create modal, and structured model/price editor. +- Fixed local preview body forwarding so Playwright can exercise real create/save requests. +- Added `/key-policy-plus/api/*` management alias support and server admin-proxy route. Production route injects the CPA management key from the existing mounted secret, not from a committed Caddyfile literal. +- Built linux/amd64 plugin with WSL Go 1.22.6. Deployed SHA256: + `0cc73b598afd9ce109aa27d8bc0522c257dfe93d2f60b34aee481b5906c55b1d`. +- Server backup path: + `/opt/codex-stacks/backups/cpa-key-policy-plus-admin-fixes-20260702-211634`. + +## Validation Results + +- `go test ./...` passed in `cpa_key_policy_plus_plugin/go`. +- Admin HTML inline script `node --check` passed. +- `tests/test_middleware.py`: 163/163 passed. +- `tests/test_cpa_usage_portal.py`: 84/84 passed. +- `python -m compileall middleware cpa_usage_portal run.py run_usage_portal.py` passed. +- Playwright local preview verified create, model selection, price save, and 390px layout without page-level horizontal overflow. +- Server verified: + - CPA logs show `cpa-key-policy-plus` loaded and registered. + - `/key-policy-plus/api/keys` and `/key-policy-plus/api/models` return `200`. + - Disabled smoke key creation through `/key-policy-plus/api/keys/create` returns `200` and persists safe settings. + - Public `cpa.konbakuyomu.us` returns `404` for Plus resource, management, API alias, and `usage-admin` paths. + - Root disk ended at 368 MB free; no Docker prune or official image pull was used. diff --git a/.trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-admin-fixes/prd.md b/.trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-admin-fixes/prd.md new file mode 100644 index 0000000..f3c6566 --- /dev/null +++ b/.trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-admin-fixes/prd.md @@ -0,0 +1,44 @@ +# CPA Key Policy Plus Admin Fixes + +## Goal + +Make `cpa-key-policy-plus` usable as the single admin surface for creating and maintaining `cpa_` user keys. The admin page must create keys reliably, save limits/prices/resets through the correct CPA management API path, and provide a model-selection experience comparable to the old Key Policy/CPAMP flow instead of hard-coded textarea editing. + +## Confirmed Facts + +- CPA plugin `ResourceRoute` is browser-navigable and GET-only in the current CPA host, so `POST`/`PUT` create/save/reset calls sent to `/v0/resource/plugins/...` cannot reach the plugin. +- `cpa-key-policy-plus` already registers management routes for key list/create/save/limits/reset and CodexCont config. +- The current Plus admin UI exposes model allowlists and prices through raw textarea/JSON fields, which makes model selection fragile and hides the registered CPA model list. +- CPA management exposes model-related sources through registered auth-file models and static model definitions; existing key configuration can provide a safe fallback union. + +## Requirements + +- Keep CPA, CPAMP, and the old Key Policy upstream source/images unchanged. +- Fix Plus admin writes by routing all mutating admin actions through a CPA management route or admin-proxy alias, not through GET-only resource routes. +- Preserve the HTML admin resource as a GET page, but make its API base choose the admin management alias first. +- Rework new-key creation so the admin can set name, enabled state, RPM, request concurrency, active Codex window limit, and initial allowed models. +- Show the generated full `cpa_` key exactly once after creation with clear copy guidance; do not store or display raw keys later. +- Replace raw model and price textareas with structured UI: + - model count / price coverage in the main table, + - searchable model selector, + - select visible, clear, and selected-chip interactions, + - per-model input/output/cache-read/cache-write prices in USD per 1M tokens. +- Discover models from CPA management data where possible and fall back to configured key models without deleting unknown existing entries. +- Never return or render OAuth tokens, management keys, raw API keys, full key hashes, Authorization headers, cookies, request bodies, response bodies, or encrypted reasoning content. + +## Acceptance Criteria + +- [ ] Creating a key from the Plus admin page succeeds and the new key appears after reload. +- [ ] Saving limits, model allowlists, prices, and reset watermarks succeeds through the management API path. +- [ ] The admin UI no longer depends on `POST`/`PUT` calls to `/v0/resource/plugins/cpa-key-policy-plus/...`. +- [ ] The model picker can search, select all visible, clear, preserve unknown configured models, and show price coverage. +- [ ] Existing keys with manually configured or no-longer-discovered models are not silently stripped on save. +- [ ] Go tests cover management writes, model normalization/fallback, and admin HTML transport expectations. +- [ ] JS syntax check, `go test ./...`, and `git diff --check` pass locally. + +## Out Of Scope + +- Switching the production `/v1/responses` execution chain. +- Changing CPA/CPAMP/old Key Policy upstream source or official images. +- Retiring `usage-admin` or redesigning the public `cpa-usage` user page in this task. +- Recomputing historical usage or changing CodexCont/Governor behavior. diff --git a/.trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-admin-fixes/task.json b/.trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-admin-fixes/task.json new file mode 100644 index 0000000..eda32b4 --- /dev/null +++ b/.trellis/tasks/archive/2026-07/07-02-cpa-key-policy-plus-admin-fixes/task.json @@ -0,0 +1,26 @@ +{ + "id": "cpa-key-policy-plus-admin-fixes", + "name": "cpa-key-policy-plus-admin-fixes", + "title": "CPA Key Policy Plus admin fixes", + "description": "", + "status": "completed", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "dxt98", + "assignee": "dxt98", + "createdAt": "2026-07-02", + "completedAt": "2026-07-02", + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file From e2082a2598337de581385520b354315c80ad4e9a Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Thu, 2 Jul 2026 21:29:12 +0800 Subject: [PATCH 42/68] chore: record journal --- .trellis/workspace/dxt98/index.md | 5 ++-- .trellis/workspace/dxt98/journal-1.md | 33 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/.trellis/workspace/dxt98/index.md b/.trellis/workspace/dxt98/index.md index 804ee30..7797318 100644 --- a/.trellis/workspace/dxt98/index.md +++ b/.trellis/workspace/dxt98/index.md @@ -8,7 +8,7 @@ - **Active File**: `journal-1.md` -- **Total Sessions**: 7 +- **Total Sessions**: 8 - **Last Active**: 2026-07-02 @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~245 | Active | +| `journal-1.md` | ~278 | Active | --- @@ -29,6 +29,7 @@ | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 8 | 2026-07-02 | CPA Key Policy Plus admin fixes | `4c6613b` | `main` | | 7 | 2026-07-02 | CPA Key Policy Plus cutover | `160af51` | `main` | | 6 | 2026-07-02 | CPA Governor and CodexCont engine rollout | `429bed5`, `d9047c8`, `50e77d0`, `69e429e` | `main` | | 5 | 2026-07-02 | CPA usage request detail closeout | `432ca38` | `main` | diff --git a/.trellis/workspace/dxt98/journal-1.md b/.trellis/workspace/dxt98/journal-1.md index ffe3d54..4bd566f 100644 --- a/.trellis/workspace/dxt98/journal-1.md +++ b/.trellis/workspace/dxt98/journal-1.md @@ -243,3 +243,36 @@ Implemented and deployed cpa-key-policy-plus as the unified cpa_ key authority, ### Next Steps - None - task complete + + +## Session 8: CPA Key Policy Plus admin fixes + +**Date**: 2026-07-02 +**Task**: CPA Key Policy Plus admin fixes +**Branch**: `main` + +### Summary + +Fixed Key Policy+ admin create/save/reset transport, added model discovery and structured model/price editing, deployed the linux/amd64 plugin to SJC, added admin proxy management alias, and verified local/server smoke tests. + +### Main Changes + +(Add details) + +### Git Commits + +| Hash | Message | +|------|---------| +| `4c6613b` | (see git log) | + +### Testing + +- [OK] (Add test results) + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete From 57aadff799a6d53d6594368e7bd028cd44aa930a Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Thu, 2 Jul 2026 22:57:21 +0800 Subject: [PATCH 43/68] fix: stabilize CPA Key Policy Plus UX --- .../check.jsonl | 1 + .../design.md | 175 +++ .../implement.jsonl | 1 + .../implement.md | 203 +++ .../prd.md | 86 ++ .../task.json | 26 + .../go/assets/admin.html | 1094 +++++++++-------- .../go/assets/shared.css | 9 + .../go/assets/user.html | 70 +- .../go/internal/policyplus/models.go | 6 + .../go/internal/policyplus/policyplus_test.go | 39 + .../go/internal/policyplus/store.go | 41 +- cpa_key_policy_plus_plugin/go/main.go | 144 ++- cpa_key_policy_plus_plugin/go/main_test.go | 69 ++ 14 files changed, 1423 insertions(+), 541 deletions(-) create mode 100644 .trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/check.jsonl create mode 100644 .trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/design.md create mode 100644 .trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/implement.jsonl create mode 100644 .trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/implement.md create mode 100644 .trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/prd.md create mode 100644 .trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/task.json diff --git a/.trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/check.jsonl b/.trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/check.jsonl new file mode 100644 index 0000000..9cd59d4 --- /dev/null +++ b/.trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/design.md b/.trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/design.md new file mode 100644 index 0000000..bb3bc55 --- /dev/null +++ b/.trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/design.md @@ -0,0 +1,175 @@ +# Design + +## Boundaries + +This task changes only the self-owned `cpa-key-policy-plus` plugin and narrow proxy routing when needed. + +- Plugin backend: `cpa_key_policy_plus_plugin/go/main.go` +- Plugin store/models: `cpa_key_policy_plus_plugin/go/internal/policyplus/**` +- Plugin UI: `cpa_key_policy_plus_plugin/go/assets/admin.html`, `user.html`, `shared.css` +- Tests: `cpa_key_policy_plus_plugin/go/**/*_test.go` +- Optional admin/public proxy routing if a path bug is proven + +Do not modify CPA, CPAMP, old Key Policy, or their images/source. + +## Current Problems By Layer + +### Public `cpa-usage` + +The public route itself is alive. Caddy rewrites `/` to the Plus user resource and the resource API returns valid JSON status responses. The observed `连接异常 / 同步失败` state is therefore more likely a UI/session/retry issue than a dead proxy route. + +Current frontend behavior couples several fetches into one refresh: + +- `/me` +- `/usage` +- `/events` +- `/codexcont` + +Any one failure puts the topbar into `连接异常`. The UI does not visibly classify `401 not_authenticated` as session expiry, and a bad poll can leave the user with an empty table and generic sync failure. + +Design response: + +- Treat `401 not_authenticated` as `会话已过期,请重新登录`, not generic network failure. +- Keep last known good data when a non-auth poll fails. +- Split refresh state into per-domain health: + - session/account + - usage/events + - CodexCont protection +- The topbar can show an aggregate state, but the content area must show which section failed. +- Manual refresh must cancel older requests, start a new sequence, and ensure stale failed responses cannot overwrite a newer successful snapshot. +- Page visibility restore should always trigger a fresh snapshot if logged in. + +### Admin `CPA Key Policy+` + +The current admin table is doing too much in one row. It has no visual difference between "configured value" and "actual usage", and the row overflows into ellipsis artifacts. + +Design response: + +Use a master/detail layout. + +Desktop: + +- Left/main list: one row per key, compact and mostly read-only. +- Right detail drawer/panel: editable settings for the selected key. + +Mobile: + +- Key list becomes stacked rows. +- Detail editor opens as a full-width panel/modal. + +Key list columns: + +- Key: name, safe preview, status chip. +- Health: enabled/disabled/archived, active sessions. +- Limits: 5H/24H/7D/month mini progress summaries. +- Controls: RPM, request concurrency, Codex windows as compact text, not inline inputs. +- Models: count and price coverage. +- Usage: selected range cost and 24H quick hint. +- Actions: edit, reset, archive. + +Detail editor sections: + +- Basic settings: name, enabled, RPM, request concurrency, Codex active windows. +- Quota windows: 5H/24H/7D/month each shows `used / limit / remaining` and an editable limit field. +- Models and prices: reuse the existing model editor modal and structured price table. +- Usage reset: soft reset per window. +- Lifecycle: archive/restore; optional hard delete in danger zone depending on product decision. + +### Key Lifecycle + +Hard deleting a key row is simple but risky because usage events, Codex summaries, reset watermarks, active sessions, and audit logs may reference the key ID. The better default is to add soft archival. + +Recommended store extension: + +- Add nullable-ish columns to `keys`: + - `archived integer default 0` + - `archived_at integer default 0` +- `ListKeys` returns all keys for admin by default, with UI filter `显示归档`. +- Frontend auth treats `archived` like disabled: cannot authenticate/use. +- User portal cannot log in with archived keys. +- Usage/history remains visible to admin. + +Optional hard delete: + +- Implement as advanced route only after archive exists. +- Either reject if usage exists, or require deleting only the key row while preserving historical events with safe preview. The safer v1 is "reject if usage exists". + +## API Changes + +Admin management routes: + +- `GET /plugins/cpa-key-policy-plus/keys` + - return keys with `usage`, `limits`, `active_sessions`, `archived`, and `remaining` projections. +- `PUT /plugins/cpa-key-policy-plus/keys/save` + - accept `archived` if lifecycle is included. +- `POST /plugins/cpa-key-policy-plus/keys/archive` + - `{ id, archived: true|false }` +- Optional: + - `DELETE /plugins/cpa-key-policy-plus/keys` + - `{ id, confirm }`, reject when usage/history exists unless explicitly allowed by later decision. + +User resource API: + +- Existing paths stay stable: + - `/user/api/session` + - `/user/api/me` + - `/user/api/usage` + - `/user/api/events` + - `/user/api/codexcont` +- Error responses should include safe `error`, `message`, and a category usable by frontend: + - `auth` + - `network` + - `usage` + - `codexcont` + - `unknown` + +## UI Preview Plan + +Create local previews with seeded data before production deploy. + +Variant A, recommended: + +- CPAMP-like key list plus right detail drawer. +- Best for many keys and dense operator work. + +Variant B: + +- Key cards plus full-width detail panel below selected card. +- More readable for small key counts, less dense for admin use. + +Variant C: + +- Two-level table: collapsed key rows with expandable editor rows. +- Closest to current table, but still risks cramped layouts and row height jumps. + +Recommendation: implement Variant A, with mobile fallback behaving like Variant B. + +Preview mechanics: + +- Extend existing preview mode or add a small dev-only preview fixture endpoint. +- Seed several keys: + - enabled normal key with meaningful usage + - disabled key + - archived key + - key with no limit + - key with incomplete prices +- Seed usage events and CodexCont summaries so progress bars and details are visible. + +## Compatibility + +- Existing keys must remain usable. +- Existing SQLite DB must migrate forward with `alter table` column checks only. +- Existing `cpa_` sessions remain valid unless the session secret changed or the key hash/ID changed. +- Existing old records without new lifecycle fields display as active/non-archived. +- Current model discovery behavior remains: + - CPA/host model hints first + - Plus configured models as fallback + - unknown selected models preserved + +## Operational Notes + +- The old `cpa-usage-portal` container is still running but not on the current public route. After this task verifies Plus user page fully replaces it, we can plan a separate stop/retire action. Do not delete its files in this task. +- Server deploy should back up the current `.so`, Plus SQLite DB, CPA config, and proxy config. +- Do not run Docker prune. +- Do not batch-delete files/directories. + diff --git a/.trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/implement.jsonl b/.trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/implement.jsonl new file mode 100644 index 0000000..9cd59d4 --- /dev/null +++ b/.trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/implement.md b/.trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/implement.md new file mode 100644 index 0000000..e44b7df --- /dev/null +++ b/.trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/implement.md @@ -0,0 +1,203 @@ +# Implementation Plan + +## Phase 0: Planning Gate + +- [ ] Review this PRD/design/implementation plan with the user. +- [ ] Resolve the key lifecycle decision: + - recommended: archive/restore in v1, hard delete only as constrained danger action. +- [ ] Do not run `task.py start` until the user approves implementation. + +## Phase 1: Evidence And Reproduction + +- [ ] Add or run local Plus preview with seeded admin/user data. +- [ ] Use Playwright to capture current admin/user screenshots: + - desktop + - 390px mobile +- [ ] Reproduce public `cpa-usage` stuck state locally by mocking: + - `/codexcont` failure while `/usage` succeeds + - `401 not_authenticated` + - slow request followed by newer successful request +- [ ] Record findings in this task before changing code. + +## Phase 2: Backend Support + +- [ ] Add key lifecycle fields to `KeyRecord` and SQLite schema: + - `archived` + - `archived_at` +- [ ] Ensure `FrontendAuthProvider` rejects archived keys with a clear safe message. +- [ ] Add store methods: + - `SetArchived(ctx, id, archived, at)` + - optional `DeleteKeyIfUnused(ctx, id)` if hard delete is approved. +- [ ] Enrich admin key projection: + - used values for 5H/24H/7D/month + - limit values + - remaining values when limit exists + - active sessions + - price coverage + - archived state +- [ ] Add admin route(s): + - `/plugins/cpa-key-policy-plus/keys/archive` + - matching `/key-policy-plus/api/keys/archive` alias + - optional delete route only if approved. +- [ ] Keep raw key/hash/body/cookie data out of all responses. + +## Phase 3: Public User Page Stability + +- [ ] Refactor refresh pipeline: + - keep last good data + - isolate auth errors from section errors + - never let stale failed requests overwrite newer snapshots + - manual refresh always clears stuck `syncing` +- [ ] Show clear state messages: + - `实时已连接` + - `部分数据同步失败` + - `会话已过期` + - `正在同步` +- [ ] Add section-level fallback cards for usage/protection failures. +- [ ] Preserve current sorting newest-first and stale processing filtering. + +## Phase 4: Admin UI Redesign + +- [ ] Replace the main inline edit table with master/detail layout. +- [ ] Key list shows safe summary fields only. +- [ ] Detail editor shows editable settings and quota progress. +- [ ] Reuse existing model/price editor with layout polish. +- [ ] Add archive/restore action and confirmation. +- [ ] Add optional hard delete danger action if approved. +- [ ] Remove meaningless overflow/ellipsis artifacts. +- [ ] Keep CPAMP-like dark style and avoid flashy effects. + +## Phase 5: Local Preview Variants + +- [ ] Produce preview Variant A: list + right detail drawer. +- [ ] Produce preview Variant B: cards + full-width detail panel. +- [ ] If needed, produce Variant C: expandable key rows. +- [ ] Capture desktop and 390px screenshots. +- [ ] Compare with user before final server deployment if the user wants to choose visually. + +## Phase 6: Validation + +Run from `cpa_key_policy_plus_plugin/go`: + +```powershell +go test ./... +``` + +Run JS checks from repo root by extracting inline scripts or using existing helper pattern: + +```powershell +node --check +node --check +``` + +Run repo checks: + +```powershell +git diff --check +``` + +Playwright checks: + +- admin desktop layout: no horizontal body overflow, no short-field vertical text +- admin 390px layout: detail editor usable +- user page: login state, refresh recovery, session-expired message +- mocked delayed/failing endpoint: page does not stick in `同步中` +- newest-first event/protection ordering remains correct + +## Phase 7: Server Deployment + +- [ ] Check disk space. +- [ ] Back up: + - current `cpa-key-policy-plus.so` + - Plus SQLite DB + - CPA config + - Caddy/admin proxy config +- [ ] Build linux/amd64 `.so` and record SHA256. +- [ ] Upload only the new `.so` and proxy config if changed. +- [ ] Restart only necessary services. +- [ ] Verify: + - `https://cpa-usage.konbakuyomu.us/` login with test key + - user page realtime polling recovers after tab hidden/visible + - `CPA Key Policy+` admin layout and lifecycle actions work + - `cpa.konbakuyomu.us` still blocks management/plugin/resource paths + - old Python `usage-admin` is not required by the public route + +## Risk And Rollback + +- Risk: DB migration adds columns incorrectly. + - Rollback: restore Plus SQLite DB backup and previous `.so`. +- Risk: frontend auth accidentally allows archived keys. + - Mitigation: backend tests for archived key rejection. +- Risk: public page session handling regresses. + - Mitigation: tests for valid session, expired session, and stale request race. +- Risk: hard delete breaks historical usage joins. + - Mitigation: prefer archive-first; reject hard delete when usage exists if implemented. + +## Execution Evidence + +- Implemented the confirmed lifecycle policy: Key Policy+ now supports + archive/restore instead of hard delete. Archived keys are hidden by default, + cannot log in to the user page, and cannot authenticate frontend requests. +- Migrated Plus SQLite schema with `keys.archived` and `keys.archived_at`, and + preserved archive state across legacy state imports so old Key Policy sync + cannot accidentally unarchive a Plus-managed key. +- Rebuilt the admin UI as a master/detail page: + - key list shows safe preview, status, active sessions, RPM, request + concurrency, Codex window limit, four quota windows, model count, and price + coverage; + - right detail panel edits basic policy, four quotas, model/price modal, + soft reset, and archive/restore; + - mobile layout keeps the dense table inside its scroll container and shows + the detail editor as a full-width section. +- Stabilized the user self-service page refresh behavior: + - auth errors return the user to login with `会话已过期`; + - usage/CodexCont partial failures keep last good data and show section-level + notices; + - old refreshes are aborted/ignored, so manual refresh and foreground resume + no longer leave the page stuck in `同步中`. +- Built linux/amd64 plugin artifact with WSL Go 1.22.6: + - `cpa_key_policy_plus_plugin/dist/linux/amd64/cpa-key-policy-plus.so` + - SHA256 `980d61ee7d2753bd9caa48036e804c02060e3a4515ed4b7af2d66ddfaa203b7c` + - `file`: ELF 64-bit x86-64 shared object. +- SJC deployment: + - backup path: + `/opt/codex-stacks/backups/cpa-key-policy-plus-ux-fixes-20260702-225009` + - uploaded only the new `.so`; + - restarted only `cpa`; + - did not pull images and did not run Docker prune. +- Production verification: + - CPA logs show `plugin_id=cpa-key-policy-plus` loaded and registered from + `/CLIProxyAPI/plugins/linux/amd64/cpa-key-policy-plus.so`; + - remote plugin SHA256 matches local artifact; + - admin API `/key-policy-plus/api/keys` returns `200`, `5` keys, and every + key projection includes `archived` and `quota`; + - admin API `/key-policy-plus/api/models` returns `200` with `7` models and + no warnings; + - archive/restore smoke on existing disabled smoke key succeeded and restored + the key to its prior unarchived state; + - Kuma test key login on `https://cpa-usage.konbakuyomu.us/` returns `200`; + `/me`, `/usage`, `/events`, and `/codexcont` all return `200`; + - public `https://cpa.konbakuyomu.us` still returns `404` for Plus resource, + Plus API alias, management, and `usage-admin` paths; + - root disk ended at about `344M` free after removing the single uploaded + `/tmp` artifact. + +## Validation Results + +- `go test ./...` in `cpa_key_policy_plus_plugin/go`: passed. +- Inline JS syntax checks for `assets/admin.html` and `assets/user.html`: + passed. +- `git diff --check`: passed, with only existing CRLF conversion warnings. +- Local Playwright preview: + - admin desktop `1600x1000`: no page overflow, master/detail usable, archive + toggle shows hidden rows, no raw `...` truncation; + - admin mobile `390x900`: no page overflow, detail editor usable, dense table + scroll contained; + - user desktop and mobile: login works, partial `/codexcont` failure keeps + app visible, recovery clears notice, mocked `401` returns to login. +- Production Playwright: + - `https://cpa-usage.konbakuyomu.us/` desktop and `390px` mobile both log in + with the Kuma test key; + - metrics render, `思维链保护` tab switches, no page-level overflow; + - manual refresh returns to `实时已连接` and does not remain stuck in + `同步中`. diff --git a/.trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/prd.md b/.trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/prd.md new file mode 100644 index 0000000..c7aacb3 --- /dev/null +++ b/.trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/prd.md @@ -0,0 +1,86 @@ +# CPA Usage and Key Policy Plus UX fixes + +## Goal + +Make `CPA Key Policy+` and the public `CPA Usage` page understandable, stable, and pleasant enough for day-to-day operation. + +This task fixes the current rough edges without changing CPA, CPAMP, or any third-party plugin source/image. The source of truth stays our self-owned `cpa-key-policy-plus` plugin plus the existing admin/public proxy routing. + +## User Value + +- Admin can manage each `cpa_` key from one clear page without guessing what a cramped table cell means. +- Admin can see configured limits together with actual used/remaining quota before saving or resetting. +- Admin can remove unwanted keys through a safe lifecycle action. +- Ordinary users can open `https://cpa-usage.konbakuyomu.us/`, log in with their key, and see a stable realtime view instead of a stuck `连接异常 / 同步失败` state. +- Future UI changes can be previewed locally before production deploy. + +## Confirmed Facts + +- The current task exists at `.trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/` and is still in `planning`. +- `cpa-usage.konbakuyomu.us` is routed by Caddy to CPA, not the old Python portal: `/` rewrites to `/v0/resource/plugins/cpa-key-policy-plus/user`, and `/v0/resource/plugins/cpa-key-policy-plus/user*` proxies to `cpa:8317`. +- The public user resource is reachable: `/v0/resource/plugins/cpa-key-policy-plus/user/api/me` returns `401 not_authenticated` without a session, and `/user/api/session` returns a structured invalid-key error when probed with a fake key. That points to a frontend/session/retry/data-state bug, not a dead domain. +- The old `cpa-usage-portal` container is still running on the server, but the current public Caddy route does not use it. This is a source of operational confusion. +- `CPA Key Policy+` admin UI is currently one wide inline-edit table. It mixes name, enabled state, RPM, request concurrency, Codex windows, four quota inputs, model/price summary, 24H usage, and reset buttons into each row. +- The `...` visible in the current admin screenshot is not a meaningful field; it is an overflow/truncation symptom from cramped columns. +- Admin key rows already receive backend usage windows from `usageWindows(...)`, but the UI only exposes a small `24H 用量` cell and does not clearly show 5H/24H/7D/month used/limit/remaining together. +- The store has hard deletion behavior only for stale legacy sync (`delete from keys where id=?`) but there is no user-facing admin route or UI for key deletion/archive. +- The user page already has stale `处理中` filtering logic and refresh abort logic, but the screenshot still shows a stuck `连接异常 / 同步失败` state; error handling and state recovery need to be made explicit and testable. + +## Requirements + +- Keep changes limited to self-owned code: + - `cpa_key_policy_plus_plugin/go/**` + - necessary admin/public proxy routing + - Trellis/task docs and tests +- Do not modify CPA, CPAMP, or old `cpa-key-policy` official source/images. +- Diagnose and fix the public `cpa-usage` realtime failure mode: + - user page must distinguish unauthenticated/session-expired from network/API failure + - a failed poll must not leave the page permanently stuck in `同步中` or `连接异常` + - manual refresh and page visibility restore must recover without full browser reload when the session is still valid +- Redesign `CPA Key Policy+` admin page around clear hierarchy rather than one huge inline table: + - overview cards + - key list + - selected-key detail editor/drawer/panel + - actual used/limit/remaining quota display for 5H/24H/7D/month + - active session/window count + - model/price coverage +- Preserve or improve existing create/save/model selection behavior: + - creating a key still shows the full `cpa_` key exactly once + - model list stays searchable and supports unknown model preservation + - price editing remains structured by model +- Add safe key lifecycle management: + - default should be `停用 + 归档/隐藏` so history and audit remain intact + - hard delete, if provided, must be clearly dangerous and constrained +- Keep user-facing data safe: + - never expose raw keys, full hashes, Authorization/cookie values, request/response bodies, or encrypted reasoning content +- Provide local preview variants before finalizing the UI: + - at minimum, preview the recommended layout and one alternative layout with seeded data + - screenshots should cover desktop and narrow/mobile widths + +## Acceptance Criteria + +- [ ] `CPA Key Policy+` admin no longer renders the single overcrowded inline table as the main editing surface. +- [ ] Admin can see for each key: name/preview/status, RPM, request concurrency, Codex window limit, active sessions, 5H/24H/7D/month used/limit/remaining, model count, and price coverage. +- [ ] Admin can edit a selected key without horizontal overflow or meaningless `...` cells on desktop. +- [ ] Admin can safely hide/archive or remove an unwanted key according to the chosen lifecycle decision. +- [ ] Public `cpa-usage` login and refresh states are testable and recover from failed polls without requiring a full page reload when the session remains valid. +- [ ] `cpa-usage` shows useful failure text for session-expired vs API/network failure. +- [ ] Existing model discovery, model selection, price editing, key creation, save, reset, usage events, and CodexCont summaries continue to work. +- [ ] Local preview(s) are runnable and screenshots are produced for desktop and 390px mobile. +- [ ] `go test ./...` passes in `cpa_key_policy_plus_plugin/go`. +- [ ] Inline JS syntax checks pass for changed HTML assets. +- [ ] Playwright verifies admin and user pages for layout, refresh recovery, and no short-field vertical text. +- [ ] Server deploy preserves public/admin boundaries: `cpa.konbakuyomu.us` still blocks plugin/resource/management paths; `cpa-usage.konbakuyomu.us` exposes only the user page/API. + +## Out Of Scope + +- Rewriting CPA, CPAMP, or official Key Policy. +- Reintroducing the old Python `usage-admin` as the source of truth. +- Changing the production `/v1/responses` execution architecture. +- Deleting production data or CPAMP/CPA history. +- Bulk filesystem cleanup or Docker prune. + +## Open Questions + +- Key lifecycle policy: should the first implementation expose only `停用 + 归档隐藏`, or also expose hard delete in an advanced/danger zone? +- UI shape: should the admin page use a right-side detail drawer, a full-width detail panel under the selected key, or a card/list layout? Recommendation is right-side detail drawer on desktop and stacked detail panel on mobile. diff --git a/.trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/task.json b/.trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/task.json new file mode 100644 index 0000000..09ad6ac --- /dev/null +++ b/.trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/task.json @@ -0,0 +1,26 @@ +{ + "id": "cpa-usage-key-policy-plus-ux-fixes", + "name": "cpa-usage-key-policy-plus-ux-fixes", + "title": "CPA Usage and Key Policy Plus UX fixes", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "dxt98", + "assignee": "dxt98", + "createdAt": "2026-07-02", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/cpa_key_policy_plus_plugin/go/assets/admin.html b/cpa_key_policy_plus_plugin/go/assets/admin.html index 565d33e..9805b31 100644 --- a/cpa_key_policy_plus_plugin/go/assets/admin.html +++ b/cpa_key_policy_plus_plugin/go/assets/admin.html @@ -6,39 +6,38 @@ CPA Key Policy+ -
+
K+
@@ -224,52 +298,54 @@

CPA Key Policy+

准备同步 - - - + + + +
-
-
-
-

Key 策略

-

空模型列表表示允许全部模型;5H/24H/7D 是滚动窗口,月限额按 Asia/Shanghai 自然月。

+
+
+
+
+

Key 列表

+

列表用于扫描状态;点击一行后在右侧编辑完整策略。

+
+
- -
-
- - - - - - - - - - - - - - - - - -
用户/Key状态RPM请求并发Codex窗口额度 USD模型/价格24H 用量操作
加载中...
-
-
+
+ + + + + + + + + + + + + + +
用户/Key状态额度概览并发策略模型/价格操作
加载中...
+
+ -
-
-
-

同步提示

-

模型列表优先使用 CPA/Plus 当前可见模型;在线获取失败时保留已有配置,不会硬删未知模型。

+
-

+        
+
尚未选择 Key。
+
+
@@ -280,7 +356,7 @@

同步提示

新建 Key

完整 Key 只会在创建成功后显示一次。

- + @@ -382,8 +458,8 @@

Key 已创建

列表里的预览不是完整 Key,不能用于登录或调用 API。需要重发时请轮换生成新的完整 Key。
@@ -394,31 +470,31 @@

Key 已创建

keys: [], modelOptions: [], modelWarnings: [], + selectedId: "", + showArchived: false, dirty: false, createModels: new Set(), editor: null, rawKey: "" }; +const windows = [ + ["5h", "5H", "five_hour_usd"], + ["24h", "24H", "daily_usd"], + ["7d", "7D", "weekly_usd"], + ["month", "月", "monthly_usd"] +]; const $ = id => document.getElementById(id); -function setSync(text, cls="info"){ - $("sync").className = "chip stream " + cls; - $("sync").innerHTML = `${escapeHTML(text)}`; -} - -async function api(path, opts={}){ - const r = await fetch(API + path, {cache:"no-store", credentials:"same-origin", ...opts}); - let d = {}; - try { d = await r.json(); } catch (_) {} - if (!r.ok || !d.ok) throw new Error(d.message || d.error || `HTTP ${r.status}`); - return d; -} - function escapeHTML(s){ return String(s ?? "").replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } function num(v){ const n = Number(v ?? 0); return Number.isFinite(n) ? n : 0; } -function money(v){ +function money(v, digits=3){ + if (v === null || v === undefined || v === "") return "未设"; + const n = Number(v); + return Number.isFinite(n) ? "$" + n.toFixed(digits) : "未设"; +} +function inputMoney(v){ if (v === null || v === undefined || v === "") return ""; const n = Number(v); return Number.isFinite(n) && n > 0 ? n.toFixed(3) : ""; @@ -428,25 +504,46 @@

Key 已创建

const n = Number(v); return Number.isFinite(n) && n > 0 ? String(n) : ""; } -function limit(k, name){ - const l = k.limits || {}; - if (name === "5h") return l.five_hour_usd; - if (name === "24h") return l.daily_usd; - if (name === "7d") return l.weekly_usd; - return l.monthly_usd; +function pct(v){ + const n = Number(v); + if (!Number.isFinite(n)) return "-"; + return Math.round(Math.max(0, Math.min(n, 9.99)) * 100) + "%"; } -function pricesOf(k){ - return structuredCloneSafe(k.prices || (k.pricing && k.pricing.models) || {}); +function setSync(text, cls="info"){ + $("sync").className = "chip stream " + cls; + $("sync").innerHTML = `${escapeHTML(text)}`; } -function structuredCloneSafe(value){ - return JSON.parse(JSON.stringify(value || {})); +async function api(path, opts={}){ + const r = await fetch(API + path, {cache:"no-store", credentials:"same-origin", ...opts}); + let d = {}; + try { d = await r.json(); } catch (_) {} + if (!r.ok || !d.ok) throw new Error(d.message || d.error || `HTTP ${r.status}`); + return d; } -function selectedModels(k){ - return Array.isArray(k.models) ? [...k.models] : []; +function structuredCloneSafe(value){ return JSON.parse(JSON.stringify(value || {})); } +function limitsOf(k){ return k.limits || {}; } +function usageOf(k){ return k.usage || {}; } +function quotaOf(k, name){ + const q = (k.quota || {})[name] || {}; + const limits = limitsOf(k); + const used = Number(q.used_usd ?? usageOf(k)[name] ?? 0); + const limit = q.limit_usd ?? ( + name === "5h" ? limits.five_hour_usd : + name === "24h" ? limits.daily_usd : + name === "7d" ? limits.weekly_usd : + limits.monthly_usd + ); + let remaining = q.remaining_usd; + let percent = q.percent; + if (remaining == null && limit != null) remaining = Math.max(Number(limit) - used, 0); + if (percent == null && limit != null && Number(limit) > 0) percent = used / Number(limit); + return {used, limit, remaining, percent}; } -function modelCountText(k){ - const models = selectedModels(k); - return models.length ? `${models.length} 个模型` : "允许全部模型"; +function selectedModels(k){ return Array.isArray(k.models) ? [...k.models] : []; } +function pricesOf(k){ return structuredCloneSafe(k.prices || (k.pricing && k.pricing.models) || {}); } +function hasPrice(p){ + if (!p) return false; + return ["input_per_million","output_per_million","cache_read_per_million","cache_creation_per_million"].some(f => Number(p[f] || 0) > 0); } function priceCoverage(k){ const models = selectedModels(k); @@ -456,122 +553,40 @@

Key 已创建

const priced = selected.filter(m => hasPrice(prices[m])).length; return `${priced}/${selected.length} 已计价`; } -function hasPrice(p){ - if (!p) return false; - return ["input_per_million","output_per_million","cache_read_per_million","cache_creation_per_million"].some(f => Number(p[f] || 0) > 0); -} -function sourceLabel(source){ - if (source === "host_auth") return "CPA"; - if (source === "online") return "在线"; - if (source === "plus_configured") return "已配置"; - return source || "未知"; -} - -function renderMetrics(){ - const total = state.keys.length; - const enabled = state.keys.filter(k => k.enabled).length; - const limited = state.keys.filter(k => num(k.max_active_sessions) > 0).length; - const cost = state.keys.reduce((s,k) => s + Number(k.usage?.["24h"] || 0), 0); - $("metrics").innerHTML = ` -
Key 总数
${total}
已启用 ${enabled}
-
Codex 窗口限制
${limited}
30 分钟空闲释放
-
24H 费用
$${cost.toFixed(3)}
按当前价格估算
-
模型来源
${state.modelOptions.length}
${state.modelWarnings.length ? "有兜底提示" : "已同步"}
`; -} - -function render(){ - renderMetrics(); - const body = $("keys-body"); - if (!state.keys.length) { - body.innerHTML = `暂无 Key,点击右上角新建。`; - renderDebug(); - return; - } - body.innerHTML = state.keys.map(k => ` - - - -
${escapeHTML(k.preview || k.id || "")}
- - - - - - -
- - - - -
- - -
-
- ${escapeHTML(modelCountText(k))} - ${escapeHTML(priceCoverage(k))} -
- ${escapeHTML(modelPreview(k))} - -
- - $${Number(k.usage?.["24h"] || 0).toFixed(3)}当前窗口 - - - - - - - `).join(""); - renderDebug(); +function modelCountText(k){ + const models = selectedModels(k); + return models.length ? `${models.length} 个模型` : "允许全部模型"; } - function modelPreview(k){ const models = selectedModels(k); if (!models.length) return "空列表:允许全部模型"; const shown = models.slice(0, 3).join(", "); return models.length > 3 ? `${shown} +${models.length - 3}` : shown; } - -function renderDebug(){ - $("raw").textContent = JSON.stringify({ - api_base: API, - keys: state.keys.map(k => ({id:k.id, preview:k.preview, models:selectedModels(k).length, usage:k.usage})), - model_sources: summarizeModelSources(), - warnings: state.modelWarnings - }, null, 2); +function displayPreview(v){ return String(v || "").replace(/\.\.\./g, "…"); } +function statusChip(k){ + if (k.archived) return `已归档`; + if (k.enabled) return `启用`; + return `禁用`; } - -function summarizeModelSources(){ - const out = {}; - for (const item of state.modelOptions) out[item.source || "unknown"] = (out[item.source || "unknown"] || 0) + 1; - return out; +function visibleKeys(){ + return state.keys.filter(k => state.showArchived || !k.archived); } - function findKey(id){ return state.keys.find(k => k.id === id); } +function ensureSelection(){ + const list = visibleKeys(); + if (state.selectedId && findKey(state.selectedId) && (state.showArchived || !findKey(state.selectedId).archived)) return; + state.selectedId = list[0]?.id || ""; +} function markDirty(){ state.dirty = true; setSync("有未保存更改", "warn"); } - -function updateKeyFromInput(target){ - const row = target.closest("tr[data-id]"); - if (!row) return; - const key = findKey(row.dataset.id); - if (!key) return; - if (target.dataset.field) { - const field = target.dataset.field; - if (target.type === "checkbox") key[field] = target.checked; - else if (["rpm","concurrency","max_active_sessions"].includes(field)) key[field] = Number(target.value || 0); - else key[field] = target.value; - } - if (target.dataset.limit) { - key.limits = key.limits || {}; - key.limits[target.dataset.limit] = nullableNumber(target.value); - } - markDirty(); +function limitValue(k, field){ return limitsOf(k)[field]; } +function setLimitValue(k, field, value){ + k.limits = k.limits || {}; + k.limits[field] = nullableNumber(value); } - function nullableNumber(raw){ raw = String(raw ?? "").trim(); if (raw === "") return null; @@ -579,25 +594,6 @@

Key 已创建

if (!Number.isFinite(n) || n < 0) throw new Error("数字字段不能为负或非法"); return n; } - -function keyPayload(k){ - const limits = k.limits || {}; - return { - id: k.id, - name: String(k.name || "").trim(), - enabled: !!k.enabled, - rpm: nullableNumber(k.rpm) || 0, - concurrency: nullableNumber(k.concurrency) || 0, - max_active_sessions: nullableNumber(k.max_active_sessions) || 0, - models: cleanStrings(k.models || []), - prices: cleanPrices(pricesOf(k)), - five_hour_usd: numberOrNull(limits.five_hour_usd), - daily_usd: numberOrNull(limits.daily_usd), - weekly_usd: numberOrNull(limits.weekly_usd), - monthly_usd: numberOrNull(limits.monthly_usd) - }; -} - function numberOrNull(v){ if (v === null || v === undefined || v === "") return null; const n = Number(v); @@ -623,8 +619,6 @@

Key 已创建

if (!model) continue; out[model] = { model, - target_model: String(raw.target_model || "").trim(), - provider: String(raw.provider || "").trim(), input_per_million: Number(raw.input_per_million || 0), output_per_million: Number(raw.output_per_million || 0), cache_read_per_million: Number(raw.cache_read_per_million || 0), @@ -633,121 +627,266 @@

Key 已创建

} return out; } - -async function refresh(){ - try { - setSync("同步中", "warn"); - const keyData = await api("/keys?ts=" + Date.now()); - state.keys = (keyData.keys || []).map(k => ({...k, prices: pricesOf(k)})); - await refreshModels(false); - state.dirty = false; - render(); - setSync("刚刚更新", "ok"); - } catch (e) { - setSync("同步失败", "bad"); - $("raw").textContent = e.message; - } +function keyPayload(k){ + const limits = limitsOf(k); + return { + id: k.id, + name: String(k.name || "").trim(), + enabled: !!k.enabled, + rpm: nullableNumber(k.rpm) || 0, + concurrency: nullableNumber(k.concurrency) || 0, + max_active_sessions: nullableNumber(k.max_active_sessions) || 0, + models: cleanStrings(k.models || []), + prices: cleanPrices(pricesOf(k)), + five_hour_usd: numberOrNull(limits.five_hour_usd), + daily_usd: numberOrNull(limits.daily_usd), + weekly_usd: numberOrNull(limits.weekly_usd), + monthly_usd: numberOrNull(limits.monthly_usd) + }; } -async function refreshModels(renderAfter=true){ - const warnings = []; - let backendModels = []; - try { - const data = await api("/models?ts=" + Date.now()); - backendModels = normalizeModelOptions(data.models || [], "plus_configured"); - warnings.push(...(data.warnings || [])); - } catch (e) { - warnings.push("Plus 模型端点不可用: " + e.message); +function renderMetrics(){ + const total = state.keys.length; + const archived = state.keys.filter(k => k.archived).length; + const enabled = state.keys.filter(k => k.enabled && !k.archived).length; + const active = state.keys.reduce((s,k) => s + Number(k.active_sessions || 0), 0); + const cost = state.keys.reduce((s,k) => s + Number(usageOf(k)["24h"] || 0), 0); + $("metrics").innerHTML = ` +
Key 总数
${total}
启用 ${enabled} · 归档 ${archived}
+
当前活跃窗口
${active}
30 分钟空闲释放
+
24H 费用
$${cost.toFixed(3)}
按当前价格估算
+
模型来源
${state.modelOptions.length}
${state.modelWarnings.length ? "有兜底提示" : "已同步"}
`; +} +function renderQuotaMini(k, name, label){ + const q = quotaOf(k, name); + const cls = Number(q.percent || 0) >= 1 ? "bad" : Number(q.percent || 0) >= .8 ? "warn" : ""; + return `
+
${escapeHTML(label)}${pct(q.percent)}
+
${money(q.used)} / ${money(q.limit)}
+ 剩余 ${money(q.remaining)} +
+
`; +} +function renderKeyList(){ + const body = $("keys-body"); + const list = visibleKeys(); + if (!list.length) { + body.innerHTML = `暂无可显示 Key,点击右上角新建或勾选显示归档。`; + return; } - const configured = normalizeModelOptions(modelUnionFromKeys(), "plus_configured").map(m => ({...m, known:false})); - state.modelOptions = mergeModelOptions(backendModels, configured); - state.modelWarnings = warnings; - if (renderAfter) { + body.innerHTML = list.map(k => ` + +
${escapeHTML(k.name || "-")}${escapeHTML(displayPreview(k.preview || k.id || ""))}
+
${statusChip(k)}活跃 ${Number(k.active_sessions || 0)}
+
${renderQuotaMini(k,"5h","5H")}${renderQuotaMini(k,"24h","24H")}${renderQuotaMini(k,"7d","7D")}${renderQuotaMini(k,"month","月")}
+
RPM ${Number(k.rpm || 0)}请求并发 ${Number(k.concurrency || 0)}Codex窗口 ${Number(k.max_active_sessions || 0)}
+
${escapeHTML(modelCountText(k))}${escapeHTML(priceCoverage(k))}
${escapeHTML(modelPreview(k))}
+ + `).join(""); + body.querySelectorAll("[data-select]").forEach(row => row.onclick = () => { + state.selectedId = row.getAttribute("data-select"); render(); - setSync("模型已更新", "ok"); - } + }); + body.querySelectorAll("[data-edit]").forEach(btn => btn.onclick = (ev) => { + ev.stopPropagation(); + state.selectedId = btn.getAttribute("data-edit"); + render(); + }); } - -function modelUnionFromKeys(){ - const ids = []; - for (const key of state.keys) { - ids.push(...(key.models || [])); - ids.push(...Object.keys(pricesOf(key))); +function renderDetail(){ + const key = findKey(state.selectedId); + const body = $("detail-body"); + if (!key) { + $("detail-subtitle").textContent = "选择左侧 Key 后编辑。"; + body.innerHTML = `
尚未选择 Key。
`; + return; } - return ids; + $("detail-subtitle").textContent = `${key.name || "-"} · ${displayPreview(key.preview || key.id)}`; + body.innerHTML = ` +
${statusChip(key)}活跃 ${Number(key.active_sessions || 0)}${escapeHTML(modelCountText(key))}${escapeHTML(priceCoverage(key))}
+

基础策略

+
+ + + + + +
+

额度窗口

已用 / 限额 / 剩余
+
${windows.map(([name,label,field]) => { + const q = quotaOf(key, name); + return `
+ ${label} +
+
${money(q.used)} / ${money(q.limit)} / ${money(q.remaining)}
+ 进度 ${pct(q.percent)} +
= .8 ? "warn" : ""}">
+
+ +
`; + }).join("")}
+

模型与价格

+
当前:${escapeHTML(modelPreview(key))};${escapeHTML(priceCoverage(key))}。价格单位为美元 / 100 万 token。
+

清零与生命周期

+
+ + + + + + +
`; + body.querySelectorAll("[data-detail]").forEach(input => input.oninput = () => updateDetail(input)); + body.querySelectorAll("[data-detail]").forEach(input => input.onchange = () => updateDetail(input)); + body.querySelectorAll("[data-limit]").forEach(input => input.oninput = () => { + setLimitValue(key, input.dataset.limit, input.value); + markDirty(); + renderKeyList(); + }); + body.querySelectorAll("[data-reset]").forEach(btn => btn.onclick = () => resetSelected(btn.dataset.reset)); + $("edit-models").onclick = () => openEditor(key.id); + $("archive-key").onclick = () => archiveSelected(); +} +function updateDetail(input){ + const key = findKey(state.selectedId); + if (!key) return; + const field = input.dataset.detail; + if (field === "enabled") key.enabled = input.value === "true"; + else if (["rpm","concurrency","max_active_sessions"].includes(field)) key[field] = Number(input.value || 0); + else key[field] = input.value; + markDirty(); + renderKeyList(); +} +function render(){ + ensureSelection(); + renderMetrics(); + renderKeyList(); + renderDetail(); } -function normalizeModelOptions(data, source){ - const items = Array.isArray(data) ? data : Array.isArray(data?.models) ? data.models : Array.isArray(data?.data) ? data.data : typeof data === "object" && data ? Object.entries(data).map(([id, value]) => typeof value === "object" ? {id, ...value} : id) : []; - const seen = new Set(); - const out = []; - for (const item of items) { - const id = typeof item === "string" ? item : (item.id || item.model || item.alias || item.name || item.target_model || item.targetModel); - const clean = String(id || "").trim(); - const key = clean.toLowerCase(); - if (!clean || seen.has(key)) continue; - seen.add(key); - out.push({ - id: clean, - display_name: String((typeof item === "object" && (item.display_name || item.displayName || item.label || item.name)) || clean), - type: String((typeof item === "object" && (item.type || item.object)) || ""), - owned_by: String((typeof item === "object" && (item.owned_by || item.ownedBy || item.provider)) || ""), - source, - known: source !== "plus_configured" - }); +async function load(){ + setSync("同步中", "info"); + try { + const [keysResp, modelsResp] = await Promise.all([api("/keys"), api("/models")]); + state.keys = keysResp.keys || []; + state.modelOptions = modelsResp.models || []; + state.modelWarnings = modelsResp.warnings || []; + state.dirty = false; + ensureSelection(); + render(); + setSync("已同步", "ok"); + } catch(e) { + setSync("同步失败:" + e.message, "bad"); } - return out; } - -function mergeModelOptions(...groups){ - const byID = new Map(); - for (const group of groups) { - for (const item of group || []) { - const id = String(item.id || "").trim(); - if (!id) continue; - const key = id.toLowerCase(); - const prev = byID.get(key) || {}; - byID.set(key, { - id, - display_name: prev.display_name && prev.display_name !== prev.id ? prev.display_name : (item.display_name || id), - type: prev.type || item.type || "", - owned_by: prev.owned_by || item.owned_by || "", - source: prev.source || item.source || "", - known: !!(prev.known || item.known) - }); - } +async function loadModels(){ + try { + const modelsResp = await api("/models"); + state.modelOptions = modelsResp.models || []; + state.modelWarnings = modelsResp.warnings || []; + render(); + setSync("模型已同步", "ok"); + } catch(e) { + setSync("模型同步失败:" + e.message, "bad"); } - return [...byID.values()].sort((a,b) => (a.known === b.known ? a.id.localeCompare(b.id) : a.known ? -1 : 1)); } - async function saveAll(){ try { - setSync("保存中", "warn"); + setSync("保存中", "info"); const payload = {keys: state.keys.map(keyPayload)}; - const data = await api("/keys/save", { + const resp = await api("/keys/save", { method: "PUT", headers: {"Content-Type":"application/json"}, body: JSON.stringify(payload) }); - state.keys = (data.keys || []).map(k => ({...k, prices: pricesOf(k)})); + state.keys = resp.keys || state.keys; state.dirty = false; render(); setSync("已保存", "ok"); - } catch (e) { - setSync("保存失败", "bad"); - alert(e.message); + } catch(e) { + setSync("保存失败:" + e.message, "bad"); } } - -function openModal(id){ - $(id).classList.add("open"); - $(id).setAttribute("aria-hidden", "false"); +async function resetSelected(windowName){ + const key = findKey(state.selectedId); + if (!key) return; + if (!confirm(`确认清零 ${key.name || key.id} 的 ${windowName} 用量统计?这只写入 soft reset,不删除历史明细。`)) return; + try { + setSync("清零中", "info"); + await api("/keys/reset", { + method: "POST", + headers: {"Content-Type":"application/json"}, + body: JSON.stringify({id:key.id, window:windowName}) + }); + await load(); + setSync("已清零", "ok"); + } catch(e) { + setSync("清零失败:" + e.message, "bad"); + } } -function closeModal(id){ - $(id).classList.remove("open"); - $(id).setAttribute("aria-hidden", "true"); +async function archiveSelected(){ + const key = findKey(state.selectedId); + if (!key) return; + const next = !key.archived; + const text = next ? "归档后该 Key 将不能登录或调用,但历史用量会保留。确认归档?" : "确认恢复这个 Key?恢复后仍受启用状态和额度限制。"; + if (!confirm(text)) return; + try { + setSync(next ? "归档中" : "恢复中", "info"); + const resp = await api("/keys/archive", { + method: "POST", + headers: {"Content-Type":"application/json"}, + body: JSON.stringify({id:key.id, archived:next}) + }); + state.keys = resp.keys || state.keys.map(k => k.id === key.id ? {...k, archived:next} : k); + if (next && !state.showArchived) state.selectedId = ""; + render(); + setSync(next ? "已归档" : "已恢复", "ok"); + } catch(e) { + setSync("生命周期操作失败:" + e.message, "bad"); + } } +function allModelOptions(extra=[]){ + const seen = new Set(); + const out = []; + for (const item of [...(state.modelOptions || []), ...extra.map(id => ({id, source:"manual", known:false}))]) { + const id = String(item.id || "").trim(); + const key = id.toLowerCase(); + if (!id || seen.has(key)) continue; + seen.add(key); + out.push({...item, id}); + } + return out; +} +function sourceLabel(source){ + if (source === "host_auth") return "CPA"; + if (source === "online") return "在线"; + if (source === "plus_configured") return "已配置"; + if (source === "manual") return "手动"; + return source || "未知"; +} +function optionMatches(option, search){ + const hay = `${option.id || ""} ${option.display_name || ""} ${option.source || ""}`.toLowerCase(); + return hay.includes(search.toLowerCase()); +} +function selectedChips(models, target){ + if (!models.size) return `未选择模型:允许全部模型`; + return [...models].map(id => `${escapeHTML(id)} `).join(""); +} +function renderModelOptions(target, models, search, options){ + const visible = options.filter(o => optionMatches(o, search)); + target.innerHTML = visible.map(o => ``).join("") || `
没有匹配模型
`; + target.querySelectorAll("[data-model-option]").forEach(input => input.onchange = () => { + if (input.checked) models.add(input.dataset.modelOption); + else models.delete(input.dataset.modelOption); + if (state.editor) renderEditor(); + else renderCreateModels(); + }); + return visible.map(o => o.id); +} function openCreate(){ state.createModels = new Set(); $("create-name").value = ""; @@ -757,110 +896,81 @@

Key 已创建

$("create-sessions").value = "2"; $("create-model-search").value = ""; $("create-add-model").value = ""; - renderCreateModels(); openModal("create-modal"); + renderCreateModels(); } - -function visibleModels(searchValue){ - const q = String(searchValue || "").trim().toLowerCase(); - return state.modelOptions.filter(m => !q || m.id.toLowerCase().includes(q) || String(m.display_name || "").toLowerCase().includes(q)); -} - -function renderModelCheckboxes(container, selectedSet, searchValue, prefix){ - const models = visibleModels(searchValue); - if (!models.length) { - container.innerHTML = `
没有匹配模型,可以手动添加。
`; - return; - } - container.innerHTML = models.map(m => ` - `).join(""); -} - -function renderChips(container, selectedSet, prefix){ - const items = [...selectedSet]; - if (!items.length) { - container.innerHTML = `未选择模型:允许全部模型。`; - return; - } - container.innerHTML = items.map(id => `${escapeHTML(id)} `).join(""); -} - function renderCreateModels(){ - renderChips($("create-selected"), state.createModels, "create"); - renderModelCheckboxes($("create-model-list"), state.createModels, $("create-model-search").value, "create"); + const search = $("create-model-search").value || ""; + const options = allModelOptions([...state.createModels]); + $("create-selected").innerHTML = selectedChips(state.createModels, "create"); + $("create-selected").querySelectorAll("[data-remove-model]").forEach(btn => btn.onclick = () => { + state.createModels.delete(btn.dataset.removeModel); + renderCreateModels(); + }); + renderModelOptions($("create-model-list"), state.createModels, search, options); } - -async function submitCreate(){ +function addCreateModel(){ + const id = $("create-add-model").value.trim(); + if (!id) return; + state.createModels.add(id); + $("create-add-model").value = ""; + renderCreateModels(); +} +async function createKey(){ try { - const body = { + setSync("创建中", "info"); + const payload = { name: $("create-name").value.trim(), enabled: $("create-enabled").value === "true", rpm: numberOrNull($("create-rpm").value) || 0, concurrency: numberOrNull($("create-concurrency").value) || 0, max_active_sessions: numberOrNull($("create-sessions").value) || 0, - models: [...state.createModels], - prices: {} + models: [...state.createModels] }; - setSync("创建中", "warn"); - const data = await api("/keys/create", { + const resp = await api("/keys/create", { method: "POST", headers: {"Content-Type":"application/json"}, - body: JSON.stringify(body) + body: JSON.stringify(payload) }); closeModal("create-modal"); - state.rawKey = data.raw_key || ""; + state.rawKey = resp.raw_key || ""; $("raw-key-box").textContent = state.rawKey; openModal("raw-key-modal"); - await refresh(); - } catch (e) { - setSync("创建失败", "bad"); - alert(e.message); + await load(); + if (resp.key && resp.key.id) state.selectedId = resp.key.id; + render(); + setSync("Key 已创建", "ok"); + } catch(e) { + setSync("创建失败:" + e.message, "bad"); } } - function openEditor(id){ const key = findKey(id); if (!key) return; - state.editor = structuredCloneSafe({...key, prices: pricesOf(key)}); - state.editor.models = cleanStrings(state.editor.models || []); + state.editor = {id, models:new Set(selectedModels(key)), prices:pricesOf(key)}; $("edit-model-search").value = ""; $("edit-add-model").value = ""; - renderEditor(); openModal("editor-modal"); -} - -function editorSelectedSet(){ - return new Set(cleanStrings(state.editor?.models || [])); + renderEditor(); } function renderEditor(){ if (!state.editor) return; - $("editor-title").textContent = `编辑模型与价格 · ${state.editor.name || state.editor.preview || state.editor.id}`; - const selected = editorSelectedSet(); - renderChips($("edit-selected"), selected, "edit"); - renderModelCheckboxes($("edit-model-list"), selected, $("edit-model-search").value, "edit"); - const known = new Set(state.modelOptions.map(m => m.id.toLowerCase())); - const unknown = [...selected].filter(id => !known.has(id.toLowerCase())); - $("unknown-notice").classList.toggle("hidden", !unknown.length); - $("unknown-notice").textContent = unknown.length ? `这些模型未在当前 CPA 模型列表中,但会被保留:${unknown.join(", ")}` : ""; - $("model-source-hint").textContent = `${state.modelOptions.length} 个可选模型`; - renderPriceRows(); -} - -function renderPriceRows(){ - const selected = cleanStrings(state.editor?.models || []); - const prices = state.editor.prices || {}; - const rows = selected.length ? selected : Object.keys(prices); - if (!rows.length) { - $("price-body").innerHTML = `选择模型后可填写价格;空模型列表表示允许全部模型。`; - return; - } - $("price-body").innerHTML = rows.map(model => { + const prices = state.editor.prices; + const extra = [...state.editor.models, ...Object.keys(prices)]; + const options = allModelOptions(extra); + const search = $("edit-model-search").value || ""; + $("edit-selected").innerHTML = selectedChips(state.editor.models, "edit"); + $("edit-selected").querySelectorAll("[data-remove-model]").forEach(btn => btn.onclick = () => { + state.editor.models.delete(btn.dataset.removeModel); + renderEditor(); + }); + const visible = renderModelOptions($("edit-model-list"), state.editor.models, search, options); + const unknown = [...state.editor.models].filter(id => !state.modelOptions.some(o => String(o.id).toLowerCase() === id.toLowerCase())); + $("unknown-notice").classList.toggle("hidden", unknown.length === 0); + $("unknown-notice").textContent = unknown.length ? `以下模型未在当前 CPA 模型列表中,保存时会保留:${unknown.join(", ")}` : ""; + $("model-source-hint").textContent = `${visible.length} 个匹配`; + const rows = [...state.editor.models]; + $("price-body").innerHTML = rows.length ? rows.map(model => { const p = prices[model] || {model}; return ` ${escapeHTML(model)} @@ -869,119 +979,71 @@

Key 已创建

`; - }).join(""); -} - -function addModelToSet(set, raw){ - const text = String(raw || "").trim(); - if (!text) return; - for (const item of set) { - if (item.toLowerCase() === text.toLowerCase()) return; - } - set.add(text); + }).join("") : `允许全部模型时,可先选择模型再配置价格。`; + $("price-body").querySelectorAll("[data-price-field]").forEach(input => input.oninput = () => { + const row = input.closest("[data-price-model]"); + const model = row.dataset.priceModel; + prices[model] = prices[model] || {model}; + prices[model].model = model; + prices[model][input.dataset.priceField] = Number(input.value || 0); + }); } -function removeModelFromArray(arr, model){ - return cleanStrings(arr).filter(item => item.toLowerCase() !== String(model).toLowerCase()); +function addEditorModel(){ + if (!state.editor) return; + const id = $("edit-add-model").value.trim(); + if (!id) return; + state.editor.models.add(id); + $("edit-add-model").value = ""; + renderEditor(); } - function applyEditor(){ - if (!state.editor) return; - const target = findKey(state.editor.id); - if (!target) return; - target.models = cleanStrings(state.editor.models || []); - target.prices = cleanPrices(state.editor.prices || {}); + const key = findKey(state.editor?.id); + if (!key) return; + key.models = cleanStrings([...state.editor.models]); + key.prices = cleanPrices(state.editor.prices); + state.editor = null; + closeModal("editor-modal"); markDirty(); render(); - closeModal("editor-modal"); -} - -async function resetKey(id, windowName){ - if (!confirm(`确认清零 ${id} 的 ${windowName} 统计?这是 soft reset,不删除历史明细。`)) return; - await api("/keys/reset", { - method: "POST", - headers: {"Content-Type":"application/json"}, - body: JSON.stringify({id, window: windowName}) - }); - await refresh(); } +function openModal(id){ $(id).classList.add("open"); $(id).setAttribute("aria-hidden", "false"); } +function closeModal(id){ $(id).classList.remove("open"); $(id).setAttribute("aria-hidden", "true"); } -document.addEventListener("input", e => { - if (e.target.closest("#keys-table")) updateKeyFromInput(e.target); - if (e.target.id === "create-model-search") renderCreateModels(); - if (e.target.id === "edit-model-search") renderEditor(); - if (e.target.dataset.priceField && state.editor) { - const row = e.target.closest("tr[data-price-model]"); - const model = row?.dataset.priceModel; - if (!model) return; - state.editor.prices = state.editor.prices || {}; - state.editor.prices[model] = state.editor.prices[model] || {model}; - state.editor.prices[model].model = model; - state.editor.prices[model][e.target.dataset.priceField] = numberOrNull(e.target.value) || 0; - } -}); - -document.addEventListener("change", e => { - if (e.target.closest("#keys-table")) updateKeyFromInput(e.target); - const createModel = e.target.dataset.createModel; - if (createModel) { - if (e.target.checked) state.createModels.add(createModel); - else state.createModels.delete(createModel); - renderCreateModels(); - } - const editModel = e.target.dataset.editModel; - if (editModel && state.editor) { - if (e.target.checked) state.editor.models = cleanStrings([...(state.editor.models || []), editModel]); - else state.editor.models = removeModelFromArray(state.editor.models || [], editModel); - renderEditor(); - } -}); - -document.addEventListener("click", e => { - const close = e.target.dataset.close; - if (close) closeModal(close); - const row = e.target.closest("tr[data-id]"); - if (e.target.dataset.action === "edit-models" && row) openEditor(row.dataset.id); - const reset = e.target.dataset.reset; - if (reset && row) resetKey(row.dataset.id, reset); - const createRemove = e.target.dataset.createRemove; - if (createRemove) { state.createModels.delete(createRemove); renderCreateModels(); } - const editRemove = e.target.dataset.editRemove; - if (editRemove && state.editor) { state.editor.models = removeModelFromArray(state.editor.models || [], editRemove); renderEditor(); } -}); - -$("refresh").onclick = refresh; -$("reload-models").onclick = () => refreshModels(true); -$("save").onclick = saveAll; +$("show-archived").onchange = () => { state.showArchived = $("show-archived").checked; render(); }; $("create").onclick = openCreate; -$("create-submit").onclick = submitCreate; -$("create-add-model-btn").onclick = () => { addModelToSet(state.createModels, $("create-add-model").value); $("create-add-model").value = ""; renderCreateModels(); }; -$("create-select-visible").onclick = () => { for (const m of visibleModels($("create-model-search").value)) state.createModels.add(m.id); renderCreateModels(); }; +$("refresh").onclick = load; +$("save").onclick = saveAll; +$("reload-models").onclick = loadModels; +$("create-model-search").oninput = renderCreateModels; +$("create-add-model-btn").onclick = addCreateModel; +$("create-select-visible").onclick = () => { + const search = $("create-model-search").value || ""; + allModelOptions([...state.createModels]).filter(o => optionMatches(o, search)).forEach(o => state.createModels.add(o.id)); + renderCreateModels(); +}; $("create-clear-models").onclick = () => { state.createModels.clear(); renderCreateModels(); }; -$("edit-add-model-btn").onclick = () => { if (!state.editor) return; state.editor.models = cleanStrings([...(state.editor.models || []), $("edit-add-model").value]); $("edit-add-model").value = ""; renderEditor(); }; -$("edit-select-visible").onclick = () => { if (!state.editor) return; state.editor.models = cleanStrings([...(state.editor.models || []), ...visibleModels($("edit-model-search").value).map(m => m.id)]); renderEditor(); }; -$("edit-clear-models").onclick = () => { if (!state.editor) return; state.editor.models = []; renderEditor(); }; +$("create-submit").onclick = createKey; +$("edit-model-search").oninput = renderEditor; +$("edit-add-model-btn").onclick = addEditorModel; +$("edit-select-visible").onclick = () => { + if (!state.editor) return; + const search = $("edit-model-search").value || ""; + allModelOptions([...state.editor.models, ...Object.keys(state.editor.prices)]).filter(o => optionMatches(o, search)).forEach(o => state.editor.models.add(o.id)); + renderEditor(); +}; +$("edit-clear-models").onclick = () => { if (state.editor) { state.editor.models.clear(); renderEditor(); } }; $("editor-apply").onclick = applyEditor; +document.querySelectorAll("[data-close]").forEach(btn => btn.onclick = () => closeModal(btn.dataset.close)); +$("close-raw-key").onclick = () => closeModal("raw-key-modal"); $("copy-raw-key").onclick = async () => { try { - await navigator.clipboard.writeText(state.rawKey || ""); - setSync("已复制 Key", "ok"); - } catch (_) { - setSync("复制失败,请手动复制", "warn"); + await navigator.clipboard.writeText(state.rawKey); + setSync("完整 Key 已复制", "ok"); + } catch(_) { + setSync("复制失败,请手动选择完整 Key", "warn"); } }; -$("close-raw-key").onclick = () => { - state.rawKey = ""; - $("raw-key-box").textContent = ""; - closeModal("raw-key-modal"); -}; - -window.addEventListener("beforeunload", e => { - if (!state.dirty) return; - e.preventDefault(); - e.returnValue = ""; -}); - -refresh(); +load(); diff --git a/cpa_key_policy_plus_plugin/go/assets/shared.css b/cpa_key_policy_plus_plugin/go/assets/shared.css index bdc37f0..3bd1a64 100644 --- a/cpa_key_policy_plus_plugin/go/assets/shared.css +++ b/cpa_key_policy_plus_plugin/go/assets/shared.css @@ -419,6 +419,15 @@ small, .muted { white-space: pre-wrap; overflow-wrap: anywhere; } +.notice { + margin: 10px 14px; + padding: 10px; + border: 1px solid rgba(244, 185, 66, .26); + border-radius: 8px; + background: var(--amber-soft); + color: #ffd893; + font-size: 12px; +} .empty { padding: 22px; text-align: center; diff --git a/cpa_key_policy_plus_plugin/go/assets/user.html b/cpa_key_policy_plus_plugin/go/assets/user.html index b0a7fa3..b1c846c 100644 --- a/cpa_key_policy_plus_plugin/go/assets/user.html +++ b/cpa_key_policy_plus_plugin/go/assets/user.html @@ -78,6 +78,7 @@

CPA 用量自助页

cardSig: "", usageRenderSig: "", protectionRenderSig: "", + errors: {usage:"", protection:""}, timer: null, refreshTimer: null, timeoutTimer: null, @@ -193,10 +194,19 @@

CPA 用量自助页

} async function api(path, opts={}){ const r = await fetch(USER_API + path, {cache:"no-store", credentials:"same-origin", ...opts}); - const d = await r.json(); - if (!d.ok) throw Object.assign(new Error(d.message || d.error || "request_failed"), {data:d, status:r.status}); + let d = {}; + try { d = await r.json(); } catch (_) {} + if (!d.ok) throw Object.assign(new Error(d.message || d.error || `HTTP ${r.status}`), {data:d, status:r.status}); return d; } +function errorMessage(e, fallback){ + const data = (e && e.data) || {}; + return data.message || data.error || (e && e.message) || fallback || "同步失败"; +} +function isAuthError(e){ + const data = (e && e.data) || {}; + return e && (e.status === 401 || e.status === 403 || data.category === "auth" || data.error === "not_authenticated"); +} function clearRefreshTimeout(){ if (state.timeoutTimer) clearTimeout(state.timeoutTimer); state.timeoutTimer = null; @@ -402,8 +412,10 @@

CPA 用量自助页

`; return main + (open ? eventDetail(ev) : ""); }).join("") || `
暂无 ${rangeLabel(state.range)} 请求明细
`; + const error = state.errors.usage ? `
${esc(state.errors.usage)}
` : ""; $("content").innerHTML = `

单 Key 实时监控

${rangeLabel(state.range)} · 最近 ${num(events.length)} 条
+ ${error}
@@ -471,8 +483,10 @@

CPA 用量自助页

const continued = items.filter(x => x.protection === "auto_continued").length; const risk = items.filter(x => x.protection === "risk_uncontinued").length; const failed = items.filter(x => x.protection === "failed").length; + const error = state.errors.protection ? `
${esc(state.errors.protection)}
` : ""; $("content").innerHTML = `

思维链保护实时状态

${num(items.length)} 条 · 自动续写 ${num(continued)} · 疑似截断 ${num(risk)} · 失败 ${num(failed)}
+ ${error}
时间状态模型思考/Tier TTFT/耗时TokensCache保护费用
@@ -501,6 +515,7 @@

CPA 用量自助页

]); if (seq !== state.seq) return; state.usage = usageResp; + state.errors.usage = ""; const events = eventResp.events || []; const result = changedIds(events, state.eventSigs, eventID, e => ({ requested_at:e.requested_at, failed:e.failed, model:e.model, actual_model:e.actual_model, @@ -517,6 +532,7 @@

CPA 用量自助页

const cc = await api("/codexcont?limit=100", {signal}); if (seq !== state.seq) return; const requests = sortProtectionItems(cc.requests || []); + state.errors.protection = ""; const result = changedIds(requests, state.protectionSigs, protectionID, r => ({ protection:r.protection, status:r.status, latest_reasoning_tokens:r.latest_reasoning_tokens, continuation_count:r.continuation_count, stopped_reason:r.stopped_reason, failure_reason:r.failure_reason, @@ -549,16 +565,53 @@

CPA 用量自助页

const startedAt = performance.now(); markRefreshStart(force); try { - const tasks = [fetchMe(seq, controller.signal)]; - if (includeInactive || state.tab === "usage") tasks.push(fetchUsage(seq, controller.signal)); - if (includeInactive || state.tab === "usage" || state.tab === "codex") tasks.push(fetchProtection(seq, controller.signal)); - await Promise.all(tasks); + await fetchMe(seq, controller.signal); + let partial = false; + const jobs = []; + if (includeInactive || state.tab === "usage") { + jobs.push(fetchUsage(seq, controller.signal).catch(e => { + if (isAuthError(e)) throw e; + partial = true; + state.errors.usage = "用量接口同步失败:" + errorMessage(e); + renderUsage(true); + })); + } + if (includeInactive || state.tab === "usage" || state.tab === "codex") { + jobs.push(fetchProtection(seq, controller.signal).catch(e => { + if (isAuthError(e)) throw e; + partial = true; + state.errors.protection = "思维链保护同步失败:" + errorMessage(e); + renderUsage(true); + renderProtection(true); + })); + } + await Promise.all(jobs); await refreshMinimumDelay(force, startedAt); if (seq !== state.seq) return; - markRefreshDone(force); + if (partial) { + if (force) setRefreshState("error", "部分失败"); + setLive("warn", "部分数据同步失败"); + } else { + markRefreshDone(force); + } } catch(e) { await refreshMinimumDelay(force, startedAt); - if (seq === state.seq) markRefreshError(force); + if (seq === state.seq) { + if (isAuthError(e)) { + setLive("bad", "会话已过期"); + if (force) setRefreshState("error", "重新登录"); + clearInterval(state.timer); + $("app").classList.add("hidden"); + $("login").classList.remove("hidden"); + $("refresh").classList.add("hidden"); + $("active-chip").classList.add("hidden"); + $("logout").classList.add("hidden"); + $("rangeSelect").classList.add("hidden"); + $("err").textContent = errorMessage(e, "会话已过期,请重新登录。"); + } else { + markRefreshError(force); + } + } } finally { if (seq === state.seq) { state.refreshing = false; @@ -574,6 +627,7 @@

CPA 用量自助页

if (hint) { $("err").textContent = "登录失败:" + hint; return; } try { await api("/session", {headers:{"X-CPA-Key-Policy-Plus-Key": raw}}); + state.errors = {usage:"", protection:""}; $("login").classList.add("hidden"); $("app").classList.remove("hidden"); $("refresh").classList.remove("hidden"); diff --git a/cpa_key_policy_plus_plugin/go/internal/policyplus/models.go b/cpa_key_policy_plus_plugin/go/internal/policyplus/models.go index 4632925..a1c3704 100644 --- a/cpa_key_policy_plus_plugin/go/internal/policyplus/models.go +++ b/cpa_key_policy_plus_plugin/go/internal/policyplus/models.go @@ -41,6 +41,8 @@ type KeyRecord struct { WeeklyLimitUSD *float64 `json:"weekly_limit_usd,omitempty"` FiveHourUSD *float64 `json:"five_hour_usd,omitempty"` MonthlyLimitUSD *float64 `json:"monthly_limit_usd,omitempty"` + Archived bool `json:"archived,omitempty"` + ArchivedAt int64 `json:"archived_at,omitempty"` } func (k KeyRecord) Safe() map[string]any { @@ -53,6 +55,8 @@ func (k KeyRecord) Safe() map[string]any { "concurrency": k.Concurrency, "max_active_sessions": k.MaxActiveSessions, "models": append([]string(nil), k.Models...), + "archived": k.Archived, + "archived_at": k.ArchivedAt, "limits": map[string]any{ "five_hour_usd": k.FiveHourUSD, "daily_usd": k.DailyLimitUSD, @@ -185,6 +189,7 @@ func parseKey(raw map[string]any) (KeyRecord, bool) { if !hasEnabled { enabled = !disabled } + archived, _ := firstBool(raw, "archived", "is_archived", "isArchived") modelItems := asList(firstAny(raw, "models", "allowed_models", "allowedModels", "model_allowlist", "modelAllowlist", "aliases")) models := parseModels(modelItems) prices := parsePrices(raw, modelItems) @@ -203,6 +208,7 @@ func parseKey(raw map[string]any) (KeyRecord, bool) { WeeklyLimitUSD: firstFloatPtr(raw, "weekly_limit_usd", "weeklyLimitUsd", "weekly_limit", "weeklyLimit", "weekly_usd", "weeklyUsd"), FiveHourUSD: firstFloatPtr(raw, "five_hour_limit_usd", "fiveHourLimitUsd", "five_hour_usd", "fiveHourUsd", "5h_limit_usd"), MonthlyLimitUSD: firstFloatPtr(raw, "monthly_limit_usd", "monthlyLimitUsd", "monthly_usd", "monthlyUsd", "month_limit_usd"), + Archived: archived, }, true } diff --git a/cpa_key_policy_plus_plugin/go/internal/policyplus/policyplus_test.go b/cpa_key_policy_plus_plugin/go/internal/policyplus/policyplus_test.go index 2b39b03..2ab6e77 100644 --- a/cpa_key_policy_plus_plugin/go/internal/policyplus/policyplus_test.go +++ b/cpa_key_policy_plus_plugin/go/internal/policyplus/policyplus_test.go @@ -239,6 +239,45 @@ func TestStoreUsageWindowsAndSoftReset(t *testing.T) { } } +func TestStoreArchivesAndRestoresKeys(t *testing.T) { + ctx := context.Background() + store, err := OpenStore(filepath.Join(t.TempDir(), "policyplus.sqlite")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + key := KeyRecord{ + ID: "alice-key", + Name: "Alice", + KeyHash: "sha256:" + SHA256Hex("cpa_live"), + Enabled: true, + Preview: HashPreview(SHA256Hex("cpa_live")), + } + if err := store.UpsertKey(ctx, key); err != nil { + t.Fatal(err) + } + if err := store.SetArchived(ctx, key.ID, true, time.Unix(1234, 0)); err != nil { + t.Fatal(err) + } + keys, err := store.ListKeys(ctx) + if err != nil || len(keys) != 1 { + t.Fatalf("keys err=%v keys=%#v", err, keys) + } + if !keys[0].Archived || keys[0].ArchivedAt != 1234 { + t.Fatalf("archive fields = %#v", keys[0]) + } + if err := store.SetArchived(ctx, key.ID, false, time.Unix(1300, 0)); err != nil { + t.Fatal(err) + } + keys, err = store.ListKeys(ctx) + if err != nil || len(keys) != 1 { + t.Fatalf("keys err=%v keys=%#v", err, keys) + } + if keys[0].Archived || keys[0].ArchivedAt != 0 { + t.Fatalf("restore fields = %#v", keys[0]) + } +} + func TestStoreImportsLegacyQuotaSQLite(t *testing.T) { ctx := context.Background() dir := t.TempDir() diff --git a/cpa_key_policy_plus_plugin/go/internal/policyplus/store.go b/cpa_key_policy_plus_plugin/go/internal/policyplus/store.go index 3b309ce..bf26d3c 100644 --- a/cpa_key_policy_plus_plugin/go/internal/policyplus/store.go +++ b/cpa_key_policy_plus_plugin/go/internal/policyplus/store.go @@ -105,6 +105,8 @@ func (s *Store) EnsureSchema(ctx context.Context) error { daily_limit_usd real, weekly_limit_usd real, monthly_limit_usd real, + archived integer not null default 0, + archived_at integer not null default 0, updated_at integer not null )`, `create table if not exists reset_watermarks ( @@ -191,6 +193,8 @@ func (s *Store) EnsureSchema(ctx context.Context) error { } if err := s.ensureColumns(ctx, "keys", map[string]string{ "max_active_sessions": "integer default 0", + "archived": "integer not null default 0", + "archived_at": "integer not null default 0", }); err != nil { return err } @@ -239,8 +243,8 @@ func (s *Store) UpsertKey(ctx context.Context, key KeyRecord) error { ctx, `insert into keys( id, name, key_hash, enabled, preview, rpm, concurrency, max_active_sessions, models_json, prices_json, - five_hour_limit_usd, daily_limit_usd, weekly_limit_usd, monthly_limit_usd, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + five_hour_limit_usd, daily_limit_usd, weekly_limit_usd, monthly_limit_usd, archived, archived_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) on conflict(id) do update set name=excluded.name, key_hash=excluded.key_hash, @@ -255,6 +259,8 @@ func (s *Store) UpsertKey(ctx context.Context, key KeyRecord) error { daily_limit_usd=excluded.daily_limit_usd, weekly_limit_usd=excluded.weekly_limit_usd, monthly_limit_usd=coalesce(keys.monthly_limit_usd, excluded.monthly_limit_usd), + archived=case when excluded.archived != 0 then excluded.archived else keys.archived end, + archived_at=case when excluded.archived != 0 then excluded.archived_at else keys.archived_at end, updated_at=excluded.updated_at`, key.ID, key.Name, @@ -270,6 +276,8 @@ func (s *Store) UpsertKey(ctx context.Context, key KeyRecord) error { key.DailyLimitUSD, key.WeeklyLimitUSD, key.MonthlyLimitUSD, + boolInt(key.Archived), + key.ArchivedAt, time.Now().Unix(), ) return err @@ -512,7 +520,7 @@ func (s *Store) syncDeletedKeys(ctx context.Context, seen map[string]bool) error func (s *Store) ListKeys(ctx context.Context) ([]KeyRecord, error) { rows, err := s.db.QueryContext(ctx, `select id, name, key_hash, enabled, preview, rpm, concurrency, max_active_sessions, models_json, prices_json, - five_hour_limit_usd, daily_limit_usd, weekly_limit_usd, monthly_limit_usd from keys order by name collate nocase`) + five_hour_limit_usd, daily_limit_usd, weekly_limit_usd, monthly_limit_usd, archived, archived_at from keys order by name collate nocase`) if err != nil { return nil, err } @@ -520,16 +528,18 @@ func (s *Store) ListKeys(ctx context.Context) ([]KeyRecord, error) { var out []KeyRecord for rows.Next() { var key KeyRecord - var enabled int + var enabled, archived int var modelsJSON, pricesJSON string var fiveHour, daily, weekly, monthly sql.NullFloat64 if err := rows.Scan( &key.ID, &key.Name, &key.KeyHash, &enabled, &key.Preview, &key.RPM, &key.Concurrency, &key.MaxActiveSessions, &modelsJSON, &pricesJSON, &fiveHour, &daily, &weekly, &monthly, + &archived, &key.ArchivedAt, ); err != nil { return nil, err } key.Enabled = enabled != 0 + key.Archived = archived != 0 key.FiveHourUSD = nullFloatPtr(fiveHour) key.DailyLimitUSD = nullFloatPtr(daily) key.WeeklyLimitUSD = nullFloatPtr(weekly) @@ -541,6 +551,29 @@ func (s *Store) ListKeys(ctx context.Context) ([]KeyRecord, error) { return out, rows.Err() } +func (s *Store) SetArchived(ctx context.Context, id string, archived bool, at time.Time) error { + if strings.TrimSpace(id) == "" { + return fmt.Errorf("missing key id") + } + archivedAt := int64(0) + if archived { + archivedAt = at.Unix() + } + res, err := s.db.ExecContext(ctx, `update keys set archived=?, archived_at=?, updated_at=? where id=?`, + boolInt(archived), archivedAt, time.Now().Unix(), id) + if err != nil { + return err + } + if affected, _ := res.RowsAffected(); affected == 0 { + return fmt.Errorf("unknown key: %s", id) + } + action := "archive_key" + if !archived { + action = "restore_key" + } + return s.Audit(ctx, "admin", action, id, map[string]any{"archived": archived, "archived_at": archivedAt}) +} + func (s *Store) FindKeyByHash(ctx context.Context, hash string) (KeyRecord, bool, error) { keys, err := s.ListKeys(ctx) if err != nil { diff --git a/cpa_key_policy_plus_plugin/go/main.go b/cpa_key_policy_plus_plugin/go/main.go index 0f7f8fc..3405659 100644 --- a/cpa_key_policy_plus_plugin/go/main.go +++ b/cpa_key_policy_plus_plugin/go/main.go @@ -128,7 +128,42 @@ func runPreviewIfRequested() { "gpt-5.5": {Model: "gpt-5.5", InputPerMillion: 5, OutputPerMillion: 30, CacheReadPerMillion: 0.5}, }, } + disabledKey := policyplus.KeyRecord{ + ID: "preview-disabled", + Name: "禁用演示", + KeyHash: "sha256:" + policyplus.SHA256Hex("cpa_preview_disabled_abcdefghijklmnopqrstuvwxyz0123"), + Enabled: false, + Preview: policyplus.HashPreview(policyplus.SHA256Hex("cpa_preview_disabled_abcdefghijklmnopqrstuvwxyz0123")), + RPM: 30, + Concurrency: 1, + MaxActiveSessions: 1, + Models: []string{"gpt-5.4-mini"}, + DailyLimitUSD: floatPtr(3), + } + archivedKey := policyplus.KeyRecord{ + ID: "preview-archived", + Name: "归档演示", + KeyHash: "sha256:" + policyplus.SHA256Hex("cpa_preview_archived_abcdefghijklmnopqrstuvwxyz0123"), + Enabled: true, + Preview: policyplus.HashPreview(policyplus.SHA256Hex("cpa_preview_archived_abcdefghijklmnopqrstuvwxyz0123")), + Archived: true, + ArchivedAt: time.Now().Add(-2 * time.Hour).Unix(), + Models: []string{"gpt-5.5"}, + } + noLimitKey := policyplus.KeyRecord{ + ID: "preview-no-limit", + Name: "无限额演示", + KeyHash: "sha256:" + policyplus.SHA256Hex("cpa_preview_nolimit_abcdefghijklmnopqrstuvwxyz0123"), + Enabled: true, + Preview: policyplus.HashPreview(policyplus.SHA256Hex("cpa_preview_nolimit_abcdefghijklmnopqrstuvwxyz0123")), + RPM: 0, + Concurrency: 0, + MaxActiveSessions: 0, + } _ = store.UpsertKey(context.Background(), previewKey) + _ = store.UpsertKey(context.Background(), disabledKey) + _ = store.UpsertKey(context.Background(), archivedKey) + _ = store.UpsertKey(context.Background(), noLimitKey) _ = store.InsertUsage(context.Background(), policyplus.UsageEvent{ RequestID: "req-preview-a", KeyID: previewKey.ID, @@ -180,10 +215,26 @@ func runPreviewIfRequested() { "stopped_reason": "completed", "rounds": []map[string]any{{"round": 1, "reasoning_tokens": 516, "decision": "continue", "truncation_match": true}, {"round": 2, "reasoning_tokens": 181, "decision": "clean", "truncation_match": false}}, }) + _ = store.InsertUsage(context.Background(), policyplus.UsageEvent{ + RequestID: "req-preview-disabled", + KeyID: disabledKey.ID, + KeyPreview: disabledKey.Preview, + Model: "gpt-5.4-mini", + RequestedAt: time.Now().Add(-25 * time.Minute), + Cost: 0.18, + }) + _ = store.InsertUsage(context.Background(), policyplus.UsageEvent{ + RequestID: "req-preview-archived", + KeyID: archivedKey.ID, + KeyPreview: archivedKey.Preview, + Model: "gpt-5.5", + RequestedAt: time.Now().Add(-70 * time.Minute), + Cost: 1.2, + }) state.mu.Lock() state.cfg = cfg state.store = store - state.keyState = policyplus.KeyPolicyState{Keys: []policyplus.KeyRecord{previewKey}} + state.keyState = policyplus.KeyPolicyState{Keys: []policyplus.KeyRecord{previewKey, disabledKey, archivedKey, noLimitKey}} state.rpmBuckets = map[string][]time.Time{} state.concurrency = map[string]int{} state.mu.Unlock() @@ -527,7 +578,7 @@ func frontendAuth(raw []byte) ([]byte, error) { return okEnvelope(frontendAuthResponse{Authenticated: false}) } record, ok := findKeyByRaw(key) - if !ok || !record.Enabled { + if !ok || !record.Enabled || record.Archived { return okEnvelope(frontendAuthResponse{Authenticated: false}) } model := requestedModelFromBody(req.Body) @@ -575,15 +626,15 @@ func lookupKeyByRaw(rawKey string) (policyplus.KeyRecord, bool) { keyState := state.keyState store := state.store state.mu.RUnlock() - if key, ok := keyState.FindByRawKey(rawKey); ok { - return key, true - } if store != nil { key, ok, err := store.FindKeyByHash(context.Background(), policyplus.SHA256Hex(rawKey)) if err == nil && ok { return key, true } } + if key, ok := keyState.FindByRawKey(rawKey); ok { + return key, true + } return policyplus.KeyRecord{}, false } @@ -928,6 +979,7 @@ func managementRegister() ([]byte, error) { {Method: http.MethodPut, Path: "/plugins/cpa-key-policy-plus/keys/save"}, {Method: http.MethodPut, Path: "/plugins/cpa-key-policy-plus/keys/limits"}, {Method: http.MethodPost, Path: "/plugins/cpa-key-policy-plus/keys/reset"}, + {Method: http.MethodPost, Path: "/plugins/cpa-key-policy-plus/keys/archive"}, {Method: http.MethodGet, Path: "/plugins/cpa-key-policy-plus/events"}, {Method: http.MethodGet, Path: "/plugins/cpa-key-policy-plus/codexcont"}, {Method: http.MethodPut, Path: "/plugins/cpa-key-policy-plus/codexcont"}, @@ -940,6 +992,7 @@ func managementRegister() ([]byte, error) { {Path: "/admin/api/keys/save"}, {Path: "/admin/api/keys/limits"}, {Path: "/admin/api/keys/reset"}, + {Path: "/admin/api/keys/archive"}, {Path: "/admin/api/events"}, {Path: "/admin/api/codexcont"}, {Path: "/user", Description: "Self-service usage dashboard"}, @@ -976,6 +1029,8 @@ func managementHandle(raw []byte) ([]byte, error) { return adminSetLimits(req) case strings.HasSuffix(path, "/admin/api/keys/reset"): return adminReset(req) + case strings.HasSuffix(path, "/admin/api/keys/archive"): + return adminArchiveKey(req) case strings.HasSuffix(path, "/admin/api/events"): return adminEvents(req) case strings.HasSuffix(path, "/admin/api/codexcont"): @@ -1002,6 +1057,8 @@ func managementHandle(raw []byte) ([]byte, error) { return adminSetLimits(req) case strings.HasSuffix(path, "/plugins/cpa-key-policy-plus/keys/reset"): return adminReset(req) + case strings.HasSuffix(path, "/plugins/cpa-key-policy-plus/keys/archive"): + return adminArchiveKey(req) case strings.HasSuffix(path, "/plugins/cpa-key-policy-plus/events"): return adminEvents(req) case strings.HasSuffix(path, "/plugins/cpa-key-policy-plus/codexcont"): @@ -1018,6 +1075,8 @@ func managementHandle(raw []byte) ([]byte, error) { return adminSetLimits(req) case strings.HasSuffix(path, "/key-policy-plus/api/keys/reset"): return adminReset(req) + case strings.HasSuffix(path, "/key-policy-plus/api/keys/archive"): + return adminArchiveKey(req) case strings.HasSuffix(path, "/key-policy-plus/api/events"): return adminEvents(req) case strings.HasSuffix(path, "/key-policy-plus/api/codexcont"): @@ -1041,7 +1100,9 @@ func adminKeys(_ managementRequest) ([]byte, error) { now := time.Now() for _, key := range keys { row := key.Safe() - row["usage"] = usageWindows(context.Background(), store, key.ID, now) + usage := usageWindows(context.Background(), store, key.ID, now) + row["usage"] = usage + row["quota"] = quotaWindows(key, usage) if active, err := store.ActiveSessionCount(context.Background(), key.ID, policyplus.DefaultSessionIdle, now); err == nil { row["active_sessions"] = active } @@ -1346,6 +1407,24 @@ func adminReset(req managementRequest) ([]byte, error) { return jsonResponse(http.StatusOK, map[string]any{"ok": true}) } +func adminArchiveKey(req managementRequest) ([]byte, error) { + var body struct { + ID string `json:"id"` + Archived bool `json:"archived"` + } + if err := json.Unmarshal(req.Body, &body); err != nil { + return jsonResponse(http.StatusBadRequest, map[string]any{"ok": false, "error": "invalid_json"}) + } + store := loadedStore() + if store == nil { + return jsonResponse(http.StatusServiceUnavailable, map[string]any{"ok": false, "error": "store_unavailable"}) + } + if err := store.SetArchived(context.Background(), body.ID, body.Archived, time.Now()); err != nil { + return jsonResponse(http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()}) + } + return adminKeys(req) +} + func adminEvents(req managementRequest) ([]byte, error) { keyID := req.Query.Get("key_id") if keyID == "" { @@ -1413,7 +1492,10 @@ func userSession(req managementRequest) ([]byte, error) { return jsonResponse(http.StatusUnauthorized, map[string]any{"ok": false, "error": hint.Error, "message": hint.Message}) } if !record.Enabled { - return jsonResponse(http.StatusForbidden, map[string]any{"ok": false, "error": "api_key_disabled", "message": "这个 Key 当前已禁用,请联系管理员。"}) + return jsonResponse(http.StatusForbidden, map[string]any{"ok": false, "error": "api_key_disabled", "category": "auth", "message": "这个 Key 当前已禁用,请联系管理员。"}) + } + if record.Archived { + return jsonResponse(http.StatusForbidden, map[string]any{"ok": false, "error": "api_key_archived", "category": "auth", "message": "这个 Key 已归档,不能再登录或调用,请联系管理员恢复。"}) } cfg := loadedConfig() token, err := policyplus.SignSession(policyplus.SessionPayload{ @@ -1455,11 +1537,13 @@ func userSubmittedKey(req managementRequest) string { func userMe(req managementRequest) ([]byte, error) { key, ok := keyFromSession(req) if !ok { - return jsonResponse(http.StatusUnauthorized, map[string]any{"ok": false, "error": "not_authenticated"}) + return jsonResponse(http.StatusUnauthorized, map[string]any{"ok": false, "error": "not_authenticated", "category": "auth", "message": "会话已过期,请重新登录。"}) } row := key.Safe() if store := loadedStore(); store != nil { - row["usage"] = usageWindows(context.Background(), store, key.ID, time.Now()) + usage := usageWindows(context.Background(), store, key.ID, time.Now()) + row["usage"] = usage + row["quota"] = quotaWindows(key, usage) } return jsonResponse(http.StatusOK, map[string]any{"ok": true, "me": row}) } @@ -1467,7 +1551,7 @@ func userMe(req managementRequest) ([]byte, error) { func userUsage(req managementRequest) ([]byte, error) { key, ok := keyFromSession(req) if !ok { - return jsonResponse(http.StatusUnauthorized, map[string]any{"ok": false, "error": "not_authenticated"}) + return jsonResponse(http.StatusUnauthorized, map[string]any{"ok": false, "error": "not_authenticated", "category": "auth", "message": "会话已过期,请重新登录。"}) } store := loadedStore() if store == nil { @@ -1504,7 +1588,7 @@ func userUsage(req managementRequest) ([]byte, error) { func userEvents(req managementRequest) ([]byte, error) { key, ok := keyFromSession(req) if !ok { - return jsonResponse(http.StatusUnauthorized, map[string]any{"ok": false, "error": "not_authenticated"}) + return jsonResponse(http.StatusUnauthorized, map[string]any{"ok": false, "error": "not_authenticated", "category": "auth", "message": "会话已过期,请重新登录。"}) } return eventsResponseFromRequest(req, key.ID) } @@ -1512,7 +1596,7 @@ func userEvents(req managementRequest) ([]byte, error) { func userCodexCont(req managementRequest) ([]byte, error) { key, ok := keyFromSession(req) if !ok { - return jsonResponse(http.StatusUnauthorized, map[string]any{"ok": false, "error": "not_authenticated"}) + return jsonResponse(http.StatusUnauthorized, map[string]any{"ok": false, "error": "not_authenticated", "category": "auth", "message": "会话已过期,请重新登录。"}) } limit := 80 if rawLimit := strings.TrimSpace(req.Query.Get("limit")); rawLimit != "" { @@ -1574,6 +1658,40 @@ func usageWindows(ctx context.Context, store *policyplus.Store, keyID string, no return out } +func quotaWindows(key policyplus.KeyRecord, usage map[string]float64) map[string]map[string]any { + limits := map[string]*float64{ + policyplus.Range5H: key.FiveHourUSD, + policyplus.Range24H: key.DailyLimitUSD, + policyplus.Range7D: key.WeeklyLimitUSD, + policyplus.RangeMonth: key.MonthlyLimitUSD, + } + out := map[string]map[string]any{} + for _, name := range []string{policyplus.Range5H, policyplus.Range24H, policyplus.Range7D, policyplus.RangeMonth} { + used := usage[name] + row := map[string]any{ + "used_usd": used, + "limit_usd": nil, + "remaining_usd": nil, + "percent": nil, + } + if limit := limits[name]; limit != nil { + remaining := *limit - used + if remaining < 0 { + remaining = 0 + } + percent := 0.0 + if *limit > 0 { + percent = used / *limit + } + row["limit_usd"] = *limit + row["remaining_usd"] = remaining + row["percent"] = percent + } + out[name] = row + } + return out +} + func codexRequestsForKey(key policyplus.KeyRecord, limit int) ([]map[string]any, string) { if requests, ok := fetchCodexContRequests(key, limit); ok { return requests, "codexcont_admin" @@ -1816,7 +1934,7 @@ func keyFromSession(req managementRequest) (policyplus.KeyRecord, bool) { for _, key := range keys { currentHash, errCurrent := policyplus.NormalizeHash(key.KeyHash) sessionHash, errSession := policyplus.NormalizeHash(payload.KeyHash) - if key.ID == payload.KeyID && key.Enabled && errCurrent == nil && errSession == nil && currentHash == sessionHash { + if key.ID == payload.KeyID && key.Enabled && !key.Archived && errCurrent == nil && errSession == nil && currentHash == sessionHash { return key, true } } diff --git a/cpa_key_policy_plus_plugin/go/main_test.go b/cpa_key_policy_plus_plugin/go/main_test.go index 556fa6f..93cfd61 100644 --- a/cpa_key_policy_plus_plugin/go/main_test.go +++ b/cpa_key_policy_plus_plugin/go/main_test.go @@ -257,6 +257,75 @@ func TestManagementAliasCreateSaveAndReset(t *testing.T) { } } +func TestArchiveKeyRejectsAuthAndUserSession(t *testing.T) { + key := setupTestState(t) + archiveRaw, _ := json.Marshal(map[string]any{"id": key.ID, "archived": true}) + raw, err := managementHandle(mustJSON(t, managementRequest{ + Method: http.MethodPost, + Path: "/key-policy-plus/api/keys/archive", + Body: archiveRaw, + })) + if err != nil { + t.Fatal(err) + } + body := decodeManagementBody(t, raw) + if !strings.Contains(string(body), `"archived":true`) { + t.Fatalf("archive response missing archived flag: %s", body) + } + if authOK(t, frontendAuthRequest{ + Headers: http.Header{"Authorization": []string{"Bearer cpa_alice_secret"}}, + Body: []byte(`{"model":"gpt-5.5","prompt_cache_key":"window-a"}`), + }) { + t.Fatal("archived key should not authenticate frontend requests") + } + raw, err = userSession(managementRequest{Headers: http.Header{"X-CPA-Key-Policy-Plus-Key": []string{"cpa_alice_secret"}}}) + if err != nil { + t.Fatal(err) + } + body = decodeManagementBody(t, raw) + if !strings.Contains(string(body), "api_key_archived") { + t.Fatalf("archived user session should fail clearly: %s", body) + } + restoreRaw, _ := json.Marshal(map[string]any{"id": key.ID, "archived": false}) + if _, err := managementHandle(mustJSON(t, managementRequest{ + Method: http.MethodPost, + Path: "/key-policy-plus/api/keys/archive", + Body: restoreRaw, + })); err != nil { + t.Fatal(err) + } + if !authOK(t, frontendAuthRequest{ + Headers: http.Header{"Authorization": []string{"Bearer cpa_alice_secret"}}, + Body: []byte(`{"model":"gpt-5.5","prompt_cache_key":"window-a"}`), + }) { + t.Fatal("restored key should authenticate again") + } +} + +func TestAdminKeysIncludesQuotaProjection(t *testing.T) { + setupTestState(t) + store := loadedStore() + if err := store.InsertUsage(context.Background(), policyplus.UsageEvent{ + RequestID: "usage-a", + KeyID: "alice-key", + RequestedAt: time.Now().Add(-time.Hour), + Cost: 2.5, + }); err != nil { + t.Fatal(err) + } + raw, err := adminKeys(managementRequest{}) + if err != nil { + t.Fatal(err) + } + body := decodeManagementBody(t, raw) + text := string(body) + for _, want := range []string{`"usage"`, `"quota"`, `"24h"`, `"remaining_usd"`} { + if !strings.Contains(text, want) { + t.Fatalf("admin key projection missing %s: %s", want, body) + } + } +} + func TestAdminModelsFallsBackToConfiguredModels(t *testing.T) { key := setupTestState(t) key.Models = []string{"gpt-5.5", "legacy-custom"} From 3fc7877cc422e492576406fa0ab7a6ce9a2f22d9 Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Thu, 2 Jul 2026 22:57:33 +0800 Subject: [PATCH 44/68] chore(task): archive 07-02-cpa-usage-key-policy-plus-ux-fixes --- .../07-02-cpa-usage-key-policy-plus-ux-fixes/check.jsonl | 0 .../07-02-cpa-usage-key-policy-plus-ux-fixes/design.md | 0 .../07-02-cpa-usage-key-policy-plus-ux-fixes/implement.jsonl | 0 .../07-02-cpa-usage-key-policy-plus-ux-fixes/implement.md | 0 .../2026-07}/07-02-cpa-usage-key-policy-plus-ux-fixes/prd.md | 0 .../07-02-cpa-usage-key-policy-plus-ux-fixes/task.json | 4 ++-- 6 files changed, 2 insertions(+), 2 deletions(-) rename .trellis/tasks/{ => archive/2026-07}/07-02-cpa-usage-key-policy-plus-ux-fixes/check.jsonl (100%) rename .trellis/tasks/{ => archive/2026-07}/07-02-cpa-usage-key-policy-plus-ux-fixes/design.md (100%) rename .trellis/tasks/{ => archive/2026-07}/07-02-cpa-usage-key-policy-plus-ux-fixes/implement.jsonl (100%) rename .trellis/tasks/{ => archive/2026-07}/07-02-cpa-usage-key-policy-plus-ux-fixes/implement.md (100%) rename .trellis/tasks/{ => archive/2026-07}/07-02-cpa-usage-key-policy-plus-ux-fixes/prd.md (100%) rename .trellis/tasks/{ => archive/2026-07}/07-02-cpa-usage-key-policy-plus-ux-fixes/task.json (90%) diff --git a/.trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/check.jsonl b/.trellis/tasks/archive/2026-07/07-02-cpa-usage-key-policy-plus-ux-fixes/check.jsonl similarity index 100% rename from .trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/check.jsonl rename to .trellis/tasks/archive/2026-07/07-02-cpa-usage-key-policy-plus-ux-fixes/check.jsonl diff --git a/.trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/design.md b/.trellis/tasks/archive/2026-07/07-02-cpa-usage-key-policy-plus-ux-fixes/design.md similarity index 100% rename from .trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/design.md rename to .trellis/tasks/archive/2026-07/07-02-cpa-usage-key-policy-plus-ux-fixes/design.md diff --git a/.trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/implement.jsonl b/.trellis/tasks/archive/2026-07/07-02-cpa-usage-key-policy-plus-ux-fixes/implement.jsonl similarity index 100% rename from .trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/implement.jsonl rename to .trellis/tasks/archive/2026-07/07-02-cpa-usage-key-policy-plus-ux-fixes/implement.jsonl diff --git a/.trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/implement.md b/.trellis/tasks/archive/2026-07/07-02-cpa-usage-key-policy-plus-ux-fixes/implement.md similarity index 100% rename from .trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/implement.md rename to .trellis/tasks/archive/2026-07/07-02-cpa-usage-key-policy-plus-ux-fixes/implement.md diff --git a/.trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/prd.md b/.trellis/tasks/archive/2026-07/07-02-cpa-usage-key-policy-plus-ux-fixes/prd.md similarity index 100% rename from .trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/prd.md rename to .trellis/tasks/archive/2026-07/07-02-cpa-usage-key-policy-plus-ux-fixes/prd.md diff --git a/.trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/task.json b/.trellis/tasks/archive/2026-07/07-02-cpa-usage-key-policy-plus-ux-fixes/task.json similarity index 90% rename from .trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/task.json rename to .trellis/tasks/archive/2026-07/07-02-cpa-usage-key-policy-plus-ux-fixes/task.json index 09ad6ac..ec183b7 100644 --- a/.trellis/tasks/07-02-cpa-usage-key-policy-plus-ux-fixes/task.json +++ b/.trellis/tasks/archive/2026-07/07-02-cpa-usage-key-policy-plus-ux-fixes/task.json @@ -3,7 +3,7 @@ "name": "cpa-usage-key-policy-plus-ux-fixes", "title": "CPA Usage and Key Policy Plus UX fixes", "description": "", - "status": "in_progress", + "status": "completed", "dev_type": null, "scope": null, "package": null, @@ -11,7 +11,7 @@ "creator": "dxt98", "assignee": "dxt98", "createdAt": "2026-07-02", - "completedAt": null, + "completedAt": "2026-07-02", "branch": null, "base_branch": "main", "worktree_path": null, From 6a851431ca32dd5c41c7223adfffe08112c649e5 Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Thu, 2 Jul 2026 22:57:46 +0800 Subject: [PATCH 45/68] chore: record journal --- .trellis/workspace/dxt98/index.md | 5 ++-- .trellis/workspace/dxt98/journal-1.md | 33 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/.trellis/workspace/dxt98/index.md b/.trellis/workspace/dxt98/index.md index 7797318..cf87538 100644 --- a/.trellis/workspace/dxt98/index.md +++ b/.trellis/workspace/dxt98/index.md @@ -8,7 +8,7 @@ - **Active File**: `journal-1.md` -- **Total Sessions**: 8 +- **Total Sessions**: 9 - **Last Active**: 2026-07-02 @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~278 | Active | +| `journal-1.md` | ~311 | Active | --- @@ -29,6 +29,7 @@ | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 9 | 2026-07-02 | CPA Key Policy Plus UX stability | `57aadff` | `main` | | 8 | 2026-07-02 | CPA Key Policy Plus admin fixes | `4c6613b` | `main` | | 7 | 2026-07-02 | CPA Key Policy Plus cutover | `160af51` | `main` | | 6 | 2026-07-02 | CPA Governor and CodexCont engine rollout | `429bed5`, `d9047c8`, `50e77d0`, `69e429e` | `main` | diff --git a/.trellis/workspace/dxt98/journal-1.md b/.trellis/workspace/dxt98/journal-1.md index 4bd566f..481eb74 100644 --- a/.trellis/workspace/dxt98/journal-1.md +++ b/.trellis/workspace/dxt98/journal-1.md @@ -276,3 +276,36 @@ Fixed Key Policy+ admin create/save/reset transport, added model discovery and s ### Next Steps - None - task complete + + +## Session 9: CPA Key Policy Plus UX stability + +**Date**: 2026-07-02 +**Task**: CPA Key Policy Plus UX stability +**Branch**: `main` + +### Summary + +Stabilized CPA Key Policy+ admin and user UX, added archive/restore lifecycle, improved user refresh error recovery, deployed the linux/amd64 plugin to SJC, and verified cpa-usage production login, refresh, tabs, and route boundaries. + +### Main Changes + +(Add details) + +### Git Commits + +| Hash | Message | +|------|---------| +| `57aadff` | (see git log) | + +### Testing + +- [OK] (Add test results) + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete From 793b4ae31e8872516ed38b8644198773c7a05834 Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Thu, 2 Jul 2026 23:20:42 +0800 Subject: [PATCH 46/68] fix: tolerate stale Key Policy Plus session cookies --- cpa_key_policy_plus_plugin/go/main.go | 57 +++++++++++++++++----- cpa_key_policy_plus_plugin/go/main_test.go | 40 +++++++++++++++ 2 files changed, 86 insertions(+), 11 deletions(-) diff --git a/cpa_key_policy_plus_plugin/go/main.go b/cpa_key_policy_plus_plugin/go/main.go index 3405659..0412774 100644 --- a/cpa_key_policy_plus_plugin/go/main.go +++ b/cpa_key_policy_plus_plugin/go/main.go @@ -21,7 +21,10 @@ import ( "gopkg.in/yaml.v3" ) -const pluginID = "cpa-key-policy-plus" +const ( + pluginID = "cpa-key-policy-plus" + plusSessionCookieName = "cpa_key_policy_plus_session" +) //go:embed assets/admin.html var adminHTMLTemplate string @@ -1515,13 +1518,33 @@ func userSession(req managementRequest) ([]byte, error) { Headers: http.Header{ "Content-Type": []string{"application/json; charset=utf-8"}, "Cache-Control": []string{"no-store"}, - "Set-Cookie": []string{fmt.Sprintf("cpa_key_policy_plus_session=%s; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400", token)}, + "Set-Cookie": userSessionSetCookies(token), }, Body: body, } return okEnvelope(resp) } +func userSessionSetCookies(token string) []string { + paths := []string{ + "/", + "/v0/resource/plugins/cpa-key-policy-plus/user", + "/key-policy-plus-user", + } + out := make([]string, 0, len(paths)) + for _, path := range paths { + out = append(out, (&http.Cookie{ + Name: plusSessionCookieName, + Value: token, + Path: path, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: int(policyplus.SessionTTL().Seconds()), + }).String()) + } + return out +} + func userSubmittedKey(req managementRequest) string { raw := firstNonEmpty( headerFirst(req.Headers, "X-CPA-Key-Policy-Plus-Key"), @@ -1905,21 +1928,33 @@ func forwardHostStream(targetStreamID string, sourceStreamID string) { func keyFromSession(req managementRequest) (policyplus.KeyRecord, bool) { _ = refreshKeyPolicyState(false) - cookie := headerFirst(req.Headers, "Cookie") - token := "" + tokens := sessionTokensFromCookie(headerFirst(req.Headers, "Cookie")) + cfg := loadedConfig() + for _, token := range tokens { + if key, ok := keyFromSessionToken(token, cfg.SessionSecret); ok { + return key, true + } + } + return policyplus.KeyRecord{}, false +} + +func sessionTokensFromCookie(cookie string) []string { + var tokens []string for _, part := range strings.Split(cookie, ";") { part = strings.TrimSpace(part) - if strings.HasPrefix(part, "cpa_key_policy_plus_session=") { - token = strings.TrimPrefix(part, "cpa_key_policy_plus_session=") - break + if strings.HasPrefix(part, plusSessionCookieName+"=") { + tokens = append(tokens, strings.TrimPrefix(part, plusSessionCookieName+"=")) + continue } if strings.HasPrefix(part, "cpa_governor_session=") { - token = strings.TrimPrefix(part, "cpa_governor_session=") - break + tokens = append(tokens, strings.TrimPrefix(part, "cpa_governor_session=")) } } - cfg := loadedConfig() - payload, ok := policyplus.VerifySession(token, cfg.SessionSecret, time.Now()) + return tokens +} + +func keyFromSessionToken(token string, secret string) (policyplus.KeyRecord, bool) { + payload, ok := policyplus.VerifySession(token, secret, time.Now()) if !ok { return policyplus.KeyRecord{}, false } diff --git a/cpa_key_policy_plus_plugin/go/main_test.go b/cpa_key_policy_plus_plugin/go/main_test.go index 93cfd61..2b57a0b 100644 --- a/cpa_key_policy_plus_plugin/go/main_test.go +++ b/cpa_key_policy_plus_plugin/go/main_test.go @@ -405,6 +405,46 @@ func TestSessionInvalidatedWhenKeyHashChanges(t *testing.T) { } } +func TestSessionCookiePathCompatibility(t *testing.T) { + setupTestState(t) + raw, err := userSession(managementRequest{Headers: http.Header{"X-CPA-Key-Policy-Plus-Key": []string{"cpa_alice_secret"}}}) + if err != nil { + t.Fatal(err) + } + var env envelope + if err := json.Unmarshal(raw, &env); err != nil { + t.Fatal(err) + } + var resp managementResponse + if err := json.Unmarshal(env.Result, &resp); err != nil { + t.Fatal(err) + } + cookies := resp.Headers.Values("Set-Cookie") + for _, want := range []string{ + "Path=/;", + "Path=/v0/resource/plugins/cpa-key-policy-plus/user;", + "Path=/key-policy-plus-user;", + } { + if !strings.Contains(strings.Join(cookies, "\n"), want) { + t.Fatalf("session response should set compatible cookie path %s: %#v", want, cookies) + } + } + valid := "" + for _, rawCookie := range cookies { + if strings.Contains(rawCookie, "Path=/;") { + valid = strings.SplitN(rawCookie, ";", 2)[0] + break + } + } + if valid == "" { + t.Fatalf("root session cookie not found: %#v", cookies) + } + staleFirst := "cpa_key_policy_plus_session=stale-invalid-token; " + valid + if _, ok := keyFromSession(managementRequest{Headers: http.Header{"Cookie": []string{staleFirst}}}); !ok { + t.Fatal("fresh session should resolve even when a stale path-specific cookie is sent first") + } +} + func authOK(t *testing.T, req frontendAuthRequest) bool { t.Helper() rawReq, _ := json.Marshal(req) From a5ddecb26ca3deb744c75ba87aaf4a98f2cfe03b Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Thu, 2 Jul 2026 23:28:12 +0800 Subject: [PATCH 47/68] docs: capture Key Policy Plus session cookie contract --- .../backend/codex-continuation-contracts.md | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/.trellis/spec/backend/codex-continuation-contracts.md b/.trellis/spec/backend/codex-continuation-contracts.md index 340c47e..ea3305f 100644 --- a/.trellis/spec/backend/codex-continuation-contracts.md +++ b/.trellis/spec/backend/codex-continuation-contracts.md @@ -458,6 +458,11 @@ credential, while CPAMP remains admin-only. - User route: `https://cpa-usage.konbakuyomu.us/`. - User API session creation is GET-only on CPA resource routes and sends the raw user key in `X-CPA-Key-Policy-Plus-Key`; do not put the key in the URL. +- User session cookie name: `cpa_key_policy_plus_session`. Session creation + must refresh compatible cookies for `/`, + `/v0/resource/plugins/cpa-key-policy-plus/user`, and + `/key-policy-plus-user` so stale path-specific cookies from earlier routes + do not survive a successful login. ### 3. Contracts - Plus is the ordinary `cpa_...` key authority after cutover. The old @@ -470,6 +475,11 @@ credential, while CPAMP remains admin-only. - Plus must never store or return raw API keys, Authorization headers, full key hashes, cookies, request bodies, response bodies, OAuth tokens, or encrypted reasoning content. +- User session validation must tolerate multiple cookies with the same + `cpa_key_policy_plus_session` name. Browsers may send both a stale + path-specific cookie and a fresh root cookie for plugin resource API paths; + the backend must try every candidate session token and accept the first valid + one instead of failing on the first invalid token. - CPA plugin `ResourceRoute` is GET-only in the current host. The Plus admin HTML may be served from a resource route, but create/save/reset mutations must go through `/key-policy-plus/api/*` -> CPA management routes. Do not @@ -513,6 +523,9 @@ credential, while CPAMP remains admin-only. - Old Key Policy enabled alongside Plus exclusive auth -> ordinary key authority is ambiguous; disable old Key Policy before accepting cutover. - Valid full `cpa_...` key -> `/v1/models` and user session login succeed. +- Valid full `cpa_...` key plus a stale same-name path-specific session cookie + -> user session login and `/user/api/me` still succeed; stale cookies must + not create a persistent `not_authenticated` loop after a successful login. - Shortened key preview or rotated full key -> login fails; only the full key shown at create/rotation can match the stored hash. - Disabled/disallowed/over-RPM/over-concurrency/over-quota request -> CPA @@ -538,6 +551,10 @@ credential, while CPAMP remains admin-only. - Good: CPA logs show Governor plus `cpa-key-policy-plus` loaded, Plus DB has imported keys, `cpa-usage` login works with a current full key, and `/v1/responses` still succeeds through the known-good CodexCont sidecar. +- Good: A browser with an old + `Path=/v0/resource/plugins/cpa-key-policy-plus/user` session cookie can log + in again; the new response refreshes all compatible paths and `/api/me` + accepts the fresh token even if the stale token is sent first. - Base: A key has no usage yet. User login still shows key metadata, configured models, prices, and empty usage tables. - Good: The admin page is a resource HTML page, while its mutations use @@ -559,6 +576,10 @@ credential, while CPAMP remains admin-only. audit, rolling/natural-month quota windows, soft reset, and cost projection. - Go unit: admin/user HTML resources are `no-store`, user login uses `X-CPA-Key-Policy-Plus-Key`, and responses do not leak raw keys/full hashes. +- Go unit: user session creation sets `cpa_key_policy_plus_session` cookies on + the root path and known user-resource aliases, and session lookup succeeds + when a stale same-name cookie appears before a fresh valid cookie in the + `Cookie` header. - Go unit: user events and CodexCont summaries are filtered to the current key. - Go unit: admin HTML points mutations at `/key-policy-plus/api`, model normalization preserves unknown configured models, and create/save/reset @@ -609,6 +630,25 @@ admin proxy -> /v0/management/plugins/cpa-key-policy-plus/keys/create The admin proxy injects the CPA management key from a mounted secret or process environment, and the public API host still blocks `/key-policy-plus*`. +#### Wrong +```text +Cookie: cpa_key_policy_plus_session=stale-path-token; cpa_key_policy_plus_session=fresh-root-token +backend -> verify only the first same-name cookie -> 401 not_authenticated +``` + +Browser cookie path precedence can put an older path-specific cookie before the +fresh root cookie on plugin resource API requests, causing a login loop even +after session creation succeeds. + +#### Correct +```text +session creation -> Set-Cookie for root and known plugin/user aliases +session lookup -> collect all same-name session cookies -> accept first valid token +``` + +The backend must treat same-name cookies as a compatibility set, not as a +single trusted value. + Separate the key authority migration from the continuation-owner migration. ## Scenario: CPA Governor plugin and CodexCont Engine rollout From 5395b0d7d31f22a8614df12de282716561d6822b Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Thu, 2 Jul 2026 23:28:26 +0800 Subject: [PATCH 48/68] chore: record journal --- .trellis/workspace/dxt98/index.md | 5 ++-- .trellis/workspace/dxt98/journal-1.md | 34 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/.trellis/workspace/dxt98/index.md b/.trellis/workspace/dxt98/index.md index cf87538..ce7552b 100644 --- a/.trellis/workspace/dxt98/index.md +++ b/.trellis/workspace/dxt98/index.md @@ -8,7 +8,7 @@ - **Active File**: `journal-1.md` -- **Total Sessions**: 9 +- **Total Sessions**: 10 - **Last Active**: 2026-07-02 @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~311 | Active | +| `journal-1.md` | ~345 | Active | --- @@ -29,6 +29,7 @@ | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 10 | 2026-07-02 | Key Policy Plus session cookie hotfix | `793b4ae`, `a5ddecb` | `main` | | 9 | 2026-07-02 | CPA Key Policy Plus UX stability | `57aadff` | `main` | | 8 | 2026-07-02 | CPA Key Policy Plus admin fixes | `4c6613b` | `main` | | 7 | 2026-07-02 | CPA Key Policy Plus cutover | `160af51` | `main` | diff --git a/.trellis/workspace/dxt98/journal-1.md b/.trellis/workspace/dxt98/journal-1.md index 481eb74..550b36d 100644 --- a/.trellis/workspace/dxt98/journal-1.md +++ b/.trellis/workspace/dxt98/journal-1.md @@ -309,3 +309,37 @@ Stabilized CPA Key Policy+ admin and user UX, added archive/restore lifecycle, i ### Next Steps - None - task complete + + +## Session 10: Key Policy Plus session cookie hotfix + +**Date**: 2026-07-02 +**Task**: Key Policy Plus session cookie hotfix +**Branch**: `main` + +### Summary + +Diagnosed cpa-usage login loops caused by stale path-specific Key Policy Plus session cookies; deployed a plugin hotfix that refreshes compatible cookie paths and accepts the first valid same-name session token; captured the cookie-path contract in backend spec. + +### Main Changes + +(Add details) + +### Git Commits + +| Hash | Message | +|------|---------| +| `793b4ae` | (see git log) | +| `a5ddecb` | (see git log) | + +### Testing + +- [OK] (Add test results) + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete From 30fa42b63c7fc897036bcc569316a1946aa500b1 Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Fri, 3 Jul 2026 00:26:24 +0800 Subject: [PATCH 49/68] fix: retire key policy session limits --- .../backend/codex-continuation-contracts.md | 91 +++++++++--- cpa_governor_plugin/README.md | 8 +- cpa_governor_plugin/go/assets/shared.css | 9 ++ cpa_governor_plugin/go/assets/user.html | 22 +-- cpa_governor_plugin/go/main.go | 40 +----- cpa_governor_plugin/go/main_test.go | 14 +- cpa_key_policy_plus_plugin/README.md | 7 +- .../go/assets/admin.html | 68 ++++----- .../go/assets/shared.css | 9 ++ .../go/assets/user.html | 22 +-- .../go/internal/policyplus/models.go | 4 +- .../go/internal/policyplus/policyplus_test.go | 58 ++++++-- .../go/internal/policyplus/store.go | 34 +++++ cpa_key_policy_plus_plugin/go/main.go | 134 +++++------------- cpa_key_policy_plus_plugin/go/main_test.go | 71 ++++++---- 15 files changed, 311 insertions(+), 280 deletions(-) diff --git a/.trellis/spec/backend/codex-continuation-contracts.md b/.trellis/spec/backend/codex-continuation-contracts.md index ea3305f..c60f389 100644 --- a/.trellis/spec/backend/codex-continuation-contracts.md +++ b/.trellis/spec/backend/codex-continuation-contracts.md @@ -423,8 +423,8 @@ credential, while CPAMP remains admin-only. ### 1. Scope / Trigger - Trigger this spec whenever work touches `cpa_key_policy_plus_plugin/`, the `cpa-key-policy-plus` CPA plugin config, `cpa-usage.konbakuyomu.us`, per-key - quota windows, active Codex window limits, or migration from the old - `cpa-key-policy` plugin. + quota windows, hard key deletion, or migration from the old `cpa-key-policy` + plugin. - This is cross-layer work: CPA dynamic plugin loading, old Key Policy JSON import, Plus SQLite state, Caddy public/admin routing, user cookies, and CodexCont/Governor deployment boundaries must agree. @@ -439,7 +439,8 @@ credential, while CPAMP remains admin-only. `codexcont_enabled`, `codexcont_route`, `codexcont_url`, and `fail_mode`. - SQLite tables owned by Plus: `keys`, `usage_events`, `reset_watermarks`, `active_requests`, `active_sessions`, `audit_log`, `codexcont_summaries`, - and `settings`. + and `settings`. `active_sessions` is retained for schema compatibility and + delete cleanup, but is not an enforcement source after the RPM-only cutover. - Admin resource: `GET /v0/resource/plugins/cpa-key-policy-plus/admin`. - Admin management routes: - `GET /v0/management/plugins/cpa-key-policy-plus/keys` @@ -448,6 +449,7 @@ credential, while CPAMP remains admin-only. - `PUT /v0/management/plugins/cpa-key-policy-plus/keys/save` - `PUT /v0/management/plugins/cpa-key-policy-plus/keys/limits` - `POST /v0/management/plugins/cpa-key-policy-plus/keys/reset` + - `POST /v0/management/plugins/cpa-key-policy-plus/keys/delete` - User resource: `GET /v0/resource/plugins/cpa-key-policy-plus/user`. - Admin convenience route: `https://cpa-admin.konbakuyomu.us/key-policy-plus/`. @@ -497,15 +499,19 @@ credential, while CPAMP remains admin-only. - `5h`, `24h`, and `7d` are rolling USD windows. `month` is the current Asia/Shanghai calendar month. Reset writes a soft watermark and does not delete historical `usage_events`. -- `max_active_sessions` limits active Codex windows, not request frequency. - Session identity priority is `X-Codex-Window-Id`, - `client_metadata.x-codex-window-id`, - `X-Codex-Turn-Metadata.window_id/prompt_cache_key`, body - `prompt_cache_key`, `Session_id` / `X-Session-ID`, then - `conversation_id`. Sessions expire after 30 idle minutes. -- Missing session identity is allowed in v1 and audited; do not reject it - because that would incorrectly block clients that do not expose a stable - window id. +- Ordinary user throttling is RPM-only plus model allowlist and quota windows. + `concurrency` and `max_active_sessions` payload fields are compatibility + fields only: create/save handlers must accept stale payloads but persist and + return both values as `0`, and frontend auth must not read them. +- Deleting a key is a hard-delete of the permission/config row: + `POST /key-policy-plus/api/keys/delete` with body + `{"id":"...","confirm":"delete"}` must remove rows from `keys`, + `reset_watermarks`, and `active_sessions`, append a `delete_key` audit entry, + and preserve `usage_events` plus `codexcont_summaries` for billing and + troubleshooting history. +- Archive/restore has been retired. Stale archive routes may remain as + compatibility guards, but must return `410 archive_removed_use_delete` + instead of mutating key state. - `exclusive_auth: true` lets Plus participate in CPA frontend auth. In the current CPA host, policy rejection may be surfaced as CPA's generic `401` `Missing API key` response because `frontendAuth` returns unauthenticated. @@ -528,11 +534,13 @@ credential, while CPAMP remains admin-only. not create a persistent `not_authenticated` loop after a successful login. - Shortened key preview or rotated full key -> login fails; only the full key shown at create/rotation can match the stored hash. -- Disabled/disallowed/over-RPM/over-concurrency/over-quota request -> CPA - rejects before upstream execution. The public sidecar route may still touch - CodexCont during migration, but CPA must not execute the upstream provider. -- Missing session id -> request is allowed and `missing_session_identity` is - recorded in audit. +- Disabled/deleted/disallowed/over-RPM/over-quota request -> CPA rejects before + upstream execution. The public sidecar route may still touch CodexCont during + migration, but CPA must not execute the upstream provider. +- Stale create/save payload includes `concurrency` or `max_active_sessions` -> + Plus ignores the requested values and persists `0`. +- Stale archive route call -> `410 archive_removed_use_delete`. +- Delete without `confirm:"delete"` -> `400 delete_confirmation_required`. - `POST/PUT /v0/resource/plugins/cpa-key-policy-plus/admin/api/*` -> fails before reaching plugin logic; this is a deployment/config bug if the admin page depends on it. @@ -560,8 +568,14 @@ credential, while CPAMP remains admin-only. - Good: The admin page is a resource HTML page, while its mutations use `/key-policy-plus/api/*` and reach Plus management handlers with the CPA management key injected by the admin proxy. +- Good: An unwanted key is removed through `/key-policy-plus/api/keys/delete`; + the key can no longer log in or authenticate requests, while historical usage + and CodexCont summaries still exist by safe `key_id`. - Bad: `cpa-usage` still reads `cpa_usage_portal` SQLite as the authority after Plus is enabled. That preserves the split-brain limit problem. +- Bad: A retired key is only hidden or archived. That keeps a confusing second + lifecycle path and can make admins think a key was fully removed when the + permission row still exists. - Bad: The Plus admin page tries to create keys through `/v0/resource/plugins/cpa-key-policy-plus/admin/api/keys/create`. The current CPA host treats ResourceRoute as GET-only, so writes fail before plugin code. @@ -572,8 +586,8 @@ credential, while CPAMP remains admin-only. ### 6. Tests Required - Go unit: old state import, Plus-native key preservation, raw-key hash login, rotation invalidation, negative value validation, model allowlist, RPM, - request concurrency, active-session limits, 30-minute expiry, missing-session - audit, rolling/natural-month quota windows, soft reset, and cost projection. + rolling/natural-month quota windows, soft reset, hard delete, retired + concurrency/session fields forced to zero, and cost projection. - Go unit: admin/user HTML resources are `no-store`, user login uses `X-CPA-Key-Policy-Plus-Key`, and responses do not leak raw keys/full hashes. - Go unit: user session creation sets `cpa_key_policy_plus_session` cookies on @@ -584,9 +598,12 @@ credential, while CPAMP remains admin-only. - Go unit: admin HTML points mutations at `/key-policy-plus/api`, model normalization preserves unknown configured models, and create/save/reset through the admin alias persist settings. +- Go unit: `/keys/delete` removes key/reset/active-session rows, preserves + usage and Codex summaries, writes `delete_key` audit, and stale archive routes + return `410 archive_removed_use_delete`. - Frontend/Playwright: Plus admin can create a key, select discovered models, - edit per-model prices, save, reload, and keep dense tables horizontally - scrollable on 390px without page-level overflow. + edit per-model prices, save, hard-delete a key, reload, and keep dense tables + horizontally scrollable on 390px without page-level overflow. - Production smoke: plugin SHA256 matches the built artifact, CPA logs show Plus loaded, Plus DB key count is nonzero, `cpa-usage` login works, admin backend `/key-policy-plus/` and `/key-policy-plus/api/models` work, a @@ -630,6 +647,38 @@ admin proxy -> /v0/management/plugins/cpa-key-policy-plus/keys/create The admin proxy injects the CPA management key from a mounted secret or process environment, and the public API host still blocks `/key-policy-plus*`. +#### Wrong +```text +admin "deletes" a key by setting enabled=false or archived=true +``` + +This leaves a permission row behind and keeps the old archive lifecycle alive. + +#### Correct +```text +admin HTML -> /key-policy-plus/api/keys/delete {"id":"...","confirm":"delete"} +Plus -> delete keys/reset_watermarks/active_sessions, keep safe history +``` + +Hard deletion removes the authority entry while preserving billing and +diagnostic records. + +#### Wrong +```text +Codex window count -> reject requests via max_active_sessions +``` + +Normal Codex conversations can reuse or fan out window/session metadata in ways +that make this limit noisy and hard to explain. + +#### Correct +```text +frontend auth -> enabled/deleted check -> model allowlist -> RPM -> quota windows +``` + +After the RPM-only cutover, concurrency/session fields are accepted only for +backward-compatible payload decoding and must be stored as zero. + #### Wrong ```text Cookie: cpa_key_policy_plus_session=stale-path-token; cpa_key_policy_plus_session=fresh-root-token diff --git a/cpa_governor_plugin/README.md b/cpa_governor_plugin/README.md index ce373b1..2995263 100644 --- a/cpa_governor_plugin/README.md +++ b/cpa_governor_plugin/README.md @@ -63,10 +63,10 @@ plugins: fail_mode: fallback ``` -Use `exclusive_auth: true` when Governor is expected to hard-block disabled, -over-quota, over-RPM, or over-concurrency user keys. Without exclusive auth, -another frontend auth provider can still authenticate the same request after -Governor declines it. +Use `exclusive_auth: true` only when Governor is expected to participate in +frontend auth. Current production key policy has moved to `cpa-key-policy-plus`, +which hard-blocks disabled, disallowed-model, over-quota, and over-RPM user +keys. Governor no longer enforces request concurrency. ## Production Routes diff --git a/cpa_governor_plugin/go/assets/shared.css b/cpa_governor_plugin/go/assets/shared.css index e53f813..066a7d3 100644 --- a/cpa_governor_plugin/go/assets/shared.css +++ b/cpa_governor_plugin/go/assets/shared.css @@ -63,6 +63,15 @@ button.primary { background: rgba(59, 130, 246, .18); } button.ghost { background: #1a2230; } +button.danger-action { + border-color: rgba(248, 113, 113, .42); + background: rgba(248, 113, 113, .12); + color: #ffb8b8; +} +button.danger-action:hover { + border-color: rgba(248, 113, 113, .62); + background: rgba(248, 113, 113, .18); +} button.compact { min-height: 28px; padding: 0 9px; diff --git a/cpa_governor_plugin/go/assets/user.html b/cpa_governor_plugin/go/assets/user.html index f0d4b89..012aa45 100644 --- a/cpa_governor_plugin/go/assets/user.html +++ b/cpa_governor_plugin/go/assets/user.html @@ -304,7 +304,7 @@

CPA 用量自助页

updateActiveChip(); $("cards").innerHTML = `
当前 Key
${esc(state.me.name || "-")}
${esc(state.me.preview || "")}
-
${rangeLabel(state.range)}费用
${money(usageSummary.total_cost, 3)}
窗口限额 ${money(limitForRange(limits, state.range), 3)}
+
${rangeLabel(state.range)}费用
${money(usageSummary.total_cost, 3)}
额度上限 ${money(limitForRange(limits, state.range), 3)}
调用统计
${num(calls)} 次
成功率 ${pct(successRate)} · 失败 ${num(failed)}
Token
${num(usage.total_tokens)}
I ${num(usage.input_tokens)} / O ${num(usage.output_tokens)}
思考量
${num(usage.reasoning_tokens)}
Responses reasoning tokens
@@ -357,9 +357,9 @@

CPA 用量自助页

["总费用", money(costs.total ?? ev.cost, 4)], ])}

限额与失败

${detailList([ - ["当前窗口", rangeLabel(state.range)], - ["窗口限额", money(limitForRange(limits, state.range), 3)], - ["计入窗口", "5 小时 / 24 小时 / 7 天 / 本月"], + ["当前范围", rangeLabel(state.range)], + ["额度上限", money(limitForRange(limits, state.range), 3)], + ["计入额度", "5 小时 / 24 小时 / 7 天 / 本月"], ["TTFT", duration(ev.ttft_ms)], ["总耗时", duration(ev.latency_ms)], ])}
${esc(failure)}
@@ -425,13 +425,13 @@

CPA 用量自助页

["最终状态", req.final_status || "-"], ["耗时", duration(req.duration_ms)], ])} -

保护判断

${detailList([ - ["保护结果", (labels[req.protection] || [req.protection || "-"])[0]], - ["是否折叠", req.folded == null ? "-" : (req.folded ? "是" : "否")], - ["是否透传", req.passthrough == null ? "-" : (req.passthrough ? "是" : "否")], - ["透传原因", req.passthrough_reason || "-"], - ["停止原因", req.stopped_reason || "-"], - ])}
+

保护判断

+
保护结果
${chip(req.protection)}
+
是否折叠
${req.folded == null ? "-" : (req.folded ? "是" : "否")}
+
是否透传
${req.passthrough == null ? "-" : (req.passthrough ? "是" : "否")}
+
透传原因
${esc(req.passthrough_reason || "-")}
+
停止原因
${esc(req.stopped_reason || "-")}
+

Reasoning 指标

${detailList([ ["命中轮", req.first_truncation_round == null ? "-" : "#" + req.first_truncation_round], ["命中 reasoning", req.first_truncation_reasoning_tokens == null ? "-" : num(req.first_truncation_reasoning_tokens)], diff --git a/cpa_governor_plugin/go/main.go b/cpa_governor_plugin/go/main.go index 7c9ccc0..ab1f54f 100644 --- a/cpa_governor_plugin/go/main.go +++ b/cpa_governor_plugin/go/main.go @@ -79,12 +79,10 @@ type runtimeState struct { keyStateModTime time.Time keyStateLastCheck time.Time rpmBuckets map[string][]time.Time - concurrency map[string]int } var state = runtimeState{ - rpmBuckets: map[string][]time.Time{}, - concurrency: map[string]int{}, + rpmBuckets: map[string][]time.Time{}, } func main() { runPreviewIfRequested() } @@ -182,7 +180,6 @@ func runPreviewIfRequested() { state.store = store state.keyState = governor.KeyPolicyState{Keys: []governor.KeyRecord{previewKey}} state.rpmBuckets = map[string][]time.Time{} - state.concurrency = map[string]int{} state.mu.Unlock() mux := http.NewServeMux() mux.HandleFunc("/v0/resource/plugins/cpa-governor/admin", func(w http.ResponseWriter, r *http.Request) { @@ -505,9 +502,6 @@ func frontendAuth(raw []byte) ([]byte, error) { if !allowQuota(record) { return okEnvelope(frontendAuthResponse{Authenticated: false}) } - if !acquireConcurrency(record) { - return okEnvelope(frontendAuthResponse{Authenticated: false}) - } return okEnvelope(frontendAuthResponse{ Authenticated: true, Principal: record.ID, @@ -682,35 +676,6 @@ func allowQuota(key governor.KeyRecord) bool { return true } -func acquireConcurrency(key governor.KeyRecord) bool { - if key.Concurrency <= 0 { - return true - } - state.mu.Lock() - if state.concurrency[key.ID] >= key.Concurrency { - state.mu.Unlock() - return false - } - state.concurrency[key.ID]++ - state.mu.Unlock() - go func() { - time.Sleep(10 * time.Minute) - releaseConcurrency(key.ID) - }() - return true -} - -func releaseConcurrency(keyID string) { - if strings.TrimSpace(keyID) == "" { - return - } - state.mu.Lock() - defer state.mu.Unlock() - if state.concurrency[keyID] > 0 { - state.concurrency[keyID]-- - } -} - func executorUnavailable() ([]byte, error) { cfg := loadedConfig() if cfg.CodexContRoute && cfg.FailMode == "fail_closed" { @@ -819,9 +784,6 @@ func usageHandle(raw []byte) ([]byte, error) { if key.ID == "" { key = keyByID[rec.Source] } - if key.ID != "" { - releaseConcurrency(key.ID) - } usage := governor.TokenUsage{ InputTokens: rec.Detail.InputTokens, OutputTokens: rec.Detail.OutputTokens, diff --git a/cpa_governor_plugin/go/main_test.go b/cpa_governor_plugin/go/main_test.go index f24e6cd..44afb05 100644 --- a/cpa_governor_plugin/go/main_test.go +++ b/cpa_governor_plugin/go/main_test.go @@ -48,7 +48,6 @@ func configureTestState(t *testing.T) governor.KeyRecord { state.store = store state.keyState = governor.KeyPolicyState{} state.rpmBuckets = map[string][]time.Time{} - state.concurrency = map[string]int{} state.mu.Unlock() t.Cleanup(func() { state.mu.Lock() @@ -178,8 +177,6 @@ func TestFrontendAuthAcceptsManagedKeyAndRejectsDisallowedModel(t *testing.T) { if !resp.Authenticated || resp.Principal != "alice-key" { t.Fatalf("auth response = %#v", resp) } - releaseConcurrency("alice-key") - disallowed, _ := json.Marshal(frontendAuthRequest{ Headers: http.Header{"Authorization": []string{"Bearer cpa_live"}}, Body: []byte(`{"model":"other-model","stream":true}`), @@ -494,11 +491,8 @@ func TestUserCodexContFiltersToCurrentKey(t *testing.T) { } } -func TestUsageHandleStoresCostAndReleasesConcurrency(t *testing.T) { - key := configureTestState(t) - if !acquireConcurrency(key) { - t.Fatal("expected concurrency acquire") - } +func TestUsageHandleStoresCost(t *testing.T) { + configureTestState(t) rec := usageRecord{ Provider: "openai", ExecutorType: "codex", @@ -527,9 +521,6 @@ func TestUsageHandleStoresCostAndReleasesConcurrency(t *testing.T) { if _, err := usageHandle(rawRec); err != nil { t.Fatal(err) } - if state.concurrency["alice-key"] != 0 { - t.Fatalf("concurrency not released: %d", state.concurrency["alice-key"]) - } events, err := loadedStore().RecentEvents(context.Background(), "alice-key", 10) if err != nil { t.Fatal(err) @@ -582,7 +573,6 @@ func TestRefreshKeyPolicyStateImportsNewKeys(t *testing.T) { state.keyStateModTime = time.Time{} state.keyStateLastCheck = time.Time{} state.rpmBuckets = map[string][]time.Time{} - state.concurrency = map[string]int{} state.mu.Unlock() t.Cleanup(func() { state.mu.Lock() diff --git a/cpa_key_policy_plus_plugin/README.md b/cpa_key_policy_plus_plugin/README.md index 46b6010..7f8a5d9 100644 --- a/cpa_key_policy_plus_plugin/README.md +++ b/cpa_key_policy_plus_plugin/README.md @@ -2,10 +2,15 @@ `cpa-key-policy-plus` is the self-owned replacement for the old Key Policy plugin. It owns ordinary user `cpa_` keys, per-key limits, usage projection, -soft resets, request concurrency, and Codex active-window limits. +soft resets, model allowlists, RPM policy, and rolling quota windows. It does not modify CPA, CPAMP, or the old Key Policy source/image. +Concurrency and Codex active-window limits were intentionally retired because +normal Codex conversations can trip them too easily. Old database fields remain +for schema compatibility, but new saves force them to `0` and frontend auth does +not enforce them. + ## Safety Boundary Plus is safe to deploy as the exclusive user-key auth and quota authority. The diff --git a/cpa_key_policy_plus_plugin/go/assets/admin.html b/cpa_key_policy_plus_plugin/go/assets/admin.html index 9805b31..372fed5 100644 --- a/cpa_key_policy_plus_plugin/go/assets/admin.html +++ b/cpa_key_policy_plus_plugin/go/assets/admin.html @@ -293,12 +293,11 @@
K+

CPA Key Policy+

-

统一管理用户 Key、模型权限、用量限额、并发和 Codex 窗口数

+

统一管理用户 Key、模型权限、RPM 和用量限额

准备同步 - @@ -323,7 +322,7 @@

Key 列表

- + @@ -363,8 +362,6 @@

新建 Key

- -
初始模型 不选择任何模型就是允许全部模型;也可以手动添加暂未发现的模型名。 @@ -471,7 +468,6 @@

Key 已创建

modelOptions: [], modelWarnings: [], selectedId: "", - showArchived: false, dirty: false, createModels: new Set(), editor: null, @@ -565,17 +561,17 @@

Key 已创建

} function displayPreview(v){ return String(v || "").replace(/\.\.\./g, "…"); } function statusChip(k){ - if (k.archived) return `已归档`; + if (k.archived) return `待删除`; if (k.enabled) return `启用`; return `禁用`; } function visibleKeys(){ - return state.keys.filter(k => state.showArchived || !k.archived); + return state.keys; } function findKey(id){ return state.keys.find(k => k.id === id); } function ensureSelection(){ const list = visibleKeys(); - if (state.selectedId && findKey(state.selectedId) && (state.showArchived || !findKey(state.selectedId).archived)) return; + if (state.selectedId && findKey(state.selectedId)) return; state.selectedId = list[0]?.id || ""; } function markDirty(){ @@ -634,8 +630,8 @@

Key 已创建

name: String(k.name || "").trim(), enabled: !!k.enabled, rpm: nullableNumber(k.rpm) || 0, - concurrency: nullableNumber(k.concurrency) || 0, - max_active_sessions: nullableNumber(k.max_active_sessions) || 0, + concurrency: 0, + max_active_sessions: 0, models: cleanStrings(k.models || []), prices: cleanPrices(pricesOf(k)), five_hour_usd: numberOrNull(limits.five_hour_usd), @@ -647,13 +643,12 @@

Key 已创建

function renderMetrics(){ const total = state.keys.length; - const archived = state.keys.filter(k => k.archived).length; const enabled = state.keys.filter(k => k.enabled && !k.archived).length; - const active = state.keys.reduce((s,k) => s + Number(k.active_sessions || 0), 0); + const disabled = state.keys.filter(k => !k.enabled || k.archived).length; const cost = state.keys.reduce((s,k) => s + Number(usageOf(k)["24h"] || 0), 0); $("metrics").innerHTML = ` -
Key 总数
${total}
启用 ${enabled} · 归档 ${archived}
-
当前活跃窗口
${active}
30 分钟空闲释放
+
Key 总数
${total}
启用 ${enabled} · 禁用 ${disabled}
+
RPM 策略
${state.keys.filter(k => Number(k.rpm || 0) > 0).length}
仅使用 RPM 控制频率
24H 费用
$${cost.toFixed(3)}
按当前价格估算
模型来源
${state.modelOptions.length}
${state.modelWarnings.length ? "有兜底提示" : "已同步"}
`; } @@ -671,15 +666,15 @@

Key 已创建

const body = $("keys-body"); const list = visibleKeys(); if (!list.length) { - body.innerHTML = `
`; + body.innerHTML = ``; return; } body.innerHTML = list.map(k => ` - + - + `).join(""); @@ -703,14 +698,12 @@

Key 已创建

} $("detail-subtitle").textContent = `${key.name || "-"} · ${displayPreview(key.preview || key.id)}`; body.innerHTML = ` -
${statusChip(key)}活跃 ${Number(key.active_sessions || 0)}${escapeHTML(modelCountText(key))}${escapeHTML(priceCoverage(key))}
+
${statusChip(key)}RPM ${Number(key.rpm || 0)}${escapeHTML(modelCountText(key))}${escapeHTML(priceCoverage(key))}

基础策略

- -

额度窗口

已用 / 限额 / 剩余
${windows.map(([name,label,field]) => { @@ -734,7 +727,7 @@

Key 已创建

- +
`; body.querySelectorAll("[data-detail]").forEach(input => input.oninput = () => updateDetail(input)); body.querySelectorAll("[data-detail]").forEach(input => input.onchange = () => updateDetail(input)); @@ -745,14 +738,14 @@

Key 已创建

}); body.querySelectorAll("[data-reset]").forEach(btn => btn.onclick = () => resetSelected(btn.dataset.reset)); $("edit-models").onclick = () => openEditor(key.id); - $("archive-key").onclick = () => archiveSelected(); + $("delete-key").onclick = () => deleteSelected(); } function updateDetail(input){ const key = findKey(state.selectedId); if (!key) return; const field = input.dataset.detail; if (field === "enabled") key.enabled = input.value === "true"; - else if (["rpm","concurrency","max_active_sessions"].includes(field)) key[field] = Number(input.value || 0); + else if (field === "rpm") key[field] = Number(input.value || 0); else key[field] = input.value; markDirty(); renderKeyList(); @@ -824,25 +817,23 @@

Key 已创建

setSync("清零失败:" + e.message, "bad"); } } -async function archiveSelected(){ +async function deleteSelected(){ const key = findKey(state.selectedId); if (!key) return; - const next = !key.archived; - const text = next ? "归档后该 Key 将不能登录或调用,但历史用量会保留。确认归档?" : "确认恢复这个 Key?恢复后仍受启用状态和额度限制。"; - if (!confirm(text)) return; + if (!confirm(`确认彻底删除 ${key.name || key.id}?删除后该 Key 不能再登录或调用;历史用量明细会保留用于账单和排障。`)) return; try { - setSync(next ? "归档中" : "恢复中", "info"); - const resp = await api("/keys/archive", { + setSync("删除中", "info"); + const resp = await api("/keys/delete", { method: "POST", headers: {"Content-Type":"application/json"}, - body: JSON.stringify({id:key.id, archived:next}) + body: JSON.stringify({id:key.id, confirm:"delete"}) }); - state.keys = resp.keys || state.keys.map(k => k.id === key.id ? {...k, archived:next} : k); - if (next && !state.showArchived) state.selectedId = ""; + state.keys = resp.keys || state.keys.filter(k => k.id !== key.id); + state.selectedId = ""; render(); - setSync(next ? "已归档" : "已恢复", "ok"); + setSync("已删除", "ok"); } catch(e) { - setSync("生命周期操作失败:" + e.message, "bad"); + setSync("删除失败:" + e.message, "bad"); } } @@ -892,8 +883,6 @@

Key 已创建

$("create-name").value = ""; $("create-enabled").value = "true"; $("create-rpm").value = "60"; - $("create-concurrency").value = "2"; - $("create-sessions").value = "2"; $("create-model-search").value = ""; $("create-add-model").value = ""; openModal("create-modal"); @@ -923,8 +912,8 @@

Key 已创建

name: $("create-name").value.trim(), enabled: $("create-enabled").value === "true", rpm: numberOrNull($("create-rpm").value) || 0, - concurrency: numberOrNull($("create-concurrency").value) || 0, - max_active_sessions: numberOrNull($("create-sessions").value) || 0, + concurrency: 0, + max_active_sessions: 0, models: [...state.createModels] }; const resp = await api("/keys/create", { @@ -1009,7 +998,6 @@

Key 已创建

function openModal(id){ $(id).classList.add("open"); $(id).setAttribute("aria-hidden", "false"); } function closeModal(id){ $(id).classList.remove("open"); $(id).setAttribute("aria-hidden", "true"); } -$("show-archived").onchange = () => { state.showArchived = $("show-archived").checked; render(); }; $("create").onclick = openCreate; $("refresh").onclick = load; $("save").onclick = saveAll; diff --git a/cpa_key_policy_plus_plugin/go/assets/shared.css b/cpa_key_policy_plus_plugin/go/assets/shared.css index 3bd1a64..aaf9aca 100644 --- a/cpa_key_policy_plus_plugin/go/assets/shared.css +++ b/cpa_key_policy_plus_plugin/go/assets/shared.css @@ -64,6 +64,15 @@ button.primary { background: rgba(59, 130, 246, .18); } button.ghost { background: #1a2230; } +button.danger-action { + border-color: rgba(248, 113, 113, .42); + background: rgba(248, 113, 113, .12); + color: #ffb8b8; +} +button.danger-action:hover { + border-color: rgba(248, 113, 113, .62); + background: rgba(248, 113, 113, .18); +} button.compact { min-height: 28px; padding: 0 9px; diff --git a/cpa_key_policy_plus_plugin/go/assets/user.html b/cpa_key_policy_plus_plugin/go/assets/user.html index b1c846c..0397b56 100644 --- a/cpa_key_policy_plus_plugin/go/assets/user.html +++ b/cpa_key_policy_plus_plugin/go/assets/user.html @@ -314,7 +314,7 @@

CPA 用量自助页

updateActiveChip(); $("cards").innerHTML = `
当前 Key
${esc(state.me.name || "-")}
${esc(state.me.preview || "")}
-
${rangeLabel(state.range)}费用
${money(usageSummary.total_cost, 3)}
窗口限额 ${money(limitForRange(limits, state.range), 3)}
+
${rangeLabel(state.range)}费用
${money(usageSummary.total_cost, 3)}
额度上限 ${money(limitForRange(limits, state.range), 3)}
调用统计
${num(calls)} 次
成功率 ${pct(successRate)} · 失败 ${num(failed)}
Token
${num(usage.total_tokens)}
I ${num(usage.input_tokens)} / O ${num(usage.output_tokens)}
思考量
${num(usage.reasoning_tokens)}
Responses reasoning tokens
@@ -367,9 +367,9 @@

CPA 用量自助页

["总费用", money(costs.total ?? ev.cost, 4)], ])}

限额与失败

${detailList([ - ["当前窗口", rangeLabel(state.range)], - ["窗口限额", money(limitForRange(limits, state.range), 3)], - ["计入窗口", "5 小时 / 24 小时 / 7 天 / 本月"], + ["当前范围", rangeLabel(state.range)], + ["额度上限", money(limitForRange(limits, state.range), 3)], + ["计入额度", "5 小时 / 24 小时 / 7 天 / 本月"], ["TTFT", duration(ev.ttft_ms)], ["总耗时", duration(ev.latency_ms)], ])}
${esc(failure)}
@@ -437,13 +437,13 @@

CPA 用量自助页

["最终状态", req.final_status || "-"], ["耗时", duration(req.duration_ms)], ])} -

保护判断

${detailList([ - ["保护结果", (labels[req.protection] || [req.protection || "-"])[0]], - ["是否折叠", req.folded == null ? "-" : (req.folded ? "是" : "否")], - ["是否透传", req.passthrough == null ? "-" : (req.passthrough ? "是" : "否")], - ["透传原因", req.passthrough_reason || "-"], - ["停止原因", req.stopped_reason || "-"], - ])}
+

保护判断

+
保护结果
${chip(req.protection)}
+
是否折叠
${req.folded == null ? "-" : (req.folded ? "是" : "否")}
+
是否透传
${req.passthrough == null ? "-" : (req.passthrough ? "是" : "否")}
+
透传原因
${esc(req.passthrough_reason || "-")}
+
停止原因
${esc(req.stopped_reason || "-")}
+

Reasoning 指标

${detailList([ ["命中轮", req.first_truncation_round == null ? "-" : "#" + req.first_truncation_round], ["命中 reasoning", req.first_truncation_reasoning_tokens == null ? "-" : num(req.first_truncation_reasoning_tokens)], diff --git a/cpa_key_policy_plus_plugin/go/internal/policyplus/models.go b/cpa_key_policy_plus_plugin/go/internal/policyplus/models.go index a1c3704..faef379 100644 --- a/cpa_key_policy_plus_plugin/go/internal/policyplus/models.go +++ b/cpa_key_policy_plus_plugin/go/internal/policyplus/models.go @@ -52,8 +52,8 @@ func (k KeyRecord) Safe() map[string]any { "enabled": k.Enabled, "preview": k.Preview, "rpm": k.RPM, - "concurrency": k.Concurrency, - "max_active_sessions": k.MaxActiveSessions, + "concurrency": 0, + "max_active_sessions": 0, "models": append([]string(nil), k.Models...), "archived": k.Archived, "archived_at": k.ArchivedAt, diff --git a/cpa_key_policy_plus_plugin/go/internal/policyplus/policyplus_test.go b/cpa_key_policy_plus_plugin/go/internal/policyplus/policyplus_test.go index 2ab6e77..3d0723b 100644 --- a/cpa_key_policy_plus_plugin/go/internal/policyplus/policyplus_test.go +++ b/cpa_key_policy_plus_plugin/go/internal/policyplus/policyplus_test.go @@ -239,7 +239,7 @@ func TestStoreUsageWindowsAndSoftReset(t *testing.T) { } } -func TestStoreArchivesAndRestoresKeys(t *testing.T) { +func TestStoreDeleteKeyRemovesConfigAndKeepsHistory(t *testing.T) { ctx := context.Background() store, err := OpenStore(filepath.Join(t.TempDir(), "policyplus.sqlite")) if err != nil { @@ -256,25 +256,61 @@ func TestStoreArchivesAndRestoresKeys(t *testing.T) { if err := store.UpsertKey(ctx, key); err != nil { t.Fatal(err) } - if err := store.SetArchived(ctx, key.ID, true, time.Unix(1234, 0)); err != nil { + now := time.Now() + if err := store.Reset(ctx, key.ID, Range5H, now.Add(-time.Hour)); err != nil { + t.Fatal(err) + } + if _, err := store.RegisterActiveSession(ctx, key.ID, newSessionIdentity("test", "window-a"), 3, DefaultSessionIdle, now); err != nil { + t.Fatal(err) + } + if err := store.InsertUsage(ctx, UsageEvent{ + RequestID: "req-delete-kept", + KeyID: key.ID, + RequestedAt: now, + Cost: 0.25, + }); err != nil { + t.Fatal(err) + } + if err := store.SaveCodexSummary(ctx, "codex-delete-kept", key.ID, "gpt-5.5", "protected_clean", map[string]any{ + "request_id": "codex-delete-kept", + "started_at": now.Format(time.RFC3339), + }); err != nil { + t.Fatal(err) + } + + if err := store.DeleteKey(ctx, key.ID); err != nil { t.Fatal(err) } keys, err := store.ListKeys(ctx) - if err != nil || len(keys) != 1 { + if err != nil || len(keys) != 0 { t.Fatalf("keys err=%v keys=%#v", err, keys) } - if !keys[0].Archived || keys[0].ArchivedAt != 1234 { - t.Fatalf("archive fields = %#v", keys[0]) + usage, err := store.UsageSummary(ctx, key.ID, WindowFor(Range24H, now)) + if err != nil { + t.Fatal(err) + } + if usage.Calls != 1 || usage.TotalCost != 0.25 { + t.Fatalf("usage history should be retained after delete: %#v", usage) } - if err := store.SetArchived(ctx, key.ID, false, time.Unix(1300, 0)); err != nil { + codex, err := store.RecentCodexSummaries(ctx, key.ID, 10) + if err != nil { t.Fatal(err) } - keys, err = store.ListKeys(ctx) - if err != nil || len(keys) != 1 { - t.Fatalf("keys err=%v keys=%#v", err, keys) + if len(codex) != 1 || codex[0].RequestID != "codex-delete-kept" { + t.Fatalf("codex history should be retained after delete: %#v", codex) + } + var resetCount, activeCount, auditCount int + if err := store.db.QueryRowContext(ctx, `select count(1) from reset_watermarks where key_id=?`, key.ID).Scan(&resetCount); err != nil { + t.Fatal(err) + } + if err := store.db.QueryRowContext(ctx, `select count(1) from active_sessions where key_id=?`, key.ID).Scan(&activeCount); err != nil { + t.Fatal(err) + } + if err := store.db.QueryRowContext(ctx, `select count(1) from audit_log where target=? and action='delete_key'`, key.ID).Scan(&auditCount); err != nil { + t.Fatal(err) } - if keys[0].Archived || keys[0].ArchivedAt != 0 { - t.Fatalf("restore fields = %#v", keys[0]) + if resetCount != 0 || activeCount != 0 || auditCount != 1 { + t.Fatalf("delete cleanup reset=%d active=%d audit=%d", resetCount, activeCount, auditCount) } } diff --git a/cpa_key_policy_plus_plugin/go/internal/policyplus/store.go b/cpa_key_policy_plus_plugin/go/internal/policyplus/store.go index bf26d3c..3757b55 100644 --- a/cpa_key_policy_plus_plugin/go/internal/policyplus/store.go +++ b/cpa_key_policy_plus_plugin/go/internal/policyplus/store.go @@ -574,6 +574,40 @@ func (s *Store) SetArchived(ctx context.Context, id string, archived bool, at ti return s.Audit(ctx, "admin", action, id, map[string]any{"archived": archived, "archived_at": archivedAt}) } +func (s *Store) DeleteKey(ctx context.Context, id string) error { + id = strings.TrimSpace(id) + if id == "" { + return fmt.Errorf("missing key id") + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + var name, preview string + if err := tx.QueryRowContext(ctx, `select name, preview from keys where id=?`, id).Scan(&name, &preview); err != nil { + if err == sql.ErrNoRows { + return fmt.Errorf("unknown key: %s", id) + } + return err + } + if _, err := tx.ExecContext(ctx, `delete from reset_watermarks where key_id=?`, id); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `delete from active_sessions where key_id=?`, id); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `delete from keys where id=?`, id); err != nil { + return err + } + raw, _ := json.Marshal(map[string]any{"name": name, "preview": preview, "kept_usage_history": true}) + if _, err := tx.ExecContext(ctx, `insert into audit_log(timestamp, actor, action, target, detail_json) values(?, ?, ?, ?, ?)`, + time.Now().Unix(), "admin", "delete_key", id, string(raw)); err != nil { + return err + } + return tx.Commit() +} + func (s *Store) FindKeyByHash(ctx context.Context, hash string) (KeyRecord, bool, error) { keys, err := s.ListKeys(ctx) if err != nil { diff --git a/cpa_key_policy_plus_plugin/go/main.go b/cpa_key_policy_plus_plugin/go/main.go index 0412774..7220369 100644 --- a/cpa_key_policy_plus_plugin/go/main.go +++ b/cpa_key_policy_plus_plugin/go/main.go @@ -85,12 +85,10 @@ type runtimeState struct { keyStateModTime time.Time keyStateLastCheck time.Time rpmBuckets map[string][]time.Time - concurrency map[string]int } var state = runtimeState{ - rpmBuckets: map[string][]time.Time{}, - concurrency: map[string]int{}, + rpmBuckets: map[string][]time.Time{}, } func main() { runPreviewIfRequested() } @@ -121,7 +119,7 @@ func runPreviewIfRequested() { Enabled: true, Preview: policyplus.HashPreview(policyplus.SHA256Hex(previewRawKey)), RPM: 60, - Concurrency: 2, + Concurrency: 0, Models: []string{"gpt-5.5", "gpt-5.4"}, FiveHourUSD: floatPtr(2), DailyLimitUSD: floatPtr(8), @@ -138,21 +136,11 @@ func runPreviewIfRequested() { Enabled: false, Preview: policyplus.HashPreview(policyplus.SHA256Hex("cpa_preview_disabled_abcdefghijklmnopqrstuvwxyz0123")), RPM: 30, - Concurrency: 1, - MaxActiveSessions: 1, + Concurrency: 0, + MaxActiveSessions: 0, Models: []string{"gpt-5.4-mini"}, DailyLimitUSD: floatPtr(3), } - archivedKey := policyplus.KeyRecord{ - ID: "preview-archived", - Name: "归档演示", - KeyHash: "sha256:" + policyplus.SHA256Hex("cpa_preview_archived_abcdefghijklmnopqrstuvwxyz0123"), - Enabled: true, - Preview: policyplus.HashPreview(policyplus.SHA256Hex("cpa_preview_archived_abcdefghijklmnopqrstuvwxyz0123")), - Archived: true, - ArchivedAt: time.Now().Add(-2 * time.Hour).Unix(), - Models: []string{"gpt-5.5"}, - } noLimitKey := policyplus.KeyRecord{ ID: "preview-no-limit", Name: "无限额演示", @@ -165,7 +153,6 @@ func runPreviewIfRequested() { } _ = store.UpsertKey(context.Background(), previewKey) _ = store.UpsertKey(context.Background(), disabledKey) - _ = store.UpsertKey(context.Background(), archivedKey) _ = store.UpsertKey(context.Background(), noLimitKey) _ = store.InsertUsage(context.Background(), policyplus.UsageEvent{ RequestID: "req-preview-a", @@ -226,20 +213,11 @@ func runPreviewIfRequested() { RequestedAt: time.Now().Add(-25 * time.Minute), Cost: 0.18, }) - _ = store.InsertUsage(context.Background(), policyplus.UsageEvent{ - RequestID: "req-preview-archived", - KeyID: archivedKey.ID, - KeyPreview: archivedKey.Preview, - Model: "gpt-5.5", - RequestedAt: time.Now().Add(-70 * time.Minute), - Cost: 1.2, - }) state.mu.Lock() state.cfg = cfg state.store = store - state.keyState = policyplus.KeyPolicyState{Keys: []policyplus.KeyRecord{previewKey, disabledKey, archivedKey, noLimitKey}} + state.keyState = policyplus.KeyPolicyState{Keys: []policyplus.KeyRecord{previewKey, disabledKey, noLimitKey}} state.rpmBuckets = map[string][]time.Time{} - state.concurrency = map[string]int{} state.mu.Unlock() mux := http.NewServeMux() mux.HandleFunc("/v0/resource/plugins/cpa-key-policy-plus/admin", func(w http.ResponseWriter, r *http.Request) { @@ -594,12 +572,6 @@ func frontendAuth(raw []byte) ([]byte, error) { if !allowQuota(record) { return okEnvelope(frontendAuthResponse{Authenticated: false}) } - if !allowActiveSession(record, req) { - return okEnvelope(frontendAuthResponse{Authenticated: false}) - } - if !acquireConcurrency(record) { - return okEnvelope(frontendAuthResponse{Authenticated: false}) - } return okEnvelope(frontendAuthResponse{ Authenticated: true, Principal: record.ID, @@ -774,51 +746,6 @@ func allowQuota(key policyplus.KeyRecord) bool { return true } -func allowActiveSession(key policyplus.KeyRecord, req frontendAuthRequest) bool { - if key.MaxActiveSessions <= 0 { - return true - } - store := loadedStore() - if store == nil { - return true - } - identity := policyplus.ExtractSessionIdentity(req.Headers, req.Body) - decision, err := store.RegisterActiveSession(context.Background(), key.ID, identity, key.MaxActiveSessions, policyplus.DefaultSessionIdle, time.Now()) - if err != nil { - return false - } - return decision.Allowed -} - -func acquireConcurrency(key policyplus.KeyRecord) bool { - if key.Concurrency <= 0 { - return true - } - state.mu.Lock() - if state.concurrency[key.ID] >= key.Concurrency { - state.mu.Unlock() - return false - } - state.concurrency[key.ID]++ - state.mu.Unlock() - go func() { - time.Sleep(10 * time.Minute) - releaseConcurrency(key.ID) - }() - return true -} - -func releaseConcurrency(keyID string) { - if strings.TrimSpace(keyID) == "" { - return - } - state.mu.Lock() - defer state.mu.Unlock() - if state.concurrency[keyID] > 0 { - state.concurrency[keyID]-- - } -} - func executorUnavailable() ([]byte, error) { cfg := loadedConfig() if cfg.CodexContRoute && cfg.FailMode == "fail_closed" { @@ -927,9 +854,6 @@ func usageHandle(raw []byte) ([]byte, error) { if key.ID == "" { key = keyByID[rec.Source] } - if key.ID != "" { - releaseConcurrency(key.ID) - } usage := policyplus.TokenUsage{ InputTokens: rec.Detail.InputTokens, OutputTokens: rec.Detail.OutputTokens, @@ -982,7 +906,7 @@ func managementRegister() ([]byte, error) { {Method: http.MethodPut, Path: "/plugins/cpa-key-policy-plus/keys/save"}, {Method: http.MethodPut, Path: "/plugins/cpa-key-policy-plus/keys/limits"}, {Method: http.MethodPost, Path: "/plugins/cpa-key-policy-plus/keys/reset"}, - {Method: http.MethodPost, Path: "/plugins/cpa-key-policy-plus/keys/archive"}, + {Method: http.MethodPost, Path: "/plugins/cpa-key-policy-plus/keys/delete"}, {Method: http.MethodGet, Path: "/plugins/cpa-key-policy-plus/events"}, {Method: http.MethodGet, Path: "/plugins/cpa-key-policy-plus/codexcont"}, {Method: http.MethodPut, Path: "/plugins/cpa-key-policy-plus/codexcont"}, @@ -995,7 +919,7 @@ func managementRegister() ([]byte, error) { {Path: "/admin/api/keys/save"}, {Path: "/admin/api/keys/limits"}, {Path: "/admin/api/keys/reset"}, - {Path: "/admin/api/keys/archive"}, + {Path: "/admin/api/keys/delete"}, {Path: "/admin/api/events"}, {Path: "/admin/api/codexcont"}, {Path: "/user", Description: "Self-service usage dashboard"}, @@ -1034,6 +958,8 @@ func managementHandle(raw []byte) ([]byte, error) { return adminReset(req) case strings.HasSuffix(path, "/admin/api/keys/archive"): return adminArchiveKey(req) + case strings.HasSuffix(path, "/admin/api/keys/delete"): + return adminDeleteKey(req) case strings.HasSuffix(path, "/admin/api/events"): return adminEvents(req) case strings.HasSuffix(path, "/admin/api/codexcont"): @@ -1062,6 +988,8 @@ func managementHandle(raw []byte) ([]byte, error) { return adminReset(req) case strings.HasSuffix(path, "/plugins/cpa-key-policy-plus/keys/archive"): return adminArchiveKey(req) + case strings.HasSuffix(path, "/plugins/cpa-key-policy-plus/keys/delete"): + return adminDeleteKey(req) case strings.HasSuffix(path, "/plugins/cpa-key-policy-plus/events"): return adminEvents(req) case strings.HasSuffix(path, "/plugins/cpa-key-policy-plus/codexcont"): @@ -1080,6 +1008,8 @@ func managementHandle(raw []byte) ([]byte, error) { return adminReset(req) case strings.HasSuffix(path, "/key-policy-plus/api/keys/archive"): return adminArchiveKey(req) + case strings.HasSuffix(path, "/key-policy-plus/api/keys/delete"): + return adminDeleteKey(req) case strings.HasSuffix(path, "/key-policy-plus/api/events"): return adminEvents(req) case strings.HasSuffix(path, "/key-policy-plus/api/codexcont"): @@ -1106,9 +1036,6 @@ func adminKeys(_ managementRequest) ([]byte, error) { usage := usageWindows(context.Background(), store, key.ID, now) row["usage"] = usage row["quota"] = quotaWindows(key, usage) - if active, err := store.ActiveSessionCount(context.Background(), key.ID, policyplus.DefaultSessionIdle, now); err == nil { - row["active_sessions"] = active - } safe = append(safe, row) } return jsonResponse(http.StatusOK, map[string]any{"ok": true, "keys": safe, "codexcont": codexcontStatus()}) @@ -1221,14 +1148,6 @@ func adminCreateKey(req managementRequest) ([]byte, error) { if rpm == 0 { rpm = 60 } - concurrency := body.Concurrency - if concurrency == 0 { - concurrency = 2 - } - maxActiveSessions := body.MaxActiveSessions - if maxActiveSessions == 0 { - maxActiveSessions = 2 - } store := loadedStore() if store == nil { return jsonResponse(http.StatusServiceUnavailable, map[string]any{"ok": false, "error": "store_unavailable"}) @@ -1240,8 +1159,8 @@ func adminCreateKey(req managementRequest) ([]byte, error) { Enabled: enabled, Preview: policyplus.HashPreview(hash), RPM: rpm, - Concurrency: concurrency, - MaxActiveSessions: maxActiveSessions, + Concurrency: 0, + MaxActiveSessions: 0, Models: cleanStrings(body.Models), Prices: cleanPrices(body.Prices), FiveHourUSD: body.FiveHourUSD, @@ -1307,8 +1226,8 @@ func adminSaveKeys(req managementRequest) ([]byte, error) { key.Name = strings.TrimSpace(item.Name) } key.RPM = item.RPM - key.Concurrency = item.Concurrency - key.MaxActiveSessions = item.MaxActiveSessions + key.Concurrency = 0 + key.MaxActiveSessions = 0 key.Models = cleanStrings(item.Models) if item.Prices != nil { key.Prices = cleanPrices(item.Prices) @@ -1411,18 +1330,29 @@ func adminReset(req managementRequest) ([]byte, error) { } func adminArchiveKey(req managementRequest) ([]byte, error) { + return jsonResponse(http.StatusGone, map[string]any{ + "ok": false, + "error": "archive_removed_use_delete", + "message": "归档/恢复功能已移除,请使用删除 Key。", + }) +} + +func adminDeleteKey(req managementRequest) ([]byte, error) { var body struct { - ID string `json:"id"` - Archived bool `json:"archived"` + ID string `json:"id"` + Confirm string `json:"confirm"` } if err := json.Unmarshal(req.Body, &body); err != nil { return jsonResponse(http.StatusBadRequest, map[string]any{"ok": false, "error": "invalid_json"}) } + if body.Confirm != "delete" { + return jsonResponse(http.StatusBadRequest, map[string]any{"ok": false, "error": "delete_confirmation_required"}) + } store := loadedStore() if store == nil { return jsonResponse(http.StatusServiceUnavailable, map[string]any{"ok": false, "error": "store_unavailable"}) } - if err := store.SetArchived(context.Background(), body.ID, body.Archived, time.Now()); err != nil { + if err := store.DeleteKey(context.Background(), body.ID); err != nil { return jsonResponse(http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()}) } return adminKeys(req) @@ -1498,7 +1428,7 @@ func userSession(req managementRequest) ([]byte, error) { return jsonResponse(http.StatusForbidden, map[string]any{"ok": false, "error": "api_key_disabled", "category": "auth", "message": "这个 Key 当前已禁用,请联系管理员。"}) } if record.Archived { - return jsonResponse(http.StatusForbidden, map[string]any{"ok": false, "error": "api_key_archived", "category": "auth", "message": "这个 Key 已归档,不能再登录或调用,请联系管理员恢复。"}) + return jsonResponse(http.StatusForbidden, map[string]any{"ok": false, "error": "api_key_unavailable", "category": "auth", "message": "这个 Key 已不可用,不能再登录或调用;请联系管理员确认是否已删除。"}) } cfg := loadedConfig() token, err := policyplus.SignSession(policyplus.SessionPayload{ diff --git a/cpa_key_policy_plus_plugin/go/main_test.go b/cpa_key_policy_plus_plugin/go/main_test.go index 2b57a0b..4d24add 100644 --- a/cpa_key_policy_plus_plugin/go/main_test.go +++ b/cpa_key_policy_plus_plugin/go/main_test.go @@ -23,7 +23,6 @@ func setupTestState(t *testing.T) policyplus.KeyRecord { state.keyState = policyplus.KeyPolicyState{} state.keyStatePath = "" state.rpmBuckets = map[string][]time.Time{} - state.concurrency = map[string]int{} old := state.store state.store = nil state.mu.Unlock() @@ -89,7 +88,7 @@ func TestPluginRegistrationIsPolicyPlusExclusiveAuth(t *testing.T) { } } -func TestFrontendAuthEnforcesActiveSessionLimit(t *testing.T) { +func TestFrontendAuthIgnoresRetiredActiveSessionLimit(t *testing.T) { setupTestState(t) req := frontendAuthRequest{ Headers: http.Header{"Authorization": []string{"Bearer cpa_alice_secret"}}, @@ -103,8 +102,8 @@ func TestFrontendAuthEnforcesActiveSessionLimit(t *testing.T) { t.Fatal("same session should refresh and authenticate") } req.Body = []byte(`{"model":"gpt-5.5","prompt_cache_key":"window-b"}`) - if authOK(t, req) { - t.Fatal("second active session should be rejected") + if !authOK(t, req) { + t.Fatal("retired Codex window limit should not reject a second session") } } @@ -153,7 +152,7 @@ func TestAdminSaveKeysPersistsUnifiedLimits(t *testing.T) { t.Fatalf("ListKeys err=%v keys=%#v", err, keys) } got := keys[0] - if got.Enabled || got.RPM != 5 || got.Concurrency != 1 || got.MaxActiveSessions != 3 { + if got.Enabled || got.RPM != 5 || got.Concurrency != 0 || got.MaxActiveSessions != 0 { t.Fatalf("updated key = %#v", got) } if got.FiveHourUSD == nil || *got.FiveHourUSD != 1.25 || got.MonthlyLimitUSD == nil || *got.MonthlyLimitUSD != 20 { @@ -198,7 +197,7 @@ func TestAdminCreateKeyReturnsRawKeyOnlyOnce(t *testing.T) { break } } - if created.ID == "" || created.Enabled || created.RPM != 9 || created.Concurrency != 4 || created.MaxActiveSessions != 3 { + if created.ID == "" || created.Enabled || created.RPM != 9 || created.Concurrency != 0 || created.MaxActiveSessions != 0 { t.Fatalf("created key did not persist requested settings: %#v", created) } if len(created.Models) != 1 || created.Models[0] != "gpt-5.4-mini" { @@ -242,6 +241,15 @@ func TestManagementAliasCreateSaveAndReset(t *testing.T) { if !strings.Contains(string(saveBody), "Alias Saved") { t.Fatalf("save via alias failed: %s", saveBody) } + keys, err := loadedStore().ListKeys(context.Background()) + if err != nil { + t.Fatal(err) + } + for _, key := range keys { + if key.ID == "alice-key" && (key.Concurrency != 0 || key.MaxActiveSessions != 0) { + t.Fatalf("alias save should force retired limits to zero: %#v", key) + } + } resetRaw, _ := json.Marshal(map[string]any{"id": "alice-key", "window": "5h"}) raw, err = managementHandle(mustJSON(t, managementRequest{ Method: http.MethodPost, @@ -257,7 +265,7 @@ func TestManagementAliasCreateSaveAndReset(t *testing.T) { } } -func TestArchiveKeyRejectsAuthAndUserSession(t *testing.T) { +func TestArchiveRouteReturnsGone(t *testing.T) { key := setupTestState(t) archiveRaw, _ := json.Marshal(map[string]any{"id": key.ID, "archived": true}) raw, err := managementHandle(mustJSON(t, managementRequest{ @@ -269,36 +277,39 @@ func TestArchiveKeyRejectsAuthAndUserSession(t *testing.T) { t.Fatal(err) } body := decodeManagementBody(t, raw) - if !strings.Contains(string(body), `"archived":true`) { - t.Fatalf("archive response missing archived flag: %s", body) + if !strings.Contains(string(body), `"archive_removed_use_delete"`) { + t.Fatalf("archive route should return delete guidance: %s", body) + } +} + +func TestDeleteKeyRejectsAuthAndUserSession(t *testing.T) { + key := setupTestState(t) + deleteRaw, _ := json.Marshal(map[string]any{"id": key.ID, "confirm": "delete"}) + raw, err := managementHandle(mustJSON(t, managementRequest{ + Method: http.MethodPost, + Path: "/key-policy-plus/api/keys/delete", + Body: deleteRaw, + })) + if err != nil { + t.Fatal(err) + } + body := decodeManagementBody(t, raw) + if !strings.Contains(string(body), `"keys"`) { + t.Fatalf("delete response should return updated key list: %s", body) } if authOK(t, frontendAuthRequest{ Headers: http.Header{"Authorization": []string{"Bearer cpa_alice_secret"}}, Body: []byte(`{"model":"gpt-5.5","prompt_cache_key":"window-a"}`), }) { - t.Fatal("archived key should not authenticate frontend requests") + t.Fatal("deleted key should not authenticate frontend requests") } raw, err = userSession(managementRequest{Headers: http.Header{"X-CPA-Key-Policy-Plus-Key": []string{"cpa_alice_secret"}}}) if err != nil { t.Fatal(err) } body = decodeManagementBody(t, raw) - if !strings.Contains(string(body), "api_key_archived") { - t.Fatalf("archived user session should fail clearly: %s", body) - } - restoreRaw, _ := json.Marshal(map[string]any{"id": key.ID, "archived": false}) - if _, err := managementHandle(mustJSON(t, managementRequest{ - Method: http.MethodPost, - Path: "/key-policy-plus/api/keys/archive", - Body: restoreRaw, - })); err != nil { - t.Fatal(err) - } - if !authOK(t, frontendAuthRequest{ - Headers: http.Header{"Authorization": []string{"Bearer cpa_alice_secret"}}, - Body: []byte(`{"model":"gpt-5.5","prompt_cache_key":"window-a"}`), - }) { - t.Fatal("restored key should authenticate again") + if strings.Contains(string(body), `"ok":true`) { + t.Fatalf("deleted user session should fail clearly: %s", body) } } @@ -372,6 +383,14 @@ func TestAdminHTMLHasRenderedSharedCSS(t *testing.T) { if !strings.Contains(html, "/key-policy-plus/api") || !strings.Contains(html, "编辑模型/价格") { t.Fatal("admin html should use the management alias and structured model editor") } + for _, removed := range []string{"请求并发", "Codex窗口", "显示归档", "归档隐藏", "恢复 Key"} { + if strings.Contains(html, removed) { + t.Fatalf("admin html should not contain retired control %q", removed) + } + } + if !strings.Contains(html, "删除 Key") || !strings.Contains(html, "/keys/delete") { + t.Fatal("admin html should expose hard delete through the management alias") + } } func TestSessionInvalidatedWhenKeyHashChanges(t *testing.T) { From b6e6aa3890899a6523dbae322fd804879b804d2e Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Fri, 3 Jul 2026 00:26:32 +0800 Subject: [PATCH 50/68] chore(task): archive 07-02-key-policy-plus-rpm-delete-ui-unify --- .../check.jsonl | 1 + .../design.md | 51 +++++++++++ .../evidence/cpa-governor-admin-desktop.png | Bin 0 -> 78302 bytes .../evidence/cpa-governor-admin-mobile.png | Bin 0 -> 41639 bytes .../cpa-usage-user-protection-desktop.png | Bin 0 -> 92650 bytes .../cpa-usage-user-protection-mobile.png | Bin 0 -> 47980 bytes .../key-policy-plus-admin-desktop.png | Bin 0 -> 92642 bytes .../evidence/key-policy-plus-admin-mobile.png | Bin 0 -> 45636 bytes .../production-cpa-usage-kuma-protection.png | Bin 0 -> 95001 bytes .../implement.jsonl | 1 + .../implement.md | 81 ++++++++++++++++++ .../prd.md | 33 +++++++ .../task.json | 26 ++++++ 13 files changed, 193 insertions(+) create mode 100644 .trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/check.jsonl create mode 100644 .trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/design.md create mode 100644 .trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/evidence/cpa-governor-admin-desktop.png create mode 100644 .trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/evidence/cpa-governor-admin-mobile.png create mode 100644 .trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/evidence/cpa-usage-user-protection-desktop.png create mode 100644 .trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/evidence/cpa-usage-user-protection-mobile.png create mode 100644 .trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/evidence/key-policy-plus-admin-desktop.png create mode 100644 .trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/evidence/key-policy-plus-admin-mobile.png create mode 100644 .trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/evidence/production-cpa-usage-kuma-protection.png create mode 100644 .trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/implement.jsonl create mode 100644 .trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/implement.md create mode 100644 .trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/prd.md create mode 100644 .trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/task.json diff --git a/.trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/check.jsonl b/.trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/check.jsonl new file mode 100644 index 0000000..9cd59d4 --- /dev/null +++ b/.trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/design.md b/.trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/design.md new file mode 100644 index 0000000..1051ef9 --- /dev/null +++ b/.trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/design.md @@ -0,0 +1,51 @@ +# Design + +## Boundaries + +- `cpa-key-policy-plus` 是普通用户 Key 的权威配置和额度统计入口。 +- `cpa-governor` 仍只负责管理员 CodexCont 实时状态展示。 +- 官方 CPA/CPAMP 镜像保持不变;生产只替换自有插件 `.so`。 + +## Key Control Changes + +- Frontend auth 删除并发和 active session 判定,只保留 enabled、archived/deleted absence、model allowlist、RPM、quota windows。 +- Store 层保留旧字段和表以兼容已有 SQLite schema,但保存 Key 时强制 `Concurrency=0`、`MaxActiveSessions=0`。 +- Governor 侧也停止执行 request concurrency,避免两套插件里仍有隐藏拦截点。 + +## Hard Delete Contract + +- 新管理路由: + - `POST /v0/management/plugins/cpa-key-policy-plus/keys/delete` + - admin proxy alias: `POST /key-policy-plus/api/keys/delete` +- Request: `{ "id": "", "confirm": "delete" }` +- Effect: + - delete from `keys` + - delete from `reset_watermarks` + - delete from `active_sessions` + - keep `usage_events` + - keep `codexcont_summaries` + - append `audit_log` action `delete_key` +- Old archive route is removed from registration and returns HTTP 410 if stale HTML calls it. + +## UI Design + +- Key Policy+ admin list/detail: + - Show Key name/preview, enabled state, RPM, quota summaries, model/pricing summary. + - Remove concurrency/session UI. + - Add destructive delete action in detail panel with explicit confirm dialog. +- Shared visual system: + - Keep a single canonical `shared.css` shape for Plus and Governor. + - Use the same `.chip` output for protection values everywhere. + - Replace large custom protection result text in user detail cards with the same chip used by Governor rows. + +## Deployment Shape + +- Build Linux amd64 Plus plugin; build Governor only if its source changes. +- Backup Plus `.so`, Plus SQLite, CPA config, Caddy/admin proxy config. +- Upload changed plugin artifacts and restart only required services. +- After plugin deployment, call delete API for all keys where `enabled=false` or `archived=true`. + +## Rollback + +- Restore previous `.so` and Plus SQLite backup, restart CPA. +- Since hard delete removes active config rows, rollback for deleted keys requires SQLite backup restoration. diff --git a/.trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/evidence/cpa-governor-admin-desktop.png b/.trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/evidence/cpa-governor-admin-desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..4283d7e9bc52afd0f3fd0e353b747c679a0a9a3c GIT binary patch literal 78302 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Ð{l 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 literal 0 HcmV?d00001 diff --git a/.trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/evidence/cpa-usage-user-protection-mobile.png b/.trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/evidence/cpa-usage-user-protection-mobile.png new file mode 100644 index 0000000000000000000000000000000000000000..f34ef204148f822998070f9f2fc3f46353af3743 GIT binary patch literal 47980 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

oi|WElq@*pIn4gUZM@e1me^M=xw3 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 literal 0 HcmV?d00001 diff --git a/.trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/evidence/key-policy-plus-admin-desktop.png b/.trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/evidence/key-policy-plus-admin-desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..0257a9c98ee6b97f6de5337250dae109a632b34f GIT binary patch literal 92642 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�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

O(Rmu4J95BAs=+???=9f4luij?cR&A|-wrKm* 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 literal 0 HcmV?d00001 diff --git a/.trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/evidence/key-policy-plus-admin-mobile.png b/.trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/evidence/key-policy-plus-admin-mobile.png new file mode 100644 index 0000000000000000000000000000000000000000..2ed382c63ac2086142bb2716a3982eaf91f9e651 GIT binary patch literal 45636 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 WiAvAHfV2%XH1I2B*;46u{{IV*_jIQK literal 0 HcmV?d00001 diff --git a/.trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/evidence/production-cpa-usage-kuma-protection.png b/.trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/evidence/production-cpa-usage-kuma-protection.png new file mode 100644 index 0000000000000000000000000000000000000000..89837a7003b6d23c719a4c92e900fedaf6c5f8ca GIT binary patch literal 95001 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� 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%0DhBcS1x(Z@4vv}2* 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 aLeN`|8%+(BK8C<6AVqmqxl$REp#KN?-AW4p literal 0 HcmV?d00001 diff --git a/.trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/implement.jsonl b/.trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/implement.jsonl new file mode 100644 index 0000000..9cd59d4 --- /dev/null +++ b/.trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/implement.md b/.trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/implement.md new file mode 100644 index 0000000..4a6f5f9 --- /dev/null +++ b/.trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/implement.md @@ -0,0 +1,81 @@ +# Implementation Plan + +## Local Implementation + +1. Update Plus backend: + - Remove frontend-auth calls to active session and request concurrency checks. + - Force create/save to persist `concurrency=0` and `max_active_sessions=0`. + - Add `DeleteKey` store method and admin delete route. + - Make archive route return 410 and remove it from management registration. +2. Update Plus admin UI: + - Remove create/detail/list fields for request concurrency and Codex windows. + - Remove show archived and archive/restore controls. + - Add delete button with confirmation and `/keys/delete` call. +3. Update Governor backend: + - Stop enforcing request concurrency in frontend auth. + - Keep compatibility fields untouched unless required by tests. +4. Update UI shared style: + - Align Plus and Governor `shared.css`. + - Render protection result in user detail cards via `chip(req.protection)`. +5. Update tests: + - Replace archive tests with delete tests. + - Add create/save tests for forced-zero concurrency/session fields. + - Add archive 410 compatibility test. + - Add frontend HTML smoke assertions for removed labels and delete button. + +## Validation + +- `go test ./...` in `cpa_key_policy_plus_plugin/go` +- `go test ./...` in `cpa_governor_plugin/go` +- Extract `assets/admin.html` and `assets/user.html` scripts for `node --check` +- `git diff --check` +- Playwright: + - Key Policy+ admin page desktop and 390px mobile + - CPA Usage user page desktop and 390px mobile + - CPA Governor admin page desktop + - Confirm removed labels and unified protection chips + +## Server Rollout + +1. Check SJC disk free space. +2. Backup: + - `/opt/codex-stacks/cpa/plugins/linux/amd64/cpa-key-policy-plus.so` + - `/opt/codex-stacks/cpa/plugins/linux/amd64/cpa-governor.so` if changed + - `/opt/codex-stacks/cpa/plugin-state/cpa-key-policy-plus/policyplus.sqlite` + - CPA config and Caddy/admin proxy config. +3. Upload changed `.so` files, restart CPA only. +4. Verify plugin load logs and SHA256. +5. Delete production disabled/archived keys through the new delete API. +6. Verify Kuma test key login and a lightweight authenticated call. +7. Verify public blocked paths remain 404. + +## Risks + +- Hard deletion is intentionally irreversible without SQLite backup. +- Existing stale admin HTML may call archive route; 410 response prevents accidental stale archive behavior. + +## Execution Notes + +- Local tests passed: + - `go test ./...` in `cpa_key_policy_plus_plugin/go` + - `go test ./...` in `cpa_governor_plugin/go` + - inline script syntax smoke for Plus/Governor `admin.html` and `user.html` + - `git diff --check` +- Playwright preview checks passed for desktop and 390px mobile: + - Key Policy+ admin has no retired concurrency/Codex-window/archive controls and has hard delete. + - CPA Usage and CPA Governor protection details render protection result with the same `.chip` component. + - Tables stay inside overflow panels; no page-level horizontal overflow. +- Linux amd64 artifacts deployed on SJC: + - `cpa-key-policy-plus.so` SHA256 `a7a2b3cac09af1a019b37942f65a25e177b27f55e384c0a15bc93b2c62bfac37` + - `cpa-governor.so` SHA256 `c0903c72d574fde4fa1f47185a0dbd9ec8ccc5579a533a8eeb8cf14c31d116dd` +- Production backup: + - `/opt/codex-stacks/backups/key-policy-plus-rpm-delete-ui-unify-20260703-000536` +- Production cleanup: + - Deleted disabled keys `key_5ce1c632...a19b5a` and `key_2bb8fd29...b016b1` through the new delete API. + - Remaining Plus keys: 3 enabled, 0 disabled/archived. +- Production smoke: + - CPA logs show both plugins loaded and registered after restart. + - Kuma test key logs into `https://cpa-usage.konbakuyomu.us/`. + - Kuma test key authenticates against `https://cpa.konbakuyomu.us/v1/models` and returns 7 models. + - User usage/events/codexcont APIs return `ok`. + - Public `https://cpa.konbakuyomu.us` returns 404 for plugin/admin/governor/codexcont paths. diff --git a/.trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/prd.md b/.trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/prd.md new file mode 100644 index 0000000..b6c5a53 --- /dev/null +++ b/.trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/prd.md @@ -0,0 +1,33 @@ +# CPA Key Policy+ RPM-only、硬删除 Key、Usage/Governor 风格统一 + +## Goal + +将普通用户 Key 控制收敛到 `RPM + 模型白名单 + 5H/24H/7D/月额度`,移除不好用的「请求并发」和「Codex窗口」限制;将 Key 生命周期从「禁用/归档」改为管理员可执行的硬删除;统一 `cpa-usage.konbakuyomu.us` 用户页和 CPAMP 左下角 `CPA Governor` 面板的视觉组件。 + +## Requirements + +- `cpa-key-policy-plus` 不再执行 `concurrency` 和 `max_active_sessions` 限制。 +- 新建/保存 Key 时兼容旧 payload,但 `concurrency` 和 `max_active_sessions` 统一保存/返回为 `0`。 +- 管理页移除「请求并发」「Codex窗口」「显示归档」「归档隐藏」「恢复 Key」。 +- 管理页新增硬删除按钮和确认流程,删除 Key 配置、reset watermarks、active sessions;保留脱敏历史 usage/codex summary。 +- 旧 archive API 不再注册,兼容入口返回 `410 archive_removed_use_delete`。 +- 部署后删除生产中当前所有禁用或归档 Key。 +- `CPA Usage` 和 `CPA Governor` 统一状态 chip、详情卡、表格、按钮、工具栏状态灯风格,特别是保护结果在详情中也使用同款气泡 chip。 +- 不改 CPA、CPAMP 官方源码或镜像;只改自有 Plus/Governor 插件和必要部署。 + +## Acceptance Criteria + +- [x] Key Policy+ 管理页不再出现「请求并发」「Codex窗口」「显示归档」或归档/恢复按钮。 +- [x] 新建/保存 Key 后返回的 `concurrency` 与 `max_active_sessions` 为 `0`。 +- [x] 删除按钮能删除 Key;删除后该完整 `cpa_` key 无法登录用户页或通过 CPA frontend auth。 +- [x] 删除不会删除该 Key 既有 usage/codex summary 历史记录。 +- [x] 当前生产禁用/归档 Key 被删除,启用 Key 继续可登录和调用。 +- [x] CPA Usage 与 CPA Governor 中保护状态 chip、详情卡、表格密度和按钮视觉一致。 +- [x] 本地 Go 测试、HTML 脚本语法检查、Playwright 关键页面检查通过。 +- [x] 生产公网 `cpa.konbakuyomu.us` 仍阻断管理和插件路径。 + +## Out of Scope + +- 不实现新的 Codex 窗口识别机制。 +- 不删除历史 usage/codex summary 账本。 +- 不切换 `/v1/responses` 执行链路。 diff --git a/.trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/task.json b/.trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/task.json new file mode 100644 index 0000000..6ce7fc3 --- /dev/null +++ b/.trellis/tasks/archive/2026-07/07-02-key-policy-plus-rpm-delete-ui-unify/task.json @@ -0,0 +1,26 @@ +{ + "id": "key-policy-plus-rpm-delete-ui-unify", + "name": "key-policy-plus-rpm-delete-ui-unify", + "title": "CPA Key Policy+ RPM-only deletion and UI unification", + "description": "", + "status": "completed", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "dxt98", + "assignee": "dxt98", + "createdAt": "2026-07-02", + "completedAt": "2026-07-03", + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file From 54f08aa0b2b0dfb67dc69ea605c16b50bdf2b0f9 Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Fri, 3 Jul 2026 00:26:40 +0800 Subject: [PATCH 51/68] chore: record journal --- .trellis/workspace/dxt98/index.md | 7 +++--- .trellis/workspace/dxt98/journal-1.md | 33 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/.trellis/workspace/dxt98/index.md b/.trellis/workspace/dxt98/index.md index ce7552b..8b86249 100644 --- a/.trellis/workspace/dxt98/index.md +++ b/.trellis/workspace/dxt98/index.md @@ -8,8 +8,8 @@ - **Active File**: `journal-1.md` -- **Total Sessions**: 10 -- **Last Active**: 2026-07-02 +- **Total Sessions**: 11 +- **Last Active**: 2026-07-03 --- @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~345 | Active | +| `journal-1.md` | ~378 | Active | --- @@ -29,6 +29,7 @@ | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 11 | 2026-07-03 | Retire Key Policy Plus session limits | `30fa42b` | `main` | | 10 | 2026-07-02 | Key Policy Plus session cookie hotfix | `793b4ae`, `a5ddecb` | `main` | | 9 | 2026-07-02 | CPA Key Policy Plus UX stability | `57aadff` | `main` | | 8 | 2026-07-02 | CPA Key Policy Plus admin fixes | `4c6613b` | `main` | diff --git a/.trellis/workspace/dxt98/journal-1.md b/.trellis/workspace/dxt98/journal-1.md index 550b36d..624a74c 100644 --- a/.trellis/workspace/dxt98/journal-1.md +++ b/.trellis/workspace/dxt98/journal-1.md @@ -343,3 +343,36 @@ Diagnosed cpa-usage login loops caused by stale path-specific Key Policy Plus se ### Next Steps - None - task complete + + +## Session 11: Retire Key Policy Plus session limits + +**Date**: 2026-07-03 +**Task**: Retire Key Policy Plus session limits +**Branch**: `main` + +### Summary + +Retired Key Policy+ request concurrency and Codex active-window enforcement, added hard delete for keys, unified Usage/Governor chip styling, documented the RPM-only and hard-delete contracts, deployed to SJC, and cleaned disabled production keys. + +### Main Changes + +(Add details) + +### Git Commits + +| Hash | Message | +|------|---------| +| `30fa42b` | (see git log) | + +### Testing + +- [OK] (Add test results) + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete From 32b8ff1aa15b523d033a3688810a51045f4daf95 Mon Sep 17 00:00:00 2001 From: konbakuyomu Date: Fri, 3 Jul 2026 10:18:05 +0800 Subject: [PATCH 52/68] fix: simplify CPA usage range UX --- .../backend/codex-continuation-contracts.md | 11 +++++ .../07-03-cpa-usage-range-ux/check.jsonl | 1 + .../tasks/07-03-cpa-usage-range-ux/design.md | 31 ++++++++++++ .../07-03-cpa-usage-range-ux/implement.jsonl | 1 + .../07-03-cpa-usage-range-ux/implement.md | 45 +++++++++++++++++ .../tasks/07-03-cpa-usage-range-ux/prd.md | 41 ++++++++++++++++ .../tasks/07-03-cpa-usage-range-ux/task.json | 26 ++++++++++ .../go/assets/user.html | 43 +++++++---------- cpa_key_policy_plus_plugin/go/main_test.go | 48 +++++++++++++++++++ 9 files changed, 221 insertions(+), 26 deletions(-) create mode 100644 .trellis/tasks/07-03-cpa-usage-range-ux/check.jsonl create mode 100644 .trellis/tasks/07-03-cpa-usage-range-ux/design.md create mode 100644 .trellis/tasks/07-03-cpa-usage-range-ux/implement.jsonl create mode 100644 .trellis/tasks/07-03-cpa-usage-range-ux/implement.md create mode 100644 .trellis/tasks/07-03-cpa-usage-range-ux/prd.md create mode 100644 .trellis/tasks/07-03-cpa-usage-range-ux/task.json diff --git a/.trellis/spec/backend/codex-continuation-contracts.md b/.trellis/spec/backend/codex-continuation-contracts.md index c60f389..c0cceb1 100644 --- a/.trellis/spec/backend/codex-continuation-contracts.md +++ b/.trellis/spec/backend/codex-continuation-contracts.md @@ -499,6 +499,14 @@ credential, while CPAMP remains admin-only. - `5h`, `24h`, and `7d` are rolling USD windows. `month` is the current Asia/Shanghai calendar month. Reset writes a soft watermark and does not delete historical `usage_events`. +- The Plus user dashboard primary live view is fixed to `24h`. Do not expose a + top-level `5h/24h/7d/month` selector on the ordinary user page; keep + four-window quota visibility in side-by-side cards instead. The user APIs may + continue accepting range parameters for compatibility and future callers. +- Refresh cancellation is browser control flow, not a user-visible sync + failure. When a manual refresh, focus/pageshow refresh, or visibility change + aborts an older in-flight user-page fetch, the page must ignore that aborted + work instead of writing usage/protection error notices. - Ordinary user throttling is RPM-only plus model allowlist and quota windows. `concurrency` and `max_active_sessions` payload fields are compatibility fields only: create/save handlers must accept stale payloads but persist and @@ -595,6 +603,9 @@ credential, while CPAMP remains admin-only. when a stale same-name cookie appears before a fresh valid cookie in the `Cookie` header. - Go unit: user events and CodexCont summaries are filtered to the current key. +- Go unit: Plus user HTML has no range dropdown, fixes usage/events requests to + `range=24h`, keeps `24H / 7D` and `5H / 本月` quota cards, and ignores + refresh-cancel aborts before rendering sync errors. - Go unit: admin HTML points mutations at `/key-policy-plus/api`, model normalization preserves unknown configured models, and create/save/reset through the admin alias persist settings. diff --git a/.trellis/tasks/07-03-cpa-usage-range-ux/check.jsonl b/.trellis/tasks/07-03-cpa-usage-range-ux/check.jsonl new file mode 100644 index 0000000..9cd59d4 --- /dev/null +++ b/.trellis/tasks/07-03-cpa-usage-range-ux/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/07-03-cpa-usage-range-ux/design.md b/.trellis/tasks/07-03-cpa-usage-range-ux/design.md new file mode 100644 index 0000000..e265bea --- /dev/null +++ b/.trellis/tasks/07-03-cpa-usage-range-ux/design.md @@ -0,0 +1,31 @@ +# Design + +## User Page Contract + +The Key Policy Plus user dashboard uses a fixed primary observation window: +`24h`. The top toolbar no longer has a time-range selector. The first-page +summary cards still show side-by-side quota windows from `/me`, while the live +usage summary and request table are backed by `/usage?range=24h` and +`/events?range=24h&limit=100`. + +## Data Flow + +- `/user/api/me` remains the source for key identity, configured limits, and + four-window quota usage. +- `/user/api/usage?range=24h` remains the source for current live usage metrics. +- `/user/api/events?range=24h&limit=100` remains the source for the request table. +- Existing backend range handling stays intact for admin surfaces and future API + consumers. + +## Error Handling + +The refresh code already cancels active fetches when a forced refresh supersedes +an in-flight one or when the page is hidden. Abort errors from that cancellation +path are control flow, not user-visible failures. Refresh jobs must treat aborts +as ignored stale work and avoid writing `state.errors.usage`, +`state.errors.protection`, or connection-failure UI. + +## Compatibility + +This is a user-page UX change only. It does not remove API range parameters, +database columns, quota windows, reset watermarks, or admin controls. diff --git a/.trellis/tasks/07-03-cpa-usage-range-ux/implement.jsonl b/.trellis/tasks/07-03-cpa-usage-range-ux/implement.jsonl new file mode 100644 index 0000000..9cd59d4 --- /dev/null +++ b/.trellis/tasks/07-03-cpa-usage-range-ux/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/07-03-cpa-usage-range-ux/implement.md b/.trellis/tasks/07-03-cpa-usage-range-ux/implement.md new file mode 100644 index 0000000..ff8e8ab --- /dev/null +++ b/.trellis/tasks/07-03-cpa-usage-range-ux/implement.md @@ -0,0 +1,45 @@ +# Implementation Plan + +1. Read the applicable Trellis backend/spec guidance before editing. +2. Update `cpa_key_policy_plus_plugin/go/assets/user.html`: + - remove the `rangeSelect` select element and all visibility/onchange logic, + - replace mutable `state.range` usage with a fixed `PRIMARY_RANGE = "24h"`, + - keep four-window quota cards from `/me`, + - add abort detection and ignore aborts inside refresh job error handling. +3. Add regression coverage in `cpa_key_policy_plus_plugin/go/main_test.go` for: + - no user range dropdown, + - fixed `range=24h` usage/events requests, + - visible fixed-24h labels, + - abort ignore helper presence. +4. Run `go test ./...` from `cpa_key_policy_plus_plugin/go`. +5. Run a final diff review focused on unrelated churn and the user-facing copy. + +## Execution Evidence + +- `go test ./...` in `cpa_key_policy_plus_plugin/go`: passed. +- `git diff --check`: passed with only CRLF conversion warnings. +- Before deployment, public `https://cpa-usage.konbakuyomu.us/` still served the + old user HTML: + - `rangeSelect`: present. + - `PRIMARY_RANGE`: absent. +- Built linux/amd64 plugin artifact: + - `cpa_key_policy_plus_plugin/dist/linux/amd64/cpa-key-policy-plus.so` + - SHA256 `4cfb5cdc0633bb428b8526c9146e6705457d753c240e7417e50a875e2fca4adc` + - `file`: ELF 64-bit x86-64 shared object. +- SJC deployment: + - uploaded only the new `cpa-key-policy-plus.so`; + - backed up prior plugin, Plus SQLite DB, CPA config, and CPA compose file to + `/opt/codex-stacks/backups/cpa-usage-range-ux-20260703-052620`; + - replaced `/opt/codex-stacks/cpa/plugins/linux/amd64/cpa-key-policy-plus.so`; + - restarted only the `cpa` container. +- Production verification after restart: + - remote plugin SHA256 matches local artifact: + `4cfb5cdc0633bb428b8526c9146e6705457d753c240e7417e50a875e2fca4adc`; + - CPA logs show `plugin_id=cpa-key-policy-plus` loaded and registered; + - public `https://cpa-usage.konbakuyomu.us/` returns `200`; + - public user HTML now has no `rangeSelect`, has `PRIMARY_RANGE`, has fixed + `range=${encodeURIComponent(PRIMARY_RANGE)}` requests, and includes + `refresh_cancelled`; + - `https://cpa.konbakuyomu.us/healthz` returns `200`; + - public blocked paths on `cpa.konbakuyomu.us` for Plus resource, + `/key-policy-plus/`, `/admin/`, and `/codexcont/` return `404`. diff --git a/.trellis/tasks/07-03-cpa-usage-range-ux/prd.md b/.trellis/tasks/07-03-cpa-usage-range-ux/prd.md new file mode 100644 index 0000000..bcec225 --- /dev/null +++ b/.trellis/tasks/07-03-cpa-usage-range-ux/prd.md @@ -0,0 +1,41 @@ +# Fix CPA usage range UX + +## Goal + +Make the Key Policy Plus user usage dashboard less misleading by removing the +time-range dropdown and fixing refresh-cancel noise. The user page should present +one clear default live view for the last 24 hours, while still showing the +important quota windows side by side. + +## Requirements + +- The user page must no longer expose the `5h / 24h / 7d / month` range dropdown. +- The primary usage summary and request table must use the last 24 hours. +- The dashboard must keep the existing four-window quota visibility for `5H`, + `24H`, `7D`, and `month` so users can compare quota risk without switching + controls. +- Existing user/admin APIs and backend range calculations must remain available + and compatible. +- Canceled in-flight refresh requests caused by a newer refresh, page focus, or + visibility transition must not render as "usage sync failed" or "protection + sync failed" notices. + +## Acceptance Criteria + +- [x] The Key Policy Plus user HTML contains no `rangeSelect` dropdown or range + onchange handler. +- [x] The user page requests `/usage?range=24h` and `/events?range=24h&limit=100` + for the live usage view. +- [x] Visible labels for the main usage card, request table heading, and request + detail range read as `24 小时`. +- [x] The four-window quota cards remain visible, including `24H / 7D` and + `5H / 本月`. +- [x] Abort/cancel errors are ignored by user-page refresh logic and do not set + usage/protection sync error notices. +- [x] `go test ./...` passes in `cpa_key_policy_plus_plugin/go`. + +## Notes + +- Evidence from live HTML showed the production page is served by the Key Policy + Plus user surface, not the older Python usage portal. +- Scope excludes the admin page range selector and backend quota/window logic. diff --git a/.trellis/tasks/07-03-cpa-usage-range-ux/task.json b/.trellis/tasks/07-03-cpa-usage-range-ux/task.json new file mode 100644 index 0000000..1488bca --- /dev/null +++ b/.trellis/tasks/07-03-cpa-usage-range-ux/task.json @@ -0,0 +1,26 @@ +{ + "id": "cpa-usage-range-ux", + "name": "cpa-usage-range-ux", + "title": "Fix CPA usage range UX", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "dxt98", + "assignee": "dxt98", + "createdAt": "2026-07-03", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/cpa_key_policy_plus_plugin/go/assets/user.html b/cpa_key_policy_plus_plugin/go/assets/user.html index 0397b56..62badb6 100644 --- a/cpa_key_policy_plus_plugin/go/assets/user.html +++ b/cpa_key_policy_plus_plugin/go/assets/user.html @@ -17,12 +17,6 @@

CPA 用量自助页

- 未登录
准备同步 - - + +
@@ -310,8 +251,8 @@

CPA Key Policy+

-

Key 列表

-

列表用于扫描状态;点击一行后在右侧编辑完整策略。

+

Key 策略

+

Key 的新增、删除、复制和别名在 CPA/CPAMP 管理;这里仅维护 Plus 策略。

@@ -319,16 +260,15 @@

Key 列表

时间保护结果模型命中轮 末轮 reasoning耗时/停止失败摘要操作用户/Key 状态 额度概览并发策略RPM策略 模型/价格 操作
暂无可显示 Key,点击右上角新建或勾选显示归档。
暂无可显示 Key,点击右上角新建。
${escapeHTML(k.name || "-")}${escapeHTML(displayPreview(k.preview || k.id || ""))}
${statusChip(k)}活跃 ${Number(k.active_sessions || 0)}
${statusChip(k)}
${renderQuotaMini(k,"5h","5H")}${renderQuotaMini(k,"24h","24H")}${renderQuotaMini(k,"7d","7D")}${renderQuotaMini(k,"month","月")}
RPM ${Number(k.rpm || 0)}请求并发 ${Number(k.concurrency || 0)}Codex窗口 ${Number(k.max_active_sessions || 0)}
RPM ${Number(k.rpm || 0)}请求频率限制
${escapeHTML(modelCountText(k))}${escapeHTML(priceCoverage(k))}
${escapeHTML(modelPreview(k))}
- + - + - - +
用户/Key来源/别名 状态 额度概览RPM策略RPM 模型/价格操作
加载中...
加载中...
@@ -337,7 +277,7 @@

Key 列表

- -