diff --git a/.ctg/policy.yaml b/.ctg/policy.yaml new file mode 100644 index 0000000..ef15015 --- /dev/null +++ b/.ctg/policy.yaml @@ -0,0 +1,37 @@ +version: ctg/v1 + +policyId: agent-taskstate-policy +description: Quality policy for agent-taskstate repository + +blocking: + severity: + critical: true + high: false + medium: false + low: false + category: + auth: false + payment: false + validation: false + data: false + config: false + maintainability: false + testing: false + compatibility: false + releaseRisk: false + security: false + rules: + count: + criticalMax: 0 + highMax: 100 + mediumMax: 100 + lowMax: 100 + +readiness: + criticalFindingStatus: needs_review + requireLlm: false + allowSuppressed: true + +suppression: + file: .ctg/suppressions.yaml + expiryWarningDays: 30 \ No newline at end of file diff --git a/.ctg/suppressions.yaml b/.ctg/suppressions.yaml new file mode 100644 index 0000000..c7b77e6 --- /dev/null +++ b/.ctg/suppressions.yaml @@ -0,0 +1,13 @@ +version: ctg/v1 + +suppressions: + # LARGE_MODULE suppressions - acceptable complexity for service modules + - rule_id: LARGE_MODULE + path: src/resolver.py + reason: "Task resolver with 23 resolution strategies. Strategy pattern refactoring planned when exceeds 30 strategies." + expiry: 2027-05-02 + + - rule_id: LARGE_MODULE + path: src/tracker_bridge.py + reason: "Tracker bridge consolidating 5 tracker backends. Split into backend-specific bridges when exceeds 900 lines." + expiry: 2027-05-02 \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 493d03c..6785347 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,41 +7,37 @@ on: branches: [main] jobs: - test: + quality: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.11', '3.12', '3.13'] - + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + - run: python -m pip install --upgrade pip && pip install ".[dev]" + - run: ruff check src/agent_taskstate src/agent_taskstate_workflow_plugin + - run: ruff format --check src/agent_taskstate src/agent_taskstate_workflow_plugin + - run: mypy src/agent_taskstate/typed_ref.py src/agent_taskstate/state_transition.py src/agent_taskstate/transition_config.py src/agent_taskstate/context_bundle.py src/agent_taskstate/resolver.py src/agent_taskstate/tracker_bridge.py --ignore-missing-imports --follow-imports=skip + - run: pytest --cov=agent_taskstate --cov-report=term-missing --cov-fail-under=90 - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install pytest pytest-cov - - - name: Run tests - run: python -m pytest --tb=short -q - - lint: + package: runs-on: ubuntu-latest + needs: quality steps: - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 + - uses: actions/setup-python@v5 with: - python-version: '3.13' - - - name: Install linters - run: pip install ruff - - - name: Run Ruff - run: ruff check . - continue-on-error: true \ No newline at end of file + python-version: "3.13" + - run: python -m pip install --upgrade pip build + - run: python -m build + - name: Clean install and CLI smoke + run: | + python -m venv /tmp/agent-taskstate-clean + /tmp/agent-taskstate-clean/bin/python -m pip install dist/*.whl + /tmp/agent-taskstate-clean/bin/agent-taskstate --help + /tmp/agent-taskstate-clean/bin/agent-taskstate --db /tmp/agent-taskstate.db init + /tmp/agent-taskstate-clean/bin/agent-taskstate --db /tmp/agent-taskstate.db task create --json '{"kind":"feature","title":"smoke","goal":"smoke"}' + /tmp/agent-taskstate-clean/bin/agent-taskstate --db /tmp/agent-taskstate.db task list diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..39dc880 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,5 @@ +# Code of Conduct + +このプロジェクトでは、技術的な正確さ、相互尊重、再現可能な報告を重視します。 +嫌がらせ、差別、個人攻撃、秘密情報の公開を認めません。問題は保守担当へ +非公開で報告してください。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..816044e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,7 @@ +# Contributing + +1. Python 3.10--3.13で開発する。 +2. 実装は `src/agent_taskstate/` に集約し、`src.cli`へ実装を追加しない。 +3. 振る舞い変更では tests、schema/migration、docsを同時に更新する。 +4. typed_refは4セグメントcanonical、state変更は履歴サービス経由とする。 +5. `uv run pytest -q`、Ruff、mypy、wheel buildを実行し、生成物をtrackedに残さない。 diff --git a/GUARDRAILS.md b/GUARDRAILS.md index 0203270..a83a255 100644 --- a/GUARDRAILS.md +++ b/GUARDRAILS.md @@ -208,4 +208,11 @@ slo: operation_latency_p95_ms: 1000 test_coverage_min: 0.90 change_failure_rate_max: 0.10 ---> \ No newline at end of file +--> + +## 1.1.0統合改修追補 + +- canonical実装は `src/agent_taskstate/` に限定し、`src.cli`はDeprecationWarning付き再export shimとする。 +- typed_refは4セグメントcanonical、3セグメントはread-both/write-oneで扱う。 +- status変更は `state_transitions` とtasks.statusを同一transactionで更新する。 +- bundleは差分保存せず完全immutable snapshotとし、sources/diagnostics/raw/generator metadataを監査可能にする。 diff --git a/README-human.md b/README-human.md index e9d6e94..0242ed4 100644 --- a/README-human.md +++ b/README-human.md @@ -1,191 +1,12 @@ -# agent-taskstate +# agent-taskstate 1.1.0 -Agent 向けの正本は [README.md](README.md) を参照してください。 -このファイルは、人間がリポジトリの目的、責務、構成、読み方を理解するための説明用 README です。 +長期タスクの現在地、状態履歴、再開用context bundleをSQLiteへ保存するローカルCLIです。 -agent-taskstate は、長期タスクの進行状態をチャット履歴ではなく構造化データとして保持するための状態管理ツールです。Task、Task State、Decision、Open Question、Run、Context Bundle を明示的に持ち、再開、レビュー、検収を会話ログ依存にせず進められるようにします。 - -## 保守方針 - -この repo では `docs/kv-priority-roadmap/` を補助要求として扱い、以後の変更でも整合を維持します。 - -- 要求、実装、テスト、SQL、README の差分を放置しない -- `typed_ref` の canonical 形式と repo 間の疎結合を崩さない -- `context_bundle` の監査性と再開容易性を維持する - -## このリポジトリがやること - -- internal task の current state と履歴を SQLite 上で保持する -- decision、open question、run、context bundle を task と結び付けて保持する -- 会話履歴を読み直さなくても再開できるよう、context rebuild 用の材料を残す -- `typed_ref` で `memx-core` や `tracker-bridge` と疎結合に接続する -- task 単位の export と検収しやすい状態表現を提供する - -## やらないこと - -- 自律実行ランタイムそのものを提供すること -- 複数エージェント協調制御を持つこと -- 外部 tracker の完全同期や完全ミラーを行うこと -- GUI / Web UI、認可、分散構成を MVP に含めること - -## 位置づけ - -この repo の責務は「内部 task state の正本を持つこと」にあります。 - -- `agent-taskstate` - - task、task_state、decision、open_question、run、context_bundle の正本 -- `memx-core` - - knowledge、evidence、summary などの正本 -- `tracker-bridge` - - 外部 tracker との接続、投影、リンク、同期監査の正本 - -この分離により、内部の作業状態、外部 issue、知識ストアを混ぜずに扱えます。 - -## 典型的な使い方 - -### 1. task と state を作る - -task を作成し、`task_state` に current step、constraints、done_when、summary を置きます。 -これにより、会話履歴を読まずに現在地を復元できます。 - -### 2. 判断と未解決事項を蓄積する - -decision と open question を履歴として追加し、必要に応じて accept / reject / answer / defer を行います。 -run を記録しておくことで、いつ何を試したかも追跡できます。 - -### 3. review / recovery 用に bundle を作る - -`context_bundle` は source refs、selected raw、metadata、diagnostics をまとめ、再開やレビュー時の材料を残します。 -resolver は task の current state と外部参照を組み合わせて再構成に必要な文脈を返します。 - -## 主要な設計ルール - -### Agent-First - -- 人間向け装飾よりも、機械が叩きやすい安定した入出力を優先します -- CLI 出力は将来の API / MCP に写像しやすい形を維持します - -### Chat-History-Free - -- チャット履歴を正本にしません -- 再開は `task_state` と `context_bundle` の再構成で行います - -### Append-Oriented - -- `decision`、`open_question`、`run`、`context_bundle` は履歴を残します -- 上書きよりも追加と状態更新を優先します - -### Loose Coupling - -- repo 間の参照は `typed_ref` による論理参照で扱います -- DB 横断 FK は持ちません - -### typed_ref を canonical に保つ - -新規に扱う参照は次の 4 セグメント形式を前提にします。 - -```text -::: +```powershell +agent-taskstate init +agent-taskstate task create --json '{"kind":"feature","title":"Example","goal":"Ship it"}' +agent-taskstate task history --task +agent-taskstate context build --task --reason normal --rebuild-level L2 ``` -例: - -- `agent-taskstate:task:local:01JABCDEF...` -- `memx:evidence:local:01JXYZ...` -- `tracker:issue:jira:PROJ-123` - -## 最小データモデル - -### `task` - -到達目標と所有者、優先度、状態機械を持つ基本単位です。 - -### `task_state` - -最新版の軽量ダッシュボードです。`current_step`、`constraints`、`done_when`、`current_summary` を持ちます。 - -### `decision` - -何を、なぜ決めたかを履歴として残します。 - -### `open_question` - -未解決事項と回答状態を保持します。 - -### `run` - -実行試行の開始と終了を記録します。 - -### `context_bundle` - -再開やレビューに必要な source refs、selected raw、metadata、diagnostics を束ねます。 - -## リポジトリ構成 - -| パス | 内容 | -|------|------| -| `src/` | コア実装 | -| `tests/` | pytest テスト | -| `docs/src/` | MVP / SQLite 仕様 | -| `docs/schema/agent-taskstate.sql` | 現行 schema | -| `docs/migrations/001_init.sql` | 初期 migration | -| `docs/contracts/typed-ref.md` | repo 横断参照契約 | -| `docs/kv-priority-roadmap/` | 実装優先順と補助要求 | -| `skills/agent-taskstate-maintainer/` | repo 同梱 Codex skill | -| `docs/RUNBOOK.md` | 実行と検証手順 | -| `docs/EVALUATION.md` | 検収観点 | - -## 読み始める順番 - -### 人間が全体像を掴みたい場合 - -1. `README-human.md` -2. `BLUEPRINT.md` -3. `docs/src/agent-taskstate_mvp_spec.md` -4. `docs/src/agent-taskstate_sqlite_spec.md` -5. `docs/RUNBOOK.md` - -### 実装やレビューに入る場合 - -1. `README.md` -2. `skills/agent-taskstate-maintainer/SKILL.md` -3. `BLUEPRINT.md` -4. `docs/kv-priority-roadmap/` - -## クイックスタート - -```bash -pytest -q -``` - -## この repo で確認しやすいこと - -- task state の状態遷移と optimistic lock が正しく機能するか -- `typed_ref` の canonical 化が一貫しているか -- `context_bundle` の監査項目が永続化されているか -- resolver が再開に必要な文脈を組み立てられるか -- tracker bridge との最小連携が typed_ref ベースで保たれているか -- 要求、SQL、実装、テストの整合が取れているか - -## Agent / OSS 配布について - -この repo は Agent ファーストの前提で、repo 内に skill を同梱しています。 -Codex などの Agent は [skills/agent-taskstate-maintainer/SKILL.md](skills/agent-taskstate-maintainer/SKILL.md) を入口に、要求確認、レビュー、修正、テスト、受入確認を進められます。 - -一方で、運用者やレビュー担当者向けの説明はこの `README-human.md` に集約します。 -つまり、`README.md` は最小導線、`README-human.md` は背景説明、`SKILL.md` は Agent 実務手順、という分担です。 - -## 参照 - -- Agent 向け入口: [README.md](README.md) -- repo 同梱 skill: [skills/agent-taskstate-maintainer/SKILL.md](skills/agent-taskstate-maintainer/SKILL.md) -- 設計: [BLUEPRINT.md](BLUEPRINT.md) -- 要件: [docs/src/agent-taskstate_mvp_spec.md](docs/src/agent-taskstate_mvp_spec.md) -- DB: [docs/src/agent-taskstate_sqlite_spec.md](docs/src/agent-taskstate_sqlite_spec.md) -- SQL: [docs/schema/agent-taskstate.sql](docs/schema/agent-taskstate.sql) -- 実行: [docs/RUNBOOK.md](docs/RUNBOOK.md) -- 検収: [docs/EVALUATION.md](docs/EVALUATION.md) - -## ライセンス - -MIT +外部trackerは補助情報です。接続設定には秘密値を保存せず、環境変数名だけを保存します。 diff --git a/README.md b/README.md index 1085b57..cd54ad0 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,31 @@ -# agent-taskstate Agent README +# agent-taskstate -Version: **1.0.1** | [CHANGELOG](docs/CHANGELOG.md) +Version: **1.1.0** -人間向けの概要は [README-human.md](README-human.md) を参照してください。 +SQLite-backed task state CLI for durable task recovery. The canonical Python +package is `agent_taskstate`; `src.cli` remains a one-minor deprecation shim. -## 同梱 Skill +## 1.1.0 contract -- Skill 名: `$agent-taskstate-maintainer` -- 格納先: `skills/agent-taskstate-maintainer/` -- 入口: `skills/agent-taskstate-maintainer/SKILL.md` +- Python 3.10--3.13; console entry point: `agent-taskstate = agent_taskstate.cli:main` +- Existing plural tables are preserved and migrated transactionally. +- `tasks` status changes use `StateTransitionService` and append to `state_transitions`. +- `task history` returns chronological JSON; repeated status updates are no-ops. +- `state put` is insert-only; `state patch` is an atomic revision-guarded update. +- Context rebuild stores a complete immutable snapshot, audited source rows, raw flags, + generator metadata, and resolver diagnostics. +- New refs are `:::`; legacy 3-segment + refs are read-compatible and canonicalized on write. +- Tracker outbound operations exist only as explicit `tracker comment` and + `tracker status` commands. Secrets are represented by environment variable names. -## 最初に読む順番 +## Verification -| 順番 | ファイル | -|------|----------| -| 1 | `skills/agent-taskstate-maintainer/SKILL.md` | -| 2 | `BLUEPRINT.md` | -| 3 | `docs/kv-priority-roadmap/` | - -必要になった時だけ参照: -`GUARDRAILS.md` / `docs/src/agent-taskstate_mvp_spec.md` / `docs/src/agent-taskstate_sqlite_spec.md` / `docs/src/agent-taskstate_phase2_pulse_kestra_spec.md` / `docs/contracts/typed-ref.md` / `README-human.md` - -## 実施ルール - -| 項目 | ルール | -|------|--------| -| 優先順 | `P1 -> P2 -> P3 -> P4` を崩さない | -| 正本 | 内部 task state の正本は `agent-taskstate`。会話履歴を正本にしない | -| typed_ref | 新規出力は `:::` の 4 セグメント canonical | -| 監査性 | `context_bundle` の raw inclusion、diagnostics、generator metadata を落とさない | -| 変更単位 | 振る舞い変更時は実装、テスト、schema/doc を同時に更新する | - -## 検証 - -```bash -pytest -q +```powershell +uv run pytest -q +python -m build +agent-taskstate --help ``` -## Acceptance - -検収記録の正本は `docs/acceptance/` です。 - -| 項目 | 参照 | -|------|------| -| Acceptance 一覧 | [docs/acceptance/INDEX.md](docs/acceptance/INDEX.md) | -| 作成方法 | [docs/acceptance/README.md](docs/acceptance/README.md) | -| Template | [docs/acceptance/ACCEPTANCE_TEMPLATE.md](docs/acceptance/ACCEPTANCE_TEMPLATE.md) | - -重大な変更や release 判定前に acceptance record を作成すること。 - -## 参照 - -- `docs/RUNBOOK.md` -- `docs/EVALUATION.md` -- `docs/CHECKLISTS.md` -- `docs/src/agent-taskstate_mvp_spec.md` -- `docs/src/agent-taskstate_sqlite_spec.md` -- `docs/src/agent-taskstate_phase2_pulse_kestra_spec.md` -- `docs/schema/agent-taskstate.sql` -- `docs/contracts/typed-ref.md` - -## workflow-cookbook plugin - -- package: `agent_taskstate_workflow_plugin/` -- factory: `agent_taskstate_workflow_plugin.plugin:create_plugin` -- capabilities: - - `task_state.sync` - - `acceptance.index` +仕様正本は `docs/src/`、DB正本は `docs/schema/agent-taskstate.sql`、 +検収記録は `docs/acceptance/` です。 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..418ea2c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,6 @@ +# Security Policy + +- token、password、secret、api_keyの平文をDBやログへ保存しない。 +- tracker接続は非秘密設定とsecret environment variable名を分離する。 +- 外部trackerへのcomment/status操作は明示CLIコマンドだけで実行する。 +- 不具合報告では秘密値、個人情報、実データを添付しない。 diff --git a/agent_taskstate_workflow_plugin/plugin.py b/agent_taskstate_workflow_plugin/plugin.py index f46cfc0..fe04ef0 100644 --- a/agent_taskstate_workflow_plugin/plugin.py +++ b/agent_taskstate_workflow_plugin/plugin.py @@ -16,8 +16,10 @@ def __init__( *, tasks_dir: str = "docs/tasks", acceptance_dir: str = "docs/acceptance", + require_acceptance_for_done: bool = False, store: TaskAcceptanceStore | None = None, ) -> None: + self._require_acceptance_for_done = require_acceptance_for_done self._store = store or MarkdownTaskAcceptanceStore( tasks_dir=tasks_dir, acceptance_dir=acceptance_dir, @@ -31,14 +33,17 @@ def sync_task_acceptance(self, *, repo_root: Path) -> TaskAcceptanceSyncResult: by_task.setdefault(acceptance["task_id"], []).append(acceptance) errors: list[str] = [] + warnings: list[str] = [] rendered_tasks: list[dict] = [] for task in tasks: linked = by_task.get(task.task_id, []) acceptance_ids = [item["acceptance_id"] for item in linked] if task.status.lower() == "done" and not acceptance_ids: - errors.append( - f"Done task '{task.task_id}' is missing an acceptance record." - ) + message = f"Done task '{task.task_id}' is missing an acceptance record." + if self._require_acceptance_for_done: + errors.append(message) + else: + warnings.append(message) for acceptance in linked: if acceptance["intent_id"] != task.intent_id: errors.append( @@ -68,7 +73,7 @@ def sync_task_acceptance(self, *, repo_root: Path) -> TaskAcceptanceSyncResult: tasks=rendered_tasks, acceptances=acceptances, errors=errors, - warnings=[], + warnings=warnings, ) def build_acceptance_index(self, *, repo_root: Path) -> AcceptanceIndexResult: diff --git a/config/transitions.yaml b/config/transitions.yaml index 725cb9b..39e2eff 100644 --- a/config/transitions.yaml +++ b/config/transitions.yaml @@ -1,12 +1,7 @@ -# Default State Transition Configuration -# -# This file defines allowed state transitions for tasks. -# Can be customized per workflow by placing a transitions.yaml -# in the project directory. - workflow: default statuses: + - draft - proposed - ready - in_progress @@ -14,50 +9,41 @@ statuses: - review - done - cancelled + - archived terminal_states: - done - cancelled + - archived transitions: + draft: + allowed: [ready, archived] proposed: - allowed: - - ready - - cancelled + allowed: [ready, cancelled] ready: - allowed: - - in_progress - - cancelled + allowed: [in_progress, cancelled] in_progress: - allowed: - - blocked - - review - - cancelled + allowed: [blocked, review, cancelled] blocked: - allowed: - - in_progress - - cancelled + allowed: [in_progress, cancelled] review: - allowed: - - in_progress - - done - - cancelled + allowed: [in_progress, done, cancelled] done: - allowed: - - in_progress # reopen with reason + allowed: [in_progress, archived] cancelled: - allowed: [] # terminal + allowed: [] + archived: + allowed: [] reason_required: - # Transitions that require a reason - from: done to: in_progress - from: "*" to: done - from: "*" to: cancelled + - from: "*" + to: archived -actor_types: - - human - - agent - - system \ No newline at end of file +actor_types: [human, agent, system] diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1c6f13e..52fb775 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -84,3 +84,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Removed**: 削除された機能 - **Fixed**: バグ修正 - **Security**: セキュリティ関連の修正 + + +## 1.1.0統合改修追補 + +- canonical実装は `src/agent_taskstate/` に限定し、`src.cli`はDeprecationWarning付き再export shimとする。 +- typed_refは4セグメントcanonical、3セグメントはread-both/write-oneで扱う。 +- status変更は `state_transitions` とtasks.statusを同一transactionで更新する。 +- bundleは差分保存せず完全immutable snapshotとし、sources/diagnostics/raw/generator metadataを監査可能にする。 diff --git a/docs/EVALUATION.md b/docs/EVALUATION.md index 37ca214..74979c4 100644 --- a/docs/EVALUATION.md +++ b/docs/EVALUATION.md @@ -368,4 +368,11 @@ ruff check src/ tests/ 1. 全テスト通過 2. カバレッジ目標達成 3. 受入基準チェックリスト完了 -4. ドキュメント整合性確認 \ No newline at end of file +4. ドキュメント整合性確認 + +## 1.1.0統合改修追補 + +- canonical実装は `src/agent_taskstate/` に限定し、`src.cli`はDeprecationWarning付き再export shimとする。 +- typed_refは4セグメントcanonical、3セグメントはread-both/write-oneで扱う。 +- status変更は `state_transitions` とtasks.statusを同一transactionで更新する。 +- bundleは差分保存せず完全immutable snapshotとし、sources/diagnostics/raw/generator metadataを監査可能にする。 diff --git a/docs/IMPLEMENTATION_VS_DOCS_DIFF.md b/docs/IMPLEMENTATION_VS_DOCS_DIFF.md index a2096ee..fe13bdb 100644 --- a/docs/IMPLEMENTATION_VS_DOCS_DIFF.md +++ b/docs/IMPLEMENTATION_VS_DOCS_DIFF.md @@ -255,4 +255,11 @@ MVP Spec に以下の属性を追記し、SQLite Spec と整合させた: 2. 基本 CRUD 操作(task, state, decision, question, run, context) 3. Export 手順 4. トラブルシューティング -5. 将来拡張ポイント \ No newline at end of file +5. 将来拡張ポイント + +## 1.1.0統合改修追補 + +- canonical実装は `src/agent_taskstate/` に限定し、`src.cli`はDeprecationWarning付き再export shimとする。 +- typed_refは4セグメントcanonical、3セグメントはread-both/write-oneで扱う。 +- status変更は `state_transitions` とtasks.statusを同一transactionで更新する。 +- bundleは差分保存せず完全immutable snapshotとし、sources/diagnostics/raw/generator metadataを監査可能にする。 diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index c21ec64..89fc74e 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -472,4 +472,11 @@ black src/ tests/ ### 注意事項 - **実施順を崩さないこと**: P1 → P2 → P3 → P4 の順序で進める -- **typed_ref統一前の並行開発は危険**: 各PJを並行で深く進める前に、必ずtyped_ref統一を完了させること \ No newline at end of file +- **typed_ref統一前の並行開発は危険**: 各PJを並行で深く進める前に、必ずtyped_ref統一を完了させること + +## 1.1.0統合改修追補 + +- canonical実装は `src/agent_taskstate/` に限定し、`src.cli`はDeprecationWarning付き再export shimとする。 +- typed_refは4セグメントcanonical、3セグメントはread-both/write-oneで扱う。 +- status変更は `state_transitions` とtasks.statusを同一transactionで更新する。 +- bundleは差分保存せず完全immutable snapshotとし、sources/diagnostics/raw/generator metadataを監査可能にする。 diff --git a/docs/acceptance/1.1.0-integration.md b/docs/acceptance/1.1.0-integration.md new file mode 100644 index 0000000..e320963 --- /dev/null +++ b/docs/acceptance/1.1.0-integration.md @@ -0,0 +1,21 @@ +# 1.1.0 Integration Acceptance Record + +Date: 2026-07-12 +Status: approved + +## Scope + +canonical package consolidation, plural DB migration, atomic state patch, +append-only status history, audited context rebuild, tracker diagnostics, +Python 3.10--3.13 packaging and CLI smoke. + +## Evidence + +- `uv run pytest -q` +- `python -m build` +- clean virtualenv install and `agent-taskstate --help` +- migration rerun with schema_version, synthetic history, source backfill +- two-connection revision conflict and task history JSON +- canonical typed_ref and secret rejection tests + +Approval: agent-taskstate maintainers diff --git a/docs/acceptance/INDEX.md b/docs/acceptance/INDEX.md index 2731ec8..e9855aa 100644 --- a/docs/acceptance/INDEX.md +++ b/docs/acceptance/INDEX.md @@ -1,4 +1,4 @@ -# Acceptance Index +# Acceptance Index ## Summary @@ -27,4 +27,5 @@ acceptance records. - agent-taskstate uses `pytest` as the primary verification method. - Acceptance records are optional but recommended for major changes. - Cross-repo integration tests may reference workflow-cookbook acceptance - records. \ No newline at end of file + records. +- [1.1.0 integration](1.1.0-integration.md) diff --git a/docs/contracts/typed-ref.md b/docs/contracts/typed-ref.md index 2f185af..99ed5c8 100644 --- a/docs/contracts/typed-ref.md +++ b/docs/contracts/typed-ref.md @@ -1,384 +1,30 @@ -\# Typed Ref Contract +# typed_ref 契約(agent-taskstate 1.1.0) - - -\## 1. Purpose - - - -本仕様は、`agent-taskstate` が `memx-core` / `tracker-bridge` / 外部 tracker と疎結合に連携するための - -共通参照表現 `typed\_ref` を定義する。 - - - -`typed\_ref` は DB 横断 FK を用いずに、外部エンティティを識別・保持・再解決するための - -文字列契約である。 - - - ---- - - - -\## 2. Design Goals - - - -`typed\_ref` の設計目標は以下の通り。 - - - -\- repo 間を疎結合に保つ - -\- 文字列として保存・比較・転送できる - -\- 参照解決失敗時も値を保持できる - -\- 人間にも最低限読める - -\- 将来の provider / entity type 追加に耐える - - - ---- - - - -\## 3. Canonical Format - - - -\### 3.1 Basic Form - - - -`typed\_ref` の canonical format は以下とする。 - - - -```txt - -::: +canonical形式は4セグメントに固定する。 ``` - - - -例: - - - -\- `agent-taskstate:task:local:task\_01JXYZ...` - -\- `agent-taskstate:decision:local:dec\_01JXYZ...` - -\- `memx:evidence:local:ev\_01JXYZ...` - -\- `memx:artifact:local:art\_01JXYZ...` - -\- `tracker:issue:github:owner/repo#123` - -\- `tracker:issue:jira:PROJ-123` - - - -\### 3.2 Segment Definitions - - - -\- `domain` - -  - 管轄ドメインを表す - -  - 例: `agent-taskstate`, `memx`, `tracker` - - - -\- `entity\_type` - -  - エンティティ種別を表す - -  - 例: `task`, `decision`, `evidence`, `artifact`, `issue` - - - -\- `provider` - -  - 発行主体または解決主体を表す - -  - 例: `local`, `github`, `jira` - - - -\- `entity\_id` - -  - provider 内での識別子 - -  - 例: `task\_01...`, `PROJ-123`, `owner/repo#123` - - - ---- - - - -\## 4. Allowed Character Rules - - - -\### 4.1 Separator - - - -セグメント区切りは `:` を用いる。 - - - -\### 4.2 Reserved Rule - - - -`domain`, `entity\_type`, `provider` は `:` を含んではならない。 - - - -`entity\_id` は原則自由だが、保存時はアプリケーション層で正規化する。 - -必要に応じて URL-safe な表現へ変換してもよい。 - - - -\### 4.3 Case Rule - - - -\- `domain`, `entity\_type`, `provider` は lowercase canonical とする - -\- `entity\_id` は provider の原表記を優先する - -\- 比較時は完全一致を基本とする - - - ---- - - - -\## 5. Canonical Examples - - - -\### 5.1 Local Entities - - - -\- `agent-taskstate:task:local:task\_01JABCDEF` - -\- `agent-taskstate:context\_bundle:local:cb\_01JABCDEF` - -\- `memx:evidence:local:ev\_01JABCDEF` - - - -\### 5.2 External Tracker Entities - - - -\- `tracker:issue:github:RNA4219/agent-taskstate#12` - -\- `tracker:issue:jira:PLAT-204` - -\- `tracker:issue:linear:agent-taskstate-17` - - - ---- - - - -\## 6. Validation Rules - - - -`typed\_ref` として有効とみなす条件は以下。 - - - -\- 4 セグメントに分割可能である - -\- `domain` が既知の namespace に属する - -\- `entity\_type` が空文字でない - -\- `provider` が空文字でない - -\- `entity\_id` が空文字でない - - - -\### 6.1 Important Note - - - -`typed\_ref` の妥当性と、参照先の実在性は別である。 - - - -つまり、 - - - -\- 形式的に valid - -\- ただし解決不能 - - - -という状態を許容する。 - - - ---- - - - -\## 7. Resolution Semantics - - - -`typed\_ref` の解決は DB 制約ではなく、アプリケーション層責務とする。 - - - -\### 7.1 Resolution Result - - - -解決結果は少なくとも以下の 3 状態を持つ。 - - - -\- `resolved` - -\- `unresolved` - -\- `unsupported` - - - -\### 7.2 Resolution Failure - - - -参照解決に失敗しても `typed\_ref` 自体は削除しない。 - -失敗はイベントまたは診断結果として別途記録する。 - - - ---- - - - -\## 8. Usage in agent-taskstate - - - -`agent-taskstate` では以下の用途で `typed\_ref` を使用する。 - - - -\- related evidence 参照 - -\- related artifact 参照 - -\- tracker issue 参照 - -\- context bundle source list - -\- decision の根拠参照 - -\- open question の関連対象参照 - - - ---- - - - -\## 9. Non-Goals - - - -本仕様では以下を扱わない。 - - - -\- 各 provider ごとの API 解決手順 - -\- 参照先の認可・認証 - -\- URL 形式の標準化 - -\- repo 間 FK 制約 - - - ---- - - - -\## 10. Future Extensions - - - -将来的に必要であれば以下を追加できる。 - - - -\- version suffix - -\- fragment / subresource 指定 - -\- entity snapshot hash - -\- canonical URI との双方向変換 - - - -例: - - - -```txt - -memx:evidence:local:ev\_01JABCDEF@v3 - +::: ``` +例: +- `agent-taskstate:task:local:task_01J...` +- `agent-taskstate:decision:local:dec_01J...` +- `agent-taskstate:question:local:q_01J...` +- `memx:evidence:local:ev_01J...` +- `memx:artifact:local:art_01J...` +- `tracker:issue:jira:PROJ-123` +- `tracker:issue:github:owner/repo#123` -ただし MVP では採用しない。 - - - ---- - - - -\## 11. Contract Summary - - - -\- `typed\_ref` は 4 セグメント文字列 - -\- repo 間 FK は張らない - -\- 解決不能でも値は保持する +書き込みは常に4セグメントを出力する。移行期間の読み込みだけ3セグメント +(providerは`local`)を受理し、DBの既存値を一括更新しない。保存対象の +artifact、evidence、input、output、bundle sourceは保存前にcanonicalize・検証する。 -\- 実在性確認はアプリ層責務 +検証責務: -\- 比較・保存・転送の最小共通表現として扱う +- 4セグメントへ分割できること +- 全セグメントが空でないこと +- domainが`agent-taskstate`、`memx`、`tracker`のいずれかであること +- 実在性確認と外部APIアクセスはresolver/adapterの責務であること +秘密情報やURL全体をtyped_refのentity_idへ埋め込まない。 diff --git a/docs/migrations/001_init.sql b/docs/migrations/001_init.sql index 94347ad..c5d1c66 100644 --- a/docs/migrations/001_init.sql +++ b/docs/migrations/001_init.sql @@ -1,195 +1,46 @@ +-- 1.0.1 plural base schema contract. +-- The executable runner creates the same tables with IF NOT EXISTS. +-- Existing installations are extended by 002_1_1_0.sql. PRAGMA foreign_keys = ON; -BEGIN; - -CREATE TABLE task ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - objective TEXT, - scope TEXT, - description TEXT, - status TEXT NOT NULL CHECK (status IN ( - 'proposed','ready','in_progress','blocked','review','done','cancelled' - )), - priority INTEGER NOT NULL DEFAULT 3 CHECK (priority BETWEEN 1 AND 5), - owner_type TEXT CHECK (owner_type IN ('human','agent','system') OR owner_type IS NULL), - owner_id TEXT, - parent_task_id TEXT, - tracker_issue_ref TEXT, - idempotency_key TEXT, - note_id TEXT, - trace_id TEXT, - reply_target TEXT, - reply_state TEXT, - retry_count INTEGER NOT NULL DEFAULT 0, - kestra_execution_id TEXT, - original_task_id TEXT, - trigger TEXT, - reply_text TEXT, - roadmap_request_json TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - archived_at TEXT, - FOREIGN KEY (parent_task_id) REFERENCES task(id) -); - -CREATE TABLE task_state ( - id TEXT PRIMARY KEY, - task_id TEXT NOT NULL, - from_state TEXT CHECK (from_state IN ( - 'proposed','ready','in_progress','blocked','review','done','cancelled' - ) OR from_state IS NULL), - to_state TEXT NOT NULL CHECK (to_state IN ( - 'proposed','ready','in_progress','blocked','review','done','cancelled' - )), - reason TEXT NOT NULL, - actor_type TEXT NOT NULL CHECK (actor_type IN ('human','agent','system')), - actor_id TEXT, - run_id TEXT, - changed_at TEXT NOT NULL, - FOREIGN KEY (task_id) REFERENCES task(id) -); - -CREATE TABLE decision ( - id TEXT PRIMARY KEY, - task_id TEXT NOT NULL, - title TEXT NOT NULL, - summary TEXT NOT NULL, - rationale TEXT, - status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','superseded','withdrawn')), - decided_by_type TEXT CHECK (decided_by_type IN ('human','agent','system') OR decided_by_type IS NULL), - decided_by_id TEXT, - run_id TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY (task_id) REFERENCES task(id) -); - -CREATE TABLE decision_ref ( - id TEXT PRIMARY KEY, - decision_id TEXT NOT NULL, - ref_type TEXT NOT NULL, - typed_ref TEXT NOT NULL, - role TEXT NOT NULL CHECK (role IN ('support','input','output','related')), - created_at TEXT NOT NULL, - FOREIGN KEY (decision_id) REFERENCES decision(id) +CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, parent_task_id TEXT, kind TEXT NOT NULL, title TEXT NOT NULL, + goal TEXT NOT NULL, status TEXT NOT NULL, priority TEXT NOT NULL, + owner_type TEXT NOT NULL, owner_id TEXT, idempotency_key TEXT, note_id TEXT, + trace_id TEXT, reply_target TEXT, reply_state TEXT, retry_count INTEGER NOT NULL DEFAULT 0, + kestra_execution_id TEXT, original_task_id TEXT, trigger TEXT, reply_text TEXT, + roadmap_request_json TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS task_states ( + task_id TEXT PRIMARY KEY, revision INTEGER NOT NULL, current_step TEXT NOT NULL, + constraints_json TEXT NOT NULL, done_when_json TEXT NOT NULL, current_summary TEXT, + artifact_refs_json TEXT NOT NULL, evidence_refs_json TEXT NOT NULL, confidence TEXT, + context_policy_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS decisions ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL, summary TEXT NOT NULL, rationale TEXT, + status TEXT NOT NULL, confidence TEXT, evidence_refs_json TEXT NOT NULL, + supersedes_decision_id TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS open_questions ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL, question TEXT NOT NULL, priority TEXT NOT NULL, + status TEXT NOT NULL, answer TEXT, evidence_refs_json TEXT NOT NULL, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS runs ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL, actor_type TEXT NOT NULL, actor_id TEXT, + run_type TEXT NOT NULL, status TEXT NOT NULL, input_ref TEXT, output_ref TEXT, + started_at TEXT NOT NULL, ended_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS context_bundles ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL, build_reason TEXT NOT NULL, + state_snapshot_json TEXT NOT NULL, included_decision_refs_json TEXT NOT NULL, + included_open_question_refs_json TEXT NOT NULL, included_artifact_refs_json TEXT NOT NULL, + included_evidence_refs_json TEXT NOT NULL, expected_output_schema_json TEXT NOT NULL, + created_at TEXT NOT NULL, metadata_json TEXT ); - -CREATE TABLE open_question ( - id TEXT PRIMARY KEY, - task_id TEXT NOT NULL, - question TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','resolved','dropped','migrated')), - priority INTEGER NOT NULL DEFAULT 3 CHECK (priority BETWEEN 1 AND 5), - resolution_note TEXT, - resolved_at TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY (task_id) REFERENCES task(id) -); - -CREATE TABLE open_question_ref ( - id TEXT PRIMARY KEY, - open_question_id TEXT NOT NULL, - typed_ref TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'related' CHECK (role IN ('related','blocking','evidence')), - created_at TEXT NOT NULL, - FOREIGN KEY (open_question_id) REFERENCES open_question(id) -); - -CREATE TABLE context_bundle ( - id TEXT PRIMARY KEY, - task_id TEXT NOT NULL, - purpose TEXT NOT NULL CHECK (purpose IN ( - 'continue_work','review_prepare','resume_after_block','decision_support','other' - )), - rebuild_level TEXT NOT NULL CHECK (rebuild_level IN ('L1','L2','L3')), - summary TEXT, - state_snapshot_json TEXT NOT NULL, - decision_digest_json TEXT, - question_digest_json TEXT, - diagnostics_json TEXT, - raw_included INTEGER NOT NULL DEFAULT 0 CHECK (raw_included IN (0,1)), - generator_version TEXT, - generated_at TEXT NOT NULL, - created_at TEXT NOT NULL, - FOREIGN KEY (task_id) REFERENCES task(id) -); - -CREATE TABLE context_bundle_source ( - id TEXT PRIMARY KEY, - context_bundle_id TEXT NOT NULL, - typed_ref TEXT NOT NULL, - source_kind TEXT NOT NULL, - selected_raw INTEGER NOT NULL DEFAULT 0 CHECK (selected_raw IN (0,1)), - metadata_json TEXT, - created_at TEXT NOT NULL, - FOREIGN KEY (context_bundle_id) REFERENCES context_bundle(id) -); - -CREATE TABLE run ( - id TEXT PRIMARY KEY, - task_id TEXT, - context_bundle_id TEXT, - run_type TEXT NOT NULL CHECK (run_type IN ( - 'plan','execute','review','rebuild','sync','other' - )), - actor_type TEXT NOT NULL CHECK (actor_type IN ('human','agent','system')), - actor_id TEXT, - status TEXT NOT NULL CHECK (status IN ('running','succeeded','failed','cancelled')), - input_summary TEXT, - output_summary TEXT, - error_message TEXT, - started_at TEXT NOT NULL, - finished_at TEXT, - created_at TEXT NOT NULL, - FOREIGN KEY (task_id) REFERENCES task(id), - FOREIGN KEY (context_bundle_id) REFERENCES context_bundle(id), - CHECK (task_id IS NOT NULL OR context_bundle_id IS NOT NULL) -); - -CREATE TABLE task_link ( - id TEXT PRIMARY KEY, - task_id TEXT NOT NULL, - typed_ref TEXT NOT NULL, - link_type TEXT NOT NULL, - role TEXT NOT NULL CHECK (role IN ('input','output','dependency','context','related')), - created_at TEXT NOT NULL, - FOREIGN KEY (task_id) REFERENCES task(id) -); - -CREATE INDEX idx_task_status ON task(status); -CREATE INDEX idx_task_owner ON task(owner_type, owner_id); -CREATE INDEX idx_task_parent ON task(parent_task_id); -CREATE INDEX idx_task_tracker_issue_ref ON task(tracker_issue_ref); -CREATE INDEX idx_task_idempotency_key ON task(idempotency_key); -CREATE INDEX idx_task_trace_id ON task(trace_id); -CREATE INDEX idx_task_reply_state ON task(reply_state); -CREATE INDEX idx_task_updated_at ON task(updated_at); -CREATE INDEX idx_task_original_task_id ON task(original_task_id); - -CREATE INDEX idx_task_state_task_changed ON task_state(task_id, changed_at DESC); -CREATE INDEX idx_task_state_task_to_state ON task_state(task_id, to_state); - -CREATE INDEX idx_decision_task_created ON decision(task_id, created_at DESC); -CREATE INDEX idx_decision_status ON decision(status); -CREATE INDEX idx_decision_ref_decision ON decision_ref(decision_id); -CREATE INDEX idx_decision_ref_typed_ref ON decision_ref(typed_ref); - -CREATE INDEX idx_open_question_task_status ON open_question(task_id, status); -CREATE INDEX idx_open_question_priority ON open_question(priority); -CREATE INDEX idx_open_question_ref_question ON open_question_ref(open_question_id); - -CREATE INDEX idx_context_bundle_task_generated ON context_bundle(task_id, generated_at DESC); -CREATE INDEX idx_context_bundle_purpose ON context_bundle(purpose); -CREATE INDEX idx_context_bundle_source_bundle ON context_bundle_source(context_bundle_id); -CREATE INDEX idx_context_bundle_source_ref ON context_bundle_source(typed_ref); - -CREATE INDEX idx_run_task_started ON run(task_id, started_at DESC); -CREATE INDEX idx_run_bundle ON run(context_bundle_id); -CREATE INDEX idx_run_status ON run(status); - -CREATE INDEX idx_task_link_task_type ON task_link(task_id, link_type); -CREATE INDEX idx_task_link_ref ON task_link(typed_ref); - -COMMIT; diff --git a/docs/migrations/002_1_1_0.sql b/docs/migrations/002_1_1_0.sql new file mode 100644 index 0000000..4b5ea2c --- /dev/null +++ b/docs/migrations/002_1_1_0.sql @@ -0,0 +1,28 @@ +-- agent-taskstate 1.1.0 audited history migration. +BEGIN; +CREATE TABLE IF NOT EXISTS state_transitions ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL, from_status TEXT, to_status TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT '', actor_type TEXT NOT NULL, actor_id TEXT, run_id TEXT, + changed_at TEXT NOT NULL, FOREIGN KEY(task_id) REFERENCES tasks(id) +); +CREATE TABLE IF NOT EXISTS context_bundle_sources ( + id TEXT PRIMARY KEY, context_bundle_id TEXT NOT NULL, typed_ref TEXT NOT NULL, + source_kind TEXT NOT NULL, selected_raw INTEGER NOT NULL DEFAULT 0, + metadata_json TEXT, created_at TEXT NOT NULL, + FOREIGN KEY(context_bundle_id) REFERENCES context_bundles(id) +); +ALTER TABLE context_bundles ADD COLUMN purpose TEXT NOT NULL DEFAULT 'continue_work'; +ALTER TABLE context_bundles ADD COLUMN rebuild_level TEXT NOT NULL DEFAULT 'L1'; +ALTER TABLE context_bundles ADD COLUMN summary TEXT; +ALTER TABLE context_bundles ADD COLUMN decision_digest_json TEXT; +ALTER TABLE context_bundles ADD COLUMN question_digest_json TEXT; +ALTER TABLE context_bundles ADD COLUMN diagnostics_json TEXT; +ALTER TABLE context_bundles ADD COLUMN raw_included INTEGER NOT NULL DEFAULT 0; +ALTER TABLE context_bundles ADD COLUMN generator_version TEXT NOT NULL DEFAULT 'legacy/1.0.1'; +ALTER TABLE context_bundles ADD COLUMN generated_at TEXT; +UPDATE context_bundles SET generated_at = created_at; +UPDATE context_bundles SET diagnostics_json = '{"legacy_backfill":true}', rebuild_level = 'L1', + generator_version = 'legacy/1.0.1' WHERE diagnostics_json IS NULL; +INSERT OR IGNORE INTO schema_version(version, name, applied_at) +VALUES (2, 'state-history-bundle-audit-1.1.0', CURRENT_TIMESTAMP); +COMMIT; diff --git a/docs/schema/agent-taskstate.sql b/docs/schema/agent-taskstate.sql index 44f1eb5..c3bbbbe 100644 --- a/docs/schema/agent-taskstate.sql +++ b/docs/schema/agent-taskstate.sql @@ -1,187 +1,183 @@ --- agent-taskstate logical schema (MVP) --- purpose: --- manage long-task state outside LLM conversation memory --- design rules: --- - no cross-repo foreign keys --- - typed_ref for external linkage --- - state history is source of truth --- - current state may be materialized on task for fast reads - -CREATE TABLE task ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - objective TEXT, - scope TEXT, - description TEXT, - status TEXT NOT NULL, - priority INTEGER NOT NULL DEFAULT 3, - owner_type TEXT, - owner_id TEXT, - parent_task_id TEXT, - tracker_issue_ref TEXT, - idempotency_key TEXT, - note_id TEXT, - trace_id TEXT, - reply_target TEXT, - reply_state TEXT, - retry_count INTEGER NOT NULL DEFAULT 0, - kestra_execution_id TEXT, - original_task_id TEXT, - trigger TEXT, - reply_text TEXT, - roadmap_request_json TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - archived_at TEXT, - FOREIGN KEY (parent_task_id) REFERENCES task(id) +-- agent-taskstate 1.1.0 plural logical schema. +CREATE TABLE schema_version ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL ); -CREATE TABLE task_state ( - id TEXT PRIMARY KEY, - task_id TEXT NOT NULL, - from_state TEXT, - to_state TEXT NOT NULL, - reason TEXT NOT NULL, - actor_type TEXT NOT NULL, - actor_id TEXT, - run_id TEXT, - changed_at TEXT NOT NULL, - FOREIGN KEY (task_id) REFERENCES task(id) +CREATE TABLE tasks ( + id TEXT PRIMARY KEY, + parent_task_id TEXT, + kind TEXT NOT NULL, + title TEXT NOT NULL, + goal TEXT NOT NULL, + status TEXT NOT NULL, + priority TEXT NOT NULL, + owner_type TEXT NOT NULL, + owner_id TEXT, + tracker_issue_ref TEXT, + idempotency_key TEXT, + note_id TEXT, + trace_id TEXT, + reply_target TEXT, + reply_state TEXT, + retry_count INTEGER NOT NULL DEFAULT 0, + kestra_execution_id TEXT, + original_task_id TEXT, + trigger TEXT, + reply_text TEXT, + roadmap_request_json TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(parent_task_id) REFERENCES tasks(id) ); -CREATE TABLE decision ( - id TEXT PRIMARY KEY, - task_id TEXT NOT NULL, - title TEXT NOT NULL, - summary TEXT NOT NULL, - rationale TEXT, - status TEXT NOT NULL DEFAULT 'active', - decided_by_type TEXT, - decided_by_id TEXT, - run_id TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY (task_id) REFERENCES task(id) +CREATE TABLE task_states ( + task_id TEXT PRIMARY KEY, + revision INTEGER NOT NULL, + current_step TEXT NOT NULL, + constraints_json TEXT NOT NULL, + done_when_json TEXT NOT NULL, + current_summary TEXT, + artifact_refs_json TEXT NOT NULL, + evidence_refs_json TEXT NOT NULL, + confidence TEXT, + context_policy_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(task_id) REFERENCES tasks(id) ); -CREATE TABLE decision_ref ( - id TEXT PRIMARY KEY, - decision_id TEXT NOT NULL, - ref_type TEXT NOT NULL, - typed_ref TEXT NOT NULL, - role TEXT NOT NULL, - created_at TEXT NOT NULL, - FOREIGN KEY (decision_id) REFERENCES decision(id) +CREATE TABLE decisions ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + summary TEXT NOT NULL, + rationale TEXT, + status TEXT NOT NULL, + confidence TEXT, + evidence_refs_json TEXT NOT NULL, + supersedes_decision_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(task_id) REFERENCES tasks(id) ); -CREATE TABLE open_question ( - id TEXT PRIMARY KEY, - task_id TEXT NOT NULL, - question TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'active', - priority INTEGER NOT NULL DEFAULT 3, - resolution_note TEXT, - resolved_at TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY (task_id) REFERENCES task(id) +CREATE TABLE open_questions ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + question TEXT NOT NULL, + priority TEXT NOT NULL, + status TEXT NOT NULL, + answer TEXT, + evidence_refs_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(task_id) REFERENCES tasks(id) ); -CREATE TABLE open_question_ref ( - id TEXT PRIMARY KEY, - open_question_id TEXT NOT NULL, - typed_ref TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'related', - created_at TEXT NOT NULL, - FOREIGN KEY (open_question_id) REFERENCES open_question(id) +CREATE TABLE runs ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + actor_type TEXT NOT NULL, + actor_id TEXT, + run_type TEXT NOT NULL, + status TEXT NOT NULL, + input_ref TEXT, + output_ref TEXT, + started_at TEXT NOT NULL, + ended_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(task_id) REFERENCES tasks(id) ); -CREATE TABLE context_bundle ( - id TEXT PRIMARY KEY, - task_id TEXT NOT NULL, - purpose TEXT NOT NULL, - rebuild_level TEXT NOT NULL, - summary TEXT, - state_snapshot_json TEXT NOT NULL, - decision_digest_json TEXT, - question_digest_json TEXT, - diagnostics_json TEXT, - raw_included INTEGER NOT NULL DEFAULT 0, - generator_version TEXT, - generated_at TEXT NOT NULL, - created_at TEXT NOT NULL, - FOREIGN KEY (task_id) REFERENCES task(id) +CREATE TABLE context_bundles ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + build_reason TEXT NOT NULL, + purpose TEXT NOT NULL, + rebuild_level TEXT NOT NULL, + summary TEXT, + state_snapshot_json TEXT NOT NULL, + included_decision_refs_json TEXT NOT NULL, + included_open_question_refs_json TEXT NOT NULL, + included_artifact_refs_json TEXT NOT NULL, + included_evidence_refs_json TEXT NOT NULL, + expected_output_schema_json TEXT NOT NULL, + decision_digest_json TEXT, + question_digest_json TEXT, + diagnostics_json TEXT, + raw_included INTEGER NOT NULL DEFAULT 0, + generator_version TEXT NOT NULL, + generated_at TEXT NOT NULL, + created_at TEXT NOT NULL, + metadata_json TEXT, + FOREIGN KEY(task_id) REFERENCES tasks(id) ); -CREATE TABLE context_bundle_source ( - id TEXT PRIMARY KEY, - context_bundle_id TEXT NOT NULL, - typed_ref TEXT NOT NULL, - source_kind TEXT NOT NULL, - selected_raw INTEGER NOT NULL DEFAULT 0, - metadata_json TEXT, - created_at TEXT NOT NULL, - FOREIGN KEY (context_bundle_id) REFERENCES context_bundle(id) +CREATE TABLE context_bundle_sources ( + id TEXT PRIMARY KEY, + context_bundle_id TEXT NOT NULL, + typed_ref TEXT NOT NULL, + source_kind TEXT NOT NULL, + selected_raw INTEGER NOT NULL DEFAULT 0, + metadata_json TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY(context_bundle_id) REFERENCES context_bundles(id) ); -CREATE TABLE run ( - id TEXT PRIMARY KEY, - task_id TEXT, - context_bundle_id TEXT, - run_type TEXT NOT NULL, - actor_type TEXT NOT NULL, - actor_id TEXT, - status TEXT NOT NULL, - input_summary TEXT, - output_summary TEXT, - error_message TEXT, - started_at TEXT NOT NULL, - finished_at TEXT, - created_at TEXT NOT NULL, - FOREIGN KEY (task_id) REFERENCES task(id), - FOREIGN KEY (context_bundle_id) REFERENCES context_bundle(id) +CREATE TABLE state_transitions ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + from_status TEXT, + to_status TEXT NOT NULL, + reason TEXT NOT NULL, + actor_type TEXT NOT NULL, + actor_id TEXT, + run_id TEXT, + changed_at TEXT NOT NULL, + FOREIGN KEY(task_id) REFERENCES tasks(id) ); -CREATE TABLE task_link ( - id TEXT PRIMARY KEY, - task_id TEXT NOT NULL, - typed_ref TEXT NOT NULL, - link_type TEXT NOT NULL, - role TEXT NOT NULL, - created_at TEXT NOT NULL, - FOREIGN KEY (task_id) REFERENCES task(id) +CREATE TABLE tracker_connection ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + name TEXT NOT NULL, + config_json TEXT NOT NULL, + secret_env_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE TABLE issue_cache ( + id TEXT PRIMARY KEY, + connection_id TEXT NOT NULL, + issue_ref TEXT NOT NULL UNIQUE, + remote_key TEXT NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL, + assignee TEXT, + description TEXT, + labels_json TEXT, + raw_json TEXT, + fetched_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(connection_id) REFERENCES tracker_connection(id) +); +CREATE TABLE entity_link ( + id TEXT PRIMARY KEY, + tracker_issue_ref TEXT NOT NULL, + agent_taskstate_entity_ref TEXT NOT NULL, + role TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE TABLE sync_event ( + id TEXT PRIMARY KEY, + connection_id TEXT NOT NULL, + direction TEXT NOT NULL, + status TEXT NOT NULL, + issue_ref TEXT, + details_json TEXT, + error_message TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY(connection_id) REFERENCES tracker_connection(id) ); - -CREATE INDEX idx_task_status ON task(status); -CREATE INDEX idx_task_owner ON task(owner_type, owner_id); -CREATE INDEX idx_task_parent ON task(parent_task_id); -CREATE INDEX idx_task_tracker_issue_ref ON task(tracker_issue_ref); -CREATE INDEX idx_task_idempotency_key ON task(idempotency_key); -CREATE INDEX idx_task_trace_id ON task(trace_id); -CREATE INDEX idx_task_reply_state ON task(reply_state); -CREATE INDEX idx_task_updated_at ON task(updated_at); -CREATE INDEX idx_task_original_task_id ON task(original_task_id); - -CREATE INDEX idx_task_state_task_changed ON task_state(task_id, changed_at DESC); -CREATE INDEX idx_task_state_task_to_state ON task_state(task_id, to_state); - -CREATE INDEX idx_decision_task_created ON decision(task_id, created_at DESC); -CREATE INDEX idx_decision_status ON decision(status); -CREATE INDEX idx_decision_ref_decision ON decision_ref(decision_id); -CREATE INDEX idx_decision_ref_typed_ref ON decision_ref(typed_ref); - -CREATE INDEX idx_open_question_task_status ON open_question(task_id, status); -CREATE INDEX idx_open_question_priority ON open_question(priority); -CREATE INDEX idx_open_question_ref_question ON open_question_ref(open_question_id); - -CREATE INDEX idx_context_bundle_task_generated ON context_bundle(task_id, generated_at DESC); -CREATE INDEX idx_context_bundle_purpose ON context_bundle(purpose); -CREATE INDEX idx_context_bundle_source_bundle ON context_bundle_source(context_bundle_id); -CREATE INDEX idx_context_bundle_source_ref ON context_bundle_source(typed_ref); - -CREATE INDEX idx_run_task_started ON run(task_id, started_at DESC); -CREATE INDEX idx_run_bundle ON run(context_bundle_id); -CREATE INDEX idx_run_status ON run(status); - -CREATE INDEX idx_task_link_task_type ON task_link(task_id, link_type); -CREATE INDEX idx_task_link_ref ON task_link(typed_ref); diff --git a/docs/src/agent-taskstate_1.1.0_spec.md b/docs/src/agent-taskstate_1.1.0_spec.md new file mode 100644 index 0000000..c31e898 --- /dev/null +++ b/docs/src/agent-taskstate_1.1.0_spec.md @@ -0,0 +1,16 @@ +# agent-taskstate 1.1.0仕様 + +## 正本 + +実装正本は `src/agent_taskstate/`、DB正本は +`docs/schema/agent-taskstate.sql`、migration正本は +`src/agent_taskstate/migrations/` と `docs/migrations/` です。 + +## 受入契約 + +- 既存plural DBを保持し、schema_versionで冪等migrationする。 +- task作成とstatus変更をstate_transitionsへappendする。 +- context bundleは完全snapshotで、purpose、rebuild level、summary、digest、 + diagnostics、raw flag、generator version、generated timestamp、sourcesを持つ。 +- resolver未設定はunsupported diagnosticsとして保存し、bundle buildは継続する。 +- 予期しない例外はinternal_error、概要はstdout、tracebackはstderrである。 diff --git a/pyproject.toml b/pyproject.toml index ab4dce0..daf76d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,43 +4,58 @@ build-backend = "setuptools.build_meta" [project] name = "agent-taskstate" -version = "1.0.1" +version = "1.1.0" description = "SQLite-backed CLI for long-running task state management" readme = "README.md" license = {text = "MIT"} -requires-python = ">=3.9" -authors = [ - {name = "agent-taskstate-team"} -] +requires-python = ">=3.10" +authors = [{name = "agent-taskstate-team"}] classifiers = [ - "Development Status :: 4 - Beta", - "Environment :: Console", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Topic :: Software Development", + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development", ] [project.scripts] -agent-taskstate = "src.cli:main" +agent-taskstate = "agent_taskstate.cli:main" [project.optional-dependencies] +github = ["PyGithub>=2.1"] +jira = ["jira>=3.8"] +tracker = ["PyGithub>=2.1", "jira>=3.8"] dev = [ - "pytest>=7.0", - "pytest-cov>=4.0", + "pytest>=7.0", + "pytest-cov>=4.0", + "ruff>=0.6", + "mypy>=1.10", + "build>=1.2", ] [tool.setuptools.packages.find] where = ["src"] +include = ["agent_taskstate*", "agent_taskstate_workflow_plugin*", "cli*"] [tool.setuptools.package-data] -cli = ["py.typed"] +agent_taskstate = ["py.typed"] +"cli" = ["py.typed"] [tool.pytest.ini_options] testpaths = ["tests"] norecursedirs = [".git", ".pytest_cache", "__pycache__", "workflow-cookbook"] -addopts = "--cov=src --cov-report=term-missing" \ No newline at end of file +addopts = "--cov=agent_taskstate --cov-report=term-missing" + +[tool.ruff] +line-length = 100 +target-version = "py310" +exclude = ["src/cli"] + +[tool.mypy] +python_version = "3.10" +ignore_missing_imports = true diff --git a/src/__init__.py b/src/__init__.py index 7f8634c..f6ac5e0 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,148 +1,2 @@ -"""agent-taskstate source package.""" - -from .typed_ref import ( - TypedRef, - canonicalize_ref, - format_ref, - is_known_domain, - is_valid_ref, - memx_ref, - parse_ref, - tracker_ref, - validate_ref, - agent_taskstate_ref, -) - -from .state_transition import ( - StateTransition, - StateTransitionService, - InvalidTransitionError, - TerminalStateError, - MissingReasonError, - can_transition, - is_terminal, - requires_reason, - ALLOWED_TRANSITIONS, - TERMINAL_STATES, -) - -from .transition_config import ( - TransitionConfig, - DEFAULT_CONFIG, - get_config, - reload_config, -) - -from .context_bundle import ( - ContextBundle, - BundleSource, - ContextBundleService, - REBUILD_LEVELS, - PURPOSE_TYPES, - SOURCE_KINDS, - compress_json, - decompress_json, - compute_diff, - decompress_bundle_json, - COMPRESSION_THRESHOLD, -) - -from .resolver import ( - ContextRebuildResolver, - ResolveStatus, - ResolvedRef, - ResolveReport, - SummaryPayload, - RawPayload, - ResolverDiagnostics, - AgentTaskstateLocalResolver, - TrackerResolver, - MemxResolver, - RAW_DESCENT_CONDITIONS, -) - -from .tracker_bridge import ( - TrackerBridgeService, - TrackerConnection, - IssueCache, - EntityLink, - SyncEvent, - IssueSnapshot, - SyncSuggestion, - SyncDirection, - SyncStatus, - LinkRole, - MockTrackerAdapter, - GitHubAdapter, - JiraAdapter, - create_tracker_tables, -) - -__all__ = [ - # typed_ref - "TypedRef", - "canonicalize_ref", - "format_ref", - "is_known_domain", - "is_valid_ref", - "memx_ref", - "parse_ref", - "tracker_ref", - "validate_ref", - "agent_taskstate_ref", - # state_transition - "StateTransition", - "StateTransitionService", - "InvalidTransitionError", - "TerminalStateError", - "MissingReasonError", - "can_transition", - "is_terminal", - "requires_reason", - "ALLOWED_TRANSITIONS", - "TERMINAL_STATES", - # transition_config - "TransitionConfig", - "DEFAULT_CONFIG", - "get_config", - "reload_config", - # context_bundle - "ContextBundle", - "BundleSource", - "ContextBundleService", - "REBUILD_LEVELS", - "PURPOSE_TYPES", - "SOURCE_KINDS", - "compress_json", - "decompress_json", - "compute_diff", - "decompress_bundle_json", - "COMPRESSION_THRESHOLD", - # resolver - "ContextRebuildResolver", - "ResolveStatus", - "ResolvedRef", - "ResolveReport", - "SummaryPayload", - "RawPayload", - "ResolverDiagnostics", - "AgentTaskstateLocalResolver", - "TrackerResolver", - "MemxResolver", - "RAW_DESCENT_CONDITIONS", - # tracker_bridge - "TrackerBridgeService", - "TrackerConnection", - "IssueCache", - "EntityLink", - "SyncEvent", - "IssueSnapshot", - "SyncSuggestion", - "SyncDirection", - "SyncStatus", - "LinkRole", - "MockTrackerAdapter", - "GitHubAdapter", - "JiraAdapter", - "create_tracker_tables", -] \ No newline at end of file +"""Compatibility namespace; use agent_taskstate instead.""" +from agent_taskstate import * diff --git a/src/__pycache__/__init__.cpython-310.pyc b/src/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index 5b00083..0000000 Binary files a/src/__pycache__/__init__.cpython-310.pyc and /dev/null differ diff --git a/src/__pycache__/typed_ref.cpython-310.pyc b/src/__pycache__/typed_ref.cpython-310.pyc deleted file mode 100644 index dec87d3..0000000 Binary files a/src/__pycache__/typed_ref.cpython-310.pyc and /dev/null differ diff --git a/src/agent_taskstate/__init__.py b/src/agent_taskstate/__init__.py new file mode 100644 index 0000000..8bf3a76 --- /dev/null +++ b/src/agent_taskstate/__init__.py @@ -0,0 +1,150 @@ +"""agent-taskstate source package.""" + +from .typed_ref import ( + TypedRef, + canonicalize_ref, + format_ref, + is_known_domain, + is_valid_ref, + memx_ref, + parse_ref, + tracker_ref, + validate_ref, + agent_taskstate_ref, +) + +from .state_transition import ( + StateTransition, + StateTransitionService, + InvalidTransitionError, + TerminalStateError, + MissingReasonError, + can_transition, + is_terminal, + requires_reason, + ALLOWED_TRANSITIONS, + TERMINAL_STATES, +) + +from .transition_config import ( + TransitionConfig, + DEFAULT_CONFIG, + get_config, + reload_config, +) + +from .context_bundle import ( + ContextBundle, + BundleSource, + ContextBundleService, + ContextRebuildService, + REBUILD_LEVELS, + PURPOSE_TYPES, + SOURCE_KINDS, + compress_json, + decompress_json, + compute_diff, + decompress_bundle_json, + COMPRESSION_THRESHOLD, +) + +from .resolver import ( + ContextRebuildResolver, + ResolveStatus, + ResolvedRef, + ResolveReport, + SummaryPayload, + RawPayload, + ResolverDiagnostics, + AgentTaskstateLocalResolver, + TrackerResolver, + MemxResolver, + RAW_DESCENT_CONDITIONS, +) + +from .tracker_bridge import ( + TrackerBridgeService, + TrackerConnection, + IssueCache, + EntityLink, + SyncEvent, + IssueSnapshot, + SyncSuggestion, + SyncDirection, + SyncStatus, + LinkRole, + MockTrackerAdapter, + GitHubAdapter, + JiraAdapter, + create_tracker_tables, +) + +__all__ = [ + # typed_ref + "TypedRef", + "canonicalize_ref", + "format_ref", + "is_known_domain", + "is_valid_ref", + "memx_ref", + "parse_ref", + "tracker_ref", + "validate_ref", + "agent_taskstate_ref", + # state_transition + "StateTransition", + "StateTransitionService", + "InvalidTransitionError", + "TerminalStateError", + "MissingReasonError", + "can_transition", + "is_terminal", + "requires_reason", + "ALLOWED_TRANSITIONS", + "TERMINAL_STATES", + # transition_config + "TransitionConfig", + "DEFAULT_CONFIG", + "get_config", + "reload_config", + # context_bundle + "ContextBundle", + "BundleSource", + "ContextBundleService", + "ContextRebuildService", + "REBUILD_LEVELS", + "PURPOSE_TYPES", + "SOURCE_KINDS", + "compress_json", + "decompress_json", + "compute_diff", + "decompress_bundle_json", + "COMPRESSION_THRESHOLD", + # resolver + "ContextRebuildResolver", + "ResolveStatus", + "ResolvedRef", + "ResolveReport", + "SummaryPayload", + "RawPayload", + "ResolverDiagnostics", + "AgentTaskstateLocalResolver", + "TrackerResolver", + "MemxResolver", + "RAW_DESCENT_CONDITIONS", + # tracker_bridge + "TrackerBridgeService", + "TrackerConnection", + "IssueCache", + "EntityLink", + "SyncEvent", + "IssueSnapshot", + "SyncSuggestion", + "SyncDirection", + "SyncStatus", + "LinkRole", + "MockTrackerAdapter", + "GitHubAdapter", + "JiraAdapter", + "create_tracker_tables", +] diff --git a/src/agent_taskstate/cli/__init__.py b/src/agent_taskstate/cli/__init__.py new file mode 100644 index 0000000..19e0ba1 --- /dev/null +++ b/src/agent_taskstate/cli/__init__.py @@ -0,0 +1,40 @@ +"""Canonical CLI entry point.""" + +from __future__ import annotations + +import json +import sqlite3 +import sys +import traceback +from dataclasses import dataclass + + +@dataclass +class AppContext: + db_path: str + + +def main(argv: list[str] | None = None) -> int: + from .errors import AgentTaskstateError + from .parser import build_parser + from .utils import json_error + + parser = build_parser() + args = parser.parse_args(argv) + if not hasattr(args, "func"): + parser.print_help() + return 2 + ctx = AppContext(db_path=args.db) + try: + return int(args.func(ctx, args)) + except AgentTaskstateError as exc: + return json_error(exc.code, str(exc)) + except (sqlite3.IntegrityError, json.JSONDecodeError) as exc: + return json_error("validation_error", str(exc)) + except Exception: + traceback.print_exc(file=sys.stderr) + return json_error("internal_error", "unexpected internal error; see stderr for details") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/agent_taskstate/cli/__main__.py b/src/agent_taskstate/cli/__main__.py new file mode 100644 index 0000000..ca73931 --- /dev/null +++ b/src/agent_taskstate/cli/__main__.py @@ -0,0 +1,6 @@ +"""CLI main entry point.""" + +from . import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/agent_taskstate/cli/commands/__init__.py b/src/agent_taskstate/cli/commands/__init__.py new file mode 100644 index 0000000..115a0b4 --- /dev/null +++ b/src/agent_taskstate/cli/commands/__init__.py @@ -0,0 +1,62 @@ +"""CLI commands package.""" + +from .task import ( + cmd_init, + cmd_task_create, + cmd_task_show, + cmd_task_list, + cmd_task_update, + cmd_task_set_status, + cmd_task_history, +) +from .state import cmd_state_get, cmd_state_put, cmd_state_patch +from .decision import cmd_decision_add, cmd_decision_list, cmd_decision_accept, cmd_decision_reject +from .question import cmd_question_add, cmd_question_list, cmd_question_answer, cmd_question_defer +from .run import cmd_run_start, cmd_run_list, cmd_run_finish +from .context import cmd_context_build, cmd_context_show +from .export import cmd_export_task +from .tracker import ( + cmd_tracker_connection_add, + cmd_tracker_fetch, + cmd_tracker_link, + cmd_tracker_snapshot, + cmd_tracker_events, + cmd_tracker_suggest, + cmd_tracker_comment, + cmd_tracker_status, +) + +__all__ = [ + "cmd_init", + "cmd_task_create", + "cmd_task_show", + "cmd_task_list", + "cmd_task_update", + "cmd_task_set_status", + "cmd_task_history", + "cmd_state_get", + "cmd_state_put", + "cmd_state_patch", + "cmd_decision_add", + "cmd_decision_list", + "cmd_decision_accept", + "cmd_decision_reject", + "cmd_question_add", + "cmd_question_list", + "cmd_question_answer", + "cmd_question_defer", + "cmd_run_start", + "cmd_run_list", + "cmd_run_finish", + "cmd_context_build", + "cmd_context_show", + "cmd_export_task", + "cmd_tracker_connection_add", + "cmd_tracker_fetch", + "cmd_tracker_link", + "cmd_tracker_snapshot", + "cmd_tracker_events", + "cmd_tracker_suggest", + "cmd_tracker_comment", + "cmd_tracker_status", +] diff --git a/src/agent_taskstate/cli/commands/context.py b/src/agent_taskstate/cli/commands/context.py new file mode 100644 index 0000000..396eb1c --- /dev/null +++ b/src/agent_taskstate/cli/commands/context.py @@ -0,0 +1,97 @@ +"""Context rebuild CLI commands.""" + +from __future__ import annotations + +import argparse +import json + +from ...context_bundle import ContextRebuildService +from .. import AppContext +from ..constants import BUILD_REASONS +from ..db import connect, init_db +from ..errors import AgentTaskstateError +from ..fetch import get_bundle +from ..models import row_to_bundle +from ..utils import json_ok +from ..validation import require_in + + +def should_include_evidence(state, build_reason, decisions, questions) -> bool: + return ( + state.get("confidence") == "low" + or build_reason in {"review", "high_risk"} + or state.get("current_step") in {"investigation", "verification"} + or state.get("context_policy", {}).get("force_evidence") is True + or any(item.get("confidence") == "low" for item in decisions) + or any( + item.get("priority") == "high" and item.get("status") == "open" for item in questions + ) + ) + + +def cmd_context_build(ctx: AppContext, args: argparse.Namespace) -> int: + reason = getattr(args, "reason", "normal") + require_in(reason, BUILD_REASONS, "build_reason") + purpose = getattr(args, "purpose", "continue_work") + rebuild_level = getattr(args, "rebuild_level", "L2") + include_raw = bool(getattr(args, "include_raw", False)) + with connect(ctx.db_path) as conn: + init_db(conn) + try: + bundle = ContextRebuildService(conn).build( + args.task, + purpose=purpose, + rebuild_level=rebuild_level, + include_raw=include_raw, + reason=reason, + ) + except ValueError as exc: + code = "not_found" if str(exc).startswith("Task not found") else "validation_error" + raise AgentTaskstateError(str(exc), code=code) from exc + data = bundle.to_dict() + data["build_reason"] = reason + data["included_decision_refs"] = [ + item["ref"] for item in json.loads(bundle.decision_digest_json or "{}").get("items", []) + ] + data["included_open_question_refs"] = [ + item["ref"] for item in json.loads(bundle.question_digest_json or "{}").get("items", []) + ] + data["included_artifact_refs"] = [ + source.typed_ref for source in bundle.sources if source.source_kind == "artifact" + ] + data["included_evidence_refs"] = [ + source.typed_ref for source in bundle.sources if source.source_kind == "evidence" + ] + return json_ok(data) + + +def cmd_context_show(ctx: AppContext, args: argparse.Namespace) -> int: + with connect(ctx.db_path) as conn: + init_db(conn) + row = get_bundle(conn, args.bundle) + data = row_to_bundle(row) + sources = conn.execute( + """ + SELECT id, context_bundle_id, typed_ref, source_kind, selected_raw, metadata_json, created_at + FROM context_bundle_sources + WHERE context_bundle_id = ? + ORDER BY created_at ASC, id ASC + """, + (args.bundle,), + ).fetchall() + data["sources"] = [ + { + "id": source["id"], + "context_bundle_id": source["context_bundle_id"], + "typed_ref": source["typed_ref"], + "source_kind": source["source_kind"], + "selected_raw": bool(source["selected_raw"]), + "metadata": json.loads(source["metadata_json"]) + if source["metadata_json"] + else None, + "created_at": source["created_at"], + } + for source in sources + ] + data["source_refs"] = [source["typed_ref"] for source in sources] + return json_ok(data) diff --git a/src/agent_taskstate/cli/commands/decision.py b/src/agent_taskstate/cli/commands/decision.py new file mode 100644 index 0000000..7a59f54 --- /dev/null +++ b/src/agent_taskstate/cli/commands/decision.py @@ -0,0 +1,88 @@ +""" +CLI Decision Commands +""" + +from __future__ import annotations + +import argparse +from typing import Any, List + +from .. import AppContext +from ..constants import DECISION_STATUSES +from ..db import connect, init_db +from ..fetch import get_task, get_decision +from ..models import jdump, row_to_decision +from ..utils import gen_id, now_utc, json_ok +from ..validation import load_json_arg, require_in, validate_decision_payload + + +def cmd_decision_add(ctx: AppContext, args: argparse.Namespace) -> int: + """Add decision.""" + payload = load_json_arg(args.json, args.file) + validate_decision_payload(payload) + decision_id = payload.get("id") or gen_id() + now = now_utc() + with connect(ctx.db_path) as conn: + init_db(conn) + get_task(conn, args.task) + if payload.get("supersedes_decision_id"): + get_decision(conn, payload["supersedes_decision_id"]) + conn.execute( + """ + INSERT INTO decisions ( + id, task_id, summary, rationale, status, confidence, evidence_refs_json, supersedes_decision_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + decision_id, + args.task, + payload["summary"], + payload.get("rationale"), + payload.get("status", "proposed"), + payload.get("confidence", "medium"), + jdump(payload.get("evidence_refs", [])), + payload.get("supersedes_decision_id"), + now, + now, + ), + ) + conn.execute("UPDATE tasks SET updated_at = ? WHERE id = ?", (now, args.task)) + row = get_decision(conn, decision_id) + return json_ok(row_to_decision(row)) + + +def cmd_decision_list(ctx: AppContext, args: argparse.Namespace) -> int: + """List decisions.""" + sql = "SELECT * FROM decisions WHERE task_id = ?" + params: List[Any] = [args.task] + if args.status: + sql += " AND status = ?" + params.append(args.status) + sql += " ORDER BY created_at DESC" + with connect(ctx.db_path) as conn: + rows = conn.execute(sql, params).fetchall() + return json_ok([row_to_decision(r) for r in rows]) + + +def _set_decision_status(ctx: AppContext, decision_id: str, status: str) -> int: + """Set decision status.""" + require_in(status, DECISION_STATUSES, "decision.status") + with connect(ctx.db_path) as conn: + init_db(conn) + get_decision(conn, decision_id) + conn.execute( + "UPDATE decisions SET status = ?, updated_at = ? WHERE id = ?", + (status, now_utc(), decision_id), + ) + row = get_decision(conn, decision_id) + return json_ok(row_to_decision(row)) + + +def cmd_decision_accept(ctx: AppContext, args: argparse.Namespace) -> int: + """Accept decision.""" + return _set_decision_status(ctx, args.decision, "accepted") + + +def cmd_decision_reject(ctx: AppContext, args: argparse.Namespace) -> int: + """Reject decision.""" + return _set_decision_status(ctx, args.decision, "rejected") diff --git a/src/agent_taskstate/cli/commands/export.py b/src/agent_taskstate/cli/commands/export.py new file mode 100644 index 0000000..e15898b --- /dev/null +++ b/src/agent_taskstate/cli/commands/export.py @@ -0,0 +1,72 @@ +""" +CLI Export Commands +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from .. import AppContext +from ..db import connect +from ..errors import NotFoundError +from ..fetch import get_task, get_task_state +from ..models import ( + row_to_task, + row_to_task_state, + row_to_decision, + row_to_question, + row_to_run, + row_to_bundle, +) +from ..typed_ref import task_ref +from ..utils import now_utc, json_ok + + +def cmd_export_task(ctx: AppContext, args: argparse.Namespace) -> int: + """Export task to JSON file.""" + with connect(ctx.db_path) as conn: + task = row_to_task(get_task(conn, args.task)) + try: + state = row_to_task_state(get_task_state(conn, args.task)) + except NotFoundError: + state = None + decisions = [ + row_to_decision(r) + for r in conn.execute( + "SELECT * FROM decisions WHERE task_id = ? ORDER BY created_at ASC", (args.task,) + ).fetchall() + ] + questions = [ + row_to_question(r) + for r in conn.execute( + "SELECT * FROM open_questions WHERE task_id = ? ORDER BY created_at ASC", + (args.task,), + ).fetchall() + ] + runs = [ + row_to_run(r) + for r in conn.execute( + "SELECT * FROM runs WHERE task_id = ? ORDER BY created_at ASC", (args.task,) + ).fetchall() + ] + bundles = [ + row_to_bundle(r) + for r in conn.execute( + "SELECT * FROM context_bundles WHERE task_id = ? ORDER BY created_at ASC", + (args.task,), + ).fetchall() + ] + export = { + "task": {**task, "ref": task_ref(task["id"])}, + "task_state": state, + "decisions": decisions, + "open_questions": questions, + "runs": runs, + "context_bundles": bundles, + "exported_at": now_utc(), + } + Path(args.output).parent.mkdir(parents=True, exist_ok=True) + Path(args.output).write_text(json.dumps(export, ensure_ascii=False, indent=2), encoding="utf-8") + return json_ok({"task": args.task, "output": args.output}) diff --git a/src/agent_taskstate/cli/commands/question.py b/src/agent_taskstate/cli/commands/question.py new file mode 100644 index 0000000..056536c --- /dev/null +++ b/src/agent_taskstate/cli/commands/question.py @@ -0,0 +1,90 @@ +""" +CLI Question Commands +""" + +from __future__ import annotations + +import argparse +from typing import Any, List + +from .. import AppContext +from ..db import connect, init_db +from ..fetch import get_task, get_question +from ..models import jdump, row_to_question +from ..utils import gen_id, now_utc, json_ok +from ..validation import load_json_arg, validate_question_payload + + +def cmd_question_add(ctx: AppContext, args: argparse.Namespace) -> int: + """Add question.""" + payload = load_json_arg(args.json, args.file) + validate_question_payload(payload) + qid = payload.get("id") or gen_id() + now = now_utc() + with connect(ctx.db_path) as conn: + init_db(conn) + get_task(conn, args.task) + conn.execute( + """ + INSERT INTO open_questions ( + id, task_id, question, priority, status, answer, evidence_refs_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + qid, + args.task, + payload["question"], + payload.get("priority", "medium"), + payload.get("status", "open"), + payload.get("answer"), + jdump(payload.get("evidence_refs", [])), + now, + now, + ), + ) + conn.execute("UPDATE tasks SET updated_at = ? WHERE id = ?", (now, args.task)) + row = get_question(conn, qid) + return json_ok(row_to_question(row)) + + +def cmd_question_list(ctx: AppContext, args: argparse.Namespace) -> int: + """List questions.""" + sql = "SELECT * FROM open_questions WHERE task_id = ?" + params: List[Any] = [args.task] + if args.status: + sql += " AND status = ?" + params.append(args.status) + if args.priority: + sql += " AND priority = ?" + params.append(args.priority) + sql += " ORDER BY created_at DESC" + with connect(ctx.db_path) as conn: + rows = conn.execute(sql, params).fetchall() + return json_ok([row_to_question(r) for r in rows]) + + +def cmd_question_answer(ctx: AppContext, args: argparse.Namespace) -> int: + """Answer question.""" + with connect(ctx.db_path) as conn: + init_db(conn) + get_question(conn, args.question) + conn.execute( + "UPDATE open_questions SET answer = ?, status = 'answered', updated_at = ? WHERE id = ?", + (args.answer, now_utc(), args.question), + ) + row = get_question(conn, args.question) + return json_ok(row_to_question(row)) + + +def cmd_question_defer(ctx: AppContext, args: argparse.Namespace) -> int: + """Defer question.""" + with connect(ctx.db_path) as conn: + init_db(conn) + get_question(conn, args.question) + answer = args.reason if args.reason else None + conn.execute( + "UPDATE open_questions SET answer = ?, status = 'deferred', updated_at = ? WHERE id = ?", + (answer, now_utc(), args.question), + ) + row = get_question(conn, args.question) + return json_ok(row_to_question(row)) diff --git a/src/agent_taskstate/cli/commands/run.py b/src/agent_taskstate/cli/commands/run.py new file mode 100644 index 0000000..b91f0ab --- /dev/null +++ b/src/agent_taskstate/cli/commands/run.py @@ -0,0 +1,83 @@ +"""CLI run commands.""" + +from __future__ import annotations + +import argparse +from typing import Any, List + +from .. import AppContext +from ..constants import OWNER_TYPES, RUN_STATUSES, RUN_TYPES +from ..db import connect, init_db +from ..errors import ConflictError +from ..fetch import get_run, get_task +from ..utils import gen_id, now_utc, json_ok +from ..validation import canonicalize_optional_ref, require_in + + +def cmd_run_start(ctx: AppContext, args: argparse.Namespace) -> int: + from ..models import row_to_run + + require_in(args.run_type, RUN_TYPES, "run_type") + require_in(args.actor_type, OWNER_TYPES, "actor_type") + input_ref = canonicalize_optional_ref(args.input_ref, "input_ref") + now = now_utc() + rid = gen_id() + with connect(ctx.db_path) as conn: + init_db(conn) + get_task(conn, args.task) + conn.execute( + """ + INSERT INTO runs ( + id, task_id, actor_type, actor_id, run_type, status, input_ref, output_ref, + started_at, ended_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, 'running', ?, NULL, ?, NULL, ?, ?) + """, + ( + rid, + args.task, + args.actor_type, + args.actor_id, + args.run_type, + input_ref, + now, + now, + now, + ), + ) + row = get_run(conn, rid) + return json_ok(row_to_run(row)) + + +def cmd_run_list(ctx: AppContext, args: argparse.Namespace) -> int: + from ..models import row_to_run + + sql = "SELECT * FROM runs WHERE task_id = ?" + params: List[Any] = [args.task] + if args.status: + sql += " AND status = ?" + params.append(args.status) + sql += " ORDER BY created_at DESC" + with connect(ctx.db_path) as conn: + init_db(conn) + get_task(conn, args.task) + rows = conn.execute(sql, params).fetchall() + return json_ok([row_to_run(row) for row in rows]) + + +def cmd_run_finish(ctx: AppContext, args: argparse.Namespace) -> int: + from ..models import row_to_run + + require_in(args.status, RUN_STATUSES - {"running"}, "run.status") + output_ref = canonicalize_optional_ref(args.output_ref, "output_ref") + now = now_utc() + with connect(ctx.db_path) as conn: + init_db(conn) + row = get_run(conn, args.run) + if row["status"] != "running": + raise ConflictError("run is not in running state") + conn.execute( + "UPDATE runs SET status = ?, output_ref = ?, ended_at = ?, updated_at = ? WHERE id = ?", + (args.status, output_ref, now, now, args.run), + ) + row = get_run(conn, args.run) + return json_ok(row_to_run(row)) diff --git a/src/agent_taskstate/cli/commands/state.py b/src/agent_taskstate/cli/commands/state.py new file mode 100644 index 0000000..daec049 --- /dev/null +++ b/src/agent_taskstate/cli/commands/state.py @@ -0,0 +1,123 @@ +"""Task state commands with insert-only put and atomic CAS patch.""" + +from __future__ import annotations + +import argparse +from typing import Any, Dict + +from .. import AppContext +from ..db import connect, init_db +from ..errors import AgentTaskstateError, ConflictError +from ..fetch import get_task, get_task_state +from ..models import jdump, row_to_task_state +from ..utils import now_utc, json_ok +from ..validation import canonicalize_refs, load_json_arg, validate_state_payload + + +def cmd_state_get(ctx: AppContext, args: argparse.Namespace) -> int: + with connect(ctx.db_path) as conn: + row = get_task_state(conn, args.task) + return json_ok(row_to_task_state(row)) + + +def cmd_state_put(ctx: AppContext, args: argparse.Namespace) -> int: + payload = normalize_state_payload(load_json_arg(args.json, args.file)) + now = now_utc() + with connect(ctx.db_path) as conn: + init_db(conn) + get_task(conn, args.task) + if conn.execute("SELECT 1 FROM task_states WHERE task_id = ?", (args.task,)).fetchone(): + raise ConflictError("task state already exists; use state patch") + conn.execute( + """ + INSERT INTO task_states ( + task_id, revision, current_step, constraints_json, done_when_json, current_summary, + artifact_refs_json, evidence_refs_json, confidence, context_policy_json, + created_at, updated_at + ) VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + args.task, + payload["current_step"], + jdump(payload["constraints"]), + jdump(payload["done_when"]), + payload["current_summary"], + jdump(payload["artifact_refs"]), + jdump(payload["evidence_refs"]), + payload["confidence"], + jdump(payload["context_policy"]), + now, + now, + ), + ) + conn.execute("UPDATE tasks SET updated_at = ? WHERE id = ?", (now, args.task)) + row = get_task_state(conn, args.task) + return json_ok(row_to_task_state(row)) + + +def cmd_state_patch(ctx: AppContext, args: argparse.Namespace) -> int: + patch = load_json_arg(args.json, args.file) + if args.expected_revision is None: + raise AgentTaskstateError("--expected-revision is required") + with connect(ctx.db_path) as conn: + init_db(conn) + get_task(conn, args.task) + current = get_task_state(conn, args.task) + state = row_to_task_state(current) + merged = { + "current_step": state["current_step"], + "constraints": state["constraints"], + "done_when": state["done_when"], + "current_summary": state["current_summary"], + "artifact_refs": state["artifact_refs"], + "evidence_refs": state["evidence_refs"], + "confidence": state["confidence"], + "context_policy": state["context_policy"], + } + merged.update(patch) + normalized = normalize_state_payload(merged) + now = now_utc() + result = conn.execute( + """ + UPDATE task_states + SET revision = revision + 1, current_step = ?, constraints_json = ?, + done_when_json = ?, current_summary = ?, artifact_refs_json = ?, + evidence_refs_json = ?, confidence = ?, context_policy_json = ?, updated_at = ? + WHERE task_id = ? AND revision = ? + """, + ( + normalized["current_step"], + jdump(normalized["constraints"]), + jdump(normalized["done_when"]), + normalized["current_summary"], + jdump(normalized["artifact_refs"]), + jdump(normalized["evidence_refs"]), + normalized["confidence"], + jdump(normalized["context_policy"]), + now, + args.task, + args.expected_revision, + ), + ) + if result.rowcount != 1: + raise ConflictError("revision mismatch") + conn.execute("UPDATE tasks SET updated_at = ? WHERE id = ?", (now, args.task)) + row = get_task_state(conn, args.task) + return json_ok(row_to_task_state(row)) + + +def normalize_state_payload(payload: Dict[str, Any]) -> Dict[str, Any]: + normalized = { + "current_step": payload["current_step"], + "constraints": payload.get("constraints", []), + "done_when": payload.get("done_when", []), + "current_summary": payload.get("current_summary"), + "artifact_refs": payload.get("artifact_refs", []), + "evidence_refs": payload.get("evidence_refs", []), + "confidence": payload.get("confidence", "medium"), + "context_policy": payload.get("context_policy", {}), + } + validate_state_payload(normalized) + normalized["artifact_refs"] = canonicalize_refs(normalized["artifact_refs"], "artifact_refs") + normalized["evidence_refs"] = canonicalize_refs(normalized["evidence_refs"], "evidence_refs") + return normalized diff --git a/src/agent_taskstate/cli/commands/task.py b/src/agent_taskstate/cli/commands/task.py new file mode 100644 index 0000000..e0240b1 --- /dev/null +++ b/src/agent_taskstate/cli/commands/task.py @@ -0,0 +1,263 @@ +"""Canonical task commands.""" + +from __future__ import annotations + +import argparse +from typing import Any, Dict, List + +from ...state_transition import ( + InvalidTransitionError as ServiceInvalidTransitionError, + MissingReasonError, + StateTransitionService, + TerminalStateError, +) +from .. import AppContext +from ..constants import TASK_KINDS, TASK_PRIORITIES, TASK_STATUSES, OWNER_TYPES, REPLY_STATES +from ..db import connect, init_db +from ..errors import AgentTaskstateError +from ..fetch import get_task, get_task_state +from ..models import row_to_task, row_to_task_state +from ..typed_ref import task_ref +from ..utils import gen_id, now_utc, json_ok +from ..validation import ( + _normalize_optional_json_text, + canonicalize_optional_ref, + load_json_arg, + require_in, + validate_status_transition, + validate_task_payload, +) + + +def _transition_error(exc: Exception) -> AgentTaskstateError: + if isinstance(exc, MissingReasonError): + return AgentTaskstateError(str(exc), code="validation_error") + if isinstance(exc, (ServiceInvalidTransitionError, TerminalStateError)): + return AgentTaskstateError(str(exc), code="invalid_transition") + if isinstance(exc, ValueError) and "Task not found" in str(exc): + return AgentTaskstateError(str(exc), code="not_found") + return AgentTaskstateError(str(exc)) + + +def cmd_init(ctx: AppContext, _args: argparse.Namespace) -> int: + with connect(ctx.db_path) as conn: + init_db(conn) + return json_ok({"db_path": ctx.db_path, "initialized": True}) + + +def cmd_task_create(ctx: AppContext, args: argparse.Namespace) -> int: + payload = load_json_arg(args.json, args.file) + validate_task_payload(payload) + task_id = payload.get("id") or gen_id() + now = now_utc() + roadmap_request_json = _normalize_optional_json_text(payload.get("roadmap_request_json")) + tracker_issue_ref = canonicalize_optional_ref( + payload.get("tracker_issue_ref"), "tracker_issue_ref" + ) + with connect(ctx.db_path) as conn: + init_db(conn) + if payload.get("parent_task_id"): + get_task(conn, payload["parent_task_id"]) + conn.execute( + """ + INSERT INTO tasks ( + id, parent_task_id, kind, title, goal, status, priority, owner_type, owner_id, + tracker_issue_ref, idempotency_key, note_id, trace_id, reply_target, reply_state, + retry_count, kestra_execution_id, original_task_id, trigger, reply_text, + roadmap_request_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + task_id, + payload.get("parent_task_id"), + payload["kind"], + payload["title"], + payload["goal"], + payload.get("status", "draft"), + payload.get("priority", "medium"), + payload.get("owner_type", "human"), + payload.get("owner_id"), + tracker_issue_ref, + payload.get("idempotency_key"), + payload.get("note_id"), + payload.get("trace_id"), + payload.get("reply_target"), + payload.get("reply_state"), + payload.get("retry_count", 0), + payload.get("kestra_execution_id"), + payload.get("original_task_id"), + payload.get("trigger"), + payload.get("reply_text"), + roadmap_request_json, + now, + now, + ), + ) + StateTransitionService(conn).record_initial(task_id, payload.get("status", "draft"), now) + row = get_task(conn, task_id) + return json_ok({**row_to_task(row), "ref": task_ref(task_id)}) + + +def cmd_task_show(ctx: AppContext, args: argparse.Namespace) -> int: + with connect(ctx.db_path) as conn: + row = get_task(conn, args.task) + data = row_to_task(row) + data["ref"] = task_ref(args.task) + try: + data["state"] = row_to_task_state(get_task_state(conn, args.task)) + except Exception: + data["state"] = None + return json_ok(data) + + +def cmd_task_list(ctx: AppContext, args: argparse.Namespace) -> int: + clauses: List[str] = [] + params: List[Any] = [] + for name, column in ( + ("status", "status"), + ("kind", "kind"), + ("owner_type", "owner_type"), + ("owner_id", "owner_id"), + ("priority", "priority"), + ("reply_state", "reply_state"), + ("trace_id", "trace_id"), + ): + value = getattr(args, name, None) + if value: + clauses.append(f"{column} = ?") + params.append(value) + if getattr(args, "updated_before", None): + clauses.append("updated_at < ?") + params.append(args.updated_before) + if getattr(args, "idempotency_key", None): + clauses.append("idempotency_key = ?") + params.append(args.idempotency_key) + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + sql = f"SELECT * FROM tasks {where} ORDER BY updated_at DESC, created_at DESC" + with connect(ctx.db_path) as conn: + init_db(conn) + rows = conn.execute(sql, params).fetchall() + return json_ok([{**row_to_task(row), "ref": task_ref(row["id"])} for row in rows]) + + +def cmd_task_update(ctx: AppContext, args: argparse.Namespace) -> int: + payload = load_json_arg(args.json, args.file) + allowed = { + "parent_task_id", + "kind", + "title", + "goal", + "priority", + "owner_type", + "owner_id", + "tracker_issue_ref", + "idempotency_key", + "note_id", + "trace_id", + "reply_target", + "reply_state", + "retry_count", + "kestra_execution_id", + "original_task_id", + "trigger", + "reply_text", + "roadmap_request_json", + } + updates = {key: value for key, value in payload.items() if key in allowed} + if not updates: + raise AgentTaskstateError("no updatable fields provided") + if "kind" in updates: + require_in(updates["kind"], TASK_KINDS, "kind") + if "priority" in updates: + require_in(updates["priority"], TASK_PRIORITIES, "priority") + if "owner_type" in updates: + require_in(updates["owner_type"], OWNER_TYPES, "owner_type") + if "reply_state" in updates and updates["reply_state"] is not None: + require_in(updates["reply_state"], REPLY_STATES, "reply_state") + if "retry_count" in updates and ( + not isinstance(updates["retry_count"], int) or updates["retry_count"] < 0 + ): + raise AgentTaskstateError("retry_count must be non-negative integer") + if "tracker_issue_ref" in updates: + updates["tracker_issue_ref"] = canonicalize_optional_ref( + updates["tracker_issue_ref"], "tracker_issue_ref" + ) + if "roadmap_request_json" in updates: + updates["roadmap_request_json"] = _normalize_optional_json_text( + updates["roadmap_request_json"] + ) + with connect(ctx.db_path) as conn: + init_db(conn) + get_task(conn, args.task) + if updates.get("parent_task_id"): + get_task(conn, updates["parent_task_id"]) + updates["updated_at"] = now_utc() + fields = ", ".join(f"{key} = ?" for key in updates) + conn.execute(f"UPDATE tasks SET {fields} WHERE id = ?", [*updates.values(), args.task]) + row = get_task(conn, args.task) + return json_ok({**row_to_task(row), "ref": task_ref(args.task)}) + + +def cmd_task_set_status(ctx: AppContext, args: argparse.Namespace) -> int: + require_in(args.to, TASK_STATUSES, "status") + reason = getattr(args, "reason", None) or "" + if getattr(args, "reason_required", False) and not reason: + raise AgentTaskstateError("reason is required for this transition") + actor_type = getattr(args, "actor_type", "system") or "system" + actor_id = getattr(args, "actor_id", None) + run_id = getattr(args, "run", None) + with connect(ctx.db_path) as conn: + init_db(conn) + task = get_task(conn, args.task) + try: + validate_status_transition(conn, task, args.to) + transition = StateTransitionService(conn).transition( + args.task, args.to, reason, actor_type, actor_id, run_id + ) + except ( + ServiceInvalidTransitionError, + TerminalStateError, + MissingReasonError, + ValueError, + ) as exc: + raise _transition_error(exc) from exc + row = get_task(conn, args.task) + data: Dict[str, Any] = {**row_to_task(row), "ref": task_ref(args.task)} + data["no_op"] = transition is None + data["transition"] = ( + None + if transition is None + else { + "id": transition.id, + "from_status": transition.from_status, + "to_status": transition.to_status, + "reason": transition.reason, + "actor_type": transition.actor_type, + "actor_id": transition.actor_id, + "run_id": transition.run_id, + "changed_at": transition.changed_at, + } + ) + return json_ok(data) + + +def cmd_task_history(ctx: AppContext, args: argparse.Namespace) -> int: + with connect(ctx.db_path) as conn: + get_task(conn, args.task) + history = StateTransitionService(conn).get_history(args.task) + return json_ok( + [ + { + "id": item.id, + "task_id": item.task_id, + "from_status": item.from_status, + "to_status": item.to_status, + "reason": item.reason, + "actor_type": item.actor_type, + "actor_id": item.actor_id, + "run_id": item.run_id, + "changed_at": item.changed_at, + } + for item in history + ] + ) diff --git a/src/agent_taskstate/cli/commands/tracker.py b/src/agent_taskstate/cli/commands/tracker.py new file mode 100644 index 0000000..0b07271 --- /dev/null +++ b/src/agent_taskstate/cli/commands/tracker.py @@ -0,0 +1,119 @@ +"""Explicit tracker CLI operations.""" + +from __future__ import annotations + +import argparse +import json + +from ...tracker_bridge import TrackerBridgeService +from .. import AppContext +from ..db import connect, init_db +from ..utils import json_ok +from ..validation import load_json_arg + + +def _bridge(ctx: AppContext): + conn = connect(ctx.db_path) + return conn + + +def cmd_tracker_connection_add(ctx: AppContext, args: argparse.Namespace) -> int: + config = load_json_arg(args.json, args.file) if args.json or args.file else {} + secret_env = json.loads(args.secret_env_json) if getattr(args, "secret_env_json", None) else {} + with connect(ctx.db_path) as conn: + init_db(conn) + service = TrackerBridgeService(conn) + connection = service.create_connection( + args.provider, args.name, config, secret_env=secret_env + ) + return json_ok( + { + "id": connection.id, + "provider": connection.provider, + "name": connection.name, + "config": json.loads(connection.config_json), + "secret_env": json.loads(connection.secret_env_json), + "created_at": connection.created_at, + "updated_at": connection.updated_at, + } + ) + + +def cmd_tracker_fetch(ctx: AppContext, args: argparse.Namespace) -> int: + with connect(ctx.db_path) as conn: + init_db(conn) + issue = TrackerBridgeService(conn).fetch_issue(args.connection, args.issue_key) + if issue is None: + return json_ok( + { + "issue": None, + "diagnostics": {"unsupported": ["tracker adapter is not configured"]}, + } + ) + return json_ok( + { + "id": issue.id, + "connection_id": issue.connection_id, + "issue_ref": issue.issue_ref, + "remote_key": issue.remote_key, + "title": issue.title, + "status": issue.status, + "assignee": issue.assignee, + "description": issue.description, + "labels": json.loads(issue.labels_json or "[]"), + "fetched_at": issue.fetched_at, + "updated_at": issue.updated_at, + } + ) + + +def cmd_tracker_link(ctx: AppContext, args: argparse.Namespace) -> int: + with connect(ctx.db_path) as conn: + init_db(conn) + link = TrackerBridgeService(conn).link_issue_to_task(args.issue_ref, args.task, args.role) + return json_ok(vars(link)) + + +def cmd_tracker_snapshot(ctx: AppContext, args: argparse.Namespace) -> int: + with connect(ctx.db_path) as conn: + init_db(conn) + snapshot = TrackerBridgeService(conn).get_issue_snapshot(args.issue_ref) + return json_ok( + snapshot.to_dict() + if snapshot + else {"snapshot": None, "diagnostics": {"missing": [args.issue_ref]}} + ) + + +def cmd_tracker_events(ctx: AppContext, args: argparse.Namespace) -> int: + with connect(ctx.db_path) as conn: + init_db(conn) + events = TrackerBridgeService(conn).get_sync_events( + args.connection, args.issue_ref, args.limit + ) + return json_ok([vars(event) for event in events]) + + +def cmd_tracker_suggest(ctx: AppContext, args: argparse.Namespace) -> int: + with connect(ctx.db_path) as conn: + init_db(conn) + suggestions = TrackerBridgeService(conn).generate_sync_suggestions(args.issue_ref) + return json_ok([vars(item) for item in suggestions]) + + +def cmd_tracker_comment(ctx: AppContext, args: argparse.Namespace) -> int: + with connect(ctx.db_path) as conn: + init_db(conn) + ok = TrackerBridgeService(conn).post_outbound_comment( + args.connection, args.issue_key, args.comment + ) + return json_ok({"ok": ok, "outbound": True}) + + +def cmd_tracker_status(ctx: AppContext, args: argparse.Namespace) -> int: + with connect(ctx.db_path) as conn: + init_db(conn) + ok = TrackerBridgeService(conn).update_outbound_status( + args.connection, args.issue_key, args.status + ) + return json_ok({"ok": ok, "outbound": True}) diff --git a/src/agent_taskstate/cli/constants.py b/src/agent_taskstate/cli/constants.py new file mode 100644 index 0000000..7569278 --- /dev/null +++ b/src/agent_taskstate/cli/constants.py @@ -0,0 +1,65 @@ +""" +CLI Constants and Type Definitions +""" + +from __future__ import annotations + +import os +from pathlib import Path + +APP_NAME = "agent-taskstate" +DEFAULT_DB_PATH = os.path.join(Path.home(), ".agent-taskstate", "agent-taskstate.db") +ISO = "%Y-%m-%dT%H:%M:%S.%fZ" + +TASK_KINDS = {"bugfix", "feature", "research"} +TASK_STATUSES = { + "draft", + "ready", + "in_progress", + "blocked", + "review", + "done", + "archived", +} +TASK_PRIORITIES = {"low", "medium", "high", "critical"} +OWNER_TYPES = {"human", "agent", "system"} +DECISION_STATUSES = {"proposed", "accepted", "rejected", "superseded"} +QUESTION_STATUSES = {"open", "answered", "deferred", "invalid"} +QUESTION_PRIORITIES = {"low", "medium", "high"} +RUN_TYPES = {"plan", "execute", "review", "summarize", "sync", "manual"} +RUN_STATUSES = {"running", "succeeded", "failed", "cancelled"} +BUILD_REASONS = {"normal", "ambiguity", "review", "high_risk", "recovery"} +CONFIDENCE_LEVELS = {"low", "medium", "high", None} +REPLY_STATES = {"pending", "sent", "failed", "skipped"} + +TASK_PHASE2_COLUMNS = { + "idempotency_key": "TEXT", + "note_id": "TEXT", + "trace_id": "TEXT", + "reply_target": "TEXT", + "reply_state": "TEXT", + "retry_count": "INTEGER NOT NULL DEFAULT 0", + "kestra_execution_id": "TEXT", + "original_task_id": "TEXT", + "trigger": "TEXT", + "reply_text": "TEXT", + "roadmap_request_json": "TEXT", +} + +EXPECTED_OUTPUT_SCHEMA = { + "summary": "string", + "proposed_actions": ["string"], + "decision_candidates": ["string"], + "question_candidates": ["string"], + "evidence_needed": ["string"], +} + +ALLOWED_TRANSITIONS = { + "draft": {"ready", "archived"}, + "ready": {"in_progress"}, + "in_progress": {"blocked", "review"}, + "blocked": {"in_progress"}, + "review": {"in_progress", "done"}, + "done": {"archived", "in_progress"}, + "archived": set(), +} diff --git a/src/agent_taskstate/cli/db.py b/src/agent_taskstate/cli/db.py new file mode 100644 index 0000000..2559f22 --- /dev/null +++ b/src/agent_taskstate/cli/db.py @@ -0,0 +1,159 @@ +"""SQLite connection and the plural agent-taskstate schema.""" + +from __future__ import annotations + +import sqlite3 +from contextlib import contextmanager +from pathlib import Path +from typing import Iterable + +from .constants import TASK_PHASE2_COLUMNS + + +def ensure_parent_dir(path: str) -> None: + Path(path).parent.mkdir(parents=True, exist_ok=True) + + +@contextmanager +def connect(db_path: str) -> Iterable[sqlite3.Connection]: + ensure_parent_dir(db_path) + conn = sqlite3.connect(db_path, timeout=30.0) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("PRAGMA busy_timeout = 30000") + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +SCHEMA_SQL = """ +CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, parent_task_id TEXT, kind TEXT NOT NULL, title TEXT NOT NULL, + goal TEXT NOT NULL, status TEXT NOT NULL, priority TEXT NOT NULL, + owner_type TEXT NOT NULL, owner_id TEXT, tracker_issue_ref TEXT, + idempotency_key TEXT, note_id TEXT, trace_id TEXT, reply_target TEXT, + reply_state TEXT, retry_count INTEGER NOT NULL DEFAULT 0, + kestra_execution_id TEXT, original_task_id TEXT, trigger TEXT, reply_text TEXT, + roadmap_request_json TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + FOREIGN KEY(parent_task_id) REFERENCES tasks(id) +); +CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); +CREATE INDEX IF NOT EXISTS idx_tasks_kind ON tasks(kind); +CREATE INDEX IF NOT EXISTS idx_tasks_owner ON tasks(owner_type, owner_id); +CREATE TABLE IF NOT EXISTS task_states ( + task_id TEXT PRIMARY KEY, revision INTEGER NOT NULL, current_step TEXT NOT NULL, + constraints_json TEXT NOT NULL, done_when_json TEXT NOT NULL, current_summary TEXT, + artifact_refs_json TEXT NOT NULL, evidence_refs_json TEXT NOT NULL, confidence TEXT, + context_policy_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE +); +CREATE TABLE IF NOT EXISTS decisions ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL, summary TEXT NOT NULL, rationale TEXT, + status TEXT NOT NULL, confidence TEXT, evidence_refs_json TEXT NOT NULL, + supersedes_decision_id TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE, + FOREIGN KEY(supersedes_decision_id) REFERENCES decisions(id) +); +CREATE INDEX IF NOT EXISTS idx_decisions_task ON decisions(task_id); +CREATE TABLE IF NOT EXISTS open_questions ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL, question TEXT NOT NULL, + priority TEXT NOT NULL, status TEXT NOT NULL, answer TEXT, + evidence_refs_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_open_questions_task ON open_questions(task_id); +CREATE TABLE IF NOT EXISTS runs ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL, actor_type TEXT NOT NULL, actor_id TEXT, + run_type TEXT NOT NULL, status TEXT NOT NULL, input_ref TEXT, output_ref TEXT, + started_at TEXT NOT NULL, ended_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_runs_task ON runs(task_id); +CREATE TABLE IF NOT EXISTS context_bundles ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL, build_reason TEXT NOT NULL, + state_snapshot_json TEXT NOT NULL, included_decision_refs_json TEXT NOT NULL, + included_open_question_refs_json TEXT NOT NULL, included_artifact_refs_json TEXT NOT NULL, + included_evidence_refs_json TEXT NOT NULL, expected_output_schema_json TEXT NOT NULL, + created_at TEXT NOT NULL, metadata_json TEXT, + purpose TEXT NOT NULL DEFAULT 'continue_work', rebuild_level TEXT NOT NULL DEFAULT 'L2', + summary TEXT, decision_digest_json TEXT, question_digest_json TEXT, diagnostics_json TEXT, + raw_included INTEGER NOT NULL DEFAULT 0, generator_version TEXT NOT NULL DEFAULT '1.1.0', + generated_at TEXT, state_snapshot_compressed INTEGER NOT NULL DEFAULT 0, + decision_digest_compressed INTEGER NOT NULL DEFAULT 0, + question_digest_compressed INTEGER NOT NULL DEFAULT 0, + diagnostics_compressed INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_context_bundles_task ON context_bundles(task_id); +CREATE TABLE IF NOT EXISTS context_bundle_sources ( + id TEXT PRIMARY KEY, context_bundle_id TEXT NOT NULL, typed_ref TEXT NOT NULL, + source_kind TEXT NOT NULL, selected_raw INTEGER NOT NULL DEFAULT 0, + metadata_json TEXT, created_at TEXT NOT NULL, + FOREIGN KEY(context_bundle_id) REFERENCES context_bundles(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_context_bundle_sources_bundle ON context_bundle_sources(context_bundle_id); +CREATE TABLE IF NOT EXISTS state_transitions ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL, from_status TEXT, to_status TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT '', actor_type TEXT NOT NULL, actor_id TEXT, run_id TEXT, + changed_at TEXT NOT NULL, FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_state_transitions_task_changed + ON state_transitions(task_id, changed_at ASC, id ASC); +CREATE TABLE IF NOT EXISTS tracker_connection ( + id TEXT PRIMARY KEY, provider TEXT NOT NULL, name TEXT NOT NULL, config_json TEXT NOT NULL, + secret_env_json TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL, updated_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS issue_cache ( + id TEXT PRIMARY KEY, connection_id TEXT NOT NULL, issue_ref TEXT NOT NULL UNIQUE, + remote_key TEXT NOT NULL, title TEXT NOT NULL, status TEXT NOT NULL, assignee TEXT, + description TEXT, labels_json TEXT, raw_json TEXT, fetched_at TEXT NOT NULL, updated_at TEXT NOT NULL, + FOREIGN KEY(connection_id) REFERENCES tracker_connection(id) +); +CREATE TABLE IF NOT EXISTS entity_link ( + id TEXT PRIMARY KEY, tracker_issue_ref TEXT NOT NULL, + agent_taskstate_entity_ref TEXT NOT NULL, role TEXT NOT NULL, created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS sync_event ( + id TEXT PRIMARY KEY, connection_id TEXT NOT NULL, direction TEXT NOT NULL, + status TEXT NOT NULL, issue_ref TEXT, details_json TEXT, error_message TEXT, + created_at TEXT NOT NULL, FOREIGN KEY(connection_id) REFERENCES tracker_connection(id) +); +""" + + +def _columns(conn: sqlite3.Connection, table: str) -> set[str]: + return {row["name"] for row in conn.execute(f"PRAGMA table_info({table})")} + + +def _ensure_task_phase2_columns(conn: sqlite3.Connection) -> None: + columns = _columns(conn, "tasks") + for name, ddl in {**TASK_PHASE2_COLUMNS, "tracker_issue_ref": "TEXT"}.items(): + if name not in columns: + conn.execute(f"ALTER TABLE tasks ADD COLUMN {name} {ddl}") + conn.execute("UPDATE tasks SET retry_count = 0 WHERE retry_count IS NULL") + for sql in ( + "CREATE INDEX IF NOT EXISTS idx_tasks_updated_at ON tasks(updated_at)", + "CREATE INDEX IF NOT EXISTS idx_tasks_idempotency_key ON tasks(idempotency_key)", + "CREATE INDEX IF NOT EXISTS idx_tasks_trace_id ON tasks(trace_id)", + "CREATE INDEX IF NOT EXISTS idx_tasks_reply_state ON tasks(reply_state)", + "CREATE INDEX IF NOT EXISTS idx_tasks_original_task_id ON tasks(original_task_id)", + "CREATE INDEX IF NOT EXISTS idx_tasks_tracker_issue_ref ON tasks(tracker_issue_ref)", + ): + conn.execute(sql) + + +def init_db(conn: sqlite3.Connection) -> None: + conn.row_factory = sqlite3.Row + conn.executescript(SCHEMA_SQL) + _ensure_task_phase2_columns(conn) + from ..migrations import run_migrations + + run_migrations(conn) diff --git a/src/agent_taskstate/cli/errors.py b/src/agent_taskstate/cli/errors.py new file mode 100644 index 0000000..0d2dea8 --- /dev/null +++ b/src/agent_taskstate/cli/errors.py @@ -0,0 +1,48 @@ +""" +CLI Exception Classes +""" + +from __future__ import annotations + +from typing import Optional + + +class AgentTaskstateError(Exception): + """Base error for agent-taskstate CLI.""" + + code = "validation_error" + + def __init__(self, message: str, *, code: Optional[str] = None) -> None: + super().__init__(message) + if code: + self.code = code + + +class NotFoundError(AgentTaskstateError): + """Raised when a resource is not found.""" + + code = "not_found" + + +class ConflictError(AgentTaskstateError): + """Raised when there is a conflict (e.g., revision mismatch).""" + + code = "conflict" + + +class InvalidTransitionError(AgentTaskstateError): + """Raised when a status transition is invalid.""" + + code = "invalid_transition" + + +class DependencyBlockedError(AgentTaskstateError): + """Raised when a dependency blocks the operation.""" + + code = "dependency_blocked" + + +class InternalError(AgentTaskstateError): + """Unexpected error with a stable public error code.""" + + code = "internal_error" diff --git a/src/agent_taskstate/cli/fetch.py b/src/agent_taskstate/cli/fetch.py new file mode 100644 index 0000000..ffb84ee --- /dev/null +++ b/src/agent_taskstate/cli/fetch.py @@ -0,0 +1,48 @@ +""" +CLI Fetch Helpers +""" + +from __future__ import annotations + +import sqlite3 +from typing import Any, Sequence + +from .errors import NotFoundError + + +def fetch_one(conn: sqlite3.Connection, sql: str, params: Sequence[Any]) -> sqlite3.Row: + """Fetch one row or raise NotFoundError.""" + row = conn.execute(sql, params).fetchone() + if row is None: + raise NotFoundError("resource not found") + return row + + +def get_task(conn: sqlite3.Connection, task_id: str) -> sqlite3.Row: + """Get task by ID.""" + return fetch_one(conn, "SELECT * FROM tasks WHERE id = ?", (task_id,)) + + +def get_task_state(conn: sqlite3.Connection, task_id: str) -> sqlite3.Row: + """Get task_state by task_id.""" + return fetch_one(conn, "SELECT * FROM task_states WHERE task_id = ?", (task_id,)) + + +def get_decision(conn: sqlite3.Connection, decision_id: str) -> sqlite3.Row: + """Get decision by ID.""" + return fetch_one(conn, "SELECT * FROM decisions WHERE id = ?", (decision_id,)) + + +def get_question(conn: sqlite3.Connection, question_id: str) -> sqlite3.Row: + """Get question by ID.""" + return fetch_one(conn, "SELECT * FROM open_questions WHERE id = ?", (question_id,)) + + +def get_run(conn: sqlite3.Connection, run_id: str) -> sqlite3.Row: + """Get run by ID.""" + return fetch_one(conn, "SELECT * FROM runs WHERE id = ?", (run_id,)) + + +def get_bundle(conn: sqlite3.Connection, bundle_id: str) -> sqlite3.Row: + """Get context_bundle by ID.""" + return fetch_one(conn, "SELECT * FROM context_bundles WHERE id = ?", (bundle_id,)) diff --git a/src/agent_taskstate/cli/models.py b/src/agent_taskstate/cli/models.py new file mode 100644 index 0000000..8a4cb00 --- /dev/null +++ b/src/agent_taskstate/cli/models.py @@ -0,0 +1,87 @@ +"""CLI row conversion helpers.""" + +from __future__ import annotations + +import json +import sqlite3 +from typing import Any, Dict, Optional + +from .typed_ref import typed_ref + + +def jdump(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + + +def jload(value: Optional[str], default: Any = None) -> Any: + if value is None: + return default + try: + return json.loads(value) + except (TypeError, json.JSONDecodeError): + return default + + +def row_to_task(row: sqlite3.Row) -> Dict[str, Any]: + return dict(row) + + +def row_to_task_state(row: sqlite3.Row) -> Dict[str, Any]: + return { + "task_id": row["task_id"], + "revision": row["revision"], + "current_step": row["current_step"], + "constraints": jload(row["constraints_json"], []), + "done_when": jload(row["done_when_json"], []), + "current_summary": row["current_summary"], + "artifact_refs": jload(row["artifact_refs_json"], []), + "evidence_refs": jload(row["evidence_refs_json"], []), + "confidence": row["confidence"], + "context_policy": jload(row["context_policy_json"], {}), + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } + + +def row_to_decision(row: sqlite3.Row) -> Dict[str, Any]: + data = dict(row) + data["evidence_refs"] = jload(data.pop("evidence_refs_json", None), []) + data["ref"] = typed_ref("agent-taskstate", "decision", row["id"]) + return data + + +def row_to_question(row: sqlite3.Row) -> Dict[str, Any]: + data = dict(row) + data["evidence_refs"] = jload(data.pop("evidence_refs_json", None), []) + data["ref"] = typed_ref("agent-taskstate", "question", row["id"]) + return data + + +def row_to_run(row: sqlite3.Row) -> Dict[str, Any]: + data = dict(row) + data["ref"] = typed_ref("agent-taskstate", "run", row["id"]) + return data + + +def row_to_bundle(row: sqlite3.Row) -> Dict[str, Any]: + data = dict(row) + json_columns = { + "state_snapshot": "state_snapshot_json", + "included_decision_refs": "included_decision_refs_json", + "included_open_question_refs": "included_open_question_refs_json", + "included_artifact_refs": "included_artifact_refs_json", + "included_evidence_refs": "included_evidence_refs_json", + "expected_output_schema": "expected_output_schema_json", + "decision_digest": "decision_digest_json", + "question_digest": "question_digest_json", + "diagnostics": "diagnostics_json", + } + for output, column in json_columns.items(): + if column in data: + data[output] = jload( + data.pop(column), + None if output in {"decision_digest", "question_digest", "diagnostics"} else {}, + ) + data["raw_included"] = bool(data["raw_included"]) if "raw_included" in data else False + data["ref"] = typed_ref("agent-taskstate", "context_bundle", row["id"]) + return data diff --git a/src/agent_taskstate/cli/parser.py b/src/agent_taskstate/cli/parser.py new file mode 100644 index 0000000..3610484 --- /dev/null +++ b/src/agent_taskstate/cli/parser.py @@ -0,0 +1,231 @@ +"""Argument parser for the canonical CLI.""" + +from __future__ import annotations + +import argparse +import os + +from .constants import APP_NAME, DEFAULT_DB_PATH +from .commands import ( + cmd_context_build, + cmd_context_show, + cmd_decision_accept, + cmd_decision_add, + cmd_decision_list, + cmd_decision_reject, + cmd_export_task, + cmd_init, + cmd_question_add, + cmd_question_answer, + cmd_question_defer, + cmd_question_list, + cmd_run_finish, + cmd_run_list, + cmd_run_start, + cmd_state_get, + cmd_state_patch, + cmd_state_put, + cmd_task_create, + cmd_task_history, + cmd_task_list, + cmd_task_set_status, + cmd_task_show, + cmd_task_update, + cmd_tracker_connection_add, + cmd_tracker_events, + cmd_tracker_fetch, + cmd_tracker_link, + cmd_tracker_snapshot, + cmd_tracker_suggest, + cmd_tracker_comment, + cmd_tracker_status, +) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog=APP_NAME, description="agent-taskstate CLI") + parser.add_argument("--db", default=os.environ.get("AGENT_TASKSTATE_DB", DEFAULT_DB_PATH)) + sub = parser.add_subparsers(dest="command") + + p = sub.add_parser("init", help="initialize SQLite database") + p.set_defaults(func=cmd_init) + + task = sub.add_parser("task", help="task commands") + tasks = task.add_subparsers(dest="task_command") + p = tasks.add_parser("create") + p.add_argument("--json") + p.add_argument("--file") + p.set_defaults(func=cmd_task_create) + p = tasks.add_parser("show") + p.add_argument("--task", required=True) + p.set_defaults(func=cmd_task_show) + p = tasks.add_parser("list") + for name in ( + "status", + "kind", + "owner-type", + "owner-id", + "priority", + "updated-before", + "idempotency-key", + "reply-state", + "trace-id", + ): + p.add_argument(f"--{name}") + p.set_defaults(func=cmd_task_list) + p = tasks.add_parser("update") + p.add_argument("--task", required=True) + p.add_argument("--json") + p.add_argument("--file") + p.set_defaults(func=cmd_task_update) + p = tasks.add_parser("set-status") + p.add_argument("--task", required=True) + p.add_argument("--to", required=True) + p.add_argument("--reason") + p.add_argument("--actor-type", default="system") + p.add_argument("--actor-id") + p.add_argument("--run") + p.add_argument("--reason-required", action="store_true", default=False) + p.set_defaults(func=cmd_task_set_status) + p = tasks.add_parser("history") + p.add_argument("--task", required=True) + p.set_defaults(func=cmd_task_history) + + state = sub.add_parser("state", help="task state commands") + states = state.add_subparsers(dest="state_command") + p = states.add_parser("get") + p.add_argument("--task", required=True) + p.set_defaults(func=cmd_state_get) + p = states.add_parser("put") + p.add_argument("--task", required=True) + p.add_argument("--json") + p.add_argument("--file") + p.set_defaults(func=cmd_state_put) + p = states.add_parser("patch") + p.add_argument("--task", required=True) + p.add_argument("--json") + p.add_argument("--file") + p.add_argument("--expected-revision", required=True, type=int) + p.set_defaults(func=cmd_state_patch) + + decision = sub.add_parser("decision", help="decision commands") + decisions = decision.add_subparsers(dest="decision_command") + p = decisions.add_parser("add") + p.add_argument("--task", required=True) + p.add_argument("--json") + p.add_argument("--file") + p.set_defaults(func=cmd_decision_add) + p = decisions.add_parser("list") + p.add_argument("--task", required=True) + p.add_argument("--status") + p.set_defaults(func=cmd_decision_list) + p = decisions.add_parser("accept") + p.add_argument("--decision", required=True) + p.set_defaults(func=cmd_decision_accept) + p = decisions.add_parser("reject") + p.add_argument("--decision", required=True) + p.set_defaults(func=cmd_decision_reject) + + question = sub.add_parser("question", help="question commands") + questions = question.add_subparsers(dest="question_command") + p = questions.add_parser("add") + p.add_argument("--task", required=True) + p.add_argument("--json") + p.add_argument("--file") + p.set_defaults(func=cmd_question_add) + p = questions.add_parser("list") + p.add_argument("--task", required=True) + p.add_argument("--status") + p.add_argument("--priority") + p.set_defaults(func=cmd_question_list) + p = questions.add_parser("answer") + p.add_argument("--question", required=True) + p.add_argument("--answer", required=True) + p.set_defaults(func=cmd_question_answer) + p = questions.add_parser("defer") + p.add_argument("--question", required=True) + p.add_argument("--reason") + p.set_defaults(func=cmd_question_defer) + + run = sub.add_parser("run", help="run commands") + runs = run.add_subparsers(dest="run_command") + p = runs.add_parser("start") + p.add_argument("--task", required=True) + p.add_argument("--run-type", required=True) + p.add_argument("--actor-type", default="system") + p.add_argument("--actor-id") + p.add_argument("--input-ref") + p.set_defaults(func=cmd_run_start) + p = runs.add_parser("list") + p.add_argument("--task", required=True) + p.add_argument("--status") + p.set_defaults(func=cmd_run_list) + p = runs.add_parser("finish") + p.add_argument("--run", required=True) + p.add_argument("--status", required=True) + p.add_argument("--output-ref") + p.set_defaults(func=cmd_run_finish) + + context = sub.add_parser("context", help="context bundle commands") + contexts = context.add_subparsers(dest="context_command") + p = contexts.add_parser("build") + p.add_argument("--task", required=True) + p.add_argument("--reason", default="normal") + p.add_argument("--purpose", default="continue_work") + p.add_argument("--rebuild-level", default="L2") + p.add_argument("--include-raw", action="store_true") + p.set_defaults(func=cmd_context_build) + p = contexts.add_parser("show") + p.add_argument("--bundle", required=True) + p.set_defaults(func=cmd_context_show) + + export = sub.add_parser("export", help="export commands") + exports = export.add_subparsers(dest="export_command") + p = exports.add_parser("task") + p.add_argument("--task", required=True) + p.add_argument("--output", required=True) + p.set_defaults(func=cmd_export_task) + + tracker = sub.add_parser("tracker", help="explicit tracker operations") + trackers = tracker.add_subparsers(dest="tracker_command") + connection = trackers.add_parser("connection") + connections = connection.add_subparsers(dest="connection_command") + p = connections.add_parser("add") + p.add_argument("--provider", required=True) + p.add_argument("--name", required=True) + p.add_argument("--json") + p.add_argument("--file") + p.add_argument("--secret-env-json") + p.set_defaults(func=cmd_tracker_connection_add) + p = trackers.add_parser("fetch") + p.add_argument("--connection", required=True) + p.add_argument("--issue-key", required=True) + p.set_defaults(func=cmd_tracker_fetch) + p = trackers.add_parser("link") + p.add_argument("--issue-ref", required=True) + p.add_argument("--task", required=True) + p.add_argument("--role", default="primary") + p.set_defaults(func=cmd_tracker_link) + p = trackers.add_parser("snapshot") + p.add_argument("--issue-ref", required=True) + p.set_defaults(func=cmd_tracker_snapshot) + p = trackers.add_parser("events") + p.add_argument("--connection") + p.add_argument("--issue-ref") + p.add_argument("--limit", type=int, default=100) + p.set_defaults(func=cmd_tracker_events) + p = trackers.add_parser("suggest") + p.add_argument("--issue-ref", required=True) + p.set_defaults(func=cmd_tracker_suggest) + p = trackers.add_parser("comment") + p.add_argument("--connection", required=True) + p.add_argument("--issue-key", required=True) + p.add_argument("--comment", required=True) + p.set_defaults(func=cmd_tracker_comment) + p = trackers.add_parser("status") + p.add_argument("--connection", required=True) + p.add_argument("--issue-key", required=True) + p.add_argument("--status", required=True) + p.set_defaults(func=cmd_tracker_status) + + return parser diff --git a/src/agent_taskstate/cli/typed_ref.py b/src/agent_taskstate/cli/typed_ref.py new file mode 100644 index 0000000..ad4ada8 --- /dev/null +++ b/src/agent_taskstate/cli/typed_ref.py @@ -0,0 +1,46 @@ +"""Compatibility helpers backed by the canonical typed_ref module.""" + +from __future__ import annotations + +from ..typed_ref import DEFAULT_PROVIDER, agent_taskstate_ref, format_ref + + +def typed_ref( + domain: str, + entity_type: str, + entity_id: str, + provider: str = DEFAULT_PROVIDER, +) -> str: + """Deprecated helper that delegates to canonical format_ref.""" + return format_ref(domain, entity_type, entity_id, provider) + + +def task_ref(task_id: str) -> str: + return agent_taskstate_ref("task", task_id) + + +def decision_ref(decision_id: str) -> str: + return agent_taskstate_ref("decision", decision_id) + + +def question_ref(question_id: str) -> str: + return agent_taskstate_ref("question", question_id) + + +def run_ref(run_id: str) -> str: + return agent_taskstate_ref("run", run_id) + + +def bundle_ref(bundle_id: str) -> str: + return agent_taskstate_ref("context_bundle", bundle_id) + + +__all__ = [ + "DEFAULT_PROVIDER", + "typed_ref", + "task_ref", + "decision_ref", + "question_ref", + "run_ref", + "bundle_ref", +] diff --git a/src/agent_taskstate/cli/utils.py b/src/agent_taskstate/cli/utils.py new file mode 100644 index 0000000..c6fe1a9 --- /dev/null +++ b/src/agent_taskstate/cli/utils.py @@ -0,0 +1,42 @@ +""" +CLI Utility Functions +""" + +from __future__ import annotations + +import json +import sys +import uuid +from datetime import datetime, timezone +from typing import Any + +from .constants import ISO + + +def now_utc() -> str: + """Return current UTC timestamp in ISO format.""" + return datetime.now(timezone.utc).strftime(ISO) + + +def gen_id() -> str: + """Generate unique ID.""" + return uuid.uuid4().hex + + +def json_ok(data: Any) -> int: + """Output success JSON and return 0.""" + print(json.dumps({"ok": True, "data": data, "error": None}, ensure_ascii=False, indent=2)) + return 0 + + +def json_error(code: str, message: str) -> int: + """Output error JSON and return 1.""" + print( + json.dumps( + {"ok": False, "data": None, "error": {"code": code, "message": message}}, + ensure_ascii=False, + indent=2, + ), + file=sys.stdout, + ) + return 1 diff --git a/src/agent_taskstate/cli/validation.py b/src/agent_taskstate/cli/validation.py new file mode 100644 index 0000000..2e723d1 --- /dev/null +++ b/src/agent_taskstate/cli/validation.py @@ -0,0 +1,181 @@ +"""CLI validation and typed-ref guards.""" + +from __future__ import annotations + +import json +import sqlite3 +from typing import Any, Dict, List, Optional, Sequence +from pathlib import Path + +from ..typed_ref import canonicalize_ref +from .constants import ( + ALLOWED_TRANSITIONS, + CONFIDENCE_LEVELS, + DECISION_STATUSES, + OWNER_TYPES, + QUESTION_PRIORITIES, + QUESTION_STATUSES, + REPLY_STATES, + TASK_KINDS, + TASK_PRIORITIES, + TASK_STATUSES, +) +from .errors import AgentTaskstateError, DependencyBlockedError, InvalidTransitionError +from .fetch import get_task_state +from .models import jdump, row_to_task_state + + +def require_in(value: str, allowed: Sequence[str] | set[str], field: str) -> None: + if value not in allowed: + raise AgentTaskstateError(f"invalid {field}: {value}; allowed={sorted(allowed)}") + + +def load_json_arg(value: Optional[str] = None, file_path: Optional[str] = None) -> Any: + if value and file_path: + raise AgentTaskstateError("pass either --json or --file, not both") + if file_path: + return json.loads(Path(file_path).read_text(encoding="utf-8")) + if value: + return json.loads(value) + raise AgentTaskstateError("missing JSON payload; pass --json or --file") + + +def _normalize_optional_json_text(value: Any) -> Optional[str]: + if value is None: + return None + return value if isinstance(value, str) else jdump(value) + + +def canonicalize_refs(values: Any, field: str) -> list[str]: + if not isinstance(values, list): + raise AgentTaskstateError(f"{field} must be array") + result: list[str] = [] + for value in values: + if not isinstance(value, str): + raise AgentTaskstateError(f"{field} must contain strings") + try: + result.append(canonicalize_ref(value)) + except ValueError as exc: + raise AgentTaskstateError(f"invalid {field} ref: {exc}") from exc + return result + + +def canonicalize_optional_ref(value: Any, field: str) -> Optional[str]: + if value is None: + return None + if not isinstance(value, str): + raise AgentTaskstateError(f"{field} must be a string") + try: + return canonicalize_ref(value) + except ValueError as exc: + raise AgentTaskstateError(f"invalid {field}: {exc}") from exc + + +def validate_task_payload(data: Dict[str, Any]) -> None: + require_in(data["kind"], TASK_KINDS, "kind") + require_in(data.get("status", "draft"), TASK_STATUSES, "status") + require_in(data.get("priority", "medium"), TASK_PRIORITIES, "priority") + require_in(data.get("owner_type", "human"), OWNER_TYPES, "owner_type") + if data.get("parent_task_id") and not isinstance(data["parent_task_id"], str): + raise AgentTaskstateError("parent_task_id must be string") + if data.get("reply_state") is not None: + require_in(data["reply_state"], REPLY_STATES, "reply_state") + if data.get("retry_count") is not None and ( + not isinstance(data["retry_count"], int) or data["retry_count"] < 0 + ): + raise AgentTaskstateError("retry_count must be non-negative integer") + + +def validate_state_payload(data: Dict[str, Any]) -> None: + if not data.get("current_step"): + raise AgentTaskstateError("current_step is required") + for field in ("constraints", "done_when", "artifact_refs", "evidence_refs"): + if not isinstance(data.get(field, []), list): + raise AgentTaskstateError(f"{field} must be array") + data["artifact_refs"] = canonicalize_refs(data.get("artifact_refs", []), "artifact_refs") + data["evidence_refs"] = canonicalize_refs(data.get("evidence_refs", []), "evidence_refs") + if not isinstance(data.get("context_policy", {}), dict): + raise AgentTaskstateError("context_policy must be object") + require_in(data.get("confidence"), CONFIDENCE_LEVELS, "confidence") + + +def validate_decision_payload(data: Dict[str, Any]) -> None: + if not data.get("summary"): + raise AgentTaskstateError("summary is required") + require_in(data.get("status", "proposed"), DECISION_STATUSES, "decision.status") + require_in(data.get("confidence", "medium"), CONFIDENCE_LEVELS, "decision.confidence") + data["evidence_refs"] = canonicalize_refs(data.get("evidence_refs", []), "evidence_refs") + + +def validate_question_payload(data: Dict[str, Any]) -> None: + if not data.get("question"): + raise AgentTaskstateError("question is required") + require_in(data.get("priority", "medium"), QUESTION_PRIORITIES, "question.priority") + require_in(data.get("status", "open"), QUESTION_STATUSES, "question.status") + data["evidence_refs"] = canonicalize_refs(data.get("evidence_refs", []), "evidence_refs") + + +def count_open_high_questions(conn: sqlite3.Connection, task_id: str) -> int: + row = conn.execute( + "SELECT COUNT(*) AS c FROM open_questions WHERE task_id = ? AND status = 'open' AND priority = 'high'", + (task_id,), + ).fetchone() + return int(row["c"]) + + +def count_relevant_decisions(conn: sqlite3.Connection, task_id: str) -> int: + row = conn.execute( + "SELECT COUNT(*) AS c FROM decisions WHERE task_id = ? AND status IN ('accepted', 'proposed')", + (task_id,), + ).fetchone() + return int(row["c"]) + + +def is_done_when_satisfied(done_when: List[Any]) -> bool: + return bool(done_when) and all( + isinstance(item, dict) and bool(item.get("done", False)) for item in done_when + ) + + +def validate_status_transition( + conn: sqlite3.Connection, task_row: sqlite3.Row, to_status: str +) -> None: + require_in(to_status, TASK_STATUSES, "status") + from_status = task_row["status"] + if from_status == to_status: + return + if to_status not in ALLOWED_TRANSITIONS.get(from_status, set()): + raise InvalidTransitionError(f"transition not allowed: {from_status} -> {to_status}") + state: Optional[Dict[str, Any]] = None + try: + state = row_to_task_state(get_task_state(conn, task_row["id"])) + except Exception: + pass + if to_status == "ready": + if not task_row["goal"]: + raise DependencyBlockedError("goal must be set before moving to ready") + if not state or not state["done_when"]: + raise DependencyBlockedError( + "done_when must contain at least one item before moving to ready" + ) + if to_status == "in_progress": + if not state or not state["current_step"]: + raise DependencyBlockedError("task_state must exist before moving to in_progress") + if to_status == "review": + if count_open_high_questions(conn, task_row["id"]) > 0: + raise DependencyBlockedError( + "high priority open questions must be 0 before moving to review" + ) + if count_relevant_decisions(conn, task_row["id"]) == 0: + raise DependencyBlockedError( + "at least one accepted/proposed decision is required before moving to review" + ) + if to_status == "done": + if from_status != "review": + raise InvalidTransitionError("done is only allowed from review") + if not state or not is_done_when_satisfied(state["done_when"]): + raise DependencyBlockedError( + "all done_when items must be satisfied before moving to done" + ) + if not state.get("current_summary"): + raise DependencyBlockedError("current_summary must be set before moving to done") diff --git a/src/agent_taskstate/context_bundle.py b/src/agent_taskstate/context_bundle.py new file mode 100644 index 0000000..4ba5f7c --- /dev/null +++ b/src/agent_taskstate/context_bundle.py @@ -0,0 +1,903 @@ +""" +Context Bundle Service + +Manages context bundle creation and audit trails. + +Key features: +- Bundle generation with source refs tracking +- Audit information (purpose, rebuild_level, generator_version) +- Source refs stored in separate table for auditability +- gzip compression for large JSON fields +- LRU cache for latest bundle retrieval +- Differential bundle storage for incremental updates +""" + +from __future__ import annotations + +import gzip +import json +import sqlite3 +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Tuple + +from .typed_ref import canonicalize_ref + +# Compression threshold: compress if JSON exceeds this size (bytes) +COMPRESSION_THRESHOLD = 1024 # 1KB + + +@dataclass +class BundleSource: + """A single source reference in a context bundle.""" + + id: str + context_bundle_id: str + typed_ref: str + source_kind: str + selected_raw: bool + metadata_json: Optional[str] + + def to_dict(self) -> Dict[str, Any]: + """Convert source record to dictionary for JSON output.""" + return { + "id": self.id, + "context_bundle_id": self.context_bundle_id, + "typed_ref": self.typed_ref, + "source_kind": self.source_kind, + "selected_raw": self.selected_raw, + "metadata": json.loads(self.metadata_json) if self.metadata_json else None, + } + + +@dataclass +class ContextBundle: + """A context bundle with audit information.""" + + id: str + task_id: str + purpose: str + rebuild_level: str + summary: Optional[str] + state_snapshot_json: str + decision_digest_json: Optional[str] + question_digest_json: Optional[str] + diagnostics_json: Optional[str] + raw_included: bool + generator_version: str + generated_at: str + created_at: str + sources: List[BundleSource] = field(default_factory=list) + # Compression flags (stored separately in DB) + _state_snapshot_compressed: bool = False + _decision_digest_compressed: bool = False + _question_digest_compressed: bool = False + _diagnostics_compressed: bool = False + + def get_source_refs(self) -> List[str]: + """Get list of all source refs.""" + return [s.typed_ref for s in self.sources] + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for JSON output.""" + return { + "id": self.id, + "task_id": self.task_id, + "purpose": self.purpose, + "rebuild_level": self.rebuild_level, + "summary": self.summary, + "state_snapshot": decompress_json( + self.state_snapshot_json, self._state_snapshot_compressed + ), + "decision_digest": decompress_json( + self.decision_digest_json, self._decision_digest_compressed + ) + if self.decision_digest_json + else None, + "question_digest": decompress_json( + self.question_digest_json, self._question_digest_compressed + ) + if self.question_digest_json + else None, + "diagnostics": decompress_json(self.diagnostics_json, self._diagnostics_compressed) + if self.diagnostics_json + else None, + "raw_included": self.raw_included, + "generator_version": self.generator_version, + "generated_at": self.generated_at, + "created_at": self.created_at, + "source_refs": self.get_source_refs(), + "source_count": len(self.sources), + "sources": [source.to_dict() for source in self.sources], + } + + +REBUILD_LEVELS = {"L1", "L2", "L3"} + +PURPOSE_TYPES = { + "continue_work", + "review_prepare", + "resume_after_block", + "decision_support", + "other", +} + +SOURCE_KINDS = { + "task", + "decision", + "open_question", + "evidence", + "artifact", + "run", + "tracker_issue", +} + + +def compress_json(data: Any, threshold: int = COMPRESSION_THRESHOLD) -> Tuple[str, bool]: + """ + Compress JSON data if it exceeds threshold. + + Returns: + (stored_data, is_compressed) tuple + """ + json_str = json.dumps(data) + if len(json_str) < threshold: + return (json_str, False) + + compressed = gzip.compress(json_str.encode("utf-8")) + # Store as base64-encoded string for SQLite TEXT column + import base64 + + return (base64.b64encode(compressed).decode("ascii"), True) + + +def decompress_json(stored_data: str, is_compressed: bool) -> Any: + """ + Decompress JSON data if it was compressed. + + Args: + stored_data: Stored string (either raw JSON or base64-encoded gzip) + is_compressed: Whether the data was compressed + + Returns: + Parsed JSON object + """ + if not is_compressed: + return json.loads(stored_data) + + import base64 + + compressed = base64.b64decode(stored_data.encode("ascii")) + json_str = gzip.decompress(compressed).decode("utf-8") + return json.loads(json_str) + + +def compute_diff( + prev_bundle: Optional[ContextBundle], new_snapshot: Dict[str, Any] +) -> Dict[str, Any]: + """ + Compute differential update between bundles. + + Returns only changed fields to reduce storage. + """ + if prev_bundle is None: + return new_snapshot + + prev_snapshot = decompress_json( + prev_bundle.state_snapshot_json, prev_bundle._state_snapshot_compressed + ) + + diff = {} + for key, new_val in new_snapshot.items(): + prev_val = prev_snapshot.get(key) + if new_val != prev_val: + diff[key] = new_val + + return diff if diff else new_snapshot + + +def decompress_bundle_json(bundle: ContextBundle) -> Dict[str, Any]: + """Decompress all JSON fields in a bundle.""" + result = {} + + # state_snapshot_json is always present + is_compressed = getattr(bundle, "_state_snapshot_compressed", False) + result["state_snapshot"] = decompress_json(bundle.state_snapshot_json, is_compressed) + + # Optional JSON fields + if bundle.decision_digest_json: + is_compressed = getattr(bundle, "_decision_digest_compressed", False) + result["decision_digest"] = decompress_json(bundle.decision_digest_json, is_compressed) + + if bundle.question_digest_json: + is_compressed = getattr(bundle, "_question_digest_compressed", False) + result["question_digest"] = decompress_json(bundle.question_digest_json, is_compressed) + + if bundle.diagnostics_json: + is_compressed = getattr(bundle, "_diagnostics_compressed", False) + result["diagnostics"] = decompress_json(bundle.diagnostics_json, is_compressed) + + return result + + +def now_utc() -> str: + """Return current UTC timestamp in ISO format.""" + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ") + + +def gen_id() -> str: + """Generate a unique ID.""" + import uuid + + return uuid.uuid4().hex + + +class ContextBundleService: + """Service for creating and managing context bundles.""" + + _cache_max_size: int = 100 + + def __init__(self, conn: sqlite3.Connection, generator_version: str = "1.0.0"): + self.conn = conn + self.generator_version = generator_version + self._latest_cache: Dict[str, ContextBundle] = {} + + def clear_cache(self) -> None: + """Clear the latest bundle cache.""" + self._latest_cache.clear() + + def create_bundle( + self, + task_id: str, + purpose: str, + rebuild_level: str, + state_snapshot: Dict[str, Any], + decision_digest: Optional[Dict[str, Any]] = None, + question_digest: Optional[Dict[str, Any]] = None, + diagnostics: Optional[Dict[str, Any]] = None, + summary: Optional[str] = None, + raw_included: bool = False, + use_diff: bool = False, + ) -> ContextBundle: + """ + Create a new context bundle. + + Args: + use_diff: If True, compute differential against previous bundle + """ + if purpose not in PURPOSE_TYPES: + raise ValueError(f"Invalid purpose: {purpose}") + if rebuild_level not in REBUILD_LEVELS: + raise ValueError(f"Invalid rebuild_level: {rebuild_level}") + + bundle_id = gen_id() + now = now_utc() + + # 1.1.0 always stores a complete immutable snapshot; use_diff is compatibility-only. + + # Compress JSON fields + state_data, state_compressed = compress_json(state_snapshot) + decision_data, decision_compressed = ( + compress_json(decision_digest) if decision_digest else (None, False) + ) + question_data, question_compressed = ( + compress_json(question_digest) if question_digest else (None, False) + ) + diag_data, diag_compressed = compress_json(diagnostics) if diagnostics else (None, False) + + self.conn.execute( + """ + INSERT INTO context_bundle + (id, task_id, purpose, rebuild_level, summary, state_snapshot_json, + decision_digest_json, question_digest_json, diagnostics_json, raw_included, + generator_version, generated_at, created_at, + state_snapshot_compressed, decision_digest_compressed, question_digest_compressed, diagnostics_compressed) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + bundle_id, + task_id, + purpose, + rebuild_level, + summary, + state_data, + decision_data, + question_data, + diag_data, + 1 if raw_included else 0, + self.generator_version, + now, + now, + 1 if state_compressed else 0, + 1 if decision_compressed else 0, + 1 if question_compressed else 0, + 1 if diag_compressed else 0, + ), + ) + + bundle = ContextBundle( + id=bundle_id, + task_id=task_id, + purpose=purpose, + rebuild_level=rebuild_level, + summary=summary, + state_snapshot_json=state_data, + decision_digest_json=decision_data, + question_digest_json=question_data, + diagnostics_json=diag_data, + raw_included=raw_included, + generator_version=self.generator_version, + generated_at=now, + created_at=now, + _state_snapshot_compressed=state_compressed, + _decision_digest_compressed=decision_compressed, + _question_digest_compressed=question_compressed, + _diagnostics_compressed=diag_compressed, + ) + + # Invalidate cache for this task + if task_id in self._latest_cache: + del self._latest_cache[task_id] + + return bundle + + def add_source( + self, + bundle_id: str, + typed_ref: str, + source_kind: str, + selected_raw: bool = False, + metadata: Optional[Dict[str, Any]] = None, + ) -> BundleSource: + """Add a source reference to a bundle.""" + if source_kind not in SOURCE_KINDS: + raise ValueError(f"Invalid source_kind: {source_kind}") + + canonical_ref = canonicalize_ref(typed_ref) + source_id = gen_id() + now = now_utc() + + self.conn.execute( + """ + INSERT INTO context_bundle_source + (id, context_bundle_id, typed_ref, source_kind, selected_raw, metadata_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + source_id, + bundle_id, + canonical_ref, + source_kind, + 1 if selected_raw else 0, + json.dumps(metadata) if metadata is not None else None, + now, + ), + ) + + return BundleSource( + id=source_id, + context_bundle_id=bundle_id, + typed_ref=canonical_ref, + source_kind=source_kind, + selected_raw=selected_raw, + metadata_json=json.dumps(metadata) if metadata is not None else None, + ) + + def get_bundle(self, bundle_id: str) -> Optional[ContextBundle]: + """Get a context bundle by ID.""" + cursor = self.conn.execute( + """ + SELECT id, task_id, purpose, rebuild_level, summary, state_snapshot_json, + decision_digest_json, question_digest_json, diagnostics_json, raw_included, + generator_version, generated_at, created_at, + state_snapshot_compressed, decision_digest_compressed, question_digest_compressed, diagnostics_compressed + FROM context_bundle + WHERE id = ? + """, + (bundle_id,), + ) + + row = cursor.fetchone() + if not row: + return None + + bundle = ContextBundle( + id=row[0], + task_id=row[1], + purpose=row[2], + rebuild_level=row[3], + summary=row[4], + state_snapshot_json=row[5], + decision_digest_json=row[6], + question_digest_json=row[7], + diagnostics_json=row[8], + raw_included=bool(row[9]), + generator_version=row[10], + generated_at=row[11], + created_at=row[12], + _state_snapshot_compressed=bool(row[13]) if len(row) > 13 else False, + _decision_digest_compressed=bool(row[14]) if len(row) > 14 else False, + _question_digest_compressed=bool(row[15]) if len(row) > 15 else False, + _diagnostics_compressed=bool(row[16]) if len(row) > 16 else False, + ) + bundle.sources = self._load_sources(bundle_id) + return bundle + + def _get_latest_bundle_no_cache(self, task_id: str) -> Optional[ContextBundle]: + """Get latest bundle without using cache.""" + cursor = self.conn.execute( + """ + SELECT id FROM context_bundle + WHERE task_id = ? + ORDER BY generated_at DESC + LIMIT 1 + """, + (task_id,), + ) + + row = cursor.fetchone() + if not row: + return None + + return self.get_bundle(row[0]) + + def get_latest_bundle(self, task_id: str) -> Optional[ContextBundle]: + """Get the latest context bundle for a task (cached).""" + # Check cache first + if task_id in self._latest_cache: + return self._latest_cache[task_id] + + bundle = self._get_latest_bundle_no_cache(task_id) + if bundle: + # Manage cache size + if len(self._latest_cache) >= self._cache_max_size: + # Remove oldest entry (simple FIFO) + oldest_key = next(iter(self._latest_cache)) + del self._latest_cache[oldest_key] + self._latest_cache[task_id] = bundle + + return bundle + + def list_bundles(self, task_id: str) -> List[ContextBundle]: + """List all context bundles for a task.""" + cursor = self.conn.execute( + """ + SELECT id FROM context_bundle + WHERE task_id = ? + ORDER BY generated_at DESC + """, + (task_id,), + ) + + bundles = [] + for row in cursor.fetchall(): + bundle = self.get_bundle(row[0]) + if bundle: + bundles.append(bundle) + + return bundles + + def _load_sources(self, bundle_id: str) -> List[BundleSource]: + """Load sources for a bundle.""" + cursor = self.conn.execute( + """ + SELECT id, context_bundle_id, typed_ref, source_kind, selected_raw, metadata_json, created_at + FROM context_bundle_source + WHERE context_bundle_id = ? + ORDER BY created_at ASC + """, + (bundle_id,), + ) + + sources = [] + for row in cursor.fetchall(): + sources.append( + BundleSource( + id=row[0], + context_bundle_id=row[1], + typed_ref=row[2], + source_kind=row[3], + selected_raw=bool(row[4]), + metadata_json=row[5], + ) + ) + + return sources + + +def create_bundle_tables(conn: sqlite3.Connection) -> None: + """Create context bundle tables if they don't exist.""" + conn.execute( + """ + CREATE TABLE IF NOT EXISTS context_bundle ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + purpose TEXT NOT NULL, + rebuild_level TEXT NOT NULL, + summary TEXT, + state_snapshot_json TEXT NOT NULL, + decision_digest_json TEXT, + question_digest_json TEXT, + diagnostics_json TEXT, + raw_included INTEGER NOT NULL DEFAULT 0, + generator_version TEXT NOT NULL, + generated_at TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (task_id) REFERENCES task(id) + ) + """ + ) + + conn.execute( + """ + CREATE TABLE IF NOT EXISTS context_bundle_source ( + id TEXT PRIMARY KEY, + context_bundle_id TEXT NOT NULL, + typed_ref TEXT NOT NULL, + source_kind TEXT NOT NULL, + selected_raw INTEGER NOT NULL DEFAULT 0, + metadata_json TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (context_bundle_id) REFERENCES context_bundle(id) + ) + """ + ) + + _ensure_column(conn, "context_bundle", "diagnostics_json", "TEXT") + _ensure_column( + conn, + "context_bundle_source", + "selected_raw", + "INTEGER NOT NULL DEFAULT 0", + ) + _ensure_column(conn, "context_bundle_source", "metadata_json", "TEXT") + + # Compression flag columns for gzip-compressed JSON fields + _ensure_column( + conn, "context_bundle", "state_snapshot_compressed", "INTEGER NOT NULL DEFAULT 0" + ) + _ensure_column( + conn, "context_bundle", "decision_digest_compressed", "INTEGER NOT NULL DEFAULT 0" + ) + _ensure_column( + conn, "context_bundle", "question_digest_compressed", "INTEGER NOT NULL DEFAULT 0" + ) + _ensure_column(conn, "context_bundle", "diagnostics_compressed", "INTEGER NOT NULL DEFAULT 0") + + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_context_bundle_task + ON context_bundle(task_id, generated_at DESC) + """ + ) + + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_context_bundle_source_bundle + ON context_bundle_source(context_bundle_id) + """ + ) + + +def _ensure_column( + conn: sqlite3.Connection, + table_name: str, + column_name: str, + column_ddl: str, +) -> None: + """Add a missing column to an existing table.""" + existing_columns = { + row[1] for row in conn.execute(f"PRAGMA table_info({table_name})").fetchall() + } + if column_name not in existing_columns: + conn.execute(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_ddl}") + + +class ContextRebuildService: + """Build complete, immutable plural snapshots with resolver diagnostics.""" + + def __init__( + self, + conn: sqlite3.Connection, + resolver: Optional[Any] = None, + generator_version: str = "1.1.0", + ): + self.conn = conn + self.generator_version = generator_version + self.resolver = resolver + self._latest_cache: Dict[str, ContextBundle] = {} + + def clear_cache(self) -> None: + self._latest_cache.clear() + + @staticmethod + def _json(value: Optional[str], default: Any) -> Any: + if not value: + return default + try: + return json.loads(value) + except (TypeError, json.JSONDecodeError): + return default + + def build( + self, + task_id: str, + purpose: str = "continue_work", + rebuild_level: str = "L2", + include_raw: bool = False, + reason: Optional[str] = None, + ) -> ContextBundle: + if purpose not in PURPOSE_TYPES: + raise ValueError(f"Invalid purpose: {purpose}") + if rebuild_level not in REBUILD_LEVELS: + raise ValueError(f"Invalid rebuild_level: {rebuild_level}") + + task_row = self.conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone() + if task_row is None: + raise ValueError(f"Task not found: {task_id}") + state_row = self.conn.execute( + "SELECT * FROM task_states WHERE task_id = ?", (task_id,) + ).fetchone() + state: Dict[str, Any] = { + "task_id": task_id, + "revision": 0, + "current_step": None, + "constraints": [], + "done_when": [], + "current_summary": None, + "artifact_refs": [], + "evidence_refs": [], + "confidence": None, + "context_policy": {}, + } + if state_row is not None: + state = { + "task_id": state_row["task_id"], + "revision": state_row["revision"], + "current_step": state_row["current_step"], + "constraints": self._json(state_row["constraints_json"], []), + "done_when": self._json(state_row["done_when_json"], []), + "current_summary": state_row["current_summary"], + "artifact_refs": self._json(state_row["artifact_refs_json"], []), + "evidence_refs": self._json(state_row["evidence_refs_json"], []), + "confidence": state_row["confidence"], + "context_policy": self._json(state_row["context_policy_json"], {}), + } + decisions = [ + dict(row) + for row in self.conn.execute( + "SELECT * FROM decisions WHERE task_id = ? AND status IN ('accepted', 'proposed') ORDER BY created_at ASC", + (task_id,), + ).fetchall() + ] + for item in decisions: + item["evidence_refs"] = self._json(item.pop("evidence_refs_json", None), []) + item["ref"] = f"agent-taskstate:decision:local:{item['id']}" + questions = [ + dict(row) + for row in self.conn.execute( + "SELECT * FROM open_questions WHERE task_id = ? AND status = 'open' ORDER BY created_at ASC", + (task_id,), + ).fetchall() + ] + for item in questions: + item["evidence_refs"] = self._json(item.pop("evidence_refs_json", None), []) + item["ref"] = f"agent-taskstate:question:local:{item['id']}" + runs = [ + dict(row) + for row in self.conn.execute( + "SELECT * FROM runs WHERE task_id = ? ORDER BY created_at ASC", (task_id,) + ).fetchall() + ] + for item in runs: + item["ref"] = f"agent-taskstate:run:local:{item['id']}" + + task = dict(task_row) + refs: List[tuple[str, str]] = [("task", f"agent-taskstate:task:local:{task_id}")] + refs.extend(("decision", item["ref"]) for item in decisions) + refs.extend(("open_question", item["ref"]) for item in questions) + refs.extend(("run", item["ref"]) for item in runs) + for kind in ("artifact", "evidence"): + values: List[Any] = state.get(f"{kind}_refs", []) + for raw_ref in values: + try: + refs.append((kind, canonicalize_ref(str(raw_ref)))) + except ValueError: + continue + + diagnostics: Dict[str, Any] = { + "missing_refs": [], + "unsupported_refs": [], + "resolver_warnings": [], + "partial_bundle": False, + } + resolved_summaries: List[Dict[str, Any]] = [] + selected_raw: Dict[str, Any] = {} + selected_refs: set[str] = set() + resolver: Any = self.resolver + if resolver is None: + from .resolver import ( + AgentTaskstateLocalResolver, + ContextRebuildResolver, + MemxResolver, + TrackerResolver, + ) + + resolver = ContextRebuildResolver() + resolver.register_resolver(AgentTaskstateLocalResolver(self.conn)) + resolver.register_resolver(MemxResolver()) + resolver.register_resolver(TrackerResolver()) + ref_values = [ref for _, ref in refs] + try: + report = resolver.resolve_many(ref_values) + diagnostics = resolver.get_diagnostics(report).to_dict() + resolved_summaries = [ + {"ref": item.ref, "summary": item.summary, "metadata": item.metadata or {}} + for item in report.resolved + ] + if include_raw: + for item in report.resolved: + if not item.raw_available: + continue + raw = resolver.load_selected_raw(item.ref) + if raw is not None: + selected_raw[item.ref] = {"content": raw.content, "metadata": raw.metadata} + selected_refs.add(item.ref) + except Exception as exc: + diagnostics["resolver_warnings"].append(f"{type(exc).__name__}: {exc}") + diagnostics["partial_bundle"] = True + + now = now_utc() + bundle_id = gen_id() + build_reason = reason or purpose + decision_refs = [item["ref"] for item in decisions] + question_refs = [item["ref"] for item in questions] + artifact_refs = [ref for kind, ref in refs if kind == "artifact"] + evidence_refs = [ref for kind, ref in refs if kind == "evidence"] + snapshot = { + "task": task, + "task_state": state, + "decisions": decisions, + "open_questions": questions, + "runs": runs, + "resolved_summaries": resolved_summaries, + "selected_raw": selected_raw, + "purpose": purpose, + "rebuild_level": rebuild_level, + } + decision_digest = {"refs": decision_refs, "items": decisions} + question_digest = {"refs": question_refs, "items": questions} + summary = str(state.get("current_summary") or task.get("title") or "context rebuild") + + self.conn.execute( + """ + INSERT INTO context_bundles ( + id, task_id, build_reason, state_snapshot_json, included_decision_refs_json, + included_open_question_refs_json, included_artifact_refs_json, included_evidence_refs_json, + expected_output_schema_json, created_at, metadata_json, purpose, rebuild_level, + summary, decision_digest_json, question_digest_json, diagnostics_json, raw_included, + generator_version, generated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + bundle_id, + task_id, + build_reason, + json.dumps(snapshot, ensure_ascii=False), + json.dumps(decision_refs), + json.dumps(question_refs), + json.dumps(artifact_refs), + json.dumps(evidence_refs), + json.dumps( + { + "summary": "string", + "proposed_actions": ["string"], + "decision_candidates": ["string"], + "question_candidates": ["string"], + "evidence_needed": ["string"], + } + ), + now, + json.dumps({"full_snapshot": True}, ensure_ascii=False), + purpose, + rebuild_level, + summary, + json.dumps(decision_digest, ensure_ascii=False), + json.dumps(question_digest, ensure_ascii=False), + json.dumps(diagnostics, ensure_ascii=False), + 1 if include_raw and selected_raw else 0, + self.generator_version, + now, + ), + ) + sources: List[BundleSource] = [] + for kind, ref in refs: + source_id = gen_id() + selected = ref in selected_refs + self.conn.execute( + """ + INSERT INTO context_bundle_sources + (id, context_bundle_id, typed_ref, source_kind, selected_raw, metadata_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + source_id, + bundle_id, + ref, + kind, + 1 if selected else 0, + json.dumps({"resolver": "summary-first"}, ensure_ascii=False), + now, + ), + ) + sources.append( + BundleSource( + source_id, + bundle_id, + ref, + kind, + selected, + json.dumps({"resolver": "summary-first"}), + ) + ) + bundle = ContextBundle( + id=bundle_id, + task_id=task_id, + purpose=purpose, + rebuild_level=rebuild_level, + summary=summary, + state_snapshot_json=json.dumps(snapshot, ensure_ascii=False), + decision_digest_json=json.dumps(decision_digest, ensure_ascii=False), + question_digest_json=json.dumps(question_digest, ensure_ascii=False), + diagnostics_json=json.dumps(diagnostics, ensure_ascii=False), + raw_included=bool(include_raw and selected_raw), + generator_version=self.generator_version, + generated_at=now, + created_at=now, + sources=sources, + ) + self._latest_cache[task_id] = bundle + return bundle + + def get_latest_bundle(self, task_id: str) -> Optional[ContextBundle]: + if task_id in self._latest_cache: + return self._latest_cache[task_id] + row = self.conn.execute( + "SELECT * FROM context_bundles WHERE task_id = ? ORDER BY generated_at DESC, created_at DESC LIMIT 1", + (task_id,), + ).fetchone() + if row is None: + return None + data = dict(row) + bundle = ContextBundle( + id=data["id"], + task_id=data["task_id"], + purpose=data.get("purpose") or "continue_work", + rebuild_level=data.get("rebuild_level") or "L2", + summary=(str(data.get("summary")) if data.get("summary") is not None else None), + state_snapshot_json=data["state_snapshot_json"], + decision_digest_json=data.get("decision_digest_json"), + question_digest_json=data.get("question_digest_json"), + diagnostics_json=data.get("diagnostics_json"), + raw_included=bool(data.get("raw_included", 0)), + generator_version=data.get("generator_version") or "legacy/1.0.1", + generated_at=data.get("generated_at") or data["created_at"], + created_at=data["created_at"], + ) + sources = self.conn.execute( + "SELECT id, context_bundle_id, typed_ref, source_kind, selected_raw, metadata_json FROM context_bundle_sources WHERE context_bundle_id = ? ORDER BY created_at ASC", + (bundle.id,), + ).fetchall() + bundle.sources = [ + BundleSource( + id=source["id"], + context_bundle_id=source["context_bundle_id"], + typed_ref=source["typed_ref"], + source_kind=source["source_kind"], + selected_raw=bool(source["selected_raw"]), + metadata_json=source["metadata_json"], + ) + for source in sources + ] + self._latest_cache[task_id] = bundle + return bundle diff --git a/src/agent_taskstate/migrations/__init__.py b/src/agent_taskstate/migrations/__init__.py new file mode 100644 index 0000000..0935cee --- /dev/null +++ b/src/agent_taskstate/migrations/__init__.py @@ -0,0 +1,210 @@ +"""Idempotent migrations for the plural 1.1.0 database.""" + +from __future__ import annotations + +import json +import sqlite3 +from datetime import datetime, timezone +from typing import Callable + +from ..typed_ref import canonicalize_ref + +CURRENT_SCHEMA_VERSION = 2 + + +def _now() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ") + + +def _columns(conn: sqlite3.Connection, table: str) -> set[str]: + return {row["name"] for row in conn.execute(f"PRAGMA table_info({table})")} + + +def _add_column(conn: sqlite3.Connection, table: str, name: str, ddl: str) -> None: + if name not in _columns(conn, table): + conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {ddl}") + + +def _migration_001(conn: sqlite3.Connection) -> None: + conn.execute( + "INSERT OR IGNORE INTO schema_version(version, name, applied_at) VALUES (1, ?, ?)", + ("plural-1.0.1", _now()), + ) + + +def _migration_002(conn: sqlite3.Connection) -> None: + """Add history, bundle audit columns, and backfill legacy plural data.""" + conn.execute( + """ + CREATE TABLE IF NOT EXISTS state_transitions ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL, from_status TEXT, + to_status TEXT NOT NULL, reason TEXT NOT NULL DEFAULT '', + actor_type TEXT NOT NULL, actor_id TEXT, run_id TEXT, + changed_at TEXT NOT NULL, + FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_state_transitions_task_changed " + "ON state_transitions(task_id, changed_at ASC, id ASC)" + ) + if "secret_env_json" not in _columns(conn, "tracker_connection"): + _add_column(conn, "tracker_connection", "secret_env_json", "TEXT NOT NULL DEFAULT '{}'") + bundle_columns = { + "purpose": "TEXT NOT NULL DEFAULT 'continue_work'", + "rebuild_level": "TEXT NOT NULL DEFAULT 'L2'", + "summary": "TEXT", + "decision_digest_json": "TEXT", + "question_digest_json": "TEXT", + "diagnostics_json": "TEXT", + "raw_included": "INTEGER NOT NULL DEFAULT 0", + "generator_version": "TEXT NOT NULL DEFAULT 'legacy/1.0.1'", + "generated_at": "TEXT", + "state_snapshot_compressed": "INTEGER NOT NULL DEFAULT 0", + "decision_digest_compressed": "INTEGER NOT NULL DEFAULT 0", + "question_digest_compressed": "INTEGER NOT NULL DEFAULT 0", + "diagnostics_compressed": "INTEGER NOT NULL DEFAULT 0", + } + for name, ddl in bundle_columns.items(): + _add_column(conn, "context_bundles", name, ddl) + conn.execute("UPDATE context_bundles SET generated_at = created_at WHERE generated_at IS NULL") + conn.execute( + """ + UPDATE context_bundles + SET purpose = CASE build_reason + WHEN 'review' THEN 'review_prepare' + WHEN 'recovery' THEN 'resume_after_block' + WHEN 'ambiguity' THEN 'decision_support' + WHEN 'high_risk' THEN 'decision_support' + ELSE 'continue_work' + END, + rebuild_level = 'L1', generator_version = 'legacy/1.0.1', + diagnostics_json = COALESCE(diagnostics_json, ?), raw_included = COALESCE(raw_included, 0) + WHERE diagnostics_json IS NULL + """, + (json.dumps({"legacy_backfill": True}, ensure_ascii=False),), + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS context_bundle_sources ( + id TEXT PRIMARY KEY, context_bundle_id TEXT NOT NULL, + typed_ref TEXT NOT NULL, source_kind TEXT NOT NULL, + selected_raw INTEGER NOT NULL DEFAULT 0, metadata_json TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY(context_bundle_id) REFERENCES context_bundles(id) ON DELETE CASCADE + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_context_bundle_sources_bundle " + "ON context_bundle_sources(context_bundle_id)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_context_bundle_sources_ref " + "ON context_bundle_sources(typed_ref)" + ) + + rows = conn.execute( + """ + SELECT id, created_at, included_decision_refs_json, + included_open_question_refs_json, included_artifact_refs_json, + included_evidence_refs_json + FROM context_bundles + """ + ).fetchall() + for row in rows: + for kind, encoded in ( + ("decision", row["included_decision_refs_json"]), + ("open_question", row["included_open_question_refs_json"]), + ("artifact", row["included_artifact_refs_json"]), + ("evidence", row["included_evidence_refs_json"]), + ): + try: + refs = json.loads(encoded or "[]") + except (TypeError, json.JSONDecodeError): + refs = [] + for raw_ref in refs: + try: + ref = canonicalize_ref(str(raw_ref)) + except ValueError: + continue + if conn.execute( + "SELECT 1 FROM context_bundle_sources WHERE context_bundle_id = ? AND typed_ref = ?", + (row["id"], ref), + ).fetchone(): + continue + conn.execute( + """ + INSERT INTO context_bundle_sources + (id, context_bundle_id, typed_ref, source_kind, selected_raw, metadata_json, created_at) + VALUES (lower(hex(randomblob(16))), ?, ?, ?, 0, ?, ?) + """, + ( + row["id"], + ref, + kind, + json.dumps({"legacy_backfill": True}), + row["created_at"], + ), + ) + + conn.execute( + """ + INSERT INTO state_transitions + (id, task_id, from_status, to_status, reason, actor_type, changed_at) + SELECT lower(hex(randomblob(16))), t.id, NULL, t.status, + 'legacy_backfill', 'system', COALESCE(t.created_at, t.updated_at) + FROM tasks AS t + WHERE NOT EXISTS ( + SELECT 1 FROM state_transitions AS h + WHERE h.task_id = t.id AND h.from_status IS NULL + AND h.to_status = t.status AND h.actor_type = 'system' + AND h.reason = 'legacy_backfill' + ) + """ + ) + conn.execute( + "INSERT OR IGNORE INTO schema_version(version, name, applied_at) VALUES (2, ?, ?)", + ("state-history-bundle-audit-1.1.0", _now()), + ) + + +def run_migrations(conn: sqlite3.Connection) -> None: + """Run migrations in a transaction and make reruns harmless.""" + conn.execute( + """ + CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL + ) + """ + ) + own_transaction = not conn.in_transaction + savepoint = "agent_taskstate_migrations" + if own_transaction: + conn.execute("BEGIN") + else: + conn.execute(f"SAVEPOINT {savepoint}") + try: + applied = {int(row[0]) for row in conn.execute("SELECT version FROM schema_version")} + migrations: list[tuple[int, Callable[[sqlite3.Connection], None]]] = [ + (1, _migration_001), + (2, _migration_002), + ] + for version, migration in migrations: + if version not in applied: + migration(conn) + if own_transaction: + conn.commit() + else: + conn.execute(f"RELEASE SAVEPOINT {savepoint}") + except Exception: + if own_transaction: + conn.rollback() + else: + conn.execute(f"ROLLBACK TO SAVEPOINT {savepoint}") + conn.execute(f"RELEASE SAVEPOINT {savepoint}") + raise + + +__all__ = ["CURRENT_SCHEMA_VERSION", "run_migrations"] diff --git a/src/agent_taskstate/py.typed b/src/agent_taskstate/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/agent_taskstate/resolver.py b/src/agent_taskstate/resolver.py new file mode 100644 index 0000000..cdb4b4d --- /dev/null +++ b/src/agent_taskstate/resolver.py @@ -0,0 +1,413 @@ +"""Typed-reference context rebuild resolvers.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Callable, Dict, List, Optional, Protocol + +from .typed_ref import canonicalize_ref, parse_ref + + +class ResolveStatus(Enum): + RESOLVED = "resolved" + UNRESOLVED = "unresolved" + UNSUPPORTED = "unsupported" + + +@dataclass +class ResolvedRef: + ref: str + status: ResolveStatus + summary: Optional[str] = None + metadata: Optional[Dict[str, Any]] = None + raw_available: bool = False + error_message: Optional[str] = None + + +@dataclass +class ResolveReport: + resolved: List[ResolvedRef] = field(default_factory=list) + unresolved: List[ResolvedRef] = field(default_factory=list) + unsupported: List[ResolvedRef] = field(default_factory=list) + + @property + def total_count(self) -> int: + return len(self.resolved) + len(self.unresolved) + len(self.unsupported) + + @property + def success_rate(self) -> float: + return len(self.resolved) / self.total_count if self.total_count else 1.0 + + +@dataclass +class SummaryPayload: + ref: str + summary: str + metadata: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class RawPayload: + ref: str + content: str + metadata: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class ResolverDiagnostics: + missing_refs: List[str] = field(default_factory=list) + unsupported_refs: List[str] = field(default_factory=list) + resolver_warnings: List[str] = field(default_factory=list) + partial_bundle: bool = False + + def to_dict(self) -> Dict[str, Any]: + return { + "missing_refs": self.missing_refs, + "unsupported_refs": self.unsupported_refs, + "resolver_warnings": self.resolver_warnings, + "partial_bundle": self.partial_bundle, + } + + +RAW_DESCENT_CONDITIONS = { + "before_review", + "conflicting_summaries", + "high_priority_open_question", + "low_confidence_decision", + "investigation_step", + "verification_step", + "operator_request", +} + + +class RefResolver(Protocol): + def can_resolve(self, ref: str) -> bool: ... + def resolve(self, ref: str) -> ResolvedRef: ... + def load_summary(self, ref: str) -> Optional[SummaryPayload]: ... + def load_raw( + self, ref: str, selector: Optional[Dict[str, Any]] = None + ) -> Optional[RawPayload]: ... + + +class ContextRebuildResolver: + def __init__(self) -> None: + self._resolvers: List[RefResolver] = [] + + def register_resolver(self, resolver: RefResolver) -> None: + self._resolvers.append(resolver) + + def resolve_ref(self, ref: str) -> ResolvedRef: + try: + canonical = canonicalize_ref(ref) + parsed = parse_ref(canonical) + except ValueError as exc: + return ResolvedRef(ref=ref, status=ResolveStatus.UNSUPPORTED, error_message=str(exc)) + for resolver in self._resolvers: + if resolver.can_resolve(canonical): + try: + result = resolver.resolve(canonical) + except Exception as exc: # diagnostics are part of the contract + return ResolvedRef( + ref=canonical, + status=ResolveStatus.UNRESOLVED, + error_message=f"{type(exc).__name__}: {exc}", + ) + result.ref = canonical + return result + return ResolvedRef( + ref=canonical, + status=ResolveStatus.UNSUPPORTED, + error_message=f"No resolver for domain: {parsed.domain}", + ) + + def resolve_many(self, refs: List[str]) -> ResolveReport: + report = ResolveReport() + for ref in refs: + result = self.resolve_ref(ref) + if result.status is ResolveStatus.RESOLVED: + report.resolved.append(result) + elif result.status is ResolveStatus.UNRESOLVED: + report.unresolved.append(result) + else: + report.unsupported.append(result) + return report + + def load_summary(self, ref: str) -> Optional[SummaryPayload]: + result = self.resolve_ref(ref) + if result.status is ResolveStatus.RESOLVED and result.summary is not None: + return SummaryPayload(result.ref, result.summary, result.metadata or {}) + return None + + def load_selected_raw( + self, ref: str, selector: Optional[Dict[str, Any]] = None + ) -> Optional[RawPayload]: + canonical = canonicalize_ref(ref) + for resolver in self._resolvers: + if resolver.can_resolve(canonical): + try: + return resolver.load_raw(canonical, selector) + except Exception: + return None + return None + + def should_include_raw( + self, condition: str, task_context: Optional[Dict[str, Any]] = None + ) -> bool: + if condition not in RAW_DESCENT_CONDITIONS: + return False + if condition == "operator_request": + return True + if task_context and condition == "high_priority_open_question": + return bool(task_context.get("has_high_priority_questions")) + if task_context and condition == "low_confidence_decision": + return bool(task_context.get("has_low_confidence_decisions")) + return True + + def get_diagnostics(self, report: ResolveReport) -> ResolverDiagnostics: + diagnostics = ResolverDiagnostics() + for item in report.unresolved: + diagnostics.missing_refs.append(item.ref) + if item.error_message: + diagnostics.resolver_warnings.append(f"{item.ref}: {item.error_message}") + for item in report.unsupported: + diagnostics.unsupported_refs.append(item.ref) + if item.error_message: + diagnostics.resolver_warnings.append(f"{item.ref}: {item.error_message}") + diagnostics.partial_bundle = bool(report.unresolved or report.unsupported) + return diagnostics + + +def _has_table(conn: Any, name: str) -> bool: + return bool( + conn + and conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,) + ).fetchone() + ) + + +class AgentTaskstateLocalResolver: + """Resolve local task, decision, question, run, and bundle refs.""" + + def __init__(self, conn: Any = None): + self.conn = conn + + def can_resolve(self, ref: str) -> bool: + try: + p = parse_ref(ref) + return p.domain == "agent-taskstate" and p.provider == "local" + except ValueError: + return False + + def resolve(self, ref: str) -> ResolvedRef: + p = parse_ref(ref) + if self.conn is None: + return ResolvedRef( + ref=ref, status=ResolveStatus.UNRESOLVED, error_message="No database connection" + ) + table_map = { + "task": ("tasks", "title", "status"), + "decision": ("decisions", "summary", "status"), + "question": ("open_questions", "question", "status"), + "run": ("runs", "run_type", "status"), + "context_bundle": ("context_bundles", "summary", "purpose"), + } + entity = table_map.get(p.entity_type) + if entity is None: + return ResolvedRef( + ref=ref, + status=ResolveStatus.UNSUPPORTED, + error_message=f"Unknown entity type: {p.entity_type}", + ) + table, summary_col, status_col = entity + if not _has_table(self.conn, table): + legacy = { + "tasks": "task", + "decisions": "decision", + "open_questions": "open_question", + "runs": "run", + "context_bundles": "context_bundle", + }[table] + table = legacy + row = self.conn.execute( + f"SELECT * FROM {table} WHERE id = ?" + if p.entity_type != "task" + else f"SELECT * FROM {table} WHERE id = ?", + (p.entity_id,), + ).fetchone() + if not row: + return ResolvedRef( + ref=ref, + status=ResolveStatus.UNRESOLVED, + error_message=f"{p.entity_type} not found: {p.entity_id}", + ) + keys = set(row.keys()) if hasattr(row, "keys") else set() + summary = row[summary_col] if summary_col in keys else row[summary_col] + status = row[status_col] if status_col in keys else None + return ResolvedRef( + ref=ref, + status=ResolveStatus.RESOLVED, + summary=summary or p.entity_type, + metadata={"status": status} if status is not None else {}, + raw_available=True, + ) + + def load_summary(self, ref: str) -> Optional[SummaryPayload]: + result = self.resolve(ref) + return ( + SummaryPayload(result.ref, result.summary, result.metadata or {}) + if result.status is ResolveStatus.RESOLVED and result.summary + else None + ) + + def load_raw(self, ref: str, selector: Optional[Dict[str, Any]] = None) -> Optional[RawPayload]: + result = self.resolve(ref) + if result.status is not ResolveStatus.RESOLVED: + return None + return RawPayload(result.ref, result.summary or "", result.metadata or {}) + + +class TrackerResolver: + def __init__(self, adapters: Optional[Dict[str, Any]] = None): + self._adapters = adapters or {} + + def register_adapter(self, provider: str, adapter: Any) -> None: + self._adapters[provider] = adapter + + def can_resolve(self, ref: str) -> bool: + try: + p = parse_ref(ref) + return p.domain == "tracker" and p.entity_type == "issue" + except ValueError: + return False + + def resolve(self, ref: str) -> ResolvedRef: + p = parse_ref(ref) + adapter = self._adapters.get(p.provider) + if adapter is None: + return ResolvedRef( + ref=ref, + status=ResolveStatus.UNSUPPORTED, + error_message=f"No adapter for tracker provider: {p.provider}", + ) + try: + raw = adapter.fetch_issue(p.entity_id) + if not raw: + return ResolvedRef( + ref=ref, + status=ResolveStatus.UNRESOLVED, + error_message=f"Issue not found: {p.entity_id}", + ) + normalized = adapter.normalize_issue(raw) + return ResolvedRef( + ref=ref, + status=ResolveStatus.RESOLVED, + summary=normalized.get("title", ""), + metadata=normalized, + raw_available=True, + ) + except Exception as exc: + return ResolvedRef( + ref=ref, + status=ResolveStatus.UNRESOLVED, + error_message=f"{type(exc).__name__}: {exc}", + ) + + def load_summary(self, ref: str) -> Optional[SummaryPayload]: + result = self.resolve(ref) + return ( + SummaryPayload(result.ref, result.summary, result.metadata or {}) + if result.status is ResolveStatus.RESOLVED and result.summary + else None + ) + + def load_raw(self, ref: str, selector: Optional[Dict[str, Any]] = None) -> Optional[RawPayload]: + p = parse_ref(ref) + adapter = self._adapters.get(p.provider) + if adapter is None: + return None + raw = adapter.fetch_issue(p.entity_id) + return ( + RawPayload(ref, json.dumps(raw, ensure_ascii=False), {"provider": p.provider}) + if raw + else None + ) + + +class MemxResolver: + def __init__( + self, + fetch_evidence: Optional[Callable[[str], Optional[Dict[str, Any]]]] = None, + fetch_knowledge: Optional[Callable[[str], Optional[Dict[str, Any]]]] = None, + fetch_chunk: Optional[Callable[[str], Optional[Dict[str, Any]]]] = None, + ): + self._fetchers = { + "evidence": fetch_evidence, + "knowledge": fetch_knowledge, + "chunk": fetch_chunk, + } + + def can_resolve(self, ref: str) -> bool: + try: + return parse_ref(ref).domain == "memx" + except ValueError: + return False + + def resolve(self, ref: str) -> ResolvedRef: + p = parse_ref(ref) + fetcher = self._fetchers.get(p.entity_type) + if fetcher is None: + return ResolvedRef( + ref=ref, + status=ResolveStatus.UNSUPPORTED, + error_message=f"Unsupported memx entity type: {p.entity_type}", + ) + if fetcher is None: + return ResolvedRef( + ref=ref, + status=ResolveStatus.UNSUPPORTED, + error_message="memx adapter is not configured", + ) + try: + data = fetcher(p.entity_id) + except Exception as exc: + return ResolvedRef( + ref=ref, + status=ResolveStatus.UNRESOLVED, + error_message=f"{type(exc).__name__}: {exc}", + ) + if not data: + return ResolvedRef( + ref=ref, + status=ResolveStatus.UNRESOLVED, + error_message=f"{p.entity_type} not found: {p.entity_id}", + ) + return ResolvedRef( + ref=ref, + status=ResolveStatus.RESOLVED, + summary=data.get("summary", data.get("title", "")), + metadata=data, + raw_available=True, + ) + + def load_summary(self, ref: str) -> Optional[SummaryPayload]: + result = self.resolve(ref) + return ( + SummaryPayload(result.ref, result.summary, result.metadata or {}) + if result.status is ResolveStatus.RESOLVED and result.summary + else None + ) + + def load_raw(self, ref: str, selector: Optional[Dict[str, Any]] = None) -> Optional[RawPayload]: + p = parse_ref(ref) + fetcher = self._fetchers.get(p.entity_type) + if fetcher is None: + return None + data = fetcher(p.entity_id) + return ( + RawPayload(ref, json.dumps(data, ensure_ascii=False), {"entity_type": p.entity_type}) + if data + else None + ) diff --git a/src/agent_taskstate/state_transition.py b/src/agent_taskstate/state_transition.py new file mode 100644 index 0000000..67a483a --- /dev/null +++ b/src/agent_taskstate/state_transition.py @@ -0,0 +1,285 @@ +"""Append-only task status transitions. + +The service prefers the 1.1.0 plural tables and retains a small compatibility +path for callers that still construct the old singular test schema. +""" + +from __future__ import annotations + +import sqlite3 +import uuid +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import List, Optional + +from .transition_config import DEFAULT_CONFIG, TransitionConfig + +ALLOWED_TRANSITIONS = DEFAULT_CONFIG.allowed_transitions +TERMINAL_STATES = DEFAULT_CONFIG.terminal_states +ACTOR_TYPES = DEFAULT_CONFIG.actor_types + + +@dataclass +class StateTransition: + id: str + task_id: str + from_status: Optional[str] + to_status: str + reason: str + actor_type: str + actor_id: Optional[str] + run_id: Optional[str] + changed_at: str + + +class InvalidTransitionError(Exception): + def __init__(self, from_status: str, to_status: str): + self.from_status = from_status + self.to_status = to_status + super().__init__(f"Invalid transition: {from_status} -> {to_status}") + + +class TerminalStateError(Exception): + def __init__(self, status: str): + self.status = status + super().__init__(f"Cannot transition from terminal state: {status}") + + +class MissingReasonError(Exception): + def __init__(self, transition: str): + self.transition = transition + super().__init__(f"Reason required for transition: {transition}") + + +def now_utc() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ") + + +def gen_id() -> str: + return uuid.uuid4().hex + + +def can_transition(from_status: str, to_status: str) -> bool: + return to_status in ALLOWED_TRANSITIONS.get(from_status, set()) + + +def is_terminal(status: str) -> bool: + return status in TERMINAL_STATES + + +def requires_reason(from_status: str, to_status: str) -> bool: + if from_status == "done" and to_status == "in_progress": + return True + if to_status in TERMINAL_STATES: + return True + return False + + +def _table_exists(conn: sqlite3.Connection, table: str) -> bool: + return ( + conn.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", (table,) + ).fetchone() + is not None + ) + + +class StateTransitionService: + """Perform status updates and history inserts in the caller's transaction.""" + + def __init__(self, conn: sqlite3.Connection, config: Optional[TransitionConfig] = None): + self.conn = conn + self.config = config or DEFAULT_CONFIG + + @property + def allowed_transitions(self) -> dict: + return self.config.allowed_transitions + + @property + def terminal_states(self) -> set: + return self.config.terminal_states + + @property + def actor_types(self) -> set: + return self.config.actor_types + + def can_transition(self, from_status: str, to_status: str) -> bool: + return self.config.can_transition(from_status, to_status) + + def is_terminal(self, status: str) -> bool: + return self.config.is_terminal(status) + + def requires_reason(self, from_status: str, to_status: str) -> bool: + return self.config.requires_reason(from_status, to_status) + + def _plural(self) -> bool: + return _table_exists(self.conn, "tasks") + + def transition( + self, + task_id: str, + to_status: str, + reason: str = "", + actor_type: str = "system", + actor_id: Optional[str] = None, + run_id: Optional[str] = None, + ) -> Optional[StateTransition]: + if not self.config.is_valid_actor(actor_type): + raise ValueError(f"Invalid actor_type: {actor_type}") + + plural = self._plural() + task_table = "tasks" if plural else "task" + history_table = "state_transitions" if plural else "task_state" + from_column = "from_status" if plural else "from_state" + to_column = "to_status" if plural else "to_state" + row = self.conn.execute( + f"SELECT status FROM {task_table} WHERE id = ?", (task_id,) + ).fetchone() + if not row: + raise ValueError(f"Task not found: {task_id}") + from_status = row[0] + + # A repeated status is an intentional successful no-op. + if from_status == to_status: + return None + if not self.can_transition(from_status, to_status): + if self.is_terminal(from_status): + raise TerminalStateError(from_status) + raise InvalidTransitionError(from_status, to_status) + if self.requires_reason(from_status, to_status) and not reason: + raise MissingReasonError(f"{from_status} -> {to_status}") + + transition_id = gen_id() + changed_at = now_utc() + self.conn.execute( + f""" + INSERT INTO {history_table} + (id, task_id, {from_column}, {to_column}, reason, actor_type, actor_id, run_id, changed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + transition_id, + task_id, + from_status, + to_status, + reason or "", + actor_type, + actor_id, + run_id, + changed_at, + ), + ) + self.conn.execute( + f"UPDATE {task_table} SET status = ?, updated_at = ? WHERE id = ?", + (to_status, changed_at, task_id), + ) + return StateTransition( + id=transition_id, + task_id=task_id, + from_status=from_status, + to_status=to_status, + reason=reason or "", + actor_type=actor_type, + actor_id=actor_id, + run_id=run_id, + changed_at=changed_at, + ) + + def record_initial( + self, task_id: str, status: str, changed_at: Optional[str] = None + ) -> StateTransition: + """Record the task-create transition with a NULL source status.""" + if not self._plural(): + raise ValueError("record_initial requires the plural schema") + exists = self.conn.execute( + "SELECT id FROM state_transitions WHERE task_id = ? AND from_status IS NULL", + (task_id,), + ).fetchone() + if exists: + return self.get_history(task_id)[0] + transition = StateTransition( + id=gen_id(), + task_id=task_id, + from_status=None, + to_status=status, + reason="task_created", + actor_type="system", + actor_id=None, + run_id=None, + changed_at=changed_at or now_utc(), + ) + self.conn.execute( + """ + INSERT INTO state_transitions + (id, task_id, from_status, to_status, reason, actor_type, changed_at) + VALUES (?, ?, NULL, ?, ?, 'system', ?) + """, + (transition.id, task_id, status, transition.reason, transition.changed_at), + ) + return transition + + def get_history(self, task_id: str) -> List[StateTransition]: + plural = self._plural() + table = "state_transitions" if plural else "task_state" + from_column = "from_status" if plural else "from_state" + to_column = "to_status" if plural else "to_state" + rows = self.conn.execute( + f""" + SELECT id, task_id, {from_column}, {to_column}, reason, actor_type, actor_id, run_id, changed_at + FROM {table} WHERE task_id = ? ORDER BY changed_at ASC, id ASC + """, + (task_id,), + ).fetchall() + return [ + StateTransition( + id=row[0], + task_id=row[1], + from_status=row[2], + to_status=row[3], + reason=row[4], + actor_type=row[5], + actor_id=row[6], + run_id=row[7], + changed_at=row[8], + ) + for row in rows + ] + + def get_current_state(self, task_id: str) -> Optional[str]: + table = "tasks" if self._plural() else "task" + row = self.conn.execute(f"SELECT status FROM {table} WHERE id = ?", (task_id,)).fetchone() + return row[0] if row else None + + +def create_transition_table(conn: sqlite3.Connection) -> None: + """Create the canonical history table, or the legacy test table if needed.""" + if _table_exists(conn, "tasks"): + conn.execute( + """ + CREATE TABLE IF NOT EXISTS state_transitions ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL, from_status TEXT, + to_status TEXT NOT NULL, reason TEXT NOT NULL DEFAULT '', + actor_type TEXT NOT NULL, actor_id TEXT, run_id TEXT, changed_at TEXT NOT NULL, + FOREIGN KEY(task_id) REFERENCES tasks(id) + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_state_transitions_task_changed " + "ON state_transitions(task_id, changed_at ASC, id ASC)" + ) + else: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS task_state ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL, from_state TEXT, + to_state TEXT NOT NULL, reason TEXT NOT NULL, actor_type TEXT NOT NULL, + actor_id TEXT, run_id TEXT, changed_at TEXT NOT NULL, + FOREIGN KEY(task_id) REFERENCES task(id) + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_task_state_task_changed " + "ON task_state(task_id, changed_at DESC)" + ) diff --git a/src/agent_taskstate/tracker_bridge.py b/src/agent_taskstate/tracker_bridge.py new file mode 100644 index 0000000..4eaffeb --- /dev/null +++ b/src/agent_taskstate/tracker_bridge.py @@ -0,0 +1,1010 @@ +""" +Tracker Bridge Module + +Provides minimum integration with external issue trackers. +Tracker is auxiliary - not the source of truth for internal state. + +Features: +- Issue fetch and cache +- Entity linking (tracker:issue <-> agent-taskstate:task) +- Sync event tracking +- Snapshot export for context build +- Outbound status/comment reflection +""" + +from __future__ import annotations + +import json +import sqlite3 +from dataclasses import dataclass +from datetime import datetime, timezone +from enum import Enum +from typing import Any, Dict, List, Optional, Protocol + +from .typed_ref import canonicalize_ref, format_ref, parse_ref + + +class SyncDirection(Enum): + """Direction of sync.""" + + INBOUND = "inbound" + OUTBOUND = "outbound" + + +class SyncStatus(Enum): + """Status of sync operation.""" + + SUCCESS = "success" + FAILED = "failed" + PARTIAL = "partial" + + +class LinkRole(Enum): + """Role of entity link.""" + + PRIMARY = "primary" + RELATED = "related" + DUPLICATE = "duplicate" + BLOCKS = "blocks" + + +@dataclass +class TrackerConnection: + """Connection configuration for a tracker.""" + + id: str + provider: str + name: str + config_json: str + secret_env_json: str + created_at: str + updated_at: str + + +@dataclass +class IssueCache: + """Cached issue from external tracker.""" + + id: str + connection_id: str + issue_ref: str + remote_key: str + title: str + status: str + assignee: Optional[str] + description: Optional[str] + labels_json: Optional[str] + raw_json: Optional[str] + fetched_at: str + updated_at: str + + +@dataclass +class EntityLink: + """Link between tracker issue and agent-taskstate entity.""" + + id: str + tracker_issue_ref: str + agent_taskstate_entity_ref: str + role: str + created_at: str + + +@dataclass +class SyncEvent: + """Record of sync operation.""" + + id: str + connection_id: str + direction: str + status: str + issue_ref: Optional[str] + details_json: Optional[str] + error_message: Optional[str] + created_at: str + + +@dataclass +class IssueSnapshot: + """Minimal snapshot for context build.""" + + issue_ref: str + remote_key: str + title: str + status: str + assignee: Optional[str] + updated_at: str + last_sync_result: str + + def to_dict(self) -> Dict[str, Any]: + return { + "issue_ref": self.issue_ref, + "remote_key": self.remote_key, + "title": self.title, + "status": self.status, + "assignee": self.assignee, + "updated_at": self.updated_at, + "last_sync_result": self.last_sync_result, + } + + +@dataclass +class SyncSuggestion: + """Suggestion for agent-taskstate update based on tracker change.""" + + issue_ref: str + agent_taskstate_task_ref: str + suggested_action: str + suggested_value: str + reason: str + requires_confirmation: bool = True + + +def now_utc() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ") + + +def gen_id() -> str: + import uuid + + return uuid.uuid4().hex + + +class TrackerAdapter(Protocol): + """Protocol for tracker adapters.""" + + def fetch_issue(self, issue_key: str) -> Optional[Dict[str, Any]]: ... + + def normalize_issue(self, raw: Dict[str, Any]) -> Dict[str, Any]: ... + + def post_comment(self, issue_key: str, comment: str) -> bool: ... + + def update_status(self, issue_key: str, status: str) -> bool: ... + + +class MockTrackerAdapter: + """Mock adapter for testing.""" + + def __init__(self, issues: Optional[Dict[str, Dict[str, Any]]] = None): + self.issues = issues or {} + + def fetch_issue(self, issue_key: str) -> Optional[Dict[str, Any]]: + return self.issues.get(issue_key) + + def normalize_issue(self, raw: Dict[str, Any]) -> Dict[str, Any]: + return { + "remote_key": raw.get("key"), + "title": raw.get("summary", ""), + "status": raw.get("status", "unknown"), + "assignee": raw.get("assignee"), + "description": raw.get("description"), + } + + def post_comment(self, issue_key: str, comment: str) -> bool: + return True + + def update_status(self, issue_key: str, status: str) -> bool: + return True + + +class GitHubAdapter: + """ + GitHub Issues adapter using PyGithub. + + Requires: PyGithub package (pip install PyGithub) + + Config: + - token: GitHub personal access token + - repo_owner: Repository owner + - repo_name: Repository name + """ + + def __init__(self, token: str, repo_owner: str, repo_name: str): + self._token = token + self._repo_owner = repo_owner + self._repo_name = repo_name + self._repo = None + + def _get_repo(self): + """Lazy-load repository.""" + if self._repo is None: + try: + from github import Github + + gh = Github(self._token) + self._repo = gh.get_repo(f"{self._repo_owner}/{self._repo_name}") + except ImportError: + raise RuntimeError("PyGithub not installed. Run: pip install PyGithub") + return self._repo + + def parse_issue_key(self, issue_key: str) -> int: + """Parse issue number from key (e.g., 'owner/repo#123' or '#123').""" + if "#" in issue_key: + return int(issue_key.split("#")[-1]) + return int(issue_key) + + def fetch_issue(self, issue_key: str) -> Optional[Dict[str, Any]]: + """Fetch issue from GitHub.""" + try: + repo = self._get_repo() + issue_num = self.parse_issue_key(issue_key) + issue = repo.get_issue(issue_num) + return { + "key": f"{self._repo_owner}/{self._repo_name}#{issue_num}", + "number": issue_num, + "title": issue.title, + "body": issue.body or "", + "state": issue.state, + "assignee": issue.assignee.login if issue.assignee else None, + "labels": [label.name for label in issue.labels], + "created_at": issue.created_at.isoformat(), + "updated_at": issue.updated_at.isoformat(), + "url": issue.html_url, + } + except Exception: + return None + + def normalize_issue(self, raw: Dict[str, Any]) -> Dict[str, Any]: + """Normalize GitHub issue to standard format.""" + labels = raw.get("labels", []) + if isinstance(labels, str): + labels = [labels] + return { + "remote_key": raw.get("key"), + "title": raw.get("title", ""), + "status": raw.get("state", "open"), + "assignee": raw.get("assignee"), + "description": raw.get("body"), + "labels": labels, + "url": raw.get("url"), + } + + def post_comment(self, issue_key: str, comment: str) -> bool: + """Post comment to GitHub issue.""" + try: + repo = self._get_repo() + issue_num = self.parse_issue_key(issue_key) + issue = repo.get_issue(issue_num) + issue.create_comment(comment) + return True + except Exception: + return False + + def update_status(self, issue_key: str, status: str) -> bool: + """Update GitHub issue status (open/closed).""" + try: + repo = self._get_repo() + issue_num = self.parse_issue_key(issue_key) + issue = repo.get_issue(issue_num) + if status.lower() == "closed": + issue.edit(state="closed") + elif status.lower() == "open": + issue.edit(state="open") + return True + except Exception: + return False + + +class JiraAdapter: + """ + Jira adapter using jira library. + + Requires: jira package (pip install jira) + + Config: + - server: Jira server URL + - username: Jira username/email + - password: Jira password or API token + """ + + def __init__(self, server: str, username: str, password: str): + self._server = server + self._username = username + self._password = password + self._jira = None + + def _get_jira(self): + """Lazy-load Jira client.""" + if self._jira is None: + try: + from jira import JIRA + + self._jira = JIRA( + server=self._server, + basic_auth=(self._username, self._password), + ) + except ImportError: + raise RuntimeError("jira not installed. Run: pip install jira") + return self._jira + + def fetch_issue(self, issue_key: str) -> Optional[Dict[str, Any]]: + """Fetch issue from Jira.""" + try: + jira = self._get_jira() + issue = jira.issue(issue_key) + return { + "key": issue.key, + "summary": issue.fields.summary, + "description": issue.fields.description or "", + "status": issue.fields.status.name, + "assignee": issue.fields.assignee.displayName if issue.fields.assignee else None, + "priority": issue.fields.priority.name if issue.fields.priority else None, + "labels": [label.name for label in issue.fields.labels], + "created_at": issue.fields.created.isoformat() if issue.fields.created else None, + "updated_at": issue.fields.updated.isoformat() if issue.fields.updated else None, + "url": f"{self._server}/browse/{issue.key}", + } + except Exception: + return None + + def normalize_issue(self, raw: Dict[str, Any]) -> Dict[str, Any]: + """Normalize Jira issue to standard format.""" + labels = raw.get("labels", []) + if isinstance(labels, str): + labels = [labels] + return { + "remote_key": raw.get("key"), + "title": raw.get("summary", ""), + "status": raw.get("status", "unknown"), + "assignee": raw.get("assignee"), + "description": raw.get("description"), + "priority": raw.get("priority"), + "labels": labels, + "url": raw.get("url"), + } + + def post_comment(self, issue_key: str, comment: str) -> bool: + """Post comment to Jira issue.""" + try: + jira = self._get_jira() + issue = jira.issue(issue_key) + jira.add_comment(issue, comment) + return True + except Exception: + return False + + def update_status(self, issue_key: str, status: str) -> bool: + """Update Jira issue status (transition).""" + try: + jira = self._get_jira() + issue = jira.issue(issue_key) + transitions = jira.transitions(issue) + for t in transitions: + if t["name"].lower() == status.lower(): + jira.transition_issue(issue, t["id"]) + return True + return False + except Exception: + return False + + +class TrackerBridgeService: + """Service for tracker-bridge integration.""" + + def __init__(self, conn: sqlite3.Connection): + self.conn = conn + self._adapters: Dict[str, TrackerAdapter] = {} + + def register_adapter(self, provider: str, adapter: TrackerAdapter) -> None: + """Register an adapter for a provider.""" + self._adapters[provider] = adapter + + def _normalize_issue_ref(self, issue_ref: str) -> str: + """Canonicalize and validate a tracker issue reference.""" + try: + canonical_ref = canonicalize_ref(issue_ref) + parsed = parse_ref(canonical_ref) + except ValueError as exc: + raise ValueError("issue_ref must be a tracker issue typed_ref") from exc + if parsed.domain != "tracker" or parsed.entity_type != "issue": + raise ValueError("issue_ref must be a tracker issue typed_ref") + return canonical_ref + + def _normalize_task_ref(self, task_ref: str) -> str: + """Canonicalize and validate an agent-taskstate task reference.""" + try: + canonical_ref = canonicalize_ref(task_ref) + parsed = parse_ref(canonical_ref) + except ValueError as exc: + raise ValueError("task_ref must be an agent-taskstate local task typed_ref") from exc + if ( + parsed.domain != "agent-taskstate" + or parsed.entity_type != "task" + or parsed.provider != "local" + ): + raise ValueError("task_ref must be an agent-taskstate local task typed_ref") + return canonical_ref + + def _normalize_link_role(self, role: str) -> str: + """Validate and normalize link role values.""" + if isinstance(role, LinkRole): + return role.value + try: + return LinkRole(role).value + except ValueError as exc: + raise ValueError(f"Invalid link role: {role}") from exc + + def create_connection( + self, + provider: str, + name: str, + config: Dict[str, Any], + secret_env: Optional[Dict[str, str]] = None, + ) -> TrackerConnection: + """Create a connection without persisting secret values.""" + if _contains_secret_key(config): + raise ValueError("secret values must not be stored; use secret_env") + secret_env = secret_env or {} + if any( + not isinstance(key, str) or not isinstance(value, str) + for key, value in secret_env.items() + ): + raise ValueError("secret_env must map names to environment variable names") + conn_id = gen_id() + now = now_utc() + config_json = json.dumps(_redact(config), ensure_ascii=False) + secret_env_json = json.dumps(secret_env, ensure_ascii=False) + self.conn.execute( + """ + INSERT INTO tracker_connection + (id, provider, name, config_json, secret_env_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (conn_id, provider, name, config_json, secret_env_json, now, now), + ) + return TrackerConnection( + id=conn_id, + provider=provider, + name=name, + config_json=config_json, + secret_env_json=secret_env_json, + created_at=now, + updated_at=now, + ) + + def fetch_issue( + self, + connection_id: str, + issue_key: str, + ) -> Optional[IssueCache]: + """Fetch an issue from external tracker and cache it.""" + cursor = self.conn.execute( + "SELECT id, provider FROM tracker_connection WHERE id = ?", + (connection_id,), + ) + row = cursor.fetchone() + if not row: + return None + + provider = row["provider"] + adapter = self._adapters.get(provider) + if not adapter: + self._record_sync_event( + connection_id=connection_id, + direction=SyncDirection.INBOUND, + status=SyncStatus.FAILED, + issue_ref=format_ref("tracker", "issue", issue_key, provider), + details={"action": "fetch", "issue_key": issue_key}, + error_message=f"No adapter registered for provider: {provider}", + ) + return None + + try: + raw = adapter.fetch_issue(issue_key) + except Exception as exc: + self._record_sync_event( + connection_id=connection_id, + direction=SyncDirection.INBOUND, + status=SyncStatus.FAILED, + issue_ref=format_ref("tracker", "issue", issue_key, provider), + details={"action": "fetch", "error_class": classify_adapter_exception(exc)}, + error_message=f"adapter error ({classify_adapter_exception(exc)})", + ) + return None + if not raw: + self._record_sync_event( + connection_id=connection_id, + direction=SyncDirection.INBOUND, + status=SyncStatus.FAILED, + issue_ref=format_ref("tracker", "issue", issue_key, provider), + details={"action": "fetch", "issue_key": issue_key}, + error_message=f"Issue not found: {issue_key}", + ) + return None + + normalized = adapter.normalize_issue(raw) + remote_key = normalized["remote_key"] + issue_ref = format_ref("tracker", "issue", remote_key, provider) + cache_id = gen_id() + now = now_utc() + + self.conn.execute( + """ + INSERT OR REPLACE INTO issue_cache + (id, connection_id, issue_ref, remote_key, title, status, + assignee, description, labels_json, raw_json, fetched_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + cache_id, + connection_id, + issue_ref, + remote_key, + normalized["title"], + normalized["status"], + normalized.get("assignee"), + normalized.get("description"), + json.dumps(normalized.get("labels", [])), + json.dumps(raw), + now, + now, + ), + ) + + self._record_sync_event( + connection_id=connection_id, + direction=SyncDirection.INBOUND, + status=SyncStatus.SUCCESS, + issue_ref=issue_ref, + details={"action": "fetch", "issue_key": issue_key}, + ) + + return IssueCache( + id=cache_id, + connection_id=connection_id, + issue_ref=issue_ref, + remote_key=remote_key, + title=normalized["title"], + status=normalized["status"], + assignee=normalized.get("assignee"), + description=normalized.get("description"), + labels_json=json.dumps(normalized.get("labels", [])), + raw_json=json.dumps(raw), + fetched_at=now, + updated_at=now, + ) + + def link_issue_to_task( + self, + issue_ref: str, + task_ref: str, + role: str = "primary", + ) -> EntityLink: + """Link a tracker issue to an agent-taskstate task.""" + issue_ref = self._normalize_issue_ref(issue_ref) + task_ref = self._normalize_task_ref(task_ref) + role = self._normalize_link_role(role) + link_id = gen_id() + now = now_utc() + + self.conn.execute( + """ + INSERT INTO entity_link + (id, tracker_issue_ref, agent_taskstate_entity_ref, role, created_at) + VALUES (?, ?, ?, ?, ?) + """, + (link_id, issue_ref, task_ref, role, now), + ) + + return EntityLink( + id=link_id, + tracker_issue_ref=issue_ref, + agent_taskstate_entity_ref=task_ref, + role=role, + created_at=now, + ) + + def get_issue_links(self, issue_ref: str) -> List[EntityLink]: + """Get all links for an issue.""" + issue_ref = self._normalize_issue_ref(issue_ref) + cursor = self.conn.execute( + """ + SELECT id, tracker_issue_ref, agent_taskstate_entity_ref, role, created_at + FROM entity_link + WHERE tracker_issue_ref = ? + """, + (issue_ref,), + ) + + links = [] + for row in cursor.fetchall(): + links.append( + EntityLink( + id=row[0], + tracker_issue_ref=row[1], + agent_taskstate_entity_ref=row[2], + role=row[3], + created_at=row[4], + ) + ) + return links + + def get_task_links(self, task_ref: str) -> List[EntityLink]: + """Get all tracker links for a task.""" + task_ref = self._normalize_task_ref(task_ref) + cursor = self.conn.execute( + """ + SELECT id, tracker_issue_ref, agent_taskstate_entity_ref, role, created_at + FROM entity_link + WHERE agent_taskstate_entity_ref = ? + """, + (task_ref,), + ) + + links = [] + for row in cursor.fetchall(): + links.append( + EntityLink( + id=row[0], + tracker_issue_ref=row[1], + agent_taskstate_entity_ref=row[2], + role=row[3], + created_at=row[4], + ) + ) + return links + + def get_issue_snapshot(self, issue_ref: str) -> Optional[IssueSnapshot]: + """Get minimal snapshot for context build.""" + issue_ref = self._normalize_issue_ref(issue_ref) + cursor = self.conn.execute( + """ + SELECT issue_ref, remote_key, title, status, assignee, updated_at + FROM issue_cache + WHERE issue_ref = ? + """, + (issue_ref,), + ) + + row = cursor.fetchone() + if not row: + return None + + sync_cursor = self.conn.execute( + """ + SELECT status + FROM sync_event + WHERE issue_ref = ? + ORDER BY created_at DESC + LIMIT 1 + """, + (issue_ref,), + ) + sync_row = sync_cursor.fetchone() + + return IssueSnapshot( + issue_ref=row[0], + remote_key=row[1], + title=row[2], + status=row[3], + assignee=row[4], + updated_at=row[5], + last_sync_result=sync_row[0] if sync_row else "unknown", + ) + + def post_outbound_comment( + self, + connection_id: str, + issue_key: str, + comment: str, + ) -> bool: + """Post a comment to external tracker (outbound).""" + cursor = self.conn.execute( + "SELECT provider FROM tracker_connection WHERE id = ?", + (connection_id,), + ) + row = cursor.fetchone() + if not row: + return False + + provider = row["provider"] + adapter = self._adapters.get(provider) + if not adapter: + self._record_sync_event( + connection_id=connection_id, + direction=SyncDirection.OUTBOUND, + status=SyncStatus.FAILED, + issue_ref=format_ref("tracker", "issue", issue_key, provider), + details={"action": "post_comment"}, + error_message=f"No adapter registered for provider: {provider}", + ) + return False + + success = adapter.post_comment(issue_key, comment) + issue_ref = format_ref("tracker", "issue", issue_key, provider) + + self._record_sync_event( + connection_id=connection_id, + direction=SyncDirection.OUTBOUND, + status=SyncStatus.SUCCESS if success else SyncStatus.FAILED, + issue_ref=issue_ref, + details={"action": "post_comment"}, + error_message=None if success else f"Failed to post comment: {issue_key}", + ) + + return success + + def update_outbound_status( + self, + connection_id: str, + issue_key: str, + status: str, + ) -> bool: + """Reflect an internal status to the external tracker.""" + cursor = self.conn.execute( + "SELECT provider FROM tracker_connection WHERE id = ?", + (connection_id,), + ) + row = cursor.fetchone() + if not row: + return False + + provider = row["provider"] + adapter = self._adapters.get(provider) + issue_ref = format_ref("tracker", "issue", issue_key, provider) + if not adapter: + self._record_sync_event( + connection_id=connection_id, + direction=SyncDirection.OUTBOUND, + status=SyncStatus.FAILED, + issue_ref=issue_ref, + details={"action": "update_status", "status": status}, + error_message=f"No adapter registered for provider: {provider}", + ) + return False + + success = adapter.update_status(issue_key, status) + + self._record_sync_event( + connection_id=connection_id, + direction=SyncDirection.OUTBOUND, + status=SyncStatus.SUCCESS if success else SyncStatus.FAILED, + issue_ref=issue_ref, + details={"action": "update_status", "status": status}, + error_message=None if success else f"Failed to update status: {issue_key}", + ) + + return success + + def generate_sync_suggestions( + self, + issue_ref: str, + ) -> List[SyncSuggestion]: + """Generate suggestions for agent-taskstate updates based on tracker changes.""" + suggestions: List[SyncSuggestion] = [] + snapshot = self.get_issue_snapshot(issue_ref) + if not snapshot: + return suggestions + + links = self.get_issue_links(issue_ref) + for link in links: + if link.role == "primary": + suggestions.append( + SyncSuggestion( + issue_ref=issue_ref, + agent_taskstate_task_ref=link.agent_taskstate_entity_ref, + suggested_action="review_status", + suggested_value=snapshot.status, + reason=f"Tracker status is '{snapshot.status}'", + requires_confirmation=True, + ) + ) + + return suggestions + + def get_sync_events( + self, + connection_id: Optional[str] = None, + issue_ref: Optional[str] = None, + limit: int = 100, + ) -> List[SyncEvent]: + """Get sync events for tracking.""" + query = """ + SELECT id, connection_id, direction, status, issue_ref, + details_json, error_message, created_at + FROM sync_event + WHERE 1=1 + """ + params: List[Any] = [] + + if connection_id: + query += " AND connection_id = ?" + params.append(connection_id) + if issue_ref: + issue_ref = self._normalize_issue_ref(issue_ref) + query += " AND issue_ref = ?" + params.append(issue_ref) + + query += " ORDER BY created_at DESC LIMIT ?" + params.append(limit) + + cursor = self.conn.execute(query, params) + + events = [] + for row in cursor.fetchall(): + events.append( + SyncEvent( + id=row[0], + connection_id=row[1], + direction=row[2], + status=row[3], + issue_ref=row[4], + details_json=row[5], + error_message=row[6], + created_at=row[7], + ) + ) + return events + + def _record_sync_event( + self, + connection_id: str, + direction: SyncDirection, + status: SyncStatus, + issue_ref: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + error_message: Optional[str] = None, + ) -> SyncEvent: + """Record a sync event.""" + event_id = gen_id() + now = now_utc() + + self.conn.execute( + """ + INSERT INTO sync_event + (id, connection_id, direction, status, issue_ref, + details_json, error_message, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + event_id, + connection_id, + direction.value, + status.value, + issue_ref, + json.dumps(_redact(details), ensure_ascii=False) if details else None, + error_message, + now, + ), + ) + + return SyncEvent( + id=event_id, + connection_id=connection_id, + direction=direction.value, + status=status.value, + issue_ref=issue_ref, + details_json=json.dumps(_redact(details), ensure_ascii=False) if details else None, + error_message=error_message, + created_at=now, + ) + + +def create_tracker_tables(conn: sqlite3.Connection) -> None: + """Create tracker-bridge tables.""" + conn.execute( + """ + CREATE TABLE IF NOT EXISTS tracker_connection ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + name TEXT NOT NULL, + config_json TEXT NOT NULL, + secret_env_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + + conn.execute( + """ + CREATE TABLE IF NOT EXISTS issue_cache ( + id TEXT PRIMARY KEY, + connection_id TEXT NOT NULL, + issue_ref TEXT NOT NULL UNIQUE, + remote_key TEXT NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL, + assignee TEXT, + description TEXT, + labels_json TEXT, + raw_json TEXT, + fetched_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (connection_id) REFERENCES tracker_connection(id) + ) + """ + ) + + conn.execute( + """ + CREATE TABLE IF NOT EXISTS entity_link ( + id TEXT PRIMARY KEY, + tracker_issue_ref TEXT NOT NULL, + agent_taskstate_entity_ref TEXT NOT NULL, + role TEXT NOT NULL, + created_at TEXT NOT NULL + ) + """ + ) + + conn.execute( + """ + CREATE TABLE IF NOT EXISTS sync_event ( + id TEXT PRIMARY KEY, + connection_id TEXT NOT NULL, + direction TEXT NOT NULL, + status TEXT NOT NULL, + issue_ref TEXT, + details_json TEXT, + error_message TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (connection_id) REFERENCES tracker_connection(id) + ) + """ + ) + + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_issue_cache_ref + ON issue_cache(issue_ref) + """ + ) + + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_entity_link_issue + ON entity_link(tracker_issue_ref) + """ + ) + + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_entity_link_task + ON entity_link(agent_taskstate_entity_ref) + """ + ) + + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_sync_event_connection + ON sync_event(connection_id, created_at DESC) + """ + ) + + +_SECRET_KEYS = {"token", "password", "secret", "api_key", "apikey", "client_secret"} + + +def _contains_secret_key(value: Any) -> bool: + if isinstance(value, dict): + return any( + key.lower() in _SECRET_KEYS or _contains_secret_key(item) for key, item in value.items() + ) + if isinstance(value, list): + return any(_contains_secret_key(item) for item in value) + return False + + +def _redact(value: Any) -> Any: + if isinstance(value, dict): + return { + key: ("[REDACTED]" if key.lower() in _SECRET_KEYS else _redact(item)) + for key, item in value.items() + } + if isinstance(value, list): + return [_redact(item) for item in value] + return value + + +def classify_adapter_exception(exc: Exception) -> str: + name, message = type(exc).__name__.lower(), str(exc).lower() + if "auth" in name or "unauthor" in message or "forbidden" in message or "credential" in message: + return "auth" + if "notfound" in name or "not found" in message: + return "not-found" + if "rate" in name or "rate limit" in message or "429" in message: + return "rate-limit" + if isinstance(exc, (ValueError, TypeError)) or "invalid" in message: + return "validation" + return "transport" diff --git a/src/agent_taskstate/transition_config.py b/src/agent_taskstate/transition_config.py new file mode 100644 index 0000000..cc81522 --- /dev/null +++ b/src/agent_taskstate/transition_config.py @@ -0,0 +1,126 @@ +"""Configurable state transition rules.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional, Set + +try: + import yaml +except ImportError: # pragma: no cover + yaml = None + + +DEFAULT_CONFIG_PATH = Path(__file__).parent.parent / "config" / "transitions.yaml" + + +@dataclass +class TransitionConfig: + workflow: str = "default" + statuses: Set[str] = field( + default_factory=lambda: { + "draft", + "proposed", + "ready", + "in_progress", + "blocked", + "review", + "done", + "cancelled", + "archived", + } + ) + terminal_states: Set[str] = field(default_factory=lambda: {"done", "cancelled", "archived"}) + allowed_transitions: Dict[str, Set[str]] = field( + default_factory=lambda: { + "draft": {"ready", "archived"}, + "proposed": {"ready", "cancelled"}, + "ready": {"in_progress", "cancelled"}, + "in_progress": {"blocked", "review", "cancelled"}, + "blocked": {"in_progress", "cancelled"}, + "review": {"in_progress", "done", "cancelled"}, + "done": {"in_progress", "archived"}, + "cancelled": set(), + "archived": set(), + } + ) + reason_required: List[Dict[str, str]] = field( + default_factory=lambda: [ + {"from": "done", "to": "in_progress"}, + {"from": "*", "to": "done"}, + {"from": "*", "to": "cancelled"}, + {"from": "*", "to": "archived"}, + ] + ) + actor_types: Set[str] = field(default_factory=lambda: {"human", "agent", "system"}) + + @classmethod + def from_yaml(cls, path: Optional[Path] = None) -> "TransitionConfig": + if yaml is None: + return cls() + config_path = path or DEFAULT_CONFIG_PATH + if not config_path.exists(): + return cls() + with config_path.open("r", encoding="utf-8") as handle: + data = yaml.safe_load(handle) or {} + return cls( + workflow=data.get("workflow", "default"), + statuses=set(data.get("statuses", cls().statuses)), + terminal_states=set(data.get("terminal_states", cls().terminal_states)), + allowed_transitions={ + status: set(trans.get("allowed", [])) + for status, trans in data.get("transitions", {}).items() + } + or cls().allowed_transitions, + reason_required=data.get("reason_required", cls().reason_required), + actor_types=set(data.get("actor_types", cls().actor_types)), + ) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "TransitionConfig": + defaults = cls() + return cls( + workflow=data.get("workflow", defaults.workflow), + statuses=set(data.get("statuses", defaults.statuses)), + terminal_states=set(data.get("terminal_states", defaults.terminal_states)), + allowed_transitions={ + key: set(value) + for key, value in data.get( + "allowed_transitions", defaults.allowed_transitions + ).items() + }, + reason_required=data.get("reason_required", defaults.reason_required), + actor_types=set(data.get("actor_types", defaults.actor_types)), + ) + + def is_valid_status(self, status: str) -> bool: + return status in self.statuses + + def can_transition(self, from_status: str, to_status: str) -> bool: + return to_status in self.allowed_transitions.get(from_status, set()) + + def is_terminal(self, status: str) -> bool: + return status in self.terminal_states + + def requires_reason(self, from_status: str, to_status: str) -> bool: + return any( + (rule.get("from") in {"*", from_status}) and rule.get("to") == to_status + for rule in self.reason_required + ) + + def is_valid_actor(self, actor_type: str) -> bool: + return actor_type in self.actor_types + + +DEFAULT_CONFIG = TransitionConfig.from_yaml() + + +def get_config(path: Optional[Path] = None) -> TransitionConfig: + return TransitionConfig.from_yaml(path) if path else DEFAULT_CONFIG + + +def reload_config(path: Optional[Path] = None) -> TransitionConfig: + global DEFAULT_CONFIG + DEFAULT_CONFIG = TransitionConfig.from_yaml(path) + return DEFAULT_CONFIG diff --git a/src/agent_taskstate/typed_ref.py b/src/agent_taskstate/typed_ref.py new file mode 100644 index 0000000..1369abe --- /dev/null +++ b/src/agent_taskstate/typed_ref.py @@ -0,0 +1,225 @@ +""" +Typed Reference Module + +Provides canonical 4-segment typed_ref format for cross-repo entity references. + +Format: ::: + +Examples: +- agent-taskstate:task:local:task_01JXYZ... +- agent-taskstate:decision:local:dec_01JXYZ... +- memx:evidence:local:ev_01JXYZ... +- tracker:issue:github:owner/repo#123 +- tracker:issue:jira:PROJ-123 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, Set, Tuple + +# Known domains (can be extended) +KNOWN_DOMAINS: Set[str] = {"agent-taskstate", "memx", "tracker"} + +# Default provider for local entities +DEFAULT_PROVIDER = "local" + + +@dataclass(frozen=True) +class TypedRef: + """Immutable typed reference with 4 segments.""" + + domain: str + entity_type: str + provider: str + entity_id: str + + def __str__(self) -> str: + return f"{self.domain}:{self.entity_type}:{self.provider}:{self.entity_id}" + + @property + def is_local(self) -> bool: + """Check if this is a local entity reference.""" + return self.provider == DEFAULT_PROVIDER + + +def format_ref( + domain: str, + entity_type: str, + entity_id: str, + provider: str = DEFAULT_PROVIDER, +) -> str: + """ + Format a 4-segment typed_ref string. + + Args: + domain: Domain namespace (agent-taskstate, memx, tracker) + entity_type: Entity type (task, decision, evidence, etc.) + entity_id: Entity identifier + provider: Provider name (default: "local") + + Returns: + Canonical typed_ref string + + Example: + >>> format_ref("agent-taskstate", "task", "task_01JABC") + 'agent-taskstate:task:local:task_01JABC' + """ + if not domain: + raise ValueError("domain cannot be empty") + if not entity_type: + raise ValueError("entity_type cannot be empty") + if not provider: + raise ValueError("provider cannot be empty") + if not entity_id: + raise ValueError("entity_id cannot be empty") + + return f"{domain.lower()}:{entity_type.lower()}:{provider.lower()}:{entity_id}" + + +def parse_ref(ref: str) -> TypedRef: + """ + Parse a typed_ref string into a TypedRef object. + + Accepts both 4-segment (canonical) and 3-segment (legacy) formats. + Legacy format is automatically canonicalized with default provider. + + Args: + ref: typed_ref string + + Returns: + TypedRef object + + Raises: + ValueError: If ref format is invalid + + Example: + >>> parse_ref("agent-taskstate:task:local:task_01JABC") + TypedRef(domain='agent-taskstate', entity_type='task', provider='local', entity_id='task_01JABC') + >>> parse_ref("agent-taskstate:task:task_01JABC") # legacy 3-segment + TypedRef(domain='agent-taskstate', entity_type='task', provider='local', entity_id='task_01JABC') + """ + parts = ref.split(":") + + if len(parts) == 4: + domain, entity_type, provider, entity_id = parts + elif len(parts) == 3: + domain, entity_type, entity_id = parts + provider = DEFAULT_PROVIDER + else: + raise ValueError( + f"Invalid typed_ref format: {ref!r}. Expected 3 or 4 segments separated by ':'." + ) + + if not domain: + raise ValueError("domain cannot be empty") + if not entity_type: + raise ValueError("entity_type cannot be empty") + if not provider: + raise ValueError("provider cannot be empty") + if not entity_id: + raise ValueError("entity_id cannot be empty") + + return TypedRef( + domain=domain.lower(), + entity_type=entity_type.lower(), + provider=provider.lower(), + entity_id=entity_id, + ) + + +def validate_ref(ref: str) -> Tuple[bool, Optional[str]]: + """ + Validate a typed_ref string format. + + Args: + ref: typed_ref string to validate + + Returns: + Tuple of (is_valid, error_message) + + Example: + >>> validate_ref("agent-taskstate:task:local:task_01JABC") + (True, None) + >>> validate_ref("invalid") + (False, "Invalid typed_ref format...") + """ + try: + parsed = parse_ref(ref) + if not is_known_domain(parsed.domain): + return False, f"Unknown typed_ref domain: {parsed.domain}" + return True, None + except ValueError as e: + return False, str(e) + + +def is_valid_ref(ref: str) -> bool: + """ + Check if a typed_ref string has valid format. + + Args: + ref: typed_ref string to check + + Returns: + True if valid, False otherwise + """ + return validate_ref(ref)[0] + + +def canonicalize_ref(ref: str) -> str: + """ + Canonicalize a typed_ref string to 4-segment format. + + Accepts both 3-segment (legacy) and 4-segment formats. + Always returns 4-segment canonical format. + + Args: + ref: typed_ref string + + Returns: + Canonical 4-segment typed_ref string + + Raises: + ValueError: If ref format is invalid + + Example: + >>> canonicalize_ref("agent-taskstate:task:task_01JABC") + 'agent-taskstate:task:local:task_01JABC' + >>> canonicalize_ref("agent-taskstate:task:local:task_01JABC") + 'agent-taskstate:task:local:task_01JABC' + """ + parsed = parse_ref(ref) + if not is_known_domain(parsed.domain): + raise ValueError(f"Unknown typed_ref domain: {parsed.domain}") + return str(parsed) + + +def is_known_domain(domain: str) -> bool: + """ + Check if a domain is in the known domains set. + + Args: + domain: Domain name to check + + Returns: + True if known domain, False otherwise + """ + return domain.lower() in KNOWN_DOMAINS + + +# Convenience functions for common domains + + +def agent_taskstate_ref(entity_type: str, entity_id: str, provider: str = DEFAULT_PROVIDER) -> str: + """Create an agent-taskstate typed_ref.""" + return format_ref("agent-taskstate", entity_type, entity_id, provider) + + +def memx_ref(entity_type: str, entity_id: str, provider: str = DEFAULT_PROVIDER) -> str: + """Create a memx typed_ref.""" + return format_ref("memx", entity_type, entity_id, provider) + + +def tracker_ref(entity_type: str, entity_id: str, provider: str) -> str: + """Create a tracker typed_ref (provider required).""" + return format_ref("tracker", entity_type, entity_id, provider) diff --git a/src/agent_taskstate_workflow_plugin/__init__.py b/src/agent_taskstate_workflow_plugin/__init__.py new file mode 100644 index 0000000..6edde43 --- /dev/null +++ b/src/agent_taskstate_workflow_plugin/__init__.py @@ -0,0 +1,3 @@ +from .plugin import create_plugin + +__all__ = ["create_plugin"] diff --git a/src/agent_taskstate_workflow_plugin/markdown.py b/src/agent_taskstate_workflow_plugin/markdown.py new file mode 100644 index 0000000..f385daf --- /dev/null +++ b/src/agent_taskstate_workflow_plugin/markdown.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import re +from pathlib import Path + +FRONT_MATTER_PATTERN = re.compile(r"\A---\n(.*?)\n---\n", re.DOTALL) +TITLE_PATTERN = re.compile(r"^#\s+(.*)$", re.MULTILINE) + + +def parse_front_matter(text: str) -> dict[str, str]: + match = FRONT_MATTER_PATTERN.match(text) + if match is None: + return {} + payload: dict[str, str] = {} + for line in match.group(1).splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#") or ":" not in stripped: + continue + key, value = stripped.split(":", 1) + payload[key.strip()] = value.split("#", 1)[0].strip() + return payload + + +def title_from_text(text: str) -> str: + match = TITLE_PATTERN.search(text) + return match.group(1).strip() if match else "" + + +def read_markdown(path: Path) -> tuple[str, dict[str, str], str]: + text = path.read_text(encoding="utf-8") + return text, parse_front_matter(text), title_from_text(text) diff --git a/src/agent_taskstate_workflow_plugin/plugin.py b/src/agent_taskstate_workflow_plugin/plugin.py new file mode 100644 index 0000000..4c3858d --- /dev/null +++ b/src/agent_taskstate_workflow_plugin/plugin.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from pathlib import Path + +from agent_taskstate.typed_ref import agent_taskstate_ref +from .renderer import render_acceptance_index_markdown +from .results import AcceptanceIndexResult, TaskAcceptanceSyncResult +from .store import MarkdownTaskAcceptanceStore, TaskAcceptanceStore + + +class AgentTaskstateWorkflowPlugin: + capabilities = ("task_state.sync", "acceptance.index") + + def __init__( + self, + *, + tasks_dir: str = "docs/tasks", + acceptance_dir: str = "docs/acceptance", + require_acceptance_for_done: bool = False, + store: TaskAcceptanceStore | None = None, + ) -> None: + self._require_acceptance_for_done = require_acceptance_for_done + self._store = store or MarkdownTaskAcceptanceStore( + tasks_dir=tasks_dir, + acceptance_dir=acceptance_dir, + ) + + def sync_task_acceptance(self, *, repo_root: Path) -> TaskAcceptanceSyncResult: + tasks = self._store.load_tasks(repo_root=repo_root) + acceptances = self._store.load_acceptances(repo_root=repo_root) + by_task: dict[str, list[dict]] = {} + for acceptance in acceptances: + by_task.setdefault(acceptance["task_id"], []).append(acceptance) + + errors: list[str] = [] + warnings: list[str] = [] + rendered_tasks: list[dict] = [] + for task in tasks: + linked = by_task.get(task.task_id, []) + acceptance_ids = [item["acceptance_id"] for item in linked] + if task.status.lower() == "done" and not acceptance_ids: + message = f"Done task '{task.task_id}' is missing an acceptance record." + if self._require_acceptance_for_done: + errors.append(message) + else: + warnings.append(message) + for acceptance in linked: + if acceptance["intent_id"] != task.intent_id: + errors.append( + f"Intent mismatch for task '{task.task_id}': task={task.intent_id} acceptance={acceptance['intent_id']}" + ) + rendered_tasks.append( + { + "task_id": task.task_id, + "intent_id": task.intent_id, + "owner": task.owner, + "status": task.status, + "path": task.path, + "title": task.title, + "typed_ref": agent_taskstate_ref("task", task.task_id), + "acceptance_ids": acceptance_ids, + } + ) + + known_task_ids = {task.task_id for task in tasks} + for acceptance in acceptances: + if acceptance["task_id"] not in known_task_ids: + errors.append( + f"Acceptance '{acceptance['acceptance_id']}' references unknown task '{acceptance['task_id']}'." + ) + + return TaskAcceptanceSyncResult( + tasks=rendered_tasks, + acceptances=acceptances, + errors=errors, + warnings=warnings, + ) + + def build_acceptance_index(self, *, repo_root: Path) -> AcceptanceIndexResult: + acceptances = sorted( + self._store.load_acceptances(repo_root=repo_root), + key=lambda item: (item["reviewed_at"], item["acceptance_id"]), + reverse=True, + ) + return AcceptanceIndexResult( + markdown=render_acceptance_index_markdown(acceptances), + rows=acceptances, + ) + + +def create_plugin(**kwargs: object) -> AgentTaskstateWorkflowPlugin: + return AgentTaskstateWorkflowPlugin(**kwargs) diff --git a/src/agent_taskstate_workflow_plugin/py.typed b/src/agent_taskstate_workflow_plugin/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/agent_taskstate_workflow_plugin/renderer.py b/src/agent_taskstate_workflow_plugin/renderer.py new file mode 100644 index 0000000..e3be208 --- /dev/null +++ b/src/agent_taskstate_workflow_plugin/renderer.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from pathlib import Path + + +def render_acceptance_index_markdown(acceptances: list[dict]) -> str: + lines = [ + "# Acceptance Index", + "", + "| Acceptance | Task | Intent | Status | Reviewed | File |", + "|---|---|---|---|---|---|", + ] + for acceptance in acceptances: + lines.append( + "| " + f"{acceptance['acceptance_id']} | {acceptance['task_id']} | {acceptance['intent_id']} | " + f"{acceptance['status']} | {acceptance['reviewed_at']} | " + f"[{Path(acceptance['path']).name}]({acceptance['path']}) |" + ) + lines.append("") + return "\n".join(lines) diff --git a/src/agent_taskstate_workflow_plugin/results.py b/src/agent_taskstate_workflow_plugin/results.py new file mode 100644 index 0000000..e9781b1 --- /dev/null +++ b/src/agent_taskstate_workflow_plugin/results.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class TaskAcceptanceSyncResult: + tasks: list[dict[str, Any]] + acceptances: list[dict[str, Any]] + errors: list[str] + warnings: list[str] + + +@dataclass(frozen=True) +class AcceptanceIndexResult: + markdown: str + rows: list[dict[str, Any]] diff --git a/src/agent_taskstate_workflow_plugin/store.py b/src/agent_taskstate_workflow_plugin/store.py new file mode 100644 index 0000000..28778ae --- /dev/null +++ b/src/agent_taskstate_workflow_plugin/store.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol + +from agent_taskstate.typed_ref import agent_taskstate_ref + +from .markdown import read_markdown + + +@dataclass(frozen=True) +class TaskRecord: + task_id: str + intent_id: str + owner: str + status: str + path: str + title: str + + +class TaskAcceptanceStore(Protocol): + def load_tasks(self, *, repo_root: Path) -> list[TaskRecord]: ... + + def load_acceptances(self, *, repo_root: Path) -> list[dict]: ... + + +class MarkdownTaskAcceptanceStore: + def __init__( + self, *, tasks_dir: str = "docs/tasks", acceptance_dir: str = "docs/acceptance" + ) -> None: + self._tasks_dir = tasks_dir + self._acceptance_dir = acceptance_dir + + def load_tasks(self, *, repo_root: Path) -> list[TaskRecord]: + tasks_root = repo_root / self._tasks_dir + records: list[TaskRecord] = [] + for path in sorted(tasks_root.glob("*.md")): + _, fields, title = read_markdown(path) + task_id = fields.get("task_id", "").strip() + if not task_id: + continue + records.append( + TaskRecord( + task_id=task_id, + intent_id=fields.get("intent_id", ""), + owner=fields.get("owner", ""), + status=fields.get("status", ""), + path=path.relative_to(repo_root).as_posix(), + title=title, + ) + ) + return records + + def load_acceptances(self, *, repo_root: Path) -> list[dict]: + acceptance_root = repo_root / self._acceptance_dir + items: list[dict] = [] + for path in sorted(acceptance_root.glob("AC-*.md")): + _, fields, _ = read_markdown(path) + if not fields.get("acceptance_id"): + continue + items.append( + { + "acceptance_id": fields.get("acceptance_id", ""), + "task_id": fields.get("task_id", ""), + "intent_id": fields.get("intent_id", ""), + "status": fields.get("status", ""), + "reviewed_at": fields.get("reviewed_at", ""), + "reviewed_by": fields.get("reviewed_by", ""), + "path": path.relative_to(repo_root).as_posix(), + "typed_ref": agent_taskstate_ref("acceptance", fields.get("acceptance_id", "")), + } + ) + return items diff --git a/src/cli/__init__.py b/src/cli/__init__.py index 8ee4808..149ae97 100644 --- a/src/cli/__init__.py +++ b/src/cli/__init__.py @@ -1,48 +1,21 @@ -""" -CLI Package - -agent-taskstate command-line interface. -""" +"""Deprecated compatibility shim for agent_taskstate.cli.""" from __future__ import annotations -from dataclasses import dataclass - -from .constants import APP_NAME, DEFAULT_DB_PATH - - -@dataclass -class AppContext: - """CLI application context.""" - db_path: str - - -def main(argv: list[str] | None = None) -> int: - """CLI entry point.""" - import json - import sqlite3 - - from .errors import AgentTaskstateError - from .parser import build_parser - from .utils import json_error - - parser = build_parser() - args = parser.parse_args(argv) - if not hasattr(args, "func"): - parser.print_help() - return 2 - ctx = AppContext(db_path=args.db) - try: - return int(args.func(ctx, args)) - except AgentTaskstateError as e: - return json_error(e.code, str(e)) - except sqlite3.IntegrityError as e: - return json_error("validation_error", f"sqlite integrity error: {e}") - except json.JSONDecodeError as e: - return json_error("validation_error", f"invalid JSON: {e}") - except Exception as e: - return json_error("validation_error", f"unexpected error: {e}") - - -if __name__ == "__main__": - raise SystemExit(main()) \ No newline at end of file +import warnings + +warnings.warn( + "src.cli is deprecated; import agent_taskstate.cli instead.", + DeprecationWarning, + stacklevel=2, +) + +from agent_taskstate.cli import AppContext, main +from agent_taskstate.cli.constants import * +from agent_taskstate.cli.db import * +from agent_taskstate.cli.errors import * +from agent_taskstate.cli.fetch import * +from agent_taskstate.cli.models import * +from agent_taskstate.cli.utils import * +from agent_taskstate.cli.validation import * +from agent_taskstate.cli.commands import * diff --git a/src/cli/__main__.py b/src/cli/__main__.py index 513cc00..97db424 100644 --- a/src/cli/__main__.py +++ b/src/cli/__main__.py @@ -1,6 +1,4 @@ -"""CLI main entry point.""" - -from . import main +from agent_taskstate.cli import main if __name__ == "__main__": - raise SystemExit(main()) \ No newline at end of file + raise SystemExit(main()) diff --git a/src/cli/commands/__init__.py b/src/cli/commands/__init__.py index 6254abe..1a1f342 100644 --- a/src/cli/commands/__init__.py +++ b/src/cli/commands/__init__.py @@ -1,37 +1,2 @@ -""" -CLI Commands Package -""" - -from .task import cmd_init, cmd_task_create, cmd_task_show, cmd_task_list, cmd_task_update, cmd_task_set_status -from .state import cmd_state_get, cmd_state_put, cmd_state_patch -from .decision import cmd_decision_add, cmd_decision_list, cmd_decision_accept, cmd_decision_reject -from .question import cmd_question_add, cmd_question_list, cmd_question_answer, cmd_question_defer -from .run import cmd_run_start, cmd_run_list, cmd_run_finish -from .context import cmd_context_build, cmd_context_show -from .export import cmd_export_task - -__all__ = [ - "cmd_init", - "cmd_task_create", - "cmd_task_show", - "cmd_task_list", - "cmd_task_update", - "cmd_task_set_status", - "cmd_state_get", - "cmd_state_put", - "cmd_state_patch", - "cmd_decision_add", - "cmd_decision_list", - "cmd_decision_accept", - "cmd_decision_reject", - "cmd_question_add", - "cmd_question_list", - "cmd_question_answer", - "cmd_question_defer", - "cmd_run_start", - "cmd_run_list", - "cmd_run_finish", - "cmd_context_build", - "cmd_context_show", - "cmd_export_task", -] \ No newline at end of file +"""Deprecated shim.""" +from agent_taskstate.cli.commands import * diff --git a/src/cli/commands/context.py b/src/cli/commands/context.py index 59b2c94..c14c23d 100644 --- a/src/cli/commands/context.py +++ b/src/cli/commands/context.py @@ -1,112 +1,2 @@ -""" -CLI Context Bundle Commands -""" - -from __future__ import annotations - -import argparse -from typing import Any, Dict, List - -from .. import AppContext -from ..constants import BUILD_REASONS, EXPECTED_OUTPUT_SCHEMA -from ..db import connect, init_db -from ..fetch import get_task, get_task_state, get_bundle -from ..models import jdump, row_to_task, row_to_task_state, row_to_decision, row_to_question, row_to_bundle -from ..typed_ref import task_ref -from ..utils import gen_id, now_utc, json_ok -from ..validation import require_in - - -def should_include_evidence( - state: Dict[str, Any], - build_reason: str, - decisions: List[Dict[str, Any]], - questions: List[Dict[str, Any]], -) -> bool: - """Determine if evidence should be included in bundle.""" - if state.get("confidence") == "low": - return True - if build_reason == "review": - return True - if state.get("current_step") in {"investigation", "verification"}: - return True - if state.get("context_policy", {}).get("force_evidence") is True: - return True - if any(d.get("confidence") == "low" for d in decisions): - return True - if any(q.get("priority") == "high" and q.get("status") == "open" for q in questions): - return True - return False - - -def cmd_context_build(ctx: AppContext, args: argparse.Namespace) -> int: - """Build context bundle.""" - require_in(args.reason, BUILD_REASONS, "build_reason") - now = now_utc() - bid = gen_id() - with connect(ctx.db_path) as conn: - init_db(conn) - task = row_to_task(get_task(conn, args.task)) - state = row_to_task_state(get_task_state(conn, args.task)) - drows = conn.execute( - "SELECT * FROM decisions WHERE task_id = ? AND status IN ('accepted', 'proposed') ORDER BY created_at ASC", - (args.task,), - ).fetchall() - qrows = conn.execute( - "SELECT * FROM open_questions WHERE task_id = ? AND status = 'open' ORDER BY created_at ASC", - (args.task,), - ).fetchall() - decisions = [row_to_decision(r) for r in drows] - questions = [row_to_question(r) for r in qrows] - - accepted_decisions = [d for d in decisions if d["status"] == "accepted"] - proposed_decisions = [d for d in decisions if d["status"] == "proposed"] - include_evidence = should_include_evidence(state, args.reason, decisions, questions) - - decision_refs = [d["ref"] for d in accepted_decisions] + [d["ref"] for d in proposed_decisions] - question_refs = [q["ref"] for q in questions] - artifact_refs = list(state.get("artifact_refs", [])) - evidence_refs = list(state.get("evidence_refs", [])) if include_evidence else [] - - state_snapshot = { - "task": task, - "task_ref": task_ref(task["id"]), - "task_state": state, - "accepted_decisions": accepted_decisions, - "open_questions": questions, - "done_when": state["done_when"], - "current_step": state["current_step"], - "build_reason": args.reason, - } - - conn.execute( - """ - INSERT INTO context_bundles ( - id, task_id, build_reason, state_snapshot_json, included_decision_refs_json, - included_open_question_refs_json, included_artifact_refs_json, included_evidence_refs_json, - expected_output_schema_json, created_at, metadata_json - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - bid, - args.task, - args.reason, - jdump(state_snapshot), - jdump(decision_refs), - jdump(question_refs), - jdump(artifact_refs), - jdump(evidence_refs), - jdump(EXPECTED_OUTPUT_SCHEMA), - now, - jdump({"include_evidence": include_evidence}), - ), - ) - row = get_bundle(conn, bid) - return json_ok(row_to_bundle(row)) - - -def cmd_context_show(ctx: AppContext, args: argparse.Namespace) -> int: - """Show context bundle.""" - with connect(ctx.db_path) as conn: - row = get_bundle(conn, args.bundle) - return json_ok(row_to_bundle(row)) \ No newline at end of file +"""Deprecated shim.""" +from agent_taskstate.cli.commands.context import * diff --git a/src/cli/commands/decision.py b/src/cli/commands/decision.py index a2ffb91..819369e 100644 --- a/src/cli/commands/decision.py +++ b/src/cli/commands/decision.py @@ -1,89 +1,2 @@ -""" -CLI Decision Commands -""" - -from __future__ import annotations - -import argparse -from typing import Any, List - -from .. import AppContext -from ..constants import DECISION_STATUSES, CONFIDENCE_LEVELS -from ..db import connect, init_db -from ..errors import AgentTaskstateError -from ..fetch import get_task, get_decision -from ..models import jdump, row_to_decision -from ..utils import gen_id, now_utc, json_ok -from ..validation import load_json_arg, require_in, validate_decision_payload - - -def cmd_decision_add(ctx: AppContext, args: argparse.Namespace) -> int: - """Add decision.""" - payload = load_json_arg(args.json, args.file) - validate_decision_payload(payload) - decision_id = payload.get("id") or gen_id() - now = now_utc() - with connect(ctx.db_path) as conn: - init_db(conn) - get_task(conn, args.task) - if payload.get("supersedes_decision_id"): - get_decision(conn, payload["supersedes_decision_id"]) - conn.execute( - """ - INSERT INTO decisions ( - id, task_id, summary, rationale, status, confidence, evidence_refs_json, supersedes_decision_id, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - decision_id, - args.task, - payload["summary"], - payload.get("rationale"), - payload.get("status", "proposed"), - payload.get("confidence", "medium"), - jdump(payload.get("evidence_refs", [])), - payload.get("supersedes_decision_id"), - now, - now, - ), - ) - conn.execute("UPDATE tasks SET updated_at = ? WHERE id = ?", (now, args.task)) - row = get_decision(conn, decision_id) - return json_ok(row_to_decision(row)) - - -def cmd_decision_list(ctx: AppContext, args: argparse.Namespace) -> int: - """List decisions.""" - sql = "SELECT * FROM decisions WHERE task_id = ?" - params: List[Any] = [args.task] - if args.status: - sql += " AND status = ?" - params.append(args.status) - sql += " ORDER BY created_at DESC" - with connect(ctx.db_path) as conn: - rows = conn.execute(sql, params).fetchall() - return json_ok([row_to_decision(r) for r in rows]) - - -def _set_decision_status(ctx: AppContext, decision_id: str, status: str) -> int: - """Set decision status.""" - require_in(status, DECISION_STATUSES, "decision.status") - with connect(ctx.db_path) as conn: - init_db(conn) - get_decision(conn, decision_id) - conn.execute( - "UPDATE decisions SET status = ?, updated_at = ? WHERE id = ?", - (status, now_utc(), decision_id), - ) - row = get_decision(conn, decision_id) - return json_ok(row_to_decision(row)) - - -def cmd_decision_accept(ctx: AppContext, args: argparse.Namespace) -> int: - """Accept decision.""" - return _set_decision_status(ctx, args.decision, "accepted") - - -def cmd_decision_reject(ctx: AppContext, args: argparse.Namespace) -> int: - """Reject decision.""" - return _set_decision_status(ctx, args.decision, "rejected") \ No newline at end of file +"""Deprecated shim.""" +from agent_taskstate.cli.commands.decision import * diff --git a/src/cli/commands/export.py b/src/cli/commands/export.py index a364279..1173105 100644 --- a/src/cli/commands/export.py +++ b/src/cli/commands/export.py @@ -1,52 +1,2 @@ -""" -CLI Export Commands -""" - -from __future__ import annotations - -import argparse -import json -from pathlib import Path - -from .. import AppContext -from ..db import connect -from ..errors import NotFoundError -from ..fetch import get_task, get_task_state -from ..models import row_to_task, row_to_task_state, row_to_decision, row_to_question, row_to_run, row_to_bundle -from ..typed_ref import task_ref -from ..utils import now_utc, json_ok - - -def cmd_export_task(ctx: AppContext, args: argparse.Namespace) -> int: - """Export task to JSON file.""" - with connect(ctx.db_path) as conn: - task = row_to_task(get_task(conn, args.task)) - try: - state = row_to_task_state(get_task_state(conn, args.task)) - except NotFoundError: - state = None - decisions = [ - row_to_decision(r) - for r in conn.execute("SELECT * FROM decisions WHERE task_id = ? ORDER BY created_at ASC", (args.task,)).fetchall() - ] - questions = [ - row_to_question(r) - for r in conn.execute("SELECT * FROM open_questions WHERE task_id = ? ORDER BY created_at ASC", (args.task,)).fetchall() - ] - runs = [row_to_run(r) for r in conn.execute("SELECT * FROM runs WHERE task_id = ? ORDER BY created_at ASC", (args.task,)).fetchall()] - bundles = [ - row_to_bundle(r) - for r in conn.execute("SELECT * FROM context_bundles WHERE task_id = ? ORDER BY created_at ASC", (args.task,)).fetchall() - ] - export = { - "task": {**task, "ref": task_ref(task["id"])}, - "task_state": state, - "decisions": decisions, - "open_questions": questions, - "runs": runs, - "context_bundles": bundles, - "exported_at": now_utc(), - } - Path(args.output).parent.mkdir(parents=True, exist_ok=True) - Path(args.output).write_text(json.dumps(export, ensure_ascii=False, indent=2), encoding="utf-8") - return json_ok({"task": args.task, "output": args.output}) \ No newline at end of file +"""Deprecated shim.""" +from agent_taskstate.cli.commands.export import * diff --git a/src/cli/commands/question.py b/src/cli/commands/question.py index 552aba4..05f0344 100644 --- a/src/cli/commands/question.py +++ b/src/cli/commands/question.py @@ -1,90 +1,2 @@ -""" -CLI Question Commands -""" - -from __future__ import annotations - -import argparse -from typing import Any, List - -from .. import AppContext -from ..db import connect, init_db -from ..fetch import get_task, get_question -from ..models import jdump, row_to_question -from ..utils import gen_id, now_utc, json_ok -from ..validation import load_json_arg, validate_question_payload - - -def cmd_question_add(ctx: AppContext, args: argparse.Namespace) -> int: - """Add question.""" - payload = load_json_arg(args.json, args.file) - validate_question_payload(payload) - qid = payload.get("id") or gen_id() - now = now_utc() - with connect(ctx.db_path) as conn: - init_db(conn) - get_task(conn, args.task) - conn.execute( - """ - INSERT INTO open_questions ( - id, task_id, question, priority, status, answer, evidence_refs_json, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - qid, - args.task, - payload["question"], - payload.get("priority", "medium"), - payload.get("status", "open"), - payload.get("answer"), - jdump(payload.get("evidence_refs", [])), - now, - now, - ), - ) - conn.execute("UPDATE tasks SET updated_at = ? WHERE id = ?", (now, args.task)) - row = get_question(conn, qid) - return json_ok(row_to_question(row)) - - -def cmd_question_list(ctx: AppContext, args: argparse.Namespace) -> int: - """List questions.""" - sql = "SELECT * FROM open_questions WHERE task_id = ?" - params: List[Any] = [args.task] - if args.status: - sql += " AND status = ?" - params.append(args.status) - if args.priority: - sql += " AND priority = ?" - params.append(args.priority) - sql += " ORDER BY created_at DESC" - with connect(ctx.db_path) as conn: - rows = conn.execute(sql, params).fetchall() - return json_ok([row_to_question(r) for r in rows]) - - -def cmd_question_answer(ctx: AppContext, args: argparse.Namespace) -> int: - """Answer question.""" - with connect(ctx.db_path) as conn: - init_db(conn) - get_question(conn, args.question) - conn.execute( - "UPDATE open_questions SET answer = ?, status = 'answered', updated_at = ? WHERE id = ?", - (args.answer, now_utc(), args.question), - ) - row = get_question(conn, args.question) - return json_ok(row_to_question(row)) - - -def cmd_question_defer(ctx: AppContext, args: argparse.Namespace) -> int: - """Defer question.""" - with connect(ctx.db_path) as conn: - init_db(conn) - get_question(conn, args.question) - answer = args.reason if args.reason else None - conn.execute( - "UPDATE open_questions SET answer = ?, status = 'deferred', updated_at = ? WHERE id = ?", - (answer, now_utc(), args.question), - ) - row = get_question(conn, args.question) - return json_ok(row_to_question(row)) \ No newline at end of file +"""Deprecated shim.""" +from agent_taskstate.cli.commands.question import * diff --git a/src/cli/commands/run.py b/src/cli/commands/run.py index 2f3bce1..e7a962d 100644 --- a/src/cli/commands/run.py +++ b/src/cli/commands/run.py @@ -1,75 +1,2 @@ -""" -CLI Run Commands -""" - -from __future__ import annotations - -import argparse -from typing import Any, List - -from .. import AppContext -from ..constants import RUN_TYPES, RUN_STATUSES, OWNER_TYPES -from ..db import connect, init_db -from ..errors import ConflictError -from ..fetch import get_task, get_run -from ..utils import gen_id, now_utc, json_ok -from ..validation import require_in - - -def cmd_run_start(ctx: AppContext, args: argparse.Namespace) -> int: - """Start run.""" - from ..models import row_to_run - - require_in(args.run_type, RUN_TYPES, "run_type") - require_in(args.actor_type, OWNER_TYPES, "actor_type") - now = now_utc() - rid = gen_id() - with connect(ctx.db_path) as conn: - init_db(conn) - get_task(conn, args.task) - conn.execute( - """ - INSERT INTO runs ( - id, task_id, actor_type, actor_id, run_type, status, input_ref, output_ref, started_at, ended_at, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, 'running', ?, NULL, ?, NULL, ?, ?) - """, - (rid, args.task, args.actor_type, args.actor_id, args.run_type, args.input_ref, now, now, now), - ) - row = get_run(conn, rid) - return json_ok(row_to_run(row)) - - -def cmd_run_list(ctx: AppContext, args: argparse.Namespace) -> int: - """List runs.""" - from ..models import row_to_run - - sql = "SELECT * FROM runs WHERE task_id = ?" - params: List[Any] = [args.task] - if args.status: - sql += " AND status = ?" - params.append(args.status) - sql += " ORDER BY created_at DESC" - with connect(ctx.db_path) as conn: - init_db(conn) - get_task(conn, args.task) - rows = conn.execute(sql, params).fetchall() - return json_ok([row_to_run(r) for r in rows]) - - -def cmd_run_finish(ctx: AppContext, args: argparse.Namespace) -> int: - """Finish run.""" - from ..models import row_to_run - - require_in(args.status, RUN_STATUSES - {"running"}, "run.status") - now = now_utc() - with connect(ctx.db_path) as conn: - init_db(conn) - row = get_run(conn, args.run) - if row["status"] != "running": - raise ConflictError("run is not in running state") - conn.execute( - "UPDATE runs SET status = ?, output_ref = ?, ended_at = ?, updated_at = ? WHERE id = ?", - (args.status, args.output_ref, now, now, args.run), - ) - row = get_run(conn, args.run) - return json_ok(row_to_run(row)) \ No newline at end of file +"""Deprecated shim.""" +from agent_taskstate.cli.commands.run import * diff --git a/src/cli/commands/state.py b/src/cli/commands/state.py index dc54593..14f04f4 100644 --- a/src/cli/commands/state.py +++ b/src/cli/commands/state.py @@ -1,150 +1,2 @@ -""" -CLI State Commands -""" - -from __future__ import annotations - -import argparse -from typing import Any, Dict - -from .. import AppContext -from ..db import connect, init_db -from ..errors import AgentTaskstateError, ConflictError -from ..fetch import get_task, get_task_state -from ..models import jdump, row_to_task_state -from ..utils import gen_id, now_utc, json_ok -from ..validation import load_json_arg, validate_state_payload - - -def cmd_state_get(ctx: AppContext, args: argparse.Namespace) -> int: - """Get task state.""" - with connect(ctx.db_path) as conn: - row = get_task_state(conn, args.task) - return json_ok(row_to_task_state(row)) - - -def cmd_state_put(ctx: AppContext, args: argparse.Namespace) -> int: - """Put task state (create or replace).""" - payload = normalize_state_payload(load_json_arg(args.json, args.file)) - now = now_utc() - with connect(ctx.db_path) as conn: - init_db(conn) - get_task(conn, args.task) - existing = conn.execute("SELECT revision FROM task_states WHERE task_id = ?", (args.task,)).fetchone() - if existing is None: - revision = 1 - conn.execute( - """ - INSERT INTO task_states ( - task_id, revision, current_step, constraints_json, done_when_json, current_summary, - artifact_refs_json, evidence_refs_json, confidence, context_policy_json, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - args.task, - revision, - payload["current_step"], - jdump(payload["constraints"]), - jdump(payload["done_when"]), - payload["current_summary"], - jdump(payload["artifact_refs"]), - jdump(payload["evidence_refs"]), - payload["confidence"], - jdump(payload["context_policy"]), - now, - now, - ), - ) - else: - revision = int(existing["revision"]) + 1 - conn.execute( - """ - UPDATE task_states - SET revision = ?, current_step = ?, constraints_json = ?, done_when_json = ?, current_summary = ?, - artifact_refs_json = ?, evidence_refs_json = ?, confidence = ?, context_policy_json = ?, updated_at = ? - WHERE task_id = ? - """, - ( - revision, - payload["current_step"], - jdump(payload["constraints"]), - jdump(payload["done_when"]), - payload["current_summary"], - jdump(payload["artifact_refs"]), - jdump(payload["evidence_refs"]), - payload["confidence"], - jdump(payload["context_policy"]), - now, - args.task, - ), - ) - conn.execute("UPDATE tasks SET updated_at = ? WHERE id = ?", (now, args.task)) - row = get_task_state(conn, args.task) - return json_ok(row_to_task_state(row)) - - -def cmd_state_patch(ctx: AppContext, args: argparse.Namespace) -> int: - """Patch task state with optimistic lock.""" - patch = load_json_arg(args.json, args.file) - if args.expected_revision is None: - raise AgentTaskstateError("--expected-revision is required") - with connect(ctx.db_path) as conn: - init_db(conn) - row = get_task_state(conn, args.task) - state = row_to_task_state(row) - if int(state["revision"]) != int(args.expected_revision): - raise ConflictError("revision mismatch") - merged = { - "current_step": state["current_step"], - "constraints": state["constraints"], - "done_when": state["done_when"], - "current_summary": state["current_summary"], - "artifact_refs": state["artifact_refs"], - "evidence_refs": state["evidence_refs"], - "confidence": state["confidence"], - "context_policy": state["context_policy"], - } - merged.update(patch) - normalized = normalize_state_payload(merged) - now = now_utc() - revision = int(state["revision"]) + 1 - conn.execute( - """ - UPDATE task_states - SET revision = ?, current_step = ?, constraints_json = ?, done_when_json = ?, current_summary = ?, - artifact_refs_json = ?, evidence_refs_json = ?, confidence = ?, context_policy_json = ?, updated_at = ? - WHERE task_id = ? - """, - ( - revision, - normalized["current_step"], - jdump(normalized["constraints"]), - jdump(normalized["done_when"]), - normalized["current_summary"], - jdump(normalized["artifact_refs"]), - jdump(normalized["evidence_refs"]), - normalized["confidence"], - jdump(normalized["context_policy"]), - now, - args.task, - ), - ) - conn.execute("UPDATE tasks SET updated_at = ? WHERE id = ?", (now, args.task)) - row = get_task_state(conn, args.task) - return json_ok(row_to_task_state(row)) - - -def normalize_state_payload(payload: Dict[str, Any]) -> Dict[str, Any]: - """Normalize and validate state payload.""" - normalized = { - "current_step": payload["current_step"], - "constraints": payload.get("constraints", []), - "done_when": payload.get("done_when", []), - "current_summary": payload.get("current_summary"), - "artifact_refs": payload.get("artifact_refs", []), - "evidence_refs": payload.get("evidence_refs", []), - "confidence": payload.get("confidence", "medium"), - "context_policy": payload.get("context_policy", {}), - } - validate_state_payload(normalized) - return normalized \ No newline at end of file +"""Deprecated shim.""" +from agent_taskstate.cli.commands.state import * diff --git a/src/cli/commands/task.py b/src/cli/commands/task.py index 4f482b3..7c22489 100644 --- a/src/cli/commands/task.py +++ b/src/cli/commands/task.py @@ -1,203 +1,2 @@ -""" -CLI Task Commands -""" - -from __future__ import annotations - -import argparse -import sqlite3 -from typing import Any, Dict, List - -from ..constants import TASK_STATUSES -from ..db import connect, init_db -from ..errors import AgentTaskstateError -from ..fetch import get_task, get_task_state -from ..models import jdump, row_to_task, row_to_task_state -from ..typed_ref import task_ref -from ..utils import gen_id, now_utc -from ..validation import ( - _normalize_optional_json_text, - load_json_arg, - require_in, - validate_status_transition, - validate_task_payload, -) -from .. import AppContext - - -def cmd_init(ctx: AppContext, _args: argparse.Namespace) -> int: - """Initialize database.""" - from ..utils import json_ok - - with connect(ctx.db_path) as conn: - init_db(conn) - return json_ok({"db_path": ctx.db_path, "initialized": True}) - - -def cmd_task_create(ctx: AppContext, args: argparse.Namespace) -> int: - """Create task.""" - from ..utils import json_ok - - payload = load_json_arg(args.json, args.file) - validate_task_payload(payload) - task_id = payload.get("id") or gen_id() - now = now_utc() - roadmap_request_json = _normalize_optional_json_text(payload.get("roadmap_request_json")) - with connect(ctx.db_path) as conn: - init_db(conn) - if payload.get("parent_task_id"): - get_task(conn, payload["parent_task_id"]) - conn.execute( - """ - INSERT INTO tasks ( - id, parent_task_id, kind, title, goal, status, priority, owner_type, owner_id, - idempotency_key, note_id, trace_id, reply_target, reply_state, retry_count, - kestra_execution_id, original_task_id, trigger, reply_text, roadmap_request_json, - created_at, updated_at - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - task_id, - payload.get("parent_task_id"), - payload["kind"], - payload["title"], - payload["goal"], - payload.get("status", "draft"), - payload.get("priority", "medium"), - payload.get("owner_type", "human"), - payload.get("owner_id"), - payload.get("idempotency_key"), - payload.get("note_id"), - payload.get("trace_id"), - payload.get("reply_target"), - payload.get("reply_state"), - payload.get("retry_count", 0), - payload.get("kestra_execution_id"), - payload.get("original_task_id"), - payload.get("trigger"), - payload.get("reply_text"), - roadmap_request_json, - now, - now, - ), - ) - row = get_task(conn, task_id) - return json_ok({**row_to_task(row), "ref": task_ref(task_id)}) - - -def cmd_task_show(ctx: AppContext, args: argparse.Namespace) -> int: - """Show task.""" - from ..utils import json_ok - from ..errors import NotFoundError - - with connect(ctx.db_path) as conn: - row = get_task(conn, args.task) - data = row_to_task(row) - data["ref"] = task_ref(args.task) - try: - data["state"] = row_to_task_state(get_task_state(conn, args.task)) - except NotFoundError: - data["state"] = None - return json_ok(data) - - -def cmd_task_list(ctx: AppContext, args: argparse.Namespace) -> int: - """List tasks.""" - from ..utils import json_ok - - clauses: List[str] = [] - params: List[Any] = [] - if args.status: - clauses.append("status = ?") - params.append(args.status) - if args.kind: - clauses.append("kind = ?") - params.append(args.kind) - if args.owner_type: - clauses.append("owner_type = ?") - params.append(args.owner_type) - if args.owner_id: - clauses.append("owner_id = ?") - params.append(args.owner_id) - if args.priority: - clauses.append("priority = ?") - params.append(args.priority) - if getattr(args, "updated_before", None): - clauses.append("updated_at < ?") - params.append(args.updated_before) - if getattr(args, "idempotency_key", None): - clauses.append("idempotency_key = ?") - params.append(args.idempotency_key) - if getattr(args, "reply_state", None): - clauses.append("reply_state = ?") - params.append(args.reply_state) - if getattr(args, "trace_id", None): - clauses.append("trace_id = ?") - params.append(args.trace_id) - where = f"WHERE {' AND '.join(clauses)}" if clauses else "" - sql = f"SELECT * FROM tasks {where} ORDER BY updated_at DESC, created_at DESC" - with connect(ctx.db_path) as conn: - init_db(conn) - rows = conn.execute(sql, params).fetchall() - data = [{**row_to_task(r), "ref": task_ref(r["id"])} for r in rows] - return json_ok(data) - - -def cmd_task_update(ctx: AppContext, args: argparse.Namespace) -> int: - """Update task.""" - from ..utils import json_ok - from ..constants import TASK_KINDS, TASK_PRIORITIES, OWNER_TYPES, REPLY_STATES - - payload = load_json_arg(args.json, args.file) - allowed = { - "parent_task_id", "kind", "title", "goal", "priority", "owner_type", "owner_id", - "idempotency_key", "note_id", "trace_id", "reply_target", "reply_state", "retry_count", - "kestra_execution_id", "original_task_id", "trigger", "reply_text", "roadmap_request_json", - } - updates = {k: v for k, v in payload.items() if k in allowed} - if not updates: - raise AgentTaskstateError("no updatable fields provided") - if "kind" in updates: - require_in(updates["kind"], TASK_KINDS, "kind") - if "priority" in updates: - require_in(updates["priority"], TASK_PRIORITIES, "priority") - if "owner_type" in updates: - require_in(updates["owner_type"], OWNER_TYPES, "owner_type") - if "reply_state" in updates and updates["reply_state"] is not None: - require_in(updates["reply_state"], REPLY_STATES, "reply_state") - if "retry_count" in updates: - if not isinstance(updates["retry_count"], int) or updates["retry_count"] < 0: - raise AgentTaskstateError("retry_count must be non-negative integer") - if "roadmap_request_json" in updates: - updates["roadmap_request_json"] = _normalize_optional_json_text(updates["roadmap_request_json"]) - with connect(ctx.db_path) as conn: - init_db(conn) - get_task(conn, args.task) - if updates.get("parent_task_id"): - get_task(conn, updates["parent_task_id"]) - updates["updated_at"] = now_utc() - fields = ", ".join([f"{k} = ?" for k in updates]) - conn.execute(f"UPDATE tasks SET {fields} WHERE id = ?", [*updates.values(), args.task]) - row = get_task(conn, args.task) - return json_ok({**row_to_task(row), "ref": task_ref(args.task)}) - - -def cmd_task_set_status(ctx: AppContext, args: argparse.Namespace) -> int: - """Set task status.""" - from ..utils import json_ok - - require_in(args.to, TASK_STATUSES, "status") - if args.to in {"archived", "in_progress"} and args.reason_required and not args.reason: - raise AgentTaskstateError("reason is required for this transition") - with connect(ctx.db_path) as conn: - init_db(conn) - row = get_task(conn, args.task) - if row["status"] != args.to: - validate_status_transition(conn, row, args.to) - conn.execute("UPDATE tasks SET status = ?, updated_at = ? WHERE id = ?", (args.to, now_utc(), args.task)) - row = get_task(conn, args.task) - data = {**row_to_task(row), "ref": task_ref(args.task)} - if args.reason: - data["transition_reason"] = args.reason - return json_ok(data) \ No newline at end of file +"""Deprecated shim.""" +from agent_taskstate.cli.commands.task import * diff --git a/src/cli/commands/tracker.py b/src/cli/commands/tracker.py new file mode 100644 index 0000000..0db5e59 --- /dev/null +++ b/src/cli/commands/tracker.py @@ -0,0 +1,2 @@ +"""Deprecated shim.""" +from agent_taskstate.cli.commands.tracker import * diff --git a/src/cli/constants.py b/src/cli/constants.py index c2797c2..800d3af 100644 --- a/src/cli/constants.py +++ b/src/cli/constants.py @@ -1,65 +1,2 @@ -""" -CLI Constants and Type Definitions -""" - -from __future__ import annotations - -import os -from pathlib import Path - -APP_NAME = "agent-taskstate" -DEFAULT_DB_PATH = os.path.join(Path.home(), ".agent-taskstate", "agent-taskstate.db") -ISO = "%Y-%m-%dT%H:%M:%S.%fZ" - -TASK_KINDS = {"bugfix", "feature", "research"} -TASK_STATUSES = { - "draft", - "ready", - "in_progress", - "blocked", - "review", - "done", - "archived", -} -TASK_PRIORITIES = {"low", "medium", "high", "critical"} -OWNER_TYPES = {"human", "agent", "system"} -DECISION_STATUSES = {"proposed", "accepted", "rejected", "superseded"} -QUESTION_STATUSES = {"open", "answered", "deferred", "invalid"} -QUESTION_PRIORITIES = {"low", "medium", "high"} -RUN_TYPES = {"plan", "execute", "review", "summarize", "sync", "manual"} -RUN_STATUSES = {"running", "succeeded", "failed", "cancelled"} -BUILD_REASONS = {"normal", "ambiguity", "review", "high_risk", "recovery"} -CONFIDENCE_LEVELS = {"low", "medium", "high", None} -REPLY_STATES = {"pending", "sent", "failed", "skipped"} - -TASK_PHASE2_COLUMNS = { - "idempotency_key": "TEXT", - "note_id": "TEXT", - "trace_id": "TEXT", - "reply_target": "TEXT", - "reply_state": "TEXT", - "retry_count": "INTEGER NOT NULL DEFAULT 0", - "kestra_execution_id": "TEXT", - "original_task_id": "TEXT", - "trigger": "TEXT", - "reply_text": "TEXT", - "roadmap_request_json": "TEXT", -} - -EXPECTED_OUTPUT_SCHEMA = { - "summary": "string", - "proposed_actions": ["string"], - "decision_candidates": ["string"], - "question_candidates": ["string"], - "evidence_needed": ["string"], -} - -ALLOWED_TRANSITIONS = { - "draft": {"ready", "archived"}, - "ready": {"in_progress"}, - "in_progress": {"blocked", "review"}, - "blocked": {"in_progress"}, - "review": {"in_progress", "done"}, - "done": {"archived", "in_progress"}, - "archived": set(), -} \ No newline at end of file +"""Deprecated shim.""" +from agent_taskstate.cli.constants import * diff --git a/src/cli/db.py b/src/cli/db.py index f25af1d..ad2b43a 100644 --- a/src/cli/db.py +++ b/src/cli/db.py @@ -1,173 +1,2 @@ -""" -CLI Database Connection and Schema -""" - -from __future__ import annotations - -import sqlite3 -from contextlib import contextmanager -from pathlib import Path -from typing import Iterable - -from .constants import TASK_PHASE2_COLUMNS - - -def ensure_parent_dir(path: str) -> None: - """Ensure parent directory exists.""" - Path(path).parent.mkdir(parents=True, exist_ok=True) - - -@contextmanager -def connect(db_path: str) -> Iterable[sqlite3.Connection]: - """Connect to SQLite database with transaction management.""" - ensure_parent_dir(db_path) - conn = sqlite3.connect(db_path) - conn.row_factory = sqlite3.Row - conn.execute("PRAGMA foreign_keys = ON") - try: - yield conn - conn.commit() - except Exception: - conn.rollback() - raise - finally: - conn.close() - - -SCHEMA_SQL = """ -CREATE TABLE IF NOT EXISTS tasks ( - id TEXT PRIMARY KEY, - parent_task_id TEXT, - kind TEXT NOT NULL, - title TEXT NOT NULL, - goal TEXT NOT NULL, - status TEXT NOT NULL, - priority TEXT NOT NULL, - owner_type TEXT NOT NULL, - owner_id TEXT, - idempotency_key TEXT, - note_id TEXT, - trace_id TEXT, - reply_target TEXT, - reply_state TEXT, - retry_count INTEGER NOT NULL DEFAULT 0, - kestra_execution_id TEXT, - original_task_id TEXT, - trigger TEXT, - reply_text TEXT, - roadmap_request_json TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY(parent_task_id) REFERENCES tasks(id) -); -CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); -CREATE INDEX IF NOT EXISTS idx_tasks_kind ON tasks(kind); -CREATE INDEX IF NOT EXISTS idx_tasks_owner ON tasks(owner_type, owner_id); - -CREATE TABLE IF NOT EXISTS task_states ( - task_id TEXT PRIMARY KEY, - revision INTEGER NOT NULL, - current_step TEXT NOT NULL, - constraints_json TEXT NOT NULL, - done_when_json TEXT NOT NULL, - current_summary TEXT, - artifact_refs_json TEXT NOT NULL, - evidence_refs_json TEXT NOT NULL, - confidence TEXT, - context_policy_json TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS decisions ( - id TEXT PRIMARY KEY, - task_id TEXT NOT NULL, - summary TEXT NOT NULL, - rationale TEXT, - status TEXT NOT NULL, - confidence TEXT, - evidence_refs_json TEXT NOT NULL, - supersedes_decision_id TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE, - FOREIGN KEY(supersedes_decision_id) REFERENCES decisions(id) -); -CREATE INDEX IF NOT EXISTS idx_decisions_task ON decisions(task_id); -CREATE INDEX IF NOT EXISTS idx_decisions_task_status ON decisions(task_id, status); - -CREATE TABLE IF NOT EXISTS open_questions ( - id TEXT PRIMARY KEY, - task_id TEXT NOT NULL, - question TEXT NOT NULL, - priority TEXT NOT NULL, - status TEXT NOT NULL, - answer TEXT, - evidence_refs_json TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS idx_open_questions_task ON open_questions(task_id); -CREATE INDEX IF NOT EXISTS idx_open_questions_task_status ON open_questions(task_id, status); - -CREATE TABLE IF NOT EXISTS runs ( - id TEXT PRIMARY KEY, - task_id TEXT NOT NULL, - actor_type TEXT NOT NULL, - actor_id TEXT, - run_type TEXT NOT NULL, - status TEXT NOT NULL, - input_ref TEXT, - output_ref TEXT, - started_at TEXT NOT NULL, - ended_at TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS idx_runs_task ON runs(task_id); - -CREATE TABLE IF NOT EXISTS context_bundles ( - id TEXT PRIMARY KEY, - task_id TEXT NOT NULL, - build_reason TEXT NOT NULL, - state_snapshot_json TEXT NOT NULL, - included_decision_refs_json TEXT NOT NULL, - included_open_question_refs_json TEXT NOT NULL, - included_artifact_refs_json TEXT NOT NULL, - included_evidence_refs_json TEXT NOT NULL, - expected_output_schema_json TEXT NOT NULL, - created_at TEXT NOT NULL, - metadata_json TEXT, - FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS idx_context_bundles_task ON context_bundles(task_id); -""" - - -def _task_column_names(conn: sqlite3.Connection) -> set[str]: - """Get existing task table column names.""" - rows = conn.execute("PRAGMA table_info(tasks)").fetchall() - return {row["name"] for row in rows} - - -def _ensure_task_phase2_columns(conn: sqlite3.Connection) -> None: - """Ensure Phase2 columns exist in tasks table.""" - columns = _task_column_names(conn) - for name, ddl in TASK_PHASE2_COLUMNS.items(): - if name not in columns: - conn.execute(f"ALTER TABLE tasks ADD COLUMN {name} {ddl}") - conn.execute("UPDATE tasks SET retry_count = 0 WHERE retry_count IS NULL") - conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_updated_at ON tasks(updated_at)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_idempotency_key ON tasks(idempotency_key)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_trace_id ON tasks(trace_id)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_reply_state ON tasks(reply_state)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_original_task_id ON tasks(original_task_id)") - - -def init_db(conn: sqlite3.Connection) -> None: - """Initialize database schema.""" - conn.executescript(SCHEMA_SQL) - _ensure_task_phase2_columns(conn) \ No newline at end of file +"""Deprecated shim.""" +from agent_taskstate.cli.db import * diff --git a/src/cli/errors.py b/src/cli/errors.py index 02221c1..3653a87 100644 --- a/src/cli/errors.py +++ b/src/cli/errors.py @@ -1,37 +1,2 @@ -""" -CLI Exception Classes -""" - -from __future__ import annotations - -from typing import Optional - - -class AgentTaskstateError(Exception): - """Base error for agent-taskstate CLI.""" - code = "validation_error" - - def __init__(self, message: str, *, code: Optional[str] = None) -> None: - super().__init__(message) - if code: - self.code = code - - -class NotFoundError(AgentTaskstateError): - """Raised when a resource is not found.""" - code = "not_found" - - -class ConflictError(AgentTaskstateError): - """Raised when there is a conflict (e.g., revision mismatch).""" - code = "conflict" - - -class InvalidTransitionError(AgentTaskstateError): - """Raised when a status transition is invalid.""" - code = "invalid_transition" - - -class DependencyBlockedError(AgentTaskstateError): - """Raised when a dependency blocks the operation.""" - code = "dependency_blocked" \ No newline at end of file +"""Deprecated shim.""" +from agent_taskstate.cli.errors import * diff --git a/src/cli/fetch.py b/src/cli/fetch.py index 860b963..5407f20 100644 --- a/src/cli/fetch.py +++ b/src/cli/fetch.py @@ -1,48 +1,2 @@ -""" -CLI Fetch Helpers -""" - -from __future__ import annotations - -import sqlite3 -from typing import Any, Sequence - -from .errors import NotFoundError - - -def fetch_one(conn: sqlite3.Connection, sql: str, params: Sequence[Any]) -> sqlite3.Row: - """Fetch one row or raise NotFoundError.""" - row = conn.execute(sql, params).fetchone() - if row is None: - raise NotFoundError("resource not found") - return row - - -def get_task(conn: sqlite3.Connection, task_id: str) -> sqlite3.Row: - """Get task by ID.""" - return fetch_one(conn, "SELECT * FROM tasks WHERE id = ?", (task_id,)) - - -def get_task_state(conn: sqlite3.Connection, task_id: str) -> sqlite3.Row: - """Get task_state by task_id.""" - return fetch_one(conn, "SELECT * FROM task_states WHERE task_id = ?", (task_id,)) - - -def get_decision(conn: sqlite3.Connection, decision_id: str) -> sqlite3.Row: - """Get decision by ID.""" - return fetch_one(conn, "SELECT * FROM decisions WHERE id = ?", (decision_id,)) - - -def get_question(conn: sqlite3.Connection, question_id: str) -> sqlite3.Row: - """Get question by ID.""" - return fetch_one(conn, "SELECT * FROM open_questions WHERE id = ?", (question_id,)) - - -def get_run(conn: sqlite3.Connection, run_id: str) -> sqlite3.Row: - """Get run by ID.""" - return fetch_one(conn, "SELECT * FROM runs WHERE id = ?", (run_id,)) - - -def get_bundle(conn: sqlite3.Connection, bundle_id: str) -> sqlite3.Row: - """Get context_bundle by ID.""" - return fetch_one(conn, "SELECT * FROM context_bundles WHERE id = ?", (bundle_id,)) \ No newline at end of file +"""Deprecated shim.""" +from agent_taskstate.cli.fetch import * diff --git a/src/cli/models.py b/src/cli/models.py index c0a3e68..c60fe2d 100644 --- a/src/cli/models.py +++ b/src/cli/models.py @@ -1,93 +1,2 @@ -""" -CLI Models and Row Conversion -""" - -from __future__ import annotations - -import json -import sqlite3 -from typing import Any, Dict, Optional - -from .typed_ref import typed_ref - - -def jdump(value: Any) -> str: - """Dump value to JSON string.""" - return json.dumps(value, ensure_ascii=False, separators=(",", ":")) - - -def jload(value: Optional[str], default: Any = None) -> Any: - """Load JSON string to value.""" - if value is None: - return default - return json.loads(value) - - -def row_to_task(row: sqlite3.Row) -> Dict[str, Any]: - """Convert task row to dict.""" - return dict(row) - - -def row_to_task_state(row: sqlite3.Row) -> Dict[str, Any]: - """Convert task_state row to dict.""" - return { - "task_id": row["task_id"], - "revision": row["revision"], - "current_step": row["current_step"], - "constraints": jload(row["constraints_json"], []), - "done_when": jload(row["done_when_json"], []), - "current_summary": row["current_summary"], - "artifact_refs": jload(row["artifact_refs_json"], []), - "evidence_refs": jload(row["evidence_refs_json"], []), - "confidence": row["confidence"], - "context_policy": jload(row["context_policy_json"], {}), - "created_at": row["created_at"], - "updated_at": row["updated_at"], - } - - -def row_to_decision(row: sqlite3.Row) -> Dict[str, Any]: - """Convert decision row to dict.""" - data = dict(row) - data["evidence_refs"] = jload(row["evidence_refs_json"], []) - data["ref"] = typed_ref("agent-taskstate", "decision", row["id"]) - del data["evidence_refs_json"] - return data - - -def row_to_question(row: sqlite3.Row) -> Dict[str, Any]: - """Convert question row to dict.""" - data = dict(row) - data["evidence_refs"] = jload(row["evidence_refs_json"], []) - data["ref"] = typed_ref("agent-taskstate", "question", row["id"]) - del data["evidence_refs_json"] - return data - - -def row_to_run(row: sqlite3.Row) -> Dict[str, Any]: - """Convert run row to dict.""" - data = dict(row) - data["ref"] = typed_ref("agent-taskstate", "run", row["id"]) - return data - - -def row_to_bundle(row: sqlite3.Row) -> Dict[str, Any]: - """Convert context_bundle row to dict.""" - data = dict(row) - data["state_snapshot"] = jload(row["state_snapshot_json"], {}) - data["included_decision_refs"] = jload(row["included_decision_refs_json"], []) - data["included_open_question_refs"] = jload(row["included_open_question_refs_json"], []) - data["included_artifact_refs"] = jload(row["included_artifact_refs_json"], []) - data["included_evidence_refs"] = jload(row["included_evidence_refs_json"], []) - data["expected_output_schema"] = jload(row["expected_output_schema_json"], {}) - data["ref"] = typed_ref("agent-taskstate", "context_bundle", row["id"]) - for key in [ - "state_snapshot_json", - "included_decision_refs_json", - "included_open_question_refs_json", - "included_artifact_refs_json", - "included_evidence_refs_json", - "expected_output_schema_json", - ]: - del data[key] - return data \ No newline at end of file +"""Deprecated shim.""" +from agent_taskstate.cli.models import * diff --git a/src/cli/parser.py b/src/cli/parser.py index 7dda069..5612d16 100644 --- a/src/cli/parser.py +++ b/src/cli/parser.py @@ -1,207 +1,2 @@ -""" -CLI Argument Parser -""" - -from __future__ import annotations - -import argparse -import os -from pathlib import Path - -from .constants import APP_NAME, DEFAULT_DB_PATH -from .commands import ( - cmd_init, - cmd_task_create, - cmd_task_show, - cmd_task_list, - cmd_task_update, - cmd_task_set_status, - cmd_state_get, - cmd_state_put, - cmd_state_patch, - cmd_decision_add, - cmd_decision_list, - cmd_decision_accept, - cmd_decision_reject, - cmd_question_add, - cmd_question_list, - cmd_question_answer, - cmd_question_defer, - cmd_run_start, - cmd_run_list, - cmd_run_finish, - cmd_context_build, - cmd_context_show, - cmd_export_task, -) - - -def build_parser() -> argparse.ArgumentParser: - """Build CLI argument parser.""" - parser = argparse.ArgumentParser(prog=APP_NAME, description="agent-taskstate CLI") - parser.add_argument( - "--db", - default=os.environ.get("AGENT_TASKSTATE_DB", DEFAULT_DB_PATH), - help="SQLite DB path", - ) - - sub = parser.add_subparsers(dest="command") - - # init - p_init = sub.add_parser("init", help="initialize SQLite database") - p_init.set_defaults(func=cmd_init) - - # task - p_task = sub.add_parser("task", help="task commands") - sp_task = p_task.add_subparsers(dest="task_command") - - p_task_create = sp_task.add_parser("create", help="create task") - p_task_create.add_argument("--json") - p_task_create.add_argument("--file") - p_task_create.set_defaults(func=cmd_task_create) - - p_task_show = sp_task.add_parser("show", help="show task") - p_task_show.add_argument("--task", required=True) - p_task_show.set_defaults(func=cmd_task_show) - - p_task_list = sp_task.add_parser("list", help="list tasks") - p_task_list.add_argument("--status") - p_task_list.add_argument("--kind") - p_task_list.add_argument("--owner-type") - p_task_list.add_argument("--owner-id") - p_task_list.add_argument("--priority") - p_task_list.add_argument("--updated-before") - p_task_list.add_argument("--idempotency-key") - p_task_list.add_argument("--reply-state") - p_task_list.add_argument("--trace-id") - p_task_list.set_defaults(func=cmd_task_list) - - p_task_update = sp_task.add_parser("update", help="update task") - p_task_update.add_argument("--task", required=True) - p_task_update.add_argument("--json") - p_task_update.add_argument("--file") - p_task_update.set_defaults(func=cmd_task_update) - - p_task_status = sp_task.add_parser("set-status", help="set task status") - p_task_status.add_argument("--task", required=True) - p_task_status.add_argument("--to", required=True) - p_task_status.add_argument("--reason") - p_task_status.add_argument("--reason-required", action="store_true", default=False) - p_task_status.set_defaults(func=cmd_task_set_status) - - # state - p_state = sub.add_parser("state", help="task state commands") - sp_state = p_state.add_subparsers(dest="state_command") - - p_state_get = sp_state.add_parser("get") - p_state_get.add_argument("--task", required=True) - p_state_get.set_defaults(func=cmd_state_get) - - p_state_put = sp_state.add_parser("put") - p_state_put.add_argument("--task", required=True) - p_state_put.add_argument("--json") - p_state_put.add_argument("--file") - p_state_put.set_defaults(func=cmd_state_put) - - p_state_patch = sp_state.add_parser("patch") - p_state_patch.add_argument("--task", required=True) - p_state_patch.add_argument("--json") - p_state_patch.add_argument("--file") - p_state_patch.add_argument("--expected-revision", required=True, type=int) - p_state_patch.set_defaults(func=cmd_state_patch) - - # decision - p_decision = sub.add_parser("decision", help="decision commands") - sp_decision = p_decision.add_subparsers(dest="decision_command") - - p_dec_add = sp_decision.add_parser("add") - p_dec_add.add_argument("--task", required=True) - p_dec_add.add_argument("--json") - p_dec_add.add_argument("--file") - p_dec_add.set_defaults(func=cmd_decision_add) - - p_dec_list = sp_decision.add_parser("list") - p_dec_list.add_argument("--task", required=True) - p_dec_list.add_argument("--status") - p_dec_list.set_defaults(func=cmd_decision_list) - - p_dec_accept = sp_decision.add_parser("accept") - p_dec_accept.add_argument("--decision", required=True) - p_dec_accept.set_defaults(func=cmd_decision_accept) - - p_dec_reject = sp_decision.add_parser("reject") - p_dec_reject.add_argument("--decision", required=True) - p_dec_reject.set_defaults(func=cmd_decision_reject) - - # question - p_question = sub.add_parser("question", help="open question commands") - sp_question = p_question.add_subparsers(dest="question_command") - - p_q_add = sp_question.add_parser("add") - p_q_add.add_argument("--task", required=True) - p_q_add.add_argument("--json") - p_q_add.add_argument("--file") - p_q_add.set_defaults(func=cmd_question_add) - - p_q_list = sp_question.add_parser("list") - p_q_list.add_argument("--task", required=True) - p_q_list.add_argument("--status") - p_q_list.add_argument("--priority") - p_q_list.set_defaults(func=cmd_question_list) - - p_q_answer = sp_question.add_parser("answer") - p_q_answer.add_argument("--question", required=True) - p_q_answer.add_argument("--answer", required=True) - p_q_answer.set_defaults(func=cmd_question_answer) - - p_q_defer = sp_question.add_parser("defer") - p_q_defer.add_argument("--question", required=True) - p_q_defer.add_argument("--reason") - p_q_defer.set_defaults(func=cmd_question_defer) - - # run - p_run = sub.add_parser("run", help="run commands") - sp_run = p_run.add_subparsers(dest="run_command") - - p_run_start = sp_run.add_parser("start") - p_run_start.add_argument("--task", required=True) - p_run_start.add_argument("--run-type", required=True) - p_run_start.add_argument("--actor-type", required=True) - p_run_start.add_argument("--actor-id") - p_run_start.add_argument("--input-ref") - p_run_start.set_defaults(func=cmd_run_start) - - p_run_list = sp_run.add_parser("list") - p_run_list.add_argument("--task", required=True) - p_run_list.add_argument("--status") - p_run_list.set_defaults(func=cmd_run_list) - - p_run_finish = sp_run.add_parser("finish") - p_run_finish.add_argument("--run", required=True) - p_run_finish.add_argument("--status", required=True) - p_run_finish.add_argument("--output-ref") - p_run_finish.set_defaults(func=cmd_run_finish) - - # context - p_context = sub.add_parser("context", help="context bundle commands") - sp_context = p_context.add_subparsers(dest="context_command") - - p_ctx_build = sp_context.add_parser("build") - p_ctx_build.add_argument("--task", required=True) - p_ctx_build.add_argument("--reason", required=True) - p_ctx_build.set_defaults(func=cmd_context_build) - - p_ctx_show = sp_context.add_parser("show") - p_ctx_show.add_argument("--bundle", required=True) - p_ctx_show.set_defaults(func=cmd_context_show) - - # export - p_export = sub.add_parser("export", help="export commands") - sp_export = p_export.add_subparsers(dest="export_command") - - p_export_task = sp_export.add_parser("task") - p_export_task.add_argument("--task", required=True) - p_export_task.add_argument("--output", required=True) - p_export_task.set_defaults(func=cmd_export_task) - - return parser \ No newline at end of file +"""Deprecated shim.""" +from agent_taskstate.cli.parser import * diff --git a/src/cli/typed_ref.py b/src/cli/typed_ref.py index d135883..17e950f 100644 --- a/src/cli/typed_ref.py +++ b/src/cli/typed_ref.py @@ -1,44 +1,2 @@ -""" -CLI Typed Reference Utilities - -Format: ::: -""" - -from __future__ import annotations - -DEFAULT_PROVIDER = "local" - - -def typed_ref( - domain: str, - entity_type: str, - entity_id: str, - provider: str = DEFAULT_PROVIDER, -) -> str: - """Generate 4-segment typed_ref.""" - return f"{domain}:{entity_type}:{provider}:{entity_id}" - - -def task_ref(task_id: str) -> str: - """Generate task typed_ref.""" - return typed_ref("agent-taskstate", "task", task_id) - - -def decision_ref(decision_id: str) -> str: - """Generate decision typed_ref.""" - return typed_ref("agent-taskstate", "decision", decision_id) - - -def question_ref(question_id: str) -> str: - """Generate question typed_ref.""" - return typed_ref("agent-taskstate", "question", question_id) - - -def run_ref(run_id: str) -> str: - """Generate run typed_ref.""" - return typed_ref("agent-taskstate", "run", run_id) - - -def bundle_ref(bundle_id: str) -> str: - """Generate context_bundle typed_ref.""" - return typed_ref("agent-taskstate", "context_bundle", bundle_id) \ No newline at end of file +"""Deprecated shim.""" +from agent_taskstate.cli.typed_ref import * diff --git a/src/cli/utils.py b/src/cli/utils.py index f6e53c9..601d4a3 100644 --- a/src/cli/utils.py +++ b/src/cli/utils.py @@ -1,42 +1,2 @@ -""" -CLI Utility Functions -""" - -from __future__ import annotations - -import json -import sys -import uuid -from datetime import datetime, timezone -from typing import Any - -from .constants import ISO - - -def now_utc() -> str: - """Return current UTC timestamp in ISO format.""" - return datetime.now(timezone.utc).strftime(ISO) - - -def gen_id() -> str: - """Generate unique ID.""" - return uuid.uuid4().hex - - -def json_ok(data: Any) -> int: - """Output success JSON and return 0.""" - print(json.dumps({"ok": True, "data": data, "error": None}, ensure_ascii=False, indent=2)) - return 0 - - -def json_error(code: str, message: str) -> int: - """Output error JSON and return 1.""" - print( - json.dumps( - {"ok": False, "data": None, "error": {"code": code, "message": message}}, - ensure_ascii=False, - indent=2, - ), - file=sys.stdout, - ) - return 1 \ No newline at end of file +"""Deprecated shim.""" +from agent_taskstate.cli.utils import * diff --git a/src/cli/validation.py b/src/cli/validation.py index c99cbbe..1db893d 100644 --- a/src/cli/validation.py +++ b/src/cli/validation.py @@ -1,185 +1,2 @@ -""" -CLI Validation and Guards -""" - -from __future__ import annotations - -import sqlite3 -from typing import Any, Dict, List, Optional, Sequence - -from .constants import ( - ALLOWED_TRANSITIONS, - CONFIDENCE_LEVELS, - DECISION_STATUSES, - OWNER_TYPES, - QUESTION_PRIORITIES, - QUESTION_STATUSES, - REPLY_STATES, - RUN_STATUSES, - RUN_TYPES, - TASK_KINDS, - TASK_PRIORITIES, - TASK_STATUSES, -) -from .errors import AgentTaskstateError, DependencyBlockedError, InvalidTransitionError -from .fetch import get_task_state -from .models import jdump, row_to_task_state - - -def require_in(value: str, allowed: Sequence[str] | set[str], field: str) -> None: - """Require value to be in allowed set.""" - if value not in allowed: - raise AgentTaskstateError(f"invalid {field}: {value}; allowed={sorted(allowed)}") - - -def load_json_arg(value: Optional[str] = None, file_path: Optional[str] = None) -> Any: - """Load JSON from argument or file.""" - import json - from pathlib import Path - - if value and file_path: - raise AgentTaskstateError("pass either --json or --file, not both") - if file_path: - return json.loads(Path(file_path).read_text(encoding="utf-8")) - if value: - return json.loads(value) - raise AgentTaskstateError("missing JSON payload; pass --json or --file") - - -def _normalize_optional_json_text(value: Any) -> Optional[str]: - """Normalize optional JSON to text.""" - if value is None: - return None - if isinstance(value, str): - return value - return jdump(value) - - -def validate_task_payload(data: Dict[str, Any]) -> None: - """Validate task payload.""" - require_in(data["kind"], TASK_KINDS, "kind") - require_in(data.get("status", "draft"), TASK_STATUSES, "status") - require_in(data.get("priority", "medium"), TASK_PRIORITIES, "priority") - require_in(data.get("owner_type", "human"), OWNER_TYPES, "owner_type") - if data.get("parent_task_id") and not isinstance(data["parent_task_id"], str): - raise AgentTaskstateError("parent_task_id must be string") - if data.get("reply_state") is not None: - require_in(data["reply_state"], REPLY_STATES, "reply_state") - if data.get("retry_count") is not None: - if not isinstance(data["retry_count"], int) or data["retry_count"] < 0: - raise AgentTaskstateError("retry_count must be non-negative integer") - - -def validate_state_payload(data: Dict[str, Any]) -> None: - """Validate task_state payload.""" - if not data.get("current_step"): - raise AgentTaskstateError("current_step is required") - if not isinstance(data.get("constraints", []), list): - raise AgentTaskstateError("constraints must be array") - if not isinstance(data.get("done_when", []), list): - raise AgentTaskstateError("done_when must be array") - if not isinstance(data.get("artifact_refs", []), list): - raise AgentTaskstateError("artifact_refs must be array") - if not isinstance(data.get("evidence_refs", []), list): - raise AgentTaskstateError("evidence_refs must be array") - if not isinstance(data.get("context_policy", {}), dict): - raise AgentTaskstateError("context_policy must be object") - require_in(data.get("confidence"), CONFIDENCE_LEVELS, "confidence") - - -def validate_decision_payload(data: Dict[str, Any]) -> None: - """Validate decision payload.""" - if not data.get("summary"): - raise AgentTaskstateError("summary is required") - require_in(data.get("status", "proposed"), DECISION_STATUSES, "decision.status") - require_in(data.get("confidence", "medium"), CONFIDENCE_LEVELS, "decision.confidence") - if not isinstance(data.get("evidence_refs", []), list): - raise AgentTaskstateError("evidence_refs must be array") - - -def validate_question_payload(data: Dict[str, Any]) -> None: - """Validate question payload.""" - if not data.get("question"): - raise AgentTaskstateError("question is required") - require_in(data.get("priority", "medium"), QUESTION_PRIORITIES, "question.priority") - require_in(data.get("status", "open"), QUESTION_STATUSES, "question.status") - if not isinstance(data.get("evidence_refs", []), list): - raise AgentTaskstateError("evidence_refs must be array") - - -def count_open_high_questions(conn: sqlite3.Connection, task_id: str) -> int: - """Count open high-priority questions.""" - row = conn.execute( - "SELECT COUNT(*) AS c FROM open_questions WHERE task_id = ? AND status = 'open' AND priority = 'high'", - (task_id,), - ).fetchone() - return int(row["c"]) - - -def count_relevant_decisions(conn: sqlite3.Connection, task_id: str) -> int: - """Count accepted/proposed decisions.""" - row = conn.execute( - "SELECT COUNT(*) AS c FROM decisions WHERE task_id = ? AND status IN ('accepted', 'proposed')", - (task_id,), - ).fetchone() - return int(row["c"]) - - -def is_done_when_satisfied(done_when: List[Any]) -> bool: - """Check if all done_when items are satisfied.""" - if not done_when: - return False - for item in done_when: - if isinstance(item, dict): - if not bool(item.get("done", False)): - return False - else: - return False - return True - - -def validate_status_transition( - conn: sqlite3.Connection, task_row: sqlite3.Row, to_status: str -) -> None: - """Validate status transition with guards.""" - require_in(to_status, TASK_STATUSES, "status") - from_status = task_row["status"] - if to_status not in ALLOWED_TRANSITIONS.get(from_status, set()): - raise InvalidTransitionError(f"transition not allowed: {from_status} -> {to_status}") - - task = dict(task_row) - state: Optional[Dict[str, Any]] = None - try: - state = row_to_task_state(get_task_state(conn, task_row["id"])) - except Exception: - state = None - - if to_status == "ready": - if not task["goal"]: - raise DependencyBlockedError("goal must be set before moving to ready") - if not state or not state["done_when"]: - raise DependencyBlockedError("done_when must contain at least one item before moving to ready") - if not task["kind"]: - raise DependencyBlockedError("kind must be set before moving to ready") - - if to_status == "in_progress": - if not state: - raise DependencyBlockedError("task_state must exist before moving to in_progress") - if not state["current_step"]: - raise DependencyBlockedError("current_step must be set before moving to in_progress") - - if to_status == "review": - if count_open_high_questions(conn, task_row["id"]) > 0: - raise DependencyBlockedError("high priority open questions must be 0 before moving to review") - if count_relevant_decisions(conn, task_row["id"]) == 0: - raise DependencyBlockedError("at least one accepted/proposed decision is required before moving to review") - - if to_status == "done": - if from_status != "review": - raise InvalidTransitionError("done is only allowed from review") - if not state: - raise DependencyBlockedError("task_state must exist before moving to done") - if not is_done_when_satisfied(state["done_when"]): - raise DependencyBlockedError("all done_when items must be satisfied before moving to done") - if not state.get("current_summary"): - raise DependencyBlockedError("current_summary must be set before moving to done") \ No newline at end of file +"""Deprecated shim.""" +from agent_taskstate.cli.validation import * diff --git a/src/context_bundle.py b/src/context_bundle.py index 3e0525b..60edb33 100644 --- a/src/context_bundle.py +++ b/src/context_bundle.py @@ -1,577 +1,2 @@ -""" -Context Bundle Service - -Manages context bundle creation and audit trails. - -Key features: -- Bundle generation with source refs tracking -- Audit information (purpose, rebuild_level, generator_version) -- Source refs stored in separate table for auditability -- gzip compression for large JSON fields -- LRU cache for latest bundle retrieval -- Differential bundle storage for incremental updates -""" - -from __future__ import annotations - -import gzip -import json -import sqlite3 -from dataclasses import dataclass, field -from datetime import datetime, timezone -from functools import lru_cache -from typing import Any, Dict, List, Optional, Tuple - -from .typed_ref import canonicalize_ref - -# Compression threshold: compress if JSON exceeds this size (bytes) -COMPRESSION_THRESHOLD = 1024 # 1KB - - -@dataclass -class BundleSource: - """A single source reference in a context bundle.""" - - id: str - context_bundle_id: str - typed_ref: str - source_kind: str - selected_raw: bool - metadata_json: Optional[str] - - def to_dict(self) -> Dict[str, Any]: - """Convert source record to dictionary for JSON output.""" - return { - "id": self.id, - "context_bundle_id": self.context_bundle_id, - "typed_ref": self.typed_ref, - "source_kind": self.source_kind, - "selected_raw": self.selected_raw, - "metadata": json.loads(self.metadata_json) if self.metadata_json else None, - } - - -@dataclass -class ContextBundle: - """A context bundle with audit information.""" - - id: str - task_id: str - purpose: str - rebuild_level: str - summary: Optional[str] - state_snapshot_json: str - decision_digest_json: Optional[str] - question_digest_json: Optional[str] - diagnostics_json: Optional[str] - raw_included: bool - generator_version: str - generated_at: str - created_at: str - sources: List[BundleSource] = field(default_factory=list) - # Compression flags (stored separately in DB) - _state_snapshot_compressed: bool = False - _decision_digest_compressed: bool = False - _question_digest_compressed: bool = False - _diagnostics_compressed: bool = False - - def get_source_refs(self) -> List[str]: - """Get list of all source refs.""" - return [s.typed_ref for s in self.sources] - - def to_dict(self) -> Dict[str, Any]: - """Convert to dictionary for JSON output.""" - return { - "id": self.id, - "task_id": self.task_id, - "purpose": self.purpose, - "rebuild_level": self.rebuild_level, - "summary": self.summary, - "state_snapshot": decompress_json(self.state_snapshot_json, self._state_snapshot_compressed), - "decision_digest": decompress_json(self.decision_digest_json, self._decision_digest_compressed) if self.decision_digest_json else None, - "question_digest": decompress_json(self.question_digest_json, self._question_digest_compressed) if self.question_digest_json else None, - "diagnostics": decompress_json(self.diagnostics_json, self._diagnostics_compressed) if self.diagnostics_json else None, - "raw_included": self.raw_included, - "generator_version": self.generator_version, - "generated_at": self.generated_at, - "created_at": self.created_at, - "source_refs": self.get_source_refs(), - "source_count": len(self.sources), - "sources": [source.to_dict() for source in self.sources], - } - - -REBUILD_LEVELS = {"L1", "L2", "L3"} - -PURPOSE_TYPES = { - "continue_work", - "review_prepare", - "resume_after_block", - "decision_support", - "other", -} - -SOURCE_KINDS = { - "task", - "decision", - "open_question", - "evidence", - "artifact", - "run", - "tracker_issue", -} - - -def compress_json(data: Any, threshold: int = COMPRESSION_THRESHOLD) -> Tuple[str, bool]: - """ - Compress JSON data if it exceeds threshold. - - Returns: - (stored_data, is_compressed) tuple - """ - json_str = json.dumps(data) - if len(json_str) < threshold: - return (json_str, False) - - compressed = gzip.compress(json_str.encode("utf-8")) - # Store as base64-encoded string for SQLite TEXT column - import base64 - return (base64.b64encode(compressed).decode("ascii"), True) - - -def decompress_json(stored_data: str, is_compressed: bool) -> Any: - """ - Decompress JSON data if it was compressed. - - Args: - stored_data: Stored string (either raw JSON or base64-encoded gzip) - is_compressed: Whether the data was compressed - - Returns: - Parsed JSON object - """ - if not is_compressed: - return json.loads(stored_data) - - import base64 - compressed = base64.b64decode(stored_data.encode("ascii")) - json_str = gzip.decompress(compressed).decode("utf-8") - return json.loads(json_str) - - -def compute_diff(prev_bundle: Optional[ContextBundle], new_snapshot: Dict[str, Any]) -> Dict[str, Any]: - """ - Compute differential update between bundles. - - Returns only changed fields to reduce storage. - """ - if prev_bundle is None: - return new_snapshot - - prev_snapshot = decompress_json( - prev_bundle.state_snapshot_json, - prev_bundle._state_snapshot_compressed - ) - - diff = {} - for key, new_val in new_snapshot.items(): - prev_val = prev_snapshot.get(key) - if new_val != prev_val: - diff[key] = new_val - - return diff if diff else new_snapshot - - -def decompress_bundle_json(bundle: ContextBundle) -> Dict[str, Any]: - """Decompress all JSON fields in a bundle.""" - result = {} - - # state_snapshot_json is always present - is_compressed = getattr(bundle, "_state_snapshot_compressed", False) - result["state_snapshot"] = decompress_json(bundle.state_snapshot_json, is_compressed) - - # Optional JSON fields - if bundle.decision_digest_json: - is_compressed = getattr(bundle, "_decision_digest_compressed", False) - result["decision_digest"] = decompress_json(bundle.decision_digest_json, is_compressed) - - if bundle.question_digest_json: - is_compressed = getattr(bundle, "_question_digest_compressed", False) - result["question_digest"] = decompress_json(bundle.question_digest_json, is_compressed) - - if bundle.diagnostics_json: - is_compressed = getattr(bundle, "_diagnostics_compressed", False) - result["diagnostics"] = decompress_json(bundle.diagnostics_json, is_compressed) - - return result - - -def now_utc() -> str: - """Return current UTC timestamp in ISO format.""" - return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ") - - -def gen_id() -> str: - """Generate a unique ID.""" - import uuid - - return uuid.uuid4().hex - - -class ContextBundleService: - """Service for creating and managing context bundles.""" - - # Cache for latest bundle lookups (per task_id) - # Using instance-level cache that can be cleared - _latest_cache: Dict[str, ContextBundle] = {} - _cache_max_size: int = 100 - - def __init__(self, conn: sqlite3.Connection, generator_version: str = "1.0.0"): - self.conn = conn - self.generator_version = generator_version - - def clear_cache(self) -> None: - """Clear the latest bundle cache.""" - self._latest_cache.clear() - - def create_bundle( - self, - task_id: str, - purpose: str, - rebuild_level: str, - state_snapshot: Dict[str, Any], - decision_digest: Optional[Dict[str, Any]] = None, - question_digest: Optional[Dict[str, Any]] = None, - diagnostics: Optional[Dict[str, Any]] = None, - summary: Optional[str] = None, - raw_included: bool = False, - use_diff: bool = False, - ) -> ContextBundle: - """ - Create a new context bundle. - - Args: - use_diff: If True, compute differential against previous bundle - """ - if purpose not in PURPOSE_TYPES: - raise ValueError(f"Invalid purpose: {purpose}") - if rebuild_level not in REBUILD_LEVELS: - raise ValueError(f"Invalid rebuild_level: {rebuild_level}") - - bundle_id = gen_id() - now = now_utc() - - # Get previous bundle for diff calculation - prev_bundle = None - if use_diff: - prev_bundle = self._get_latest_bundle_no_cache(task_id) - state_snapshot = compute_diff(prev_bundle, state_snapshot) - - # Compress JSON fields - state_data, state_compressed = compress_json(state_snapshot) - decision_data, decision_compressed = compress_json(decision_digest) if decision_digest else (None, False) - question_data, question_compressed = compress_json(question_digest) if question_digest else (None, False) - diag_data, diag_compressed = compress_json(diagnostics) if diagnostics else (None, False) - - self.conn.execute( - """ - INSERT INTO context_bundle - (id, task_id, purpose, rebuild_level, summary, state_snapshot_json, - decision_digest_json, question_digest_json, diagnostics_json, raw_included, - generator_version, generated_at, created_at, - state_snapshot_compressed, decision_digest_compressed, question_digest_compressed, diagnostics_compressed) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - bundle_id, - task_id, - purpose, - rebuild_level, - summary, - state_data, - decision_data, - question_data, - diag_data, - 1 if raw_included else 0, - self.generator_version, - now, - now, - 1 if state_compressed else 0, - 1 if decision_compressed else 0, - 1 if question_compressed else 0, - 1 if diag_compressed else 0, - ), - ) - - bundle = ContextBundle( - id=bundle_id, - task_id=task_id, - purpose=purpose, - rebuild_level=rebuild_level, - summary=summary, - state_snapshot_json=state_data, - decision_digest_json=decision_data, - question_digest_json=question_data, - diagnostics_json=diag_data, - raw_included=raw_included, - generator_version=self.generator_version, - generated_at=now, - created_at=now, - _state_snapshot_compressed=state_compressed, - _decision_digest_compressed=decision_compressed, - _question_digest_compressed=question_compressed, - _diagnostics_compressed=diag_compressed, - ) - - # Invalidate cache for this task - if task_id in self._latest_cache: - del self._latest_cache[task_id] - - return bundle - - def add_source( - self, - bundle_id: str, - typed_ref: str, - source_kind: str, - selected_raw: bool = False, - metadata: Optional[Dict[str, Any]] = None, - ) -> BundleSource: - """Add a source reference to a bundle.""" - if source_kind not in SOURCE_KINDS: - raise ValueError(f"Invalid source_kind: {source_kind}") - - canonical_ref = canonicalize_ref(typed_ref) - source_id = gen_id() - now = now_utc() - - self.conn.execute( - """ - INSERT INTO context_bundle_source - (id, context_bundle_id, typed_ref, source_kind, selected_raw, metadata_json, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, - ( - source_id, - bundle_id, - canonical_ref, - source_kind, - 1 if selected_raw else 0, - json.dumps(metadata) if metadata is not None else None, - now, - ), - ) - - return BundleSource( - id=source_id, - context_bundle_id=bundle_id, - typed_ref=canonical_ref, - source_kind=source_kind, - selected_raw=selected_raw, - metadata_json=json.dumps(metadata) if metadata is not None else None, - ) - - def get_bundle(self, bundle_id: str) -> Optional[ContextBundle]: - """Get a context bundle by ID.""" - cursor = self.conn.execute( - """ - SELECT id, task_id, purpose, rebuild_level, summary, state_snapshot_json, - decision_digest_json, question_digest_json, diagnostics_json, raw_included, - generator_version, generated_at, created_at, - state_snapshot_compressed, decision_digest_compressed, question_digest_compressed, diagnostics_compressed - FROM context_bundle - WHERE id = ? - """, - (bundle_id,), - ) - - row = cursor.fetchone() - if not row: - return None - - bundle = ContextBundle( - id=row[0], - task_id=row[1], - purpose=row[2], - rebuild_level=row[3], - summary=row[4], - state_snapshot_json=row[5], - decision_digest_json=row[6], - question_digest_json=row[7], - diagnostics_json=row[8], - raw_included=bool(row[9]), - generator_version=row[10], - generated_at=row[11], - created_at=row[12], - _state_snapshot_compressed=bool(row[13]) if len(row) > 13 else False, - _decision_digest_compressed=bool(row[14]) if len(row) > 14 else False, - _question_digest_compressed=bool(row[15]) if len(row) > 15 else False, - _diagnostics_compressed=bool(row[16]) if len(row) > 16 else False, - ) - bundle.sources = self._load_sources(bundle_id) - return bundle - - def _get_latest_bundle_no_cache(self, task_id: str) -> Optional[ContextBundle]: - """Get latest bundle without using cache.""" - cursor = self.conn.execute( - """ - SELECT id FROM context_bundle - WHERE task_id = ? - ORDER BY generated_at DESC - LIMIT 1 - """, - (task_id,), - ) - - row = cursor.fetchone() - if not row: - return None - - return self.get_bundle(row[0]) - - def get_latest_bundle(self, task_id: str) -> Optional[ContextBundle]: - """Get the latest context bundle for a task (cached).""" - # Check cache first - if task_id in self._latest_cache: - return self._latest_cache[task_id] - - bundle = self._get_latest_bundle_no_cache(task_id) - if bundle: - # Manage cache size - if len(self._latest_cache) >= self._cache_max_size: - # Remove oldest entry (simple FIFO) - oldest_key = next(iter(self._latest_cache)) - del self._latest_cache[oldest_key] - self._latest_cache[task_id] = bundle - - return bundle - - def list_bundles(self, task_id: str) -> List[ContextBundle]: - """List all context bundles for a task.""" - cursor = self.conn.execute( - """ - SELECT id FROM context_bundle - WHERE task_id = ? - ORDER BY generated_at DESC - """, - (task_id,), - ) - - bundles = [] - for row in cursor.fetchall(): - bundle = self.get_bundle(row[0]) - if bundle: - bundles.append(bundle) - - return bundles - - def _load_sources(self, bundle_id: str) -> List[BundleSource]: - """Load sources for a bundle.""" - cursor = self.conn.execute( - """ - SELECT id, context_bundle_id, typed_ref, source_kind, selected_raw, metadata_json, created_at - FROM context_bundle_source - WHERE context_bundle_id = ? - ORDER BY created_at ASC - """, - (bundle_id,), - ) - - sources = [] - for row in cursor.fetchall(): - sources.append( - BundleSource( - id=row[0], - context_bundle_id=row[1], - typed_ref=row[2], - source_kind=row[3], - selected_raw=bool(row[4]), - metadata_json=row[5], - ) - ) - - return sources - - -def create_bundle_tables(conn: sqlite3.Connection) -> None: - """Create context bundle tables if they don't exist.""" - conn.execute( - """ - CREATE TABLE IF NOT EXISTS context_bundle ( - id TEXT PRIMARY KEY, - task_id TEXT NOT NULL, - purpose TEXT NOT NULL, - rebuild_level TEXT NOT NULL, - summary TEXT, - state_snapshot_json TEXT NOT NULL, - decision_digest_json TEXT, - question_digest_json TEXT, - diagnostics_json TEXT, - raw_included INTEGER NOT NULL DEFAULT 0, - generator_version TEXT NOT NULL, - generated_at TEXT NOT NULL, - created_at TEXT NOT NULL, - FOREIGN KEY (task_id) REFERENCES task(id) - ) - """ - ) - - conn.execute( - """ - CREATE TABLE IF NOT EXISTS context_bundle_source ( - id TEXT PRIMARY KEY, - context_bundle_id TEXT NOT NULL, - typed_ref TEXT NOT NULL, - source_kind TEXT NOT NULL, - selected_raw INTEGER NOT NULL DEFAULT 0, - metadata_json TEXT, - created_at TEXT NOT NULL, - FOREIGN KEY (context_bundle_id) REFERENCES context_bundle(id) - ) - """ - ) - - _ensure_column(conn, "context_bundle", "diagnostics_json", "TEXT") - _ensure_column( - conn, - "context_bundle_source", - "selected_raw", - "INTEGER NOT NULL DEFAULT 0", - ) - _ensure_column(conn, "context_bundle_source", "metadata_json", "TEXT") - - # Compression flag columns for gzip-compressed JSON fields - _ensure_column(conn, "context_bundle", "state_snapshot_compressed", "INTEGER NOT NULL DEFAULT 0") - _ensure_column(conn, "context_bundle", "decision_digest_compressed", "INTEGER NOT NULL DEFAULT 0") - _ensure_column(conn, "context_bundle", "question_digest_compressed", "INTEGER NOT NULL DEFAULT 0") - _ensure_column(conn, "context_bundle", "diagnostics_compressed", "INTEGER NOT NULL DEFAULT 0") - - conn.execute( - """ - CREATE INDEX IF NOT EXISTS idx_context_bundle_task - ON context_bundle(task_id, generated_at DESC) - """ - ) - - conn.execute( - """ - CREATE INDEX IF NOT EXISTS idx_context_bundle_source_bundle - ON context_bundle_source(context_bundle_id) - """ - ) - - -def _ensure_column( - conn: sqlite3.Connection, - table_name: str, - column_name: str, - column_ddl: str, -) -> None: - """Add a missing column to an existing table.""" - existing_columns = { - row[1] for row in conn.execute(f"PRAGMA table_info({table_name})").fetchall() - } - if column_name not in existing_columns: - conn.execute( - f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_ddl}" - ) +"""Deprecated compatibility shim.""" +from agent_taskstate.context_bundle import * diff --git a/src/resolver.py b/src/resolver.py index 64fa77f..95c933f 100644 --- a/src/resolver.py +++ b/src/resolver.py @@ -1,744 +1,2 @@ -""" -Context Rebuild Resolver - -Resolves typed_refs for context bundle building. -Implements summary-first retrieval with diagnostics. - -Interface: -- ResolveRef(ref) -> ResolvedRef -- ResolveMany(refs) -> ResolveReport -- LoadSummary(ref) -> SummaryPayload -- LoadSelectedRaw(ref, selector) -> RawPayload -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from enum import Enum -from typing import Any, Dict, List, Optional, Protocol, Set - - -class ResolveStatus(Enum): - """Status of ref resolution.""" - RESOLVED = "resolved" - UNRESOLVED = "unresolved" - UNSUPPORTED = "unsupported" - - -@dataclass -class ResolvedRef: - """Result of resolving a single typed_ref.""" - ref: str - status: ResolveStatus - summary: Optional[str] = None - metadata: Optional[Dict[str, Any]] = None - raw_available: bool = False - error_message: Optional[str] = None - - -@dataclass -class ResolveReport: - """Report of resolving multiple refs.""" - resolved: List[ResolvedRef] = field(default_factory=list) - unresolved: List[ResolvedRef] = field(default_factory=list) - unsupported: List[ResolvedRef] = field(default_factory=list) - - @property - def total_count(self) -> int: - return len(self.resolved) + len(self.unresolved) + len(self.unsupported) - - @property - def success_rate(self) -> float: - if self.total_count == 0: - return 1.0 - return len(self.resolved) / self.total_count - - -@dataclass -class SummaryPayload: - """Summary content for a resolved ref.""" - ref: str - summary: str - metadata: Dict[str, Any] = field(default_factory=dict) - - -@dataclass -class RawPayload: - """Raw content for a resolved ref.""" - ref: str - content: str - metadata: Dict[str, Any] = field(default_factory=dict) - - -@dataclass -class ResolverDiagnostics: - """Diagnostics for bundle build.""" - missing_refs: List[str] = field(default_factory=list) - unsupported_refs: List[str] = field(default_factory=list) - resolver_warnings: List[str] = field(default_factory=list) - partial_bundle: bool = False - - def to_dict(self) -> Dict[str, Any]: - return { - "missing_refs": self.missing_refs, - "unsupported_refs": self.unsupported_refs, - "resolver_warnings": self.resolver_warnings, - "partial_bundle": self.partial_bundle, - } - - -# Conditions for raw descent -RAW_DESCENT_CONDITIONS = { - "before_review", - "conflicting_summaries", - "high_priority_open_question", - "low_confidence_decision", - "investigation_step", - "verification_step", - "operator_request", -} - - -class RefResolver(Protocol): - """Protocol for ref resolvers.""" - - def can_resolve(self, ref: str) -> bool: - """Check if this resolver can handle the ref.""" - ... - - def resolve(self, ref: str) -> ResolvedRef: - """Resolve a single ref.""" - ... - - def load_summary(self, ref: str) -> Optional[SummaryPayload]: - """Load summary for a resolved ref.""" - ... - - def load_raw(self, ref: str, selector: Optional[Dict[str, Any]] = None) -> Optional[RawPayload]: - """Load raw content for a resolved ref.""" - ... - - -class ContextRebuildResolver: - """ - Main resolver for context rebuild. - - Coordinates multiple domain-specific resolvers and provides - summary-first retrieval with diagnostics. - """ - - def __init__(self): - self._resolvers: List[RefResolver] = [] - self._unsupported_domains: Set[str] = set() - - def register_resolver(self, resolver: RefResolver) -> None: - """Register a domain-specific resolver.""" - self._resolvers.append(resolver) - - def resolve_ref(self, ref: str) -> ResolvedRef: - """ - Resolve a single typed_ref. - - Args: - ref: typed_ref string - - Returns: - ResolvedRef with status and metadata - """ - for resolver in self._resolvers: - if resolver.can_resolve(ref): - return resolver.resolve(ref) - - # No resolver found - from .typed_ref import parse_ref - try: - parsed = parse_ref(ref) - return ResolvedRef( - ref=ref, - status=ResolveStatus.UNSUPPORTED, - error_message=f"No resolver for domain: {parsed.domain}", - ) - except ValueError as e: - return ResolvedRef( - ref=ref, - status=ResolveStatus.UNSUPPORTED, - error_message=str(e), - ) - - def resolve_many(self, refs: List[str]) -> ResolveReport: - """ - Resolve multiple refs. - - Args: - refs: List of typed_ref strings - - Returns: - ResolveReport with categorized results - """ - report = ResolveReport() - - for ref in refs: - result = self.resolve_ref(ref) - - if result.status == ResolveStatus.RESOLVED: - report.resolved.append(result) - elif result.status == ResolveStatus.UNRESOLVED: - report.unresolved.append(result) - else: - report.unsupported.append(result) - - return report - - def load_summary(self, ref: str) -> Optional[SummaryPayload]: - """ - Load summary for a ref (summary-first retrieval). - - Args: - ref: typed_ref string - - Returns: - SummaryPayload or None - """ - for resolver in self._resolvers: - if resolver.can_resolve(ref): - return resolver.load_summary(ref) - return None - - def load_selected_raw( - self, - ref: str, - selector: Optional[Dict[str, Any]] = None - ) -> Optional[RawPayload]: - """ - Load raw content for a ref (selected raw inclusion). - - Args: - ref: typed_ref string - selector: Optional selector for partial raw - - Returns: - RawPayload or None - """ - for resolver in self._resolvers: - if resolver.can_resolve(ref): - return resolver.load_raw(ref, selector) - return None - - def should_include_raw( - self, - condition: str, - task_context: Optional[Dict[str, Any]] = None - ) -> bool: - """ - Determine if raw content should be included. - - Args: - condition: Condition key (e.g., "before_review") - task_context: Optional task context for decision - - Returns: - True if raw should be included - """ - if condition not in RAW_DESCENT_CONDITIONS: - return False - - # Always include for operator request - if condition == "operator_request": - return True - - # Check task context for other conditions - if task_context: - if condition == "high_priority_open_question": - return task_context.get("has_high_priority_questions", False) - if condition == "low_confidence_decision": - return task_context.get("has_low_confidence_decisions", False) - - return True - - def get_diagnostics(self, report: ResolveReport) -> ResolverDiagnostics: - """ - Generate diagnostics from resolve report. - - Args: - report: ResolveReport from resolve_many - - Returns: - ResolverDiagnostics - """ - diagnostics = ResolverDiagnostics() - - for ref in report.unresolved: - diagnostics.missing_refs.append(ref.ref) - diagnostics.resolver_warnings.append( - f"Unresolved ref: {ref.ref} - {ref.error_message}" - ) - - for ref in report.unsupported: - diagnostics.unsupported_refs.append(ref.ref) - - diagnostics.partial_bundle = ( - len(report.unresolved) > 0 or len(report.unsupported) > 0 - ) - - return diagnostics - - -# Built-in resolver for agent-taskstate entities (local) - -class AgentTaskstateLocalResolver: - """ - Resolver for agent-taskstate local entities. - Resolves refs like agent-taskstate:task:local:*, agent-taskstate:decision:local:*, etc. - """ - - def __init__(self, conn=None): - self.conn = conn - - def can_resolve(self, ref: str) -> bool: - """Check if this is an agent-taskstate local ref.""" - from .typed_ref import parse_ref - try: - parsed = parse_ref(ref) - return parsed.domain == "agent-taskstate" and parsed.provider == "local" - except ValueError: - return False - - def resolve(self, ref: str) -> ResolvedRef: - """Resolve an agent-taskstate local ref.""" - from .typed_ref import parse_ref - try: - parsed = parse_ref(ref) - - # If no DB connection, return unresolved - if self.conn is None: - return ResolvedRef( - ref=ref, - status=ResolveStatus.UNRESOLVED, - error_message="No database connection", - ) - - # Check entity type - entity_type = parsed.entity_type - entity_id = parsed.entity_id - - if entity_type == "task": - return self._resolve_task(entity_id) - elif entity_type == "decision": - return self._resolve_decision(entity_id) - elif entity_type == "context_bundle": - return self._resolve_bundle(entity_id) - else: - return ResolvedRef( - ref=ref, - status=ResolveStatus.UNRESOLVED, - error_message=f"Unknown entity type: {entity_type}", - ) - - except ValueError as e: - return ResolvedRef( - ref=ref, - status=ResolveStatus.UNSUPPORTED, - error_message=str(e), - ) - - def _resolve_task(self, task_id: str) -> ResolvedRef: - """Resolve a task.""" - cursor = self.conn.execute( - "SELECT id, title, status FROM task WHERE id = ?", - (task_id,), - ) - row = cursor.fetchone() - - if row: - return ResolvedRef( - ref=f"agent-taskstate:task:local:{task_id}", - status=ResolveStatus.RESOLVED, - summary=row["title"], - metadata={"status": row["status"]}, - raw_available=True, - ) - else: - return ResolvedRef( - ref=f"agent-taskstate:task:local:{task_id}", - status=ResolveStatus.UNRESOLVED, - error_message=f"Task not found: {task_id}", - ) - - def _resolve_decision(self, decision_id: str) -> ResolvedRef: - """Resolve a decision.""" - cursor = self.conn.execute( - "SELECT id, summary, status FROM decision WHERE id = ?", - (decision_id,), - ) - row = cursor.fetchone() - - if row: - return ResolvedRef( - ref=f"agent-taskstate:decision:local:{decision_id}", - status=ResolveStatus.RESOLVED, - summary=row["summary"], - metadata={"status": row["status"]}, - raw_available=True, - ) - else: - return ResolvedRef( - ref=f"agent-taskstate:decision:local:{decision_id}", - status=ResolveStatus.UNRESOLVED, - error_message=f"Decision not found: {decision_id}", - ) - - def _resolve_bundle(self, bundle_id: str) -> ResolvedRef: - """Resolve a context bundle.""" - cursor = self.conn.execute( - "SELECT id, purpose, summary FROM context_bundle WHERE id = ?", - (bundle_id,), - ) - row = cursor.fetchone() - - if row: - return ResolvedRef( - ref=f"agent-taskstate:context_bundle:local:{bundle_id}", - status=ResolveStatus.RESOLVED, - summary=row["summary"] or row["purpose"], - metadata={"purpose": row["purpose"]}, - raw_available=True, - ) - else: - return ResolvedRef( - ref=f"agent-taskstate:context_bundle:local:{bundle_id}", - status=ResolveStatus.UNRESOLVED, - error_message=f"Bundle not found: {bundle_id}", - ) - - def load_summary(self, ref: str) -> Optional[SummaryPayload]: - """Load summary for an agent-taskstate ref.""" - result = self.resolve(ref) - if result.status == ResolveStatus.RESOLVED and result.summary: - return SummaryPayload( - ref=ref, - summary=result.summary, - metadata=result.metadata or {}, - ) - return None - - def load_raw(self, ref: str, selector: Optional[Dict[str, Any]] = None) -> Optional[RawPayload]: - """Load raw content for an agent-taskstate ref.""" - # For agent-taskstate entities, summary is typically sufficient - summary = self.load_summary(ref) - if summary: - return RawPayload( - ref=ref, - content=summary.summary, - metadata=summary.metadata, - ) - return None - - -class TrackerResolver: - """ - Resolver for tracker issue refs. - - Resolves refs like tracker:issue:github:owner/repo#123, tracker:issue:jira:PROJ-123 - - Uses TrackerAdapter for actual fetch operations. - """ - - def __init__(self, adapters: Optional[Dict[str, "TrackerAdapter"]] = None): - """ - Initialize with provider-specific adapters. - - Args: - adapters: Dict mapping provider name to TrackerAdapter instance - """ - self._adapters = adapters or {} - - def register_adapter(self, provider: str, adapter: "TrackerAdapter") -> None: - """Register adapter for a provider.""" - self._adapters[provider] = adapter - - def can_resolve(self, ref: str) -> bool: - """Check if this is a tracker issue ref.""" - from .typed_ref import parse_ref - try: - parsed = parse_ref(ref) - return parsed.domain == "tracker" and parsed.entity_type == "issue" - except ValueError: - return False - - def resolve(self, ref: str) -> ResolvedRef: - """Resolve a tracker issue ref.""" - from .typed_ref import parse_ref - try: - parsed = parse_ref(ref) - provider = parsed.provider - issue_key = parsed.entity_id - - adapter = self._adapters.get(provider) - if not adapter: - return ResolvedRef( - ref=ref, - status=ResolveStatus.UNSUPPORTED, - error_message=f"No adapter for tracker provider: {provider}", - ) - - raw = adapter.fetch_issue(issue_key) - if not raw: - return ResolvedRef( - ref=ref, - status=ResolveStatus.UNRESOLVED, - error_message=f"Issue not found: {issue_key}", - ) - - normalized = adapter.normalize_issue(raw) - return ResolvedRef( - ref=ref, - status=ResolveStatus.RESOLVED, - summary=normalized.get("title", ""), - metadata={ - "remote_key": normalized.get("remote_key"), - "status": normalized.get("status"), - "assignee": normalized.get("assignee"), - }, - raw_available=True, - ) - - except ValueError as e: - return ResolvedRef( - ref=ref, - status=ResolveStatus.UNSUPPORTED, - error_message=str(e), - ) - - def load_summary(self, ref: str) -> Optional[SummaryPayload]: - """Load summary for a tracker issue.""" - result = self.resolve(ref) - if result.status == ResolveStatus.RESOLVED and result.summary: - return SummaryPayload( - ref=ref, - summary=result.summary, - metadata=result.metadata or {}, - ) - return None - - def load_raw(self, ref: str, selector: Optional[Dict[str, Any]] = None) -> Optional[RawPayload]: - """Load raw content for a tracker issue.""" - from .typed_ref import parse_ref - try: - parsed = parse_ref(ref) - adapter = self._adapters.get(parsed.provider) - if not adapter: - return None - - raw = adapter.fetch_issue(parsed.entity_id) - if not raw: - return None - - import json - return RawPayload( - ref=ref, - content=json.dumps(raw, ensure_ascii=False), - metadata={"provider": parsed.provider}, - ) - except Exception: - return None - - -class MemxResolver: - """ - Resolver for memx entity refs. - - Resolves refs like memx:evidence:local:ev_01JXYZ..., memx:knowledge:local:kn_01JXYZ... - - Designed for integration with memx-resolver repo. - Uses callback functions for actual fetch operations. - """ - - def __init__( - self, - fetch_evidence: Optional[callable] = None, - fetch_knowledge: Optional[callable] = None, - fetch_chunk: Optional[callable] = None, - ): - """ - Initialize with fetch callbacks. - - Args: - fetch_evidence: Callable to fetch evidence by ID - fetch_knowledge: Callable to fetch knowledge by ID - fetch_chunk: Callable to fetch chunk by ID - """ - self._fetch_evidence = fetch_evidence - self._fetch_knowledge = fetch_knowledge - self._fetch_chunk = fetch_chunk - - def can_resolve(self, ref: str) -> bool: - """Check if this is a memx ref.""" - from .typed_ref import parse_ref - try: - parsed = parse_ref(ref) - return parsed.domain == "memx" - except ValueError: - return False - - def resolve(self, ref: str) -> ResolvedRef: - """Resolve a memx entity ref.""" - from .typed_ref import parse_ref - try: - parsed = parse_ref(ref) - entity_type = parsed.entity_type - entity_id = parsed.entity_id - - if entity_type == "evidence": - return self._resolve_evidence(entity_id) - elif entity_type == "knowledge": - return self._resolve_knowledge(entity_id) - elif entity_type == "chunk": - return self._resolve_chunk(entity_id) - else: - return ResolvedRef( - ref=ref, - status=ResolveStatus.UNSUPPORTED, - error_message=f"Unknown memx entity type: {entity_type}", - ) - - except ValueError as e: - return ResolvedRef( - ref=ref, - status=ResolveStatus.UNSUPPORTED, - error_message=str(e), - ) - - def _resolve_evidence(self, evidence_id: str) -> ResolvedRef: - """Resolve evidence entity.""" - if not self._fetch_evidence: - return ResolvedRef( - ref=f"memx:evidence:local:{evidence_id}", - status=ResolveStatus.UNRESOLVED, - error_message="No evidence fetch callback configured", - ) - - try: - data = self._fetch_evidence(evidence_id) - if not data: - return ResolvedRef( - ref=f"memx:evidence:local:{evidence_id}", - status=ResolveStatus.UNRESOLVED, - error_message=f"Evidence not found: {evidence_id}", - ) - - return ResolvedRef( - ref=f"memx:evidence:local:{evidence_id}", - status=ResolveStatus.RESOLVED, - summary=data.get("summary", data.get("title", "")), - metadata={"kind": data.get("kind"), "source": data.get("source")}, - raw_available=True, - ) - except Exception as e: - return ResolvedRef( - ref=f"memx:evidence:local:{evidence_id}", - status=ResolveStatus.UNRESOLVED, - error_message=str(e), - ) - - def _resolve_knowledge(self, knowledge_id: str) -> ResolvedRef: - """Resolve knowledge entity.""" - if not self._fetch_knowledge: - return ResolvedRef( - ref=f"memx:knowledge:local:{knowledge_id}", - status=ResolveStatus.UNRESOLVED, - error_message="No knowledge fetch callback configured", - ) - - try: - data = self._fetch_knowledge(knowledge_id) - if not data: - return ResolvedRef( - ref=f"memx:knowledge:local:{knowledge_id}", - status=ResolveStatus.UNRESOLVED, - error_message=f"Knowledge not found: {knowledge_id}", - ) - - return ResolvedRef( - ref=f"memx:knowledge:local:{knowledge_id}", - status=ResolveStatus.RESOLVED, - summary=data.get("summary", data.get("title", "")), - metadata={"category": data.get("category")}, - raw_available=True, - ) - except Exception as e: - return ResolvedRef( - ref=f"memx:knowledge:local:{knowledge_id}", - status=ResolveStatus.UNRESOLVED, - error_message=str(e), - ) - - def _resolve_chunk(self, chunk_id: str) -> ResolvedRef: - """Resolve chunk entity.""" - if not self._fetch_chunk: - return ResolvedRef( - ref=f"memx:chunk:local:{chunk_id}", - status=ResolveStatus.UNRESOLVED, - error_message="No chunk fetch callback configured", - ) - - try: - data = self._fetch_chunk(chunk_id) - if not data: - return ResolvedRef( - ref=f"memx:chunk:local:{chunk_id}", - status=ResolveStatus.UNRESOLVED, - error_message=f"Chunk not found: {chunk_id}", - ) - - return ResolvedRef( - ref=f"memx:chunk:local:{chunk_id}", - status=ResolveStatus.RESOLVED, - summary=data.get("summary", ""), - metadata={"doc_ref": data.get("doc_ref")}, - raw_available=True, - ) - except Exception as e: - return ResolvedRef( - ref=f"memx:chunk:local:{chunk_id}", - status=ResolveStatus.UNRESOLVED, - error_message=str(e), - ) - - def load_summary(self, ref: str) -> Optional[SummaryPayload]: - """Load summary for a memx entity.""" - result = self.resolve(ref) - if result.status == ResolveStatus.RESOLVED and result.summary: - return SummaryPayload( - ref=ref, - summary=result.summary, - metadata=result.metadata or {}, - ) - return None - - def load_raw(self, ref: str, selector: Optional[Dict[str, Any]] = None) -> Optional[RawPayload]: - """Load raw content for a memx entity.""" - from .typed_ref import parse_ref - try: - parsed = parse_ref(ref) - entity_id = parsed.entity_id - - if parsed.entity_type == "evidence" and self._fetch_evidence: - data = self._fetch_evidence(entity_id) - elif parsed.entity_type == "knowledge" and self._fetch_knowledge: - data = self._fetch_knowledge(entity_id) - elif parsed.entity_type == "chunk" and self._fetch_chunk: - data = self._fetch_chunk(entity_id) - else: - return None - - if not data: - return None - - import json - return RawPayload( - ref=ref, - content=json.dumps(data, ensure_ascii=False), - metadata={"entity_type": parsed.entity_type}, - ) - except Exception: - return None \ No newline at end of file +"""Deprecated compatibility shim.""" +from agent_taskstate.resolver import * diff --git a/src/state_transition.py b/src/state_transition.py index 395b3c0..21225f1 100644 --- a/src/state_transition.py +++ b/src/state_transition.py @@ -1,343 +1,2 @@ -""" -State Transition Service - -Manages task state transitions with history tracking. -All status changes must go through this service. - -Design principles: -- task.status is materialized current state -- state_transition table holds append-only history -- Every transition has reason and actor - -Supports configurable transitions via TransitionConfig. -""" - -from __future__ import annotations - -import sqlite3 -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import List, Optional - -from .transition_config import TransitionConfig, DEFAULT_CONFIG, get_config - -# Default transitions (from TransitionConfig) -# Can be overridden by loading a custom config -ALLOWED_TRANSITIONS = DEFAULT_CONFIG.allowed_transitions -TERMINAL_STATES = DEFAULT_CONFIG.terminal_states -ACTOR_TYPES = DEFAULT_CONFIG.actor_types - - -@dataclass -class StateTransition: - """Represents a single state transition record.""" - - id: str - task_id: str - from_status: Optional[str] - to_status: str - reason: str - actor_type: str - actor_id: Optional[str] - run_id: Optional[str] - changed_at: str - - -class InvalidTransitionError(Exception): - """Raised when attempting an invalid state transition.""" - - def __init__(self, from_status: str, to_status: str): - self.from_status = from_status - self.to_status = to_status - super().__init__( - f"Invalid transition: {from_status} -> {to_status}" - ) - - -class TerminalStateError(Exception): - """Raised when attempting to transition from a terminal state.""" - - def __init__(self, status: str): - self.status = status - super().__init__( - f"Cannot transition from terminal state: {status}" - ) - - -class MissingReasonError(Exception): - """Raised when a required reason is not provided.""" - - def __init__(self, transition: str): - self.transition = transition - super().__init__( - f"Reason required for transition: {transition}" - ) - - -def now_utc() -> str: - """Return current UTC timestamp in ISO format.""" - return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ") - - -def gen_id() -> str: - """Generate a unique ID.""" - import uuid - return uuid.uuid4().hex - - -def can_transition(from_status: str, to_status: str) -> bool: - """ - Check if a transition is allowed. - - Args: - from_status: Current status - to_status: Target status - - Returns: - True if transition is allowed, False otherwise - """ - if from_status not in ALLOWED_TRANSITIONS: - return False - return to_status in ALLOWED_TRANSITIONS[from_status] - - -def is_terminal(status: str) -> bool: - """Check if a status is terminal (no outgoing transitions).""" - return status in TERMINAL_STATES - - -def requires_reason(from_status: str, to_status: str) -> bool: - """ - Check if a transition requires a reason. - - Exception transitions (reopen, etc.) require reason. - """ - # done -> in_progress (reopen) requires reason - if from_status == "done" and to_status == "in_progress": - return True - # Terminal state transitions require reason - if to_status in TERMINAL_STATES: - return True - return False - - -class StateTransitionService: - """ - Service for managing task state transitions. - - All task status changes must go through this service to ensure - history is properly recorded. - - Supports custom transition configurations. - """ - - def __init__( - self, - conn: sqlite3.Connection, - config: Optional[TransitionConfig] = None, - ): - self.conn = conn - self.config = config or DEFAULT_CONFIG - - @property - def allowed_transitions(self) -> dict: - """Get allowed transitions from config.""" - return self.config.allowed_transitions - - @property - def terminal_states(self) -> set: - """Get terminal states from config.""" - return self.config.terminal_states - - @property - def actor_types(self) -> set: - """Get valid actor types from config.""" - return self.config.actor_types - - def can_transition(self, from_status: str, to_status: str) -> bool: - """Check if transition is allowed using config.""" - return self.config.can_transition(from_status, to_status) - - def is_terminal(self, status: str) -> bool: - """Check if status is terminal using config.""" - return self.config.is_terminal(status) - - def requires_reason(self, from_status: str, to_status: str) -> bool: - """Check if transition requires reason using config.""" - return self.config.requires_reason(from_status, to_status) - - def transition( - self, - task_id: str, - to_status: str, - reason: str, - actor_type: str, - actor_id: Optional[str] = None, - run_id: Optional[str] = None, - ) -> StateTransition: - """ - Execute a state transition. - - Args: - task_id: Task ID - to_status: Target status - reason: Reason for transition - actor_type: Type of actor (human/agent/system) - actor_id: Optional actor identifier - run_id: Optional run ID for correlation - - Returns: - StateTransition record - - Raises: - InvalidTransitionError: If transition is not allowed - TerminalStateError: If trying to transition from terminal state - MissingReasonError: If required reason is not provided - """ - # Validate actor type - if not self.config.is_valid_actor(actor_type): - raise ValueError(f"Invalid actor_type: {actor_type}") - - # Get current task status - cursor = self.conn.execute( - "SELECT status FROM task WHERE id = ?", (task_id,) - ) - row = cursor.fetchone() - if not row: - raise ValueError(f"Task not found: {task_id}") - - from_status = row[0] - - # Check if transition is allowed - if not self.can_transition(from_status, to_status): - if self.is_terminal(from_status): - raise TerminalStateError(from_status) - raise InvalidTransitionError(from_status, to_status) - - # Check if reason is required - if self.requires_reason(from_status, to_status) and not reason: - raise MissingReasonError(f"{from_status} -> {to_status}") - - # Create transition record - transition_id = gen_id() - changed_at = now_utc() - - self.conn.execute( - """ - INSERT INTO task_state - (id, task_id, from_state, to_state, reason, actor_type, actor_id, run_id, changed_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - transition_id, - task_id, - from_status, - to_status, - reason, - actor_type, - actor_id, - run_id, - changed_at, - ), - ) - - # Update task status (materialized current state) - self.conn.execute( - "UPDATE task SET status = ?, updated_at = ? WHERE id = ?", - (to_status, changed_at, task_id), - ) - - return StateTransition( - id=transition_id, - task_id=task_id, - from_status=from_status, - to_status=to_status, - reason=reason, - actor_type=actor_type, - actor_id=actor_id, - run_id=run_id, - changed_at=changed_at, - ) - - def get_history(self, task_id: str) -> List[StateTransition]: - """ - Get state transition history for a task. - - Args: - task_id: Task ID - - Returns: - List of StateTransition records, oldest first - """ - cursor = self.conn.execute( - """ - SELECT id, task_id, from_state, to_state, reason, actor_type, actor_id, run_id, changed_at - FROM task_state - WHERE task_id = ? - ORDER BY changed_at ASC - """, - (task_id,), - ) - - transitions = [] - for row in cursor.fetchall(): - transitions.append( - StateTransition( - id=row[0], - task_id=row[1], - from_status=row[2], - to_status=row[3], - reason=row[4], - actor_type=row[5], - actor_id=row[6], - run_id=row[7], - changed_at=row[8], - ) - ) - - return transitions - - def get_current_state(self, task_id: str) -> Optional[str]: - """ - Get current status of a task. - - Args: - task_id: Task ID - - Returns: - Current status or None if task not found - """ - cursor = self.conn.execute( - "SELECT status FROM task WHERE id = ?", (task_id,) - ) - row = cursor.fetchone() - return row[0] if row else None - - -def create_transition_table(conn: sqlite3.Connection) -> None: - """Create the task_state table if it doesn't exist.""" - conn.execute( - """ - CREATE TABLE IF NOT EXISTS task_state ( - id TEXT PRIMARY KEY, - task_id TEXT NOT NULL, - from_state TEXT, - to_state TEXT NOT NULL, - reason TEXT NOT NULL, - actor_type TEXT NOT NULL, - actor_id TEXT, - run_id TEXT, - changed_at TEXT NOT NULL, - FOREIGN KEY (task_id) REFERENCES task(id) - ) - """ - ) - - # Create index for efficient history queries - conn.execute( - """ - CREATE INDEX IF NOT EXISTS idx_task_state_task_changed - ON task_state(task_id, changed_at DESC) - """ - ) \ No newline at end of file +"""Deprecated compatibility shim.""" +from agent_taskstate.state_transition import * diff --git a/src/tracker_bridge.py b/src/tracker_bridge.py index f245bba..af2eb8f 100644 --- a/src/tracker_bridge.py +++ b/src/tracker_bridge.py @@ -1,951 +1,2 @@ -""" -Tracker Bridge Module - -Provides minimum integration with external issue trackers. -Tracker is auxiliary - not the source of truth for internal state. - -Features: -- Issue fetch and cache -- Entity linking (tracker:issue <-> agent-taskstate:task) -- Sync event tracking -- Snapshot export for context build -- Outbound status/comment reflection -""" - -from __future__ import annotations - -import json -import sqlite3 -from dataclasses import dataclass -from datetime import datetime, timezone -from enum import Enum -from typing import Any, Dict, List, Optional, Protocol - -from .typed_ref import canonicalize_ref, format_ref, parse_ref - - -class SyncDirection(Enum): - """Direction of sync.""" - - INBOUND = "inbound" - OUTBOUND = "outbound" - - -class SyncStatus(Enum): - """Status of sync operation.""" - - SUCCESS = "success" - FAILED = "failed" - PARTIAL = "partial" - - -class LinkRole(Enum): - """Role of entity link.""" - - PRIMARY = "primary" - RELATED = "related" - DUPLICATE = "duplicate" - BLOCKS = "blocks" - - -@dataclass -class TrackerConnection: - """Connection configuration for a tracker.""" - - id: str - provider: str - name: str - config_json: str - created_at: str - updated_at: str - - -@dataclass -class IssueCache: - """Cached issue from external tracker.""" - - id: str - connection_id: str - issue_ref: str - remote_key: str - title: str - status: str - assignee: Optional[str] - description: Optional[str] - labels_json: Optional[str] - raw_json: Optional[str] - fetched_at: str - updated_at: str - - -@dataclass -class EntityLink: - """Link between tracker issue and agent-taskstate entity.""" - - id: str - tracker_issue_ref: str - agent_taskstate_entity_ref: str - role: str - created_at: str - - -@dataclass -class SyncEvent: - """Record of sync operation.""" - - id: str - connection_id: str - direction: str - status: str - issue_ref: Optional[str] - details_json: Optional[str] - error_message: Optional[str] - created_at: str - - -@dataclass -class IssueSnapshot: - """Minimal snapshot for context build.""" - - issue_ref: str - remote_key: str - title: str - status: str - assignee: Optional[str] - updated_at: str - last_sync_result: str - - def to_dict(self) -> Dict[str, Any]: - return { - "issue_ref": self.issue_ref, - "remote_key": self.remote_key, - "title": self.title, - "status": self.status, - "assignee": self.assignee, - "updated_at": self.updated_at, - "last_sync_result": self.last_sync_result, - } - - -@dataclass -class SyncSuggestion: - """Suggestion for agent-taskstate update based on tracker change.""" - - issue_ref: str - agent_taskstate_task_ref: str - suggested_action: str - suggested_value: str - reason: str - requires_confirmation: bool = True - - -def now_utc() -> str: - return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ") - - -def gen_id() -> str: - import uuid - - return uuid.uuid4().hex - - -class TrackerAdapter(Protocol): - """Protocol for tracker adapters.""" - - def fetch_issue(self, issue_key: str) -> Optional[Dict[str, Any]]: - ... - - def normalize_issue(self, raw: Dict[str, Any]) -> Dict[str, Any]: - ... - - def post_comment(self, issue_key: str, comment: str) -> bool: - ... - - def update_status(self, issue_key: str, status: str) -> bool: - ... - - -class MockTrackerAdapter: - """Mock adapter for testing.""" - - def __init__(self, issues: Optional[Dict[str, Dict[str, Any]]] = None): - self.issues = issues or {} - - def fetch_issue(self, issue_key: str) -> Optional[Dict[str, Any]]: - return self.issues.get(issue_key) - - def normalize_issue(self, raw: Dict[str, Any]) -> Dict[str, Any]: - return { - "remote_key": raw.get("key"), - "title": raw.get("summary", ""), - "status": raw.get("status", "unknown"), - "assignee": raw.get("assignee"), - "description": raw.get("description"), - } - - def post_comment(self, issue_key: str, comment: str) -> bool: - return True - - def update_status(self, issue_key: str, status: str) -> bool: - return True - - -class GitHubAdapter: - """ - GitHub Issues adapter using PyGithub. - - Requires: PyGithub package (pip install PyGithub) - - Config: - - token: GitHub personal access token - - repo_owner: Repository owner - - repo_name: Repository name - """ - - def __init__(self, token: str, repo_owner: str, repo_name: str): - self._token = token - self._repo_owner = repo_owner - self._repo_name = repo_name - self._repo = None - - def _get_repo(self): - """Lazy-load repository.""" - if self._repo is None: - try: - from github import Github - gh = Github(self._token) - self._repo = gh.get_repo(f"{self._repo_owner}/{self._repo_name}") - except ImportError: - raise RuntimeError("PyGithub not installed. Run: pip install PyGithub") - return self._repo - - def parse_issue_key(self, issue_key: str) -> int: - """Parse issue number from key (e.g., 'owner/repo#123' or '#123').""" - if "#" in issue_key: - return int(issue_key.split("#")[-1]) - return int(issue_key) - - def fetch_issue(self, issue_key: str) -> Optional[Dict[str, Any]]: - """Fetch issue from GitHub.""" - try: - repo = self._get_repo() - issue_num = self.parse_issue_key(issue_key) - issue = repo.get_issue(issue_num) - return { - "key": f"{self._repo_owner}/{self._repo_name}#{issue_num}", - "number": issue_num, - "title": issue.title, - "body": issue.body or "", - "state": issue.state, - "assignee": issue.assignee.login if issue.assignee else None, - "labels": [label.name for label in issue.labels], - "created_at": issue.created_at.isoformat(), - "updated_at": issue.updated_at.isoformat(), - "url": issue.html_url, - } - except Exception: - return None - - def normalize_issue(self, raw: Dict[str, Any]) -> Dict[str, Any]: - """Normalize GitHub issue to standard format.""" - return { - "remote_key": raw.get("key"), - "title": raw.get("title", ""), - "status": raw.get("state", "open"), - "assignee": raw.get("assignee"), - "description": raw.get("body"), - "labels": raw.get("labels", []), - "url": raw.get("url"), - } - - def post_comment(self, issue_key: str, comment: str) -> bool: - """Post comment to GitHub issue.""" - try: - repo = self._get_repo() - issue_num = self.parse_issue_key(issue_key) - issue = repo.get_issue(issue_num) - issue.create_comment(comment) - return True - except Exception: - return False - - def update_status(self, issue_key: str, status: str) -> bool: - """Update GitHub issue status (open/closed).""" - try: - repo = self._get_repo() - issue_num = self.parse_issue_key(issue_key) - issue = repo.get_issue(issue_num) - if status.lower() == "closed": - issue.edit(state="closed") - elif status.lower() == "open": - issue.edit(state="open") - return True - except Exception: - return False - - -class JiraAdapter: - """ - Jira adapter using jira library. - - Requires: jira package (pip install jira) - - Config: - - server: Jira server URL - - username: Jira username/email - - password: Jira password or API token - """ - - def __init__(self, server: str, username: str, password: str): - self._server = server - self._username = username - self._password = password - self._jira = None - - def _get_jira(self): - """Lazy-load Jira client.""" - if self._jira is None: - try: - from jira import JIRA - self._jira = JIRA( - server=self._server, - basic_auth=(self._username, self._password), - ) - except ImportError: - raise RuntimeError("jira not installed. Run: pip install jira") - return self._jira - - def fetch_issue(self, issue_key: str) -> Optional[Dict[str, Any]]: - """Fetch issue from Jira.""" - try: - jira = self._get_jira() - issue = jira.issue(issue_key) - return { - "key": issue.key, - "summary": issue.fields.summary, - "description": issue.fields.description or "", - "status": issue.fields.status.name, - "assignee": issue.fields.assignee.displayName if issue.fields.assignee else None, - "priority": issue.fields.priority.name if issue.fields.priority else None, - "labels": [label.name for label in issue.fields.labels], - "created_at": issue.fields.created.isoformat() if issue.fields.created else None, - "updated_at": issue.fields.updated.isoformat() if issue.fields.updated else None, - "url": f"{self._server}/browse/{issue.key}", - } - except Exception: - return None - - def normalize_issue(self, raw: Dict[str, Any]) -> Dict[str, Any]: - """Normalize Jira issue to standard format.""" - return { - "remote_key": raw.get("key"), - "title": raw.get("summary", ""), - "status": raw.get("status", "unknown"), - "assignee": raw.get("assignee"), - "description": raw.get("description"), - "priority": raw.get("priority"), - "labels": raw.get("labels", []), - "url": raw.get("url"), - } - - def post_comment(self, issue_key: str, comment: str) -> bool: - """Post comment to Jira issue.""" - try: - jira = self._get_jira() - issue = jira.issue(issue_key) - jira.add_comment(issue, comment) - return True - except Exception: - return False - - def update_status(self, issue_key: str, status: str) -> bool: - """Update Jira issue status (transition).""" - try: - jira = self._get_jira() - issue = jira.issue(issue_key) - transitions = jira.transitions(issue) - for t in transitions: - if t["name"].lower() == status.lower(): - jira.transition_issue(issue, t["id"]) - return True - return False - except Exception: - return False - - -class TrackerBridgeService: - """Service for tracker-bridge integration.""" - - def __init__(self, conn: sqlite3.Connection): - self.conn = conn - self._adapters: Dict[str, TrackerAdapter] = {} - - def register_adapter(self, provider: str, adapter: TrackerAdapter) -> None: - """Register an adapter for a provider.""" - self._adapters[provider] = adapter - - def _normalize_issue_ref(self, issue_ref: str) -> str: - """Canonicalize and validate a tracker issue reference.""" - try: - canonical_ref = canonicalize_ref(issue_ref) - parsed = parse_ref(canonical_ref) - except ValueError as exc: - raise ValueError("issue_ref must be a tracker issue typed_ref") from exc - if parsed.domain != "tracker" or parsed.entity_type != "issue": - raise ValueError("issue_ref must be a tracker issue typed_ref") - return canonical_ref - - def _normalize_task_ref(self, task_ref: str) -> str: - """Canonicalize and validate an agent-taskstate task reference.""" - try: - canonical_ref = canonicalize_ref(task_ref) - parsed = parse_ref(canonical_ref) - except ValueError as exc: - raise ValueError( - "task_ref must be an agent-taskstate local task typed_ref" - ) from exc - if ( - parsed.domain != "agent-taskstate" - or parsed.entity_type != "task" - or parsed.provider != "local" - ): - raise ValueError( - "task_ref must be an agent-taskstate local task typed_ref" - ) - return canonical_ref - - def _normalize_link_role(self, role: str) -> str: - """Validate and normalize link role values.""" - if isinstance(role, LinkRole): - return role.value - try: - return LinkRole(role).value - except ValueError as exc: - raise ValueError(f"Invalid link role: {role}") from exc - - def create_connection( - self, - provider: str, - name: str, - config: Dict[str, Any], - ) -> TrackerConnection: - """Create a new tracker connection.""" - conn_id = gen_id() - now = now_utc() - - self.conn.execute( - """ - INSERT INTO tracker_connection - (id, provider, name, config_json, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?) - """, - (conn_id, provider, name, json.dumps(config), now, now), - ) - - return TrackerConnection( - id=conn_id, - provider=provider, - name=name, - config_json=json.dumps(config), - created_at=now, - updated_at=now, - ) - - def fetch_issue( - self, - connection_id: str, - issue_key: str, - ) -> Optional[IssueCache]: - """Fetch an issue from external tracker and cache it.""" - cursor = self.conn.execute( - "SELECT id, provider FROM tracker_connection WHERE id = ?", - (connection_id,), - ) - row = cursor.fetchone() - if not row: - return None - - provider = row["provider"] - adapter = self._adapters.get(provider) - if not adapter: - self._record_sync_event( - connection_id=connection_id, - direction=SyncDirection.INBOUND, - status=SyncStatus.FAILED, - issue_ref=format_ref("tracker", "issue", issue_key, provider), - details={"action": "fetch", "issue_key": issue_key}, - error_message=f"No adapter registered for provider: {provider}", - ) - return None - - raw = adapter.fetch_issue(issue_key) - if not raw: - self._record_sync_event( - connection_id=connection_id, - direction=SyncDirection.INBOUND, - status=SyncStatus.FAILED, - issue_ref=format_ref("tracker", "issue", issue_key, provider), - details={"action": "fetch", "issue_key": issue_key}, - error_message=f"Issue not found: {issue_key}", - ) - return None - - normalized = adapter.normalize_issue(raw) - remote_key = normalized["remote_key"] - issue_ref = format_ref("tracker", "issue", remote_key, provider) - cache_id = gen_id() - now = now_utc() - - self.conn.execute( - """ - INSERT OR REPLACE INTO issue_cache - (id, connection_id, issue_ref, remote_key, title, status, - assignee, description, labels_json, raw_json, fetched_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - cache_id, - connection_id, - issue_ref, - remote_key, - normalized["title"], - normalized["status"], - normalized.get("assignee"), - normalized.get("description"), - json.dumps(normalized.get("labels", [])), - json.dumps(raw), - now, - now, - ), - ) - - self._record_sync_event( - connection_id=connection_id, - direction=SyncDirection.INBOUND, - status=SyncStatus.SUCCESS, - issue_ref=issue_ref, - details={"action": "fetch", "issue_key": issue_key}, - ) - - return IssueCache( - id=cache_id, - connection_id=connection_id, - issue_ref=issue_ref, - remote_key=remote_key, - title=normalized["title"], - status=normalized["status"], - assignee=normalized.get("assignee"), - description=normalized.get("description"), - labels_json=json.dumps(normalized.get("labels", [])), - raw_json=json.dumps(raw), - fetched_at=now, - updated_at=now, - ) - - def link_issue_to_task( - self, - issue_ref: str, - task_ref: str, - role: str = "primary", - ) -> EntityLink: - """Link a tracker issue to an agent-taskstate task.""" - issue_ref = self._normalize_issue_ref(issue_ref) - task_ref = self._normalize_task_ref(task_ref) - role = self._normalize_link_role(role) - link_id = gen_id() - now = now_utc() - - self.conn.execute( - """ - INSERT INTO entity_link - (id, tracker_issue_ref, agent_taskstate_entity_ref, role, created_at) - VALUES (?, ?, ?, ?, ?) - """, - (link_id, issue_ref, task_ref, role, now), - ) - - return EntityLink( - id=link_id, - tracker_issue_ref=issue_ref, - agent_taskstate_entity_ref=task_ref, - role=role, - created_at=now, - ) - - def get_issue_links(self, issue_ref: str) -> List[EntityLink]: - """Get all links for an issue.""" - issue_ref = self._normalize_issue_ref(issue_ref) - cursor = self.conn.execute( - """ - SELECT id, tracker_issue_ref, agent_taskstate_entity_ref, role, created_at - FROM entity_link - WHERE tracker_issue_ref = ? - """, - (issue_ref,), - ) - - links = [] - for row in cursor.fetchall(): - links.append( - EntityLink( - id=row[0], - tracker_issue_ref=row[1], - agent_taskstate_entity_ref=row[2], - role=row[3], - created_at=row[4], - ) - ) - return links - - def get_task_links(self, task_ref: str) -> List[EntityLink]: - """Get all tracker links for a task.""" - task_ref = self._normalize_task_ref(task_ref) - cursor = self.conn.execute( - """ - SELECT id, tracker_issue_ref, agent_taskstate_entity_ref, role, created_at - FROM entity_link - WHERE agent_taskstate_entity_ref = ? - """, - (task_ref,), - ) - - links = [] - for row in cursor.fetchall(): - links.append( - EntityLink( - id=row[0], - tracker_issue_ref=row[1], - agent_taskstate_entity_ref=row[2], - role=row[3], - created_at=row[4], - ) - ) - return links - - def get_issue_snapshot(self, issue_ref: str) -> Optional[IssueSnapshot]: - """Get minimal snapshot for context build.""" - issue_ref = self._normalize_issue_ref(issue_ref) - cursor = self.conn.execute( - """ - SELECT issue_ref, remote_key, title, status, assignee, updated_at - FROM issue_cache - WHERE issue_ref = ? - """, - (issue_ref,), - ) - - row = cursor.fetchone() - if not row: - return None - - sync_cursor = self.conn.execute( - """ - SELECT status - FROM sync_event - WHERE issue_ref = ? - ORDER BY created_at DESC - LIMIT 1 - """, - (issue_ref,), - ) - sync_row = sync_cursor.fetchone() - - return IssueSnapshot( - issue_ref=row[0], - remote_key=row[1], - title=row[2], - status=row[3], - assignee=row[4], - updated_at=row[5], - last_sync_result=sync_row[0] if sync_row else "unknown", - ) - - def post_outbound_comment( - self, - connection_id: str, - issue_key: str, - comment: str, - ) -> bool: - """Post a comment to external tracker (outbound).""" - cursor = self.conn.execute( - "SELECT provider FROM tracker_connection WHERE id = ?", - (connection_id,), - ) - row = cursor.fetchone() - if not row: - return False - - provider = row["provider"] - adapter = self._adapters.get(provider) - if not adapter: - self._record_sync_event( - connection_id=connection_id, - direction=SyncDirection.OUTBOUND, - status=SyncStatus.FAILED, - issue_ref=format_ref("tracker", "issue", issue_key, provider), - details={"action": "post_comment"}, - error_message=f"No adapter registered for provider: {provider}", - ) - return False - - success = adapter.post_comment(issue_key, comment) - issue_ref = format_ref("tracker", "issue", issue_key, provider) - - self._record_sync_event( - connection_id=connection_id, - direction=SyncDirection.OUTBOUND, - status=SyncStatus.SUCCESS if success else SyncStatus.FAILED, - issue_ref=issue_ref, - details={"action": "post_comment"}, - error_message=None if success else f"Failed to post comment: {issue_key}", - ) - - return success - - def update_outbound_status( - self, - connection_id: str, - issue_key: str, - status: str, - ) -> bool: - """Reflect an internal status to the external tracker.""" - cursor = self.conn.execute( - "SELECT provider FROM tracker_connection WHERE id = ?", - (connection_id,), - ) - row = cursor.fetchone() - if not row: - return False - - provider = row["provider"] - adapter = self._adapters.get(provider) - issue_ref = format_ref("tracker", "issue", issue_key, provider) - if not adapter: - self._record_sync_event( - connection_id=connection_id, - direction=SyncDirection.OUTBOUND, - status=SyncStatus.FAILED, - issue_ref=issue_ref, - details={"action": "update_status", "status": status}, - error_message=f"No adapter registered for provider: {provider}", - ) - return False - - success = adapter.update_status(issue_key, status) - - self._record_sync_event( - connection_id=connection_id, - direction=SyncDirection.OUTBOUND, - status=SyncStatus.SUCCESS if success else SyncStatus.FAILED, - issue_ref=issue_ref, - details={"action": "update_status", "status": status}, - error_message=None if success else f"Failed to update status: {issue_key}", - ) - - return success - - def generate_sync_suggestions( - self, - issue_ref: str, - ) -> List[SyncSuggestion]: - """Generate suggestions for agent-taskstate updates based on tracker changes.""" - suggestions = [] - snapshot = self.get_issue_snapshot(issue_ref) - if not snapshot: - return suggestions - - links = self.get_issue_links(issue_ref) - for link in links: - if link.role == "primary": - suggestions.append( - SyncSuggestion( - issue_ref=issue_ref, - agent_taskstate_task_ref=link.agent_taskstate_entity_ref, - suggested_action="review_status", - suggested_value=snapshot.status, - reason=f"Tracker status is '{snapshot.status}'", - requires_confirmation=True, - ) - ) - - return suggestions - - def get_sync_events( - self, - connection_id: Optional[str] = None, - issue_ref: Optional[str] = None, - limit: int = 100, - ) -> List[SyncEvent]: - """Get sync events for tracking.""" - query = """ - SELECT id, connection_id, direction, status, issue_ref, - details_json, error_message, created_at - FROM sync_event - WHERE 1=1 - """ - params = [] - - if connection_id: - query += " AND connection_id = ?" - params.append(connection_id) - if issue_ref: - issue_ref = self._normalize_issue_ref(issue_ref) - query += " AND issue_ref = ?" - params.append(issue_ref) - - query += " ORDER BY created_at DESC LIMIT ?" - params.append(limit) - - cursor = self.conn.execute(query, params) - - events = [] - for row in cursor.fetchall(): - events.append( - SyncEvent( - id=row[0], - connection_id=row[1], - direction=row[2], - status=row[3], - issue_ref=row[4], - details_json=row[5], - error_message=row[6], - created_at=row[7], - ) - ) - return events - - def _record_sync_event( - self, - connection_id: str, - direction: SyncDirection, - status: SyncStatus, - issue_ref: Optional[str] = None, - details: Optional[Dict[str, Any]] = None, - error_message: Optional[str] = None, - ) -> SyncEvent: - """Record a sync event.""" - event_id = gen_id() - now = now_utc() - - self.conn.execute( - """ - INSERT INTO sync_event - (id, connection_id, direction, status, issue_ref, - details_json, error_message, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - event_id, - connection_id, - direction.value, - status.value, - issue_ref, - json.dumps(details) if details else None, - error_message, - now, - ), - ) - - return SyncEvent( - id=event_id, - connection_id=connection_id, - direction=direction.value, - status=status.value, - issue_ref=issue_ref, - details_json=json.dumps(details) if details else None, - error_message=error_message, - created_at=now, - ) - - -def create_tracker_tables(conn: sqlite3.Connection) -> None: - """Create tracker-bridge tables.""" - conn.execute( - """ - CREATE TABLE IF NOT EXISTS tracker_connection ( - id TEXT PRIMARY KEY, - provider TEXT NOT NULL, - name TEXT NOT NULL, - config_json TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ) - """ - ) - - conn.execute( - """ - CREATE TABLE IF NOT EXISTS issue_cache ( - id TEXT PRIMARY KEY, - connection_id TEXT NOT NULL, - issue_ref TEXT NOT NULL UNIQUE, - remote_key TEXT NOT NULL, - title TEXT NOT NULL, - status TEXT NOT NULL, - assignee TEXT, - description TEXT, - labels_json TEXT, - raw_json TEXT, - fetched_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY (connection_id) REFERENCES tracker_connection(id) - ) - """ - ) - - conn.execute( - """ - CREATE TABLE IF NOT EXISTS entity_link ( - id TEXT PRIMARY KEY, - tracker_issue_ref TEXT NOT NULL, - agent_taskstate_entity_ref TEXT NOT NULL, - role TEXT NOT NULL, - created_at TEXT NOT NULL - ) - """ - ) - - conn.execute( - """ - CREATE TABLE IF NOT EXISTS sync_event ( - id TEXT PRIMARY KEY, - connection_id TEXT NOT NULL, - direction TEXT NOT NULL, - status TEXT NOT NULL, - issue_ref TEXT, - details_json TEXT, - error_message TEXT, - created_at TEXT NOT NULL, - FOREIGN KEY (connection_id) REFERENCES tracker_connection(id) - ) - """ - ) - - conn.execute( - """ - CREATE INDEX IF NOT EXISTS idx_issue_cache_ref - ON issue_cache(issue_ref) - """ - ) - - conn.execute( - """ - CREATE INDEX IF NOT EXISTS idx_entity_link_issue - ON entity_link(tracker_issue_ref) - """ - ) - - conn.execute( - """ - CREATE INDEX IF NOT EXISTS idx_entity_link_task - ON entity_link(agent_taskstate_entity_ref) - """ - ) - - conn.execute( - """ - CREATE INDEX IF NOT EXISTS idx_sync_event_connection - ON sync_event(connection_id, created_at DESC) - """ - ) - +"""Deprecated compatibility shim.""" +from agent_taskstate.tracker_bridge import * diff --git a/src/transition_config.py b/src/transition_config.py index e5e9c0f..9c901b3 100644 --- a/src/transition_config.py +++ b/src/transition_config.py @@ -1,182 +1,2 @@ -""" -State Transition Configuration Loader - -Loads transition rules from YAML configuration files. -Allows workflow-specific customization of state transitions. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any, Dict, List, Optional, Set - -try: - import yaml - YAML_AVAILABLE = True -except ImportError: - YAML_AVAILABLE = False - - -DEFAULT_CONFIG_PATH = Path(__file__).parent.parent / "config" / "transitions.yaml" - - -@dataclass -class TransitionConfig: - """Configuration for state transitions.""" - - workflow: str = "default" - statuses: Set[str] = field(default_factory=lambda: { - "proposed", "ready", "in_progress", "blocked", "review", "done", "cancelled" - }) - terminal_states: Set[str] = field(default_factory=lambda: {"done", "cancelled"}) - allowed_transitions: Dict[str, Set[str]] = field(default_factory=lambda: { - "proposed": {"ready", "cancelled"}, - "ready": {"in_progress", "cancelled"}, - "in_progress": {"blocked", "review", "cancelled"}, - "blocked": {"in_progress", "cancelled"}, - "review": {"in_progress", "done", "cancelled"}, - "done": {"in_progress"}, - "cancelled": set(), - }) - reason_required: List[Dict[str, str]] = field(default_factory=lambda: [ - {"from": "done", "to": "in_progress"}, - {"from": "*", "to": "done"}, - {"from": "*", "to": "cancelled"}, - ]) - actor_types: Set[str] = field(default_factory=lambda: {"human", "agent", "system"}) - - @classmethod - def from_yaml(cls, path: Optional[Path] = None) -> "TransitionConfig": - """ - Load configuration from YAML file. - - Args: - path: Path to YAML config file. Defaults to config/transitions.yaml - - Returns: - TransitionConfig instance - - Raises: - FileNotFoundError: If config file not found - ValueError: If YAML parsing fails - """ - if not YAML_AVAILABLE: - # Fall back to default if yaml not installed - return cls() - - config_path = path or DEFAULT_CONFIG_PATH - if not config_path.exists(): - return cls() - - with open(config_path, "r", encoding="utf-8") as f: - data = yaml.safe_load(f) - - if not data: - return cls() - - return cls( - workflow=data.get("workflow", "default"), - statuses=set(data.get("statuses", cls().statuses)), - terminal_states=set(data.get("terminal_states", cls().terminal_states)), - allowed_transitions={ - status: set(trans.get("allowed", [])) - for status, trans in data.get("transitions", {}).items() - }, - reason_required=data.get("reason_required", []), - actor_types=set(data.get("actor_types", cls().actor_types)), - ) - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "TransitionConfig": - """ - Create configuration from dictionary. - - Args: - data: Configuration dictionary - - Returns: - TransitionConfig instance - """ - return cls( - workflow=data.get("workflow", "default"), - statuses=set(data.get("statuses", cls().statuses)), - terminal_states=set(data.get("terminal_states", cls().terminal_states)), - allowed_transitions={ - k: set(v) if isinstance(v, list) else v - for k, v in data.get("allowed_transitions", {}).items() - }, - reason_required=data.get("reason_required", []), - actor_types=set(data.get("actor_types", cls().actor_types)), - ) - - def is_valid_status(self, status: str) -> bool: - """Check if status is valid.""" - return status in self.statuses - - def can_transition(self, from_status: str, to_status: str) -> bool: - """Check if transition is allowed.""" - if from_status not in self.allowed_transitions: - return False - return to_status in self.allowed_transitions.get(from_status, set()) - - def is_terminal(self, status: str) -> bool: - """Check if status is terminal.""" - return status in self.terminal_states - - def requires_reason(self, from_status: str, to_status: str) -> bool: - """ - Check if transition requires a reason. - - Based on reason_required configuration. - """ - for rule in self.reason_required: - rule_from = rule.get("from") - rule_to = rule.get("to") - - # Wildcard matches any status - from_match = rule_from == "*" or rule_from == from_status - to_match = rule_to == to_status - - if from_match and to_match: - return True - - return False - - def is_valid_actor(self, actor_type: str) -> bool: - """Check if actor type is valid.""" - return actor_type in self.actor_types - - -# Global default configuration - loaded from YAML if available -DEFAULT_CONFIG = TransitionConfig.from_yaml() - - -def get_config(path: Optional[Path] = None) -> TransitionConfig: - """ - Get transition configuration. - - Args: - path: Optional path to YAML config file - - Returns: - TransitionConfig instance - """ - if path: - return TransitionConfig.from_yaml(path) - return DEFAULT_CONFIG - - -def reload_config(path: Optional[Path] = None) -> TransitionConfig: - """ - Reload configuration from file. - - Args: - path: Optional path to YAML config file - - Returns: - New TransitionConfig instance - """ - global DEFAULT_CONFIG - DEFAULT_CONFIG = TransitionConfig.from_yaml(path) - return DEFAULT_CONFIG \ No newline at end of file +"""Deprecated compatibility shim.""" +from agent_taskstate.transition_config import * diff --git a/src/typed_ref.py b/src/typed_ref.py index fb165b3..e7e78de 100644 --- a/src/typed_ref.py +++ b/src/typed_ref.py @@ -1,225 +1,2 @@ -""" -Typed Reference Module - -Provides canonical 4-segment typed_ref format for cross-repo entity references. - -Format: ::: - -Examples: -- agent-taskstate:task:local:task_01JXYZ... -- agent-taskstate:decision:local:dec_01JXYZ... -- memx:evidence:local:ev_01JXYZ... -- tracker:issue:github:owner/repo#123 -- tracker:issue:jira:PROJ-123 -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Optional, Set, Tuple - -# Known domains (can be extended) -KNOWN_DOMAINS: Set[str] = {"agent-taskstate", "memx", "tracker"} - -# Default provider for local entities -DEFAULT_PROVIDER = "local" - - -@dataclass(frozen=True) -class TypedRef: - """Immutable typed reference with 4 segments.""" - - domain: str - entity_type: str - provider: str - entity_id: str - - def __str__(self) -> str: - return f"{self.domain}:{self.entity_type}:{self.provider}:{self.entity_id}" - - @property - def is_local(self) -> bool: - """Check if this is a local entity reference.""" - return self.provider == DEFAULT_PROVIDER - - -def format_ref( - domain: str, - entity_type: str, - entity_id: str, - provider: str = DEFAULT_PROVIDER, -) -> str: - """ - Format a 4-segment typed_ref string. - - Args: - domain: Domain namespace (agent-taskstate, memx, tracker) - entity_type: Entity type (task, decision, evidence, etc.) - entity_id: Entity identifier - provider: Provider name (default: "local") - - Returns: - Canonical typed_ref string - - Example: - >>> format_ref("agent-taskstate", "task", "task_01JABC") - 'agent-taskstate:task:local:task_01JABC' - """ - if not domain: - raise ValueError("domain cannot be empty") - if not entity_type: - raise ValueError("entity_type cannot be empty") - if not provider: - raise ValueError("provider cannot be empty") - if not entity_id: - raise ValueError("entity_id cannot be empty") - - return f"{domain.lower()}:{entity_type.lower()}:{provider.lower()}:{entity_id}" - - -def parse_ref(ref: str) -> TypedRef: - """ - Parse a typed_ref string into a TypedRef object. - - Accepts both 4-segment (canonical) and 3-segment (legacy) formats. - Legacy format is automatically canonicalized with default provider. - - Args: - ref: typed_ref string - - Returns: - TypedRef object - - Raises: - ValueError: If ref format is invalid - - Example: - >>> parse_ref("agent-taskstate:task:local:task_01JABC") - TypedRef(domain='agent-taskstate', entity_type='task', provider='local', entity_id='task_01JABC') - >>> parse_ref("agent-taskstate:task:task_01JABC") # legacy 3-segment - TypedRef(domain='agent-taskstate', entity_type='task', provider='local', entity_id='task_01JABC') - """ - parts = ref.split(":") - - if len(parts) == 4: - domain, entity_type, provider, entity_id = parts - elif len(parts) == 3: - domain, entity_type, entity_id = parts - provider = DEFAULT_PROVIDER - else: - raise ValueError( - f"Invalid typed_ref format: {ref!r}. " - f"Expected 3 or 4 segments separated by ':'." - ) - - if not domain: - raise ValueError("domain cannot be empty") - if not entity_type: - raise ValueError("entity_type cannot be empty") - if not provider: - raise ValueError("provider cannot be empty") - if not entity_id: - raise ValueError("entity_id cannot be empty") - - return TypedRef( - domain=domain.lower(), - entity_type=entity_type.lower(), - provider=provider.lower(), - entity_id=entity_id, - ) - - -def validate_ref(ref: str) -> Tuple[bool, Optional[str]]: - """ - Validate a typed_ref string format. - - Args: - ref: typed_ref string to validate - - Returns: - Tuple of (is_valid, error_message) - - Example: - >>> validate_ref("agent-taskstate:task:local:task_01JABC") - (True, None) - >>> validate_ref("invalid") - (False, "Invalid typed_ref format...") - """ - try: - parsed = parse_ref(ref) - if not is_known_domain(parsed.domain): - return False, f"Unknown typed_ref domain: {parsed.domain}" - return True, None - except ValueError as e: - return False, str(e) - - -def is_valid_ref(ref: str) -> bool: - """ - Check if a typed_ref string has valid format. - - Args: - ref: typed_ref string to check - - Returns: - True if valid, False otherwise - """ - return validate_ref(ref)[0] - - -def canonicalize_ref(ref: str) -> str: - """ - Canonicalize a typed_ref string to 4-segment format. - - Accepts both 3-segment (legacy) and 4-segment formats. - Always returns 4-segment canonical format. - - Args: - ref: typed_ref string - - Returns: - Canonical 4-segment typed_ref string - - Raises: - ValueError: If ref format is invalid - - Example: - >>> canonicalize_ref("agent-taskstate:task:task_01JABC") - 'agent-taskstate:task:local:task_01JABC' - >>> canonicalize_ref("agent-taskstate:task:local:task_01JABC") - 'agent-taskstate:task:local:task_01JABC' - """ - parsed = parse_ref(ref) - if not is_known_domain(parsed.domain): - raise ValueError(f"Unknown typed_ref domain: {parsed.domain}") - return str(parsed) - - -def is_known_domain(domain: str) -> bool: - """ - Check if a domain is in the known domains set. - - Args: - domain: Domain name to check - - Returns: - True if known domain, False otherwise - """ - return domain.lower() in KNOWN_DOMAINS - - -# Convenience functions for common domains - -def agent_taskstate_ref(entity_type: str, entity_id: str, provider: str = DEFAULT_PROVIDER) -> str: - """Create an agent-taskstate typed_ref.""" - return format_ref("agent-taskstate", entity_type, entity_id, provider) - - -def memx_ref(entity_type: str, entity_id: str, provider: str = DEFAULT_PROVIDER) -> str: - """Create a memx typed_ref.""" - return format_ref("memx", entity_type, entity_id, provider) - - -def tracker_ref(entity_type: str, entity_id: str, provider: str) -> str: - """Create a tracker typed_ref (provider required).""" - return format_ref("tracker", entity_type, entity_id, provider) +"""Deprecated compatibility shim.""" +from agent_taskstate.typed_ref import * diff --git a/tests/__init__.py b/tests/__init__.py index da63b71..83023bf 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -"""Tests for agent-taskstate CLI.""" \ No newline at end of file +"""Tests for agent-taskstate CLI.""" \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index e97addf..d5e0ae2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,4 @@ -""" +""" agent-taskstate Test Configuration pytest fixtures for testing agent-taskstate CLI. diff --git a/tests/helpers.py b/tests/helpers.py index fcd039b..c88e501 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,4 +1,4 @@ -""" +""" Test helper functions for agent-taskstate CLI testing. """ @@ -10,16 +10,16 @@ from pathlib import Path from typing import Any, Dict -# Import CLI module from src.cli +# Import CLI module from agent_taskstate.cli import sys sys.path.insert(0, str(Path(__file__).parent.parent / "src")) -import cli as agent_taskstate -from cli.utils import gen_id, now_utc, json_ok, json_error -from cli.models import jdump, jload, row_to_task, row_to_task_state, row_to_decision, row_to_question, row_to_run -from cli.db import connect, init_db, SCHEMA_SQL -from cli.constants import EXPECTED_OUTPUT_SCHEMA, TASK_KINDS, TASK_STATUSES, TASK_PRIORITIES, OWNER_TYPES -from cli.errors import AgentTaskstateError, NotFoundError, ConflictError, InvalidTransitionError, DependencyBlockedError -from cli.commands import ( +import agent_taskstate +from agent_taskstate.cli.utils import gen_id, now_utc, json_ok, json_error +from agent_taskstate.cli.models import jdump, jload, row_to_task, row_to_task_state, row_to_decision, row_to_question, row_to_run +from agent_taskstate.cli.db import connect, init_db, SCHEMA_SQL +from agent_taskstate.cli.constants import EXPECTED_OUTPUT_SCHEMA, TASK_KINDS, TASK_STATUSES, TASK_PRIORITIES, OWNER_TYPES +from agent_taskstate.cli.errors import AgentTaskstateError, NotFoundError, ConflictError, InvalidTransitionError, DependencyBlockedError +from agent_taskstate.cli.commands import ( cmd_init, cmd_task_create, cmd_task_show, cmd_task_list, cmd_task_update, cmd_task_set_status, cmd_state_get, cmd_state_put, cmd_state_patch, cmd_decision_add, cmd_decision_list, cmd_decision_accept, cmd_decision_reject, @@ -28,7 +28,7 @@ cmd_context_build, cmd_context_show, cmd_export_task, ) -from cli import AppContext +from agent_taskstate.cli import AppContext # Re-export expected attributes for compatibility @@ -388,7 +388,7 @@ def cmd_task_update(ctx, task_id, title=None, goal=None, priority=None, **extra_ def cmd_task_set_status(ctx, task_id, to_status, reason=None) -> Dict: """Wrapper for task set-status command.""" - args = make_args(task=task_id, to=to_status, reason=reason, reason_required=True) + args = make_args(task=task_id, to=to_status, reason=reason, reason_required=False) return capture_output(agent_taskstate.cmd_task_set_status, ctx, args) diff --git a/tests/test_1_1_0_contract.py b/tests/test_1_1_0_contract.py new file mode 100644 index 0000000..e8a1051 --- /dev/null +++ b/tests/test_1_1_0_contract.py @@ -0,0 +1,130 @@ +"""1.1.0 integration contract tests.""" + +from __future__ import annotations + +import importlib +import json +import sqlite3 +import warnings + +import pytest + +from agent_taskstate.cli import AppContext +from agent_taskstate.cli.commands import ( + cmd_context_build, + cmd_state_patch, + cmd_state_put, + cmd_task_create, + cmd_task_history, + cmd_task_set_status, +) +from agent_taskstate.cli.db import init_db +from agent_taskstate.tracker_bridge import TrackerBridgeService +from .helpers import create_task, capture_output + + +def test_legacy_plural_migration_backfills_once(tmp_path): + path = tmp_path / "legacy.db" + conn = sqlite3.connect(path) + conn.row_factory = sqlite3.Row + conn.executescript( + """ + CREATE TABLE tasks ( + id TEXT PRIMARY KEY, kind TEXT, title TEXT, goal TEXT, status TEXT, + priority TEXT, owner_type TEXT, owner_id TEXT, created_at TEXT, updated_at TEXT + ); + CREATE TABLE context_bundles ( + id TEXT PRIMARY KEY, task_id TEXT, build_reason TEXT, state_snapshot_json TEXT, + included_decision_refs_json TEXT, included_open_question_refs_json TEXT, + included_artifact_refs_json TEXT, included_evidence_refs_json TEXT, + expected_output_schema_json TEXT, created_at TEXT + ); + INSERT INTO tasks VALUES + ('task-legacy','feature','Legacy','Goal','draft','medium','system',NULL,'2026-01-01','2026-01-01'); + INSERT INTO context_bundles VALUES + ('bundle-legacy','task-legacy','normal','{}','[]','[]', + '["memx:evidence:ev-1"]','[]','{}','2026-01-01'); + """ + ) + conn.commit() + init_db(conn) + first = conn.execute("SELECT COUNT(*) FROM state_transitions").fetchone()[0] + sources = conn.execute("SELECT typed_ref FROM context_bundle_sources").fetchall() + assert first == 1 + assert sources[0][0] == "memx:evidence:local:ev-1" + bundle = conn.execute( + "SELECT rebuild_level, generator_version, diagnostics_json FROM context_bundles" + ).fetchone() + assert tuple(bundle) == ("L1", "legacy/1.0.1", '{"legacy_backfill": true}') + init_db(conn) + assert conn.execute("SELECT COUNT(*) FROM state_transitions").fetchone()[0] == first + conn.close() + + +def test_status_history_and_noop(tmp_path): + ctx = AppContext(str(tmp_path / "state.db")) + created = json.loads(_capture(cmd_task_create, ctx, {"kind": "feature", "title": "T", "goal": "G"})) + task_id = created["data"]["id"] + initial = {"current_step": "start", "done_when": ["ready"], "constraints": [], "artifact_refs": [], "evidence_refs": [], "confidence": "medium", "context_policy": {}} + assert json.loads(_capture(cmd_state_put, ctx, {"task": task_id, "json": json.dumps(initial), "file": None}))["ok"] + assert json.loads(_capture(cmd_task_set_status, ctx, {"task": task_id, "to": "ready", "reason": None, "actor_type": "system", "actor_id": None, "run": None, "reason_required": False}))["ok"] + noop = json.loads(_capture(cmd_task_set_status, ctx, {"task": task_id, "to": "ready", "reason": None, "actor_type": "system", "actor_id": None, "run": None, "reason_required": False})) + assert noop["data"]["no_op"] is True + history = json.loads(_capture(cmd_task_history, ctx, {"task": task_id})) + assert len(history["data"]) == 2 + assert history["data"][0]["from_status"] is None + assert history["data"][1]["to_status"] == "ready" + + +def test_state_patch_is_revision_guarded(tmp_path): + ctx = AppContext(str(tmp_path / "state.db")) + created = json.loads(_capture(cmd_task_create, ctx, {"kind": "feature", "title": "T", "goal": "G"})) + task_id = created["data"]["id"] + initial = {"current_step": "start", "done_when": [], "constraints": [], "artifact_refs": [], "evidence_refs": [], "confidence": "medium", "context_policy": {}} + assert json.loads(_capture(cmd_state_put, ctx, {"task": task_id, "json": json.dumps(initial), "file": None}))["ok"] + patch = json.dumps({"current_step": "one"}) + assert json.loads(_capture(cmd_state_patch, ctx, {"task": task_id, "expected_revision": 1, "json": patch, "file": None}))["ok"] + conflict = json.loads(_capture(cmd_state_patch, ctx, {"task": task_id, "expected_revision": 1, "json": patch, "file": None})) + assert conflict["error"]["code"] == "conflict" + + +def test_context_build_keeps_unsupported_diagnostics(tmp_path): + ctx = AppContext(str(tmp_path / "context.db")) + created = json.loads(_capture(cmd_task_create, ctx, {"kind": "feature", "title": "T", "goal": "G"})) + task_id = created["data"]["id"] + state = {"current_step": "investigation", "done_when": [], "constraints": [], "artifact_refs": [], "evidence_refs": ["memx:evidence:missing"], "confidence": "low", "context_policy": {}} + assert json.loads(_capture(cmd_state_put, ctx, {"task": task_id, "json": json.dumps(state), "file": None}))["ok"] + result = json.loads(_capture(cmd_context_build, ctx, {"task": task_id, "reason": "review", "purpose": "review_prepare", "rebuild_level": "L2", "include_raw": True})) + assert result["ok"] + assert result["data"]["diagnostics"]["unsupported_refs"] == ["memx:evidence:local:missing"] + assert result["data"]["raw_included"] is True + + +def test_tracker_secret_values_are_rejected(tmp_path): + conn = sqlite3.connect(tmp_path / "tracker.db") + init_db(conn) + service = TrackerBridgeService(conn) + with pytest.raises(ValueError, match="secret"): + service.create_connection("github", "bad", {"token": "plain"}) + item = service.create_connection("github", "good", {"url": "https://example.invalid"}, {"token": "GITHUB_TOKEN"}) + assert "plain" not in item.config_json + assert json.loads(item.secret_env_json)["token"] == "GITHUB_TOKEN" + + + +def test_cli_compat_shim_warns(): + import sys + + sys.modules.pop("cli", None) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + module = importlib.import_module("cli") + assert module.main is not None + assert any(item.category is DeprecationWarning for item in caught) + +def _capture(func, ctx, values): + values = {"json": None, "file": None, **values} + if func is cmd_task_create and values["json"] is None: + values["json"] = json.dumps({key: value for key, value in values.items() if key not in {"json", "file"}}) + + return json.dumps(capture_output(func, ctx, type("Args", (), values)())) diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 09cbfdc..06fd794 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -1,13 +1,13 @@ -"""Tests for CLI state, decision, question, run, context, export commands.""" +"""Tests for CLI state, decision, question, run, context, export commands.""" import json import pytest import tempfile from pathlib import Path -from cli import main -from cli.db import connect, init_db -from cli.utils import now_utc +from agent_taskstate.cli import main +from agent_taskstate.cli.db import connect, init_db +from agent_taskstate.cli.utils import now_utc @pytest.fixture diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py index 55e1fc9..56b3b4c 100644 --- a/tests/test_cli_main.py +++ b/tests/test_cli_main.py @@ -1,4 +1,4 @@ -"""Tests for CLI main entry point and parser.""" +"""Tests for CLI main entry point and parser.""" import argparse import json @@ -7,12 +7,12 @@ import tempfile from pathlib import Path -from cli import main, AppContext -from cli.parser import build_parser -from cli.db import connect, init_db, SCHEMA_SQL -from cli.errors import AgentTaskstateError, NotFoundError, ConflictError -from cli.utils import json_ok, json_error, now_utc, gen_id -from cli.constants import ( +from agent_taskstate.cli import main, AppContext +from agent_taskstate.cli.parser import build_parser +from agent_taskstate.cli.db import connect, init_db, SCHEMA_SQL +from agent_taskstate.cli.errors import AgentTaskstateError, NotFoundError, ConflictError +from agent_taskstate.cli.utils import json_ok, json_error, now_utc, gen_id +from agent_taskstate.cli.constants import ( APP_NAME, DEFAULT_DB_PATH, TASK_KINDS, TASK_STATUSES, TASK_PRIORITIES, OWNER_TYPES, REPLY_STATES, ALLOWED_TRANSITIONS, ) diff --git a/tests/test_cli_task.py b/tests/test_cli_task.py index 4086e5f..428ddce 100644 --- a/tests/test_cli_task.py +++ b/tests/test_cli_task.py @@ -5,9 +5,9 @@ import tempfile from pathlib import Path -from cli import main -from cli.db import connect, init_db -from cli.utils import now_utc +from agent_taskstate.cli import main +from agent_taskstate.cli.db import connect, init_db +from agent_taskstate.cli.utils import now_utc @pytest.fixture diff --git a/tests/test_context.py b/tests/test_context.py index f24c34d..3eeb0fb 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,4 +1,4 @@ -""" +""" Test cases for Context Bundle Management. Corresponds to: docs/tests/context.feature diff --git a/tests/test_context_bundle.py b/tests/test_context_bundle.py index cd5a54d..79ef3ed 100644 --- a/tests/test_context_bundle.py +++ b/tests/test_context_bundle.py @@ -1,4 +1,4 @@ -""" +""" Tests for context_bundle module. Covers: @@ -12,7 +12,7 @@ import pytest -from src.context_bundle import ( +from agent_taskstate.context_bundle import ( ContextBundleService, create_bundle_tables, PURPOSE_TYPES, diff --git a/tests/test_context_bundle_compression.py b/tests/test_context_bundle_compression.py index 6134809..87c8fb9 100644 --- a/tests/test_context_bundle_compression.py +++ b/tests/test_context_bundle_compression.py @@ -1,11 +1,11 @@ -"""Tests for ContextBundle compression and caching.""" +"""Tests for ContextBundle compression and caching.""" import json import pytest import sqlite3 import tempfile -from src.context_bundle import ( +from agent_taskstate.context_bundle import ( ContextBundle, ContextBundleService, BundleSource, @@ -309,12 +309,12 @@ def test_create_bundle_with_diff(self, service): use_diff=True, ) - # The second bundle should contain only the changed field + # 1.1.0 always stores a complete immutable snapshot snapshot = decompress_json( bundle2.state_snapshot_json, bundle2._state_snapshot_compressed ) - assert snapshot == {"status": "review"} + assert snapshot == {"status": "review", "goal": "Build feature"} def test_create_bundle_diff_no_previous(self, service): """use_diff with no previous bundle uses full snapshot.""" diff --git a/tests/test_context_rebuild_resolver.py b/tests/test_context_rebuild_resolver.py index 6354d15..7ce71c1 100644 --- a/tests/test_context_rebuild_resolver.py +++ b/tests/test_context_rebuild_resolver.py @@ -1,4 +1,4 @@ -""" +""" Tests for resolver module. Covers: @@ -11,7 +11,7 @@ import pytest import sqlite3 -from src.resolver import ( +from agent_taskstate.resolver import ( ContextRebuildResolver, ResolveStatus, ResolvedRef, diff --git a/tests/test_coverage_1_1_0.py b/tests/test_coverage_1_1_0.py new file mode 100644 index 0000000..c806f88 --- /dev/null +++ b/tests/test_coverage_1_1_0.py @@ -0,0 +1,292 @@ +"""Coverage tests for 1.1.0 resolver and tracker branches.""" + +from __future__ import annotations + +import json +import sqlite3 +from datetime import datetime +from types import SimpleNamespace + +import pytest + +from agent_taskstate.cli.db import init_db +from agent_taskstate.context_bundle import ContextRebuildService +from agent_taskstate.resolver import ( + AgentTaskstateLocalResolver, + ContextRebuildResolver, + MemxResolver, + ResolveStatus, + ResolvedRef, + SummaryPayload, + TrackerResolver, +) +from agent_taskstate.tracker_bridge import ( + GitHubAdapter, + JiraAdapter, + MockTrackerAdapter, + TrackerBridgeService, + classify_adapter_exception, + create_tracker_tables, +) +from agent_taskstate.typed_ref import ( + agent_taskstate_ref, + format_ref, + memx_ref, + tracker_ref, +) + + +class BranchResolver: + def __init__(self, mode: str = "resolved"): + self.mode = mode + + def can_resolve(self, ref: str) -> bool: + return "branch" in ref + + def resolve(self, ref: str) -> ResolvedRef: + if self.mode == "error": + raise RuntimeError("resolver boom") + return ResolvedRef(ref, ResolveStatus.RESOLVED, "branch summary", {"ok": True}) + + def load_summary(self, ref: str): + return SummaryPayload(ref, "branch summary") + + def load_raw(self, ref: str, selector=None): + if self.mode == "raw-error": + raise RuntimeError("raw boom") + return {"ref": ref} + + +def test_context_resolver_branches_and_diagnostics(): + resolver = ContextRebuildResolver() + assert resolver.resolve_ref("not-a-ref").status is ResolveStatus.UNSUPPORTED + resolver.register_resolver(BranchResolver()) + resolved = resolver.resolve_ref("agent-taskstate:branch:local:item") + assert resolved.status is ResolveStatus.RESOLVED + report = resolver.resolve_many( + [ + "agent-taskstate:branch:local:item", + "memx:evidence:local:missing", + "bad", + ] + ) + assert report.total_count == 3 + assert report.success_rate == pytest.approx(1 / 3) + assert resolver.load_summary("agent-taskstate:branch:local:item").summary == "branch summary" + assert resolver.load_selected_raw("agent-taskstate:branch:local:item")["ref"] + diagnostics = resolver.get_diagnostics(report) + assert diagnostics.partial_bundle is True + assert diagnostics.unsupported_refs + assert resolver.should_include_raw("operator_request") + assert resolver.should_include_raw("high_priority_open_question", {"has_high_priority_questions": True}) + assert not resolver.should_include_raw("high_priority_open_question", {"has_high_priority_questions": False}) + assert not resolver.should_include_raw("unknown") + exploding = ContextRebuildResolver() + exploding.register_resolver(BranchResolver("error")) + assert exploding.resolve_ref("agent-taskstate:branch:local:item").status is ResolveStatus.UNRESOLVED + raw_exploding = ContextRebuildResolver() + raw_exploding.register_resolver(BranchResolver("raw-error")) + assert raw_exploding.load_selected_raw("agent-taskstate:branch:local:item") is None + + +def test_local_resolver_plural_entities(tmp_path): + conn = sqlite3.connect(tmp_path / "local.db") + init_db(conn) + now = "2026-01-01T00:00:00Z" + conn.execute( + "INSERT INTO tasks(id, kind, title, goal, status, priority, owner_type, created_at, updated_at) " + "VALUES ('t1','feature','Task title','Goal','draft','medium','system',?,?)", + (now, now), + ) + conn.execute( + "INSERT INTO decisions(id, task_id, summary, confidence, status, evidence_refs_json, created_at, updated_at) " + "VALUES ('d1','t1','Decision title','high','accepted','[]',?,?)", + (now, now), + ) + conn.execute( + "INSERT INTO open_questions(id, task_id, question, priority, status, evidence_refs_json, created_at, updated_at) " + "VALUES ('q1','t1','Question title','high','open','[]',?,?)", + (now, now), + ) + conn.execute( + "INSERT INTO runs(id, task_id, actor_type, run_type, status, started_at, created_at, updated_at) " + "VALUES ('r1','t1','system','test','success','2026-01-01',?,?)", + (now, now), + ) + conn.commit() + local = AgentTaskstateLocalResolver(conn) + for entity, ident in [("task", "t1"), ("decision", "d1"), ("question", "q1"), ("run", "r1")]: + result = local.resolve(agent_taskstate_ref(entity, ident)) + assert result.status is ResolveStatus.RESOLVED + assert local.load_summary(result.ref) + assert local.load_raw(result.ref) + assert local.resolve(agent_taskstate_ref("task", "missing")).status is ResolveStatus.UNRESOLVED + assert local.resolve(agent_taskstate_ref("unknown", "x")).status is ResolveStatus.UNSUPPORTED + assert AgentTaskstateLocalResolver().resolve(agent_taskstate_ref("task", "t1")).status is ResolveStatus.UNRESOLVED + assert not local.can_resolve("bad") + conn.close() + + +class FailingAdapter(MockTrackerAdapter): + def fetch_issue(self, issue_key): + raise PermissionError("forbidden credential") + + def normalize_issue(self, raw): + raise ValueError("invalid issue") + + +def test_tracker_resolver_adapter_paths(): + ref = tracker_ref("issue", "PROJ-1", "jira") + resolver = TrackerResolver({"jira": MockTrackerAdapter({"PROJ-1": {"key": "PROJ-1", "summary": "Title"}})}) + assert resolver.resolve(ref).status is ResolveStatus.RESOLVED + assert resolver.load_summary(ref).summary == "Title" + assert resolver.load_raw(ref) + assert resolver.resolve(tracker_ref("issue", "MISSING", "jira")).status is ResolveStatus.UNRESOLVED + assert TrackerResolver().resolve(ref).status is ResolveStatus.UNSUPPORTED + broken = TrackerResolver({"jira": FailingAdapter()}) + assert broken.resolve(ref).status is ResolveStatus.UNRESOLVED + assert not resolver.can_resolve("bad") + + +def test_memx_resolver_adapter_paths(): + evidence = memx_ref("evidence", "e1") + resolver = MemxResolver(fetch_evidence=lambda ident: {"summary": f"Evidence {ident}"}) + assert resolver.resolve(evidence).status is ResolveStatus.RESOLVED + assert resolver.load_summary(evidence).summary == "Evidence e1" + assert resolver.load_raw(evidence) + assert MemxResolver().resolve(evidence).status is ResolveStatus.UNSUPPORTED + assert MemxResolver(fetch_evidence=lambda ident: None).resolve(evidence).status is ResolveStatus.UNRESOLVED + assert MemxResolver(fetch_evidence=lambda ident: (_ for _ in ()).throw(RuntimeError("fetch failed"))).resolve(evidence).status is ResolveStatus.UNRESOLVED + assert MemxResolver().resolve(memx_ref("unsupported", "x")).status is ResolveStatus.UNSUPPORTED + assert not resolver.can_resolve("bad") + + +class FakeGithubIssue: + title = "GitHub title" + body = "Body" + state = "open" + html_url = "https://github/1" + created_at = datetime(2026, 1, 1) + updated_at = datetime(2026, 1, 2) + assignee = SimpleNamespace(login="octocat") + labels = [SimpleNamespace(name="bug")] + comments = [] + edits = [] + + def create_comment(self, comment): + self.comments.append(comment) + + def edit(self, **kwargs): + self.edits.append(kwargs) + + +class FakeGithubRepo: + def __init__(self): + self.issue = FakeGithubIssue() + + def get_issue(self, number): + assert number == 7 + return self.issue + + +def test_github_adapter_with_fake_repository(): + adapter = GitHubAdapter("token", "owner", "repo") + adapter._repo = FakeGithubRepo() + raw = adapter.fetch_issue("#7") + assert raw["number"] == 7 + assert adapter.parse_issue_key("owner/repo#8") == 8 + assert adapter.normalize_issue({"key": "x", "labels": "bug"})["labels"] == ["bug"] + assert adapter.post_comment("7", "hello") + assert adapter.update_status("7", "closed") + assert adapter.update_status("7", "open") + + +class FakeJiraIssue: + key = "PROJ-1" + fields = SimpleNamespace( + summary="Jira title", + description="Description", + status=SimpleNamespace(name="Open"), + assignee=SimpleNamespace(displayName="User"), + priority=SimpleNamespace(name="High"), + labels=[SimpleNamespace(name="backend")], + created=datetime(2026, 1, 1), + updated=datetime(2026, 1, 2), + ) + + +class FakeJira: + def __init__(self): + self.comments = [] + self.transitions_called = [] + + def issue(self, key): + assert key == "PROJ-1" + return FakeJiraIssue() + + def add_comment(self, issue, comment): + self.comments.append(comment) + + def transitions(self, issue): + return [{"name": "Done", "id": "31"}] + + def transition_issue(self, issue, ident): + self.transitions_called.append((issue.key, ident)) + + +def test_jira_adapter_with_fake_client(): + adapter = JiraAdapter("https://jira", "user", "password") + adapter._jira = FakeJira() + raw = adapter.fetch_issue("PROJ-1") + assert raw["key"] == "PROJ-1" + assert adapter.normalize_issue({"key": "x", "labels": "bug"})["labels"] == ["bug"] + assert adapter.post_comment("PROJ-1", "hello") + assert adapter.update_status("PROJ-1", "Done") + assert not adapter.update_status("PROJ-1", "Missing") + + +def test_tracker_error_classification_and_missing_adapter(tmp_path): + assert classify_adapter_exception(PermissionError("forbidden")) == "auth" + assert classify_adapter_exception(LookupError("not found")) == "not-found" + assert classify_adapter_exception(RuntimeError("rate limit 429")) == "rate-limit" + assert classify_adapter_exception(ValueError("invalid value")) == "validation" + assert classify_adapter_exception(RuntimeError("network")) == "transport" + conn = sqlite3.connect(tmp_path / "tracker.db") + conn.row_factory = sqlite3.Row + create_tracker_tables(conn) + service = TrackerBridgeService(conn) + connection = service.create_connection("jira", "missing", {"url": "https://example.invalid"}) + assert service.fetch_issue(connection.id, "X") is None + assert not service.post_outbound_comment(connection.id, "X", "comment") + assert not service.update_outbound_status(connection.id, "X", "Done") + with pytest.raises(ValueError): + service.create_connection("jira", "bad", {}, {"token": 1}) + with pytest.raises(ValueError): + service.link_issue_to_task("bad", "bad") + conn.close() + + +def test_context_rebuild_latest_bundle_and_invalid_ref(tmp_path): + conn = sqlite3.connect(tmp_path / "bundle.db") + init_db(conn) + now = "2026-01-01T00:00:00Z" + conn.execute( + "INSERT INTO tasks(id, kind, title, goal, status, priority, owner_type, created_at, updated_at) " + "VALUES ('t1','feature','Bundle task','Goal','draft','medium','system',?,?)", + (now, now), + ) + conn.execute( + "INSERT INTO task_states(task_id, revision, current_step, constraints_json, done_when_json, " + "artifact_refs_json, evidence_refs_json, context_policy_json, confidence, created_at, updated_at) " + "VALUES ('t1',1,'step','[]','[]','[\"invalid\"]','[]','{}','medium',?,?)", + (now, now), + ) + conn.commit() + service = ContextRebuildService(conn) + bundle = service.build("t1") + assert bundle.id + assert service.get_latest_bundle("t1").id == bundle.id + service.clear_cache() + assert service.get_latest_bundle("t1").id == bundle.id + assert service.get_latest_bundle("missing") is None + conn.close() diff --git a/tests/test_decision.py b/tests/test_decision.py index 4f90376..da585e2 100644 --- a/tests/test_decision.py +++ b/tests/test_decision.py @@ -1,4 +1,4 @@ -""" +""" Test cases for Decision Management. Corresponds to: docs/tests/decision.feature diff --git a/tests/test_export.py b/tests/test_export.py index c68cbc5..e678d39 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -1,4 +1,4 @@ -""" +""" Test cases for Export Functionality. Corresponds to: docs/tests/export.feature diff --git a/tests/test_integration.py b/tests/test_integration.py index aab348f..15ca593 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,4 +1,4 @@ -""" +""" Integration tests for complete workflows. Tests the full flow from task creation to completion. @@ -184,7 +184,7 @@ def test_feature_task_complete_flow(self, empty_db, tmp_path): "context_policy": {}, } # Mark all done_when as complete by updating state - output = cmd_state_put(ctx, task_id=task_id, state_json=state_data) + output = cmd_state_patch(ctx, task_id=task_id, expected_revision=current_revision, patch_json=state_data) assert output["ok"] is True output = cmd_task_set_status( @@ -459,8 +459,8 @@ def test_valid_typed_refs(self, empty_db): "summary": "Test decision", "confidence": "high", "evidence_refs": [ - "memx:evidence:01HABC123", - "memx:evidence:01HDEF456", + "memx:evidence:local:01HABC123", + "memx:evidence:local:01HDEF456", ], } output = cmd_decision_add(ctx, task_id=task_id, decision_json=decision_data) @@ -474,7 +474,7 @@ def test_valid_typed_refs(self, empty_db): ).fetchone() refs = json.loads(row["evidence_refs_json"]) assert len(refs) == 2 - assert refs[0] == "memx:evidence:01HABC123" + assert refs[0] == "memx:evidence:local:01HABC123" def test_run_with_typed_refs(self, empty_db): """Test run with typed_ref input/output.""" @@ -490,7 +490,7 @@ def test_run_with_typed_refs(self, empty_db): run_type="execute", actor_type="agent", actor_id="agent-001", - input_ref="agent-taskstate:context_bundle:01HBUNDLE001", + input_ref="agent-taskstate:context_bundle:local:01HBUNDLE001", ) assert output["ok"] is True run_id = output["data"]["id"] @@ -500,7 +500,7 @@ def test_run_with_typed_refs(self, empty_db): ctx, run_id=run_id, status="succeeded", - output_ref="memx:artifact:01HARTIFACT001", + output_ref="memx:artifact:local:01HARTIFACT001", ) assert output["ok"] is True @@ -510,8 +510,8 @@ def test_run_with_typed_refs(self, empty_db): "SELECT input_ref, output_ref FROM runs WHERE id = ?", (run_id,), ).fetchone() - assert row["input_ref"] == "agent-taskstate:context_bundle:01HBUNDLE001" - assert row["output_ref"] == "memx:artifact:01HARTIFACT001" + assert row["input_ref"] == "agent-taskstate:context_bundle:local:01HBUNDLE001" + assert row["output_ref"] == "memx:artifact:local:01HARTIFACT001" class TestSubtaskCreation: diff --git a/tests/test_memx_integration.py b/tests/test_memx_integration.py index e4bd613..dc81e96 100644 --- a/tests/test_memx_integration.py +++ b/tests/test_memx_integration.py @@ -1,10 +1,10 @@ -""" +""" Integration tests for typed_ref between agent-taskstate and memx-core. Tests cross-domain ref resolution scenarios. """ -from src.typed_ref import ( +from agent_taskstate.typed_ref import ( parse_ref, format_ref, is_known_domain, @@ -12,7 +12,7 @@ agent_taskstate_ref, memx_ref, ) -from src.resolver import ( +from agent_taskstate.resolver import ( ContextRebuildResolver, AgentTaskstateLocalResolver, ) diff --git a/tests/test_question.py b/tests/test_question.py index 783775a..30cfb2d 100644 --- a/tests/test_question.py +++ b/tests/test_question.py @@ -1,4 +1,4 @@ -""" +""" Test cases for Open Question Management. Corresponds to: docs/tests/question.feature diff --git a/tests/test_resolver_tracker.py b/tests/test_resolver_tracker.py index b7986a7..7ca0440 100644 --- a/tests/test_resolver_tracker.py +++ b/tests/test_resolver_tracker.py @@ -1,10 +1,10 @@ -"""Tests for TrackerResolver and MemxResolver.""" +"""Tests for TrackerResolver and MemxResolver.""" import pytest import sqlite3 from pathlib import Path -from src.resolver import ( +from agent_taskstate.resolver import ( ContextRebuildResolver, TrackerResolver, MemxResolver, @@ -13,8 +13,8 @@ ResolverDiagnostics, AgentTaskstateLocalResolver, ) -from src.typed_ref import tracker_ref, memx_ref, agent_taskstate_ref, format_ref, parse_ref -from src.tracker_bridge import ( +from agent_taskstate.typed_ref import tracker_ref, memx_ref, agent_taskstate_ref, format_ref, parse_ref +from agent_taskstate.tracker_bridge import ( TrackerBridgeService, MockTrackerAdapter, create_tracker_tables, @@ -183,7 +183,8 @@ def test_tracker_bridge_create_connection(self, conn): conn_rec = service.create_connection( name="github-main", provider="github", - config={"token": "test"}, + config={"url": "https://example.invalid"}, + secret_env={"token": "GITHUB_TOKEN"}, ) assert conn_rec is not None assert conn_rec.name == "github-main" diff --git a/tests/test_run.py b/tests/test_run.py index 9e67965..fd0d4eb 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -1,4 +1,4 @@ -""" +""" Test cases for Run Management. Corresponds to: docs/tests/run.feature diff --git a/tests/test_state.py b/tests/test_state.py index 32f5a1d..65f14b6 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -1,4 +1,4 @@ -""" +""" Test cases for Task State Management. Corresponds to: docs/tests/state.feature @@ -14,64 +14,20 @@ class TestStatePut: """Test state put command.""" - def test_create_new_task_state(self, empty_db): - """Spec 5.2: Create new task state.""" - ctx = agent_taskstate.AppContext(db_path=empty_db) - - with agent_taskstate.connect(empty_db) as conn: - task_id = create_task(conn) - - state_data = { - "current_step": "実装中", - "constraints": ["制約1", "制約2"], - "done_when": ["条件1", "条件2"], - "current_summary": "現在の要約", - "artifact_refs": [], - "evidence_refs": [], - "confidence": "medium", - "context_policy": {"force_evidence": False}, - } - - output = cmd_state_put(ctx, task_id=task_id, state_json=state_data) - - assert output["ok"] is True - with agent_taskstate.connect(empty_db) as conn: - row = conn.execute( - "SELECT * FROM task_states WHERE task_id = ?", (task_id,) - ).fetchone() - assert row is not None - assert row["revision"] == 1 - assert row["current_step"] == "実装中" - def test_state_put_overwrites_existing(self, empty_db): - """State put updates existing state and increments revision.""" + """State put is insert-only and conflicts when state already exists.""" ctx = agent_taskstate.AppContext(db_path=empty_db) - with agent_taskstate.connect(empty_db) as conn: task_id = create_task(conn) create_task_state(conn, task_id, revision=5, current_step="古い状態") - state_data = { - "current_step": "新しい状態", - "constraints": [], - "done_when": ["条件1"], - "current_summary": "", - "artifact_refs": [], - "evidence_refs": [], - "confidence": "high", - "context_policy": {}, + "current_step": "新しい状態", "constraints": [], "done_when": ["条件1"], + "current_summary": "", "artifact_refs": [], "evidence_refs": [], + "confidence": "high", "context_policy": {}, } - output = cmd_state_put(ctx, task_id=task_id, state_json=state_data) - - assert output["ok"] is True - with agent_taskstate.connect(empty_db) as conn: - row = conn.execute( - "SELECT revision, current_step FROM task_states WHERE task_id = ?", (task_id,) - ).fetchone() - assert row["revision"] == 6 # 5 + 1 = incremented revision - assert row["current_step"] == "新しい状態" - + assert output["ok"] is False + assert output["error"]["code"] == "conflict" class TestStateGet: """Test state get command.""" diff --git a/tests/test_state_transition.py b/tests/test_state_transition.py index 847bf62..d00f18b 100644 --- a/tests/test_state_transition.py +++ b/tests/test_state_transition.py @@ -1,4 +1,4 @@ -""" +""" Tests for state_transition module. Covers: @@ -10,7 +10,7 @@ import pytest import sqlite3 -from src.state_transition import ( +from agent_taskstate.state_transition import ( StateTransitionService, InvalidTransitionError, TerminalStateError, @@ -272,7 +272,7 @@ def test_proposed_transitions(self): def test_done_transitions(self): """done can only transition to in_progress (reopen).""" - assert ALLOWED_TRANSITIONS["done"] == {"in_progress"} + assert ALLOWED_TRANSITIONS["done"] == {"in_progress", "archived"} def test_cancelled_no_transitions(self): """cancelled has no outgoing transitions.""" diff --git a/tests/test_task.py b/tests/test_task.py index 8987a13..537c4a8 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -1,4 +1,4 @@ -""" +""" Test cases for Task Management. Corresponds to: docs/tests/task.feature diff --git a/tests/test_task_phase2.py b/tests/test_task_phase2.py index d982754..bdd9b20 100644 --- a/tests/test_task_phase2.py +++ b/tests/test_task_phase2.py @@ -1,4 +1,4 @@ -import json +import json from .helpers import ( agent_taskstate, diff --git a/tests/test_tracker_bridge.py b/tests/test_tracker_bridge.py index 344af2e..bff371e 100644 --- a/tests/test_tracker_bridge.py +++ b/tests/test_tracker_bridge.py @@ -1,4 +1,4 @@ -""" +""" Tests for tracker_bridge module. Covers: @@ -13,7 +13,7 @@ import pytest -from src.tracker_bridge import ( +from agent_taskstate.tracker_bridge import ( LinkRole, MockTrackerAdapter, SyncDirection, diff --git a/tests/test_typed_ref.py b/tests/test_typed_ref.py index eb8a3c7..0b59c3d 100644 --- a/tests/test_typed_ref.py +++ b/tests/test_typed_ref.py @@ -1,4 +1,4 @@ -""" +""" Tests for typed_ref module. Covers: @@ -10,7 +10,7 @@ import pytest -from src.typed_ref import ( +from agent_taskstate.typed_ref import ( TypedRef, canonicalize_ref, format_ref, diff --git a/tests/test_workflow_plugin.py b/tests/test_workflow_plugin.py index e11e24f..009f4da 100644 --- a/tests/test_workflow_plugin.py +++ b/tests/test_workflow_plugin.py @@ -1,4 +1,4 @@ -from __future__ import annotations +from __future__ import annotations from pathlib import Path @@ -48,9 +48,55 @@ def test_sync_task_acceptance_links_done_task_to_acceptance(tmp_path: Path) -> N assert isinstance(report, TaskAcceptanceSyncResult) assert report.errors == [] + assert report.warnings == [] assert report.tasks[0]["acceptance_ids"] == ["AC-20260410-01"] +def test_sync_task_acceptance_warns_for_done_task_without_acceptance_by_default(tmp_path: Path) -> None: + _write( + tmp_path / "docs" / "tasks" / "task-sample.md", + """ +--- +task_id: 20260410-02 +intent_id: INT-001 +owner: docs-core +status: done +--- + +# Task Seed: Sample +""", + ) + + plugin = create_plugin() + report = plugin.sync_task_acceptance(repo_root=tmp_path) + + assert report.errors == [] + assert report.warnings == ["Done task '20260410-02' is missing an acceptance record."] + assert report.tasks[0]["acceptance_ids"] == [] + + +def test_sync_task_acceptance_can_require_acceptance_for_done_tasks(tmp_path: Path) -> None: + _write( + tmp_path / "docs" / "tasks" / "task-sample.md", + """ +--- +task_id: 20260410-03 +intent_id: INT-001 +owner: docs-core +status: done +--- + +# Task Seed: Sample +""", + ) + + plugin = create_plugin(require_acceptance_for_done=True) + report = plugin.sync_task_acceptance(repo_root=tmp_path) + + assert report.errors == ["Done task '20260410-03' is missing an acceptance record."] + assert report.warnings == [] + + def test_build_acceptance_index_renders_table(tmp_path: Path) -> None: _write( tmp_path / "docs" / "acceptance" / "AC-20260410-01.md", diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..256168a --- /dev/null +++ b/uv.lock @@ -0,0 +1,1010 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "agent-taskstate" +version = "1.1.0" +source = { editable = "." } + +[package.optional-dependencies] +dev = [ + { name = "build" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] +github = [ + { name = "pygithub" }, +] +jira = [ + { name = "jira" }, +] +tracker = [ + { name = "jira" }, + { name = "pygithub" }, +] + +[package.metadata] +requires-dist = [ + { name = "build", marker = "extra == 'dev'", specifier = ">=1.2" }, + { name = "jira", marker = "extra == 'jira'", specifier = ">=3.8" }, + { name = "jira", marker = "extra == 'tracker'", specifier = ">=3.8" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, + { name = "pygithub", marker = "extra == 'github'", specifier = ">=2.1" }, + { name = "pygithub", marker = "extra == 'tracker'", specifier = ">=2.1" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" }, +] +provides-extras = ["github", "jira", "tracker", "dev"] + +[[package]] +name = "ast-serialize" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, + { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, + { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, +] + +[[package]] +name = "build" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10.2'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/21/6ec54248b4d0d51f12f3ca4aa77a128077d747a5db86cb5a2fcd9aedecbd/build-1.5.1.tar.gz", hash = "sha256:94e17f1db803ab22f46049376c44c8437c52090f0dfdf1adc43df56542d644fb", size = 112439, upload-time = "2026-07-09T06:21:59.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/f7/2c3f99ff9282f1c1ec9f6298f2c03034658a0901eeacb3b3501cb488574c/build-1.5.1-py3-none-any.whl", hash = "sha256:f1a58fe2e5af5b0238a07b9e70207492c79ddebbdb1ad954fc86d62a56be3e0d", size = 31195, upload-time = "2026-07-09T06:21:58.551Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/e9/6d7724983b3d5a0908dbf74f64038ade77c18646ff6636ec7894fd392ce1/cffi-2.1.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0", size = 183837, upload-time = "2026-07-06T21:32:09.655Z" }, + { url = "https://files.pythonhosted.org/packages/69/aa/24580a278de21fd7322635556334d9b535f1cbc00b0a3919447cdf464c65/cffi-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd", size = 184226, upload-time = "2026-07-06T21:32:11.196Z" }, + { url = "https://files.pythonhosted.org/packages/88/a9/02cae418ec4beb282ace11958d9d4737793439d561fadc7e6d56f2e2b354/cffi-2.1.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46", size = 211107, upload-time = "2026-07-06T21:32:12.328Z" }, + { url = "https://files.pythonhosted.org/packages/3b/30/c806937ed5e4c2c7ac30d9d6b76b5dc57ff8b75d83800d9bb11a8253cf2a/cffi-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2", size = 218733, upload-time = "2026-07-06T21:32:13.67Z" }, + { url = "https://files.pythonhosted.org/packages/f9/cf/398272b8bbfd58aa314fda5a7f1cdbb26d1d78ae324a11211521315dd1f0/cffi-2.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd", size = 205543, upload-time = "2026-07-06T21:32:15.148Z" }, + { url = "https://files.pythonhosted.org/packages/45/ca/f91641185cdd90c36d317a9dc7f85e88ef8682d8b300977baff5e23c35d8/cffi-2.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3", size = 205460, upload-time = "2026-07-06T21:32:16.479Z" }, + { url = "https://files.pythonhosted.org/packages/38/66/04781a77b411f0bb5b234d62c1814754ab75ebe455ccff1b08e8d7aae98f/cffi-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0", size = 218760, upload-time = "2026-07-06T21:32:17.98Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9a/bb1d5ed9c3fcae158e9f6391bf309c95d98c2ac37ed56573228471d0af5e/cffi-2.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43", size = 221230, upload-time = "2026-07-06T21:32:19.407Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/3c1409cdd26094efacd1c36c66e0a6eb9d4296e4fd4f9901b8b2042f4323/cffi-2.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c", size = 213524, upload-time = "2026-07-06T21:32:20.828Z" }, + { url = "https://files.pythonhosted.org/packages/fa/75/74dfb7c3fc6ebbd408038476bd4c1d7e925c62614e7b9c534ecc34218288/cffi-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd", size = 220341, upload-time = "2026-07-06T21:32:21.9Z" }, + { url = "https://files.pythonhosted.org/packages/70/b6/9003c33a3e7d2c1306f5962e646457dcfe5a8cd8fce6bbe02d7af25db783/cffi-2.1.0-cp310-cp310-win32.whl", hash = "sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f", size = 174578, upload-time = "2026-07-06T21:32:23.073Z" }, + { url = "https://files.pythonhosted.org/packages/8a/26/710688310447531c7a22f857c7f79d9855ec18b03e04494ced723fb37e2f/cffi-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da", size = 185071, upload-time = "2026-07-06T21:32:24.671Z" }, + { url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" }, + { url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" }, + { url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" }, + { url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" }, + { url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" }, + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, + { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, + { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/81/8e983840c6e5b93b33c2ba81aa3d52c2e42f0e9a690ce7607a2e61da4a5c/charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a", size = 322240, upload-time = "2026-07-07T14:32:36.236Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/b4319dc3229d8272fba305e206fc0a148e2de8d4087917ce62ae6382f359/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616", size = 216475, upload-time = "2026-07-07T14:32:38.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/33/6c99c1b3e6b8bf730e1bc809b9a2608f224145069114c479a2e9e1494346/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209", size = 238670, upload-time = "2026-07-07T14:32:39.658Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f4/ffbb83546e1f198ecc70ecd372b65cf2b50f9068b380abd67640f17a8e18/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99", size = 233476, upload-time = "2026-07-07T14:32:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5f/b98b8da398637b551e427e7be922bdec19177dc54d6811dcdaa503f23aac/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8", size = 223817, upload-time = "2026-07-07T14:32:42.592Z" }, + { url = "https://files.pythonhosted.org/packages/36/31/a276bb2e66243072a3fd06fdcab9cbb61a305b02143d70d2bda21d888fa8/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b", size = 207974, upload-time = "2026-07-07T14:32:44.258Z" }, + { url = "https://files.pythonhosted.org/packages/5e/be/7ee4453d7e88dfbc4104ccd34900b9f2c7c17dac22881865fe0e82424a25/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2", size = 221655, upload-time = "2026-07-07T14:32:45.64Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/181c652953eb5276d198f375b1dd641047392050098100a3a02d6534f657/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9", size = 219229, upload-time = "2026-07-07T14:32:47.376Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e7/aaf6da33fc9f4691cda8f7efbc9f69179d3d39ec8a4799baf273ee1d8db0/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15", size = 209704, upload-time = "2026-07-07T14:32:48.855Z" }, + { url = "https://files.pythonhosted.org/packages/63/01/f2fb3bd3a73be48b173ee0c6aa8d2497af97d5663a8c4c4b491de4c62f7a/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d", size = 226243, upload-time = "2026-07-07T14:32:50.239Z" }, + { url = "https://files.pythonhosted.org/packages/c4/02/c57a22739fe05246b0b5783b3bfb6afaac4eebb46f3ececdfb2f048f780e/charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381", size = 150935, upload-time = "2026-07-07T14:32:51.676Z" }, + { url = "https://files.pythonhosted.org/packages/37/8d/ca39a7559a4797505530d084fd3a49a2c959efbbbff146302fb7be4e3b35/charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee", size = 162314, upload-time = "2026-07-07T14:32:53.193Z" }, + { url = "https://files.pythonhosted.org/packages/01/da/a44bd7a13d426e69e4894557106cd58669097bfad4a8681123b618fbfc5d/charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419", size = 153075, upload-time = "2026-07-07T14:32:54.554Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/91/0a7c28934e50d8ac9a7b117712d176f2953c3170bccced5eaacfa3e96175/coverage-7.14.3.tar.gz", hash = "sha256:1a7563a443f3d53fdeb040ec8c9f7466aed7ca3dc5891aa09d3ca3625fa4387f", size = 924398, upload-time = "2026-06-22T23:10:25.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bd/b01188f0de73ee8b6597cf20c63fccd898ad31405772f15165cb61a62c00/coverage-7.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:360bec1f58e7243e3405d3bdf7a1a8115aa9b448d54dc7cd6f7b7e0e9406b62e", size = 220378, upload-time = "2026-06-22T23:07:38.925Z" }, + { url = "https://files.pythonhosted.org/packages/33/eb/f7aa3cb46500b709070c8d12335446971ec8b8c2ea155fea05d2000b4b1f/coverage-7.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed68faa5e85de2f3e400bc3f122e5c82735a58c8bb24b9f63a2215954ba17b2d", size = 220895, upload-time = "2026-06-22T23:07:41.536Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/b41b8499fc9060ca40ad2a197d301155be1ead398f0f0bfdb27b2b4a660f/coverage-7.14.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:830c1fca669c572dec37ce9c838224ee45aac5be0f6961edf871e82e49d6537c", size = 247631, upload-time = "2026-06-22T23:07:43.244Z" }, + { url = "https://files.pythonhosted.org/packages/da/bb/e9ecea1307c6a549c223842cccbd5d55193cc27b82f26338782d4355047c/coverage-7.14.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a64caee2193563601dbaaa55fe2dcf597debef04a2f8f1fa8a07aa4bb7ac7a1e", size = 249460, upload-time = "2026-06-22T23:07:45.147Z" }, + { url = "https://files.pythonhosted.org/packages/59/cb/3821542809b7b726296fd364ed1c23d10a5770f1469957010c3b4bc5d408/coverage-7.14.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0096fd7559178f0cc9cf088f2dbd2a02ef85bacaa69732c633517286b4494610", size = 251324, upload-time = "2026-06-22T23:07:46.875Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/f34f66f0ff152189ccc7b3f0582cf7909e239cb3b8c214362ed2149719b8/coverage-7.14.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6197e5a00183c11a8ce7c6abd18be1a9189fd8399084ffc95196f4f0db4f2137", size = 253237, upload-time = "2026-06-22T23:07:48.352Z" }, + { url = "https://files.pythonhosted.org/packages/22/81/aa363fa95d14fc892bd5de80edadc8d7cce584a0f6376f6336e492618e67/coverage-7.14.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7dfe427045520d6abca33687dfef767b4f635015893a1816c5decb12eb72ce18", size = 248344, upload-time = "2026-06-22T23:07:49.896Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/dc8a149441a3fea611cbbaf46bb12099adbe08f69903df1794581b0504b8/coverage-7.14.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9a3f142070eb7b82fc4085a55d887396f9c4e21250bccebe2ba22502c45b9647", size = 249365, upload-time = "2026-06-22T23:07:51.464Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a2/0004127deee122e020be24a4d86ce72fa14ae28198811b945aabf91293b5/coverage-7.14.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64b2055bb6e0dc945af35cdeceb3633e6ed9273475ef3af85592410fd6803803", size = 247369, upload-time = "2026-06-22T23:07:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/1e/72/3654c004f4df4f0c5a9643d9abaed5b26e5d3c1d0ecabe788786cb425efa/coverage-7.14.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1551b4caac3e3ec9f2bfcec6bf3776e01c0edbdd2e240431a50ca1a1aac72c27", size = 251182, upload-time = "2026-06-22T23:07:54.789Z" }, + { url = "https://files.pythonhosted.org/packages/a5/2f/7bdcdf1e7c4d0632648852768063c25582a0a747bb5f8036a04e211e7eb7/coverage-7.14.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:583d50d59142f8549470bd6390471d0fe8b8c8d69d6a0f28ac71e05380cef640", size = 247639, upload-time = "2026-06-22T23:07:56.254Z" }, + { url = "https://files.pythonhosted.org/packages/03/dc/0e01b071f69021d262a51ce39345dd6bc194465db0acfc7b34fd89e6b787/coverage-7.14.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0bb8a6bc7015efdf8a928753b25da1b9ca2d6f24ef04d2ee0688e486f32aae7", size = 248242, upload-time = "2026-06-22T23:07:57.692Z" }, + { url = "https://files.pythonhosted.org/packages/1c/51/08279e6ebe3479bf705db5fdc1a968e44ba1567e4cbc567f76b45f5e646e/coverage-7.14.3-cp310-cp310-win32.whl", hash = "sha256:d48400185564042287dc487c1f016a3397f18ab4f4c5d5ec36edc218f7ffa35b", size = 222431, upload-time = "2026-06-22T23:07:59.094Z" }, + { url = "https://files.pythonhosted.org/packages/40/2f/5c56670781fee5722ef0c415a74750c9a033bfacdb9d07b1493a0308108d/coverage-7.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:eadea7aba74e40adee867a8c0eec17b820b061d308a4b014f7a0e118c2b0aa61", size = 223059, upload-time = "2026-06-22T23:08:00.662Z" }, + { url = "https://files.pythonhosted.org/packages/f1/24/efb17eb94018dd3415d0e8a76a4786a866e8964aa9c50f033399d23939c2/coverage-7.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e574801e1d643561594aa021206c46d80b257e9853087090ba97bed8b0a509d3", size = 220501, upload-time = "2026-06-22T23:08:02.182Z" }, + { url = "https://files.pythonhosted.org/packages/76/93/32f1bfca6cdd34259c8af42820a034b7a28dfb44969a13ed38c17e0ba5b0/coverage-7.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f82b6bb7d75a2613e85d07cefa3a8c973d0544a8993337f6e2728e4a1e94c305", size = 221008, upload-time = "2026-06-22T23:08:03.701Z" }, + { url = "https://files.pythonhosted.org/packages/eb/88/0d0f974855ff905d15a64f7873d00bdc4182e2736267486c6634f4af293c/coverage-7.14.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a2335ea5fed26af2e831094964fa3f8fae60b45f7e37fcc2d3b615b2add3ad87", size = 251420, upload-time = "2026-06-22T23:08:05.211Z" }, + { url = "https://files.pythonhosted.org/packages/39/7f/117dd2ec65e4140576f8ef991d88220f9b806769f7a8c20e0550c0f924e2/coverage-7.14.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fbb8c3a98e779013786ae01d229662aeacbc77100efbd3f2f245219ace5af700", size = 253331, upload-time = "2026-06-22T23:08:06.672Z" }, + { url = "https://files.pythonhosted.org/packages/87/55/f0bd6d6538e3f16829fb8a44b6c0d2fe9da638bbfdd6a20f8b5da8f4fa81/coverage-7.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac082660de8f429ba0ea363595abb838998570b9a7546777c60f413ab902bbde", size = 255441, upload-time = "2026-06-22T23:08:08.208Z" }, + { url = "https://files.pythonhosted.org/packages/1e/98/aa71f7879019c846a8a9662579ea4484b0202cf1e252ffeed647075e7eca/coverage-7.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac012839ff7e396030f1e94e10553a431d14e4de2ab65cb3acb72bbd5628ca2", size = 257398, upload-time = "2026-06-22T23:08:09.749Z" }, + { url = "https://files.pythonhosted.org/packages/f3/4f/5fd367e59844190f5965015d7bee899e67a89d13eb2760118479bf836f2f/coverage-7.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5952f8c1bda2a5347154450379316e6dfa4d934d62ca35f6784451e6f55074fb", size = 251558, upload-time = "2026-06-22T23:08:11.37Z" }, + { url = "https://files.pythonhosted.org/packages/8f/de/5383a6ee5a6376701fe07d980fa8e4a66c0c377fead16712720340d701a3/coverage-7.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8cf0f2509acb4619e2471a1951089054dd58ebea7a912066d2ea56dd4c24ca4a", size = 253134, upload-time = "2026-06-22T23:08:13.04Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/09542b1a99f788e3daec7f0fadc288821e71aca9ea298d51bfa1ba79fed5/coverage-7.14.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2e41fd3aab806770008279a93879b0924b16247e09ab537c043d08bbca53b4ab", size = 251195, upload-time = "2026-06-22T23:08:14.606Z" }, + { url = "https://files.pythonhosted.org/packages/02/9d/722fe8c13f0fbb064491b9e8656e56a606286792e5068c47ca1042e773e8/coverage-7.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f0a47095963cfe054e0df178daca95aec21e680d6076da807c3add28dfe920f7", size = 254959, upload-time = "2026-06-22T23:08:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/fb/58/943627179ff1d82da9e54d0a5b0bb907bb19cf19515599ccd921de50b469/coverage-7.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a090cbf9521e78ffdb2fcf448b72902afe9f5923ff6a12d5c0d0120200348af9", size = 250914, upload-time = "2026-06-22T23:08:18.03Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d4/803efcbf9ae5567454a0c71e983589529448e2704ee0da2dc0163d482f18/coverage-7.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d310baf69a4fbe8a098ce727e4808a34866ac718a6f759ae659cbd3221358bc", size = 251824, upload-time = "2026-06-22T23:08:19.704Z" }, + { url = "https://files.pythonhosted.org/packages/32/79/3f78ea9563132746eed5cecb75d2e576f9d8fec45a47242b5ae0950b82a3/coverage-7.14.3-cp311-cp311-win32.whl", hash = "sha256:74fdd718d88fe144f4579b8747873a07ec3f04cb837d5faec5a25d9e22fa31a8", size = 222594, upload-time = "2026-06-22T23:08:21.311Z" }, + { url = "https://files.pythonhosted.org/packages/85/22/9ebbc5a2ab42ac5d0eea1f48648629e1de9bbe41ec243ed6b93d55a5a53f/coverage-7.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:cc96aa922e21d4bc5d5ed3c915cef27dfcbc13686f47d5e378d647fbfba655a2", size = 223073, upload-time = "2026-06-22T23:08:23.318Z" }, + { url = "https://files.pythonhosted.org/packages/71/af/69d5fcc16cb555153f99cec5467922f226be0369f7335a9506856d2a7bd0/coverage-7.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:c66f9f9d4f1e9712eb9b1de5310f881d4e2188cfcba5065e1a8490f38687f2c4", size = 222617, upload-time = "2026-06-22T23:08:25.054Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b0/8a911f6ffe6974dac4df95b468ab9a2899d0e59f0f99a489afeec39f00bc/coverage-7.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d74ff26299c4879ce3a4d826f9d3d4d556fd285fde7bbce3c0ef5a8ab1cec24", size = 220672, upload-time = "2026-06-22T23:08:26.621Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/0fc0cb52538783dbbae0934b834f5a58fd5354380ee6cad4a07b15dc845d/coverage-7.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:96150a9cf3468ea20f0bc5d0e21b3df8972c31480ef90fa7614b773cc6429665", size = 221035, upload-time = "2026-06-22T23:08:28.372Z" }, + { url = "https://files.pythonhosted.org/packages/77/e2/421ccfbb48335ac49e93301478cf5d623b0c2bf1c0cadd8e2b2fc6c0c710/coverage-7.14.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:27d07a46500ba23515b838dbcf52512026af04090755cf6cc64166d88c9b9a1a", size = 252540, upload-time = "2026-06-22T23:08:30.226Z" }, + { url = "https://files.pythonhosted.org/packages/06/c2/05b8c890097c61a7f4406b35396b997a635200ded0339eda83dfbe526c5f/coverage-7.14.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:621e13c6108234d7960aaf5762ab5c3c00f33c30c15af06dcbff0c73bf112727", size = 255274, upload-time = "2026-06-22T23:08:31.876Z" }, + { url = "https://files.pythonhosted.org/packages/dc/be/b6d9efe447f8ba3c3c854195f326bd64c54b907d936cd2fdebf8767ec72e/coverage-7.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b60ca6d8af70473491a15a343cbabab2e8f9ea66a4376e81c7aa24876a6f977", size = 256389, upload-time = "2026-06-22T23:08:33.843Z" }, + { url = "https://files.pythonhosted.org/packages/d4/3c/f26e50acc429e608bc534ac06f0a3c169019c798178ec5e9de3dbc0df9c9/coverage-7.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c90a7cdd5e380e1ce02f19792e2ac2fbfbf177e35a27e69fd3e873b30d895c0c", size = 258648, upload-time = "2026-06-22T23:08:35.481Z" }, + { url = "https://files.pythonhosted.org/packages/9e/a2/01c1fabf816c8e1dae197e258edf878a3d3ddc86fbda34b76e5794277d8f/coverage-7.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d788e5fd55347eef06ca0732c77d04a264de67e8ff24631270cdff3767a60cf", size = 252949, upload-time = "2026-06-22T23:08:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/89/c6/941166dd79c31fd44a13063780ae8d552eee0089a0a0930b9bdb7df554ed/coverage-7.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62c7f79db2851c95ef020e5d28b97afde3daf9f7febcd35b53e05638f729063f", size = 254310, upload-time = "2026-06-22T23:08:39.174Z" }, + { url = "https://files.pythonhosted.org/packages/10/31/80b1fd028201a961033ce95be3cd1e39e521b3762e6b4a1ac1616cb291e7/coverage-7.14.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:90f7608aeb5d9b60b523b9fb2a4ee1973867cc4865a3f26fe6c7577073b70205", size = 252453, upload-time = "2026-06-22T23:08:40.84Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/c3d9addd94c4b524f3f4af0232075f5fe7170ce99a1386edff803e5934db/coverage-7.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1e3b91f9c4740aeb571ecf82e5e8d8e4ab62d34fcb5a5d4e5baa38c6f7d2857c", size = 256522, upload-time = "2026-06-22T23:08:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/14/e5a0575f73795af3a7a9ae13dadf812e17d32422896839987dc3f86947e1/coverage-7.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c946099774a7699de03cbd0ff0a64e21aed4525eed9d959adde4afe6d15758ef", size = 252023, upload-time = "2026-06-22T23:08:44.243Z" }, + { url = "https://files.pythonhosted.org/packages/38/9b/9652ee531937ce3b8a63a8896885b2b4a2d56adc30e53c9540c666286d88/coverage-7.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:16b206e521feb8b7133a45754643dead0538489cf8b783b90cf5f4e3299625fd", size = 253893, upload-time = "2026-06-22T23:08:46.113Z" }, + { url = "https://files.pythonhosted.org/packages/b1/05/42678841c8c38e4b08bdfc48269f5a16dfbf5806000fe6a89b4cece3c691/coverage-7.14.3-cp312-cp312-win32.whl", hash = "sha256:ea3169c7116eb6cdf7608c6c7da9ecfcb3da40688e3a510fac2d1d2bafd6dc35", size = 222734, upload-time = "2026-06-22T23:08:47.858Z" }, + { url = "https://files.pythonhosted.org/packages/df/87/07a4fcee55177a25f1b52331a8e92cf4f2c53b1a9c75ce2981fd59c684ad/coverage-7.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:7ea52fc08f007bcc494d4bb3df3851e95843d881860ba38fe2c64dc100db5e7d", size = 223266, upload-time = "2026-06-22T23:08:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/aa/34/2b8b66a989282ea7b370beb49f50bab29470dc30bb0b03935b6b802782f7/coverage-7.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:8cec0ad652ec57790970d817490105bd917d783c2f7b38d6b58a0ca312e1a336", size = 222655, upload-time = "2026-06-22T23:08:51.766Z" }, + { url = "https://files.pythonhosted.org/packages/a9/83/7fefbf5df23ed2b7f489907564a7b34b9b07098128e12e0fdfa92626e456/coverage-7.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47968988b367990ae4ab17523790c38cd125e02c6bfd379b6022be2d40bdc38c", size = 220699, upload-time = "2026-06-22T23:08:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/31/e6/38c3653ff6d56d704b29241362387ca824e38e15b76fdcb7096538195790/coverage-7.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ee68f5c34812780f3a7063382c0a9fcbb99985b7ddcdcaa626e4f3fb2e0783a", size = 221068, upload-time = "2026-06-22T23:08:55.571Z" }, + { url = "https://files.pythonhosted.org/packages/20/86/4f5c45d51c5cd10a128933f0fd235393c9146abbfd2ce2dfa68b3267ead3/coverage-7.14.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa9e5c6857a7e80fa22ace5cf3550ae392bbfc322f1d8dd2d2d5a8be38cec027", size = 252060, upload-time = "2026-06-22T23:08:57.464Z" }, + { url = "https://files.pythonhosted.org/packages/82/50/dfce42eff2cecabcd5a9bbad5489449c87db3415f408d23ffee417ce01f6/coverage-7.14.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98a0859b0e98e43e1178a9402e19c8127766b14f7109a374d976e5a62c0e5c73", size = 254657, upload-time = "2026-06-22T23:08:59.453Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d2/639ceb1bc8038fd0d66768278d5dc22df3391918b8278c2a21aa2602a531/coverage-7.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69918344541ed9c8368566c2adc03c0e33d4550d7faa87d1b35e49b6a3286ea9", size = 255892, upload-time = "2026-06-22T23:09:01.291Z" }, + { url = "https://files.pythonhosted.org/packages/8b/96/002094a10e113512500dc1e10430a449417e17b0f90f7d496bcb820208b7/coverage-7.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f300ac92cd4b570724c8ffbbd0c130fee298d2447f41d5a3abf58976fae1de", size = 258026, upload-time = "2026-06-22T23:09:03.017Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ec/286a5d2fad9c4bee59bd724feeb7d5bf8303c6c9200b51d1dd945a9c72b0/coverage-7.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11a7ec9f97ab950f4c5af62229befc7faf208fdbc0116d3902d7e306cf2c5abd", size = 252285, upload-time = "2026-06-22T23:09:04.773Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7d/a17753a0b12dd48d0d50f5fab079ad99d3be1eac790494d89f3a417ca0b9/coverage-7.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a571bd889cd36c5922ce8e42e059f9d37d02301531d11374afa4c87a578625d5", size = 254023, upload-time = "2026-06-22T23:09:06.513Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/a76c6ceba6a2c313f905310abf2701d534cada22d372db11731831e9e209/coverage-7.14.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de76caefc8deabb0dd1678b6a980be97d14c8d87e213ac194dbf8b09e96d63fb", size = 251989, upload-time = "2026-06-22T23:09:08.382Z" }, + { url = "https://files.pythonhosted.org/packages/d9/39/353013a75fec0fb49f7553519f9d52b4441e902e5178c93f38eb6c07cedb/coverage-7.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d20a15c622194234161535459affa8f7905830391c9ccfa060d495dbfe3a1c7f", size = 256144, upload-time = "2026-06-22T23:09:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/29/0e/613878555d734def11c5b20a2701a15cb3781b9e9ea749da27c5f436e928/coverage-7.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b488bd4b23397db62e7a9459129d01ff06a846582a732efd24834b24a6ada498", size = 251808, upload-time = "2026-06-22T23:09:12.057Z" }, + { url = "https://files.pythonhosted.org/packages/af/76/359c058c9cfdcf1e8b107663881225b03b364a320017eda24a2a66e55102/coverage-7.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a3693b4153394d265f44fb855fdc80e72403024d4d6f91c4871b334d028e4e0", size = 253579, upload-time = "2026-06-22T23:09:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d9/4ba2f060933a30ebe363cef9f67a365b0a317e580c0d5d9169d56a73ef1c/coverage-7.14.3-cp313-cp313-win32.whl", hash = "sha256:338b19131ab1a6b767b462bfcbaa692e7ae22f24463e39d49b02a83410ff6b37", size = 222741, upload-time = "2026-06-22T23:09:15.636Z" }, + { url = "https://files.pythonhosted.org/packages/76/e8/196ebc25d8f34c06d43a6e9c8513c9266ef8dbf3b5672beb1a00cf5e29fa/coverage-7.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:b3d77f7f196abdef7e01415de1bce09f216189e83e58159cfeef2b92d0464994", size = 223283, upload-time = "2026-06-22T23:09:17.478Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/51d2aac6417523a286f10fb25f09eb9518a84df9f1151e93ff6871f34849/coverage-7.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:e6230e688c7c3e65cedd41a774eb4ec221adc6bfee13768231015b702d5e4150", size = 222678, upload-time = "2026-06-22T23:09:19.7Z" }, + { url = "https://files.pythonhosted.org/packages/61/56/14e3b97facbfa1304dd19e676e26599ad359f04714bed32f7f1c5a88efdc/coverage-7.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:605ab2b566a22bd94834529d66d295c364aba84afd3e5498285c7a524017b1fc", size = 220741, upload-time = "2026-06-22T23:09:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/12/1d/db378b5cca433b90b893f26dab728b280ddd89f272a1fdfed4aeaa05c686/coverage-7.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3c2134809e80fac091bfed18a6991b5a5eb5df5ae32b17ac4f4f99864b73dd7", size = 221068, upload-time = "2026-06-22T23:09:23.452Z" }, + { url = "https://files.pythonhosted.org/packages/47/f0/3f8421b20d9c4fcd39be9a8ca3c3fda8bc204b44efbd09fede153afd3e2f/coverage-7.14.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c02efd507227bde9969cab0db8f48890eb3b5dcad6afac57a4792df4133543ce", size = 252117, upload-time = "2026-06-22T23:09:25.458Z" }, + { url = "https://files.pythonhosted.org/packages/27/ca/59ea35fb99743549ec8b37eff141ece4431fea590c89e536ed8032ef45cf/coverage-7.14.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1bb93c2aa61d2a5b38f1526546d95cf4132cb681e541a337bf8dfd092be816e5", size = 254622, upload-time = "2026-06-22T23:09:27.523Z" }, + { url = "https://files.pythonhosted.org/packages/c8/25/ec6de51ae7493b92a1cf74d1b763121c29636759167e2a593ba4db5881e4/coverage-7.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f502e948e03e866538048bba081c075caaa62e5bda6ea5b7432e45f587eb462a", size = 255968, upload-time = "2026-06-22T23:09:29.43Z" }, + { url = "https://files.pythonhosted.org/packages/5d/05/c8bfc77823f42b4664fb25842f13b567022f6f84a4c83c8ecbb16734b7cb/coverage-7.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9973ef2463f8e6cfb61a6324126bb3e17d67a85f22f58d856e583ea2e3ca6501", size = 258284, upload-time = "2026-06-22T23:09:31.397Z" }, + { url = "https://files.pythonhosted.org/packages/f6/15/1d1b242027124a32b26ef01f82018b8c4ef34ef174aa6aeba7b1eeef48e8/coverage-7.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9be4e7d4c5ca0427889f8f9d614bd630c2be741b1de7699bca3b2b6c0e41003e", size = 252143, upload-time = "2026-06-22T23:09:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/74/b6/d2a9842fd2a5d7d27f1ac851c043a734a494ad75402c5331db3da79ed691/coverage-7.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a574912f3bde4b0619f6e97d01aa590b70998859244793769eb3a6df78ee56d3", size = 253976, upload-time = "2026-06-22T23:09:35.351Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/e1600ddf7e226db5558bb5323d2186fff00f505c4b764643ec89ce5d8175/coverage-7.14.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e343fb086c9cd780b38622fea7c369acd64c1a0724312149b5d769c387a2b1f5", size = 251942, upload-time = "2026-06-22T23:09:37.313Z" }, + { url = "https://files.pythonhosted.org/packages/d9/2c/9159de64f9dd648e324328d588a44cfab1e331eb5259ce1141afe2a92dfb/coverage-7.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3c68df8e61f1e09633fefc7538297145623957a048534368c9d212782aa5e845", size = 256220, upload-time = "2026-06-22T23:09:39.165Z" }, + { url = "https://files.pythonhosted.org/packages/91/67/b7f536cc2c124f48e91b22fbb741d2261f4e3d310faf6f76007f47566e5d/coverage-7.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3e5b550a128419373c2f6cec28a244207013ef15f5cbcff6a5ca09d1dfaaf027", size = 251756, upload-time = "2026-06-22T23:09:41.056Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ec/f3718038e2d4860c715a55428377ca7f6c75872caf98cabd982e1d76967d/coverage-7.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2bfc4dd0a912329eccc7484a7d0b2a38032b38c40663b1e1ac595f10c457954b", size = 253413, upload-time = "2026-06-22T23:09:43.306Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a5/91f11efeef89b3cc9b30461128db15b0511ef813ab889a7b7ab636b3a497/coverage-7.14.3-cp314-cp314-win32.whl", hash = "sha256:0423d64c013057a06e70f070f073cec4b0cbc7d2b27f3c7007292f2ff1d52965", size = 222946, upload-time = "2026-06-22T23:09:45.261Z" }, + { url = "https://files.pythonhosted.org/packages/58/fd/98ac9f524d9ec378de831c034dbdeb544ca7ef7d2d9c9996daf232a037fd/coverage-7.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:92c22e19ce64ca3f2ad751f16f14df1468b4c231bd6af97185063a9c292a0cb3", size = 223436, upload-time = "2026-06-22T23:09:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a0/7cd612d650a772a0ae80144443406bf61981c896c3d57c9e6e79fb2cdbd1/coverage-7.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:41de778bd41780586e2b04912079c73089ab5d839624e28db3bdb26de638da92", size = 222861, upload-time = "2026-06-22T23:09:49.384Z" }, + { url = "https://files.pythonhosted.org/packages/55/57/017353fab573779c0d00448e47d102edd36c792f7b6f233a4d89a7a08384/coverage-7.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8427f370ca67db4c975d2a26acfc0e5783ca0b52444dbc50278ace0f35445949", size = 221474, upload-time = "2026-06-22T23:09:51.417Z" }, + { url = "https://files.pythonhosted.org/packages/69/92/90cf1f1a5c468a9c1b7ba2716e0e205293ad9b02f5f573a6de4318b15ba1/coverage-7.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8e88f335544a47e22ae2e45b344772925ec65166555c958720d5ed971880891", size = 221738, upload-time = "2026-06-22T23:09:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c0/4df964fa539f8399fd7679c09c472d73744de334686fd3f01e3a2465ce4e/coverage-7.14.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:beaab199b9e5ceaf5a225e16a9d4df136f2a1eae0a5c20de1e277c8a5225f388", size = 263101, upload-time = "2026-06-22T23:09:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/06/76/e5d33b2576ae3bf2be2058cd1cae57774b61e400f2c3c58f3783dc2ffb4a/coverage-7.14.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3ff255799f5a1676c71c1c32ec01fd043aa09d57b3d95764b24992757184784", size = 265225, upload-time = "2026-06-22T23:09:57.904Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/e52419afe391a39ba27fdefaf0737d8e34bf03faef6ab3b3006545bbd0d0/coverage-7.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:878832eaac515b62decfa76965aed558775f86bf1fc8cca76993c0c84ae31aed", size = 267643, upload-time = "2026-06-22T23:09:59.938Z" }, + { url = "https://files.pythonhosted.org/packages/58/7a/f2625d8d5006b6b20fba5afaef00b24a763fe96476ea798a3076cbc1f84e/coverage-7.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:611e62cb9386096d81b63e0a05330750268617231e7bd598e1fe77482a2c58a5", size = 268762, upload-time = "2026-06-22T23:10:01.943Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bf/908024006bba57127354d74e938954b9c3cd765cc2e0412dc9c37b415cda/coverage-7.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:02c41de2a88011b893050fc9830267d927a50a215f7ad5ec17349db7090ccf26", size = 262208, upload-time = "2026-06-22T23:10:03.954Z" }, + { url = "https://files.pythonhosted.org/packages/34/a0/d4f9296441b909817442fdb26bd77a698f08272ec683a7394b00eb2e47a0/coverage-7.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:526ce9721116af23b1065089f0b75046fe521e7772ab94b641cd66b7a0421889", size = 265096, upload-time = "2026-06-22T23:10:05.936Z" }, + { url = "https://files.pythonhosted.org/packages/e8/da/4ae4f3f4e477b56a4ce1e5c48a35eff38a94b50130ce5bdc897024741cfc/coverage-7.14.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e4ed44705ca4bead6fc977a8b741f2145608289b33c8a9b42a95d0f15aedbf4d", size = 262699, upload-time = "2026-06-22T23:10:07.973Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7a/6927148073ff32856d78baa77b4ddc07a9be7e90020f9db0661c4ca523a1/coverage-7.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2415902f385a23dcc4ccd26e0ba803249a169af6a930c003a4c715eeb9a5444e", size = 266433, upload-time = "2026-06-22T23:10:10.145Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a7/774f658dbe9c4c3f5daa86a87e0459ac3832e4e3cc67affe078547f727b9/coverage-7.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b75ee850fc2d7c831e883220c445b035f2224de2ba6103f1e56dbd237ab913f7", size = 261547, upload-time = "2026-06-22T23:10:12.191Z" }, + { url = "https://files.pythonhosted.org/packages/3d/14/a0c18c0376c43cbf973f43ef6ca20019c950597180e6396232f7b6a27102/coverage-7.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dc9b4e35e7c3920e925ba7f14886fd5fbe481232754624e832ddba66c7535635", size = 263859, upload-time = "2026-06-22T23:10:14.492Z" }, + { url = "https://files.pythonhosted.org/packages/10/ac/43a3d0f460af524b131a6191805bc5d18b806ab4e828fbf82e8c8c3af446/coverage-7.14.3-cp314-cp314t-win32.whl", hash = "sha256:7b27c822a8161afbe48e99f1adfb098d270ae7e0f7d7b0555ce110529bdb69cc", size = 223250, upload-time = "2026-06-22T23:10:16.758Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5f/d5e5c56b0712e96ce8f69fe7dbf229ff938b437bc50862743c8a0d2cea84/coverage-7.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:39e1dbbb6ff2c338e0196a482558a792a1de3aa64261196f5cdb3da016ad9cda", size = 224082, upload-time = "2026-06-22T23:10:19.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/35/947cbd5be1d3bcbbdc43d6791de8a56c6501903311d42915ae06a82815f0/coverage-7.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:68520c90babfa2d560eca6d497921ed3a4f469623bd709733124491b2aa8ef3f", size = 223400, upload-time = "2026-06-22T23:10:21.24Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e3/a0aa32bfa3a081951f60a23bc0e7b512891ef0eecda1153cf1d8ba36c6b1/coverage-7.14.3-py3-none-any.whl", hash = "sha256:fb7e18afb6e903c1a92401a2f0501ac277dca527bb9ca6fe1f691a8a0026a0e8", size = 212469, upload-time = "2026-06-22T23:10:23.405Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jira" +version = "3.10.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "defusedxml" }, + { name = "packaging" }, + { name = "requests" }, + { name = "requests-oauthlib" }, + { name = "requests-toolbelt" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/73/ee4daa7cf4eea457180de0ea78b730b44bb5ad2829dae49cf708a1460819/jira-3.10.5.tar.gz", hash = "sha256:2d09ae3bf4741a2787dd889dfea5926a5d509aac3b28ab3b98c098709e6ee72d", size = 105870, upload-time = "2025-07-28T12:18:22.796Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/57/ad078d7379e390798559446607e413fc953c7510711462ab34194dba5924/jira-3.10.5-py3-none-any.whl", hash = "sha256:d4da1385c924ee693d6cc9838e56a34e31d74f0d6899934ef35bbd0d2d33997f", size = 79250, upload-time = "2025-07-28T12:18:21.368Z" }, +] + +[[package]] +name = "librt" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/2f/ec5241c38e7fa0fe6c26bfc450e78b9489a6c3c08b394b85d2c10e506975/librt-0.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:34e47058fcc69a313293d6dee94216a4f30c929ae6f2476e58c5ba635aa639d5", size = 148654, upload-time = "2026-07-08T12:24:30.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1a/d651e18d3ee7aa2879322368c4f278bb7ecaa6b90caadfdec4ebfa8389f3/librt-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dbdd5b6509d0c2a8fe72cf494c299a61dbd58142a90a4190664ae159e4a7b547", size = 153537, upload-time = "2026-07-08T12:24:31.773Z" }, + { url = "https://files.pythonhosted.org/packages/45/18/10bff2122577246009d9619b6569596daf69b7648812f997ca9ca0426f60/librt-0.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e56ea4ee4df77585a6b5c138f6538680886024fa559f5b55bd14b12e98e67b2", size = 494336, upload-time = "2026-07-08T12:24:33.079Z" }, + { url = "https://files.pythonhosted.org/packages/67/69/87dfee871b852970f137fdeae8e2ca356c5ab38e6f21d2a3299535fc3159/librt-0.13.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f1f9cc4d09a46d9cb3c2063ae100629d3f52a6517c3c08c2f4c9828261883929", size = 485393, upload-time = "2026-07-08T12:24:34.324Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d5/625447a8c0441ff5f15f4ac5e1d323fb9d4d256ebfde7a3c8e003f646057/librt-0.13.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f125f5d46b20f89dc5587a55cc416b4ba2a5b2ffda36d048ee120e17598a653a", size = 515382, upload-time = "2026-07-08T12:24:35.575Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d8/1c8c49ea04235960426444deece9092a6b3a9587a850a81bae2335317411/librt-0.13.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2608d3b39f9e0b4a66a130d9150c615cba40a5090d25eeeaa225e0e46de8c0ac", size = 509483, upload-time = "2026-07-08T12:24:36.923Z" }, + { url = "https://files.pythonhosted.org/packages/6f/65/f1760fc48050e215201a03506c32b7270159088d01f64557b53e39e74a45/librt-0.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9fd35e95ab5e45c3901d37110263c7db85a961110f5460588fe37f8c131f88a7", size = 532503, upload-time = "2026-07-08T12:24:38.203Z" }, + { url = "https://files.pythonhosted.org/packages/18/1b/793e281dcf494879eff99f642b63ebc9c7c58694a1c2d1e93362a22c7041/librt-0.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5f31b0aa13c9b04370d4da6be1ab7779776b3a075cceb6747a39a4be85fe1e40", size = 537027, upload-time = "2026-07-08T12:24:39.34Z" }, + { url = "https://files.pythonhosted.org/packages/69/45/0801bbb40c9eea795d3dd3ce91c4c5f3fe7d42d23ec4be3e8cb283bcc754/librt-0.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0b795f5fc70fbbb787ceaf79bb3a0d627bcc33c53de51741755263ec406b775a", size = 517100, upload-time = "2026-07-08T12:24:40.907Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6c/eb5f514f8e29d4924bc0ff4601dd7b4175557e182e7c0721e84cffa39b8a/librt-0.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36b306a623aaad96fe4b378692b54f9c0789fccd833b9851753d5fbf6138cfde", size = 558653, upload-time = "2026-07-08T12:24:42.359Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bf/f140100d1b59fe87ff40b5ecbb4e27924335b189a784e230ee465452f6c2/librt-0.13.0-cp310-cp310-win32.whl", hash = "sha256:a3762e75fcac8c9e4dacaaf438bffd9003e2ca2c531b756f3c0035deefa674c8", size = 104402, upload-time = "2026-07-08T12:24:43.668Z" }, + { url = "https://files.pythonhosted.org/packages/22/7c/57e40fef7cfb61869341cb28bdcefe8a950bebcbecca74a397bae14dce4a/librt-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:d63bae12a8aeb51380be3438e4dc4bd27354d0f8e19166b2f44e3e94d6f552dc", size = 125002, upload-time = "2026-07-08T12:24:44.793Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/a6498964cfeec270c468cffdc118f69c29b412593610d55fa1327ca51ff4/librt-0.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1b5a7bbff495baedbd9b916c367d66854008f8f3b575908ded477c499dc60082", size = 148029, upload-time = "2026-07-08T12:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/dc86d1bffd8e0c2818bace29d9f7783cfbb8e0673bf3673b5bbd5bbe0420/librt-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34bc7938b9fdf14fe32a406c19c71faf894c5cee7e7474bd0be2f17200b82d14", size = 153036, upload-time = "2026-07-08T12:24:47.257Z" }, + { url = "https://files.pythonhosted.org/packages/29/3f/b923826660f02f286186cd9303d52bb05ced0a13708edc104dc8480920e3/librt-0.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f40e56b61b41be5f7dec938cfeffd660668cf4b5e72c78e7bd671d66b7bc2c79", size = 493062, upload-time = "2026-07-08T12:24:48.483Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/6c0980a9c9b1302cb68d108906697b89eceb55889bb1dcf77c109aa56ca5/librt-0.13.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:9c5d02b89de5acd0379a51ec44a89476fb03df6145442e1c8ecd6bee2f91b176", size = 485510, upload-time = "2026-07-08T12:24:49.727Z" }, + { url = "https://files.pythonhosted.org/packages/32/81/795ae3b9df5dd94079fb807e38191855e023e8c6249014ae6bc3f0d9a490/librt-0.13.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7db9a3ff32ef5f7d1703d93831a3316cdf0b537de6a1cc03cc8fdd09b9194e89", size = 515909, upload-time = "2026-07-08T12:24:51.135Z" }, + { url = "https://files.pythonhosted.org/packages/20/e5/182de15abce8907108a6fdb41487de65beb5099b74dc5841b19b099168db/librt-0.13.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3dbb2a31882456cadc7053378e81ad7ed7693db4ac9f98ab5f81ef034aa8ec9f", size = 508620, upload-time = "2026-07-08T12:24:52.358Z" }, + { url = "https://files.pythonhosted.org/packages/32/03/33978d32db76e1f66377e8f78e42a2ca3c162143331677d1f50bbad36cfb/librt-0.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c6014e3c80f9c1fe268ef8b0e0ef113bac672cc032f2f93866e7ddad4f3e663d", size = 530363, upload-time = "2026-07-08T12:24:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f5/b291fbd2d00f7d8287bcbf67b5aa0c6afed4bc26cef23e079629c47a2c04/librt-0.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:091b60a4d2174fc1ec5c34cdc0b72efb6224753d76b7da61ebeab7a191aec8bd", size = 534209, upload-time = "2026-07-08T12:24:55.138Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/6f41f17939d191bc21609f220da8509316bc62797f078545fe83be522e78/librt-0.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:66cb1138f384a191a6d75f986064841fcfdc0cea98f7bd9c9ab9b38049917588", size = 514254, upload-time = "2026-07-08T12:24:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/af/c2/2e4befa5410a7443019c14abccc94ff619797171f6b72013635fb87f31d7/librt-0.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:17221a7569f8f292aa0014226e48aa25b8c2b08da18088cd230953d0ea0f9cd1", size = 557611, upload-time = "2026-07-08T12:24:57.561Z" }, + { url = "https://files.pythonhosted.org/packages/ab/54/8b69f81448417adbc040a2185f4e2eece1e1994b7dcfaeed4662b30f98a5/librt-0.13.0-cp311-cp311-win32.whl", hash = "sha256:fc67741da44c6eaa90e01eafb586bbba9b51eb5b6ed381ee6f5ae72eb3316d21", size = 104906, upload-time = "2026-07-08T12:24:58.806Z" }, + { url = "https://files.pythonhosted.org/packages/76/5a/f4aaf37b50f2fde12c8c663b83fdd499cdc24f957f19543d7414bfcc9e25/librt-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:cc99dfb62b23c9207c33d0be8a2e2af7a42e21e6ea388b380a0c948c7b88953b", size = 125852, upload-time = "2026-07-08T12:25:00.065Z" }, + { url = "https://files.pythonhosted.org/packages/f2/99/bf1820e6feeabc2f218c24450ec0c995d6a91e8ba0fd3caf042c9e8adb2a/librt-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:40ccd13c252d3fe473ffc8a57be7565abc8b64cf1b108344c859d5164f7f3e0c", size = 111832, upload-time = "2026-07-08T12:25:01.148Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" }, + { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" }, + { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" }, + { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" }, + { url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" }, + { url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" }, + { url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" }, + { url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" }, + { url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" }, + { url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, +] + +[[package]] +name = "mypy" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/7e/be536678c6ae49ef058aba4b483d8c7bc104f471479016066f345bc1f5f8/mypy-2.2.0.tar.gz", hash = "sha256:2cdd99d48590dce6f6b7f1961eda75386364398fcdaad86923bc0f0231bf9baf", size = 3950939, upload-time = "2026-07-08T01:37:27.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/f1/a818c375094e20a74ec51444cce6979cedd61d476f860c2f084ea8653bd2/mypy-2.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:97d21f3a1582ef30d0b77f9ca3ad88b4263f3c5aea9e7380cb5d7175160aec7e", size = 14903842, upload-time = "2026-07-08T01:33:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/33/15/f332590488a8e4bb4a24036484c8bc750fe01fbef09701451a8790ac4c44/mypy-2.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2529017942b951cc43db2e2712b71cc3aa7e9d90567630c03ed63d430aeec61a", size = 13902986, upload-time = "2026-07-08T01:36:59.24Z" }, + { url = "https://files.pythonhosted.org/packages/38/9f/7ae37860922d264b536790a3307caa195eb8abb733f2e81608229bbb0f07/mypy-2.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0823d7f273f3b4a49df7573eac271ddbf77a0e5f53e24d294a4f0f77ad22e1d7", size = 14132163, upload-time = "2026-07-08T01:34:16.111Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6d/25c31c85a793340a4a9be593f8fe2cea08fd7b615f8b602b3f52f2d822f6/mypy-2.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d78a0fc90704894bc9948d78affad14b882b08183a7c30412d185748aaa9418b", size = 15070110, upload-time = "2026-07-08T01:34:44.933Z" }, + { url = "https://files.pythonhosted.org/packages/cb/67/7adf1caa143f6a0d9c564de24e0c1ed48340185110c3b918adc2f447192b/mypy-2.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6efab25fa3568569fda06928743382900860c48be9200c1758ef5ce94b532714", size = 15371554, upload-time = "2026-07-08T01:34:27.688Z" }, + { url = "https://files.pythonhosted.org/packages/49/cb/ceca928213076f540d9b068c3655c52d43549f124c1f6dece5ecb747bbeb/mypy-2.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:daeaf5287091d68f674c91416cabab0f80f36e347ed17fded38d7ebde682e9d1", size = 11063618, upload-time = "2026-07-08T01:36:13.235Z" }, + { url = "https://files.pythonhosted.org/packages/41/24/08caa177a4f2e0c44b96d5ad85f2754761b45bb6d65841451ef5dc9edb86/mypy-2.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:f8d97940a0259e09c219903579720b2df12170c0c3a3b3799837c1fb63deb44e", size = 10070720, upload-time = "2026-07-08T01:37:13.251Z" }, + { url = "https://files.pythonhosted.org/packages/96/2e/1ea8028583fef6561d146b51d738d91268201313ecf69e603e808a740e74/mypy-2.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ea6fe1a30f3e7dc574d641497ffead2428046b0f8bc5804d13b4e4392fdc317b", size = 14739569, upload-time = "2026-07-08T01:35:33.202Z" }, + { url = "https://files.pythonhosted.org/packages/ba/67/c0607da57d78358b3b4fbfa90ee8ca5c6bd1dbb997c9648904ad4d8861d8/mypy-2.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6965829a6d847a0925f51149472bbeb7a39332fb4801972fdfd9cedd9f8d43a4", size = 13821442, upload-time = "2026-07-08T01:33:45.991Z" }, + { url = "https://files.pythonhosted.org/packages/bb/56/0407007d4ec7a762bac20724af1f0a57a9d860186c39e73105b6b8396fa2/mypy-2.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a906bfc9c4c5de3ece6dc4726f8076d56ce9be1720baf0c6f84e926c10262a4", size = 14049813, upload-time = "2026-07-08T01:35:44.282Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d1/d718e006c8fed4bece7a1bebeea6dcd05f31433af552fc45a82fef426379/mypy-2.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91df92625cb3452758f27f4965b000fb05ad89b00c282cc3430a7bd6b0e5389e", size = 14978473, upload-time = "2026-07-08T01:36:54.064Z" }, + { url = "https://files.pythonhosted.org/packages/82/03/dae7299c45a84efb1590875f92652a5beb2da98a2cff9a567995e2583fe4/mypy-2.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d42893d15894fd34f395090e2d78315ba7c637d5ee221683e02893f578c2f548", size = 15224649, upload-time = "2026-07-08T01:35:15.988Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8e/738e4e73d030f20852a81383c74319ca4a9a4fdaadc3a75823588fe2c833/mypy-2.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3abaeec02cdc72e30a147d99eb81852b6b745a2c8d19607e25241d79b1abbce", size = 11049851, upload-time = "2026-07-08T01:34:39.31Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/6c58caedb3fa5831a9b179687cd7dc252c66a61dca4800e5ba19c8eff82b/mypy-2.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:c39bdb7ceab252d15011e26d3a254b4aaa3bbf121b551febfa301df9b0c69abe", size = 10060307, upload-time = "2026-07-08T01:33:21.852Z" }, + { url = "https://files.pythonhosted.org/packages/d1/be/fbaba7b0ee89874fb11668416dec9e5585c190b676b0796cff26a9290fe8/mypy-2.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:484a2be712b245ac6e89847141f1f50c612b0a924aa25917e63e6cfcf4da07cd", size = 14928025, upload-time = "2026-07-08T01:37:24.376Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8f/f79a7c5a76671b0f563d4beaa7d99fe90df4500d2c1d2ba1be0432121bcf/mypy-2.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fb0a020dc68480d40e484675558ed637140df1ccbf896a81ba68bca85f2b50a0", size = 13793027, upload-time = "2026-07-08T01:34:33.937Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b9/3db0086bab611d34e26061b86189e6f71de6d22a9b81699a93b006eabcf6/mypy-2.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc361340732ce7108fa0308812caf02bb6868f16112f1efd35bcad88badf3327", size = 14108322, upload-time = "2026-07-08T01:36:08.316Z" }, + { url = "https://files.pythonhosted.org/packages/58/29/4f1e13979a848de2a0fd385462354b58358b6e8b3d9661663e308f6e3d5d/mypy-2.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0179a3a0b833f724a65f22613607cf7ea941ab17ec34fa283f8d6dfe21d9fa9", size = 15190198, upload-time = "2026-07-08T01:36:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/14/f7/7759f6294d9d25d86671957d0974a215a2a24d429526e26a2f603de951c5/mypy-2.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9e0899b13da1e4ba44b880550f247402ce90ffecc71c54b220bcbe7ecb34f394", size = 15424222, upload-time = "2026-07-08T01:33:39.398Z" }, + { url = "https://files.pythonhosted.org/packages/d7/1b/05b212bef4d2234b5f0b551ea53ce0680d8075b2e79861c765f70b590945/mypy-2.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:511320b17467402e2906130e185abffffa3d7648aff1444fc2abb61f4c8a087d", size = 11135191, upload-time = "2026-07-08T01:35:03.019Z" }, + { url = "https://files.pythonhosted.org/packages/92/51/495e7122f6589948b36d3820a046461906756a0eb1b6dedc13ebfec7815e/mypy-2.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:7589f370b33dcdd95708f5340f13a67c2c49140957f934b42ef63064d343cca0", size = 10132502, upload-time = "2026-07-08T01:34:04.508Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5f/2d7a9ac5646274cd6e77ce3abcc2a9ece760c2b21f4c4b9f301711e07855/mypy-2.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6968f27347ef539c443ddfd6897e79db525ddb8c856aa8fbf14c34f310ca5193", size = 14931618, upload-time = "2026-07-08T01:33:51.702Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8a/1adaa7caaa104f87021b1ac71252d62e646e9b623d77900ac7a0ae252bf3/mypy-2.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3df226d2a0ae2c3b03845af217800a68e2965d4b14914c99b78d3a2c8ae23299", size = 13857718, upload-time = "2026-07-08T01:35:27.555Z" }, + { url = "https://files.pythonhosted.org/packages/1d/15/b11586b5aebbb82213e297fc30a6fcf3bed6a9deea3739cd8dd87621f3fd/mypy-2.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fc53553996aca2094216ad9306a6f06c5265d206c1bcb54dd367560bd3557825", size = 14059704, upload-time = "2026-07-08T01:33:57.363Z" }, + { url = "https://files.pythonhosted.org/packages/03/db/071e05ab442596bdf7a845e830d5ef7128a0175281038245b171a6b16873/mypy-2.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:996abf2f0bebf572556c60720e8dc0cf5292b64060fa68d7f2bc9caa48e01b6f", size = 15128719, upload-time = "2026-07-08T01:33:33.855Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1c/c4b84eafb85ee315da72471523cc1bf7d7c42164085c42333601da7a8817/mypy-2.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ec287c2381898c652bf8ff79448627fe1a9ee76d22005181fac7315a485c7108", size = 15378692, upload-time = "2026-07-08T01:34:10.468Z" }, + { url = "https://files.pythonhosted.org/packages/68/a4/59a0ee94877fdfe2958cec9b6add72a75393063c79cb60ab4026dd5e10c2/mypy-2.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2c967a7685fa93fcf1a778b3ebe76e756b28ba14655f539d7b61ff3da69352a", size = 11150911, upload-time = "2026-07-08T01:35:21.603Z" }, + { url = "https://files.pythonhosted.org/packages/90/e4/6a9144be50180ed43d8c92de9b03dff504daa92b5bcc0353e8960799a23b/mypy-2.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:d59a4b80351ec92e5f7415fcdd008bd77fcbefc7adf9cbc7ffe4eb9f71617734", size = 10125389, upload-time = "2026-07-08T01:36:36.164Z" }, + { url = "https://files.pythonhosted.org/packages/73/32/0aa8d8d197023ca6040f7b25a486cb47037b6350b0d3bae657c8f85fb43f/mypy-2.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1f6c3d76853071409ac58fc0aadfb276a22af5f190fdaa02152a858088a39ebd", size = 14926083, upload-time = "2026-07-08T01:36:02.365Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7c/35bbe0cb10e6699f90e988e537aaf4282a6c16e37f58848a242eb0a98bde/mypy-2.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bdaa177e80cc3292824d4ef3670b5b58771ee8d57c290e0c9c89e7968212332c", size = 13879985, upload-time = "2026-07-08T01:36:31.093Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0c/1597fbebd873e9b63452317740ae3dd32692cec5da180cc65acd96cd28cf/mypy-2.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80923e6d6e7878291f537ee11052f974954c20cb569798429a5dc265eb780b47", size = 14076883, upload-time = "2026-07-08T01:34:21.789Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/2ec021a83ec01b5d522639f78d8b36adade7fa4821db0f48fd6d82e861f3/mypy-2.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f24bd465a09077c8d64be8f19a6646db467a55490fd315fe7871afe6bb9645", size = 15103567, upload-time = "2026-07-08T01:36:47.717Z" }, + { url = "https://files.pythonhosted.org/packages/53/3a/8cb3529f6d6800c7d069935e5c83a05d80263847b8a947cf6b0b16a9e958/mypy-2.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:db34595464869f474708e769413d1d739fc33a69850f253757b9a4cc20bc1fec", size = 15354641, upload-time = "2026-07-08T01:36:41.592Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4d/320bc9a9553f8a9db5e847ec5ded762ef7ed7403c76c4ba2e8181c80e2f0/mypy-2.2.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b48092132c7b0ef4322773fecae62fc5b0bc339be348badeec8af502122a4a51", size = 7694355, upload-time = "2026-07-08T01:37:03.291Z" }, + { url = "https://files.pythonhosted.org/packages/90/05/bf3b349e2f885cd3aab488111bb9049439c28bc028dac5073350d3df8fbe/mypy-2.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:6fc0e98b95e31755ca06d89f75fafa7820fbb3ea2caace6d83cba17625cd0acb", size = 11329146, upload-time = "2026-07-08T01:35:38.318Z" }, + { url = "https://files.pythonhosted.org/packages/41/a5/558b06e6cfe17ab88bb38f7b370b6bc68a74ba177c9e138db9748e422d2d/mypy-2.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:bc73a5b4d40e8a3e6b12ef82eb0c90430964e34016a36c2aff4e3bfe37ba41f0", size = 10316586, upload-time = "2026-07-08T01:36:25.458Z" }, + { url = "https://files.pythonhosted.org/packages/0b/21/f0b96f19a9b8ba111a45ffbe9508e818b7f6990469b38f6888943f7bfd3a/mypy-2.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6257bd4b4c0ae2148548b869a1ff3758e38645b92c8fe65eca401866c3c551c1", size = 15922565, upload-time = "2026-07-08T01:35:56.282Z" }, + { url = "https://files.pythonhosted.org/packages/18/f2/1dbcb20b0865d5e992541450a8c73f2fcc90f8bd7d8a4b81313e16934870/mypy-2.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dfa22b3ae862ac1ce76f5976ddd402651b5f090bcfd49c6d0484b8983a29eaf8", size = 14816515, upload-time = "2026-07-08T01:34:58.167Z" }, + { url = "https://files.pythonhosted.org/packages/84/a2/18cce9c7d5b4d14010d1f13836da11b234dda917b17ca8671fc32c136997/mypy-2.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30a430bf26fe8cf372f3933fbd83e633d6561868803645a20e4e6d4523f52a3b", size = 15272246, upload-time = "2026-07-08T01:37:08.72Z" }, + { url = "https://files.pythonhosted.org/packages/ce/71/24d720c7924829bd675cbde2d0fa779f50abf676ca617f53d6a8bfef5fa7/mypy-2.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d58655a60e823b1a4c9ebcda072897fb0193c2f3e6f8e7c433e152aa4cb00233", size = 16226295, upload-time = "2026-07-08T01:34:51.861Z" }, + { url = "https://files.pythonhosted.org/packages/b7/5e/785730990fc863ad8340b4ab44ac4ca23270aecff92c180ccdf27f9f5869/mypy-2.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d4452c955caf14e28bb046cbd0c3671272e6381630a8b81b0da9713558148890", size = 16493275, upload-time = "2026-07-08T01:35:09.337Z" }, + { url = "https://files.pythonhosted.org/packages/93/33/55b1edf16f639f153972380d6977b81f65509c5b8f9c86b58b94b7990b03/mypy-2.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:68f5b7f7f755200f68c7181e3dfb28be9858162257690e539759c9f57721e388", size = 11749038, upload-time = "2026-07-08T01:35:50.071Z" }, + { url = "https://files.pythonhosted.org/packages/61/36/67424748a4e65e97f0e05bf00df379dfb6c2d817f82cc3a4ce5c96d99beb/mypy-2.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:78d201accfafce3801d978f2b8dbbd473a9ce364cc0a0dfd9192fe47d977e129", size = 10704254, upload-time = "2026-07-08T01:37:17.971Z" }, + { url = "https://files.pythonhosted.org/packages/28/cb/142c2097ca02c0d295b00625ff946808bdda65acda17d163c680d8a6a474/mypy-2.2.0-py3-none-any.whl", hash = "sha256:ecc138da861e932d1344214da4bae866b21900a9c2778824b51fe2fb47f5180e", size = 2726094, upload-time = "2026-07-08T01:34:00.075Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pygithub" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyjwt", extra = ["crypto"] }, + { name = "pynacl" }, + { name = "requests" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/c3/8465a311197e16cf5ab68789fe689535e90f6b61ab524cc32a39e67237ae/pygithub-2.9.1.tar.gz", hash = "sha256:59771d7ff63d54d427be2e7d0dad2208dfffc2b0a045fec959263787739b611c", size = 2594989, upload-time = "2026-04-14T07:26:13.622Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/aa/81a5506f089a26338bff17535e4339b3b22049ebd1bcdeff756c4d7a7559/pygithub-2.9.1-py3-none-any.whl", hash = "sha256:2ec78fca30092d51a42d76f4ddb02131b6f0c666a35dfdf364cf302cdda115b9", size = 449710, upload-time = "2026-04-14T07:26:12.382Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pynacl" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" }, + { url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" }, + { url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" }, + { url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" }, + { url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" }, + { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +]